diff --git a/.github/workflows/docker_ci.yml b/.github/workflows/docker_ci.yml index 2c2143c1fee68..e91a82af16902 100644 --- a/.github/workflows/docker_ci.yml +++ b/.github/workflows/docker_ci.yml @@ -13,7 +13,7 @@ on: - release/* paths: - frontend/** - types: [opened, synchronize, reopened, unlocked, ready_for_review] + types: [ opened, synchronize, reopened, unlocked, ready_for_review ] concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} diff --git a/.github/workflows/flutter_ci.yaml b/.github/workflows/flutter_ci.yaml index c1cdd7bc177a1..9b9725a09f567 100644 --- a/.github/workflows/flutter_ci.yaml +++ b/.github/workflows/flutter_ci.yaml @@ -246,12 +246,27 @@ jobs: - name: Run Docker-Compose working-directory: AppFlowy-Cloud env: - BACKEND_VERSION: 0.3.24-amd64 + APPFLOWY_CLOUD_VERSION: 0.6.4-amd64 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. Pulling the correct version..." + docker compose pull + docker compose up -d + echo "Waiting for the container to be ready..." + sleep 10 + else + echo "AppFlowy-Cloud is running with the correct version." + fi + fi - name: Checkout source code uses: actions/checkout@v4 diff --git a/.github/workflows/ios_ci.yaml b/.github/workflows/ios_ci.yaml index d24eaed1f3b10..e1be95894d7fa 100644 --- a/.github/workflows/ios_ci.yaml +++ b/.github/workflows/ios_ci.yaml @@ -21,27 +21,42 @@ on: env: FLUTTER_VERSION: "3.22.0" - RUST_TOOLCHAIN: "1.77.2" + RUST_TOOLCHAIN: "1.80.1" 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 + + build-macos: + if: github.event.pull_request.head.repo.full_name != github.repository + runs-on: macos-13 steps: - name: Checkout source code uses: actions/checkout@v2 - name: Install Rust toolchain - id: rust_toolchain uses: actions-rs/toolchain@v1 with: toolchain: ${{ env.RUST_TOOLCHAIN }} @@ -49,8 +64,7 @@ jobs: override: true profile: minimal - - name: Install flutter - id: flutter + - name: Install Flutter uses: subosito/flutter-action@v2 with: channel: "stable" @@ -59,13 +73,13 @@ 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 @@ -85,7 +99,7 @@ jobs: - uses: futureware-tech/simulator-action@v3 id: simulator-action with: - model: 'iPhone 15' + model: "iPhone 15" shutdown_after_job: false # - name: Run AppFlowy on simulator diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ed6313dda7bd..63d04320610e4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 f61925f9a6235..566aef3b7b281 100644 --- a/.github/workflows/rust_ci.yaml +++ b/.github/workflows/rust_ci.yaml @@ -8,6 +8,7 @@ on: - "release/*" paths: - "frontend/rust-lib/**" + - ".github/workflows/rust_ci.yaml" pull_request: branches: @@ -22,17 +23,74 @@ env: RUST_TOOLCHAIN: "1.77.2" jobs: - test-on-ubuntu: + self-hosted-job: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: self-hosted + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Checkout Appflowy Cloud + uses: actions/checkout@v4 + with: + repository: AppFlowy-IO/AppFlowy-Cloud + path: AppFlowy-Cloud + + - name: Prepare Appflowy Cloud env + working-directory: AppFlowy-Cloud + run: | + cp deploy.env .env + sed -i '' 's|RUST_LOG=.*|RUST_LOG=trace|' .env + sed -i '' 's|API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://localhost|' .env + + - name: Ensure AppFlowy-Cloud is Running with Correct Version + working-directory: AppFlowy-Cloud + env: + APPFLOWY_CLOUD_VERSION: 0.6.4-amd64 + 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. Pulling the correct version..." + docker compose pull + docker compose up -d + echo "Waiting for the container to be ready..." + sleep 10 + else + echo "AppFlowy-Cloud is running with the correct version." + fi + fi + + - name: Run rust-lib tests + working-directory: frontend/rust-lib + env: + RUST_LOG: info + RUST_BACKTRACE: 1 + af_cloud_test_base_url: http://localhost + af_cloud_test_ws_url: ws://localhost/ws/v1 + af_cloud_test_gotrue_url: http://localhost/gotrue + run: | + DISABLE_CI_TEST_LOG="true" cargo test --no-default-features --features="dart" + + - name: rustfmt rust-lib + run: cargo fmt --all -- --check + working-directory: frontend/rust-lib/ + + - name: clippy rust-lib + run: cargo clippy --all-targets -- -D warnings + working-directory: frontend/rust-lib + + ubuntu-job: + if: github.event.pull_request.head.repo.full_name != github.repository 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' - - # the following step is required to avoid running out of space - name: Maximize build space run: | sudo rm -rf /usr/share/dotnet @@ -45,23 +103,16 @@ jobs: 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 +125,34 @@ 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|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: 0.6.4-amd64 run: | - docker pull appflowyinc/appflowy_cloud:latest - docker compose up -d + 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. Pulling the correct version..." + docker compose pull + docker compose up -d + echo "Waiting for the container to be ready..." + sleep 10 + else + echo "AppFlowy-Cloud is running with the correct version." + fi + fi - name: Run rust-lib tests working-directory: frontend/rust-lib @@ -106,6 +173,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/tauri2_ci.yaml b/.github/workflows/tauri2_ci.yaml index 28414f099788e..6bbb7928ee7ef 100644 --- a/.github/workflows/tauri2_ci.yaml +++ b/.github/workflows/tauri2_ci.yaml @@ -1,4 +1,5 @@ name: Tauri-CI + on: pull_request: paths: @@ -11,28 +12,47 @@ env: NODE_VERSION: "18.16.0" PNPM_VERSION: "8.5.0" RUST_TOOLCHAIN: "1.77.2" + CARGO_MAKE_VERSION: "0.36.6" + CI: true concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: - tauri-build: - if: github.event.pull_request.draft != true - strategy: - fail-fast: false - matrix: - platform: [ ubuntu-20.04 ] - - runs-on: ${{ matrix.platform }} + # tauri-build-self-hosted: + # if: github.event.pull_request.head.repo.full_name == github.repository + # runs-on: self-hosted + # + # steps: + # - uses: actions/checkout@v4 + # - 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" + + tauri-build-ubuntu: + #if: github.event.pull_request.head.repo.full_name != github.repository + runs-on: ubuntu-20.04 - env: - CI: true steps: - uses: actions/checkout@v4 - - - name: Maximize build space (ubuntu only) - if: matrix.platform == 'ubuntu-20.04' + - name: Maximize build space run: | sudo rm -rf /usr/share/dotnet sudo rm -rf /opt/ghc @@ -61,36 +81,27 @@ jobs: 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' + - name: install dependencies working-directory: frontend run: | sudo apt-get update sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf - - name: install cargo-make + - uses: taiki-e/install-action@v2 + with: + tool: cargo-make@${{ env.CARGO_MAKE_VERSION }} + + - name: install tauri deps tools working-directory: frontend run: | - cargo install --force cargo-make cargo make appflowy-tauri-deps-tools + shell: bash - name: install frontend dependencies working-directory: frontend/appflowy_web_app diff --git a/CHANGELOG.md b/CHANGELOG.md index 69dc1af975763..f86f554620dbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,41 @@ # Release Notes +## Version 0.6.8 - 22/08/2024 +### New Features +- Optimized date picker and mention block. +- Added the ability to open database row on mobile. +- Added the ability to invite members to workspace on mobile. +- Added support for Monochrome theme on Android. +- Added AI Bubble button on homepage on mobile. +- Settings, trash, members and help & support have been moved into the settings pop up menu. + +### Bug Fixes +- Removed Wayland header from AppImage build +- Fixed the issue where pasting web image on mobile failed. +- Fixed the issue where the name of newly created card in board view didn't show up. + +## 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. diff --git a/doc/readme/desktop_guide_1.jpg b/doc/readme/desktop_guide_1.jpg new file mode 100644 index 0000000000000..d264c816957b9 Binary files /dev/null and b/doc/readme/desktop_guide_1.jpg differ diff --git a/doc/readme/desktop_guide_2.jpg b/doc/readme/desktop_guide_2.jpg new file mode 100644 index 0000000000000..d9cdbe5fc1aa7 Binary files /dev/null and b/doc/readme/desktop_guide_2.jpg differ diff --git a/doc/readme/getting_started_1.png b/doc/readme/getting_started_1.png new file mode 100644 index 0000000000000..8c3c7658ffc40 Binary files /dev/null and b/doc/readme/getting_started_1.png differ diff --git a/doc/readme/mobile_guide_1.png b/doc/readme/mobile_guide_1.png new file mode 100644 index 0000000000000..744fdf29dc38f Binary files /dev/null and b/doc/readme/mobile_guide_1.png differ diff --git a/doc/readme/mobile_guide_2.png b/doc/readme/mobile_guide_2.png new file mode 100644 index 0000000000000..d92c0295c6999 Binary files /dev/null and b/doc/readme/mobile_guide_2.png differ diff --git a/doc/readme/mobile_guide_3.png b/doc/readme/mobile_guide_3.png new file mode 100644 index 0000000000000..9e3cc52d92a73 Binary files /dev/null and b/doc/readme/mobile_guide_3.png differ diff --git a/doc/readme/mobile_guide_4.png b/doc/readme/mobile_guide_4.png new file mode 100644 index 0000000000000..b39e03c251d4f Binary files /dev/null and b/doc/readme/mobile_guide_4.png differ diff --git a/doc/readme/mobile_guide_5.png b/doc/readme/mobile_guide_5.png new file mode 100644 index 0000000000000..9083b80bed63a Binary files /dev/null and b/doc/readme/mobile_guide_5.png differ diff --git a/frontend/Makefile.toml b/frontend/Makefile.toml index c4670654802e6..bfcd639cb297e 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.6.4" +APPFLOWY_VERSION = "0.6.9" FLUTTER_DESKTOP_FEATURES = "dart" PRODUCT_NAME = "AppFlowy" MACOSX_DEPLOYMENT_TARGET = "11.0" diff --git a/frontend/appflowy_flutter/android/app/build.gradle b/frontend/appflowy_flutter/android/app/build.gradle index fe788cbb529e7..35dcadda87420 100644 --- a/frontend/appflowy_flutter/android/app/build.gradle +++ b/frontend/appflowy_flutter/android/app/build.gradle @@ -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/ic_launcher-playstore.png b/frontend/appflowy_flutter/android/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000000000..c691e14bdc563 Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/ic_launcher-playstore.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/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 0000000000000..c7ec6fdd6f5c7 --- /dev/null +++ b/frontend/appflowy_flutter/android/app/src/main/res/drawable/launcher_foreground.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000000..ba42ab6878248 --- /dev/null +++ b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000000..036d09bc5fd52 --- /dev/null +++ b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index b00c03fd17463..911ee844c765f 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 0000000000000..1b466c0eb266a Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000000000..56ea852799b4d Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000000..f4d14c0d6000d Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index e76d95c5be0a4..fe7a94797a341 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 0000000000000..15fb3c4ddf8d5 Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000000000..63fa775f58c4e Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000000000..fda3c7fa3e197 Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index c5188d2de41c1..61e49810e883f 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 0000000000000..132a0e9ff077a Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000000000..f9e393537dc8a Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000..8efe0ff281f5d Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 3cc1a254c9a3f..be4cf46069de6 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 0000000000000..95a312fbc55cf Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000000000..a63acece70328 Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000..727cb0c58a7f3 Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index c8f21cf1b3a20..c9e8059fe3c58 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 0000000000000..d5ce932756139 Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000000000..ad1543e064b8c Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000..010733d23d4a7 Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/values/ic_launcher_background.xml b/frontend/appflowy_flutter/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000000000..c5d5899fdf0a1 --- /dev/null +++ b/frontend/appflowy_flutter/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/frontend/appflowy_flutter/assets/icons/icons.json b/frontend/appflowy_flutter/assets/icons/icons.json new file mode 100644 index 0000000000000..4ad858c4141d8 --- /dev/null +++ b/frontend/appflowy_flutter/assets/icons/icons.json @@ -0,0 +1 @@ +{ "artificial_intelligence": [ { "name": "ai-chip-spark", "keywords": [ "chip", "processor", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-cloud-spark", "keywords": [ "cloud", "internet", "server", "network", "artificial", "intelligence", "ai" ], "content": "\n \n \n \n \n \n \n \n \n\n" }, { "name": "ai-edit-spark", "keywords": [ "change", "edit", "modify", "pencil", "write", "writing", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-email-generator-spark", "keywords": [ "mail", "envelope", "inbox", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-gaming-spark", "keywords": [ "remote", "control", "controller", "technology", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-generate-landscape-image-spark", "keywords": [ "picture", "photography", "photo", "image", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-generate-music-spark", "keywords": [ "music", "audio", "note", "entertainment", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-generate-portrait-image-spark", "keywords": [ "picture", "photography", "photo", "image", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-generate-variation-spark", "keywords": [ "module", "application", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-navigation-spark", "keywords": [ "map", "location", "direction", "travel", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-network-spark", "keywords": [ "globe", "internet", "world", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-prompt-spark", "keywords": [ "app", "code", "apps", "window", "website", "web", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-redo-spark", "keywords": [ "arrow", "refresh", "sync", "synchronize", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-science-spark", "keywords": [ "atom", "scientific", "experiment", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-settings-spark", "keywords": [ "cog", "gear", "settings", "machine", "artificial", "intelligence" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-technology-spark", "keywords": [ "lightbulb", "idea", "bright", "lighting", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-upscale-spark", "keywords": [ "magnifier", "zoom", "view", "find", "search", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-vehicle-spark-1", "keywords": [ "car", "automated", "transportation", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "artificial-intelligence-spark", "keywords": [ "brain", "thought", "ai", "automated", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "computer_devices": [ { "name": "adobe", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "alt", "keywords": [ "windows", "key", "alt", "pc", "keyboard" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "amazon", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "android", "keywords": [ "android", "code", "apps", "bugdroid", "programming" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "app-store", "keywords": [], "content": "\n\n\n" }, { "name": "apple", "keywords": [ "os", "system", "apple" ], "content": "\n\n\n" }, { "name": "asterisk-1", "keywords": [ "asterisk", "star", "keyboard" ], "content": "\n\n\n" }, { "name": "battery-alert-1", "keywords": [ "phone", "mobile", "charge", "device", "electricity", "power", "battery", "alert", "warning" ], "content": "\n\n\n" }, { "name": "battery-charging", "keywords": [ "phone", "mobile", "charge", "device", "electricity", "power", "battery", "charging" ], "content": "\n\n\n" }, { "name": "battery-empty-1", "keywords": [ "phone", "mobile", "charge", "device", "electricity", "empty", "power", "battery" ], "content": "\n\n\n" }, { "name": "battery-empty-2", "keywords": [ "phone", "mobile", "charge", "device", "electricity", "empty", "power", "battery" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "battery-full-1", "keywords": [ "phone", "mobile", "charge", "device", "electricity", "power", "battery", "full" ], "content": "\n\n\n" }, { "name": "battery-low-1", "keywords": [ "phone", "mobile", "charge", "device", "electricity", "power", "battery", "low" ], "content": "\n\n\n" }, { "name": "battery-medium-1", "keywords": [ "phone", "mobile", "charge", "medium", "device", "electricity", "power", "battery" ], "content": "\n\n\n" }, { "name": "bluetooth", "keywords": [ "bluetooth", "internet", "server", "network", "wireless", "connection" ], "content": "\n\n\n" }, { "name": "bluetooth-disabled", "keywords": [ "bluetooth", "internet", "server", "network", "wireless", "disabled", "off", "connection" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bluetooth-searching", "keywords": [ "bluetooth", "internet", "server", "network", "wireless", "searching", "connecting", "connection" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "browser-wifi", "keywords": [ "wireless", "wifi", "internet", "server", "network", "browser", "connection" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chrome", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "command", "keywords": [ "mac", "command", "apple", "keyboard" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "computer-chip-1", "keywords": [ "computer", "device", "chip", "electronics", "cpu", "microprocessor" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "computer-chip-2", "keywords": [ "core", "microprocessor", "device", "electronics", "chip", "computer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "computer-pc-desktop", "keywords": [ "screen", "desktop", "monitor", "device", "electronics", "display", "pc", "computer" ], "content": "\n\n\n" }, { "name": "controller", "keywords": [ "remote", "quadcopter", "drones", "flying", "drone", "control", "controller", "technology", "fly" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "controller-1", "keywords": [ "remote", "quadcopter", "drones", "flying", "drone", "control", "controller", "technology", "fly" ], "content": "\n\n\n" }, { "name": "controller-wireless", "keywords": [ "remote", "gaming", "drones", "drone", "control", "controller", "technology", "console" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cursor-click", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cyborg", "keywords": [ "artificial", "robotics", "intelligence", "machine", "technology", "android" ], "content": "\n\n\n" }, { "name": "cyborg-2", "keywords": [ "artificial", "robotics", "intelligence", "machine", "technology", "android" ], "content": "\n\n\n" }, { "name": "database", "keywords": [ "raid", "storage", "code", "disk", "programming", "database", "array", "hard", "disc" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "database-check", "keywords": [ "raid", "storage", "code", "disk", "programming", "database", "array", "hard", "disc", "check", "approve" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "database-lock", "keywords": [ "raid", "storage", "code", "disk", "programming", "database", "array", "hard", "disc", "password", "security", "protection", "lock", "secure" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "database-refresh", "keywords": [ "raid", "storage", "code", "disk", "programming", "database", "array", "hard", "disc", "refresh" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "database-remove", "keywords": [ "raid", "storage", "code", "disk", "programming", "database", "array", "hard", "disc", "remove", "delete", "cross" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "database-server-1", "keywords": [ "server", "network", "internet" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "database-server-2", "keywords": [ "server", "network", "internet" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "database-setting", "keywords": [ "raid", "storage", "code", "disk", "programming", "database", "array", "hard", "disc", "setting" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "database-subtract-2-raid-storage-code-disk-programming-database-array-hard-disc-minus", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "delete-keyboard", "keywords": [], "content": "\n\n\n" }, { "name": "desktop-chat", "keywords": [ "bubble", "chat", "customer", "service", "conversation", "display", "device" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "desktop-check", "keywords": [ "success", "approve", "device", "display", "desktop", "computer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "desktop-code", "keywords": [ "desktop", "device", "display", "computer", "code", "terminal", "html", "css", "programming", "system" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "desktop-delete", "keywords": [ "device", "remove", "display", "computer", "deny", "desktop", "fail", "failure", "cross" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "desktop-dollar", "keywords": [ "cash", "desktop", "display", "device", "notification", "computer", "money", "currency" ], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "desktop-emoji", "keywords": [ "device", "display", "desktop", "padlock", "smiley" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "desktop-favorite-star", "keywords": [ "desktop", "device", "display", "like", "favorite", "star" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "desktop-game", "keywords": [ "controller", "display", "device", "computer", "games", "leisure" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "desktop-help", "keywords": [ "device", "help", "information", "display", "desktop", "question", "info" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "device-database-encryption-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "discord", "keywords": [], "content": "\n\n\n" }, { "name": "drone", "keywords": [ "artificial", "robotics", "intelligence", "machine", "technology", "android", "flying" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dropbox", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "eject", "keywords": [ "eject", "unmount", "dismount", "remove", "keyboard" ], "content": "\n\n\n" }, { "name": "electric-cord-1", "keywords": [ "electricity", "electronic", "appliances", "device", "cord", "cable", "plug", "connection" ], "content": "\n\n\n" }, { "name": "electric-cord-3", "keywords": [ "electricity", "electronic", "appliances", "device", "cord", "cable", "plug", "connection" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "facebook-1", "keywords": [ "media", "facebook", "social" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "figma", "keywords": [], "content": "\n\n\n" }, { "name": "floppy-disk", "keywords": [ "disk", "floppy", "electronics", "device", "disc", "computer", "storage" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "gmail", "keywords": [], "content": "\n\n\n" }, { "name": "google", "keywords": [ "media", "google", "social" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "google-drive", "keywords": [], "content": "\n\n\n" }, { "name": "hand-held", "keywords": [ "tablet", "kindle", "device", "electronics", "ipad", "computer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hand-held-tablet-drawing", "keywords": [ "tablet", "kindle", "device", "electronics", "ipad", "digital", "drawing", "canvas" ], "content": "\n\n\n" }, { "name": "hand-held-tablet-writing", "keywords": [ "tablet", "kindle", "device", "electronics", "ipad", "writing", "digital", "paper", "notepad" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hard-disk", "keywords": [ "device", "disc", "drive", "disk", "electronics", "platter", "turntable", "raid", "storage" ], "content": "\n\n\n" }, { "name": "hard-drive-1", "keywords": [ "disk", "device", "electronics", "disc", "drive", "raid", "storage" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "instagram", "keywords": [], "content": "\n\n\n" }, { "name": "keyboard", "keywords": [ "keyboard", "device", "electronics", "dvorak", "qwerty" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "keyboard-virtual", "keywords": [ "remote", "device", "electronics", "qwerty", "keyboard", "virtual", "interface" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "keyboard-wireless-2", "keywords": [ "remote", "device", "wireless", "electronics", "qwerty", "keyboard", "bluetooth" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "laptop-charging", "keywords": [ "device", "laptop", "electronics", "computer", "notebook", "charging" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "linkedin", "keywords": [ "network", "linkedin", "professional" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "local-storage-folder", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "meta", "keywords": [], "content": "\n\n\n" }, { "name": "mouse", "keywords": [ "device", "electronics", "mouse" ], "content": "\n\n\n" }, { "name": "mouse-wireless", "keywords": [ "remote", "wireless", "device", "electronics", "mouse", "computer" ], "content": "\n\n\n" }, { "name": "mouse-wireless-1", "keywords": [ "remote", "wireless", "device", "electronics", "mouse", "computer" ], "content": "\n\n\n" }, { "name": "netflix", "keywords": [], "content": "\n\n\n" }, { "name": "network", "keywords": [ "network", "server", "internet", "ethernet", "connection" ], "content": "\n\n\n" }, { "name": "next", "keywords": [ "next", "arrow", "right", "keyboard" ], "content": "\n\n\n" }, { "name": "paypal", "keywords": [ "payment", "paypal" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "play-store", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "printer", "keywords": [ "scan", "device", "electronics", "printer", "print", "computer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "return-2", "keywords": [ "arrow", "return", "enter", "keyboard" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "screen-1", "keywords": [ "screen", "device", "electronics", "monitor", "diplay", "computer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "screen-2", "keywords": [ "screen", "device", "electronics", "monitor", "diplay", "computer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "screen-curve", "keywords": [ "screen", "curved", "device", "electronics", "monitor", "diplay", "computer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "screensaver-monitor-wallpaper", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shift", "keywords": [ "key", "shift", "up", "arrow", "keyboard" ], "content": "\n\n\n" }, { "name": "shredder", "keywords": [ "device", "electronics", "shred", "paper", "cut", "destroy", "remove", "delete" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "signal-loading", "keywords": [ "bracket", "loading", "internet", "angle", "signal", "server", "network", "connecting", "connection" ], "content": "\n\n\n" }, { "name": "slack", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "spotify", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "telegram", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "tiktok", "keywords": [], "content": "\n\n\n" }, { "name": "tinder", "keywords": [], "content": "\n\n\n" }, { "name": "twitter", "keywords": [ "media", "twitter", "social" ], "content": "\n\n\n" }, { "name": "usb-drive", "keywords": [ "usb", "drive", "stick", "memory", "storage", "data", "connection" ], "content": "\n\n\n" }, { "name": "virtual-reality", "keywords": [ "gaming", "virtual", "gear", "controller", "reality", "games", "headset", "technology", "vr", "eyewear" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "voice-mail", "keywords": [ "mic", "audio", "mike", "music", "microphone" ], "content": "\n\n\n" }, { "name": "voice-mail-off", "keywords": [ "mic", "audio", "mike", "music", "microphone", "mute", "off" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "VPN-connection", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "watch-1", "keywords": [ "device", "timepiece", "cirle", "electronics", "face", "blank", "watch", "smart" ], "content": "\n\n\n" }, { "name": "watch-2", "keywords": [ "device", "square", "timepiece", "electronics", "face", "blank", "watch", "smart" ], "content": "\n\n\n" }, { "name": "watch-circle-charging", "keywords": [ "device", "timepiece", "circle", "watch", "round", "charge", "charging", "power" ], "content": "\n\n\n" }, { "name": "watch-circle-heartbeat-monitor-1", "keywords": [ "device", "timepiece", "circle", "watch", "round", "heart", "beat", "monitor", "healthcare" ], "content": "\n\n\n" }, { "name": "watch-circle-heartbeat-monitor-2", "keywords": [ "device", "timepiece", "circle", "watch", "round", "heart", "beat", "monitor", "healthcare" ], "content": "\n\n\n" }, { "name": "watch-circle-menu", "keywords": [ "device", "timepiece", "circle", "watch", "round", "menu", "list", "option", "app" ], "content": "\n\n\n" }, { "name": "watch-circle-time", "keywords": [ "device", "timepiece", "circle", "watch", "round", "time", "clock", "analog" ], "content": "\n\n\n" }, { "name": "webcam", "keywords": [ "webcam", "camera", "future", "tech", "chat", "skype", "technology", "video" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "webcam-video", "keywords": [ "work", "video", "meeting", "camera", "company", "conference", "office" ], "content": "\n\n\n" }, { "name": "webcam-video-circle", "keywords": [ "work", "video", "meeting", "camera", "company", "conference", "office" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "webcam-video-off", "keywords": [ "work", "video", "meeting", "camera", "company", "conference", "office", "off" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "whatsapp", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "wifi", "keywords": [ "wireless", "wifi", "internet", "server", "network", "connection" ], "content": "\n\n\n" }, { "name": "wifi-antenna", "keywords": [ "wireless", "wifi", "internet", "server", "network", "antenna", "connection" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "wifi-disabled", "keywords": [ "wireless", "wifi", "internet", "server", "network", "disabled", "off", "offline", "connection" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "wifi-horizontal", "keywords": [ "wireless", "wifi", "internet", "server", "network", "horizontal", "connection" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "wifi-router", "keywords": [ "wireless", "wifi", "internet", "server", "network", "connection" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "windows", "keywords": [ "os", "system", "microsoft" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "culture": [ { "name": "christian-cross-1", "keywords": [ "religion", "christian", "cross", "culture", "bold" ], "content": "\n\n\n" }, { "name": "christian-cross-2", "keywords": [ "religion", "christian", "cross", "culture", "bold" ], "content": "\n\n\n" }, { "name": "christianity", "keywords": [ "religion", "jesus", "christianity", "christ", "fish", "culture" ], "content": "\n\n\n" }, { "name": "dhammajak", "keywords": [ "religion", "dhammajak", "culture", "bhuddhism", "buddish" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hexagram", "keywords": [ "star", "jew", "jewish", "judaism", "hexagram", "culture", "religion", "david" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hinduism", "keywords": [ "religion", "hinduism", "culture", "hindu" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "islam", "keywords": [ "religion", "islam", "moon", "crescent", "muslim", "culture", "star" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "news-paper", "keywords": [ "newspaper", "periodical", "fold", "content", "entertainment" ], "content": "\n\n\n" }, { "name": "peace-symbol", "keywords": [ "religion", "peace", "war", "culture", "symbol" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "politics-compaign", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "politics-speech", "keywords": [], "content": "\n\n\n" }, { "name": "politics-vote-2", "keywords": [], "content": "\n\n\n" }, { "name": "ticket-1", "keywords": [ "hobby", "ticket", "event", "entertainment", "stub", "theater", "entertainment", "culture" ], "content": "\n\n\n" }, { "name": "tickets", "keywords": [ "hobby", "ticket", "event", "entertainment", "stub", "theater", "entertainment", "culture" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "yin-yang-symbol", "keywords": [ "religion", "tao", "yin", "yang", "taoism", "culture", "symbol" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "zodiac-1", "keywords": [ "sign", "astrology", "stars", "space", "scorpio" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "zodiac-10", "keywords": [ "sign", "astrology", "stars", "space", "pisces" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "zodiac-11", "keywords": [ "sign", "astrology", "stars", "space", "sagittarius" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "zodiac-12", "keywords": [ "sign", "astrology", "stars", "space", "cancer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "zodiac-2", "keywords": [ "sign", "astrology", "stars", "space", "virgo" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "zodiac-3", "keywords": [ "sign", "astrology", "stars", "space", "leo" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "zodiac-4", "keywords": [ "sign", "astrology", "stars", "space", "aquarius" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "zodiac-5", "keywords": [ "sign", "astrology", "stars", "space", "taurus" ], "content": "\n\n\n" }, { "name": "zodiac-6", "keywords": [ "sign", "astrology", "stars", "space", "capricorn" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "zodiac-7", "keywords": [ "sign", "astrology", "stars", "space", "ares" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "zodiac-8", "keywords": [ "sign", "astrology", "stars", "space", "libra" ], "content": "\n\n\n" }, { "name": "zodiac-9", "keywords": [ "sign", "astrology", "stars", "space", "gemini" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "entertainment": [ { "name": "balloon", "keywords": [ "hobby", "entertainment", "party", "balloon" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bow", "keywords": [ "entertainment", "gaming", "bow", "weapon" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "button-fast-forward-1", "keywords": [ "button", "controls", "fast", "forward", "movies", "television", "video", "tv" ], "content": "\n\n\n" }, { "name": "button-fast-forward-2", "keywords": [ "button", "controls", "fast", "forward", "movies", "television", "video", "tv" ], "content": "\n\n\n" }, { "name": "button-next", "keywords": [ "button", "television", "buttons", "movies", "skip", "next", "video", "controls" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "button-pause-2", "keywords": [ "button", "television", "buttons", "movies", "tv", "pause", "video", "controls" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "button-play", "keywords": [ "button", "television", "buttons", "movies", "play", "tv", "video", "controls" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "button-power-1", "keywords": [ "power", "button", "on", "off" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "button-previous", "keywords": [ "button", "television", "buttons", "movies", "skip", "previous", "video", "controls" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "button-record-3", "keywords": [ "button", "television", "buttons", "movies", "record", "tv", "video", "controls" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "button-rewind-1", "keywords": [ "rewind", "television", "button", "movies", "buttons", "tv", "video", "controls" ], "content": "\n\n\n" }, { "name": "button-rewind-2", "keywords": [ "rewind", "television", "button", "movies", "buttons", "tv", "video", "controls" ], "content": "\n\n\n" }, { "name": "button-stop", "keywords": [ "button", "television", "buttons", "movies", "stop", "tv", "video", "controls" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "camera-video", "keywords": [ "film", "television", "tv", "camera", "movies", "video", "recorder" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cards", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chess-bishop", "keywords": [], "content": "\n\n\n" }, { "name": "chess-king", "keywords": [], "content": "\n\n\n" }, { "name": "chess-knight", "keywords": [], "content": "\n\n\n" }, { "name": "chess-pawn", "keywords": [], "content": "\n\n\n" }, { "name": "cloud-gaming-1", "keywords": [ "entertainment", "cloud", "gaming" ], "content": "\n\n\n" }, { "name": "clubs-symbol", "keywords": [ "entertainment", "gaming", "card", "clubs", "symbol" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "diamonds-symbol", "keywords": [ "entertainment", "gaming", "card", "diamonds", "symbol" ], "content": "\n\n\n" }, { "name": "dice-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dice-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dice-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dice-4", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dice-5", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dice-6", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dices-entertainment-gaming-dices", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "earpods", "keywords": [ "airpods", "audio", "earpods", "music", "earbuds", "true", "wireless", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "epic-games-1", "keywords": [ "epic", "games", "entertainment", "gaming" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "esports", "keywords": [ "entertainment", "gaming", "esports" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fireworks-rocket", "keywords": [ "hobby", "entertainment", "party", "fireworks", "rocket" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "gameboy", "keywords": [ "entertainment", "gaming", "device", "gameboy" ], "content": "\n\n\n" }, { "name": "gramophone", "keywords": [ "music", "audio", "note", "gramophone", "player", "vintage", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hearts-symbol", "keywords": [ "entertainment", "gaming", "card", "hearts", "symbol" ], "content": "\n\n\n" }, { "name": "music-equalizer", "keywords": [ "music", "audio", "note", "wave", "sound", "equalizer", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "music-note-1", "keywords": [ "music", "audio", "note", "entertainment" ], "content": "\n\n\n" }, { "name": "music-note-2", "keywords": [ "music", "audio", "note", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "music-note-off-1", "keywords": [ "music", "audio", "note", "off", "mute", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "music-note-off-2", "keywords": [ "music", "audio", "note", "off", "mute", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "nintendo-switch", "keywords": [ "nintendo", "switch", "entertainment", "gaming" ], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "one-vesus-one", "keywords": [ "entertainment", "gaming", "one", "vesus", "one" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pacman", "keywords": [ "entertainment", "gaming", "pacman", "video" ], "content": "\n\n\n" }, { "name": "party-popper", "keywords": [ "hobby", "entertainment", "party", "popper", "confetti", "event" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "play-list-4", "keywords": [ "screen", "television", "display", "player", "movies", "players", "tv", "media", "video", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "play-list-5", "keywords": [ "player", "television", "movies", "slider", "media", "tv", "players", "video", "stack", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "play-list-8", "keywords": [ "player", "television", "movies", "slider", "media", "tv", "players", "video", "stack", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "play-list-9", "keywords": [ "player", "television", "movies", "slider", "media", "tv", "players", "video", "stack", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "play-list-folder", "keywords": [ "player", "television", "movies", "slider", "media", "tv", "players", "video" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "play-station", "keywords": [ "play", "station", "entertainment", "gaming" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "radio", "keywords": [ "antenna", "audio", "music", "radio", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "recording-tape-bubble-circle", "keywords": [ "phone", "device", "mail", "mobile", "voice", "machine", "answering", "chat", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "recording-tape-bubble-square", "keywords": [ "phone", "device", "mail", "mobile", "voice", "machine", "answering", "chat", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "song-recommendation", "keywords": [ "song", "recommendation", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "spades-symbol", "keywords": [ "entertainment", "gaming", "card", "spades", "symbol" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "speaker-1", "keywords": [ "speaker", "music", "audio", "subwoofer", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "speaker-2", "keywords": [ "speakers", "music", "audio", "entertainment" ], "content": "\n\n\n" }, { "name": "stream", "keywords": [ "stream", "entertainment", "gaming" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "tape-cassette-record", "keywords": [ "music", "entertainment", "tape", "cassette", "record" ], "content": "\n\n\n" }, { "name": "volume-down", "keywords": [ "speaker", "down", "volume", "control", "audio", "music", "decrease", "entertainment" ], "content": "\n\n\n" }, { "name": "volume-level-high", "keywords": [ "speaker", "high", "volume", "control", "audio", "music", "entertainment" ], "content": "\n\n\n" }, { "name": "volume-level-low", "keywords": [ "volume", "speaker", "lower", "down", "control", "music", "low", "audio", "entertainment" ], "content": "\n\n\n" }, { "name": "volume-level-off", "keywords": [ "volume", "speaker", "control", "music", "audio", "entertainment" ], "content": "\n\n\n" }, { "name": "volume-mute", "keywords": [ "speaker", "remove", "volume", "control", "audio", "music", "mute", "off", "cross", "entertainment" ], "content": "\n\n\n" }, { "name": "volume-off", "keywords": [ "speaker", "music", "mute", "volume", "control", "audio", "off", "mute", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "vr-headset-1", "keywords": [ "entertainment", "gaming", "vr", "headset" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "vr-headset-2", "keywords": [ "entertainment", "gaming", "vr", "headset" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "xbox", "keywords": [ "xbox", "entertainment", "gaming" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "food_drink": [ { "name": "beer-mug", "keywords": [ "beer", "cook", "brewery", "drink", "mug", "cooking", "nutrition", "brew", "brewing", "food" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "beer-pitch", "keywords": [ "drink", "glass", "beer", "pitch" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "burger", "keywords": [ "burger", "fast", "cook", "cooking", "nutrition", "food" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "burrito-fastfood", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cake-slice", "keywords": [ "cherry", "cake", "birthday", "event", "special", "sweet", "bake" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "candy-cane", "keywords": [ "candy", "sweet", "cane", "christmas" ], "content": "\n\n\n" }, { "name": "champagne-party-alcohol", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cheese", "keywords": [ "cook", "cheese", "animal", "products", "cooking", "nutrition", "dairy", "food" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cherries", "keywords": [ "cook", "plant", "cherry", "plants", "cooking", "nutrition", "vegetarian", "fruit", "food", "cherries" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chicken-grilled-stream", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cocktail", "keywords": [ "cook", "alcohol", "food", "cocktail", "drink", "cooking", "nutrition", "alcoholic", "beverage", "glass" ], "content": "\n\n\n" }, { "name": "coffee-bean", "keywords": [ "cook", "cooking", "nutrition", "coffee", "bean" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "coffee-mug", "keywords": [ "coffee", "cook", "cup", "drink", "mug", "cooking", "nutrition", "cafe", "caffeine", "food" ], "content": "\n\n\n" }, { "name": "coffee-takeaway-cup", "keywords": [ "cup", "coffee", "hot", "takeaway", "drink", "caffeine" ], "content": "\n\n\n" }, { "name": "donut", "keywords": [ "dessert", "donut" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fork-knife", "keywords": [ "fork", "spoon", "knife", "food", "dine", "cook", "utensils", "eat", "restaurant", "dining", "kitchenware" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fork-spoon", "keywords": [ "fork", "spoon", "food", "dine", "cook", "utensils", "eat", "restaurant", "dining", "kitchenware" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ice-cream-2", "keywords": [ "cook", "frozen", "popsicle", "freezer", "nutrition", "cream", "stick", "cold", "ice", "cooking" ], "content": "\n\n\n" }, { "name": "ice-cream-3", "keywords": [ "cook", "frozen", "cone", "cream", "ice", "cooking", "nutrition", "freezer", "cold", "food" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "lemon-fruit-seasoning", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "microwave", "keywords": [ "cook", "food", "appliances", "cooking", "nutrition", "appliance", "microwave", "kitchenware" ], "content": "\n\n\n" }, { "name": "milkshake", "keywords": [ "milkshake", "drink", "takeaway", "cup", "cold", "beverage" ], "content": "\n\n\n" }, { "name": "popcorn", "keywords": [ "cook", "corn", "movie", "snack", "cooking", "nutrition", "bake", "popcorn" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pork-meat", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "refrigerator", "keywords": [ "fridge", "cook", "appliances", "cooking", "nutrition", "freezer", "appliance", "food", "kitchenware" ], "content": "\n\n\n" }, { "name": "serving-dome", "keywords": [ "cook", "tool", "dome", "kitchen", "serving", "paltter", "dish", "tools", "food", "kitchenware" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shrimp", "keywords": [ "sea", "food", "shrimp" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "strawberry", "keywords": [ "fruit", "sweet", "berries", "plant", "strawberry" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "tea-cup", "keywords": [ "herbal", "cook", "tea", "tisane", "cup", "drink", "cooking", "nutrition", "mug", "food" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "toast", "keywords": [ "bread", "toast", "breakfast" ], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "water-glass", "keywords": [ "glass", "water", "juice", "drink", "liquid" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "wine", "keywords": [ "drink", "cook", "glass", "cooking", "wine", "nutrition", "food" ], "content": "\n\n\n" } ], "health": [ { "name": "ambulance", "keywords": [ "car", "emergency", "health", "medical", "ambulance" ], "content": "\n\n\n" }, { "name": "bacteria-virus-cells-biology", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bandage", "keywords": [ "health", "medical", "hospital", "medicine", "capsule", "bandage", "vaccine" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "blood-bag-donation", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "blood-donate-drop", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "blood-drop-donation", "keywords": [], "content": "\n\n\n" }, { "name": "brain", "keywords": [ "medical", "health", "brain" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "brain-cognitive", "keywords": [ "health", "medical", "brain", "cognitive", "specialities" ], "content": "\n\n\n" }, { "name": "call-center-support-service", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "checkup-medical-report-clipboard", "keywords": [], "content": "\n\n\n" }, { "name": "ear-hearing", "keywords": [ "health", "medical", "hearing", "ear" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "eye-optic", "keywords": [ "health", "medical", "eye", "optic" ], "content": "\n\n\n" }, { "name": "flu-mask", "keywords": [ "health", "medical", "hospital", "mask", "flu", "vaccine", "protection" ], "content": "\n\n\n" }, { "name": "health-care-2", "keywords": [ "health", "medical", "hospital", "heart", "care", "symbol" ], "content": "\n\n\n" }, { "name": "heart-rate-pulse-graph", "keywords": [], "content": "\n\n\n" }, { "name": "heart-rate-search", "keywords": [ "health", "medical", "monitor", "heart", "rate", "search" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hospital-sign-circle", "keywords": [ "health", "sign", "medical", "symbol", "hospital", "circle", "emergency" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hospital-sign-square", "keywords": [ "health", "sign", "medical", "symbol", "hospital", "square", "emergency" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "insurance-hand", "keywords": [ "health", "medical", "insurance", "hand", "cross" ], "content": "\n\n\n" }, { "name": "medical-bag", "keywords": [ "health", "sign", "medical", "symbol", "hospital", "bag", "medicine", "medkit" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "medical-cross-sign-healthcare", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "medical-cross-symbol", "keywords": [ "health", "sign", "medical", "symbol", "hospital", "emergency" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "medical-files-report-history", "keywords": [], "content": "\n\n\n" }, { "name": "medical-ribbon-1", "keywords": [ "ribbon", "medical", "cancer", "health", "beauty", "symbol" ], "content": "\n\n\n" }, { "name": "medical-search-diagnosis", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "microscope-observation-sciene", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "nurse-assistant-emergency", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "nurse-hat", "keywords": [ "health", "medical", "hospital", "nurse", "doctor", "cap" ], "content": "\n\n\n" }, { "name": "online-medical-call-service", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "online-medical-service-monitor", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "online-medical-web-service", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "petri-dish-lab-equipment", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pharmacy", "keywords": [ "health", "medical", "pharmacy", "sign", "medicine", "mortar", "pestle" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "prescription-pills-drugs-healthcare", "keywords": [], "content": "\n\n\n" }, { "name": "sign-cross-square", "keywords": [ "health", "sign", "medical", "symbol", "hospital", "cross", "square" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "sos-help-emergency-sign", "keywords": [], "content": "\n\n\n" }, { "name": "stethoscope", "keywords": [ "instrument", "health", "medical", "stethoscope" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "syringe", "keywords": [ "instrument", "medical", "syringe", "health", "beauty", "needle" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "tablet-capsule", "keywords": [ "health", "medical", "hospital", "medicine", "capsule", "tablet" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "tooth", "keywords": [ "health", "medical", "tooth" ], "content": "\n\n\n" }, { "name": "virus-antivirus", "keywords": [ "health", "medical", "covid19", "flu", "influenza", "virus", "antivirus" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "waiting-appointments-calendar", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "wheelchair", "keywords": [ "health", "medical", "hospital", "wheelchair", "disable", "help", "sign" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "images_photography": [ { "name": "auto-flash", "keywords": [], "content": "\n\n\n" }, { "name": "camera-1", "keywords": [ "photos", "picture", "camera", "photography", "photo", "pictures" ], "content": "\n\n\n" }, { "name": "camera-disabled", "keywords": [ "photos", "picture", "camera", "photography", "photo", "pictures", "disabled", "off" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "camera-loading", "keywords": [ "photos", "picture", "camera", "photography", "photo", "pictures", "loading", "option", "setting" ], "content": "\n\n\n" }, { "name": "camera-square", "keywords": [ "photos", "picture", "camera", "photography", "photo", "pictures", "frame", "square" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "composition-oval", "keywords": [ "camera", "frame", "composition", "photography", "pictures", "landscape", "photo", "oval" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "composition-vertical", "keywords": [ "camera", "portrait", "frame", "vertical", "composition", "photography", "photo" ], "content": "\n\n\n" }, { "name": "compsition-horizontal", "keywords": [ "camera", "horizontal", "panorama", "composition", "photography", "photo", "pictures" ], "content": "\n\n\n" }, { "name": "edit-image-photo", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "film-roll-1", "keywords": [ "photos", "camera", "shutter", "picture", "photography", "pictures", "photo", "film", "roll" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "film-slate", "keywords": [ "pictures", "photo", "film", "slate" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "flash-1", "keywords": [ "flash", "power", "connect", "charge", "electricity", "lightning" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "flash-2", "keywords": [ "flash", "power", "connect", "charge", "electricity", "lightning" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "flash-3", "keywords": [ "flash", "power", "connect", "charge", "electricity", "lightning" ], "content": "\n\n\n" }, { "name": "flash-off", "keywords": [ "flash", "power", "connect", "charge", "off", "electricity", "lightning" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "flower", "keywords": [ "photos", "photo", "picture", "camera", "photography", "pictures", "flower", "image" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "focus-points", "keywords": [ "camera", "frame", "photography", "pictures", "photo", "focus", "position" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "landscape-2", "keywords": [ "photos", "photo", "landscape", "picture", "photography", "camera", "pictures", "image" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "landscape-setting", "keywords": [ "design", "composition", "horizontal", "lanscape" ], "content": "\n\n\n" }, { "name": "laptop-camera", "keywords": [ "photos", "photo", "picture", "photography", "camera", "pictures", "laptop", "computer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "mobile-phone-camera", "keywords": [ "photos", "photo", "picture", "photography", "camera", "pictures", "phone" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "orientation-landscape", "keywords": [ "photos", "photo", "orientation", "landscape", "picture", "photography", "camera", "pictures", "image" ], "content": "\n\n\n" }, { "name": "orientation-portrait", "keywords": [ "photos", "photo", "orientation", "portrait", "picture", "photography", "camera", "pictures", "image" ], "content": "\n\n\n" }, { "name": "polaroid-four", "keywords": [ "photos", "camera", "polaroid", "picture", "photography", "pictures", "four", "photo", "image" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "interface_essential": [ { "name": "add-1", "keywords": [ "expand", "cross", "buttons", "button", "more", "remove", "plus", "add", "+", "mathematics", "math" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "add-bell-notification", "keywords": [ "notification", "alarm", "alert", "bell", "add" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "add-circle", "keywords": [ "button", "remove", "cross", "add", "buttons", "plus", "circle", "+", "mathematics", "math" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "add-layer-2", "keywords": [ "layer", "add", "design", "plus", "layers", "square", "box" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "add-square", "keywords": [ "square", "remove", "cross", "buttons", "add", "plus", "button", "+", "mathematics", "math" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "alarm-clock", "keywords": [ "time", "tock", "stopwatch", "measure", "clock", "tick" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "align-back-1", "keywords": [ "back", "design", "layer", "layers", "pile", "stack", "arrange", "square" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "align-center", "keywords": [ "text", "alignment", "align", "paragraph", "centered", "formatting", "center" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "align-front-1", "keywords": [ "design", "front", "layer", "layers", "pile", "stack", "arrange", "square" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "align-left", "keywords": [ "paragraph", "text", "alignment", "align", "left", "formatting", "right" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "align-right", "keywords": [ "rag", "paragraph", "text", "alignment", "align", "right", "formatting", "left" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ampersand", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "archive-box", "keywords": [ "box", "content", "banker", "archive", "file" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-bend-left-down-2", "keywords": [ "arrow", "bend", "curve", "change", "direction", "left", "to", "down" ], "content": "\n\n\n" }, { "name": "arrow-bend-right-down-2", "keywords": [ "arrow", "bend", "curve", "change", "direction", "right", "to", "down" ], "content": "\n\n\n" }, { "name": "arrow-crossover-down", "keywords": [ "cross", "move", "over", "arrow", "arrows", "down" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-crossover-left", "keywords": [ "cross", "move", "over", "arrow", "arrows", "left" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-crossover-right", "keywords": [ "cross", "move", "over", "arrow", "arrows", "ight" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-crossover-up", "keywords": [ "cross", "move", "over", "arrow", "arrows", "right" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-cursor-1", "keywords": [ "mouse", "select", "cursor" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-cursor-2", "keywords": [ "mouse", "select", "cursor" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-curvy-up-down-1", "keywords": [ "both", "direction", "arrow", "curvy", "diagram", "zigzag", "vertical" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-curvy-up-down-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-down-2", "keywords": [ "down", "move", "arrow", "arrows" ], "content": "\n\n\n" }, { "name": "arrow-down-dashed-square", "keywords": [ "arrow", "keyboard", "button", "down", "square", "dashes" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-expand", "keywords": [ "expand", "small", "bigger", "retract", "smaller", "big" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-infinite-loop", "keywords": [ "arrow", "diagram", "loop", "infinity", "repeat" ], "content": "\n\n\n" }, { "name": "arrow-move", "keywords": [ "move", "button", "arrows", "direction" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-reload-horizontal-1", "keywords": [ "arrows", "load", "arrow", "sync", "square", "loading", "reload", "synchronize" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-reload-horizontal-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-reload-vertical-1", "keywords": [ "arrows", "load", "arrow", "sync", "square", "loading", "reload", "synchronize" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-reload-vertical-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-roadmap", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-round-left", "keywords": [ "diagram", "round", "arrow", "left" ], "content": "\n\n\n" }, { "name": "arrow-round-right", "keywords": [ "diagram", "round", "arrow", "right" ], "content": "\n\n\n" }, { "name": "arrow-shrink", "keywords": [ "expand", "retract", "shrink", "bigger", "big", "small", "smaller" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-shrink-diagonal-1", "keywords": [ "expand", "retract", "shrink", "bigger", "big", "small", "smaller" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-shrink-diagonal-2", "keywords": [ "expand", "retract", "shrink", "bigger", "big", "small", "smaller" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-transfer-diagonal-1", "keywords": [ "arrows", "arrow", "server", "data", "diagonal", "internet", "transfer", "network" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-transfer-diagonal-2", "keywords": [ "arrows", "arrow", "server", "data", "diagonal", "internet", "transfer", "network" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-transfer-diagonal-3", "keywords": [ "arrows", "arrow", "server", "data", "diagonal", "internet", "transfer", "network" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-up-1", "keywords": [ "arrow", "up", "keyboard" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-up-dashed-square", "keywords": [ "arrow", "keyboard", "button", "up", "square", "dashes" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ascending-number-order", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "attribution", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "blank-calendar", "keywords": [ "blank", "calendar", "date", "day", "month", "empty" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "blank-notepad", "keywords": [ "content", "notes", "book", "notepad", "notebook" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "block-bell-notification", "keywords": [ "notification", "alarm", "alert", "bell", "block" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bomb", "keywords": [ "delete", "bomb", "remove" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bookmark", "keywords": [ "bookmarks", "tags", "favorite" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "braces-circle", "keywords": [ "interface", "math", "braces", "sign", "mathematics" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "brightness-1", "keywords": [ "bright", "adjust", "brightness", "adjustment", "sun", "raise", "controls" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "brightness-2", "keywords": [ "bright", "adjust", "brightness", "adjustment", "sun", "raise", "controls", "half" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "brightness-3", "keywords": [ "bright", "adjust", "brightness", "adjustment", "sun", "raise", "controls", "dot", "small" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "broken-link-2", "keywords": [ "break", "broken", "hyperlink", "link", "remove", "unlink", "chain" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bullet-list", "keywords": [ "points", "bullet", "unordered", "list", "lists", "bullets" ], "content": "\n\n\n" }, { "name": "calendar-add", "keywords": [ "add", "calendar", "date", "day", "month" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "calendar-edit", "keywords": [ "calendar", "date", "day", "compose", "edit", "note" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "calendar-jump-to-date", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "calendar-star", "keywords": [ "calendar", "date", "day", "favorite", "like", "month", "star" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "celsius", "keywords": [ "degrees", "temperature", "centigrade", "celsius", "degree", "weather" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "check", "keywords": [ "check", "form", "validation", "checkmark", "success", "add", "addition", "tick" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "check-square", "keywords": [ "check", "form", "validation", "checkmark", "success", "add", "addition", "box", "square", "tick" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "circle", "keywords": [ "geometric", "circle", "round", "design", "shape", "shapes", "shape" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "circle-clock", "keywords": [ "clock", "loading", "measure", "time", "circle" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "clipboard-add", "keywords": [ "edit", "task", "edition", "add", "clipboard", "form" ], "content": "\n\n\n" }, { "name": "clipboard-check", "keywords": [ "checkmark", "edit", "task", "edition", "checklist", "check", "success", "clipboard", "form" ], "content": "\n\n\n" }, { "name": "clipboard-remove", "keywords": [ "edit", "task", "edition", "remove", "delete", "clipboard", "form" ], "content": "\n\n\n" }, { "name": "cloud", "keywords": [ "cloud", "meteorology", "cloudy", "overcast", "cover", "weather" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cog", "keywords": [ "work", "loading", "cog", "gear", "settings", "machine" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "color-palette", "keywords": [ "color", "palette", "company", "office", "supplies", "work" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "color-picker", "keywords": [ "color", "colors", "design", "dropper", "eye", "eyedrop", "eyedropper", "painting", "picker" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "color-swatches", "keywords": [ "color", "colors", "design", "painting", "palette", "sample", "swatch" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cone-shape", "keywords": [], "content": "\n\n\n" }, { "name": "convert-PDF-2", "keywords": [ "essential", "files", "folder", "convert", "to", "PDF" ], "content": "\n\n\n" }, { "name": "copy-paste", "keywords": [ "clipboard", "copy", "cut", "paste" ], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "creative-commons", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "crop-selection", "keywords": [ "artboard", "crop", "design", "image", "picture" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "crown", "keywords": [ "reward", "social", "rating", "media", "queen", "vip", "king", "crown" ], "content": "\n\n\n" }, { "name": "customer-support-1", "keywords": [ "customer", "headset", "help", "microphone", "phone", "support" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cut", "keywords": [ "coupon", "cut", "discount", "price", "prices", "scissors" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dark-dislay-mode", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dashboard-3", "keywords": [ "app", "application", "dashboard", "home", "layout", "vertical" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dashboard-circle", "keywords": [ "app", "application", "dashboard", "home", "layout", "circle" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "delete-1", "keywords": [ "remove", "add", "button", "buttons", "delete", "cross", "x", "mathematics", "multiply", "math" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "descending-number-order", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "disable-bell-notification", "keywords": [ "disable", "silent", "notification", "off", "silence", "alarm", "bell", "alert" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "disable-heart", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "division-circle", "keywords": [ "interface", "math", "divided", "by", "sign", "mathematics" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "download-box-1", "keywords": [ "arrow", "box", "down", "download", "internet", "network", "server", "upload" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "download-circle", "keywords": [ "arrow", "circle", "down", "download", "internet", "network", "server", "upload" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "download-computer", "keywords": [ "action", "actions", "computer", "desktop", "device", "display", "download", "monitor", "screen" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "download-file", "keywords": [], "content": "\n\n\n" }, { "name": "empty-clipboard", "keywords": [ "work", "plain", "clipboard", "task", "list", "company", "office" ], "content": "\n\n\n" }, { "name": "equal-sign", "keywords": [ "interface", "math", "equal", "sign", "mathematics" ], "content": "\n\n\n" }, { "name": "expand", "keywords": [ "big", "bigger", "design", "expand", "larger", "resize", "size", "square" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "expand-horizontal-1", "keywords": [ "expand", "resize", "bigger", "horizontal", "smaller", "size", "arrow", "arrows", "big" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "expand-window-2", "keywords": [ "expand", "small", "bigger", "retract", "smaller", "big" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "face-scan-1", "keywords": [ "identification", "angle", "secure", "human", "id", "person", "face", "security", "brackets" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "factorial", "keywords": [ "interface", "math", "number", "factorial", "sign", "mathematics" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fahrenheit", "keywords": [ "degrees", "temperature", "fahrenheit", "degree", "weather" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fastforward-clock", "keywords": [ "time", "clock", "reset", "stopwatch", "circle", "measure", "loading" ], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "file-add-alternate", "keywords": [ "file", "common", "add" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "file-delete-alternate", "keywords": [ "file", "common", "delete", "cross" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "file-remove-alternate", "keywords": [ "file", "common", "remove", "minus", "subtract" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "filter-2", "keywords": [ "funnel", "filter", "angle", "oil" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fingerprint-1", "keywords": [ "identification", "password", "touch", "id", "secure", "fingerprint", "finger", "security" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fingerprint-2", "keywords": [ "identification", "password", "touch", "id", "secure", "fingerprint", "finger", "security" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fist", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fit-to-height-square", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "flip-vertical-arrow-2", "keywords": [ "arrow", "design", "flip", "reflect", "up", "down" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "flip-vertical-circle-1", "keywords": [ "flip", "bottom", "object", "work" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "flip-vertical-square-2", "keywords": [ "design", "up", "flip", "reflect", "vertical" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "folder-add", "keywords": [ "add", "folder", "plus" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "folder-check", "keywords": [ "remove", "check", "folder" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "folder-delete", "keywords": [ "remove", "minus", "folder", "subtract", "delete" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "front-camera", "keywords": [], "content": "\n\n\n" }, { "name": "gif-format", "keywords": [], "content": "\n\n\n" }, { "name": "give-gift", "keywords": [ "reward", "social", "rating", "media", "queen", "vip", "gift" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "glasses", "keywords": [ "vision", "sunglasses", "protection", "spectacles", "correction", "sun", "eye", "glasses" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "half-star-1", "keywords": [ "reward", "rating", "rate", "social", "star", "media", "favorite", "like", "stars", "half" ], "content": "\n\n\n" }, { "name": "hand-cursor", "keywords": [ "hand", "select", "cursor", "finger" ], "content": "\n\n\n" }, { "name": "hand-grab", "keywords": [ "hand", "select", "cursor", "finger", "grab" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "heading-1-paragraph-styles-heading", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "heading-2-paragraph-styles-heading", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "heading-3-paragraph-styles-heading", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "heart", "keywords": [ "reward", "social", "rating", "media", "heart", "it", "like", "favorite", "love" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "help-chat-2", "keywords": [ "bubble", "help", "mark", "message", "query", "question", "speech", "circle" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "help-question-1", "keywords": [ "circle", "faq", "frame", "help", "info", "mark", "more", "query", "question" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hierarchy-10", "keywords": [ "node", "organization", "links", "structure", "link", "nodes", "network", "hierarchy" ], "content": "\n\n\n" }, { "name": "hierarchy-13", "keywords": [ "node", "organization", "links", "structure", "link", "nodes", "network", "hierarchy" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hierarchy-14", "keywords": [ "node", "organization", "links", "structure", "link", "nodes", "network", "hierarchy" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hierarchy-2", "keywords": [ "node", "organization", "links", "structure", "link", "nodes", "network", "hierarchy" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hierarchy-4", "keywords": [ "node", "organization", "links", "structure", "link", "nodes", "network", "hierarchy" ], "content": "\n\n\n" }, { "name": "hierarchy-7", "keywords": [ "node", "organization", "links", "structure", "link", "nodes", "network", "hierarchy" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "home-3", "keywords": [ "home", "house", "roof", "shelter" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "home-4", "keywords": [ "home", "house", "roof", "shelter" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "horizontal-menu-circle", "keywords": [ "navigation", "dots", "three", "circle", "button", "horizontal", "menu" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "humidity-none", "keywords": [ "humidity", "drop", "weather" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "image-blur", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "image-saturation", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "information-circle", "keywords": [ "information", "frame", "info", "more", "help", "point", "circle" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "input-box", "keywords": [ "cursor", "text", "formatting", "type", "format" ], "content": "\n\n\n" }, { "name": "insert-side", "keywords": [ "points", "bullet", "align", "paragraph", "formatting", "bullets", "text" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "insert-top-left", "keywords": [ "alignment", "wrap", "formatting", "paragraph", "image", "left", "text" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "insert-top-right", "keywords": [ "paragraph", "image", "text", "alignment", "wrap", "right", "formatting" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "invisible-1", "keywords": [ "disable", "eye", "eyeball", "hide", "off", "view" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "invisible-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "jump-object", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "key", "keywords": [ "entry", "key", "lock", "login", "pass", "unlock", "access" ], "content": "\n\n\n" }, { "name": "keyhole-lock-circle", "keywords": [ "circle", "frame", "key", "keyhole", "lock", "locked", "secure", "security" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "lasso-tool", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "layers-1", "keywords": [ "design", "layer", "layers", "pile", "stack", "align" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "layers-2", "keywords": [ "design", "layer", "layers", "pile", "stack", "align" ], "content": "\n\n\n" }, { "name": "layout-window-1", "keywords": [ "column", "layout", "layouts", "left", "sidebar" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "layout-window-11", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "layout-window-2", "keywords": [ "column", "header", "layout", "layouts", "masthead", "sidebar" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "layout-window-8", "keywords": [ "grid", "header", "layout", "layouts", "masthead" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "lightbulb", "keywords": [ "lighting", "light", "incandescent", "bulb", "lights" ], "content": "\n\n\n" }, { "name": "like-1", "keywords": [ "reward", "social", "up", "rating", "media", "like", "thumb", "hand" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "link-chain", "keywords": [ "create", "hyperlink", "link", "make", "unlink", "connection", "chain" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "live-video", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "lock-rotation", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "login-1", "keywords": [ "arrow", "enter", "frame", "left", "login", "point", "rectangle" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "logout-1", "keywords": [ "arrow", "exit", "frame", "leave", "logout", "rectangle", "right" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "loop-1", "keywords": [ "multimedia", "multi", "button", "repeat", "media", "loop", "infinity", "controls" ], "content": "\n\n\n" }, { "name": "magic-wand-2", "keywords": [ "design", "magic", "star", "supplies", "tool", "wand" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "magnifying-glass", "keywords": [ "glass", "search", "magnifying" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "magnifying-glass-circle", "keywords": [ "circle", "glass", "search", "magnifying" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "manual-book", "keywords": [], "content": "\n\n\n" }, { "name": "megaphone-2", "keywords": [ "bullhorn", "loud", "megaphone", "share", "speaker", "transmit" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "minimize-window-2", "keywords": [ "expand", "retract", "shrink", "bigger", "big", "small", "smaller" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "moon-cloud", "keywords": [ "cloud", "meteorology", "cloudy", "partly", "sunny", "weather" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "move-left", "keywords": [ "move", "left", "arrows" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "move-right", "keywords": [ "move", "right", "arrows" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "multiple-file-2", "keywords": [ "double", "common", "file" ], "content": "\n\n\n" }, { "name": "music-folder-song", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "new-file", "keywords": [ "empty", "common", "file", "content" ], "content": "\n\n\n" }, { "name": "new-folder", "keywords": [ "empty", "folder" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "new-sticky-note", "keywords": [ "empty", "common", "file" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "not-equal-sign", "keywords": [ "interface", "math", "not", "equal", "sign", "mathematics" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ok-hand", "keywords": [], "content": "\n\n\n" }, { "name": "one-finger-drag-horizontal", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "one-finger-drag-vertical", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "one-finger-hold", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "one-finger-tap", "keywords": [], "content": "\n\n\n" }, { "name": "open-book", "keywords": [ "content", "books", "book", "open" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "open-umbrella", "keywords": [ "storm", "rain", "umbrella", "open", "weather" ], "content": "\n\n\n" }, { "name": "padlock-square-1", "keywords": [ "combination", "combo", "lock", "locked", "padlock", "secure", "security", "shield", "keyhole" ], "content": "\n\n\n" }, { "name": "page-setting", "keywords": [ "page", "setting", "square", "triangle", "circle", "line", "combination", "variation" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "paint-bucket", "keywords": [ "bucket", "color", "colors", "design", "paint", "painting" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "paint-palette", "keywords": [ "color", "colors", "design", "paint", "painting", "palette" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "paintbrush-1", "keywords": [ "brush", "color", "colors", "design", "paint", "painting" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "paintbrush-2", "keywords": [ "brush", "color", "colors", "design", "paint", "painting" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "paperclip-1", "keywords": [ "attachment", "link", "paperclip", "unlink" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "paragraph", "keywords": [ "alignment", "paragraph", "formatting", "text" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pathfinder-divide", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pathfinder-exclude", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pathfinder-intersect", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pathfinder-merge", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pathfinder-minus-front-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pathfinder-trim", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pathfinder-union", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "peace-hand", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pen-3", "keywords": [ "content", "creation", "edit", "pen", "pens", "write" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pen-draw", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pen-tool", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pencil", "keywords": [ "change", "edit", "modify", "pencil", "write", "writing" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pentagon", "keywords": [ "pentagon", "design", "geometric", "shape", "shapes", "shape" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pi-symbol-circle", "keywords": [ "interface", "math", "pi", "sign", "mathematics", "22", "7" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pictures-folder-memories", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "podium", "keywords": [ "work", "desk", "notes", "company", "presentation", "office", "podium", "microphone" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "polygon", "keywords": [ "polygon", "octangle", "design", "geometric", "shape", "shapes", "shape" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "praying-hand", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "projector-board", "keywords": [ "projector", "screen", "work", "meeting", "presentation" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pyramid-shape", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "quotation-2", "keywords": [ "quote", "quotation", "format", "formatting", "open", "close", "marks", "text" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "radioactive-2", "keywords": [ "warning", "radioactive", "radiation", "emergency", "danger", "safety" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "rain-cloud", "keywords": [ "cloud", "rain", "rainy", "meteorology", "precipitation", "weather" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "recycle-bin-2", "keywords": [ "remove", "delete", "empty", "bin", "trash", "garbage" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ringing-bell-notification", "keywords": [ "notification", "vibrate", "ring", "sound", "alarm", "alert", "bell", "noise" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "rock-and-roll-hand", "keywords": [], "content": "\n\n\n" }, { "name": "rotate-angle-45", "keywords": [ "rotate", "angle", "company", "office", "supplies", "work" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "round-cap", "keywords": [], "content": "\n\n\n" }, { "name": "satellite-dish", "keywords": [ "broadcast", "satellite", "share", "transmit", "satellite" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "scanner", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "search-visual", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "select-circle-area-1", "keywords": [ "select", "area", "object", "work" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "share-link", "keywords": [ "share", "transmit" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shield-1", "keywords": [ "shield", "protection", "security", "defend", "crime", "war", "cover" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shield-2", "keywords": [ "shield", "protection", "security", "defend", "crime", "war", "cover" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shield-check", "keywords": [ "shield", "protection", "security", "defend", "crime", "war", "cover", "check" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shield-cross", "keywords": [ "shield", "secure", "security", "cross", "add", "plus" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shrink-horizontal-1", "keywords": [ "resize", "shrink", "bigger", "horizontal", "smaller", "size", "arrow", "arrows", "big" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shuffle", "keywords": [ "multimedia", "shuffle", "multi", "button", "controls", "media" ], "content": "\n\n\n" }, { "name": "sigma", "keywords": [ "formula", "text", "format", "sigma", "formatting", "sum" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "skull-1", "keywords": [ "crash", "death", "delete", "die", "error", "garbage", "remove", "skull", "trash" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "sleep", "keywords": [], "content": "\n\n\n" }, { "name": "snow-flake", "keywords": [ "winter", "freeze", "snow", "freezing", "ice", "cold", "weather", "snowflake" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "sort-descending", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "spiral-shape", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "split-vertical", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "spray-paint", "keywords": [ "can", "color", "colors", "design", "paint", "painting", "spray" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "square-brackets-circle", "keywords": [ "interface", "math", "brackets", "sign", "mathematics" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "square-cap", "keywords": [], "content": "\n\n\n" }, { "name": "square-clock", "keywords": [ "clock", "loading", "frame", "measure", "time", "circle" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "square-root-x-circle", "keywords": [ "interface", "math", "square", "root", "sign", "mathematics" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "star-1", "keywords": [ "reward", "rating", "rate", "social", "star", "media", "favorite", "like", "stars" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "star-2", "keywords": [ "reward", "rating", "rate", "social", "star", "media", "favorite", "like", "stars", "spark" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "star-badge", "keywords": [ "ribbon", "reward", "like", "social", "rating", "media" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "straight-cap", "keywords": [], "content": "\n\n\n" }, { "name": "subtract-1", "keywords": [ "button", "delete", "buttons", "subtract", "horizontal", "remove", "line", "add", "mathematics", "math", "minus" ], "content": "\n\n\n" }, { "name": "subtract-circle", "keywords": [ "delete", "add", "circle", "subtract", "button", "buttons", "remove", "mathematics", "math", "minus" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "subtract-square", "keywords": [ "subtract", "buttons", "remove", "add", "button", "square", "delete", "mathematics", "math", "minus" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "sun-cloud", "keywords": [ "cloud", "meteorology", "cloudy", "partly", "sunny", "weather" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "synchronize-disable", "keywords": [ "arrows", "loading", "load", "sync", "synchronize", "arrow", "reload" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "synchronize-warning", "keywords": [ "arrow", "fail", "notification", "sync", "warning", "failure", "synchronize", "error" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "table-lamp-1", "keywords": [ "lighting", "light", "incandescent", "bulb", "lights", "table", "lamp" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "tag", "keywords": [ "tags", "bookmark", "favorite" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "text-flow-rows", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "text-square", "keywords": [ "text", "options", "formatting", "format", "square", "color", "border", "fill" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "text-style", "keywords": [ "text", "style", "formatting", "format" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "thermometer", "keywords": [ "temperature", "thermometer", "weather", "level", "meter", "mercury", "measure" ], "content": "\n\n\n" }, { "name": "trending-content", "keywords": [ "lit", "flame", "torch", "trending" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "trophy", "keywords": [ "reward", "rating", "trophy", "social", "award", "media" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "two-finger-drag-hotizontal", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "two-finger-tap", "keywords": [], "content": "\n\n\n" }, { "name": "underline-text-1", "keywords": [ "text", "underline", "formatting", "format" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "upload-box-1", "keywords": [ "arrow", "box", "download", "internet", "network", "server", "up", "upload" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "upload-circle", "keywords": [ "arrow", "circle", "download", "internet", "network", "server", "up", "upload" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "upload-computer", "keywords": [ "action", "actions", "computer", "desktop", "device", "display", "monitor", "screen", "upload" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "upload-file", "keywords": [], "content": "\n\n\n" }, { "name": "user-add-plus", "keywords": [ "actions", "add", "close", "geometric", "human", "person", "plus", "single", "up", "user" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "user-check-validate", "keywords": [ "actions", "close", "checkmark", "check", "geometric", "human", "person", "single", "success", "up", "user" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "user-circle-single", "keywords": [ "circle", "geometric", "human", "person", "single", "user" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "user-identifier-card", "keywords": [], "content": "\n\n\n" }, { "name": "user-multiple-circle", "keywords": [ "close", "geometric", "human", "multiple", "person", "up", "user", "circle" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "user-multiple-group", "keywords": [ "close", "geometric", "human", "multiple", "person", "up", "user" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "user-profile-focus", "keywords": [ "close", "geometric", "human", "person", "profile", "focus", "user" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "user-protection-2", "keywords": [ "shield", "secure", "security", "profile", "person" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "user-remove-subtract", "keywords": [ "actions", "remove", "close", "geometric", "human", "person", "minus", "single", "up", "user" ], "content": "\n\n\n" }, { "name": "user-single-neutral-male", "keywords": [ "close", "geometric", "human", "person", "single", "up", "user", "male" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "user-sync-online-in-person", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "vertical-slider-square", "keywords": [ "adjustment", "adjust", "controls", "fader", "vertical", "settings", "slider", "square" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "video-swap-camera", "keywords": [], "content": "\n\n\n" }, { "name": "visible", "keywords": [ "eye", "eyeball", "open", "view" ], "content": "\n\n\n" }, { "name": "voice-scan-2", "keywords": [ "identification", "secure", "id", "soundwave", "sound", "voice", "brackets", "security" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "waning-cresent-moon", "keywords": [ "night", "new", "moon", "crescent", "weather", "time", "waning" ], "content": "\n\n\n" }, { "name": "warning-octagon", "keywords": [ "frame", "alert", "warning", "octagon", "exclamation", "caution" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "warning-triangle", "keywords": [ "frame", "alert", "warning", "triangle", "exclamation", "caution" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "mail": [ { "name": "chat-bubble-oval", "keywords": [ "messages", "message", "bubble", "chat", "oval" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chat-bubble-oval-notification", "keywords": [ "messages", "message", "bubble", "chat", "oval", "notify", "ping" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chat-bubble-oval-smiley-1", "keywords": [ "messages", "message", "bubble", "chat", "oval", "smiley", "smile" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chat-bubble-oval-smiley-2", "keywords": [ "messages", "message", "bubble", "chat", "oval", "smiley", "smile" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chat-bubble-square-block", "keywords": [ "messages", "message", "bubble", "chat", "square", "block" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chat-bubble-square-question", "keywords": [ "bubble", "square", "messages", "notification", "chat", "message", "question", "help" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chat-bubble-square-warning", "keywords": [ "bubble", "square", "messages", "notification", "chat", "message", "warning", "alert" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chat-bubble-square-write", "keywords": [ "messages", "message", "bubble", "chat", "square", "write", "review", "pen", "pencil", "compose" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chat-bubble-text-square", "keywords": [ "messages", "message", "bubble", "text", "square", "chat" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chat-bubble-typing-oval", "keywords": [ "messages", "message", "bubble", "typing", "chat" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chat-two-bubbles-oval", "keywords": [ "messages", "message", "bubble", "chat", "oval", "conversation" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "discussion-converstion-reply", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "happy-face", "keywords": [ "smiley", "chat", "message", "smile", "emoji", "face", "satisfied" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "inbox-block", "keywords": [ "mail", "envelope", "email", "message", "block", "spam", "remove" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "inbox-favorite", "keywords": [ "mail", "envelope", "email", "message", "star", "favorite", "important", "bookmark" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "inbox-favorite-heart", "keywords": [ "mail", "envelope", "email", "message", "heart", "favorite", "like", "love", "important", "bookmark" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "inbox-lock", "keywords": [ "mail", "envelope", "email", "message", "secure", "password", "lock", "encryption" ], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "inbox-tray-1", "keywords": [ "mail", "email", "outbox", "drawer", "empty", "open", "inbox", "arrow", "down" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "inbox-tray-2", "keywords": [ "mail", "email", "outbox", "drawer", "empty", "open", "inbox", "arrow", "up" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "mail-incoming", "keywords": [ "inbox", "envelope", "email", "message", "down", "arrow", "inbox" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "mail-search", "keywords": [ "inbox", "envelope", "email", "message", "search" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "mail-send-email-message", "keywords": [ "send", "email", "paper", "airplane", "deliver" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "mail-send-envelope", "keywords": [ "envelope", "email", "message", "unopened", "sealed", "close" ], "content": "\n\n\n" }, { "name": "mail-send-reply-all", "keywords": [ "email", "message", "reply", "all", "actions", "action", "arrow" ], "content": "\n\n\n" }, { "name": "sad-face", "keywords": [ "smiley", "chat", "message", "emoji", "sad", "face", "unsatisfied" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "send-email", "keywords": [ "mail", "send", "email", "paper", "airplane" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "sign-at", "keywords": [ "mail", "email", "at", "sign", "read", "address" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "sign-hashtag", "keywords": [ "mail", "sharp", "sign", "hashtag", "tag" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-angry", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-cool", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-crying-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-cute", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-drool", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-emoji-kiss-nervous", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-emoji-terrified", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-grumpy", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-happy", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-in-love", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-kiss", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-laughing-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "map_travel": [ { "name": "airplane", "keywords": [ "travel", "plane", "adventure", "airplane", "transportation" ], "content": "\n\n\n" }, { "name": "airport-plane-transit", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "airport-plane", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "airport-security", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "anchor", "keywords": [ "anchor", "marina", "harbor", "port", "travel", "places" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "baggage", "keywords": [ "check", "baggage", "travel", "adventure", "luggage", "bag", "checked", "airport" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "beach", "keywords": [ "island", "waves", "outdoor", "recreation", "tree", "beach", "palm", "wave", "water", "travel", "places" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bicycle-bike", "keywords": [], "content": "\n\n\n" }, { "name": "braille-blind", "keywords": [ "disability", "braille", "blind" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bus", "keywords": [ "transportation", "travel", "bus", "transit", "transport", "motorcoach", "public" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "camping-tent", "keywords": [ "outdoor", "recreation", "camping", "tent", "teepee", "tipi", "travel", "places" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cane", "keywords": [ "disability", "cane" ], "content": "\n\n\n" }, { "name": "capitol", "keywords": [ "capitol", "travel", "places" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "car-battery-charging", "keywords": [], "content": "\n\n\n" }, { "name": "car-taxi-1", "keywords": [ "transportation", "travel", "taxi", "transport", "cab", "car" ], "content": "\n\n\n\n\n" }, { "name": "city-hall", "keywords": [ "city", "hall", "travel", "places" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "compass-navigator", "keywords": [], "content": "\n\n\n" }, { "name": "crutch", "keywords": [ "disability", "crutch" ], "content": "\n\n\n" }, { "name": "dangerous-zone-sign", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "earth-1", "keywords": [ "planet", "earth", "globe", "world" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "earth-airplane", "keywords": [ "travel", "plane", "trip", "airplane", "international", "adventure", "globe", "world", "airport" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "emergency-exit", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fire-alarm-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fire-extinguisher-sign", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "gas-station-fuel-petroleum", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hearing-deaf-1", "keywords": [ "disability", "hearing", "deaf" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hearing-deaf-2", "keywords": [ "disability", "hearing", "deaf" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "high-speed-train-front", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hot-spring", "keywords": [ "relax", "location", "outdoor", "recreation", "spa", "travel", "places" ], "content": "\n\n\n" }, { "name": "hotel-air-conditioner", "keywords": [ "heating", "ac", "air", "hvac", "cool", "cooling", "cold", "hot", "conditioning", "hotel" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hotel-bed-2", "keywords": [ "bed", "double", "bedroom", "bedrooms", "queen", "king", "full", "hotel", "hotel" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hotel-laundry", "keywords": [ "laundry", "machine", "hotel" ], "content": "\n\n\n" }, { "name": "hotel-one-star", "keywords": [ "one", "star", "reviews", "review", "rating", "hotel", "star" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hotel-shower-head", "keywords": [ "bathe", "bath", "bathroom", "shower", "water", "head", "hotel" ], "content": "\n\n\n" }, { "name": "hotel-two-star", "keywords": [ "two", "stars", "reviews", "review", "rating", "hotel", "star" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "information-desk-customer", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "information-desk", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "iron", "keywords": [ "laundry", "iron", "heat", "hotel" ], "content": "\n\n\n" }, { "name": "ladder", "keywords": [ "business", "product", "metaphor", "ladder" ], "content": "\n\n\n" }, { "name": "lift", "keywords": [ "arrow", "up", "human", "down", "person", "user", "lift", "elevator" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "lift-disability", "keywords": [ "arrow", "up", "human", "down", "person", "user", "lift", "elevator", "disability", "wheelchair", "accessible" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "location-compass-1", "keywords": [ "arrow", "compass", "location", "gps", "map", "maps", "point" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "location-pin-3", "keywords": [ "navigation", "map", "maps", "pin", "gps", "location" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "location-pin-disabled", "keywords": [ "navigation", "map", "maps", "pin", "gps", "location", "disabled", "off" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "location-target-1", "keywords": [ "navigation", "location", "map", "services", "maps", "gps", "target" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "lost-and-found", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "man-symbol", "keywords": [ "geometric", "gender", "boy", "person", "male", "human", "user" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "map-fold", "keywords": [ "navigation", "map", "maps", "gps", "travel", "fold" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "navigation-arrow-off", "keywords": [ "compass", "arrow", "map", "bearing", "navigation", "maps", "heading", "gps", "off", "disable" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "navigation-arrow-on", "keywords": [ "compass", "arrow", "map", "bearing", "navigation", "maps", "heading", "gps", "off", "disable" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "parking-sign", "keywords": [ "discount", "coupon", "parking", "price", "prices", "hotel" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "parliament", "keywords": [ "travel", "places", "parliament" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "passport", "keywords": [ "travel", "book", "id", "adventure", "visa", "airport" ], "content": "\n\n\n" }, { "name": "pet-paw", "keywords": [ "paw", "foot", "animals", "pets", "footprint", "track", "hotel" ], "content": "\n\n\n" }, { "name": "pets-allowed", "keywords": [ "travel", "wayfinder", "pets", "allowed" ], "content": "\n\n\n" }, { "name": "pool-ladder", "keywords": [ "pool", "stairs", "swim", "swimming", "water", "ladder", "hotel" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "rock-slide", "keywords": [ "hill", "cliff", "sign", "danger", "stone" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "sail-ship", "keywords": [ "travel", "boat", "transportation", "transport", "ocean", "ship", "sea", "water" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "school-bus-side", "keywords": [], "content": "\n\n\n" }, { "name": "smoke-detector", "keywords": [ "smoke", "alert", "fire", "signal" ], "content": "\n\n\n" }, { "name": "smoking-area", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "snorkle", "keywords": [ "diving", "scuba", "outdoor", "recreation", "ocean", "mask", "water", "sea", "snorkle", "travel", "places" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "steering-wheel", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "street-road", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "street-sign", "keywords": [ "crossroad", "street", "sign", "metaphor", "directions", "travel", "places" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "take-off", "keywords": [ "travel", "plane", "adventure", "airplane", "take", "off", "airport" ], "content": "\n\n\n" }, { "name": "toilet-man", "keywords": [ "travel", "wayfinder", "toilet", "man" ], "content": "\n\n\n" }, { "name": "toilet-sign-man-woman-2", "keywords": [ "toilet", "sign", "restroom", "bathroom", "user", "human", "person" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "toilet-women", "keywords": [ "travel", "wayfinder", "toilet", "women" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "traffic-cone", "keywords": [ "street", "sign", "traffic", "cone", "road" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "triangle-flag", "keywords": [ "navigation", "map", "maps", "flag", "gps", "location", "destination", "goal" ], "content": "\n\n\n" }, { "name": "wheelchair-1", "keywords": [ "person", "access", "wheelchair", "accomodation", "human", "disability", "disabled", "user" ], "content": "\n\n\n" }, { "name": "woman-symbol", "keywords": [ "geometric", "gender", "female", "person", "human", "user" ], "content": "\n\n\n" } ], "money_shopping": [ { "name": "annoncement-megaphone", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "backpack", "keywords": [ "bag", "backpack", "school", "baggage", "cloth", "clothing", "accessories" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bag", "keywords": [ "bag", "payment", "cash", "money", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bag-dollar", "keywords": [ "bag", "payment", "cash", "money", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bag-pound", "keywords": [ "bag", "payment", "cash", "money", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bag-rupee", "keywords": [ "bag", "payment", "cash", "money", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bag-suitcase-1", "keywords": [ "product", "business", "briefcase" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bag-suitcase-2", "keywords": [ "product", "business", "briefcase" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bag-yen", "keywords": [ "bag", "payment", "cash", "money", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ball", "keywords": [ "sports", "ball", "sport", "basketball", "shopping", "catergories" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bank", "keywords": [ "institution", "saving", "bank", "payment", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "beanie", "keywords": [ "beanie", "winter", "hat", "warm", "cloth", "clothing", "wearable", "accessories" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bill-1", "keywords": [ "billing", "bills", "payment", "finance", "cash", "currency", "money", "accounting" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bill-2", "keywords": [ "currency", "billing", "payment", "finance", "cash", "bill", "money", "accounting" ], "content": "\n\n\n" }, { "name": "bill-4", "keywords": [ "accounting", "billing", "payment", "finance", "cash", "currency", "money", "bill", "dollar", "stack" ], "content": "\n\n\n" }, { "name": "bill-cashless", "keywords": [ "currency", "billing", "payment", "finance", "no", "cash", "bill", "money", "accounting", "cashless" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "binance-circle", "keywords": [ "crypto", "circle", "payment", "blockchain", "finance", "binance", "currency" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bitcoin", "keywords": [ "crypto", "circle", "payment", "blokchain", "finance", "bitcoin", "money", "currency" ], "content": "\n\n\n" }, { "name": "bow-tie", "keywords": [ "bow", "tie", "dress", "gentleman", "cloth", "clothing", "accessories" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "briefcase-dollar", "keywords": [ "briefcase", "payment", "cash", "money", "finance", "baggage", "bag" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "building-2", "keywords": [ "real", "home", "tower", "building", "house", "estate" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "business-card", "keywords": [ "name", "card", "business", "information", "money", "payment" ], "content": "\n\n\n" }, { "name": "business-handshake", "keywords": [ "deal", "contract", "business", "money", "payment", "agreement" ], "content": "\n\n\n" }, { "name": "business-idea-money", "keywords": [], "content": "\n\n\n" }, { "name": "business-profession-home-office", "keywords": [ "workspace", "home", "office", "work", "business", "remote", "working" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "business-progress-bar-2", "keywords": [ "business", "production", "arrow", "workflow", "money", "flag", "timeline" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "business-user-curriculum", "keywords": [], "content": "\n\n\n" }, { "name": "calculator-1", "keywords": [ "shop", "shopping", "pay", "payment", "store", "cash", "calculate", "math" ], "content": "\n\n\n" }, { "name": "calculator-2", "keywords": [ "shop", "shopping", "pay", "payment", "store", "cash", "calculate", "math", "sign" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cane", "keywords": [ "walking", "stick", "cane", "accessories", "gentleman", "accessories" ], "content": "\n\n\n" }, { "name": "chair", "keywords": [ "chair", "business", "product", "comfort", "decoration", "sit", "furniture" ], "content": "\n\n\n" }, { "name": "closet", "keywords": [ "closet", "dressing", "dresser", "product", "decoration", "cloth", "clothing", "cabinet", "furniture" ], "content": "\n\n\n" }, { "name": "coin-share", "keywords": [ "payment", "cash", "money", "finance", "receive", "give", "coin", "hand" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "coins-stack", "keywords": [ "accounting", "billing", "payment", "stack", "cash", "coins", "currency", "money", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "credit-card-1", "keywords": [ "credit", "pay", "payment", "debit", "card", "finance", "plastic", "money", "atm" ], "content": "\n\n\n" }, { "name": "credit-card-2", "keywords": [ "deposit", "payment", "finance", "atm", "withdraw", "atm" ], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "diamond-2", "keywords": [ "diamond", "money", "payment", "finance", "wealth", "jewelry" ], "content": "\n\n\n" }, { "name": "discount-percent-badge", "keywords": [ "shop", "shops", "stores", "discount", "coupon" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "discount-percent-circle", "keywords": [ "store", "shop", "shops", "stores", "discount", "coupon" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "discount-percent-coupon", "keywords": [ "shop", "shops", "stores", "discount", "coupon", "voucher" ], "content": "\n\n\n" }, { "name": "discount-percent-cutout", "keywords": [ "store", "shop", "shops", "stores", "discount", "coupon" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "discount-percent-fire", "keywords": [ "shop", "shops", "stores", "discount", "coupon", "hot", "trending" ], "content": "\n\n\n" }, { "name": "dollar-coin", "keywords": [ "accounting", "billing", "payment", "cash", "coin", "currency", "money", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dollar-coin-1", "keywords": [ "accounting", "billing", "payment", "cash", "coin", "currency", "money", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dressing-table", "keywords": [ "makeup", "dressing", "table", "mirror", "cabinet", "product", "decoration", "furniture" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ethereum", "keywords": [ "crypto", "circle", "payment", "blokchain", "finance", "ethereum", "eth", "currency" ], "content": "\n\n\n" }, { "name": "ethereum-circle", "keywords": [ "crypto", "circle", "payment", "blockchain", "finance", "eth", "currency" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "euro", "keywords": [ "exchange", "payment", "euro", "forex", "finance", "foreign", "currency" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "gift", "keywords": [ "reward", "box", "social", "present", "gift", "media", "rating", "bow" ], "content": "\n\n\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "gift-2", "keywords": [ "reward", "box", "social", "present", "gift", "media", "rating", "bow" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "gold", "keywords": [ "gold", "money", "payment", "bars", "finance", "wealth", "bullion", "jewelry" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "graph", "keywords": [ "analytics", "business", "product", "graph", "data", "chart", "analysis" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "graph-arrow-decrease", "keywords": [ "down", "stats", "graph", "descend", "right", "arrow" ], "content": "\n\n\n" }, { "name": "graph-arrow-increase", "keywords": [ "ascend", "growth", "up", "arrow", "stats", "graph", "right", "grow" ], "content": "\n\n\n" }, { "name": "graph-bar-decrease", "keywords": [ "arrow", "product", "performance", "down", "decrease", "graph", "business", "chart" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "graph-bar-increase", "keywords": [ "up", "product", "performance", "increase", "arrow", "graph", "business", "chart" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "graph-dot", "keywords": [ "product", "data", "bars", "analysis", "analytics", "graph", "business", "chart", "dot" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "investment-selection", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "justice-hammer", "keywords": [ "hammer", "work", "mallet", "office", "company", "gavel", "justice", "judge", "arbitration", "court" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "justice-scale-1", "keywords": [ "office", "work", "scale", "justice", "company", "arbitration", "balance", "court" ], "content": "\n\n\n" }, { "name": "justice-scale-2", "keywords": [ "office", "work", "scale", "justice", "unequal", "company", "arbitration", "unbalance", "court" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "lipstick", "keywords": [ "fashion", "beauty", "lip", "lipstick", "makeup", "shopping" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "make-up-brush", "keywords": [ "fashion", "beauty", "make", "up", "brush" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "moustache", "keywords": [ "fashion", "beauty", "moustache", "grooming" ], "content": "\n\n\n" }, { "name": "mouth-lip", "keywords": [ "fashion", "beauty", "mouth", "lip" ], "content": "\n\n\n" }, { "name": "necklace", "keywords": [ "diamond", "money", "payment", "finance", "wealth", "accessory", "necklace", "jewelry" ], "content": "\n\n\n" }, { "name": "necktie", "keywords": [ "necktie", "businessman", "business", "cloth", "clothing", "gentleman", "accessories" ], "content": "\n\n\n" }, { "name": "payment-10", "keywords": [ "deposit", "payment", "finance", "atm", "transfer", "dollar" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "payment-cash-out-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "pie-chart", "keywords": [ "product", "data", "analysis", "analytics", "pie", "business", "chart" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "piggy-bank", "keywords": [ "institution", "saving", "bank", "payment", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "polka-dot-circle", "keywords": [ "crypto", "circle", "payment", "blockchain", "finance", "polka", "dot", "currency" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "production-belt", "keywords": [ "production", "produce", "box", "belt", "factory", "product", "package", "business" ], "content": "\n\n\n" }, { "name": "qr-code", "keywords": [ "codes", "tags", "code", "qr" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "receipt", "keywords": [ "shop", "shopping", "pay", "payment", "store", "cash", "bill", "receipt" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "receipt-add", "keywords": [ "shop", "shopping", "pay", "payment", "store", "cash", "bill", "receipt", "add", "plus", "new" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "receipt-check", "keywords": [ "shop", "shopping", "pay", "payment", "store", "cash", "bill", "receipt", "check", "confirm" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "receipt-subtract", "keywords": [ "shop", "shopping", "pay", "payment", "store", "cash", "bill", "receipt", "subtract", "minus", "remove" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "safe-vault", "keywords": [ "saving", "combo", "payment", "safe", "combination", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "scanner-3", "keywords": [ "payment", "electronic", "cash", "dollar", "codes", "tags", "upc", "barcode", "qr" ], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "scanner-bar-code", "keywords": [ "codes", "tags", "upc", "barcode" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shelf", "keywords": [ "shelf", "drawer", "cabinet", "prodcut", "decoration", "furniture" ], "content": "\n\n\n" }, { "name": "shopping-bag-hand-bag-2", "keywords": [ "shopping", "bag", "purse", "goods", "item", "products" ], "content": "\n\n\n" }, { "name": "shopping-basket-1", "keywords": [ "shopping", "basket" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shopping-basket-2", "keywords": [ "shopping", "basket" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shopping-cart-1", "keywords": [ "shopping", "cart", "checkout" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shopping-cart-2", "keywords": [ "shopping", "cart", "checkout" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shopping-cart-3", "keywords": [ "shopping", "cart", "checkout" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shopping-cart-add", "keywords": [ "shopping", "cart", "checkout", "add", "plus", "new" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shopping-cart-check", "keywords": [ "shopping", "cart", "checkout", "check", "confirm" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shopping-cart-subtract", "keywords": [ "shopping", "cart", "checkout", "subtract", "minus", "remove" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "signage-3", "keywords": [ "street", "sandwich", "shops", "shop", "stores", "board", "sign", "store" ], "content": "\n\n\n" }, { "name": "signage-4", "keywords": [ "street", "billboard", "shops", "shop", "stores", "board", "sign", "ads", "banner" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "startup", "keywords": [ "shop", "rocket", "launch", "startup" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "stock", "keywords": [ "price", "stock", "wallstreet", "dollar", "money", "currency", "fluctuate", "candlestick", "business" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "store-1", "keywords": [ "store", "shop", "shops", "stores" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "store-2", "keywords": [ "store", "shop", "shops", "stores" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "store-computer", "keywords": [ "store", "shop", "shops", "stores", "online", "computer", "website", "desktop", "app" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "subscription-cashflow", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "tag", "keywords": [ "codes", "tags", "tag", "product", "label" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "tall-hat", "keywords": [ "tall", "hat", "cloth", "clothing", "wearable", "magician", "gentleman", "accessories" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "target", "keywords": [ "shop", "bullseye", "arrow", "target" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "target-3", "keywords": [ "shop", "bullseye", "shooting", "target" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "wallet", "keywords": [ "money", "payment", "finance", "wallet" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "wallet-purse", "keywords": [ "money", "payment", "finance", "wallet", "purse" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "xrp-circle", "keywords": [ "crypto", "circle", "payment", "blockchain", "finance", "xrp", "currency" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "yuan", "keywords": [ "exchange", "payment", "forex", "finance", "yuan", "foreign", "currency" ], "content": "\n\n\n" }, { "name": "yuan-circle", "keywords": [ "exchange", "payment", "forex", "finance", "yuan", "foreign", "currency" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "nature_ecology": [ { "name": "affordable-and-clean-energy", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "alien", "keywords": [ "science", "extraterristerial", "life", "form", "space", "universe", "head", "astronomy" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bone", "keywords": [ "nature", "pet", "dog", "bone", "food", "snack" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cat-1", "keywords": [ "nature", "head", "cat", "pet", "animals", "felyne" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "circle-flask", "keywords": [ "science", "experiment", "lab", "flask", "chemistry", "solution" ], "content": "\n\n\n" }, { "name": "clean-water-and-sanitation", "keywords": [], "content": "\n\n\n" }, { "name": "comet", "keywords": [ "nature", "meteor", "fall", "space", "object", "danger" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "decent-work-and-economic-growth", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dna", "keywords": [ "science", "biology", "experiment", "lab", "science" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "erlenmeyer-flask", "keywords": [ "science", "experiment", "lab", "flask", "chemistry", "solution" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "flower", "keywords": [ "nature", "plant", "tree", "flower", "petals", "bloom" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "galaxy-1", "keywords": [ "science", "space", "universe", "astronomy" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "galaxy-2", "keywords": [ "science", "space", "universe", "astronomy" ], "content": "\n\n\n" }, { "name": "gender-equality", "keywords": [], "content": "\n\n\n" }, { "name": "good-health-and-well-being", "keywords": [], "content": "\n\n\n" }, { "name": "industry-innovation-and-infrastructure", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "leaf", "keywords": [ "nature", "environment", "leaf", "ecology", "plant", "plants", "eco" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "log", "keywords": [ "nature", "tree", "plant", "circle", "round", "log" ], "content": "\n\n\n" }, { "name": "no-poverty", "keywords": [], "content": "\n\n\n" }, { "name": "octopus", "keywords": [ "nature", "sealife", "animals" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "planet", "keywords": [ "science", "solar", "system", "ring", "planet", "saturn", "space", "astronomy", "astronomy" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "potted-flower-tulip", "keywords": [ "nature", "flower", "plant", "tree", "pot" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "quality-education", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "rainbow", "keywords": [ "nature", "arch", "rain", "colorful", "rainbow", "curve", "half", "circle" ], "content": "\n\n\n" }, { "name": "recycle-1", "keywords": [ "nature", "sign", "environment", "protect", "save", "arrows" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "reduced-inequalities", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "rose", "keywords": [ "nature", "flower", "rose", "plant", "tree" ], "content": "\n\n\n" }, { "name": "shell", "keywords": [ "nature", "sealife", "animals" ], "content": "\n\n\n" }, { "name": "shovel-rake", "keywords": [ "nature", "crops", "plants" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "sprout", "keywords": [], "content": "\n\n\n" }, { "name": "telescope", "keywords": [ "science", "experiment", "star", "gazing", "sky", "night", "space", "universe", "astronomy", "astronomy" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "test-tube", "keywords": [ "science", "experiment", "lab", "chemistry", "test", "tube", "solution" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "tidal-wave", "keywords": [ "nature", "ocean", "wave" ], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "tree-2", "keywords": [ "nature", "tree", "plant", "circle", "round", "park" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "tree-3", "keywords": [ "nature", "tree", "plant", "cloud", "shape", "park" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "volcano", "keywords": [ "nature", "eruption", "erupt", "mountain", "volcano", "lava", "magma", "explosion" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "windmill", "keywords": [], "content": "\n\n\n" }, { "name": "zero-hunger", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "phone": [ { "name": "airplane-disabled", "keywords": [ "server", "plane", "airplane", "disabled", "off", "wireless", "mode", "internet", "network" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "airplane-enabled", "keywords": [ "server", "plane", "airplane", "enabled", "on", "wireless", "mode", "internet", "network" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "back-camera-1", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "camera", "lenses" ], "content": "\n\n\n" }, { "name": "call-hang-up", "keywords": [ "phone", "telephone", "mobile", "device", "smartphone", "call", "hang", "up" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cellular-network-4g", "keywords": [], "content": "\n\n\n" }, { "name": "cellular-network-5g", "keywords": [], "content": "\n\n\n" }, { "name": "cellular-network-lte", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "contact-phonebook-2", "keywords": [], "content": "\n\n\n" }, { "name": "hang-up-1", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "telephone", "hang", "up" ], "content": "\n\n\n" }, { "name": "hang-up-2", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "telephone", "hang", "up" ], "content": "\n\n\n" }, { "name": "incoming-call", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "ringing", "incoming", "call" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "missed-call", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "missed", "call" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "notification-alarm-2", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "alert", "bell", "alarm" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "notification-application-1", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "alert", "box" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "notification-application-2", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "alert", "box" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "notification-message-alert", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "alert", "message", "text" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "outgoing-call", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "ringing", "outgoing", "call" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "phone", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "phone-mobile-phone", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone" ], "content": "\n\n\n" }, { "name": "phone-qr", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "qr", "code", "scan" ], "content": "\n\n\n" }, { "name": "phone-ringing-1", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "ringing", "incoming", "call" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "phone-ringing-2", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "ringing" ], "content": "\n\n\n" }, { "name": "signal-full", "keywords": [ "phone", "mobile", "device", "signal", "wireless", "smartphone", "iphone", "bar", "bars", "full", "android" ], "content": "\n\n\n" }, { "name": "signal-low", "keywords": [ "phone", "mobile", "device", "signal", "wireless", "smartphone", "iphone", "bar", "low", "bars", "android" ], "content": "\n\n\n" }, { "name": "signal-medium", "keywords": [ "smartphone", "phone", "mobile", "device", "iphone", "signal", "medium", "wireless", "bar", "bars", "android" ], "content": "\n\n\n" }, { "name": "signal-none", "keywords": [ "phone", "mobile", "device", "signal", "wireless", "smartphone", "iphone", "bar", "bars", "no", "zero", "android" ], "content": "\n\n\n" } ], "programing": [ { "name": "application-add", "keywords": [ "application", "new", "add", "square" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bracket", "keywords": [ "code", "angle", "programming", "file", "bracket" ], "content": "\n\n\n" }, { "name": "browser-add", "keywords": [ "app", "code", "apps", "add", "window", "plus" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "browser-block", "keywords": [ "block", "access", "denied", "window", "browser", "privacy", "remove" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "browser-build", "keywords": [ "build", "website", "development", "window", "code", "web", "backend", "browser", "dev" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "browser-check", "keywords": [ "checkmark", "pass", "window", "app", "code", "success", "check", "apps" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "browser-delete", "keywords": [ "app", "code", "apps", "fail", "delete", "window", "remove", "cross" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "browser-hash", "keywords": [ "window", "hash", "code", "internet", "language", "browser", "web", "tag" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "browser-lock", "keywords": [ "secure", "password", "window", "browser", "lock", "security", "login", "encryption" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "browser-multiple-window", "keywords": [ "app", "code", "apps", "two", "window", "cascade" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "browser-remove", "keywords": [ "app", "code", "apps", "subtract", "window", "minus" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "browser-website-1", "keywords": [ "app", "code", "apps", "window", "website", "web" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bug", "keywords": [ "code", "bug", "security", "programming", "secure", "computer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bug-antivirus-debugging", "keywords": [ "code", "bug", "security", "programming", "secure", "computer", "antivirus", "block", "protection", "malware", "debugging" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bug-antivirus-shield", "keywords": [ "code", "bug", "security", "programming", "secure", "computer", "antivirus", "shield", "protection", "malware" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bug-virus-browser", "keywords": [ "bug", "browser", "file", "virus", "threat", "danger", "internet" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bug-virus-document", "keywords": [ "bug", "document", "file", "virus", "threat", "danger" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bug-virus-folder", "keywords": [ "bug", "document", "folder", "virus", "threat", "danger" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cloud-add", "keywords": [ "cloud", "network", "internet", "add", "server", "plus" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cloud-block", "keywords": [ "cloud", "network", "internet", "block", "server", "deny" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cloud-check", "keywords": [ "cloud", "network", "internet", "check", "server", "approve" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cloud-data-transfer", "keywords": [ "cloud", "data", "transfer", "internet", "server", "network" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cloud-refresh", "keywords": [ "cloud", "network", "internet", "server", "refresh" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cloud-share", "keywords": [ "cloud", "network", "internet", "server", "share" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cloud-warning", "keywords": [ "cloud", "network", "internet", "server", "warning", "alert" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cloud-wifi", "keywords": [ "cloud", "wifi", "internet", "server", "network" ], "content": "\n\n\n" }, { "name": "code-analysis", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "code-monitor-1", "keywords": [ "code", "tags", "angle", "bracket", "monitor" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "code-monitor-2", "keywords": [ "code", "tags", "angle", "image", "ui", "ux", "design" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "css-three", "keywords": [ "language", "three", "code", "programming", "html", "css" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "curly-brackets", "keywords": [], "content": "\n\n\n" }, { "name": "file-code-1", "keywords": [ "code", "files", "angle", "programming", "file", "bracket" ], "content": "\n\n\n" }, { "name": "incognito-mode", "keywords": [ "internet", "safe", "mode", "browser" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "insert-cloud-video", "keywords": [], "content": "\n\n\n" }, { "name": "markdown-circle-programming", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "markdown-document-programming", "keywords": [], "content": "\n\n\n" }, { "name": "module-puzzle-1", "keywords": [ "code", "puzzle", "module", "programming", "plugin", "piece" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "module-puzzle-3", "keywords": [ "code", "puzzle", "module", "programming", "plugin", "piece" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "module-three", "keywords": [ "code", "three", "module", "programming", "plugin" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "rss-square", "keywords": [ "wireless", "rss", "feed", "square", "transmit", "broadcast" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "shipping": [ { "name": "box-sign", "keywords": [ "box", "package", "label", "delivery", "shipment", "shipping", "this", "way", "up", "arrow", "sign", "sticker" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "container", "keywords": [ "box", "package", "label", "delivery", "shipment", "shipping", "container" ], "content": "\n\n\n" }, { "name": "fragile", "keywords": [ "fragile", "shipping", "glass", "delivery", "wine", "crack", "shipment", "sign", "sticker" ], "content": "\n\n\n" }, { "name": "parachute-drop", "keywords": [ "package", "box", "fulfillment", "cart", "warehouse", "shipping", "delivery", "drop", "parachute" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shipment-add", "keywords": [ "shipping", "parcel", "shipment", "add" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shipment-check", "keywords": [ "shipping", "parcel", "shipment", "check", "approved" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shipment-download", "keywords": [ "shipping", "parcel", "shipment", "download" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shipment-remove", "keywords": [ "shipping", "parcel", "shipment", "remove", "subtract" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shipment-upload", "keywords": [ "shipping", "parcel", "shipment", "upload" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shipping-box-1", "keywords": [ "box", "package", "label", "delivery", "shipment", "shipping" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shipping-truck", "keywords": [ "truck", "shipping", "delivery", "transfer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "transfer-motorcycle", "keywords": [ "motorcycle", "shipping", "delivery", "courier", "transfer" ], "content": "\n\n\n" }, { "name": "transfer-van", "keywords": [ "van", "shipping", "delivery", "transfer" ], "content": "\n\n\n" }, { "name": "warehouse-1", "keywords": [ "delivery", "warehouse", "shipping", "fulfillment" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "work_education": [ { "name": "book-reading", "keywords": [ "book", "reading", "learning" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "class-lesson", "keywords": [ "class", "lesson", "education", "teacher" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "collaborations-idea", "keywords": [ "collaborations", "idea", "work" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "definition-search-book", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dictionary-language-book", "keywords": [], "content": "\n\n\n" }, { "name": "global-learning", "keywords": [ "global", "learning", "education" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "graduation-cap", "keywords": [ "graduation", "cap", "education" ], "content": "\n\n\n" }, { "name": "group-meeting-call", "keywords": [ "group", "meeting", "call", "work" ], "content": "\n\n\n" }, { "name": "office-building-1", "keywords": [ "office", "building", "work" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "office-worker", "keywords": [ "office", "worker", "human", "resources" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "search-dollar", "keywords": [ "search", "pay", "product", "currency", "query", "magnifying", "cash", "business", "money", "glass" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "strategy-tasks", "keywords": [ "strategy", "tasks", "work" ], "content": "\n\n\n" }, { "name": "task-list", "keywords": [ "task", "list", "work" ], "content": "\n\n\n" }, { "name": "workspace-desk", "keywords": [ "workspace", "desk", "work" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ] } \ No newline at end of file diff --git a/frontend/appflowy_flutter/build.yaml b/frontend/appflowy_flutter/build.yaml new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/frontend/appflowy_flutter/dart_dependency_validator.yaml b/frontend/appflowy_flutter/dart_dependency_validator.yaml new file mode 100644 index 0000000000000..cb1df68bb60c2 --- /dev/null +++ b/frontend/appflowy_flutter/dart_dependency_validator.yaml @@ -0,0 +1,12 @@ +# dart_dependency_validator.yaml + +allow_pins: true + +include: + - "lib/**" + +exclude: + - "packages/**" + +ignore: + - analyzer diff --git a/frontend/appflowy_flutter/integration_test/cloud/anon_user_continue_test.dart b/frontend/appflowy_flutter/integration_test/cloud/anon_user_continue_test.dart index 0c8b96fa20d92..00a02788dae3e 100644 --- a/frontend/appflowy_flutter/integration_test/cloud/anon_user_continue_test.dart +++ b/frontend/appflowy_flutter/integration_test/cloud/anon_user_continue_test.dart @@ -3,8 +3,6 @@ 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'; @@ -12,6 +10,7 @@ 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/account/account.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'; @@ -19,6 +18,7 @@ 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/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:intl/intl.dart'; @@ -42,7 +42,7 @@ void main() { await tester.tapAnonymousSignInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); - // reanme the name of the anon user + // rename the name of the anon user await tester.openSettings(); await tester.openSettingsPage(SettingsPage.account); await tester.pumpAndSettle(); @@ -51,12 +51,12 @@ void main() { // Scroll to sign-in await tester.scrollUntilVisible( - find.byType(SignInOutButton), + find.byType(AccountSignInOutButton), 100, scrollable: find.findSettingsScrollable(), ); - await tester.tapButton(find.byType(SignInOutButton)); + await tester.tapButton(find.byType(AccountSignInOutButton)); // sign up with Google await tester.tapGoogleLoginInButton(); @@ -68,7 +68,7 @@ void main() { // Scroll to sign-out await tester.scrollUntilVisible( - find.byType(SignInOutButton), + find.byType(AccountSignInOutButton), 100, scrollable: find.findSettingsScrollable(), ); @@ -85,7 +85,7 @@ void main() { await tester.openSettings(); await tester.openSettingsPage(SettingsPage.account); final userNameInput = - tester.widget(find.byType(UserProfileSetting)) as UserProfileSetting; + tester.widget(find.byType(AccountUserProfile)) as AccountUserProfile; 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 index 5aa3a02d83fce..dacc900103d87 100644 --- a/frontend/appflowy_flutter/integration_test/cloud/appflowy_cloud_auth_test.dart +++ b/frontend/appflowy_flutter/integration_test/cloud/appflowy_cloud_auth_test.dart @@ -6,6 +6,7 @@ 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/account/account.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'; @@ -41,17 +42,17 @@ void main() { // Scroll to sign-out await tester.scrollUntilVisible( - find.byType(SignInOutButton), + find.byType(AccountSignInOutButton), 100, scrollable: find.findSettingsScrollable(), ); - await tester.tapButton(find.byType(SignInOutButton)); + await tester.tapButton(find.byType(AccountSignInOutButton)); - tester.expectToSeeText(LocaleKeys.button_confirm.tr()); - await tester.tapButtonWithName(LocaleKeys.button_confirm.tr()); + 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: 1)); + await tester.pumpAndSettle(const Duration(seconds: 5)); tester.expectToSeeGoogleLoginButton(); }); @@ -67,11 +68,11 @@ void main() { // Scroll to sign-in await tester.scrollUntilVisible( - find.byType(SignInOutButton), + find.byType(AccountSignInOutButton), 100, scrollable: find.findSettingsScrollable(), ); - await tester.tapButton(find.byType(SignInOutButton)); + await tester.tapButton(find.byType(AccountSignInOutButton)); tester.expectToSeeGoogleLoginButton(); }); diff --git a/frontend/appflowy_flutter/integration_test/cloud/supabase_auth_test.dart b/frontend/appflowy_flutter/integration_test/cloud/supabase_auth_test.dart index 15c9c3c3479f6..71cbc1143175f 100644 --- a/frontend/appflowy_flutter/integration_test/cloud/supabase_auth_test.dart +++ b/frontend/appflowy_flutter/integration_test/cloud/supabase_auth_test.dart @@ -1,93 +1,93 @@ -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); - }); - }); -} +// 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 index 5791803a0eeac..253d533607404 100644 --- a/frontend/appflowy_flutter/integration_test/cloud/user_setting_sync_test.dart +++ b/frontend/appflowy_flutter/integration_test/cloud/user_setting_sync_test.dart @@ -2,8 +2,6 @@ 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'; @@ -11,6 +9,7 @@ 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/account/account_user_profile.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'; @@ -18,6 +17,7 @@ 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/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:path/path.dart' as p; @@ -47,13 +47,12 @@ void main() { await tester.openSettingsPage(SettingsPage.account); await tester.enterUserName(name); - await tester.tapEscButton(); + await tester.pumpAndSettle(const Duration(seconds: 6)); + await tester.logout(); - // 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, @@ -68,7 +67,7 @@ void main() { // Verify name final profileSetting = - tester.widget(find.byType(UserProfileSetting)) as UserProfileSetting; + tester.widget(find.byType(AccountUserProfile)) as AccountUserProfile; expect(profileSetting.name, name); }); 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 index 3fe48b5f6f70a..387c35adf2af6 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/board/board_field_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/board/board_field_test.dart @@ -22,7 +22,7 @@ void main() { const fieldName = "test change field"; await tester.createField( FieldType.RichText, - fieldName, + name: fieldName, layout: ViewLayoutPB.Board, ); await tester.tapButton(card1); 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 cb8338fbb6df1..28f50bf817f16 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); @@ -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); @@ -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_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_field_test.dart index cc1187da21b31..45d05207ff067 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,12 +1,13 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; import 'package:appflowy/plugins/database/widgets/field/type_option_editor/select/select_option.dart'; import 'package:appflowy/util/field_type_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.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'; @@ -56,11 +57,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 +82,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 +102,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'); @@ -126,26 +135,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 +151,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 +171,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 +191,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 +212,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(); @@ -292,11 +261,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); diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_media_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_media_test.dart new file mode 100644 index 0000000000000..ddbffa6ca743d --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_media_test.dart @@ -0,0 +1,96 @@ +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/database/widgets/cell_editor/media_cell_editor.dart'; +import 'package:appflowy/startup/startup.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', (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.tapButtonWithName( + LocaleKeys.document_plugins_file_fileUploadHint.tr(), + ); + await tester.pumpAndSettle(); + + // 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.tapButtonWithName( + LocaleKeys.document_plugins_file_fileUploadHint.tr(), + ); + await tester.pumpAndSettle(); + + // Expect two files + expect(find.byType(RenderMedia), findsNWidgets(2)); + + // Remove the temp files + await Future.wait([firstFile.delete(), secondFile.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 0934e7721be86..095d633b2dbac 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,3 +1,6 @@ +import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; +import 'package:appflowy/plugins/database/widgets/row/row_detail.dart'; +import 'package:appflowy/util/field_type_extension.dart'; import 'package:flutter/material.dart'; import 'package:appflowy/plugins/database/widgets/row/row_banner.dart'; @@ -121,15 +124,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)); } }); 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 f06a273fac989..0fb8cc90e84de 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'; @@ -25,6 +25,7 @@ void main() { 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 +52,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 730d776460640..457934dff4f5b 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,11 +1,10 @@ import 'dart:io'; -import 'package:flutter/services.dart'; - import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.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:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -166,6 +165,44 @@ void main() { }); }); + 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(); @@ -246,10 +283,6 @@ void main() { 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', - ); }, ); }, 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 index 239e7e09a8e23..018ed1b8d4db5 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner.dart @@ -12,10 +12,13 @@ import 'document_more_actions_test.dart' as document_more_actions_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_file_test.dart' as document_with_file_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_multi_image_block_test.dart' + as document_with_multi_image_block_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; @@ -38,6 +41,8 @@ void startTesting() { document_text_direction_test.main(); document_option_action_test.main(); 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(); } 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 f401cb1e0be12..eb07a2e7a84f5 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,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'; @@ -7,7 +6,6 @@ import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.d 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_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -22,7 +20,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,7 +48,7 @@ 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( @@ -66,7 +64,7 @@ void main() { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); - await insertReferenceDatabase(tester, ViewLayoutPB.Calendar); + await insertLinkedDatabase(tester, ViewLayoutPB.Calendar); // validate the referenced grid is inserted expect( @@ -129,7 +127,7 @@ void main() { } /// Insert a referenced database of [layout] into the document -Future insertReferenceDatabase( +Future insertLinkedDatabase( WidgetTester tester, ViewLayoutPB layout, ) async { @@ -150,7 +148,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 +174,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_file_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_file_test.dart new file mode 100644 index 0000000000000..76ad7d612fb1f --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_file_test.dart @@ -0,0 +1,169 @@ +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/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/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() { + 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); + + await tester.tap(find.byType(FileBlockComponent)); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + 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.tap( + find.text(LocaleKeys.document_plugins_file_fileUploadHint.tr()), + ); + 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); + + await tester.tap(find.byType(FileBlockComponent)); + await tester.pumpAndSettle(const Duration(seconds: 1)); + 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 976d812da1165..fb0c68682490a 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,12 +3,12 @@ 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/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu, ResizableImage; @@ -36,13 +36,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( @@ -84,13 +86,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( @@ -137,13 +141,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( @@ -161,5 +167,69 @@ void main() { expect(find.byType(UnsplashImageWidget), findsOneWidget); }); }); + + testWidgets('insert two images from local file at once', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent( + name: LocaleKeys.document_plugins_image_addAnImageDesktop.tr(), + ); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_image.tr(), + ); + expect(find.byType(CustomImageBlockComponent), findsOneWidget); + expect(find.byType(ImagePlaceholder), findsOneWidget); + expect( + find.descendant( + of: find.byType(ImagePlaceholder), + matching: find.byType(AppFlowyPopover), + ), + findsOneWidget, + ); + expect(find.byType(UploadImageMenu), findsOneWidget); + + final firstImage = + await rootBundle.load('assets/test/images/sample.jpeg'); + final secondImage = + await rootBundle.load('assets/test/images/sample.gif'); + final tempDirectory = await getTemporaryDirectory(); + + final firstImagePath = p.join(tempDirectory.path, 'sample.jpeg'); + final firstFile = File(firstImagePath) + ..writeAsBytesSync(firstImage.buffer.asUint8List()); + + final secondImagePath = p.join(tempDirectory.path, 'sample.gif'); + final secondFile = File(secondImagePath) + ..writeAsBytesSync(secondImage.buffer.asUint8List()); + + mockPickFilePaths(paths: [firstImagePath, secondImagePath]); + + await getIt().set(KVKeys.kCloudType, '0'); + await tester.tapButtonWithName( + LocaleKeys.document_imageBlock_upload_placeholder.tr(), + ); + await tester.pumpAndSettle(); + + expect(find.byType(ResizableImage), findsNWidgets(2)); + + final firstNode = + tester.editor.getCurrentEditorState().getNodeAtPath([0])!; + expect(firstNode.type, ImageBlockKeys.type); + expect(firstNode.attributes[ImageBlockKeys.url], isNotEmpty); + + final secondNode = + tester.editor.getCurrentEditorState().getNodeAtPath([0])!; + expect(secondNode.type, ImageBlockKeys.type); + expect(secondNode.attributes[ImageBlockKeys.url], isNotEmpty); + + // remove the temp files + await Future.wait([firstFile.delete(), secondFile.delete()]); + }); }); } diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_math_equation_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_math_equation_test.dart index 27f02f17cd8fe..c4a8e71a02b6a 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 @@ -33,7 +33,7 @@ void main() { ); // tap the inline math equation button - final inlineMathEquationButton = find.byTooltip( + final inlineMathEquationButton = find.findFlowyTooltip( LocaleKeys.document_plugins_createInlineMathEquation.tr(), ); await tester.tapButton(inlineMathEquationButton); @@ -78,7 +78,7 @@ void main() { ); // tap the inline math equation button - var inlineMathEquationButton = find.byTooltip( + var inlineMathEquationButton = find.findFlowyTooltip( LocaleKeys.document_plugins_createInlineMathEquation.tr(), ); await tester.tapButton(inlineMathEquationButton); @@ -93,11 +93,11 @@ void main() { ); // 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(), + inlineMathEquationButton = find.descendant( + of: find.findFlowyTooltip( + LocaleKeys.document_plugins_createInlineMathEquation.tr(), + ), + matching: find.byType(SVGIconItemWidget), ); expect( tester.widget(inlineMathEquationButton).isHighlight, 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 335f9a377fc21..45613fe97fc96 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/document/presentation/editor_plugins/mention/mention_page_block.dart'; +import 'package:appflowy/shared/flowy_error_page.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,7 @@ void main() { ); expect(finder, findsOneWidget); await tester.tapButton(finder); - expect(find.byType(FlowyErrorPage), findsOneWidget); + expect(find.byType(AppFlowyErrorPage), 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 0000000000000..d85e6c631eccf --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_multi_image_block_test.dart @@ -0,0 +1,293 @@ +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_block_component.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'; +import '../board/board_hide_groups_test.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 bfd8198295d84..fc6d0f86a6fbf 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 @@ -171,7 +171,8 @@ 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(), + offset: 100, ); await tester.pumpAndSettle(); } 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 1d42cd8d280b4..0000000000000 --- 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/ai_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/shared/auth_operation.dart b/frontend/appflowy_flutter/integration_test/shared/auth_operation.dart index e2c926fcfebf6..1f2f23dc2c96b 100644 --- a/frontend/appflowy_flutter/integration_test/shared/auth_operation.dart +++ b/frontend/appflowy_flutter/integration_test/shared/auth_operation.dart @@ -1,12 +1,10 @@ -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'; @@ -14,7 +12,7 @@ import 'util.dart'; extension AppFlowyAuthTest on WidgetTester { Future tapGoogleLoginInButton() async { await tapButton( - find.byKey(const Key('signInWithGoogleButton')), + find.byKey(signInWithGoogleButtonKey), ); } @@ -22,15 +20,15 @@ extension AppFlowyAuthTest on WidgetTester { 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 { @@ -38,7 +36,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) { @@ -53,26 +51,6 @@ 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 assertAppFlowyCloudEnableSyncSwitchValue(bool value) { assertToggleValue( find.descendant( @@ -83,15 +61,6 @@ extension AppFlowyAuthTest on WidgetTester { ); } - 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), diff --git a/frontend/appflowy_flutter/integration_test/shared/base.dart b/frontend/appflowy_flutter/integration_test/shared/base.dart index ab72247c24a2e..371cd9b839ae6 100644 --- a/frontend/appflowy_flutter/integration_test/shared/base.dart +++ b/frontend/appflowy_flutter/integration_test/shared/base.dart @@ -1,21 +1,19 @@ 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/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; @@ -56,8 +54,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 +72,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(); @@ -231,13 +220,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 8af2b2f075e7d..33ae6a1eca355 100644 --- a/frontend/appflowy_flutter/integration_test/shared/common_operations.dart +++ b/frontend/appflowy_flutter/integration_test/shared/common_operations.dart @@ -1,5 +1,10 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; +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'; @@ -30,10 +35,6 @@ 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 'emoji.dart'; @@ -671,4 +672,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/database_test_op.dart b/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart index 754e80342cffe..b25fb056a5866 100644 --- a/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart +++ b/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart @@ -41,6 +41,7 @@ import 'package:appflowy/plugins/database/tab_bar/desktop/tab_bar_header.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/checkbox.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/checklist.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/date.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/media.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/number.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/select_option.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/text.dart'; @@ -50,6 +51,7 @@ import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_cell_edi 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/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'; @@ -72,7 +74,6 @@ 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'; @@ -474,7 +475,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( @@ -627,12 +628,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 { @@ -786,7 +782,7 @@ 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); } @@ -857,6 +853,11 @@ extension AppFlowyDatabaseTest on WidgetTester { 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); @@ -887,18 +888,19 @@ extension AppFlowyDatabaseTest on WidgetTester { } Future createField( - FieldType fieldType, - String name, { + 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 { @@ -1580,6 +1582,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/editor_test_operations.dart b/frontend/appflowy_flutter/integration_test/shared/editor_test_operations.dart index 4eff62321a4b7..388b685bfbdad 100644 --- a/frontend/appflowy_flutter/integration_test/shared/editor_test_operations.dart +++ b/frontend/appflowy_flutter/integration_test/shared/editor_test_operations.dart @@ -3,15 +3,15 @@ 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/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_editor/appflowy_editor.dart' hide Log; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -84,7 +84,7 @@ class EditorOperations { final Finder button = !isInPicker ? find.text(LocaleKeys.document_plugins_cover_removeIcon.tr()) : find.descendant( - of: find.byType(FlowyIconPicker), + of: find.byType(FlowyIconEmojiPicker), matching: find.text(LocaleKeys.button_remove.tr()), ); await tester.tapButton(button); @@ -170,8 +170,26 @@ class EditorOperations { /// Tap the slash menu item with [name] /// /// Must call [showSlashMenu] first. - Future tapSlashMenuItemWithName(String name) async { + 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); } diff --git a/frontend/appflowy_flutter/integration_test/shared/settings.dart b/frontend/appflowy_flutter/integration_test/shared/settings.dart index a650bd33addce..cb95471cbef91 100644 --- a/frontend/appflowy_flutter/integration_test/shared/settings.dart +++ b/frontend/appflowy_flutter/integration_test/shared/settings.dart @@ -3,7 +3,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart'; -import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.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/settings_dialog.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu_element.dart'; @@ -13,17 +13,23 @@ import 'package:flowy_infra_ui/style_widget/text_field.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; } @@ -72,14 +78,14 @@ extension AppFlowySettings on WidgetTester { Future enterUserName(String name) async { // Enable editing username final editUsernameFinder = find.descendant( - of: find.byType(UserProfileSetting), + of: find.byType(AccountUserProfile), matching: find.byFlowySvg(FlowySvgs.edit_s), ); - 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); diff --git a/frontend/appflowy_flutter/integration_test/shared/workspace.dart b/frontend/appflowy_flutter/integration_test/shared/workspace.dart index 4d20d88ce193f..67506879d59d3 100644 --- a/frontend/appflowy_flutter/integration_test/shared/workspace.dart +++ b/frontend/appflowy_flutter/integration_test/shared/workspace.dart @@ -1,5 +1,5 @@ import 'package:appflowy/generated/locale_keys.g.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/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'; @@ -58,7 +58,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 86cefebb343b1..28d37bfa2331c 100644 --- a/frontend/appflowy_flutter/ios/Podfile.lock +++ b/frontend/appflowy_flutter/ios/Podfile.lock @@ -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): @@ -65,12 +63,15 @@ PODS: - FlutterMacOS - permission_handler_apple (9.3.0): - Flutter - - printing (1.0.0): - - Flutter - ReachabilitySwift (5.0.0) - SDWebImage (5.14.2): - SDWebImage/Core (= 5.14.2) - SDWebImage/Core (5.14.2) + - Sentry/HybridSDK (8.35.1) + - sentry_flutter (8.8.0): + - Flutter + - FlutterMacOS + - Sentry/HybridSDK (= 8.35.1) - share_plus (0.0.1): - Flutter - shared_preferences_foundation (0.0.1): @@ -95,7 +96,6 @@ 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`) @@ -103,7 +103,7 @@ DEPENDENCIES: - 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`) - - printing (from `.symlinks/plugins/printing/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`) @@ -116,6 +116,7 @@ SPEC REPOS: - DKPhotoGallery - ReachabilitySwift - SDWebImage + - Sentry - SwiftyGif - Toast @@ -136,8 +137,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: @@ -152,8 +151,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/path_provider_foundation/darwin" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" - printing: - :path: ".symlinks/plugins/printing/ios" + sentry_flutter: + :path: ".symlinks/plugins/sentry_flutter/ios" share_plus: :path: ".symlinks/plugins/share_plus/ios" shared_preferences_foundation: @@ -175,8 +174,7 @@ SPEC CHECKSUMS: file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 flowy_infra_ui: 0455e1fa8c51885aa1437848e361e99419f34ebc Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265 - image_gallery_saver: cb43cc43141711190510e92c460eb1655cd343cb + fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c image_picker_ios: 99dfe1854b4fa34d0364e74a78448a0151025425 integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4 irondash_engine_context: 3458bf979b90d616ffb8ae03a150bafe2e860cc9 @@ -184,9 +182,10 @@ SPEC CHECKSUMS: package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 - printing: 233e1b73bd1f4a05615548e9b5a324c98588640b ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84 + Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1 + sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737 share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5 shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec diff --git a/frontend/appflowy_flutter/ios/Runner.xcodeproj/project.pbxproj b/frontend/appflowy_flutter/ios/Runner.xcodeproj/project.pbxproj index aa53cf9b8862f..804ad052be50a 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/Info.plist b/frontend/appflowy_flutter/ios/Runner/Info.plist index 5ec528b05e431..0c8c1eff43371 100644 --- a/frontend/appflowy_flutter/ios/Runner/Info.plist +++ b/frontend/appflowy_flutter/ios/Runner/Info.plist @@ -1,75 +1,73 @@ - - NSCameraUsageDescription - AppFlowy requires access to the camera. - NSPhotoLibraryUsageDescription - AppFlowy requires access to the photo library. - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleLocalizations - - en - - FLTEnableImpeller - - CFBundleName - AppFlowy - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleURLTypes - - - CFBundleURLName - - CFBundleURLSchemes - - appflowy-flutter - - - - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UIApplicationSupportsIndirectInputEvents - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - NSAppTransportSecurity + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLocalizations + + en + + CFBundleName + AppFlowy + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleURLTypes + - NSAllowsArbitraryLoads - + CFBundleURLName + + CFBundleURLSchemes + + appflowy-flutter + + + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + FLTEnableImpeller + + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + - \ No newline at end of file + NSPhotoLibraryUsageDescription + AppFlowy needs access to your photos to let you add images to your documents + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UIViewControllerBasedStatusBarAppearance + + UISupportsDocumentBrowser + + + diff --git a/frontend/appflowy_flutter/ios/Runner/Runner.entitlements b/frontend/appflowy_flutter/ios/Runner/Runner.entitlements index 903def2af5306..80b5221de760c 100644 --- a/frontend/appflowy_flutter/ios/Runner/Runner.entitlements +++ b/frontend/appflowy_flutter/ios/Runner/Runner.entitlements @@ -4,5 +4,9 @@ aps-environment development + com.apple.developer.applesignin + + Default + diff --git a/frontend/appflowy_flutter/lib/core/frameless_window.dart b/frontend/appflowy_flutter/lib/core/frameless_window.dart index 799c90f5b75b4..15732f163dfa6 100644 --- a/frontend/appflowy_flutter/lib/core/frameless_window.dart +++ b/frontend/appflowy_flutter/lib/core/frameless_window.dart @@ -3,16 +3,6 @@ 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/shared/window_title_bar.dart'; -import 'package:appflowy/util/theme_extension.dart'; -import 'package:appflowy/workspace/application/home/home_setting_bloc.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_bloc/flutter_bloc.dart'; - class CocoaWindowChannel { CocoaWindowChannel._(); @@ -40,11 +30,9 @@ class MoveWindowDetector extends StatefulWidget { const MoveWindowDetector({ super.key, this.child, - this.showTitleBar = false, }); final Widget? child; - final bool showTitleBar; @override MoveWindowDetectorState createState() => MoveWindowDetectorState(); @@ -56,28 +44,10 @@ class MoveWindowDetectorState extends State { @override Widget build(BuildContext context) { - if (!Platform.isMacOS && !Platform.isWindows) { + if (!Platform.isMacOS) { return widget.child ?? const SizedBox.shrink(); } - if (Platform.isWindows) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (widget.showTitleBar) ...[ - WindowTitleBar( - leftChildren: [ - _buildToggleMenuButton(context), - ], - ), - ] else ...[ - const SizedBox(height: 5), - ], - widget.child ?? const SizedBox.shrink(), - ], - ); - } - return GestureDetector( // https://stackoverflow.com/questions/52965799/flutter-gesturedetector-not-working-with-containers-in-stack behavior: HitTestBehavior.translucent, @@ -98,47 +68,4 @@ class MoveWindowDetectorState extends State { child: widget.child, ); } - - Widget _buildToggleMenuButton(BuildContext context) { - if (!context.read().state.isMenuCollapsed) { - return const SizedBox.shrink(); - } - - final color = Theme.of(context).isLightMode ? Colors.white : Colors.black; - final textSpan = TextSpan( - children: [ - TextSpan( - text: '${LocaleKeys.sideBar_openSidebar.tr()}\n', - style: Theme.of(context).textTheme.bodyMedium!.copyWith(color: color), - ), - TextSpan( - text: Platform.isMacOS ? '⌘+.' : 'Ctrl+\\', - style: Theme.of(context) - .textTheme - .bodyMedium! - .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), - ), - ), - ), - ), - ); - } } diff --git a/frontend/appflowy_flutter/lib/env/backend_env.dart b/frontend/appflowy_flutter/lib/env/backend_env.dart index fa0bf575a34b7..f8aa715a4062c 100644 --- a/frontend/appflowy_flutter/lib/env/backend_env.dart +++ b/frontend/appflowy_flutter/lib/env/backend_env.dart @@ -13,7 +13,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,41 +27,12 @@ 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({ diff --git a/frontend/appflowy_flutter/lib/env/cloud_env.dart b/frontend/appflowy_flutter/lib/env/cloud_env.dart index 9e8ea0d4f93b1..fcad1a1f2f9f6 100644 --- a/frontend/appflowy_flutter/lib/env/cloud_env.dart +++ b/frontend/appflowy_flutter/lib/env/cloud_env.dart @@ -21,9 +21,6 @@ Future _setAuthenticatorType(AuthenticatorType ty) async { 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 +60,6 @@ Future getAuthenticatorType() async { switch (value ?? "0") { case "0": return AuthenticatorType.local; - case "1": - return AuthenticatorType.supabase; case "2": return AuthenticatorType.appflowyCloud; case "3": @@ -93,10 +88,6 @@ Future getAuthenticatorType() async { /// 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,19 +95,6 @@ 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; -} - /// Determines if AppFlowy Cloud is enabled. bool get isAppFlowyCloudEnabled { return currentCloudType().isAppFlowyCloudEnabled; @@ -124,7 +102,6 @@ bool get isAppFlowyCloudEnabled { enum AuthenticatorType { local, - supabase, appflowyCloud, appflowyCloudSelfHost, // The 'appflowyCloudDevelop' type is used for develop purposes only. @@ -137,14 +114,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 +131,6 @@ enum AuthenticatorType { switch (value) { case 0: return AuthenticatorType.local; - case 1: - return AuthenticatorType.supabase; case 2: return AuthenticatorType.appflowyCloud; case 3: @@ -197,25 +168,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. 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 +190,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 +201,6 @@ class AppFlowyCloudSharedEnv { return AppFlowyCloudSharedEnv( authenticatorType: authenticatorType, appflowyCloudConfig: appflowyCloudConfig, - supabaseConfig: supabaseCloudConfig, ); } else { // Using the cloud settings from the .env file. @@ -257,7 +213,6 @@ class AppFlowyCloudSharedEnv { return AppFlowyCloudSharedEnv( authenticatorType: AuthenticatorType.fromValue(Env.authenticatorType), appflowyCloudConfig: appflowyCloudConfig, - supabaseConfig: SupabaseConfiguration.defaultConfig(), ); } } @@ -265,8 +220,7 @@ class AppFlowyCloudSharedEnv { @override String toString() { return 'authenticator: $_authenticatorType\n' - 'appflowy: ${appflowyCloudConfig.toJson()}\n' - 'supabase: ${supabaseConfig.toJson()})\n'; + 'appflowy: ${appflowyCloudConfig.toJson()}\n'; } } @@ -354,22 +308,3 @@ Future setSupabaseServer( 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 b861b4cfb87b0..cfd9837944d09 100644 --- a/frontend/appflowy_flutter/lib/env/env.dart +++ b/frontend/appflowy_flutter/lib/env/env.dart @@ -36,4 +36,11 @@ abstract class Env { defaultValue: '', ) static const String internalBuild = _Env.internalBuild; + + @EnviedField( + obfuscate: false, + varName: 'SENTRY_DSN', + defaultValue: '', + ) + static const String sentryDsn = _Env.sentryDsn; } diff --git a/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart b/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart index 8b9f1e70ff6e4..153ed451bea74 100644 --- a/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart +++ b/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart @@ -2,27 +2,41 @@ import 'dart:async'; import 'dart:convert'; import 'package:appflowy/mobile/presentation/chat/mobile_chat_screen.dart'; -import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; -import 'package:flutter/material.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 { + Future pushView( + ViewPB view, { + Map? arguments, + bool addInRecent = true, + bool showMoreButton = true, + String? fixedTitle, + }) 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; + } + } final uri = Uri( path: view.routeName, - queryParameters: view.queryParameters(arguments), + queryParameters: queryParameters, ).toString(); await push(uri); } diff --git a/frontend/appflowy_flutter/lib/mobile/application/notification/notification_reminder_bloc.dart b/frontend/appflowy_flutter/lib/mobile/application/notification/notification_reminder_bloc.dart new file mode 100644 index 0000000000000..193634b2d5825 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/application/notification/notification_reminder_bloc.dart @@ -0,0 +1,217 @@ +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.name, + view: view, + reminderContent: node.delta?.toPlainText() ?? '', + nodes: [node], + status: NotificationReminderStatus.loaded, + ), + ); + } + } else if (layout.isDatabaseView) { + emit( + NotificationReminderState( + createdAt: createdAt, + pageTitle: view.name, + 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, + 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 52552fce3b11d..4dde5cc10243c 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 @@ -23,6 +23,9 @@ class DocumentPageStyleBloc await event.when( initial: () async { try { + if (view.id.isEmpty) { + return; + } final layoutObject = await ViewBackendService.getView(view.id).fold( (s) => jsonDecode(s.extra), @@ -146,7 +149,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 +165,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(); 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 547c81f00b988..99098f930d82e 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'; @@ -113,7 +111,6 @@ class RecentViewBloc extends Bloc { ); } - final _service = DocumentService(); final ViewPB view; final DocumentListener _documentListener; final ViewListener _viewListener; @@ -124,16 +121,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); } 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 7edec07cc16ae..1480cc02e9763 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,12 +12,12 @@ 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 = diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/animated_gesture.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/animated_gesture.dart new file mode 100644 index 0000000000000..d0e973ae6447a --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/animated_gesture.dart @@ -0,0 +1,54 @@ +import 'package:appflowy/shared/feedback_gesture_detector.dart'; +import 'package:flutter/material.dart'; + +class AnimatedGestureDetector extends StatefulWidget { + const AnimatedGestureDetector({ + super.key, + this.scaleFactor = 0.98, + this.feedback = true, + this.duration = const Duration(milliseconds: 100), + this.alignment = Alignment.center, + this.behavior = HitTestBehavior.opaque, + this.onTapUp, + required this.child, + }); + + final Widget child; + final double scaleFactor; + final Duration duration; + final Alignment alignment; + final bool feedback; + final HitTestBehavior behavior; + final VoidCallback? onTapUp; + + @override + State createState() => + _AnimatedGestureDetectorState(); +} + +class _AnimatedGestureDetectorState extends State { + double scale = 1.0; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: widget.behavior, + onTapUp: (details) { + setState(() => scale = 1.0); + + HapticFeedbackType.light.call(); + + widget.onTapUp?.call(); + }, + onTapDown: (details) { + setState(() => scale = widget.scaleFactor); + }, + child: AnimatedScale( + scale: scale, + alignment: widget.alignment, + duration: widget.duration, + child: widget.child, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/app_bar/app_bar.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/app_bar/app_bar.dart index 335f1af489f9e..396ecd6bb8630 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: 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 b59c1e68ccae4..72142d446bf84 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 9e21a002b1c36..569cdd5fe61d5 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 @@ -3,8 +3,8 @@ import 'package:appflowy/mobile/application/base/mobile_view_page_bloc.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart'; +import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_state_container.dart'; -import 'package:appflowy/plugins/base/emoji/emoji_text.dart'; import 'package:appflowy/plugins/document/presentation/document_collaborators.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; @@ -27,6 +27,8 @@ class MobileViewPage extends StatefulWidget { required this.viewLayout, this.title, this.arguments, + this.fixedTitle, + this.showMoreButton = true, }); /// view id @@ -34,6 +36,10 @@ class MobileViewPage extends StatefulWidget { final ViewLayoutPB viewLayout; final String? title; final Map? arguments; + final bool showMoreButton; + + // only used in row page + final String? fixedTitle; @override State createState() => _MobileViewPageState(); @@ -46,10 +52,18 @@ class _MobileViewPageState extends State { // control the app bar opacity when in immersive mode final ValueNotifier _appBarOpacity = ValueNotifier(1.0); + @override + void initState() { + super.initState(); + + getIt().add(const ReminderEvent.started()); + } + @override void dispose() { _appBarOpacity.dispose(); _scrollNotificationObserver = null; + super.dispose(); } @@ -78,8 +92,7 @@ class _MobileViewPageState extends State { ViewBloc(view: view)..add(const ViewEvent.initial()), ), BlocProvider.value( - value: getIt() - ..add(const ReminderEvent.started()), + value: getIt(), ), if (view.layout.isDocumentView) BlocProvider( @@ -125,7 +138,7 @@ class _MobileViewPageState extends State { return child; }, ) - : child; + : SafeArea(child: child); return Scaffold( extendBodyBehindAppBar: isDocument, appBar: appBar, @@ -157,6 +170,9 @@ class _MobileViewPageState extends State { return plugin.widgetBuilder.buildWidget( shrinkWrap: false, context: PluginContext(userProfile: state.userProfilePB), + data: { + MobileDocumentScreen.viewFixedTitle: widget.fixedTitle, + }, ); }, (error) { @@ -209,13 +225,19 @@ class _MobileViewPageState extends State { ]); } - actions.addAll([ - MobileViewPageMoreButton( - view: view, - isImmersiveMode: isImmersiveMode, - appBarOpacity: _appBarOpacity, - ), - ]); + if (widget.showMoreButton) { + actions.addAll([ + MobileViewPageMoreButton( + view: view, + isImmersiveMode: isImmersiveMode, + appBarOpacity: _appBarOpacity, + ), + ]); + } else { + actions.addAll([ + const HSpace(18.0), + ]); + } return actions; } @@ -225,19 +247,20 @@ class _MobileViewPageState extends State { return Row( mainAxisSize: MainAxisSize.min, children: [ - if (icon != null && icon.isNotEmpty) - ConstrainedBox( - constraints: const BoxConstraints.tightFor(width: 34.0), - child: EmojiText( - emoji: '$icon ', - fontSize: 22.0, - ), + if (icon != null && icon.isNotEmpty) ...[ + FlowyText.emoji( + icon, + fontSize: 15.0, + figmaLineHeight: 18.0, ), + const HSpace(4), + ], Expanded( child: FlowyText.medium( - view?.name ?? widget.title ?? '', + widget.fixedTitle ?? view?.name ?? widget.title ?? '', fontSize: 15.0, overflow: TextOverflow.ellipsis, + figmaLineHeight: 18.0, ), ), ], 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 497f76935444d..5e4595a1e5d53 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 @@ -102,6 +102,7 @@ class _TypeOptionMenuItem extends StatelessWidget { value.text, fontSize: 14.0, maxLines: 2, + lineHeight: 1.0, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, ), 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 1a8ff64f2b639..3316b7049beb8 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 @@ -24,9 +24,10 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget { height: 52.0, leftIcon: const FlowySvg( FlowySvgs.icon_document_s, - size: Size.square(18), + size: Size.square(20), ), showTopBorder: false, + showBottomBorder: false, onTap: () => onAction(ViewLayoutPB.Document), ), FlowyOptionTile.text( @@ -34,9 +35,10 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget { height: 52.0, leftIcon: const FlowySvg( FlowySvgs.icon_grid_s, - size: Size.square(18), + size: Size.square(20), ), showTopBorder: false, + showBottomBorder: false, onTap: () => onAction(ViewLayoutPB.Grid), ), FlowyOptionTile.text( @@ -44,9 +46,10 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget { height: 52.0, leftIcon: const FlowySvg( FlowySvgs.icon_board_s, - size: Size.square(18), + size: Size.square(20), ), showTopBorder: false, + showBottomBorder: false, onTap: () => onAction(ViewLayoutPB.Board), ), FlowyOptionTile.text( @@ -54,9 +57,10 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget { height: 52.0, leftIcon: const FlowySvg( FlowySvgs.icon_calendar_s, - size: Size.square(18), + size: Size.square(20), ), showTopBorder: false, + showBottomBorder: false, onTap: () => onAction(ViewLayoutPB.Calendar), ), FlowyOptionTile.text( @@ -64,9 +68,10 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget { height: 52.0, leftIcon: const FlowySvg( FlowySvgs.chat_ai_page_s, - size: Size.square(18), + 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_rename_widget.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_rename_widget.dart index d4f49cb9a97a5..8ac4d9b20e765 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 75b0151a3a518..c1129af79dc22 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,4 +1,3 @@ -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'; @@ -6,6 +5,7 @@ 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'; @@ -64,6 +64,10 @@ class _MobileViewItemBottomSheetState extends State { case MobileViewItemBottomSheetBodyAction.duplicate: Navigator.pop(context); context.read().add(const ViewEvent.duplicate()); + showToastNotification( + context, + message: LocaleKeys.button_duplicateSuccessfully.tr(), + ); break; case MobileViewItemBottomSheetBodyAction.share: // unimplemented @@ -79,6 +83,12 @@ class _MobileViewItemBottomSheetState extends State { context .read() .add(FavoriteEvent.toggle(widget.view)); + showToastNotification( + context, + message: !widget.view.isFavorite + ? LocaleKeys.button_favoriteSuccessfully.tr() + : LocaleKeys.button_unfavoriteSuccessfully.tr(), + ); break; case MobileViewItemBottomSheetBodyAction.removeFromRecent: _removeFromRecent(context); @@ -109,16 +119,6 @@ class _MobileViewItemBottomSheetState extends State { await _showConfirmDialog( onDelete: () { recentViewsBloc.add(RecentViewsEvent.removeRecentViews([viewId])); - - fToast.showToast( - child: const _RemoveToast(), - positionedToastBuilder: (context, child) { - return Positioned.fill( - top: 450, - child: child, - ); - }, - ); }, ); } @@ -126,48 +126,30 @@ class _MobileViewItemBottomSheetState extends State { Future _showConfirmDialog({required VoidCallback onDelete}) async { await showFlowyCupertinoConfirmDialog( title: LocaleKeys.sideBar_removePageFromRecent.tr(), - leftButton: FlowyText.regular( + leftButton: FlowyText( LocaleKeys.button_cancel.tr(), - color: const Color(0xFF1456F0), + fontSize: 17.0, + figmaLineHeight: 24.0, + fontWeight: FontWeight.w500, + color: const Color(0xFF007AFF), ), - rightButton: FlowyText.medium( + 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); - }, - ); - } -} -class _RemoveToast extends StatelessWidget { - const _RemoveToast(); + Navigator.pop(context); - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 13.0), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12.0), - color: const Color(0xE5171717), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const FlowySvg( - FlowySvgs.success_s, - blendMode: null, - ), - const HSpace(8.0), - FlowyText.regular( - LocaleKeys.sideBar_removeSuccess.tr(), - fontSize: 16.0, - color: Colors.white, - ), - ], - ), + showToastNotification( + context, + message: LocaleKeys.sideBar_removeSuccess.tr(), + ); + }, ); } } 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 1583e9e2e015f..e0b10d153b37e 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 @@ -7,6 +7,7 @@ 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/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'; @@ -40,18 +41,32 @@ enum MobilePaneActionType { backgroundColor: const Color(0xFFFA217F), svg: FlowySvgs.favorite_section_remove_from_favorite_s, size: 24.0, - onPressed: (context) => context - .read() - .add(FavoriteEvent.toggle(context.read().view)), + onPressed: (context) { + showToastNotification( + context, + message: LocaleKeys.button_unfavoriteSuccessfully.tr(), + ); + + context + .read() + .add(FavoriteEvent.toggle(context.read().view)); + }, ); case MobilePaneActionType.addToFavorites: return MobileSlideActionButton( backgroundColor: const Color(0xFF00C8FF), svg: FlowySvgs.favorite_s, size: 24.0, - onPressed: (context) => context - .read() - .add(FavoriteEvent.toggle(context.read().view)), + onPressed: (context) { + showToastNotification( + context, + message: LocaleKeys.button_favoriteSuccessfully.tr(), + ); + + context + .read() + .add(FavoriteEvent.toggle(context.read().view)); + }, ); case MobilePaneActionType.add: return MobileSlideActionButton( @@ -69,6 +84,7 @@ enum MobilePaneActionType { showDragHandle: true, showCloseButton: true, useRootNavigator: true, + showDivider: false, backgroundColor: Theme.of(context).colorScheme.surface, builder: (sheetContext) { return AddNewPageWidgetBottomSheet( @@ -145,8 +161,6 @@ enum MobilePaneActionType { ? MobileViewItemBottomSheetBodyAction.removeFromFavorites : MobileViewItemBottomSheetBodyAction.addToFavorites, MobileViewItemBottomSheetBodyAction.divider, - if (view.layout != ViewLayoutPB.Chat) - MobileViewItemBottomSheetBodyAction.duplicate, MobileViewItemBottomSheetBodyAction.divider, MobileViewItemBottomSheetBodyAction.removeFromRecent, ]; @@ -156,7 +170,6 @@ enum MobilePaneActionType { ? MobileViewItemBottomSheetBodyAction.removeFromFavorites : MobileViewItemBottomSheetBodyAction.addToFavorites, MobileViewItemBottomSheetBodyAction.divider, - MobileViewItemBottomSheetBodyAction.duplicate, ]; } } @@ -181,12 +194,13 @@ ActionPane buildEndActionPane( bool needSpace = true, MobilePageCardType? cardType, FolderSpaceType? spaceType, + required double spaceRatio, }) { return ActionPane( motion: const ScrollMotion(), - extentRatio: actions.length / 5, + extentRatio: actions.length / spaceRatio, children: [ - if (needSpace) const HSpace(20), + if (needSpace) const HSpace(60), ...actions.map( (action) => action.actionButton( context, 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 be815b6550c6a..9af49e98c883f 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 @@ -70,6 +70,7 @@ Future showMobileBottomSheet( backgroundColor ??= Theme.of(context).brightness == Brightness.light ? const Color(0xFFF7F8FB) : const Color(0xFF23262B); + barrierColor ??= Colors.black.withOpacity(0.3); return showModalBottomSheet( context: context, @@ -226,10 +227,14 @@ 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) 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 index a1fc2a70a3697..90ebd4f61f97e 100644 --- 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 @@ -3,13 +3,13 @@ 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/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/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/shared/flowy_error_page.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; @@ -69,10 +69,10 @@ class _MobileBoardPageState extends State { loading: (_) => const Center( child: CircularProgressIndicator.adaptive(), ), - error: (err) => FlowyMobileStateContainer.error( - emoji: '🛸', - title: LocaleKeys.board_mobile_failedToLoad.tr(), - errorMsg: err.toString(), + error: (err) => Center( + child: AppFlowyErrorPage( + error: err.error, + ), ), ready: (data) => const _BoardContent(), orElse: () => const SizedBox.shrink(), 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 0b0c16f951e36..f80525786ed6d 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,9 +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/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'; @@ -11,7 +14,6 @@ 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'; @@ -200,7 +202,7 @@ class MobileHiddenGroup extends StatelessWidget { children: [ Expanded( child: Text( - context.read().generateGroupNameFromGroup(group), + group.generateGroupName(databaseController), style: Theme.of(context).textTheme.bodyMedium, maxLines: 2, overflow: TextOverflow.ellipsis, 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 c65f899c34c74..4b080e540dda7 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 @@ -1,8 +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/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'; @@ -22,7 +25,6 @@ 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'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:go_router/go_router.dart'; @@ -294,6 +296,7 @@ class MobileRowDetailPageContentState RowCache get rowCache => widget.databaseController.rowCache; FieldController get fieldController => widget.databaseController.fieldController; + ValueNotifier primaryFieldId = ValueNotifier(''); @override void initState() { @@ -304,6 +307,8 @@ class MobileRowDetailPageContentState viewId: viewId, rowCache: rowCache, ); + rowController.initialize(); + cellBuilder = EditableCellBuilder( databaseController: widget.databaseController, ); @@ -326,7 +331,13 @@ class MobileRowDetailPageContentState fieldController: fieldController, rowMeta: rowController.rowMeta, )..add(const RowBannerEvent.initial()), - child: BlocBuilder( + 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(); @@ -366,6 +377,23 @@ class MobileRowDetailPageContentState 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, 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 d683a9b72dfc3..1d3d3efcf5579 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 @@ -22,7 +22,7 @@ class MobileRowDetailCreateFieldButton extends StatelessWidget { return ConstrainedBox( constraints: BoxConstraints( minWidth: double.infinity, - minHeight: GridSize.headerHeight, + maxHeight: GridSize.headerHeight, ), child: TextButton.icon( style: Theme.of(context).textButtonTheme.style?.copyWith( @@ -37,7 +37,7 @@ class MobileRowDetailCreateFieldButton extends StatelessWidget { alignment: AlignmentDirectional.centerStart, splashFactory: NoSplash.splashFactory, padding: const WidgetStatePropertyAll( - EdgeInsets.symmetric(vertical: 14, horizontal: 6), + 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 04984275474f1..95298daeff3ae 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,3 +1,5 @@ +import 'package:flutter/material.dart'; + 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'; @@ -6,7 +8,6 @@ import 'package:appflowy/plugins/database/grid/application/row/row_detail_bloc.d 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'; class MobileRowPropertyList extends StatelessWidget { @@ -87,6 +88,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/row_page_button.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/row_page_button.dart new file mode 100644 index 0000000000000..49f95887ab8fc --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/row_page_button.dart @@ -0,0 +1,143 @@ +import 'dart:async'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/mobile_router.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/workspace/application/view/prelude.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class OpenRowPageButton extends StatefulWidget { + const OpenRowPageButton({ + super.key, + required this.documentId, + required this.databaseController, + required this.cellContext, + }); + + final String documentId; + + final DatabaseController databaseController; + final CellContext cellContext; + + @override + State createState() => _OpenRowPageButtonState(); +} + +class _OpenRowPageButtonState extends State { + late final cellBloc = TextCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + + ViewPB? view; + + @override + void initState() { + super.initState(); + + _preloadView(context, createDocumentIfMissed: true); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + bloc: cellBloc, + builder: (context, state) { + return ConstrainedBox( + constraints: BoxConstraints( + minWidth: double.infinity, + maxHeight: GridSize.buttonHeight, + ), + child: TextButton.icon( + style: Theme.of(context).textButtonTheme.style?.copyWith( + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + ), + overlayColor: WidgetStateProperty.all( + Theme.of(context).hoverColor, + ), + alignment: AlignmentDirectional.centerStart, + splashFactory: NoSplash.splashFactory, + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric(horizontal: 6), + ), + ), + label: FlowyText.medium( + LocaleKeys.grid_field_openRowDocument.tr(), + fontSize: 15, + ), + icon: const Padding( + padding: EdgeInsets.all(4.0), + child: FlowySvg( + FlowySvgs.full_view_s, + size: Size.square(16.0), + ), + ), + onPressed: () { + final name = state.content; + _openRowPage(context, name); + }, + ), + ); + }, + ); + } + + Future _openRowPage(BuildContext context, String fieldName) async { + Log.info('Open row page(${widget.documentId})'); + + if (view == null) { + showToastNotification(context, message: 'Failed to open row page'); + // reload the view again + unawaited(_preloadView(context)); + Log.error('Failed to open row page(${widget.documentId})'); + return; + } + + if (context.mounted) { + // the document in row is an orphan document, so we don't add it to recent + await context.pushView( + view!, + addInRecent: false, + showMoreButton: false, + fixedTitle: fieldName, + ); + } + } + + // preload view to reduce the time to open the view + Future _preloadView( + BuildContext context, { + bool createDocumentIfMissed = false, + }) async { + Log.info('Preload row page(${widget.documentId})'); + final result = await ViewBackendService.getView(widget.documentId); + view = result.fold((s) => s, (f) => null); + + if (view == null && createDocumentIfMissed) { + // create view if not exists + Log.info('Create row page(${widget.documentId})'); + final result = await ViewBackendService.createOrphanView( + name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), + viewId: widget.documentId, + layoutType: ViewLayoutPB.Document, + ); + view = result.fold((s) => s, (f) => null); + } + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/mobile_card_content.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/mobile_card_content.dart index 48d8b2f097374..26978410051ef 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,16 @@ +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/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: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 MobileCardContent extends StatelessWidget { const MobileCardContent({ @@ -21,19 +28,47 @@ class MobileCardContent extends StatelessWidget { @override Widget build(BuildContext context) { + final attachmentCount = rowMeta.attachmentCount.toInt(); + return Padding( padding: styleConfiguration.cardPadding, child: Column( mainAxisSize: MainAxisSize.min, - children: cells.map( - (cellMeta) { - return cellBuilder.build( - cellContext: cellMeta.cellContext(), - styleMap: mobileBoardCardCellStyleMap(context), - hasNotes: !rowMeta.isDocumentEmpty, - ); - }, - ).toList(), + children: [ + ...cells.map( + (cellMeta) { + return cellBuilder.build( + cellContext: cellMeta.cellContext(), + styleMap: mobileBoardCardCellStyleMap(context), + hasNotes: !rowMeta.isDocumentEmpty, + ); + }, + ), + if (attachmentCount > 0) ...[ + const VSpace(4), + Padding( + padding: const EdgeInsets.only(left: 8), + 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: ['$attachmentCount']), + fontSize: 12, + color: AFThemeExtension.of(context).secondaryTextColor, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + ], ), ); } 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 d794817339bf4..a51e3561f8464 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,12 +1,13 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/mobile/presentation/database/field/mobile_full_field_editor.dart'; -import 'package:appflowy/plugins/database/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'; -import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; class MobileEditPropertyScreen extends StatefulWidget { @@ -49,7 +50,7 @@ class _MobileEditPropertyScreenState extends State { return PopScope( onPopInvoked: (didPop) { - if (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 fb1bda724d310..74c7a5a56b141 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 @@ -29,6 +29,7 @@ const mobileSupportedFieldTypes = [ FieldType.CreatedTime, FieldType.Checkbox, FieldType.Checklist, + FieldType.Media, // FieldType.Time, ]; 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 0b415b04a67bd..311794aa42ec0 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'; @@ -11,7 +13,6 @@ import 'package:appflowy/plugins/database/widgets/setting/field_visibility_exten 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) => @@ -99,7 +101,7 @@ class _QuickEditFieldState extends State { context.pop(); }, ), - if (!widget.fieldInfo.isPrimary) + if (!widget.fieldInfo.isPrimary) ...[ FlowyOptionTile.text( showTopBorder: false, text: fieldVisibility.isVisibleState() @@ -115,7 +117,6 @@ class _QuickEditFieldState extends State { } }, ), - if (!widget.fieldInfo.isPrimary) FlowyOptionTile.text( showTopBorder: false, text: LocaleKeys.grid_field_insertLeft.tr(), @@ -132,6 +133,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/edit_database_view_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/edit_database_view_screen.dart index 4d8acbbeba024..f5812541c8336 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, ), 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 14c4e022aef45..aacc055e7474f 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 @@ -7,15 +7,21 @@ class MobileDocumentScreen extends StatelessWidget { super.key, required this.id, this.title, + this.showMoreButton = true, + this.fixedTitle, }); /// view id final String id; final String? title; + final bool showMoreButton; + final String? fixedTitle; static const routeName = '/docs'; static const viewId = 'id'; static const viewTitle = 'title'; + static const viewShowMoreButton = 'show_more_button'; + static const viewFixedTitle = 'fixed_title'; @override Widget build(BuildContext context) { @@ -23,6 +29,8 @@ class MobileDocumentScreen extends StatelessWidget { id: id, title: title, viewLayout: ViewLayoutPB.Document, + showMoreButton: showMoreButton, + fixedTitle: fixedTitle, ); } } 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 index ded486983ef0e..6282421109071 100644 --- 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 @@ -96,38 +96,34 @@ class _FavoriteViews extends StatelessWidget { final borderColor = Theme.of(context).isLightMode ? const Color(0xFFE9E9EC) : const Color(0x1AFFFFFF); - return Scrollbar( - child: ListView.separated( - key: const PageStorageKey('favorite_views_page_storage_key'), - padding: EdgeInsets.only( - left: HomeSpaceViewSizes.mHorizontalPadding, - right: HomeSpaceViewSizes.mHorizontalPadding, - 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, - ), + 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, - ), + ), + 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 57ac43f255c1b..1efee460ebacd 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 @@ -72,6 +72,7 @@ class MobileFavoriteFolder extends StatelessWidget { 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 index 965c396d42655..56513795226ab 100644 --- 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 @@ -25,21 +25,17 @@ class _MobileHomeSpaceState extends State final workspaceId = context.read().state.currentWorkspace?.workspaceId ?? ''; - return Scrollbar( - child: SingleChildScrollView( - child: Padding( - padding: EdgeInsets.only( - left: HomeSpaceViewSizes.mHorizontalPadding, - right: HomeSpaceViewSizes.mHorizontalPadding, - top: HomeSpaceViewSizes.mVerticalPadding, - bottom: HomeSpaceViewSizes.mVerticalPadding + - MediaQuery.of(context).padding.bottom, - ), - child: MobileFolders( - user: widget.userProfile, - workspaceId: workspaceId, - showFavorite: false, - ), + 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 64ad7e4cd1a4e..0013650df94ac 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart @@ -1,10 +1,6 @@ -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/presentation/home/home.dart'; import 'package:appflowy/mobile/presentation/home/section_folder/mobile_home_section_folder.dart'; import 'package:appflowy/mobile/presentation/home/space/mobile_space.dart'; -import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; @@ -15,7 +11,6 @@ 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:go_router/go_router.dart'; // Contains Public And Private Sections class MobileFolders extends StatelessWidget { @@ -32,73 +27,54 @@ class MobileFolders extends StatelessWidget { @override Widget build(BuildContext context) { - return MultiBlocProvider( - providers: [ - BlocProvider( - create: (_) => SidebarSectionsBloc() - ..add(SidebarSectionsEvent.initial(user, workspaceId)), - ), - BlocProvider( - create: (_) => FavoriteBloc()..add(const FavoriteEvent.initial()), - ), - BlocProvider( - create: (_) => SpaceBloc() - ..add(SpaceEvent.initial(user, workspaceId, openFirstPage: false)), - ), - ], - child: BlocListener( - listener: (context, state) { - context.read().add( - SidebarSectionsEvent.initial( - user, - state.currentWorkspace?.workspaceId ?? workspaceId, - ), - ); - context.read().add( - SpaceEvent.reset( - user, - state.currentWorkspace?.workspaceId ?? workspaceId, - ), - ); - }, - child: MultiBlocListener( - listeners: [ - BlocListener( - listenWhen: (p, c) => - p.lastCreatedPage?.id != c.lastCreatedPage?.id, - listener: (context, state) { - final lastCreatedPage = state.lastCreatedPage; - if (lastCreatedPage != null) { - context.pushView(lastCreatedPage); - } - }, - ), - BlocListener( - listenWhen: (p, c) => - p.lastCreatedRootView?.id != c.lastCreatedRootView?.id, - listener: (context, state) { - final lastCreatedPage = state.lastCreatedRootView; - if (lastCreatedPage != null) { - context.pushView(lastCreatedPage); - } - }, - ), - ], - child: BlocBuilder( - builder: (context, state) { - return SlidableAutoCloseBehavior( - child: Column( - children: [ - ..._buildSpaceOrSection(context, state), - const VSpace(4.0), - const _TrashButton(), - ], - ), - ); - }, + final workspaceId = + context.read().state.currentWorkspace?.workspaceId ?? + ''; + return BlocListener( + listenWhen: (previous, current) => + previous.currentWorkspace?.workspaceId != + current.currentWorkspace?.workspaceId, + listener: (context, state) { + context.read().add( + SidebarSectionsEvent.initial( + user, + state.currentWorkspace?.workspaceId ?? workspaceId, + ), + ); + context.read().add( + SpaceEvent.reset( + user, + state.currentWorkspace?.workspaceId ?? workspaceId, + false, + ), + ); + }, + child: const _MobileFolder(), + ); + } +} + +class _MobileFolder extends StatefulWidget { + const _MobileFolder(); + + @override + State<_MobileFolder> createState() => _MobileFolderState(); +} + +class _MobileFolderState extends State<_MobileFolder> { + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return SlidableAutoCloseBehavior( + child: Column( + children: [ + ..._buildSpaceOrSection(context, state), + const VSpace(80.0), + ], ), - ), - ), + ); + }, ); } @@ -137,28 +113,3 @@ class MobileFolders extends StatelessWidget { ]; } } - -class _TrashButton extends StatelessWidget { - const _TrashButton(); - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 52, - child: FlowyButton( - expand: true, - margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 2.0), - leftIcon: const FlowySvg( - FlowySvgs.m_delete_s, - ), - leftIconSize: const Size.square(18), - iconPadding: 10.0, - text: FlowyText.regular( - LocaleKeys.trash_text.tr(), - fontSize: 16.0, - ), - onTap: () => context.push(MobileHomeTrashPage.routeName), - ), - ); - } -} 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 1dc72f370d3a2..f2d866533d0ee 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,25 +1,32 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; - +import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/home/mobile_home_page_header.dart'; import 'package:appflowy/mobile/presentation/home/tab/mobile_space_tab.dart'; import 'package:appflowy/mobile/presentation/home/tab/space_order_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; import 'package:appflowy/workspace/application/recent/cached_recent_service.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy/workspace/presentation/home/errors/workspace_failed_screen.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide Log; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; +import 'package:sentry/sentry.dart'; +import 'package:toastification/toastification.dart'; class MobileHomeScreen extends StatelessWidget { const MobileHomeScreen({super.key}); @@ -57,6 +64,14 @@ class MobileHomeScreen extends StatelessWidget { return const WorkspaceFailedScreen(); } + Sentry.configureScope( + (scope) => scope.setUser( + SentryUser( + id: userProfile.id.toString(), + ), + ), + ); + return Scaffold( body: SafeArea( bottom: false, @@ -74,6 +89,9 @@ class MobileHomeScreen extends StatelessWidget { } } +final PropertyValueNotifier mCurrentWorkspace = + PropertyValueNotifier(null); + class MobileHomePage extends StatefulWidget { const MobileHomePage({ super.key, @@ -89,16 +107,20 @@ class MobileHomePage extends StatefulWidget { } 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(); } @@ -118,51 +140,167 @@ class _MobileHomePageState extends State { value: getIt()..add(const ReminderEvent.started()), ), ], - child: BlocConsumer( - buildWhen: (previous, current) => - previous.currentWorkspace?.workspaceId != - current.currentWorkspace?.workspaceId, - listener: (context, state) => getIt().reset(), - builder: (context, state) { - if (state.currentWorkspace == null) { - return const SizedBox.shrink(); - } - - return Column( - children: [ - // Header - Padding( - padding: EdgeInsets.only( - left: HomeSpaceViewSizes.mHorizontalPadding, - right: 8.0, - top: Platform.isAndroid ? 8.0 : 0.0, - ), - child: MobileHomePageHeader( - userProfile: widget.userProfile, - ), + child: _HomePage(userProfile: widget.userProfile), + ); + } + + void _onLatestViewChange() async { + final id = getIt().latestOpenView?.id; + if (id == null) { + return; + } + await FolderEventSetLatestView(ViewIdPB(value: id)).send(); + } +} + +class _HomePage extends StatefulWidget { + const _HomePage({required this.userProfile}); + + final UserProfilePB userProfile; + + @override + State<_HomePage> createState() => _HomePageState(); +} + +class _HomePageState extends State<_HomePage> { + Loading? loadingIndicator; + + @override + Widget build(BuildContext context) { + return BlocConsumer( + buildWhen: (previous, current) => + previous.currentWorkspace?.workspaceId != + current.currentWorkspace?.workspaceId, + listener: (context, state) { + getIt().reset(); + mCurrentWorkspace.value = state.currentWorkspace; + + Debounce.debounce( + 'workspace_action_result', + const Duration(milliseconds: 150), + () { + _showResultDialog(context, state); + }, + ); + }, + builder: (context, state) { + if (state.currentWorkspace == null) { + return const SizedBox.shrink(); + } + + final workspaceId = state.currentWorkspace!.workspaceId; + + return Column( + key: ValueKey('mobile_home_page_$workspaceId'), + children: [ + // Header + Padding( + padding: const EdgeInsets.only( + left: HomeSpaceViewSizes.mHorizontalPadding, + right: 8.0, + + ), + child: MobileHomePageHeader( + userProfile: widget.userProfile, ), + ), - Expanded( - child: BlocProvider( - create: (context) => - SpaceOrderBloc()..add(const SpaceOrderEvent.initial()), - child: MobileSpaceTab( - 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() + ..add( + SpaceEvent.initial( + widget.userProfile, + workspaceId, + openFirstPage: false, + ), + ), + ), + ], + child: MobileSpaceTab( + userProfile: widget.userProfile, ), ), - ], - ); - }, - ), + ), + ], + ); + }, ); } - void _onLatestViewChange() async { - final id = getIt().latestOpenView?.id; - if (id == null) { + void _showResultDialog(BuildContext context, UserWorkspaceState state) { + final actionResult = state.actionResult; + if (actionResult == null) { return; } - await FolderEventSetLatestView(ViewIdPB(value: id)).send(); + + 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.fold( + (s) { + toastType = ToastificationType.success; + return LocaleKeys.workspace_openSuccess.tr(); + }, + (e) { + toastType = ToastificationType.error; + return '${LocaleKeys.workspace_openFailed.tr()}: ${e.msg}'; + }, + ); + break; + + default: + message = null; + toastType = ToastificationType.error; + break; + } + + if (message != null) { + showToastNotification(context, message: message, type: toastType); + } } } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart index 28d0915aefc65..03d444e0b9035 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,10 +1,10 @@ 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/startup/startup.dart'; import 'package:appflowy/util/built_in_svgs.dart'; import 'package:appflowy/workspace/application/user/settings_user_bloc.dart'; @@ -17,6 +17,8 @@ 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, @@ -44,15 +46,10 @@ class MobileHomePageHeader extends StatelessWidget { ? _MobileWorkspace(userProfile: userProfile) : _MobileUser(userProfile: userProfile), ), - GestureDetector( - onTap: () => context.push( - MobileHomeSettingPage.routeName, - ), - child: const Padding( - padding: EdgeInsets.all(8.0), - child: FlowySvg(FlowySvgs.m_setting_m), - ), + HomePageSettingsPopupMenu( + userProfile: userProfile, ), + const HSpace(8.0), ], ), ); @@ -113,8 +110,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(), ); @@ -130,6 +129,7 @@ class _MobileWorkspace extends StatelessWidget { fontSize: 16.0, enableEdit: false, alignment: Alignment.centerLeft, + figmaLineHeight: 16.0, onSelected: (result) => context.read().add( UserWorkspaceEvent.updateWorkspaceIcon( currentWorkspace.workspaceId, @@ -143,7 +143,7 @@ class _MobileWorkspace extends StatelessWidget { : const HSpace(8), FlowyText.semibold( currentWorkspace.name, - fontSize: 16.0, + fontSize: 20.0, overflow: TextOverflow.ellipsis, ), ], @@ -162,9 +162,10 @@ class _MobileWorkspace extends StatelessWidget { showHeader: true, showDragHandle: true, showCloseButton: true, + useRootNavigator: true, title: LocaleKeys.workspace_menuTitle.tr(), backgroundColor: Theme.of(context).colorScheme.surface, - builder: (_) { + builder: (sheetContext) { return BlocProvider.value( value: context.read(), child: BlocBuilder( @@ -179,7 +180,7 @@ class _MobileWorkspace extends StatelessWidget { currentWorkspace: currentWorkspace, workspaces: workspaces, onWorkspaceSelected: (workspace) { - context.pop(); + Navigator.of(sheetContext).pop(); if (workspace == currentWorkspace) { return; 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 964f9e5aa5094..1e0ddb5a5163f 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 @@ -5,6 +5,7 @@ import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/mobile/presentation/setting/cloud/cloud_setting_group.dart'; import 'package:appflowy/mobile/presentation/setting/user_session_setting_group.dart'; +import 'package:appflowy/mobile/presentation/setting/workspace/workspace_setting_group.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; @@ -79,14 +80,14 @@ class _MobileHomeSettingPageState extends State { PersonalInfoSettingGroup( userProfile: userProfile, ), - // TODO: Enable and implement along with Push Notifications - // const NotificationsSettingGroup(), + const WorkspaceSettingGroup(), const AppearanceSettingGroup(), const LanguageSettingGroup(), if (Env.enableCustomCloud) const CloudSettingGroup(), const SupportSettingGroup(), const AboutSettingGroup(), UserSessionSettingGroup( + userProfile: userProfile, showThirdPartyLogin: showThirdPartyLogin, ), const VSpace(20), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_trash_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_trash_page.dart index 2ee9e14175685..73a5381d42c2c 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({ 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 index f3e807ec9cbeb..c0baa641d9bb0 100644 --- 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 @@ -68,38 +68,34 @@ class _RecentViews extends StatelessWidget { ? const Color(0xFFE9E9EC) : const Color(0x1AFFFFFF); return SlidableAutoCloseBehavior( - child: Scrollbar( - child: ListView.separated( - key: const PageStorageKey('recent_views_page_storage_key'), - padding: EdgeInsets.only( - left: HomeSpaceViewSizes.mHorizontalPadding, - right: HomeSpaceViewSizes.mHorizontalPadding, - 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: 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, - ), + ), + 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 4cd212f5a9ca1..d610b4452b9d7 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,6 +1,6 @@ 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/workspace/application/menu/sidebar_sections_bloc.dart'; @@ -43,47 +43,17 @@ class MobileSectionFolder extends StatelessWidget { onPressed: () => context .read() .add(const FolderEvent.expandOrUnExpand()), - onAdded: () { - context.read().add( - SidebarSectionsEvent.createRootViewInSection( - name: LocaleKeys.menuAppHeader_defaultNewPageName - .tr(), - index: 0, - viewSection: spaceType.toViewSectionPB, - ), - ); - context.read().add( - const FolderEvent.expandOrUnExpand(isExpanded: true), - ); - }, + onAdded: () => _createNewPage(context), ), ), if (state.isExpanded) - ...views.map( - (view) => MobileViewItem( - key: ValueKey( - '${FolderSpaceType.private.name} ${view.id}', - ), + Padding( + padding: const EdgeInsets.only( + left: HomeSpaceViewSizes.leftPadding, + ), + child: _Pages( + views: views, spaceType: spaceType, - isFirstChild: view.id == views.first.id, - view: view, - level: 0, - leftPadding: HomeSpaceViewSizes.leftPadding, - isFeedback: false, - onSelected: context.pushView, - endActionPane: (context) { - final view = context.read().state.view; - return buildEndActionPane( - context, - [ - MobilePaneActionType.more, - if (view.layout == ViewLayoutPB.Document) - MobilePaneActionType.add, - ], - spaceType: spaceType, - needSpace: false, - ); - }, ), ), ], @@ -92,4 +62,63 @@ 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: context.pushView, + endActionPane: (context) { + final view = context.read().state.view; + return buildEndActionPane( + context, + [ + MobilePaneActionType.more, + if (view.layout == ViewLayoutPB.Document) + MobilePaneActionType.add, + ], + spaceType: spaceType, + needSpace: false, + spaceRatio: 5, + ); + }, + ), + ) + .toList(), + ); + } } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/section_folder/mobile_home_section_folder_header.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/section_folder/mobile_home_section_folder_header.dart index aa84dc7601727..b1d2bf690925a 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 @@ -32,6 +32,7 @@ class _MobileSectionFolderHeaderState extends State { Widget build(BuildContext context) { return Row( children: [ + const HSpace(HomeSpaceViewSizes.mHorizontalPadding), Expanded( child: FlowyButton( text: FlowyText.medium( @@ -57,15 +58,19 @@ class _MobileSectionFolderHeaderState extends State { }, ), ), - FlowyIconButton( - key: mobileCreateNewPageButtonKey, - hoverColor: Theme.of(context).colorScheme.secondaryContainer, - height: HomeSpaceViewSizes.mViewButtonDimension, - width: HomeSpaceViewSizes.mViewButtonDimension, - icon: const FlowySvg( - FlowySvgs.m_space_add_s, + 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 0000000000000..f5afc10465b8d --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/setting/settings_popup_menu.dart @@ -0,0 +1,155 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/presentation.dart'; +import 'package:appflowy/mobile/presentation/setting/workspace/invite_members_screen.dart'; +import 'package:appflowy/shared/popup_menu/appflowy_popup_menu.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart' + hide PopupMenuButton, PopupMenuDivider, PopupMenuItem, PopupMenuEntry; +import 'package:go_router/go_router.dart'; + +enum _MobileSettingsPopupMenuItem { + settings, + members, + trash, + help, +} + +class HomePageSettingsPopupMenu extends StatelessWidget { + const HomePageSettingsPopupMenu({ + super.key, + required this.userProfile, + }); + + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + + return PopupMenuButton<_MobileSettingsPopupMenuItem>( + offset: const Offset(0, 36), + padding: EdgeInsets.zero, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(12.0), + ), + ), + shadowColor: const Color(0x68000000), + elevation: 10, + color: context.popupMenuBackgroundColor, + itemBuilder: (BuildContext context) => + >[ + _buildItem( + value: _MobileSettingsPopupMenuItem.settings, + svg: FlowySvgs.m_notification_settings_s, + text: LocaleKeys.settings_popupMenuItem_settings.tr(), + ), + // only show the member items in cloud mode + if (userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) ...[ + const PopupMenuDivider(height: 0.5), + _buildItem( + value: _MobileSettingsPopupMenuItem.members, + svg: FlowySvgs.m_settings_member_s, + text: LocaleKeys.settings_popupMenuItem_members.tr(), + ), + ], + const PopupMenuDivider(height: 0.5), + _buildItem( + value: _MobileSettingsPopupMenuItem.trash, + svg: FlowySvgs.trash_s, + text: LocaleKeys.settings_popupMenuItem_trash.tr(), + ), + const PopupMenuDivider(height: 0.5), + _buildItem( + value: _MobileSettingsPopupMenuItem.help, + svg: FlowySvgs.message_support_s, + text: LocaleKeys.settings_popupMenuItem_helpAndSupport.tr(), + ), + ], + onSelected: (_MobileSettingsPopupMenuItem value) { + switch (value) { + case _MobileSettingsPopupMenuItem.members: + _openMembersPage(context); + break; + case _MobileSettingsPopupMenuItem.trash: + _openTrashPage(context); + break; + case _MobileSettingsPopupMenuItem.settings: + _openSettingsPage(context); + break; + case _MobileSettingsPopupMenuItem.help: + _openHelpPage(context); + break; + } + }, + child: const Padding( + padding: EdgeInsets.all(8.0), + child: FlowySvg( + FlowySvgs.m_settings_more_s, + ), + ), + ); + } + + PopupMenuItem _buildItem({ + required T value, + required FlowySvgData svg, + required String text, + }) { + return PopupMenuItem( + value: value, + padding: EdgeInsets.zero, + child: _PopupButton( + svg: svg, + text: text, + ), + ); + } + + void _openMembersPage(BuildContext context) { + context.push(InviteMembersScreen.routeName); + } + + void _openTrashPage(BuildContext context) { + context.push(MobileHomeTrashPage.routeName); + } + + void _openHelpPage(BuildContext context) { + afLaunchUrlString('https://discord.com/invite/9Q2xaN37tV'); + } + + void _openSettingsPage(BuildContext context) { + context.push(MobileHomeSettingPage.routeName); + } +} + +class _PopupButton extends StatelessWidget { + const _PopupButton({ + required this.svg, + required this.text, + }); + + final FlowySvgData svg; + final String text; + + @override + Widget build(BuildContext context) { + return Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + FlowySvg(svg, size: const Size.square(20)), + const HSpace(12), + FlowyText.regular( + text, + fontSize: 16, + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/empty_placeholder.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/empty_placeholder.dart index 341acb8099d67..2de40600f2cc8 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/empty_placeholder.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/empty_placeholder.dart @@ -6,7 +6,10 @@ 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}); + const EmptySpacePlaceholder({ + super.key, + required this.type, + }); final MobilePageCardType type; @@ -36,6 +39,7 @@ class EmptySpacePlaceholder extends StatelessWidget { lineHeight: 1.3, color: Theme.of(context).hintColor, ), + const VSpace(kBottomNavigationBarHeight + 36.0), ], ), ); 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 index 71be20453dd96..f5e031666af8d 100644 --- 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 @@ -5,6 +5,7 @@ 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/plugins.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; @@ -15,6 +16,7 @@ import 'package:appflowy/workspace/application/settings/appearance/appearance_cu 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'; @@ -76,13 +78,14 @@ class MobileViewPage extends StatelessWidget { : MobilePaneActionType.addToFavorites, ], cardType: type, + spaceRatio: 4, ), - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTapUp: (_) => context.pushView(view), + child: AnimatedGestureDetector( + onTapUp: () => context.pushView(view), child: Row( mainAxisSize: MainAxisSize.min, children: [ + const HSpace(HomeSpaceViewSizes.mHorizontalPadding), Expanded(child: _buildDescription(context, state)), const HSpace(20.0), SizedBox( @@ -90,6 +93,7 @@ class MobileViewPage extends StatelessWidget { height: 60, child: _buildCover(context, state), ), + const HSpace(HomeSpaceViewSizes.mHorizontalPadding), ], ), ), @@ -211,7 +215,7 @@ class MobileViewPage extends StatelessWidget { Widget _buildLastViewed(BuildContext context) { final textColor = Theme.of(context).isLightMode - ? const Color(0xFF171717) + ? const Color(0x7F171717) : Colors.white.withOpacity(0.45); if (timestamp == null) { return const SizedBox.shrink(); 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 index 48ef17e86c987..41f36f7b709e6 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space.dart @@ -4,37 +4,21 @@ 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/mobile/presentation/presentation.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 StatefulWidget { +class MobileSpace extends StatelessWidget { const MobileSpace({super.key}); - @override - State createState() => _MobileSpaceState(); -} - -class _MobileSpaceState extends State { - @override - void initState() { - super.initState(); - createNewPageNotifier.addListener(_createNewPage); - } - - @override - void dispose() { - createNewPageNotifier.removeListener(_createNewPage); - super.dispose(); - } - @override Widget build(BuildContext context) { return BlocBuilder( @@ -50,23 +34,17 @@ class _MobileSpaceState extends State { MobileSpaceHeader( isExpanded: state.isExpanded, space: currentSpace, - onAdded: () { - context.read().add( - SpaceEvent.createPage( - name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), - layout: ViewLayoutPB.Document, - index: 0, - ), - ); - context.read().add( - SpaceEvent.expand(currentSpace, true), - ); - }, + onAdded: () => _showCreatePageMenu(context, currentSpace), onPressed: () => _showSpaceMenu(context), ), - _Pages( - key: ValueKey(currentSpace.id), - space: currentSpace, + Padding( + padding: const EdgeInsets.only( + left: HomeSpaceViewSizes.mHorizontalPadding, + ), + child: _Pages( + key: ValueKey(currentSpace.id), + space: currentSpace, + ), ), ], ); @@ -82,6 +60,7 @@ class _MobileSpaceState extends State { showDragHandle: true, showCloseButton: true, showDoneButton: true, + useRootNavigator: true, title: LocaleKeys.space_title.tr(), backgroundColor: Theme.of(context).colorScheme.surface, builder: (_) { @@ -96,13 +75,36 @@ class _MobileSpaceState extends State { ); } - void _createNewPage() { - context.read().add( - SpaceEvent.createPage( - name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), - layout: ViewLayoutPB.Document, - ), + 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: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), + layout: layout, + index: 0, + ), + ); + context.read().add( + SpaceEvent.expand(space, true), + ); + }, ); + }, + ); } } @@ -124,8 +126,15 @@ class _Pages extends StatelessWidget { 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: state.view.childViews + children: childViews .map( (view) => MobileViewItem( key: ValueKey( @@ -140,15 +149,16 @@ class _Pages extends StatelessWidget { onSelected: context.pushView, endActionPane: (context) { final view = context.read().state.view; + final actions = [ + MobilePaneActionType.more, + if (view.layout == ViewLayoutPB.Document) + MobilePaneActionType.add, + ]; return buildEndActionPane( context, - [ - MobilePaneActionType.more, - if (view.layout == ViewLayoutPB.Document) - MobilePaneActionType.add, - ], + actions, spaceType: spaceType, - needSpace: false, + spaceRatio: actions.length == 1 ? 3 : 4, ); }, ), 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 index ffc16914048ae..0cd80ff1bbf3a 100644 --- 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 @@ -1,4 +1,5 @@ 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'; @@ -30,9 +31,12 @@ class MobileSpaceHeader extends StatelessWidget { 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), @@ -49,8 +53,15 @@ class MobileSpaceHeader extends StatelessWidget { GestureDetector( behavior: HitTestBehavior.translucent, onTap: onAdded, - child: const FlowySvg( - FlowySvgs.m_space_add_s, + child: Container( + // expand the touch area + margin: const EdgeInsets.symmetric( + horizontal: HomeSpaceViewSizes.mHorizontalPadding, + vertical: 8.0, + ), + child: const FlowySvg( + FlowySvgs.m_space_add_s, + ), ), ), ], diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_menu.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_menu.dart index 6788e1396d4cb..2fe580cecd68d 100644 --- 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 @@ -59,6 +59,7 @@ class _SidebarSpaceMenuItem extends StatelessWidget { children: [ FlowyText.medium( space.name, + fontSize: 16.0, ), const HSpace(6.0), if (space.spacePermission == SpacePermission.private) @@ -68,16 +69,19 @@ class _SidebarSpaceMenuItem extends StatelessWidget { ), ], ), + 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(20), + leftIconSize: const Size.square(24), rightIcon: isSelected ? const FlowySvg( - FlowySvgs.workspace_selected_s, + FlowySvgs.m_blue_check_s, blendMode: null, ) : null, 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 index 5602f46f895df..f8c9a0d3b11ad 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/_tab_bar.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/_tab_bar.dart @@ -21,12 +21,14 @@ class MobileSpaceTabBar extends StatelessWidget { Widget build(BuildContext context) { final baseStyle = Theme.of(context).textTheme.bodyMedium; final labelStyle = baseStyle?.copyWith( - fontWeight: FontWeight.w600, + 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( diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/ai_bubble_button.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/ai_bubble_button.dart new file mode 100644 index 0000000000000..a5cb39ffaa6fd --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/ai_bubble_button.dart @@ -0,0 +1,81 @@ +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: DecoratedBox( + decoration: _buildShadowDecoration(context), + child: Container( + decoration: _buildWrapperDecoration(context), + height: 48, + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only(left: 18), + child: _buildHintText(context), + ), + ), + ), + ); + } + + BoxDecoration _buildShadowDecoration(BuildContext context) { + return BoxDecoration( + borderRadius: BorderRadius.circular(30), + boxShadow: [ + BoxShadow( + blurRadius: 20, + spreadRadius: 1, + offset: const Offset(0, 4), + color: Colors.black.withOpacity(0.05), + ), + ], + ); + } + + BoxDecoration _buildWrapperDecoration(BuildContext context) { + final outlineColor = Theme.of(context).colorScheme.outline; + final borderColor = Theme.of(context).isLightMode + ? outlineColor.withOpacity(0.7) + : outlineColor.withOpacity(0.3); + return BoxDecoration( + borderRadius: BorderRadius.circular(30), + color: Theme.of(context).colorScheme.surface, + border: Border.fromBorderSide( + BorderSide( + color: borderColor, + ), + ), + ); + } + + Widget _buildHintText(BuildContext context) { + return Row( + children: [ + FlowySvg( + FlowySvgs.toolbar_item_ai_s, + size: const Size.square(16.0), + color: Theme.of(context).hintColor, + opacity: 0.7, + ), + const HSpace(8), + FlowyText( + LocaleKeys.chat_inputMessageHint.tr(), + color: Theme.of(context).hintColor, + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/mobile_space_tab.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/mobile_space_tab.dart index 097bd22910826..8181e0a10aa3e 100644 --- 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 @@ -1,16 +1,34 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/mobile/presentation/home/favorite_folder/favorite_space.dart'; import 'package:appflowy/mobile/presentation/home/home_space/home_space.dart'; import 'package:appflowy/mobile/presentation/home/recent_folder/recent_space.dart'; import 'package:appflowy/mobile/presentation/home/tab/_tab_bar.dart'; import 'package:appflowy/mobile/presentation/home/tab/space_order_bloc.dart'; +import 'package:appflowy/mobile/presentation/presentation.dart'; +import 'package:appflowy/mobile/presentation/setting/workspace/invite_members_screen.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_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}); + const MobileSpaceTab({ + super.key, + required this.userProfile, + }); final UserProfilePB userProfile; @@ -22,10 +40,24 @@ 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(); } @@ -33,36 +65,60 @@ class _MobileSpaceTabState extends State Widget build(BuildContext context) { return Provider.value( value: widget.userProfile, - child: BlocBuilder( - builder: (context, state) { - if (state.isLoading) { - return const SizedBox.shrink(); - } + child: MultiBlocListener( + listeners: [ + BlocListener( + listenWhen: (p, c) => + p.lastCreatedPage?.id != c.lastCreatedPage?.id, + listener: (context, state) { + final lastCreatedPage = state.lastCreatedPage; + if (lastCreatedPage != null) { + context.pushView(lastCreatedPage); + } + }, + ), + BlocListener( + listenWhen: (p, c) => + p.lastCreatedRootView?.id != c.lastCreatedRootView?.id, + listener: (context, state) { + final lastCreatedPage = state.lastCreatedRootView; + if (lastCreatedPage != null) { + context.pushView(lastCreatedPage); + } + }, + ), + ], + child: BlocBuilder( + builder: (context, state) { + if (state.isLoading) { + return const SizedBox.shrink(); + } - _initTabController(state); + _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), + 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), + ), + ), + ], + ); + }, + ), ), ); } @@ -96,7 +152,20 @@ class _MobileSpaceTabState extends State case MobileSpaceTabType.recent: return const MobileRecentSpace(); case MobileSpaceTabType.spaces: - return MobileHomeSpace(userProfile: widget.userProfile); + return Stack( + children: [ + MobileHomeSpace(userProfile: widget.userProfile), + // only show ai chat button for cloud user + if (widget.userProfile.authenticator == + AuthenticatorPB.AppFlowyCloud) + Positioned( + bottom: MediaQuery.of(context).padding.bottom + 16, + left: 20, + right: 20, + child: const FloatingAIEntry(), + ), + ], + ); case MobileSpaceTabType.favorites: return MobileFavoriteSpace(userProfile: widget.userProfile); default: @@ -104,4 +173,45 @@ class _MobileSpaceTabState extends State } }).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: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), + layout: layout, + ), + ); + } else if (layout == ViewLayoutPB.Document) { + // only support create document in section + context.read().add( + SidebarSectionsEvent.createRootViewInSection( + name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), + index: 0, + viewSection: FolderSpaceType.public.toViewSectionPB, + ), + ); + } + } + + void _leaveWorkspace() { + final workspaceId = + context.read().state.currentWorkspace?.workspaceId; + if (workspaceId == null) { + Log.error('Workspace ID is null'); + return; + } + context + .read() + .add(UserWorkspaceEvent.leaveWorkspace(workspaceId)); + } } 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 17962bcc3c265..53d28387e95fa 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 @@ -138,17 +138,21 @@ class _WorkspaceMenuItem extends StatelessWidget { height: 60, showTopBorder: showTopBorder, showBottomBorder: false, - leftIcon: WorkspaceIcon( - enableEdit: false, - iconSize: 26, - fontSize: 16.0, - workspace: workspace, - onSelected: (result) => context.read().add( - UserWorkspaceEvent.updateWorkspaceIcon( - workspace.workspaceId, - result.emoji, + leftIcon: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: WorkspaceIcon( + enableEdit: false, + iconSize: 26, + fontSize: 16.0, + figmaLineHeight: 16.0, + workspace: workspace, + onSelected: (result) => context.read().add( + UserWorkspaceEvent.updateWorkspaceIcon( + workspace.workspaceId, + result.emoji, + ), ), - ), + ), ), trailing: workspace.workspaceId == currentWorkspace.workspaceId ? const FlowySvg( 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 5d33dfa500551..1de6c568d3067 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,14 +1,33 @@ +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'; -final PropertyValueNotifier createNewPageNotifier = +enum BottomNavigationBarActionType { + home, + notificationMultiSelect, +} + +final PropertyValueNotifier mobileCreateNewPageNotifier = PropertyValueNotifier(null); +final ValueNotifier bottomNavigationBarType = + ValueNotifier(BottomNavigationBarActionType.home); const _homeLabel = 'home'; const _addLabel = 'add'; @@ -25,16 +44,16 @@ final _items = [ ), const BottomNavigationBarItem( label: _notificationLabel, - icon: FlowySvg(FlowySvgs.m_home_notification_m), - activeIcon: FlowySvg( - FlowySvgs.m_home_notification_m, + icon: _NotificationNavigationBarItemIcon(), + activeIcon: _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, @@ -44,30 +63,140 @@ 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 isLightMode = Theme.of(context).isLightMode; - final backgroundColor = isLightMode - ? Colors.white.withOpacity(0.95) - : const Color(0xFF23262B).withOpacity(0.95); + _bottomNavigationBar = switch (bottomNavigationBarType.value) { + BottomNavigationBarActionType.home => + _buildHomePageNavigationBar(context), + BottomNavigationBarActionType.notificationMultiSelect => + _buildNotificationNavigationBar(context), + }; + return Scaffold( - body: navigationShell, + body: widget.navigationShell, extendBody: true, - bottomNavigationBar: ClipRRect( - child: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: 3, - sigmaY: 3, - ), - child: DecoratedBox( - decoration: BoxDecoration( - border: isLightMode - ? Border( - top: BorderSide(color: Theme.of(context).dividerColor), + 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, ) - : null, - color: backgroundColor, - ), + : 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, @@ -85,13 +214,32 @@ class MobileBottomNavigationBar extends StatelessWidget { ); } + 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) { - if (_items[bottomBarIndex].label == _addLabel) { + // close the popup menu + closePopupMenu(); + + final label = _items[bottomBarIndex].label; + if (label == _addLabel) { // show an add dialog - createNewPageNotifier.value = ViewLayoutPB.Document; + mobileCreateNewPageNotifier.value = ViewLayoutPB.Document; return; + } else if (label == _notificationLabel) { + 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 @@ -106,3 +254,112 @@ 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( + context, + message: LocaleKeys + .settings_notifications_markAsReadNotifications_allSuccess + .tr(), + ); + + getIt() + .add(ReminderEvent.markAsRead(mSelectedNotificationIds.value)); + + mSelectedNotificationIds.value = []; + } + + void _onArchive(BuildContext context) { + if (mSelectedNotificationIds.value.isEmpty) { + return; + } + + showToastNotification( + context, + message: LocaleKeys.settings_notifications_archiveNotifications_allSuccess + .tr(), + ); + + getIt() + .add(ReminderEvent.archive(mSelectedNotificationIds.value)); + + mSelectedNotificationIds.value = []; + } +} + +extension on BuildContext { + Color get backgroundColor { + return Theme.of(this).isLightMode + ? Colors.white.withOpacity(0.95) + : const Color(0xFF23262B).withOpacity(0.95); + } + + Color get borderColor { + return Theme.of(this).isLightMode + ? const Color(0x141F2329) + : const Color(0xFF23262B).withOpacity(0.5); + } + + Border? get border { + return Theme.of(this).isLightMode + ? Border(top: BorderSide(color: borderColor)) + : null; + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_multiple_select_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_multiple_select_page.dart new file mode 100644 index 0000000000000..cf7ce35e802ba --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_multiple_select_page.dart @@ -0,0 +1,59 @@ +import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart'; +import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MobileNotificationsMultiSelectScreen extends StatelessWidget { + const MobileNotificationsMultiSelectScreen({super.key}); + + static const routeName = '/notifications_multi_select'; + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: getIt(), + child: const MobileNotificationMultiSelect(), + ); + } +} + +class MobileNotificationMultiSelect extends StatefulWidget { + const MobileNotificationMultiSelect({ + super.key, + }); + + @override + State createState() => + _MobileNotificationMultiSelectState(); +} + +class _MobileNotificationMultiSelectState + extends State { + @override + void dispose() { + mSelectedNotificationIds.value.clear(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MobileNotificationMultiSelectPageHeader(), + VSpace(12.0), + Expanded( + child: MultiSelectNotificationTab(), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_screen.dart new file mode 100644 index 0000000000000..b1219d8e9874c --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_screen.dart @@ -0,0 +1,129 @@ +import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_multiple_select_page.dart'; +import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart'; +import 'package:appflowy/mobile/presentation/presentation.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +final PropertyValueNotifier> mSelectedNotificationIds = + PropertyValueNotifier([]); + +class MobileNotificationsScreenV2 extends StatefulWidget { + const MobileNotificationsScreenV2({super.key}); + + static const routeName = '/notifications'; + + @override + State createState() => + _MobileNotificationsScreenV2State(); +} + +class _MobileNotificationsScreenV2State + extends State + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + void initState() { + super.initState(); + + mCurrentWorkspace.addListener(_onRefresh); + } + + @override + void dispose() { + mCurrentWorkspace.removeListener(_onRefresh); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + + return BlocProvider.value( + value: getIt(), + child: ValueListenableBuilder( + valueListenable: bottomNavigationBarType, + builder: (_, value, __) { + switch (value) { + case BottomNavigationBarActionType.home: + return const MobileNotificationsTab(); + case BottomNavigationBarActionType.notificationMultiSelect: + return const MobileNotificationMultiSelect(); + } + }, + ), + ); + } + + void _onRefresh() { + getIt().add(const ReminderEvent.refresh()); + } +} + +class MobileNotificationsTab extends StatefulWidget { + const MobileNotificationsTab({ + super.key, + }); + + @override + State createState() => _MobileNotificationsTabState(); +} + +class _MobileNotificationsTabState extends State + with SingleTickerProviderStateMixin { + late TabController tabController; + + final tabs = [ + MobileNotificationTabType.inbox, + MobileNotificationTabType.unread, + MobileNotificationTabType.archive, + ]; + + @override + void initState() { + super.initState(); + + tabController = TabController( + length: 3, + vsync: this, + ); + } + + @override + void dispose() { + tabController.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const MobileNotificationPageHeader(), + MobileNotificationTabBar( + tabController: tabController, + tabs: tabs, + ), + const VSpace(12.0), + Expanded( + child: TabBarView( + controller: tabController, + children: tabs.map((e) => NotificationTab(tabType: e)).toList(), + ), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/color.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/color.dart new file mode 100644 index 0000000000000..8a59336378028 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/color.dart @@ -0,0 +1,11 @@ +import 'package:appflowy/util/theme_extension.dart'; +import 'package:flutter/material.dart'; + +extension NotificationItemColors on BuildContext { + Color get notificationItemTextColor { + if (Theme.of(this).isLightMode) { + return const Color(0xFF171717); + } + return const Color(0xFFffffff).withOpacity(0.8); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/empty.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/empty.dart new file mode 100644 index 0000000000000..e5598cc6e591a --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/empty.dart @@ -0,0 +1,58 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class EmptyNotification extends StatelessWidget { + const EmptyNotification({ + super.key, + required this.type, + }); + + final MobileNotificationTabType type; + + @override + Widget build(BuildContext context) { + final title = switch (type) { + MobileNotificationTabType.inbox => + LocaleKeys.settings_notifications_emptyInbox_title.tr(), + MobileNotificationTabType.archive => + LocaleKeys.settings_notifications_emptyArchived_title.tr(), + MobileNotificationTabType.unread => + LocaleKeys.settings_notifications_emptyUnread_title.tr(), + }; + final desc = switch (type) { + MobileNotificationTabType.inbox => + LocaleKeys.settings_notifications_emptyInbox_description.tr(), + MobileNotificationTabType.archive => + LocaleKeys.settings_notifications_emptyArchived_description.tr(), + MobileNotificationTabType.unread => + LocaleKeys.settings_notifications_emptyUnread_description.tr(), + }; + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const FlowySvg(FlowySvgs.m_empty_notification_xl), + const VSpace(12.0), + FlowyText( + title, + fontSize: 16.0, + figmaLineHeight: 24.0, + fontWeight: FontWeight.w500, + ), + const VSpace(4.0), + Opacity( + opacity: 0.45, + child: FlowyText( + desc, + fontSize: 15.0, + figmaLineHeight: 22.0, + fontWeight: FontWeight.w400, + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/header.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/header.dart new file mode 100644 index 0000000000000..9f1311d1e714f --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/header.dart @@ -0,0 +1,96 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart'; +import 'package:appflowy/mobile/presentation/notifications/widgets/settings_popup_menu.dart'; +import 'package:appflowy/mobile/presentation/presentation.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class MobileNotificationPageHeader extends StatelessWidget { + const MobileNotificationPageHeader({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(minHeight: 56), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const HSpace(18.0), + FlowyText( + LocaleKeys.settings_notifications_titles_notifications.tr(), + fontSize: 20, + fontWeight: FontWeight.w600, + ), + const Spacer(), + const NotificationSettingsPopupMenu(), + const HSpace(16.0), + ], + ), + ); + } +} + +class MobileNotificationMultiSelectPageHeader extends StatelessWidget { + const MobileNotificationMultiSelectPageHeader({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(minHeight: 56), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildCancelButton( + isOpaque: false, + padding: const EdgeInsets.symmetric(horizontal: 16), + onTap: () => bottomNavigationBarType.value = + BottomNavigationBarActionType.home, + ), + ValueListenableBuilder( + valueListenable: mSelectedNotificationIds, + builder: (_, value, __) { + return FlowyText( + // todo: i18n + '${value.length} Selected', + fontSize: 17.0, + figmaLineHeight: 24.0, + fontWeight: FontWeight.w500, + ); + }, + ), + // this button is used to align the text to the center + _buildCancelButton( + isOpaque: true, + padding: const EdgeInsets.symmetric(horizontal: 16), + ), + ], + ), + ); + } + + // + Widget _buildCancelButton({ + required bool isOpaque, + required EdgeInsets padding, + VoidCallback? onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Padding( + padding: padding, + child: FlowyText( + LocaleKeys.button_cancel.tr(), + fontSize: 17.0, + figmaLineHeight: 24.0, + fontWeight: FontWeight.w400, + color: isOpaque ? Colors.transparent : null, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/multi_select_notification_item.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/multi_select_notification_item.dart new file mode 100644 index 0000000000000..ea57d5d391c23 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/multi_select_notification_item.dart @@ -0,0 +1,115 @@ +import 'package:appflowy/mobile/application/notification/notification_reminder_bloc.dart'; +import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; +import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart'; +import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MultiSelectNotificationItem extends StatelessWidget { + const MultiSelectNotificationItem({ + super.key, + required this.reminder, + }); + + final ReminderPB reminder; + + @override + Widget build(BuildContext context) { + final settings = context.read().state; + final dateFormate = settings.dateFormat; + final timeFormate = settings.timeFormat; + return BlocProvider( + create: (context) => NotificationReminderBloc() + ..add( + NotificationReminderEvent.initial( + reminder, + dateFormate, + timeFormate, + ), + ), + child: BlocBuilder( + builder: (context, state) { + if (state.status == NotificationReminderStatus.loading || + state.status == NotificationReminderStatus.initial) { + return const SizedBox.shrink(); + } + + if (state.status == NotificationReminderStatus.error) { + // error handle. + return const SizedBox.shrink(); + } + + final child = ValueListenableBuilder( + valueListenable: mSelectedNotificationIds, + builder: (_, selectedIds, child) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 12), + decoration: selectedIds.contains(reminder.id) + ? ShapeDecoration( + color: const Color(0x1900BCF0), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ) + : null, + child: child, + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: _InnerNotificationItem( + reminder: reminder, + ), + ), + ); + + return AnimatedGestureDetector( + scaleFactor: 0.99, + onTapUp: () { + if (mSelectedNotificationIds.value.contains(reminder.id)) { + mSelectedNotificationIds.value = mSelectedNotificationIds.value + ..remove(reminder.id); + } else { + mSelectedNotificationIds.value = mSelectedNotificationIds.value + ..add(reminder.id); + } + }, + child: child, + ); + }, + ), + ); + } +} + +class _InnerNotificationItem extends StatelessWidget { + const _InnerNotificationItem({ + required this.reminder, + }); + + final ReminderPB reminder; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const HSpace(10.0), + NotificationCheckIcon( + isSelected: mSelectedNotificationIds.value.contains(reminder.id), + ), + const HSpace(3.0), + !reminder.isRead ? const UnreadRedDot() : const HSpace(6.0), + const HSpace(3.0), + NotificationIcon(reminder: reminder), + const HSpace(12.0), + Expanded( + child: NotificationContent(reminder: reminder), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/notification_item.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/notification_item.dart new file mode 100644 index 0000000000000..8f6db18265da5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/notification_item.dart @@ -0,0 +1,164 @@ +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; + if (view == null) { + return; + } + + await context.pushView(view); + + if (!reminder.isRead && context.mounted) { + context.read().add( + ReminderEvent.markAsRead([reminder.id]), + ); + } + }, + ); + }, + ), + ); + } +} + +class _InnerNotificationItem extends StatelessWidget { + const _InnerNotificationItem({ + required this.reminder, + required this.tabType, + }); + + final MobileNotificationTabType tabType; + final ReminderPB reminder; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const HSpace(8.0), + !reminder.isRead ? const UnreadRedDot() : const HSpace(6.0), + const HSpace(4.0), + NotificationIcon(reminder: reminder), + const HSpace(12.0), + Expanded( + child: NotificationContent(reminder: reminder), + ), + ], + ); + } +} + +class _SlidableNotificationItem extends StatelessWidget { + const _SlidableNotificationItem({ + required this.tabType, + required this.reminder, + required this.child, + }); + + final MobileNotificationTabType tabType; + final ReminderPB reminder; + final Widget child; + + @override + Widget build(BuildContext context) { + final List actions = switch (tabType) { + MobileNotificationTabType.inbox => [ + NotificationPaneActionType.more, + if (!reminder.isRead) NotificationPaneActionType.markAsRead, + ], + MobileNotificationTabType.unread => [ + NotificationPaneActionType.more, + NotificationPaneActionType.markAsRead, + ], + MobileNotificationTabType.archive => [ + if (kDebugMode) NotificationPaneActionType.unArchive, + ], + }; + + if (actions.isEmpty) { + return child; + } + + final children = actions + .map( + (action) => action.actionButton( + context, + tabType: tabType, + ), + ) + .toList(); + + final extentRatio = actions.length == 1 ? 1 / 5 : 1 / 3; + + return Slidable( + endActionPane: ActionPane( + motion: const ScrollMotion(), + extentRatio: extentRatio, + children: children, + ), + child: child, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/settings_popup_menu.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/settings_popup_menu.dart new file mode 100644 index 0000000000000..dfa277f2ef33d --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/settings_popup_menu.dart @@ -0,0 +1,170 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/presentation.dart'; +import 'package:appflowy/shared/popup_menu/appflowy_popup_menu.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' + hide PopupMenuButton, PopupMenuDivider, PopupMenuItem, PopupMenuEntry; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +enum _NotificationSettingsPopupMenuItem { + settings, + markAllAsRead, + archiveAll, + // only visible in debug mode + unarchiveAll; +} + +class NotificationSettingsPopupMenu extends StatelessWidget { + const NotificationSettingsPopupMenu({super.key}); + + @override + Widget build(BuildContext context) { + return PopupMenuButton<_NotificationSettingsPopupMenuItem>( + offset: const Offset(0, 36), + padding: EdgeInsets.zero, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(12.0), + ), + ), + // todo: replace it with shadows + shadowColor: const Color(0x68000000), + elevation: 10, + color: context.popupMenuBackgroundColor, + itemBuilder: (BuildContext context) => + >[ + _buildItem( + value: _NotificationSettingsPopupMenuItem.settings, + svg: FlowySvgs.m_notification_settings_s, + text: LocaleKeys.settings_notifications_settings_settings.tr(), + ), + const PopupMenuDivider(height: 0.5), + _buildItem( + value: _NotificationSettingsPopupMenuItem.markAllAsRead, + svg: FlowySvgs.m_notification_mark_as_read_s, + text: LocaleKeys.settings_notifications_settings_markAllAsRead.tr(), + ), + const PopupMenuDivider(height: 0.5), + _buildItem( + value: _NotificationSettingsPopupMenuItem.archiveAll, + svg: FlowySvgs.m_notification_archived_s, + text: LocaleKeys.settings_notifications_settings_archiveAll.tr(), + ), + // only visible in debug mode + if (kDebugMode) ...[ + const PopupMenuDivider(height: 0.5), + _buildItem( + value: _NotificationSettingsPopupMenuItem.unarchiveAll, + svg: FlowySvgs.m_notification_archived_s, + text: 'Unarchive all (Debug Mode)', + ), + ], + ], + onSelected: (_NotificationSettingsPopupMenuItem value) { + switch (value) { + case _NotificationSettingsPopupMenuItem.markAllAsRead: + _onMarkAllAsRead(context); + break; + case _NotificationSettingsPopupMenuItem.archiveAll: + _onArchiveAll(context); + break; + case _NotificationSettingsPopupMenuItem.settings: + context.push(MobileHomeSettingPage.routeName); + break; + case _NotificationSettingsPopupMenuItem.unarchiveAll: + _onUnarchiveAll(context); + break; + } + }, + child: const Padding( + padding: EdgeInsets.all(8.0), + child: FlowySvg( + FlowySvgs.m_settings_more_s, + ), + ), + ); + } + + PopupMenuItem _buildItem({ + required T value, + required FlowySvgData svg, + required String text, + }) { + return PopupMenuItem( + value: value, + padding: EdgeInsets.zero, + child: _PopupButton( + svg: svg, + text: text, + ), + ); + } + + void _onMarkAllAsRead(BuildContext context) { + showToastNotification( + context, + message: LocaleKeys + .settings_notifications_markAsReadNotifications_allSuccess + .tr(), + ); + + context.read().add(const ReminderEvent.markAllRead()); + } + + void _onArchiveAll(BuildContext context) { + showToastNotification( + context, + message: LocaleKeys.settings_notifications_archiveNotifications_allSuccess + .tr(), + ); + + context.read().add(const ReminderEvent.archiveAll()); + } + + void _onUnarchiveAll(BuildContext context) { + if (!kDebugMode) { + return; + } + + showToastNotification( + context, + message: 'Unarchive all success (Debug Mode)', + ); + + context.read().add(const ReminderEvent.unarchiveAll()); + } +} + +class _PopupButton extends StatelessWidget { + const _PopupButton({ + required this.svg, + required this.text, + }); + + final FlowySvgData svg; + final String text; + + @override + Widget build(BuildContext context) { + return Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + FlowySvg(svg), + const HSpace(12), + FlowyText.regular( + text, + fontSize: 16, + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/shared.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/shared.dart new file mode 100644 index 0000000000000..4f1a7d2db051e --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/shared.dart @@ -0,0 +1,286 @@ +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'; + +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 = getEditorBuilderMap( + context: context, + editorState: editorState, + styleCustomizer: styleCustomizer, + // the editor is not editable in the chat + editable: false, + customHeadingPadding: EdgeInsets.zero, + ); + + return IgnorePointer( + child: Opacity( + opacity: reminder.type == ReminderType.past ? 0.3 : 1, + child: AppFlowyEditor( + editorState: editorState, + editorStyle: editorStyle, + disableSelectionService: true, + disableKeyboardService: true, + disableScrollService: true, + editable: false, + shrinkWrap: true, + blockComponentBuilders: blockBuilders, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/slide_actions.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/slide_actions.dart new file mode 100644 index 0000000000000..85f468c76c1ee --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/slide_actions.dart @@ -0,0 +1,212 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/notification/notification_reminder_bloc.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart'; +import 'package:appflowy/mobile/presentation/page_item/mobile_slide_action_button.dart'; +import 'package:appflowy/mobile/presentation/presentation.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/user/application/reminder/reminder_extension.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +enum NotificationPaneActionType { + more, + markAsRead, + // only used in the debug mode. + unArchive; + + MobileSlideActionButton actionButton( + BuildContext context, { + required MobileNotificationTabType tabType, + }) { + switch (this) { + case NotificationPaneActionType.markAsRead: + return MobileSlideActionButton( + backgroundColor: const Color(0xFF00C8FF), + svg: FlowySvgs.m_notification_action_mark_as_read_s, + size: 24.0, + onPressed: (context) { + showToastNotification( + context, + message: LocaleKeys + .settings_notifications_markAsReadNotifications_success + .tr(), + ); + + context.read().add( + ReminderEvent.update( + ReminderUpdate( + id: context.read().reminder.id, + isRead: true, + ), + ), + ); + }, + ); + // this action is only used in the debug mode. + case NotificationPaneActionType.unArchive: + return MobileSlideActionButton( + backgroundColor: const Color(0xFF00C8FF), + svg: FlowySvgs.m_notification_action_mark_as_read_s, + size: 24.0, + onPressed: (context) { + showToastNotification( + context, + message: 'Unarchive notification success', + ); + + context.read().add( + ReminderEvent.update( + ReminderUpdate( + id: context.read().reminder.id, + isArchived: false, + ), + ), + ); + }, + ); + case NotificationPaneActionType.more: + return MobileSlideActionButton( + backgroundColor: const Color(0xE5515563), + svg: FlowySvgs.three_dots_s, + size: 24.0, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(10), + bottomLeft: Radius.circular(10), + ), + onPressed: (context) { + final reminderBloc = context.read(); + final notificationReminderBloc = + context.read(); + + showMobileBottomSheet( + context, + showDragHandle: true, + showDivider: false, + useRootNavigator: true, + backgroundColor: Theme.of(context).colorScheme.surface, + builder: (_) { + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: reminderBloc), + BlocProvider.value(value: notificationReminderBloc), + ], + child: _NotificationMoreActions( + onClickMultipleChoice: () { + Future.delayed(const Duration(milliseconds: 250), () { + bottomNavigationBarType.value = + BottomNavigationBarActionType + .notificationMultiSelect; + }); + }, + ), + ); + }, + ); + }, + ); + } + } +} + +class _NotificationMoreActions extends StatelessWidget { + const _NotificationMoreActions({ + required this.onClickMultipleChoice, + }); + + final VoidCallback onClickMultipleChoice; + + @override + Widget build(BuildContext context) { + final reminder = context.read().reminder; + return Column( + children: [ + if (!reminder.isRead) + FlowyOptionTile.text( + height: 52.0, + text: LocaleKeys.settings_notifications_action_markAsRead.tr(), + leftIcon: const FlowySvg( + FlowySvgs.m_notification_action_mark_as_read_s, + size: Size.square(20), + ), + showTopBorder: false, + showBottomBorder: false, + onTap: () => _onMarkAsRead(context), + ), + FlowyOptionTile.text( + height: 52.0, + text: LocaleKeys.settings_notifications_action_multipleChoice.tr(), + leftIcon: const FlowySvg( + FlowySvgs.m_notification_action_multiple_choice_s, + size: Size.square(20), + ), + showTopBorder: false, + showBottomBorder: false, + onTap: () => _onMultipleChoice(context), + ), + if (!reminder.isArchived) + FlowyOptionTile.text( + height: 52.0, + text: LocaleKeys.settings_notifications_action_archive.tr(), + leftIcon: const FlowySvg( + FlowySvgs.m_notification_action_archive_s, + size: Size.square(20), + ), + showTopBorder: false, + showBottomBorder: false, + onTap: () => _onArchive(context), + ), + ], + ); + } + + void _onMarkAsRead(BuildContext context) { + Navigator.of(context).pop(); + + showToastNotification( + context, + message: LocaleKeys.settings_notifications_markAsReadNotifications_success + .tr(), + ); + + context.read().add( + ReminderEvent.update( + ReminderUpdate( + id: context.read().reminder.id, + isRead: true, + ), + ), + ); + } + + void _onMultipleChoice(BuildContext context) { + Navigator.of(context).pop(); + + onClickMultipleChoice(); + } + + void _onArchive(BuildContext context) { + showToastNotification( + context, + message: LocaleKeys.settings_notifications_archiveNotifications_success + .tr() + .tr(), + ); + + context.read().add( + ReminderEvent.update( + ReminderUpdate( + id: context.read().reminder.id, + isRead: true, + isArchived: true, + ), + ), + ); + + Navigator.of(context).pop(); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/tab.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/tab.dart new file mode 100644 index 0000000000000..7dda8f0a14ff2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/tab.dart @@ -0,0 +1,137 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart'; +import 'package:appflowy/shared/list_extension.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/user/application/reminder/reminder_extension.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/appflowy_backend.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class NotificationTab extends StatefulWidget { + const NotificationTab({ + super.key, + required this.tabType, + }); + + final MobileNotificationTabType tabType; + + @override + State createState() => _NotificationTabState(); +} + +class _NotificationTabState extends State + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + + return BlocBuilder( + builder: (context, state) { + final reminders = _filterReminders(state.reminders); + + if (reminders.isEmpty) { + // add refresh indicator to the empty notification. + return EmptyNotification( + type: widget.tabType, + ); + } + + final child = ListView.separated( + itemCount: reminders.length, + separatorBuilder: (context, index) => const VSpace(8.0), + itemBuilder: (context, index) { + final reminder = reminders[index]; + return NotificationItem( + key: ValueKey('${widget.tabType}_${reminder.id}'), + tabType: widget.tabType, + reminder: reminder, + ); + }, + ); + + return RefreshIndicator.adaptive( + onRefresh: () async => _onRefresh(context), + child: child, + ); + }, + ); + } + + Future _onRefresh(BuildContext context) async { + context.read().add(const ReminderEvent.refresh()); + + // at least 0.5 seconds to dismiss the refresh indicator. + // otherwise, it will be dismissed immediately. + await context.read().stream.firstOrNull; + await Future.delayed(const Duration(milliseconds: 500)); + + if (context.mounted) { + showToastNotification( + context, + message: LocaleKeys.settings_notifications_refreshSuccess.tr(), + ); + } + } + + List _filterReminders(List reminders) { + switch (widget.tabType) { + case MobileNotificationTabType.inbox: + return reminders.reversed + .where((reminder) => !reminder.isArchived) + .toList() + .unique((reminder) => reminder.id); + case MobileNotificationTabType.archive: + return reminders.reversed + .where((reminder) => reminder.isArchived) + .toList() + .unique((reminder) => reminder.id); + case MobileNotificationTabType.unread: + return reminders.reversed + .where((reminder) => !reminder.isRead) + .toList() + .unique((reminder) => reminder.id); + } + } +} + +class MultiSelectNotificationTab extends StatelessWidget { + const MultiSelectNotificationTab({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + // find the reminders that are not archived or read. + final reminders = state.reminders.reversed + .where((reminder) => !reminder.isArchived || !reminder.isRead) + .toList(); + + if (reminders.isEmpty) { + // add refresh indicator to the empty notification. + return const SizedBox.shrink(); + } + + return ListView.separated( + itemCount: reminders.length, + separatorBuilder: (context, index) => const VSpace(8.0), + itemBuilder: (context, index) { + final reminder = reminders[index]; + return MultiSelectNotificationItem( + key: ValueKey(reminder.id), + reminder: reminder, + ); + }, + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/tab_bar.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/tab_bar.dart new file mode 100644 index 0000000000000..35fb6ea067249 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/tab_bar.dart @@ -0,0 +1,87 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/home/tab/_round_underline_tab_indicator.dart'; +import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:reorderable_tabbar/reorderable_tabbar.dart'; + +enum MobileNotificationTabType { + inbox, + unread, + archive; + + String get tr { + switch (this) { + case MobileNotificationTabType.inbox: + return LocaleKeys.settings_notifications_tabs_inbox.tr(); + case MobileNotificationTabType.unread: + return LocaleKeys.settings_notifications_tabs_unread.tr(); + case MobileNotificationTabType.archive: + return LocaleKeys.settings_notifications_tabs_archived.tr(); + } + } + + List get actions { + switch (this) { + case MobileNotificationTabType.inbox: + return [ + NotificationPaneActionType.more, + NotificationPaneActionType.markAsRead, + ]; + case MobileNotificationTabType.unread: + case MobileNotificationTabType.archive: + return []; + } + } +} + +class MobileNotificationTabBar extends StatelessWidget { + const MobileNotificationTabBar({ + super.key, + this.height = 38.0, + required this.tabController, + required this.tabs, + }); + + final double height; + final List tabs; + final TabController tabController; + + @override + Widget build(BuildContext context) { + final baseStyle = Theme.of(context).textTheme.bodyMedium; + final labelStyle = baseStyle?.copyWith( + fontWeight: FontWeight.w500, + fontSize: 16.0, + height: 22.0 / 16.0, + ); + final unselectedLabelStyle = baseStyle?.copyWith( + fontWeight: FontWeight.w400, + fontSize: 15.0, + height: 22.0 / 15.0, + ); + + return Container( + height: height, + padding: const EdgeInsets.only(left: 8.0), + child: ReorderableTabBar( + controller: tabController, + tabs: tabs.map((e) => Tab(text: e.tr)).toList(), + indicatorSize: TabBarIndicatorSize.label, + isScrollable: true, + labelStyle: labelStyle, + labelColor: baseStyle?.color, + labelPadding: const EdgeInsets.symmetric(horizontal: 12.0), + unselectedLabelStyle: unselectedLabelStyle, + overlayColor: WidgetStateProperty.all(Colors.transparent), + indicator: const RoundUnderlineTabIndicator( + width: 28.0, + borderSide: BorderSide( + color: Color(0xFF00C8FF), + width: 3, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/widgets.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/widgets.dart new file mode 100644 index 0000000000000..92cd83a74e06d --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/widgets.dart @@ -0,0 +1,9 @@ +export 'empty.dart'; +export 'header.dart'; +export 'multi_select_notification_item.dart'; +export 'notification_item.dart'; +export 'settings_popup_menu.dart'; +export 'shared.dart'; +export 'slide_actions.dart'; +export 'tab.dart'; +export 'tab_bar.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_view_item.dart b/frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_view_item.dart index 6ef969bb0b79c..b6b173241cf97 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 @@ -260,6 +260,7 @@ class _SingleMobileInnerViewItemState extends State { child: FlowyText.regular( widget.view.name, fontSize: 16.0, + figmaLineHeight: 20.0, overflow: TextOverflow.ellipsis, ), ), @@ -297,12 +298,17 @@ class _SingleMobileInnerViewItemState extends State { ? FlowyText.emoji( widget.view.icon.value, fontSize: 18.0, + figmaLineHeight: 20.0, + optimizeEmojiAlign: true, ) : Opacity( opacity: 0.7, - child: widget.view.defaultIcon(), + child: widget.view.defaultIcon(size: const Size.square(18)), ); - return SizedBox(width: 18.0, child: icon); + return SizedBox( + width: 18.0, + child: icon, + ); } // > button or · button 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 3ce8e57b36fd8..24c50f7ae6df4 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 @@ -16,13 +16,10 @@ 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 runAppFlowy(); + }, ), ); } 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 050bf4b594d07..1076b9dba6c64 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 6f4e65f2b4de7..6473485514a0f 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/support_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/support_setting_group.dart index 5222a05b8ffb6..584b8677363a3 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,14 @@ 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( + showToastNotification( context, - LocaleKeys.settings_files_clearCacheSuccess.tr(), + 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 8f8fd99ecb6f9..15172aa68c38b 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,203 @@ 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.authenticator == AuthenticatorPB.AppFlowyCloud) ...[ + const VSpace(16.0), + MobileLogoutButton( + text: LocaleKeys.button_deleteAccount.tr(), + textColor: Theme.of(context).colorScheme.error, + onPressed: () => _showDeleteAccountDialog(context), ), - const VSpace(8), ], - MobileSignInOrLogoutButton( - labelText: LocaleKeys.settings_menu_logout.tr(), - onPressed: () async { - await showFlowyMobileConfirmDialog( + ], + ); + } + + Widget _buildThirdPartySignInButtons(BuildContext context) { + return BlocProvider( + create: (context) => getIt(), + child: BlocConsumer( + listener: (context, state) { + state.successOrFail?.fold( + (result) => runAppFlowy(), + (e) => Log.error(e), + ); + }, + builder: (context, state) { + return const ThirdPartySignInButtons( + expanded: true, + ); + }, + ), + ); + } + + Future _showDeleteAccountDialog(BuildContext context) async { + return showMobileBottomSheet( + context, + useRootNavigator: true, + backgroundColor: Theme.of(context).colorScheme.surface, + builder: (_) => const _DeleteAccountBottomSheet(), + ); + } + + Future _showLogoutDialog() async { + return showFlowyCupertinoConfirmDialog( + title: LocaleKeys.settings_menu_logoutPrompt.tr(), + leftButton: FlowyText( + LocaleKeys.button_cancel.tr(), + fontSize: 17.0, + figmaLineHeight: 24.0, + fontWeight: FontWeight.w500, + color: const Color(0xFF007AFF), + ), + rightButton: FlowyText( + LocaleKeys.button_logout.tr(), + fontSize: 17.0, + figmaLineHeight: 24.0, + fontWeight: FontWeight.w400, + color: const Color(0xFFFE0220), + ), + onRightButtonPressed: (context) async { + Navigator.of(context).pop(); + await getIt().signOut(); + await runAppFlowy(); + }, + ); + } +} + +class _DeleteAccountBottomSheet extends StatefulWidget { + const _DeleteAccountBottomSheet(); + + @override + State<_DeleteAccountBottomSheet> createState() => + _DeleteAccountBottomSheetState(); +} + +class _DeleteAccountBottomSheetState extends State<_DeleteAccountBottomSheet> { + final controller = TextEditingController(); + final isChecked = ValueNotifier(false); + + @override + void dispose() { + controller.dispose(); + isChecked.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const VSpace(18.0), + const FlowySvg( + FlowySvgs.icon_warning_xl, + blendMode: null, + ), + const VSpace(12.0), + FlowyText( + LocaleKeys.newSettings_myAccount_deleteAccount_title.tr(), + fontSize: 20.0, + fontWeight: FontWeight.w500, + ), + const VSpace(12.0), + FlowyText( + LocaleKeys.newSettings_myAccount_deleteAccount_confirmHint1.tr(), + fontSize: 14.0, + fontWeight: FontWeight.w400, + maxLines: 10, + ), + const VSpace(18.0), + SizedBox( + height: 36.0, + child: FlowyTextField( + controller: controller, + textStyle: const TextStyle(fontSize: 14.0), + hintStyle: const TextStyle(fontSize: 14.0), + hintText: LocaleKeys + .newSettings_myAccount_deleteAccount_confirmHint3 + .tr(), + ), + ), + const VSpace(18.0), + _buildCheckbox(), + const VSpace(18.0), + MobileLogoutButton( + text: LocaleKeys.button_deleteAccount.tr(), + textColor: Theme.of(context).colorScheme.error, + onPressed: () => deleteMyAccount( context, - 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, + onSuccess: () => Navigator.of(context).pop(), + ), + ), + 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/workspace/invite_members_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_members_screen.dart new file mode 100644 index 0000000000000..2e394c95e0f20 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_members_screen.dart @@ -0,0 +1,360 @@ +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 'package:toastification/toastification.dart'; + +import 'member_list.dart'; + +ValueNotifier mobileLeaveWorkspaceNotifier = ValueNotifier(0); + +class InviteMembersScreen extends StatelessWidget { + const InviteMembersScreen({ + super.key, + }); + + static const routeName = '/invite_member'; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FlowyAppBar( + titleText: LocaleKeys.settings_appearance_members_label.tr(), + ), + body: const _InviteMemberPage(), + resizeToAvoidBottomInset: false, + ); + } +} + +class _InviteMemberPage extends StatefulWidget { + const _InviteMemberPage(); + + @override + State<_InviteMemberPage> createState() => _InviteMemberPageState(); +} + +class _InviteMemberPageState extends State<_InviteMemberPage> { + final emailController = TextEditingController(); + late final Future userProfile; + bool exceededLimit = false; + + @override + void initState() { + super.initState(); + userProfile = UserBackendService.getCurrentUserProfile().fold( + (s) => s, + (f) => null, + ); + } + + @override + void dispose() { + emailController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: userProfile, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const SizedBox.shrink(); + } + if (snapshot.hasError || snapshot.data == null) { + return _buildError(context); + } + + final userProfile = snapshot.data!; + + return BlocProvider( + create: (context) => WorkspaceMemberBloc(userProfile: userProfile) + ..add(const WorkspaceMemberEvent.initial()), + child: BlocConsumer( + listener: _onListener, + builder: (context, state) { + return Column( + children: [ + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (state.myRole.isOwner) ...[ + Padding( + padding: const EdgeInsets.all(16.0), + child: _buildInviteMemberArea(context), + ), + const VSpace(16), + ], + if (state.members.isNotEmpty) ...[ + const VSpace(8), + MobileMemberList( + members: state.members, + userProfile: userProfile, + myRole: state.myRole, + ), + ], + ], + ), + ), + if (state.myRole.isMember) const _LeaveWorkspaceButton(), + const VSpace(48), + ], + ); + }, + ), + ); + }, + ); + } + + Widget _buildInviteMemberArea(BuildContext context) { + return Column( + children: [ + TextFormField( + autofocus: true, + controller: emailController, + keyboardType: TextInputType.text, + decoration: InputDecoration( + hintText: LocaleKeys.settings_appearance_members_inviteHint.tr(), + ), + ), + const VSpace(16), + if (exceededLimit) ...[ + FlowyText.regular( + LocaleKeys.settings_appearance_members_inviteFailedMemberLimitMobile + .tr(), + fontSize: 14.0, + maxLines: 3, + color: Theme.of(context).colorScheme.error, + ), + const VSpace(16), + ], + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => _inviteMember(context), + child: Text( + LocaleKeys.settings_appearance_members_sendInvite.tr(), + ), + ), + ), + ], + ); + } + + Widget _buildError(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 48.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText.medium( + LocaleKeys.settings_appearance_members_workspaceMembersError.tr(), + fontSize: 18.0, + textAlign: TextAlign.center, + ), + const VSpace(8.0), + FlowyText.regular( + LocaleKeys + .settings_appearance_members_workspaceMembersErrorDescription + .tr(), + fontSize: 17.0, + maxLines: 10, + textAlign: TextAlign.center, + lineHeight: 1.3, + color: Theme.of(context).hintColor, + ), + ], + ), + ), + ); + } + + void _onListener(BuildContext context, WorkspaceMemberState state) { + final actionResult = state.actionResult; + if (actionResult == null) { + return; + } + + final actionType = actionResult.actionType; + final result = actionResult.result; + + // get keyboard height + final keyboardHeight = MediaQuery.of(context).viewInsets.bottom; + + // only show the result dialog when the action is WorkspaceMemberActionType.add + if (actionType == WorkspaceMemberActionType.add) { + result.fold( + (s) { + showToastNotification( + context, + message: + LocaleKeys.settings_appearance_members_addMemberSuccess.tr(), + bottomPadding: keyboardHeight, + ); + }, + (f) { + Log.error('add workspace member failed: $f'); + final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded + ? LocaleKeys + .settings_appearance_members_inviteFailedMemberLimitMobile + .tr() + : LocaleKeys.settings_appearance_members_failedToAddMember.tr(); + setState(() { + exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded; + }); + showToastNotification( + context, + type: ToastificationType.error, + bottomPadding: keyboardHeight, + message: message, + ); + }, + ); + } else if (actionType == WorkspaceMemberActionType.invite) { + result.fold( + (s) { + showToastNotification( + context, + message: + LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(), + bottomPadding: keyboardHeight, + ); + }, + (f) { + Log.error('invite workspace member failed: $f'); + final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded + ? LocaleKeys + .settings_appearance_members_inviteFailedMemberLimitMobile + .tr() + : LocaleKeys.settings_appearance_members_failedToInviteMember + .tr(); + setState(() { + exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded; + }); + showToastNotification( + context, + type: ToastificationType.error, + message: message, + bottomPadding: keyboardHeight, + ); + }, + ); + } else if (actionType == WorkspaceMemberActionType.remove) { + result.fold( + (s) { + showToastNotification( + context, + message: LocaleKeys + .settings_appearance_members_removeFromWorkspaceSuccess + .tr(), + bottomPadding: keyboardHeight, + ); + }, + (f) { + showToastNotification( + context, + type: ToastificationType.error, + message: LocaleKeys + .settings_appearance_members_removeFromWorkspaceFailed + .tr(), + bottomPadding: keyboardHeight, + ); + }, + ); + } + } + + void _inviteMember(BuildContext context) { + final email = emailController.text; + if (!isEmail(email)) { + return showToastNotification( + context, + type: ToastificationType.error, + message: LocaleKeys.settings_appearance_members_emailInvalidError.tr(), + ); + } + context + .read() + .add(WorkspaceMemberEvent.inviteWorkspaceMember(email)); + // clear the email field after inviting + emailController.clear(); + } +} + +class _LeaveWorkspaceButton extends StatelessWidget { + const _LeaveWorkspaceButton(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + margin: const EdgeInsets.symmetric(horizontal: 16), + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.transparent, + foregroundColor: Theme.of(context).colorScheme.error, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + side: BorderSide( + color: Theme.of(context).colorScheme.error, + width: 0.5, + ), + ), + ), + onPressed: () => _leaveWorkspace(context), + child: FlowyText( + LocaleKeys.workspace_leaveCurrentWorkspace.tr(), + fontSize: 14.0, + color: Theme.of(context).colorScheme.error, + fontWeight: FontWeight.w500, + ), + ), + ); + } + + void _leaveWorkspace(BuildContext context) { + showFlowyCupertinoConfirmDialog( + title: LocaleKeys.workspace_leaveCurrentWorkspacePrompt.tr(), + leftButton: FlowyText( + LocaleKeys.button_cancel.tr(), + fontSize: 17.0, + figmaLineHeight: 24.0, + fontWeight: FontWeight.w500, + color: const Color(0xFF007AFF), + ), + rightButton: FlowyText( + LocaleKeys.button_confirm.tr(), + fontSize: 17.0, + figmaLineHeight: 24.0, + fontWeight: FontWeight.w400, + color: const Color(0xFFFE0220), + ), + onRightButtonPressed: (buttonContext) async { + // try to use popUntil with a specific route name but failed + // so use pop twice as a workaround + Navigator.of(buttonContext).pop(); + Navigator.of(context).pop(); + Navigator.of(context).pop(); + + mobileLeaveWorkspaceNotifier.value = + mobileLeaveWorkspaceNotifier.value + 1; + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/member_list.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/member_list.dart new file mode 100644 index 0000000000000..7c653209ab3f5 --- /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: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:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_slidable/flutter_slidable.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 (PlatformExtension.isDesktop) { + child = Row( + children: [ + Expanded( + child: FlowyText.medium( + member.name, + color: textColor, + fontSize: 15.0, + ), + ), + Expanded( + child: FlowyText.medium( + member.role.description, + color: textColor, + fontSize: 15.0, + textAlign: TextAlign.end, + ), + ), + ], + ); + } else { + child = Row( + children: [ + Expanded( + child: FlowyText.medium( + member.name, + color: textColor, + fontSize: 15.0, + overflow: TextOverflow.ellipsis, + ), + ), + const HSpace(36.0), + FlowyText.medium( + member.role.description, + color: textColor, + fontSize: 15.0, + textAlign: TextAlign.end, + ), + ], + ); + } + + child = Container( + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: child, + ); + + if (canDelete) { + child = Slidable( + key: ValueKey(member.email), + endActionPane: ActionPane( + extentRatio: 1 / 6.0, + motion: const ScrollMotion(), + children: [ + CustomSlidableAction( + backgroundColor: const Color(0xE5515563), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(10), + bottomLeft: Radius.circular(10), + ), + onPressed: (context) { + HapticFeedback.mediumImpact(); + _showDeleteMenu(context); + }, + padding: EdgeInsets.zero, + child: const FlowySvg( + FlowySvgs.three_dots_s, + size: Size.square(24), + color: Colors.white, + ), + ), + ], + ), + child: child, + ); + } + + return child; + } + + void _showDeleteMenu(BuildContext context) { + final workspaceMemberBloc = context.read(); + showMobileBottomSheet( + context, + showDragHandle: true, + showDivider: false, + useRootNavigator: true, + backgroundColor: Theme.of(context).colorScheme.surface, + builder: (context) { + return FlowyOptionTile.text( + text: LocaleKeys.settings_appearance_members_removeFromWorkspace.tr(), + height: 52.0, + textColor: Theme.of(context).colorScheme.error, + leftIcon: FlowySvg( + FlowySvgs.trash_s, + size: const Size.square(18), + color: Theme.of(context).colorScheme.error, + ), + showTopBorder: false, + showBottomBorder: false, + onTap: () { + workspaceMemberBloc.add( + WorkspaceMemberEvent.removeWorkspaceMember( + member.email, + ), + ); + Navigator.of(context).pop(); + }, + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/workspace_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/workspace_setting_group.dart new file mode 100644 index 0000000000000..9c2161a4d16e2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/workspace_setting_group.dart @@ -0,0 +1,29 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../widgets/widgets.dart'; +import 'invite_members_screen.dart'; + +class WorkspaceSettingGroup extends StatelessWidget { + const WorkspaceSettingGroup({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return MobileSettingGroup( + groupTitle: LocaleKeys.settings_appearance_members_label.tr(), + settingItemList: [ + MobileSettingItem( + name: LocaleKeys.settings_appearance_members_label.tr(), + trailing: const Icon(Icons.chevron_right), + onTap: () { + context.push(InviteMembersScreen.routeName); + }, + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/widgets/navigation_bar_button.dart b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/navigation_bar_button.dart new file mode 100644 index 0000000000000..2058e03e168bb --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/navigation_bar_button.dart @@ -0,0 +1,47 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class NavigationBarButton extends StatelessWidget { + const NavigationBarButton({ + super.key, + required this.text, + required this.icon, + required this.onTap, + this.enable = true, + }); + + final String text; + final FlowySvgData icon; + final VoidCallback onTap; + final bool enable; + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: enable ? 1.0 : 0.3, + child: Container( + height: 40, + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + side: const BorderSide(color: Color(0x3F1F2329)), + borderRadius: BorderRadius.circular(10), + ), + ), + child: FlowyButton( + useIntrinsicWidth: true, + expandText: false, + iconPadding: 8, + leftIcon: FlowySvg(icon), + onTap: enable ? onTap : null, + text: FlowyText( + text, + fontSize: 15.0, + figmaLineHeight: 18.0, + fontWeight: FontWeight.w400, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart index 321632a36a490..90bb12120a1f8 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 @@ -98,12 +98,13 @@ Future showFlowyCupertinoConfirmDialog({ }) { return showDialog( context: context ?? AppGlobals.context, + barrierColor: Colors.black.withOpacity(0.25), builder: (context) => CupertinoAlertDialog( title: FlowyText.medium( title, - fontSize: 18, + fontSize: 16, maxLines: 10, - lineHeight: 1.3, + figmaLineHeight: 22.0, ), actions: [ CupertinoDialogAction( 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 index a806a5c97d872..19fff60bbfdaa 100644 --- 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 @@ -1,33 +1,53 @@ import 'dart:async'; -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/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.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)) { + }) : super( + ChatAIMessageState.initial( + message, + messageReferenceSource(refSourceJsonString), + ), + ) { if (state.stream != null) { - _subscription = state.stream!.listen((text) { - if (isClosed) { - return; - } - - if (text.startsWith("data:")) { - add(ChatAIMessageEvent.newText(text.substring(5))); - } else if (text.startsWith("error:")) { - add(ChatAIMessageEvent.receiveError(text.substring(5))); - } - }); + state.stream!.listen( + onData: (text) { + if (!isClosed) { + add(ChatAIMessageEvent.updateText(text)); + } + }, + onError: (error) { + if (!isClosed) { + add(ChatAIMessageEvent.receiveError(error.toString())); + } + }, + onAIResponseLimit: () { + if (!isClosed) { + add(const ChatAIMessageEvent.onAIResponseLimit()); + } + }, + onMetadata: (sources) { + if (!isClosed) { + add(ChatAIMessageEvent.receiveSources(sources)); + } + }, + ); if (state.stream!.error != null) { Future.delayed(const Duration(milliseconds: 300), () { @@ -42,11 +62,16 @@ class ChatAIMessageBloc extends Bloc { (event, emit) async { await event.when( initial: () async {}, - newText: (newText) { - emit(state.copyWith(text: state.text + newText, error: null)); + updateText: (newText) { + emit( + state.copyWith( + text: newText, + messageState: const MessageState.ready(), + ), + ); }, receiveError: (error) { - emit(state.copyWith(error: error)); + emit(state.copyWith(messageState: MessageState.onError(error))); }, retry: () { if (questionId is! Int64) { @@ -55,8 +80,7 @@ class ChatAIMessageBloc extends Bloc { } emit( state.copyWith( - retryState: const LoadingState.loading(), - error: null, + messageState: const MessageState.loading(), ), ); @@ -64,7 +88,7 @@ class ChatAIMessageBloc extends Bloc { chatId: chatId, messageId: questionId, ); - ChatEventGetAnswerForQuestion(payload).send().then((result) { + AIEventGetAnswerForQuestion(payload).send().then((result) { if (!isClosed) { result.fold( (answer) { @@ -82,8 +106,21 @@ class ChatAIMessageBloc extends Bloc { emit( state.copyWith( text: text, - error: null, - retryState: const LoadingState.finish(), + messageState: const MessageState.ready(), + ), + ); + }, + onAIResponseLimit: () { + emit( + state.copyWith( + messageState: const MessageState.onAIResponseLimit(), + ), + ); + }, + receiveSources: (List sources) { + emit( + state.copyWith( + sources: sources, ), ); }, @@ -92,13 +129,6 @@ class ChatAIMessageBloc extends Bloc { ); } - @override - Future close() { - _subscription?.cancel(); - return super.close(); - } - - StreamSubscription? _subscription; final String chatId; final Int64? questionId; } @@ -106,26 +136,42 @@ class ChatAIMessageBloc extends Bloc { @freezed class ChatAIMessageEvent with _$ChatAIMessageEvent { const factory ChatAIMessageEvent.initial() = Initial; - const factory ChatAIMessageEvent.newText(String text) = _NewText; + 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.receiveSources( + List sources, + ) = _ReceiveMetadata; } @freezed class ChatAIMessageState with _$ChatAIMessageState { const factory ChatAIMessageState({ AnswerStream? stream, - String? error, required String text, - required LoadingState retryState, + required MessageState messageState, + required List sources, }) = _ChatAIMessageState; - factory ChatAIMessageState.initial(dynamic text) { + factory ChatAIMessageState.initial( + dynamic text, + List sources, + ) { return ChatAIMessageState( text: text is String ? text : "", stream: text is AnswerStream ? text : null, - retryState: const LoadingState.finish(), + messageState: const MessageState.ready(), + sources: sources, ); } } + +@freezed +class MessageState with _$MessageState { + const factory MessageState.onError(String error) = _Error; + const factory MessageState.onAIResponseLimit() = _AIResponseLimit; + const factory MessageState.ready() = _Ready; + const factory MessageState.loading() = _Loading; +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart index 2dc4926cc8b0e..f1b6be106ad41 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart @@ -1,13 +1,11 @@ import 'dart:async'; import 'dart:collection'; -import 'dart:ffi'; -import 'dart:isolate'; +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-chat/entities.pb.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-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_backend/protobuf/flowy-user/user_profile.pb.dart'; @@ -18,12 +16,12 @@ import 'package:flutter_chat_types/flutter_chat_types.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'; part 'chat_bloc.freezed.dart'; -const sendMessageErrorKey = "sendMessageError"; - class ChatBloc extends Bloc { ChatBloc({ required ViewPB view, @@ -41,7 +39,8 @@ class ChatBloc extends Bloc { final String chatId; /// The last streaming message id - String lastStreamMessageId = ''; + String answerStreamMessageId = ''; + String questionStreamMessageId = ''; /// Using a temporary map to associate the real message ID with the last streaming message ID. /// @@ -70,7 +69,7 @@ class ChatBloc extends Bloc { chatId: state.view.id, limit: Int64(10), ); - ChatEventLoadNextMessage(payload).send().then( + AIEventLoadNextMessage(payload).send().then( (result) { result.fold((list) { if (!isClosed) { @@ -84,16 +83,23 @@ class ChatBloc extends Bloc { }, ); }, + // Loading messages startLoadingPrevMessage: () async { Int64? beforeMessageId; final oldestMessage = _getOlderstMessage(); if (oldestMessage != null) { - beforeMessageId = Int64.parseInt(oldestMessage.id); + try { + beforeMessageId = Int64.parseInt(oldestMessage.id); + } catch (e) { + Log.error( + "Failed to parse message id: $e, messaeg_id: ${oldestMessage.id}", + ); + } } _loadPrevMessage(beforeMessageId); emit( state.copyWith( - loadingPreviousStatus: const LoadingState.loading(), + loadingPreviousStatus: const ChatLoadingState.loading(), ), ); }, @@ -109,7 +115,7 @@ class ChatBloc extends Bloc { emit( state.copyWith( messages: uniqueMessages, - loadingPreviousStatus: const LoadingState.finish(), + loadingPreviousStatus: const ChatLoadingState.finish(), hasMorePrevMessage: hasMore, ), ); @@ -123,24 +129,51 @@ class ChatBloc extends Bloc { emit( state.copyWith( messages: uniqueMessages, - initialLoadingStatus: const LoadingState.finish(), + initialLoadingStatus: const ChatLoadingState.finish(), ), ); }, - streaming: (Message message) { - final allMessages = _perminentMessages(); - allMessages.insert(0, message); + // streaming message + finishAnswerStreaming: () { emit( state.copyWith( - messages: allMessages, - streamingStatus: const LoadingState.loading(), + streamingState: const StreamingState.done(), + acceptRelatedQuestion: true, + canSendMessage: + state.sendingState == const SendMessageState.done(), ), ); }, - didFinishStreaming: () { - emit( - state.copyWith(streamingStatus: const LoadingState.finish()), - ); + didUpdateAnswerStream: (AnswerStream stream) { + emit(state.copyWith(answerStream: stream)); + }, + stopStream: () async { + if (state.answerStream == null) { + return; + } + + final payload = StopStreamPB(chatId: chatId); + await AIEventStopStream(payload).send(); + final allMessages = _perminentMessages(); + if (state.streamingState != const StreamingState.done()) { + // If the streaming is not started, remove the message from the list + if (!state.answerStream!.hasStarted) { + allMessages.removeWhere( + (element) => element.id == answerStreamMessageId, + ); + answerStreamMessageId = ""; + } + + // when stop stream, we will set the answer stream to null. Which means the streaming + // is finished or canceled. + emit( + state.copyWith( + messages: allMessages, + answerStream: null, + streamingState: const StreamingState.done(), + ), + ); + } }, receveMessage: (Message message) { final allMessages = _perminentMessages(); @@ -153,23 +186,52 @@ class ChatBloc extends Bloc { ), ); }, - sendMessage: (String message) { - _startStreamingMessage(message, emit); + startAnswerStreaming: (Message message) { + final allMessages = _perminentMessages(); + allMessages.insert(0, message); + emit( + state.copyWith( + messages: allMessages, + streamingState: const StreamingState.streaming(), + canSendMessage: false, + ), + ); + }, + sendMessage: (String message, Map? metadata) async { + unawaited(_startStreamingMessage(message, metadata, emit)); final allMessages = _perminentMessages(); emit( state.copyWith( lastSentMessage: null, messages: allMessages, relatedQuestions: [], + acceptRelatedQuestion: false, + sendingState: const SendMessageState.sending(), + canSendMessage: false, ), ); }, + finishSending: (ChatMessagePB message) { + emit( + state.copyWith( + lastSentMessage: message, + sendingState: const SendMessageState.done(), + canSendMessage: + state.streamingState == const StreamingState.done(), + ), + ); + }, + // related question didReceiveRelatedQuestion: (List questions) { + if (questions.isEmpty) { + return; + } + final allMessages = _perminentMessages(); final message = CustomMessage( metadata: OnetimeShotType.relatedQuestion.toMap(), - author: const User(id: "system"), - id: 'system', + author: const User(id: systemUserId), + id: systemUserId, ); allMessages.insert(0, message); emit( @@ -186,44 +248,6 @@ class ChatBloc extends Bloc { ), ); }, - didSentUserMessage: (ChatMessagePB message) { - emit( - state.copyWith( - lastSentMessage: message, - ), - ); - }, - didUpdateAnswerStream: (AnswerStream stream) { - emit(state.copyWith(answerStream: stream)); - }, - stopStream: () async { - if (state.answerStream == null) { - return; - } - - final payload = StopStreamPB(chatId: chatId); - await ChatEventStopStream(payload).send(); - final allMessages = _perminentMessages(); - if (state.streamingStatus != const LoadingState.finish()) { - // If the streaming is not started, remove the message from the list - if (!state.answerStream!.hasStarted) { - allMessages.removeWhere( - (element) => element.id == lastStreamMessageId, - ); - lastStreamMessageId = ""; - } - - // when stop stream, we will set the answer stream to null. Which means the streaming - // is finished or canceled. - emit( - state.copyWith( - messages: allMessages, - answerStream: null, - streamingStatus: const LoadingState.finish(), - ), - ); - } - }, ); }, ); @@ -234,10 +258,17 @@ class ChatBloc extends Bloc { chatMessageCallback: (pb) { if (!isClosed) { // 3 mean message response from AI - if (pb.authorType == 3 && lastStreamMessageId.isNotEmpty) { + if (pb.authorType == 3 && answerStreamMessageId.isNotEmpty) { temporaryMessageIDMap[pb.messageId.toString()] = - lastStreamMessageId; - lastStreamMessageId = ""; + answerStreamMessageId; + answerStreamMessageId = ""; + } + + // 1 mean message response from User + if (pb.authorType == 1 && questionStreamMessageId.isNotEmpty) { + temporaryMessageIDMap[pb.messageId.toString()] = + questionStreamMessageId; + questionStreamMessageId = ""; } final message = _createTextMessage(pb); @@ -247,7 +278,7 @@ class ChatBloc extends Bloc { chatErrorMessageCallback: (err) { if (!isClosed) { Log.error("chat error: ${err.errorMessage}"); - add(const ChatEvent.didFinishStreaming()); + add(const ChatEvent.finishAnswerStreaming()); } }, latestMessageCallback: (list) { @@ -264,7 +295,7 @@ class ChatBloc extends Bloc { }, finishStreamingCallback: () { if (!isClosed) { - add(const ChatEvent.didFinishStreaming()); + add(const ChatEvent.finishAnswerStreaming()); // The answer strema will bet set to null after the streaming is finished or canceled. // so if the answer stream is null, we will not get related question. if (state.lastSentMessage != null && state.answerStream != null) { @@ -273,11 +304,13 @@ class ChatBloc extends Bloc { messageId: state.lastSentMessage!.messageId, ); // When user message was sent to the server, we start gettting related question - ChatEventGetRelatedQuestion(payload).send().then((result) { + AIEventGetRelatedQuestion(payload).send().then((result) { if (!isClosed) { result.fold( (list) { - add(ChatEvent.didReceiveRelatedQuestion(list.items)); + if (state.acceptRelatedQuestion) { + add(ChatEvent.didReceiveRelatedQuestion(list.items)); + } }, (err) { Log.error("Failed to get related question: $err"); @@ -322,11 +355,12 @@ class ChatBloc extends Bloc { limit: Int64(10), beforeMessageId: beforeMessageId, ); - ChatEventLoadPrevMessage(payload).send(); + AIEventLoadPrevMessage(payload).send(); } Future _startStreamingMessage( String message, + Map? metadata, Emitter emit, ) async { if (state.answerStream != null) { @@ -334,29 +368,37 @@ class ChatBloc extends Bloc { } final answerStream = AnswerStream(); + final questionStream = QuestionStream(); add(ChatEvent.didUpdateAnswerStream(answerStream)); final payload = StreamChatPayloadPB( chatId: state.view.id, message: message, messageType: ChatMessageTypePB.User, - textStreamPort: Int64(answerStream.nativePort), + questionStreamPort: Int64(questionStream.nativePort), + answerStreamPort: Int64(answerStream.nativePort), + metadata: await metadataPBFromMetadata(metadata), + ); + + final questionStreamMessage = _createQuestionStreamMessage( + questionStream, + metadata, ); + add(ChatEvent.receveMessage(questionStreamMessage)); // Stream message to the server - final result = await ChatEventStreamMessage(payload).send(); + final result = await AIEventStreamMessage(payload).send(); result.fold( (ChatMessagePB question) { if (!isClosed) { - add(ChatEvent.didSentUserMessage(question)); + add(ChatEvent.finishSending(question)); - final questionMessageId = question.messageId; - final message = _createTextMessage(question); - add(ChatEvent.receveMessage(message)); + // final message = _createTextMessage(question); + // add(ChatEvent.receveMessage(message)); final streamAnswer = - _createStreamMessage(answerStream, questionMessageId); - add(ChatEvent.streaming(streamAnswer)); + _createAnswerStreamMessage(answerStream, question.messageId); + add(ChatEvent.startAnswerStreaming(streamAnswer)); } }, (err) { @@ -369,8 +411,8 @@ class ChatBloc extends Bloc { final error = CustomMessage( metadata: metadata, - author: const User(id: "system"), - id: 'system', + author: const User(id: systemUserId), + id: systemUserId, ); add(ChatEvent.receveMessage(error)); @@ -379,16 +421,18 @@ class ChatBloc extends Bloc { ); } - Message _createStreamMessage(AnswerStream stream, Int64 questionMessageId) { + Message _createAnswerStreamMessage( + AnswerStream stream, + Int64 questionMessageId, + ) { final streamMessageId = (questionMessageId + 1).toString(); - - lastStreamMessageId = streamMessageId; + answerStreamMessageId = streamMessageId; return TextMessage( - author: User(id: nanoid()), + author: User(id: "streamId:${nanoid()}"), metadata: { "$AnswerStream": stream, - "question": questionMessageId, + messageQuestionIdKey: questionMessageId, "chatId": chatId, }, id: streamMessageId, @@ -397,6 +441,32 @@ class ChatBloc extends Bloc { ); } + Message _createQuestionStreamMessage( + QuestionStream stream, + Map? sentMetadata, + ) { + final now = DateTime.now(); + final timestamp = now.millisecondsSinceEpoch; + questionStreamMessageId = timestamp.toString(); + final Map metadata = {}; + + // if (sentMetadata != null) { + // metadata[messageMetadataJsonStringKey] = sentMetadata; + // } + + metadata["$QuestionStream"] = stream; + metadata["chatId"] = chatId; + metadata[messageChatFileListKey] = + chatFilesFromMessageMetadata(sentMetadata); + return TextMessage( + author: User(id: state.userProfile.id.toString()), + metadata: metadata, + id: questionStreamMessageId, + createdAt: DateTime.now().millisecondsSinceEpoch, + text: '', + ); + } + Message _createTextMessage(ChatMessagePB message) { String messageId = message.messageId.toString(); @@ -410,6 +480,9 @@ class ChatBloc extends Bloc { id: messageId, text: message.content, createdAt: message.createdAt.toInt() * 1000, + metadata: { + messageRefSourceJsonStringKey: message.metadata, + }, ); } } @@ -417,7 +490,22 @@ class ChatBloc extends Bloc { @freezed class ChatEvent with _$ChatEvent { const factory ChatEvent.initialLoad() = _InitialLoadMessage; - const factory ChatEvent.sendMessage(String message) = _SendMessage; + + // send message + const factory ChatEvent.sendMessage({ + required String message, + Map? metadata, + }) = _SendMessage; + const factory ChatEvent.finishSending(ChatMessagePB message) = + _FinishSendMessage; + +// receive message + const factory ChatEvent.startAnswerStreaming(Message message) = + _StartAnswerStreaming; + const factory ChatEvent.receveMessage(Message message) = _ReceiveMessage; + const factory ChatEvent.finishAnswerStreaming() = _FinishAnswerStreaming; + +// loading messages const factory ChatEvent.startLoadingPrevMessage() = _StartLoadPrevMessage; const factory ChatEvent.didLoadPreviousMessages( List messages, @@ -425,16 +513,13 @@ class ChatEvent with _$ChatEvent { ) = _DidLoadPreviousMessages; const factory ChatEvent.didLoadLatestMessages(List messages) = _DidLoadMessages; - const factory ChatEvent.streaming(Message message) = _StreamingMessage; - const factory ChatEvent.receveMessage(Message message) = _ReceiveMessage; - const factory ChatEvent.didFinishStreaming() = _FinishStreamingMessage; +// related questions const factory ChatEvent.didReceiveRelatedQuestion( List questions, ) = _DidReceiveRelatedQueston; const factory ChatEvent.clearReleatedQuestion() = _ClearRelatedQuestion; - const factory ChatEvent.didSentUserMessage(ChatMessagePB message) = - _DidSendUserMessage; + const factory ChatEvent.didUpdateAnswerStream( AnswerStream stream, ) = _DidUpdateAnswerStream; @@ -449,20 +534,23 @@ class ChatState with _$ChatState { required UserProfilePB userProfile, // When opening the chat, the initial loading status will be set as loading. //After the initial loading is done, the status will be set as finished. - required LoadingState initialLoadingStatus, + required ChatLoadingState initialLoadingStatus, // When loading previous messages, the status will be set as loading. // After the loading is done, the status will be set as finished. - required LoadingState loadingPreviousStatus, + required ChatLoadingState loadingPreviousStatus, // When sending a user message, the status will be set as loading. // After the message is sent, the status will be set as finished. - required LoadingState streamingStatus, + required StreamingState streamingState, + required SendMessageState sendingState, // Indicate whether there are more previous messages to load. required bool hasMorePrevMessage, // The related questions that are received after the user message is sent. required List relatedQuestions, + @Default(false) bool acceptRelatedQuestion, // The last user message that is sent to the server. ChatMessagePB? lastSentMessage, AnswerStream? answerStream, + @Default(true) bool canSendMessage, }) = _ChatState; factory ChatState.initial(ViewPB view, UserProfilePB userProfile) => @@ -470,97 +558,17 @@ class ChatState with _$ChatState { view: view, messages: [], userProfile: userProfile, - initialLoadingStatus: const LoadingState.finish(), - loadingPreviousStatus: const LoadingState.finish(), - streamingStatus: const LoadingState.finish(), + initialLoadingStatus: const ChatLoadingState.finish(), + loadingPreviousStatus: const ChatLoadingState.finish(), + streamingState: const StreamingState.done(), + sendingState: const SendMessageState.done(), hasMorePrevMessage: true, relatedQuestions: [], ); } -@freezed -class LoadingState with _$LoadingState { - const factory LoadingState.loading() = _Loading; - const factory LoadingState.finish({FlowyError? error}) = _Finish; -} - -enum OnetimeShotType { - unknown, - relatedQuestion, - invalidSendMesssage, -} - -const onetimeShotType = "OnetimeShotType"; - -extension OnetimeMessageTypeExtension on OnetimeShotType { - static OnetimeShotType fromString(String value) { - switch (value) { - case 'OnetimeShotType.relatedQuestion': - return OnetimeShotType.relatedQuestion; - case 'OnetimeShotType.invalidSendMesssage': - return OnetimeShotType.invalidSendMesssage; - default: - Log.error('Unknown OnetimeShotType: $value'); - return OnetimeShotType.unknown; - } - } - - Map toMap() { - return { - onetimeShotType: toString(), - }; - } -} - -OnetimeShotType? onetimeMessageTypeFromMeta(Map? metadata) { - if (metadata == null) { - return null; - } - - for (final entry in metadata.entries) { - if (entry.key == onetimeShotType) { - return OnetimeMessageTypeExtension.fromString(entry.value as String); - } - } - return null; -} - -typedef AnswerStreamElement = String; - -class AnswerStream { - AnswerStream() { - _port.handler = _controller.add; - _subscription = _controller.stream.listen( - (event) { - if (event.startsWith("data:")) { - _hasStarted = true; - } else if (event.startsWith("error:")) { - _error = event.substring(5); - } - }, - ); - } - - final RawReceivePort _port = RawReceivePort(); - final StreamController _controller = - StreamController.broadcast(); - late StreamSubscription _subscription; - bool _hasStarted = false; - String? _error; - - int get nativePort => _port.sendPort.nativePort; - bool get hasStarted => _hasStarted; - String? get error => _error; - - Future dispose() async { - await _controller.close(); - await _subscription.cancel(); - _port.close(); - } - - StreamSubscription listen( - void Function(AnswerStreamElement event)? onData, - ) { - return _controller.stream.listen(onData); - } +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_entity.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_entity.dart new file mode 100644 index 0000000000000..c0a2d5bf321e0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_entity.dart @@ -0,0 +1,182 @@ +import 'dart:io'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.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 sendMessageErrorKey = "sendMessageError"; +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); +} + +@freezed +class StreamingState with _$StreamingState { + const factory StreamingState.streaming() = _Streaming; + const factory StreamingState.done({FlowyError? error}) = _StreamDone; +} + +@freezed +class SendMessageState with _$SendMessageState { + const factory SendMessageState.sending() = _Sending; + const factory SendMessageState.done({FlowyError? error}) = _SendDone; +} + +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(); + + ChatMessageMetaTypePB fileType; + switch (extension) { + case '.pdf': + fileType = ChatMessageMetaTypePB.PDF; + break; + case '.txt': + fileType = ChatMessageMetaTypePB.Txt; + break; + case '.md': + fileType = ChatMessageMetaTypePB.Markdown; + break; + default: + fileType = ChatMessageMetaTypePB.UnknownMetaType; + } + + return ChatFile( + filePath: filePath, + fileName: fileName, + fileType: fileType, + ); + } + + final String filePath; + final String fileName; + final ChatMessageMetaTypePB fileType; + + @override + List get props => [filePath]; +} + +extension ChatFileTypeExtension on ChatMessageMetaTypePB { + Widget get icon { + switch (this) { + case ChatMessageMetaTypePB.PDF: + return const FlowySvg( + FlowySvgs.file_pdf_s, + color: Color(0xff00BCF0), + ); + case ChatMessageMetaTypePB.Txt: + return const FlowySvg( + FlowySvgs.file_txt_s, + color: Color(0xff00BCF0), + ); + case ChatMessageMetaTypePB.Markdown: + return const FlowySvg( + FlowySvgs.file_md_s, + color: Color(0xff00BCF0), + ); + default: + return const FlowySvg(FlowySvgs.file_unknown_s); + } + } +} + +typedef ChatInputFileMetadata = 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 { + unknown, + sendingMessage, + relatedQuestion, + invalidSendMesssage, +} + +const onetimeShotType = "OnetimeShotType"; + +extension OnetimeMessageTypeExtension on OnetimeShotType { + static OnetimeShotType fromString(String value) { + switch (value) { + case 'OnetimeShotType.sendingMessage': + return OnetimeShotType.sendingMessage; + case 'OnetimeShotType.relatedQuestion': + return OnetimeShotType.relatedQuestion; + case 'OnetimeShotType.invalidSendMesssage': + return OnetimeShotType.invalidSendMesssage; + default: + Log.error('Unknown OnetimeShotType: $value'); + return OnetimeShotType.unknown; + } + } + + Map toMap() { + return { + onetimeShotType: toString(), + }; + } +} + +OnetimeShotType? onetimeMessageTypeFromMeta(Map? metadata) { + if (metadata == null) { + return null; + } + + for (final entry in metadata.entries) { + if (entry.key == onetimeShotType) { + return OnetimeMessageTypeExtension.fromString(entry.value as String); + } + } + return null; +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_file_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_file_bloc.dart index c257a4f95ff80..a01c3b32d5a4e 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_file_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_file_bloc.dart @@ -1,18 +1,27 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'chat_input_bloc.dart'; + part 'chat_file_bloc.freezed.dart'; class ChatFileBloc extends Bloc { - ChatFileBloc({ - required String chatId, - }) : listener = LocalLLMListener(), + ChatFileBloc() + : listener = LocalLLMListener(), super(const ChatFileState()) { listener.start( + stateCallback: (pluginState) { + if (!isClosed) { + add(ChatFileEvent.updatePluginState(pluginState)); + } + }, chatStateCallback: (chatState) { if (!isClosed) { add(ChatFileEvent.updateChatState(chatState)); @@ -24,7 +33,7 @@ class ChatFileBloc extends Bloc { (event, emit) async { await event.when( initial: () async { - final result = await ChatEventGetLocalAIChatState().send(); + final result = await AIEventGetLocalAIChatState().send(); result.fold( (chatState) { if (!isClosed) { @@ -38,16 +47,59 @@ class ChatFileBloc extends Bloc { }, ); }, - newFile: (String filePath) { - final payload = ChatFilePB(filePath: filePath, chatId: chatId); - ChatEventChatWithFile(payload).send(); + newFile: (String filePath, String fileName) async { + final files = List.from(state.uploadFiles); + final newFile = ChatFile.fromFilePath(filePath); + if (newFile != null) { + files.add(newFile); + emit( + state.copyWith( + uploadFiles: files, + ), + ); + } }, updateChatState: (LocalAIChatPB chatState) { // Only user enable chat with file and the plugin is already running final supportChatWithFile = chatState.fileEnabled && chatState.pluginState.state == RunningStatePB.Running; emit( - state.copyWith(supportChatWithFile: supportChatWithFile), + state.copyWith( + supportChatWithFile: supportChatWithFile, + chatState: chatState, + ), + ); + }, + updatePluginState: (LocalAIPluginStatePB chatState) { + final fileEnabled = state.chatState?.fileEnabled ?? false; + final supportChatWithFile = + fileEnabled && chatState.state == RunningStatePB.Running; + + final aiType = chatState.state == RunningStatePB.Running + ? const AIType.localAI() + : const AIType.appflowyAI(); + + emit( + state.copyWith( + supportChatWithFile: supportChatWithFile, + aiType: aiType, + ), + ); + }, + deleteFile: (file) { + final files = List.from(state.uploadFiles); + files.remove(file); + emit( + state.copyWith( + uploadFiles: files, + ), + ); + }, + clear: () { + emit( + state.copyWith( + uploadFiles: [], + ), ); }, ); @@ -55,6 +107,19 @@ class ChatFileBloc extends Bloc { ); } + ChatInputFileMetadata consumeMetaData() { + final metadata = state.uploadFiles.fold( + {}, + (map, file) => map..putIfAbsent(file.filePath, () => file), + ); + + if (metadata.isNotEmpty) { + add(const ChatFileEvent.clear()); + } + + return metadata; + } + final LocalLLMListener listener; @override @@ -67,20 +132,23 @@ class ChatFileBloc extends Bloc { @freezed class ChatFileEvent with _$ChatFileEvent { const factory ChatFileEvent.initial() = Initial; - const factory ChatFileEvent.newFile(String filePath) = _NewFile; + const factory ChatFileEvent.newFile(String filePath, String fileName) = + _NewFile; + const factory ChatFileEvent.deleteFile(ChatFile file) = _DeleteFile; + const factory ChatFileEvent.clear() = _ClearFile; const factory ChatFileEvent.updateChatState(LocalAIChatPB chatState) = _UpdateChatState; + const factory ChatFileEvent.updatePluginState( + LocalAIPluginStatePB chatState, + ) = _UpdatePluginState; } @freezed class ChatFileState with _$ChatFileState { const factory ChatFileState({ @Default(false) bool supportChatWithFile, + LocalAIChatPB? chatState, + @Default([]) List uploadFiles, + @Default(AIType.appflowyAI()) AIType aiType, }) = _ChatFileState; } - -@freezed -class LocalAIChatFileIndicator with _$LocalAIChatFileIndicator { - const factory LocalAIChatFileIndicator.ready(bool isEnabled) = _Ready; - const factory LocalAIChatFileIndicator.loading() = _Loading; -} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_action_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_action_bloc.dart new file mode 100644 index 0000000000000..466f82ca0b4db --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_action_bloc.dart @@ -0,0 +1,217 @@ +import 'dart:async'; + +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:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'chat_input_action_control.dart'; + +part 'chat_input_action_bloc.freezed.dart'; + +class ChatInputActionBloc + extends Bloc { + ChatInputActionBloc({required this.chatId}) + : super(const ChatInputActionState()) { + on(_handleEvent); + } + + final String chatId; + + Future _handleEvent( + ChatInputActionEvent event, + Emitter emit, + ) async { + await event.when( + started: () async { + unawaited( + ViewBackendService.getAllViews().then( + (result) { + final views = result + .toNullable() + ?.items + .where( + (v) => + v.layout.isDocumentView && + !v.isSpace && + v.parentViewId.isNotEmpty, + ) + .toList() ?? + []; + if (!isClosed) { + add(ChatInputActionEvent.refreshViews(views)); + } + }, + ), + ); + }, + refreshViews: (List views) { + final List pages = _filterPages( + views, + state.selectedPages, + state.filter, + ); + emit( + state.copyWith( + views: views, + pages: pages, + indicator: const ChatActionMenuIndicator.ready(), + ), + ); + }, + filter: (String filter) { + Log.debug("Filter chat input pages: $filter"); + final List pages = _filterPages( + state.views, + state.selectedPages, + filter, + ); + + emit(state.copyWith(pages: pages, filter: filter)); + }, + handleKeyEvent: (PhysicalKeyboardKey physicalKey) { + emit( + state.copyWith( + keyboardKey: ChatInputKeyboardEvent(physicalKey: physicalKey), + ), + ); + }, + addPage: (ChatInputMention page) { + if (!state.selectedPages.any((p) => p.pageId == page.pageId)) { + final List pages = _filterPages( + state.views, + state.selectedPages, + state.filter, + ); + emit( + state.copyWith( + pages: pages, + selectedPages: [...state.selectedPages, page], + ), + ); + } + }, + removePage: (String text) { + final List selectedPages = + List.from(state.selectedPages); + selectedPages.retainWhere((t) => !text.contains(t.title)); + + final List allPages = _filterPages( + state.views, + state.selectedPages, + state.filter, + ); + + emit( + state.copyWith( + selectedPages: selectedPages, + pages: allPages, + ), + ); + }, + clear: () { + emit( + state.copyWith( + selectedPages: [], + filter: "", + ), + ); + }, + ); + } +} + +List _filterPages( + List views, + List selectedPages, + String filter, +) { + final pages = views + .map( + (v) => ViewActionPage(view: v), + ) + .toList(); + + pages.retainWhere((page) { + return !selectedPages.contains(page); + }); + + if (filter.isEmpty) { + return pages; + } + + return pages + .where( + (v) => v.title.toLowerCase().contains(filter.toLowerCase()), + ) + .toList(); +} + +class ViewActionPage extends ChatInputMention { + ViewActionPage({required this.view}); + + final ViewPB view; + + @override + String get pageId => view.id; + + @override + String get title => view.name; + + @override + List get props => [pageId]; + + @override + dynamic get page => view; + + @override + Widget get icon => view.defaultIcon(); +} + +@freezed +class ChatInputActionEvent with _$ChatInputActionEvent { + const factory ChatInputActionEvent.started() = _Started; + const factory ChatInputActionEvent.refreshViews(List views) = + _RefreshViews; + const factory ChatInputActionEvent.filter(String filter) = _Filter; + const factory ChatInputActionEvent.handleKeyEvent( + PhysicalKeyboardKey keyboardKey, + ) = _HandleKeyEvent; + const factory ChatInputActionEvent.addPage(ChatInputMention page) = _AddPage; + const factory ChatInputActionEvent.removePage(String text) = _RemovePage; + const factory ChatInputActionEvent.clear() = _Clear; +} + +@freezed +class ChatInputActionState with _$ChatInputActionState { + const factory ChatInputActionState({ + @Default([]) List views, + @Default([]) List pages, + @Default([]) List selectedPages, + @Default("") String filter, + ChatInputKeyboardEvent? keyboardKey, + @Default(ChatActionMenuIndicator.loading()) + ChatActionMenuIndicator indicator, + }) = _ChatInputActionState; +} + +class ChatInputKeyboardEvent extends Equatable { + ChatInputKeyboardEvent({required this.physicalKey}); + + final PhysicalKeyboardKey physicalKey; + final int timestamp = DateTime.now().millisecondsSinceEpoch; + + @override + List get props => [timestamp]; +} + +@freezed +class ChatActionMenuIndicator with _$ChatActionMenuIndicator { + const factory ChatActionMenuIndicator.ready() = _Ready; + const factory ChatActionMenuIndicator.loading() = _Loading; +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_action_control.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_action_control.dart new file mode 100644 index 0000000000000..b945b9c2d7ac8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_action_control.dart @@ -0,0 +1,172 @@ +import 'package:appflowy/plugins/ai_chat/application/chat_input_action_bloc.dart'; +import 'package:appflowy/plugins/ai_chat/presentation/chat_input_action_menu.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +abstract class ChatInputMention extends Equatable { + String get title; + String get pageId; + dynamic get page; + Widget get icon; +} + +/// Key: the key is the pageId +typedef ChatInputMentionMetadata = Map; + +class ChatInputActionControl extends ChatActionHandler { + ChatInputActionControl({ + required this.textController, + required this.textFieldFocusNode, + required this.chatId, + }) : _commandBloc = ChatInputActionBloc(chatId: chatId); + + final TextEditingController textController; + final ChatInputActionBloc _commandBloc; + final FocusNode textFieldFocusNode; + final String chatId; + + // Private attributes + String _atText = ""; + String _prevText = ""; + String _showMenuText = ""; + + // Getter + List get tags => + _commandBloc.state.selectedPages.map((e) => e.title).toList(); + + ChatInputMentionMetadata consumeMetaData() { + final metadata = _commandBloc.state.selectedPages.fold( + {}, + (map, page) => map..putIfAbsent(page.pageId, () => page), + ); + + if (metadata.isNotEmpty) { + _commandBloc.add(const ChatInputActionEvent.clear()); + } + + return metadata; + } + + void handleKeyEvent(KeyEvent event) { + // ignore: deprecated_member_use + if (event is KeyDownEvent || event is RawKeyDownEvent) { + _commandBloc.add(ChatInputActionEvent.handleKeyEvent(event.physicalKey)); + } + } + + bool canHandleKeyEvent(KeyEvent event) { + return _showMenuText.isNotEmpty && + { + PhysicalKeyboardKey.arrowDown, + PhysicalKeyboardKey.arrowUp, + PhysicalKeyboardKey.enter, + PhysicalKeyboardKey.escape, + }.contains(event.physicalKey); + } + + void dispose() { + _commandBloc.close(); + } + + @override + void onSelected(ChatInputMention page) { + _commandBloc.add(ChatInputActionEvent.addPage(page)); + textController.text = "$_showMenuText${page.title}"; + + onExit(); + } + + @override + void onExit() { + _atText = ""; + _showMenuText = ""; + _prevText = ""; + _commandBloc.add(const ChatInputActionEvent.filter("")); + } + + @override + void onEnter() { + _commandBloc.add(const ChatInputActionEvent.started()); + _showMenuText = textController.text; + } + + @override + double actionMenuOffsetX() { + final TextPosition textPosition = textController.selection.extent; + if (textFieldFocusNode.context == null) { + return 0; + } + + final RenderBox renderBox = + textFieldFocusNode.context?.findRenderObject() as RenderBox; + + final TextPainter textPainter = TextPainter( + text: TextSpan(text: textController.text), + textDirection: TextDirection.ltr, + ); + textPainter.layout( + minWidth: renderBox.size.width, + maxWidth: renderBox.size.width, + ); + + final Offset caretOffset = + textPainter.getOffsetForCaret(textPosition, Rect.zero); + final List boxes = textPainter.getBoxesForSelection( + TextSelection( + baseOffset: textPosition.offset, + extentOffset: textPosition.offset, + ), + ); + + if (boxes.isNotEmpty) { + return boxes.last.right; + } + return caretOffset.dx; + } + + bool onTextChanged(String text) { + final String inputText = text; + if (_prevText.length > inputText.length) { + final deleteStartIndex = textController.selection.baseOffset; + final deleteEndIndex = + _prevText.length - inputText.length + deleteStartIndex; + final deletedText = _prevText.substring(deleteStartIndex, deleteEndIndex); + _commandBloc.add(ChatInputActionEvent.removePage(deletedText)); + } + + // If the action menu is shown, filter the views + if (_showMenuText.isNotEmpty) { + if (text.length >= _showMenuText.length) { + final filterText = inputText.substring(_showMenuText.length); + _commandBloc.add(ChatInputActionEvent.filter(filterText)); + } + + // If the text change from "xxx @"" to "xxx", which means user delete the @, we should hide the action menu + if (_atText.isNotEmpty && !inputText.contains(_atText)) { + _commandBloc.add( + const ChatInputActionEvent.handleKeyEvent(PhysicalKeyboardKey.escape), + ); + } + } else { + final isTypingNewAt = + text.endsWith("@") && _prevText.length < text.length; + if (isTypingNewAt) { + _atText = text; + _prevText = text; + return true; + } + } + _prevText = text; + return false; + } + + @override + void onFilter(String filter) { + Log.info("filter: $filter"); + } + + @override + ChatInputActionBloc get commandBloc => _commandBloc; +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_bloc.dart index 4b229db0eff68..a0cedd29003d1 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_bloc.dart @@ -3,24 +3,25 @@ import 'dart:async'; 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-chat/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:bloc/bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'chat_input_bloc.freezed.dart'; -class ChatInputBloc extends Bloc { - ChatInputBloc() +class ChatInputStateBloc + extends Bloc { + ChatInputStateBloc() : listener = LocalLLMListener(), - super(const ChatInputState(aiType: _AppFlowyAI())) { + super(const ChatInputStateState(aiType: _AppFlowyAI())) { listener.start( stateCallback: (pluginState) { if (!isClosed) { - add(ChatInputEvent.updateState(pluginState)); + add(ChatInputStateEvent.updatePluginState(pluginState)); } }, ); - on(_handleEvent); + on(_handleEvent); } final LocalLLMListener listener; @@ -32,38 +33,47 @@ class ChatInputBloc extends Bloc { } Future _handleEvent( - ChatInputEvent event, - Emitter emit, + ChatInputStateEvent event, + Emitter emit, ) async { await event.when( started: () async { - final result = await ChatEventGetLocalAIPluginState().send(); + final result = await AIEventGetLocalAIPluginState().send(); result.fold( (pluginState) { if (!isClosed) { - add(ChatInputEvent.updateState(pluginState)); + add( + ChatInputStateEvent.updatePluginState(pluginState), + ); } }, - (err) => Log.error(err.toString()), + (err) { + Log.error(err.toString()); + }, ); }, - updateState: (LocalAIPluginStatePB aiPluginState) { - emit(const ChatInputState(aiType: _AppFlowyAI())); + updatePluginState: (pluginState) { + if (pluginState.state == RunningStatePB.Running) { + emit(const ChatInputStateState(aiType: _LocalAI())); + } else { + emit(const ChatInputStateState(aiType: _AppFlowyAI())); + } }, ); } } @freezed -class ChatInputEvent with _$ChatInputEvent { - const factory ChatInputEvent.started() = _Started; - const factory ChatInputEvent.updateState(LocalAIPluginStatePB aiPluginState) = - _UpdatePluginState; +class ChatInputStateEvent with _$ChatInputStateEvent { + const factory ChatInputStateEvent.started() = _Started; + const factory ChatInputStateEvent.updatePluginState( + LocalAIPluginStatePB pluginState, + ) = _UpdatePluginState; } @freezed -class ChatInputState with _$ChatInputState { - const factory ChatInputState({required AIType aiType}) = _ChatInputState; +class ChatInputStateState with _$ChatInputStateState { + const factory ChatInputStateState({required AIType aiType}) = _ChatInputState; } @freezed @@ -71,3 +81,7 @@ class AIType with _$AIType { const factory AIType.appflowyAI() = _AppFlowyAI; const factory AIType.localAI() = _LocalAI; } + +extension AITypeX on AIType { + bool isLocalAI() => this is _LocalAI; +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_file_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_file_bloc.dart new file mode 100644 index 0000000000000..048c8709b3fb2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_file_bloc.dart @@ -0,0 +1,49 @@ + +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({ + // ignore: avoid_unused_constructor_parameters + required String chatId, + required this.file, + }) : super(const ChatInputFileState()) { + on( + (event, emit) async { + await event.when( + initial: () async {}, + updateUploadState: (UploadFileIndicator indicator) { + emit(state.copyWith(uploadFileIndicator: indicator)); + }, + ); + }, + ); + } + + final ChatFile file; +} + +@freezed +class ChatInputFileEvent with _$ChatInputFileEvent { + const factory ChatInputFileEvent.initial() = Initial; + const factory ChatInputFileEvent.updateUploadState( + UploadFileIndicator indicator, + ) = _UpdateUploadState; +} + +@freezed +class ChatInputFileState with _$ChatInputFileState { + const factory ChatInputFileState({ + UploadFileIndicator? uploadFileIndicator, + }) = _ChatInputFileState; +} + +@freezed +class UploadFileIndicator with _$UploadFileIndicator { + const factory UploadFileIndicator.finish() = _Finish; + const factory UploadFileIndicator.uploading() = _Uploading; + const factory UploadFileIndicator.error(String error) = _Error; +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_member_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_member_bloc.dart new file mode 100644 index 0000000000000..c0f68bb9b3a23 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_member_bloc.dart @@ -0,0 +1,80 @@ +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:equatable/equatable.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'chat_member_bloc.freezed.dart'; + +class ChatMemberBloc extends Bloc { + ChatMemberBloc() : super(const ChatMemberState()) { + on( + (event, emit) async { + event.when( + initial: () {}, + receiveMemberInfo: (String id, WorkspaceMemberPB memberInfo) { + final members = Map.from(state.members); + members[id] = ChatMember(info: memberInfo); + emit(state.copyWith(members: members)); + }, + getMemberInfo: (String userId) { + if (state.members.containsKey(userId)) { + // Member info already exists. Debouncing refresh member info from backend would be better. + return; + } + + final payload = WorkspaceMemberIdPB( + uid: Int64.parseInt(userId), + ); + UserEventGetMemberInfo(payload).send().then((result) { + if (!isClosed) { + result.fold((member) { + add( + ChatMemberEvent.receiveMemberInfo( + userId, + member, + ), + ); + }, (err) { + Log.error("Error getting member info: $err"); + }); + } + }); + }, + ); + }, + ); + } +} + +@freezed +class ChatMemberEvent with _$ChatMemberEvent { + const factory ChatMemberEvent.initial() = Initial; + 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 index a26acd916ffcd..46678062864f5 100644 --- 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 @@ -1,8 +1,8 @@ import 'dart:async'; import 'dart:typed_data'; -import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-chat/notification.pb.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'; diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_service.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_service.dart new file mode 100644 index 0000000000000..1985e41242b4f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_service.dart @@ -0,0 +1,160 @@ +import 'dart:convert'; + +import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_input_action_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-ai/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:nanoid/nanoid.dart'; + +/// Indicate file source from appflowy document +const appflowySoruce = "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); +} + +List messageReferenceSource(String? s) { + if (s == null || s.isEmpty || s == "null") { + return []; + } + + final List metadata = []; + try { + final metadataJson = jsonDecode(s); + if (metadataJson == null) { + Log.warn("metadata is null"); + return []; + } + // [{"id":null,"name":"The Five Dysfunctions of a Team.pdf","source":"/Users/weidongfu/Desktop/The Five Dysfunctions of a Team.pdf"}] + + if (metadataJson is Map) { + if (metadataJson.isNotEmpty) { + metadata.add(ChatMessageRefSource.fromJson(metadataJson)); + } + } else if (metadataJson is List) { + metadata.addAll( + metadataJson.map( + (e) => ChatMessageRefSource.fromJson(e as Map), + ), + ); + } else { + Log.error("Invalid metadata: $metadataJson"); + } + } catch (e) { + Log.error("Failed to parse metadata: $e"); + } + + return metadata; +} + +Future> metadataPBFromMetadata( + Map? map, +) async { + final List metadata = []; + if (map != null) { + for (final entry in map.entries) { + if (entry.value is ViewActionPage) { + if (entry.value.page is ViewPB) { + final view = entry.value.page as ViewPB; + if (view.layout.isDocumentView) { + final payload = OpenDocumentPayloadPB(documentId: view.id); + final result = await DocumentEventGetDocumentText(payload).send(); + result.fold((pb) { + metadata.add( + ChatMessageMetaPB( + id: view.id, + name: view.name, + data: pb.text, + dataType: ChatMessageMetaTypePB.Txt, + source: appflowySoruce, + ), + ); + }, (err) { + Log.error('Failed to get document text: $err'); + }); + } + } + } else if (entry.value is ChatFile) { + metadata.add( + ChatMessageMetaPB( + id: nanoid(8), + name: entry.value.fileName, + data: entry.value.filePath, + dataType: entry.value.fileType, + source: entry.value.filePath, + ), + ); + } + } + } + + return metadata; +} + +List chatFilesFromMessageMetadata( + Map? map, +) { + final List metadata = []; + if (map != null) { + for (final entry in map.entries) { + if (entry.value is ChatFile) { + metadata.add(entry.value); + } + } + } + + return metadata; +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_stream.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_stream.dart new file mode 100644 index 0000000000000..438f3874e2957 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_stream.dart @@ -0,0 +1,193 @@ +import 'dart:async'; +import 'dart:ffi'; +import 'dart:isolate'; + +import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_message_service.dart'; + +class AnswerStream { + AnswerStream() { + _port.handler = _controller.add; + _subscription = _controller.stream.listen( + (event) { + if (event.startsWith("data:")) { + _hasStarted = true; + final newText = event.substring(5); + _text += newText; + if (_onData != null) { + _onData!(_text); + } + } else if (event.startsWith("error:")) { + _error = event.substring(5); + if (_onError != null) { + _onError!(_error!); + } + } else if (event.startsWith("metadata:")) { + if (_onMetadata != null) { + final s = event.substring(9); + _onMetadata!(messageReferenceSource(s)); + } + } else if (event == "AI_RESPONSE_LIMIT") { + if (_onAIResponseLimit != null) { + _onAIResponseLimit!(); + } + } + }, + onDone: () { + if (_onEnd != null) { + _onEnd!(); + } + }, + 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()? _onStart; + void Function()? _onEnd; + void Function(String error)? _onError; + void Function()? _onAIResponseLimit; + void Function(List metadata)? _onMetadata; + + 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()? onStart, + void Function()? onEnd, + void Function(String error)? onError, + void Function()? onAIResponseLimit, + void Function(List metadata)? onMetadata, + }) { + _onData = onData; + _onStart = onStart; + _onEnd = onEnd; + _onError = onError; + _onAIResponseLimit = onAIResponseLimit; + _onMetadata = onMetadata; + + if (_onStart != null) { + _onStart!(); + } + } +} + +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 index 194748858b466..7dc1b550c3070 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_notification.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_notification.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:typed_data'; import 'package:appflowy/core/notification/notification_helper.dart'; -import 'package:appflowy_backend/protobuf/flowy-chat/notification.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/protobuf.dart'; import 'package:appflowy_backend/rust_stream.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_side_pannel_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_side_pannel_bloc.dart new file mode 100644 index 0000000000000..83dc4375b0419 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_side_pannel_bloc.dart @@ -0,0 +1,85 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/ai_chat/application/chat_entity.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:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'chat_side_pannel_bloc.freezed.dart'; + +const double kDefaultSidePannelWidth = 500; + +class ChatSidePannelBloc + extends Bloc { + ChatSidePannelBloc({ + required this.chatId, + }) : super(const ChatSidePannelState()) { + on( + (event, emit) async { + await event.when( + selectedMetadata: (ChatMessageRefSource metadata) async { + emit( + state.copyWith( + metadata: metadata, + indicator: const ChatSidePannelIndicator.loading(), + ), + ); + unawaited( + ViewBackendService.getView(metadata.id).then( + (result) { + result.fold((view) { + if (!isClosed) { + add(ChatSidePannelEvent.open(view)); + } + }, (err) { + Log.error("Failed to get view: $err"); + }); + }, + ), + ); + }, + close: () { + emit(state.copyWith(metadata: null, isShowPannel: false)); + }, + open: (ViewPB view) { + emit( + state.copyWith( + indicator: ChatSidePannelIndicator.ready(view), + isShowPannel: true, + ), + ); + }, + ); + }, + ); + } + + final String chatId; +} + +@freezed +class ChatSidePannelEvent with _$ChatSidePannelEvent { + const factory ChatSidePannelEvent.selectedMetadata( + ChatMessageRefSource metadata, + ) = _SelectedMetadata; + const factory ChatSidePannelEvent.close() = _Close; + const factory ChatSidePannelEvent.open(ViewPB view) = _Open; +} + +@freezed +class ChatSidePannelState with _$ChatSidePannelState { + const factory ChatSidePannelState({ + ChatMessageRefSource? metadata, + @Default(ChatSidePannelIndicator.loading()) + ChatSidePannelIndicator indicator, + @Default(false) bool isShowPannel, + }) = _ChatSidePannelState; +} + +@freezed +class ChatSidePannelIndicator with _$ChatSidePannelIndicator { + const factory ChatSidePannelIndicator.ready(ViewPB view) = _Ready; + const factory ChatSidePannelIndicator.loading() = _Loading; +} 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 index cae2b28c30a7d..d6918eab530c2 100644 --- 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 @@ -1,9 +1,6 @@ -import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart'; import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; -import 'package:fixnum/fixnum.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_chat_types/flutter_chat_types.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'chat_user_message_bloc.freezed.dart'; @@ -11,26 +8,85 @@ part 'chat_user_message_bloc.freezed.dart'; class ChatUserMessageBloc extends Bloc { ChatUserMessageBloc({ - required Message message, - }) : super(ChatUserMessageState.initial(message)) { + required dynamic message, + }) : super( + ChatUserMessageState.initial( + message, + ), + ) { on( - (event, emit) async { + (event, emit) { event.when( initial: () { - final payload = - WorkspaceMemberIdPB(uid: Int64.parseInt(message.author.id)); - UserEventGetMemberInfo(payload).send().then((result) { + if (state.stream != null) { if (!isClosed) { - result.fold((member) { - add(ChatUserMessageEvent.didReceiveMemberInfo(member)); - }, (err) { - Log.error("Error getting member info: $err"); - }); + add(ChatUserMessageEvent.updateText(state.stream!.text)); } - }); + } + + state.stream?.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(), + ), + ); + } + }, + ); + }, + updateText: (String text) { + emit(state.copyWith(text: text)); + }, + updateMessageId: (String messageId) { + emit(state.copyWith(messageId: messageId)); }, - didReceiveMemberInfo: (WorkspaceMemberPB memberInfo) { - emit(state.copyWith(member: memberInfo)); + receiveError: (String error) {}, + updateQuestionState: (QuestionMessageState newState) { + emit(state.copyWith(messageState: newState)); }, ); }, @@ -41,18 +97,47 @@ class ChatUserMessageBloc @freezed class ChatUserMessageEvent with _$ChatUserMessageEvent { const factory ChatUserMessageEvent.initial() = Initial; - const factory ChatUserMessageEvent.didReceiveMemberInfo( - WorkspaceMemberPB memberInfo, - ) = _MemberInfo; + 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 Message message, - WorkspaceMemberPB? member, + required String text, + QuestionStream? stream, + String? messageId, + @Default(QuestionMessageState.finish()) QuestionMessageState messageState, }) = _ChatUserMessageState; - factory ChatUserMessageState.initial(Message message) => - ChatUserMessageState(message: message); + factory ChatUserMessageState.initial( + dynamic message, + ) => + ChatUserMessageState( + text: message is String ? message : "", + stream: message is QuestionStream ? message : null, + ); +} + +@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/application/chat_user_message_bubble_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_user_message_bubble_bloc.dart new file mode 100644 index 0000000000000..c4571915df9c0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_user_message_bubble_bloc.dart @@ -0,0 +1,60 @@ +import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_chat_types/flutter_chat_types.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'chat_message_service.dart'; + +part 'chat_user_message_bubble_bloc.freezed.dart'; + +class ChatUserMessageBubbleBloc + extends Bloc { + ChatUserMessageBubbleBloc({ + required Message message, + }) : super( + ChatUserMessageBubbleState.initial( + message, + _getFiles(message.metadata), + ), + ) { + on( + (event, emit) async { + event.when( + initial: () {}, + ); + }, + ); + } +} + +List _getFiles(Map? metadata) { + if (metadata == null) { + return []; + } + final refSourceMetadata = metadata[messageRefSourceJsonStringKey] as String?; + final files = metadata[messageChatFileListKey] as List?; + + if (refSourceMetadata != null) { + return chatFilesFromMetadataString(refSourceMetadata); + } + return files ?? []; +} + +@freezed +class ChatUserMessageBubbleEvent with _$ChatUserMessageBubbleEvent { + const factory ChatUserMessageBubbleEvent.initial() = Initial; +} + +@freezed +class ChatUserMessageBubbleState with _$ChatUserMessageBubbleState { + const factory ChatUserMessageBubbleState({ + required Message message, + required List files, + }) = _ChatUserMessageBubbleState; + + factory ChatUserMessageBubbleState.initial( + Message message, + List files, + ) => + ChatUserMessageBubbleState(message: message, files: files); +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat.dart index d5c76be5175aa..3d7c1609ace2f 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat.dart @@ -6,6 +6,7 @@ import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_stack.dart'; import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart'; import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; +import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -104,6 +105,11 @@ class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder } }); + if (context.userProfile == null) { + Log.error("User profile is null when opening AI Chat plugin"); + return const SizedBox(); + } + return BlocProvider.value( value: bloc, child: AIChatPage( diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart index 4bab926d46cab..bb2c3af521889 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart @@ -1,28 +1,33 @@ -import 'package:appflowy/plugins/ai_chat/application/chat_file_bloc.dart'; -import 'package:appflowy/plugins/ai_chat/application/chat_input_bloc.dart'; -import 'package:desktop_drop/desktop_drop.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; +import 'dart:math'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart'; -import 'package:appflowy/plugins/ai_chat/presentation/ai_message_bubble.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_file_bloc.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_input_bloc.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart'; import 'package:appflowy/plugins/ai_chat/presentation/chat_related_question.dart'; -import 'package:appflowy/plugins/ai_chat/presentation/user_message_bubble.dart'; -import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy/plugins/ai_chat/presentation/message/ai_message_bubble.dart'; +import 'package:appflowy/plugins/ai_chat/presentation/message/other_user_message_bubble.dart'; +import 'package:appflowy/plugins/ai_chat/presentation/message/user_message_bubble.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:desktop_drop/desktop_drop.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/platform_extension.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:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_chat_types/flutter_chat_types.dart'; import 'package:flutter_chat_types/flutter_chat_types.dart' as types; +import 'package:flutter_chat_types/flutter_chat_types.dart'; import 'package:flutter_chat_ui/flutter_chat_ui.dart' show Chat; +import 'package:styled_widget/styled_widget.dart'; -import 'presentation/chat_input.dart'; -import 'presentation/chat_popmenu.dart'; +import 'application/chat_member_bloc.dart'; +import 'application/chat_side_pannel_bloc.dart'; +import 'presentation/chat_input/chat_input.dart'; +import 'presentation/chat_side_pannel.dart'; import 'presentation/chat_theme.dart'; import 'presentation/chat_user_invalid_message.dart'; import 'presentation/chat_welcome_page.dart'; @@ -70,38 +75,44 @@ class AIChatPage extends StatelessWidget { if (userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) { return MultiBlocProvider( providers: [ - BlocProvider( - create: (_) => ChatFileBloc(chatId: view.id.toString()), - ), + /// [ChatBloc] is used to handle chat messages including send/receive message BlocProvider( create: (_) => ChatBloc( view: view, userProfile: userProfile, )..add(const ChatEvent.initialLoad()), ), - BlocProvider(create: (_) => ChatInputBloc()), + + /// [ChatFileBloc] is used to handle file indexing as a chat context + BlocProvider( + create: (_) => ChatFileBloc()..add(const ChatFileEvent.initial()), + ), + + /// [ChatInputStateBloc] is used to handle chat input text field state + BlocProvider( + create: (_) => + ChatInputStateBloc()..add(const ChatInputStateEvent.started()), + ), + BlocProvider(create: (_) => ChatSidePannelBloc(chatId: view.id)), + BlocProvider(create: (_) => ChatMemberBloc()), ], child: BlocBuilder( builder: (context, state) { - Widget child = _ChatContentPage( - view: view, - userProfile: userProfile, - ); - - // If the chat supports file upload, wrap the chat content with a drop target - if (state.supportChatWithFile) { - child = DropTarget( - onDragDone: (DropDoneDetails detail) async { + return DropTarget( + onDragDone: (DropDoneDetails detail) async { + if (state.supportChatWithFile) { for (final file in detail.files) { context .read() - .add(ChatFileEvent.newFile(file.path)); + .add(ChatFileEvent.newFile(file.path, file.name)); } - }, - child: child, - ); - } - return child; + } + }, + child: _ChatContentPage( + view: view, + userProfile: userProfile, + ), + ); }, ), ); @@ -141,7 +152,71 @@ class _ChatContentPageState extends State<_ChatContentPage> { @override Widget build(BuildContext context) { if (widget.userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) { - return buildChatWidget(); + if (PlatformExtension.isDesktop) { + return BlocSelector( + selector: (state) => state.isShowPannel, + builder: (context, isShowPannel) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final double chatOffsetX = isShowPannel + ? 60 + : (constraints.maxWidth > 784 + ? (constraints.maxWidth - 784) / 2.0 + : 60); + + final double width = isShowPannel + ? (constraints.maxWidth - chatOffsetX * 2) * 0.46 + : min(constraints.maxWidth - chatOffsetX * 2, 784); + + final double sidePannelOffsetX = chatOffsetX + width; + + return Stack( + alignment: AlignmentDirectional.centerStart, + children: [ + buildChatWidget() + .constrained(width: width) + .positioned( + top: 0, + bottom: 0, + left: chatOffsetX, + animate: true, + ) + .animate( + const Duration(milliseconds: 200), + Curves.easeOut, + ), + if (isShowPannel) + buildChatSidePannel() + .positioned( + left: sidePannelOffsetX, + right: 0, + top: 0, + bottom: 0, + animate: true, + ) + .animate( + const Duration(milliseconds: 200), + Curves.easeOut, + ), + ], + ); + }, + ); + }, + ); + } else { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 784), + child: buildChatWidget(), + ), + ), + ], + ); + } } return Center( @@ -152,94 +227,122 @@ class _ChatContentPageState extends State<_ChatContentPage> { ); } + Widget buildChatSidePannel() { + if (PlatformExtension.isDesktop) { + return BlocBuilder( + builder: (context, state) { + if (state.metadata != null) { + return const ChatSidePannel(); + } else { + return const SizedBox.shrink(); + } + }, + ); + } else { + // TODO(lucas): implement mobile chat side panel + return const SizedBox.shrink(); + } + } + Widget buildChatWidget() { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 784), - child: BlocBuilder( - builder: (blocContext, state) => Chat( - messages: state.messages, - onSendPressed: (_) { - // We use custom bottom widget for chat input, so - // do not need to handle this event. - }, - customBottomWidget: buildChatInput(blocContext), - user: _user, - theme: buildTheme(context), - onEndReached: () async { - if (state.hasMorePrevMessage && - state.loadingPreviousStatus != - const LoadingState.loading()) { - blocContext + return BlocBuilder( + builder: (blocContext, state) => Chat( + key: ValueKey(widget.view.id), + messages: state.messages, + onSendPressed: (_) { + // We use custom bottom widget for chat input, so + // do not need to handle this event. + }, + customBottomWidget: _buildBottom(blocContext), + user: _user, + theme: buildTheme(context), + onEndReached: () async { + if (state.hasMorePrevMessage && + state.loadingPreviousStatus.isFinish) { + blocContext + .read() + .add(const ChatEvent.startLoadingPrevMessage()); + } + }, + emptyState: BlocBuilder( + builder: (_, state) => state.initialLoadingStatus.isFinish + ? Padding( + padding: AIChatUILayout.welcomePagePadding, + child: ChatWelcomePage( + userProfile: widget.userProfile, + onSelectedQuestion: (question) => blocContext .read() - .add(const ChatEvent.startLoadingPrevMessage()); - } - }, - emptyState: BlocBuilder( - builder: (_, state) => - state.initialLoadingStatus == const LoadingState.finish() - ? Padding( - padding: AIChatUILayout.welcomePagePadding, - child: ChatWelcomePage( - onSelectedQuestion: (question) => blocContext - .read() - .add(ChatEvent.sendMessage(question)), - ), - ) - : const Center( - child: CircularProgressIndicator.adaptive(), - ), + .add(ChatEvent.sendMessage(message: question)), + ), + ) + : const Center( + child: CircularProgressIndicator.adaptive(), ), - messageWidthRatio: AIChatUILayout.messageWidthRatio, - textMessageBuilder: ( - textMessage, { - required messageWidth, - required showName, - }) => - _buildAITextMessage(blocContext, textMessage), - bubbleBuilder: ( - child, { - required message, - required nextMessageInGroup, - }) { - if (message.author.id == _user.id) { - return ChatUserMessageBubble( - message: message, - child: child, - ); - } - - return _buildAIBubble(message, blocContext, state, child); - }, - ), - ), - ), ), - ], + messageWidthRatio: AIChatUILayout.messageWidthRatio, + textMessageBuilder: ( + textMessage, { + required messageWidth, + required showName, + }) => + _buildTextMessage(blocContext, textMessage), + bubbleBuilder: ( + child, { + required message, + required nextMessageInGroup, + }) => + _buildBubble(blocContext, message, child, state), + ), ); } - Widget _buildAITextMessage(BuildContext context, TextMessage message) { - final isAuthor = message.author.id == _user.id; - if (isAuthor) { - return ChatTextMessageWidget( + Widget _buildBubble( + BuildContext blocContext, + Message message, + Widget child, + ChatState state, + ) { + if (message.author.id == _user.id) { + return ChatUserMessageBubble( + message: message, + child: child, + ); + } else if (isOtherUserMessage(message)) { + return OtherUserMessageBubble( + message: message, + child: child, + ); + } else { + return _buildAIBubble(message, blocContext, state, child); + } + } + + Widget _buildTextMessage(BuildContext context, TextMessage message) { + if (message.author.id == _user.id) { + final stream = message.metadata?["$QuestionStream"]; + return ChatUserMessageWidget( + key: ValueKey(message.id), user: message.author, - messageUserId: message.id, - text: message.text, + message: stream is QuestionStream ? stream : message.text, ); } else { final stream = message.metadata?["$AnswerStream"]; - final questionId = message.metadata?["question"]; - return ChatAITextMessageWidget( + final questionId = message.metadata?[messageQuestionIdKey]; + final refSourceJsonString = + message.metadata?[messageRefSourceJsonStringKey] as String?; + return ChatAIMessageWidget( user: message.author, messageUserId: message.id, - text: stream is AnswerStream ? stream : message.text, + message: stream is AnswerStream ? stream : message.text, key: ValueKey(message.id), questionId: questionId, chatId: widget.view.id, + refSourceJsonString: refSourceJsonString, + onSelectedMetadata: (ChatMessageRefSource metadata) { + context.read().add( + ChatSidePannelEvent.selectedMetadata(metadata), + ); + }, ); } } @@ -263,7 +366,9 @@ class _ChatContentPageState extends State<_ChatContentPage> { if (messageType == OnetimeShotType.relatedQuestion) { return RelatedQuestionList( onQuestionSelected: (question) { - blocContext.read().add(ChatEvent.sendMessage(question)); + blocContext + .read() + .add(ChatEvent.sendMessage(message: question)); blocContext .read() .add(const ChatEvent.clearReleatedQuestion()); @@ -280,93 +385,47 @@ class _ChatContentPageState extends State<_ChatContentPage> { ); } - Widget buildBubble(Message message, Widget child) { - final isAuthor = message.author.id == _user.id; - const borderRadius = BorderRadius.all(Radius.circular(6)); - final childWithPadding = isAuthor - ? Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), - child: child, - ) - : Padding( - padding: const EdgeInsets.all(8), - child: child, - ); - - // If the message is from the author, we will decorate it with a different color - final decoratedChild = isAuthor - ? DecoratedBox( - decoration: BoxDecoration( - borderRadius: borderRadius, - color: !isAuthor || message.type == types.MessageType.image - ? AFThemeExtension.of(context).tint1 - : Theme.of(context).colorScheme.secondary, - ), - child: childWithPadding, - ) - : childWithPadding; - - // If the message is from the author, no further actions are needed - if (isAuthor) { - return ClipRRect( - borderRadius: borderRadius, - child: decoratedChild, - ); - } else { - if (isMobile) { - return ChatPopupMenu( - onAction: (action) { - switch (action) { - case ChatMessageAction.copy: - if (message is TextMessage) { - Clipboard.setData(ClipboardData(text: message.text)); - showMessageToast(LocaleKeys.grid_row_copyProperty.tr()); - } - break; - } - }, - builder: (context) => - ClipRRect(borderRadius: borderRadius, child: decoratedChild), - ); - } else { - // Show hover effect only on desktop - return ClipRRect( - borderRadius: borderRadius, - child: ChatAIMessageHover( - message: message, - child: decoratedChild, - ), - ); - } - } - } - - Widget buildChatInput(BuildContext context) { + Widget _buildBottom(BuildContext context) { return ClipRect( child: Padding( padding: AIChatUILayout.safeAreaInsets(context), - child: BlocBuilder( + child: BlocBuilder( builder: (context, state) { - return state.aiType.when( - appflowyAI: () => Column( - children: [ - BlocSelector( - selector: (state) => state.streamingStatus, - builder: (context, state) { - return ChatInput( - chatId: widget.view.id, - onSendPressed: (message) => - onSendPressed(context, message.text), - isStreaming: state != const LoadingState.finish(), - onStopStreaming: () { - context - .read() - .add(const ChatEvent.stopStream()); - }, - ); - }, - ), - const VSpace(6), + // Show different hint text based on the AI type + final aiType = state.aiType; + final hintText = state.aiType.when( + appflowyAI: () => LocaleKeys.chat_inputMessageHint.tr(), + localAI: () => LocaleKeys.chat_inputLocalAIMessageHint.tr(), + ); + + return Column( + children: [ + BlocSelector( + selector: (state) => state.canSendMessage, + builder: (context, canSendMessage) { + return ChatInput( + aiType: aiType, + chatId: widget.view.id, + onSendPressed: (message) { + context.read().add( + ChatEvent.sendMessage( + message: message.text, + metadata: message.metadata, + ), + ); + }, + isStreaming: !canSendMessage, + onStopStreaming: () { + context + .read() + .add(const ChatEvent.stopStream()); + }, + hintText: hintText, + ); + }, + ), + const VSpace(6), + if (PlatformExtension.isDesktop) Opacity( opacity: 0.6, child: FlowyText( @@ -374,63 +433,57 @@ class _ChatContentPageState extends State<_ChatContentPage> { fontSize: 12, ), ), - ], - ), - localAI: () => const SizedBox.shrink(), + ], ); }, ), ), ); } +} - AFDefaultChatTheme buildTheme(BuildContext context) { - return AFDefaultChatTheme( - backgroundColor: AFThemeExtension.of(context).background, - primaryColor: Theme.of(context).colorScheme.primary, - secondaryColor: AFThemeExtension.of(context).tint1, - receivedMessageDocumentIconColor: Theme.of(context).primaryColor, - receivedMessageCaptionTextStyle: TextStyle( - color: AFThemeExtension.of(context).textColor, - fontSize: 16, - fontWeight: FontWeight.w500, - height: 1.5, - ), - receivedMessageBodyTextStyle: TextStyle( - color: AFThemeExtension.of(context).textColor, - fontSize: 16, - fontWeight: FontWeight.w500, - height: 1.5, - ), - receivedMessageLinkTitleTextStyle: TextStyle( - color: AFThemeExtension.of(context).textColor, - fontSize: 16, - fontWeight: FontWeight.w500, - height: 1.5, - ), - receivedMessageBodyLinkTextStyle: const TextStyle( - color: Colors.lightBlue, - fontSize: 16, - fontWeight: FontWeight.w500, - height: 1.5, - ), - sentMessageBodyTextStyle: TextStyle( - color: AFThemeExtension.of(context).textColor, - fontSize: 16, - fontWeight: FontWeight.w500, - height: 1.5, - ), - sentMessageBodyLinkTextStyle: const TextStyle( - color: Colors.blue, - fontSize: 16, - fontWeight: FontWeight.w500, - height: 1.5, - ), - inputElevation: 2, - ); - } - - void onSendPressed(BuildContext context, String message) { - context.read().add(ChatEvent.sendMessage(message)); - } +AFDefaultChatTheme buildTheme(BuildContext context) { + return AFDefaultChatTheme( + backgroundColor: AFThemeExtension.of(context).background, + primaryColor: Theme.of(context).colorScheme.primary, + secondaryColor: AFThemeExtension.of(context).tint1, + receivedMessageDocumentIconColor: Theme.of(context).primaryColor, + receivedMessageCaptionTextStyle: TextStyle( + color: AFThemeExtension.of(context).textColor, + fontSize: 16, + fontWeight: FontWeight.w500, + height: 1.5, + ), + receivedMessageBodyTextStyle: TextStyle( + color: AFThemeExtension.of(context).textColor, + fontSize: 16, + fontWeight: FontWeight.w500, + height: 1.5, + ), + receivedMessageLinkTitleTextStyle: TextStyle( + color: AFThemeExtension.of(context).textColor, + fontSize: 16, + fontWeight: FontWeight.w500, + height: 1.5, + ), + receivedMessageBodyLinkTextStyle: const TextStyle( + color: Colors.lightBlue, + fontSize: 16, + fontWeight: FontWeight.w500, + height: 1.5, + ), + sentMessageBodyTextStyle: TextStyle( + color: AFThemeExtension.of(context).textColor, + fontSize: 16, + fontWeight: FontWeight.w500, + height: 1.5, + ), + sentMessageBodyLinkTextStyle: const TextStyle( + color: Colors.blue, + fontSize: 16, + fontWeight: FontWeight.w500, + height: 1.5, + ), + inputElevation: 2, + ); } 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 index 7f2e01f989137..c9f1422900883 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_avatar.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_avatar.dart @@ -8,6 +8,8 @@ import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:string_validator/string_validator.dart'; +const defaultAvatarSize = 30.0; + class ChatChatUserAvatar extends StatelessWidget { const ChatChatUserAvatar({required this.userId, super.key}); @@ -33,15 +35,18 @@ class ChatBorderedCircleAvatar extends StatelessWidget { @override Widget build(BuildContext context) { - return CircleAvatar( - backgroundColor: border.color, - child: ConstrainedBox( - constraints: const BoxConstraints.expand(), - child: CircleAvatar( - backgroundImage: backgroundImage, - backgroundColor: - Theme.of(context).colorScheme.surfaceContainerHighest, - child: child, + return SizedBox( + width: defaultAvatarSize, + child: CircleAvatar( + backgroundColor: border.color, + child: ConstrainedBox( + constraints: const BoxConstraints.expand(), + child: CircleAvatar( + backgroundImage: backgroundImage, + backgroundColor: + Theme.of(context).colorScheme.surfaceContainerHighest, + child: child, + ), ), ), ); @@ -53,13 +58,15 @@ class ChatUserAvatar extends StatelessWidget { super.key, required this.iconUrl, required this.name, - required this.size, + this.size = defaultAvatarSize, this.isHovering = false, + this.defaultName, }); final String iconUrl; final String name; final double size; + final String? defaultName; // If true, a border will be applied on top of the avatar final bool isHovering; @@ -76,7 +83,8 @@ class ChatUserAvatar extends StatelessWidget { } Widget _buildEmptyAvatar(BuildContext context) { - final String nameOrDefault = _userName(name); + final String nameOrDefault = _userName(name, defaultName); + final Color color = ColorGenerator(name).toColor(); const initialsCount = 2; @@ -170,8 +178,8 @@ class ChatUserAvatar extends StatelessWidget { /// Return the user name, if the user name is empty, /// return the default user name. /// - String _userName(String name) => - name.isEmpty ? LocaleKeys.defaultUsername.tr() : name; + String _userName(String name, String? defaultName) => + name.isEmpty ? (defaultName ?? LocaleKeys.defaultUsername.tr()) : name; /// Used to darken the generated color for the hover border effect. /// The color is darkened by 15% - Hence the 0.15 value. diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input.dart deleted file mode 100644 index fdea08b0b1dc9..0000000000000 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input.dart +++ /dev/null @@ -1,302 +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/theme_extension.dart'; -import 'package:flowy_infra_ui/style_widget/icon_button.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_chat_types/flutter_chat_types.dart' as types; -import 'package:flutter_chat_ui/flutter_chat_ui.dart'; - -class ChatInput extends StatefulWidget { - /// Creates [ChatInput] widget. - const ChatInput({ - super.key, - this.isAttachmentUploading, - this.onAttachmentPressed, - required this.onSendPressed, - required this.chatId, - this.options = const InputOptions(), - required this.isStreaming, - required this.onStopStreaming, - }); - - final bool? isAttachmentUploading; - final VoidCallback? onAttachmentPressed; - final void Function(types.PartialText) onSendPressed; - final void Function() onStopStreaming; - final InputOptions options; - final String chatId; - final bool isStreaming; - - @override - State createState() => _ChatInputState(); -} - -/// [ChatInput] widget state. -class _ChatInputState extends State { - late final _inputFocusNode = FocusNode( - onKeyEvent: (node, event) { - if (event.physicalKey == PhysicalKeyboardKey.enter && - !HardwareKeyboard.instance.physicalKeysPressed.any( - (el) => { - PhysicalKeyboardKey.shiftLeft, - PhysicalKeyboardKey.shiftRight, - }.contains(el), - )) { - if (kIsWeb && _textController.value.isComposingRangeValid) { - return KeyEventResult.ignored; - } - if (event is KeyDownEvent) { - if (!widget.isStreaming) { - _handleSendPressed(); - } - } - return KeyEventResult.handled; - } else { - return KeyEventResult.ignored; - } - }, - ); - - bool _sendButtonVisible = false; - late TextEditingController _textController; - - @override - void initState() { - super.initState(); - - _textController = - widget.options.textEditingController ?? InputTextFieldController(); - _handleSendButtonVisibilityModeChange(); - } - - void _handleSendButtonVisibilityModeChange() { - _textController.removeListener(_handleTextControllerChange); - _sendButtonVisible = - _textController.text.trim() != '' || widget.isStreaming; - _textController.addListener(_handleTextControllerChange); - } - - void _handleSendPressed() { - final trimmedText = _textController.text.trim(); - if (trimmedText != '') { - final partialText = types.PartialText(text: trimmedText); - widget.onSendPressed(partialText); - - if (widget.options.inputClearMode == InputClearMode.always) { - _textController.clear(); - } - } - } - - void _handleTextControllerChange() { - if (_textController.value.isComposingRangeValid) { - return; - } - setState(() { - _sendButtonVisible = _textController.text.trim() != ''; - }); - } - - Widget _inputBuilder() { - const textPadding = EdgeInsets.symmetric(horizontal: 16, vertical: 6); - const buttonPadding = EdgeInsets.symmetric(horizontal: 16, vertical: 6); - const inputPadding = EdgeInsets.all(6); - - return Focus( - autofocus: !widget.options.autofocus, - child: Padding( - padding: inputPadding, - child: Material( - borderRadius: BorderRadius.circular(30), - color: isMobile - ? Theme.of(context).colorScheme.surfaceContainer - : Theme.of(context).colorScheme.surfaceContainerHighest, - elevation: 0.6, - child: Row( - children: [ - if (widget.onAttachmentPressed != null) - AttachmentButton( - isLoading: widget.isAttachmentUploading ?? false, - onPressed: widget.onAttachmentPressed, - padding: buttonPadding, - ), - Expanded(child: _inputTextField(textPadding)), - _sendButton(buttonPadding), - ], - ), - ), - ), - ); - } - - Padding _inputTextField(EdgeInsets textPadding) { - return Padding( - padding: textPadding, - child: TextField( - controller: _textController, - focusNode: _inputFocusNode, - decoration: InputDecoration( - border: InputBorder.none, - hintText: LocaleKeys.chat_inputMessageHint.tr(), - hintStyle: TextStyle( - color: AFThemeExtension.of(context).textColor.withOpacity(0.5), - ), - ), - style: TextStyle( - color: AFThemeExtension.of(context).textColor, - ), - autocorrect: widget.options.autocorrect, - autofocus: widget.options.autofocus, - enableSuggestions: widget.options.enableSuggestions, - keyboardType: widget.options.keyboardType, - textCapitalization: TextCapitalization.sentences, - maxLines: 10, - minLines: 1, - onChanged: widget.options.onTextChanged, - onTap: widget.options.onTextFieldTap, - ), - ); - } - - ConstrainedBox _sendButton(EdgeInsets buttonPadding) { - return ConstrainedBox( - constraints: BoxConstraints( - minHeight: buttonPadding.bottom + buttonPadding.top + 24, - ), - child: Visibility( - visible: _sendButtonVisible, - child: Padding( - padding: buttonPadding, - child: AccessoryButton( - onSendPressed: () { - if (!widget.isStreaming) { - widget.onStopStreaming(); - _handleSendPressed(); - } - }, - onStopStreaming: () { - widget.onStopStreaming(); - }, - isStreaming: widget.isStreaming, - ), - ), - ), - ); - } - - @override - void didUpdateWidget(covariant ChatInput oldWidget) { - super.didUpdateWidget(oldWidget); - _handleSendButtonVisibilityModeChange(); - } - - @override - void dispose() { - _inputFocusNode.dispose(); - _textController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) => GestureDetector( - onTap: () => _inputFocusNode.requestFocus(), - child: _inputBuilder(), - ); -} - -@immutable -class InputOptions { - const InputOptions({ - this.inputClearMode = InputClearMode.always, - this.keyboardType = TextInputType.multiline, - this.onTextChanged, - this.onTextFieldTap, - this.textEditingController, - this.autocorrect = true, - this.autofocus = false, - this.enableSuggestions = true, - this.enabled = true, - }); - - /// Controls the [ChatInput] clear behavior. Defaults to [InputClearMode.always]. - final InputClearMode inputClearMode; - - /// Controls the [ChatInput] keyboard type. Defaults to [TextInputType.multiline]. - final TextInputType keyboardType; - - /// Will be called whenever the text inside [TextField] changes. - final void Function(String)? onTextChanged; - - /// Will be called on [TextField] tap. - final VoidCallback? onTextFieldTap; - - /// Custom [TextEditingController]. If not provided, defaults to the - /// [InputTextFieldController], which extends [TextEditingController] and has - /// additional fatures like markdown support. If you want to keep additional - /// features but still need some methods from the default [TextEditingController], - /// you can create your own [InputTextFieldController] (imported from this lib) - /// and pass it here. - final TextEditingController? textEditingController; - - /// Controls the [TextInput] autocorrect behavior. Defaults to [true]. - final bool autocorrect; - - /// Whether [TextInput] should have focus. Defaults to [false]. - final bool autofocus; - - /// Controls the [TextInput] enableSuggestions behavior. Defaults to [true]. - final bool enableSuggestions; - - /// Controls the [TextInput] enabled behavior. Defaults to [true]. - final bool enabled; -} - -final isMobile = defaultTargetPlatform == TargetPlatform.android || - defaultTargetPlatform == TargetPlatform.iOS; - -class AccessoryButton extends StatelessWidget { - const AccessoryButton({ - required this.onSendPressed, - required this.onStopStreaming, - required this.isStreaming, - super.key, - }); - - final void Function() onSendPressed; - final void Function() onStopStreaming; - final bool isStreaming; - - @override - Widget build(BuildContext context) { - if (isStreaming) { - return FlowyIconButton( - width: 36, - icon: FlowySvg( - FlowySvgs.ai_stream_stop_s, - size: const Size.square(28), - color: Theme.of(context).colorScheme.primary, - ), - onPressed: onStopStreaming, - radius: BorderRadius.circular(18), - fillColor: AFThemeExtension.of(context).lightGreyHover, - hoverColor: AFThemeExtension.of(context).lightGreyHover, - ); - } else { - return FlowyIconButton( - width: 36, - fillColor: AFThemeExtension.of(context).lightGreyHover, - hoverColor: AFThemeExtension.of(context).lightGreyHover, - radius: BorderRadius.circular(18), - icon: FlowySvg( - FlowySvgs.send_s, - size: const Size.square(24), - color: Theme.of(context).colorScheme.primary, - ), - onPressed: onSendPressed, - ); - } - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_at_button.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_at_button.dart new file mode 100644 index 0000000000000..53741f44316a3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_at_button.dart @@ -0,0 +1,30 @@ +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 'package:flutter/material.dart'; + +class ChatInputAtButton extends StatelessWidget { + const ChatInputAtButton({required this.onTap, super.key}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return FlowyTooltip( + message: LocaleKeys.chat_clickToMention.tr(), + child: FlowyIconButton( + hoverColor: AFThemeExtension.of(context).lightGreyHover, + radius: BorderRadius.circular(6), + icon: FlowySvg( + FlowySvgs.chat_at_s, + size: const Size.square(20), + color: Colors.grey.shade600, + ), + onPressed: onTap, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input.dart new file mode 100644 index 0000000000000..54c70d61cc64b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input.dart @@ -0,0 +1,450 @@ +import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_file_bloc.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_input_action_bloc.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_input_action_control.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_input_bloc.dart'; +import 'package:appflowy/plugins/ai_chat/presentation/chat_input/chat_input_file.dart'; +import 'package:appflowy/plugins/ai_chat/presentation/chat_input_action_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mobile_page_selector_sheet.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:extended_text_field/extended_text_field.dart'; +import 'package:flowy_infra/file_picker/file_picker_service.dart'; +import 'package:flowy_infra/platform_extension.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 'package:flutter_chat_types/flutter_chat_types.dart' as types; +import 'package:flutter_chat_ui/flutter_chat_ui.dart'; + +import 'chat_at_button.dart'; +import 'chat_input_attachment.dart'; +import 'chat_input_span.dart'; +import 'chat_send_button.dart'; +import 'layout_define.dart'; + +class ChatInput extends StatefulWidget { + /// Creates [ChatInput] widget. + const ChatInput({ + super.key, + this.onAttachmentPressed, + required this.onSendPressed, + required this.chatId, + this.options = const InputOptions(), + required this.isStreaming, + required this.onStopStreaming, + required this.hintText, + required this.aiType, + }); + + final VoidCallback? onAttachmentPressed; + final void Function(types.PartialText) onSendPressed; + final void Function() onStopStreaming; + final InputOptions options; + final String chatId; + final bool isStreaming; + final String hintText; + final AIType aiType; + + @override + State createState() => _ChatInputState(); +} + +/// [ChatInput] widget state. +class _ChatInputState extends State { + final GlobalKey _textFieldKey = GlobalKey(); + final LayerLink _layerLink = LayerLink(); + late ChatInputActionControl _inputActionControl; + late FocusNode _inputFocusNode; + late TextEditingController _textController; + bool _sendButtonEnabled = false; + + @override + void initState() { + super.initState(); + _textController = InputTextFieldController(); + _inputFocusNode = FocusNode( + onKeyEvent: (node, event) { + if (PlatformExtension.isDesktop) { + if (_inputActionControl.canHandleKeyEvent(event)) { + _inputActionControl.handleKeyEvent(event); + return KeyEventResult.handled; + } else { + return _handleEnterKeyWithoutShift( + event, + _textController, + widget.isStreaming, + _handleSendPressed, + ); + } + } else { + return KeyEventResult.ignored; + } + }, + ); + + _inputFocusNode.addListener(() { + setState(() {}); + }); + + _inputActionControl = ChatInputActionControl( + chatId: widget.chatId, + textController: _textController, + textFieldFocusNode: _inputFocusNode, + ); + _inputFocusNode.requestFocus(); + _handleSendButtonVisibilityModeChange(); + } + + @override + void dispose() { + _inputFocusNode.dispose(); + _textController.dispose(); + _inputActionControl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: inputPadding, + // ignore: use_decorated_box + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: _inputFocusNode.hasFocus + ? Theme.of(context).colorScheme.primary.withOpacity(0.6) + : Theme.of(context).colorScheme.secondary, + ), + borderRadius: borderRadius, + ), + child: Material( + borderRadius: borderRadius, + color: color, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (context.read().state.uploadFiles.isNotEmpty) + Padding( + padding: EdgeInsets.only( + top: 12, + bottom: 12, + left: textPadding.left + sendButtonSize, + right: textPadding.right, + ), + child: BlocBuilder( + builder: (context, state) { + return ChatInputFile( + chatId: widget.chatId, + files: state.uploadFiles, + onDeleted: (file) => context.read().add( + ChatFileEvent.deleteFile(file), + ), + ); + }, + ), + ), + + // + Row( + children: [ + // TODO(lucas): support mobile + if (PlatformExtension.isDesktop && + widget.aiType.isLocalAI()) + _attachmentButton(buttonPadding), + + // text field + Expanded(child: _inputTextField(context, textPadding)), + + // mention button + _mentionButton(buttonPadding), + + if (PlatformExtension.isMobile) const HSpace(6.0), + + // send button + _sendButton(buttonPadding), + ], + ), + ], + ), + ), + ), + ), + ); + } + + void _handleSendButtonVisibilityModeChange() { + _textController.removeListener(_handleTextControllerChange); + _sendButtonEnabled = + _textController.text.trim() != '' || widget.isStreaming; + _textController.addListener(_handleTextControllerChange); + } + + void _handleSendPressed() { + final trimmedText = _textController.text.trim(); + if (trimmedText != '') { + // consume metadata + final ChatInputMentionMetadata mentionPageMetadata = + _inputActionControl.consumeMetaData(); + final ChatInputFileMetadata fileMetadata = + context.read().consumeMetaData(); + + // combine metadata + final Map metadata = {} + ..addAll(mentionPageMetadata) + ..addAll(fileMetadata); + + final partialText = types.PartialText( + text: trimmedText, + metadata: metadata, + ); + widget.onSendPressed(partialText); + _textController.clear(); + } + } + + void _handleTextControllerChange() { + if (_textController.value.isComposingRangeValid) { + return; + } + setState(() { + _sendButtonEnabled = _textController.text.trim() != ''; + }); + } + + Widget _inputTextField(BuildContext context, EdgeInsets textPadding) { + return CompositedTransformTarget( + link: _layerLink, + child: Padding( + padding: textPadding, + child: ExtendedTextField( + key: _textFieldKey, + controller: _textController, + focusNode: _inputFocusNode, + decoration: _buildInputDecoration(context), + keyboardType: TextInputType.multiline, + textCapitalization: TextCapitalization.sentences, + minLines: 1, + maxLines: 10, + style: _buildTextStyle(context), + specialTextSpanBuilder: ChatInputTextSpanBuilder( + inputActionControl: _inputActionControl, + ), + onChanged: (text) { + _handleOnTextChange(context, text); + }, + ), + ), + ); + } + + InputDecoration _buildInputDecoration(BuildContext context) { + return InputDecoration( + border: InputBorder.none, + enabledBorder: InputBorder.none, + hintText: widget.hintText, + focusedBorder: InputBorder.none, + hintStyle: TextStyle( + color: AFThemeExtension.of(context).textColor.withOpacity(0.5), + ), + ); + } + + TextStyle? _buildTextStyle(BuildContext context) { + if (!isMobile) { + return TextStyle( + color: AFThemeExtension.of(context).textColor, + fontSize: 15, + ); + } + + return Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: 15, + height: 1.2, + ); + } + + Future _handleOnTextChange(BuildContext context, String text) async { + if (!_inputActionControl.onTextChanged(text)) { + return; + } + + if (PlatformExtension.isDesktop) { + ChatActionsMenu( + anchor: ChatInputAnchor( + anchorKey: _textFieldKey, + layerLink: _layerLink, + ), + handler: _inputActionControl, + context: context, + style: Theme.of(context).brightness == Brightness.dark + ? const ChatActionsMenuStyle.dark() + : const ChatActionsMenuStyle.light(), + ).show(); + } else { + // if the focus node is on focus, unfocus it for better animation + // otherwise, the page sheet animation will be blocked by the keyboard + if (_inputFocusNode.hasFocus) { + _inputFocusNode.unfocus(); + Future.delayed(const Duration(milliseconds: 100), () async { + await _referPage(_inputActionControl); + }); + } else { + await _referPage(_inputActionControl); + } + } + } + + Widget _sendButton(EdgeInsets buttonPadding) { + return Padding( + padding: buttonPadding, + child: SizedBox.square( + dimension: sendButtonSize, + child: ChatInputSendButton( + onSendPressed: () { + if (!_sendButtonEnabled) { + return; + } + + if (!widget.isStreaming) { + widget.onStopStreaming(); + _handleSendPressed(); + } + }, + onStopStreaming: () => widget.onStopStreaming(), + isStreaming: widget.isStreaming, + enabled: _sendButtonEnabled, + ), + ), + ); + } + + Widget _attachmentButton(EdgeInsets buttonPadding) { + return Padding( + padding: buttonPadding, + child: SizedBox.square( + dimension: attachButtonSize, + child: ChatInputAttachment( + onTap: () async { + final path = await getIt().pickFiles( + dialogTitle: '', + type: FileType.custom, + allowedExtensions: ["pdf"], + ); + if (path == null) { + return; + } + + for (final file in path.files) { + if (file.path != null) { + if (mounted) { + context + .read() + .add(ChatFileEvent.newFile(file.path!, file.name)); + } + } + } + }, + ), + ), + ); + } + + Widget _mentionButton(EdgeInsets buttonPadding) { + return Padding( + padding: buttonPadding, + child: SizedBox.square( + dimension: attachButtonSize, + child: ChatInputAtButton( + onTap: () { + _textController.text += '@'; + if (!isMobile) { + _inputFocusNode.requestFocus(); + } + _handleOnTextChange(context, _textController.text); + }, + ), + ), + ); + } + + Future _referPage(ChatActionHandler handler) async { + handler.onEnter(); + final selectedView = await showPageSelectorSheet( + context, + filter: (view) => + view.layout.isDocumentView && + !view.isSpace && + view.parentViewId.isNotEmpty, + ); + if (selectedView == null) { + handler.onExit(); + return; + } + handler.onSelected(ViewActionPage(view: selectedView)); + handler.onExit(); + _inputFocusNode.requestFocus(); + } + + @override + void didUpdateWidget(covariant ChatInput oldWidget) { + super.didUpdateWidget(oldWidget); + _handleSendButtonVisibilityModeChange(); + } +} + +final isMobile = defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS; + +class ChatInputAnchor extends ChatAnchor { + ChatInputAnchor({ + required this.anchorKey, + required this.layerLink, + }); + + @override + final GlobalKey> anchorKey; + + @override + final LayerLink layerLink; +} + +/// Handles the key press event for the Enter key without Shift. +/// +/// This function checks if the Enter key is pressed without either of the Shift keys. +/// If the conditions are met, it performs the action of sending a message if the +/// text controller is not in a composing range and if the event is a key down event. +/// +/// - Returns: A `KeyEventResult` indicating whether the key event was handled or ignored. +KeyEventResult _handleEnterKeyWithoutShift( + KeyEvent event, + TextEditingController textController, + bool isStreaming, + void Function() handleSendPressed, +) { + if (event.physicalKey == PhysicalKeyboardKey.enter && + !HardwareKeyboard.instance.physicalKeysPressed.any( + (el) => { + PhysicalKeyboardKey.shiftLeft, + PhysicalKeyboardKey.shiftRight, + }.contains(el), + )) { + if (textController.value.isComposingRangeValid) { + return KeyEventResult.ignored; + } + + if (event is KeyDownEvent) { + if (!isStreaming) { + handleSendPressed(); + } + } + return KeyEventResult.handled; + } else { + return KeyEventResult.ignored; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input_attachment.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input_attachment.dart new file mode 100644 index 0000000000000..954988da7c62f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input_attachment.dart @@ -0,0 +1,30 @@ +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'; + +class ChatInputAttachment extends StatelessWidget { + const ChatInputAttachment({required this.onTap, super.key}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return FlowyTooltip( + message: LocaleKeys.chat_uploadFile.tr(), + child: FlowyIconButton( + hoverColor: AFThemeExtension.of(context).lightGreyHover, + radius: BorderRadius.circular(6), + icon: FlowySvg( + FlowySvgs.ai_attachment_s, + size: const Size.square(20), + color: Colors.grey.shade600, + ), + onPressed: onTap, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input_file.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input_file.dart new file mode 100644 index 0000000000000..ba6170a69a6da --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input_file.dart @@ -0,0 +1,130 @@ +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_input_file_bloc.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:styled_widget/styled_widget.dart'; + +class ChatInputFile extends StatelessWidget { + const ChatInputFile({ + required this.chatId, + required this.files, + required this.onDeleted, + super.key, + }); + final List files; + final String chatId; + + final Function(ChatFile) onDeleted; + + @override + Widget build(BuildContext context) { + final List children = files + .map( + (file) => ChatFilePreview( + chatId: chatId, + file: file, + onDeleted: onDeleted, + ), + ) + .toList(); + + return Wrap( + spacing: 6, + runSpacing: 6, + children: children, + ); + } +} + +class ChatFilePreview extends StatelessWidget { + const ChatFilePreview({ + required this.chatId, + required this.file, + required this.onDeleted, + super.key, + }); + final String chatId; + final ChatFile file; + final Function(ChatFile) onDeleted; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => ChatInputFileBloc(chatId: chatId, file: file) + ..add(const ChatInputFileEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + return FlowyHover( + builder: (context, onHover) { + return ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 260, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: + Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Stack( + clipBehavior: Clip.none, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10.0, + vertical: 14, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + file.fileType.icon, + const HSpace(6), + Flexible( + child: FlowyText( + file.fileName, + fontSize: 12, + maxLines: 6, + ), + ), + ], + ), + ), + if (onHover) + _CloseButton( + onPressed: () => onDeleted(file), + ).positioned(top: -6, right: -6), + ], + ), + ), + ); + }, + ); + }, + ), + ); + } +} + +class _CloseButton extends StatelessWidget { + const _CloseButton({required this.onPressed}); + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return FlowyIconButton( + width: 24, + height: 24, + isSelected: true, + radius: BorderRadius.circular(12), + fillColor: Theme.of(context).colorScheme.surfaceContainer, + icon: const FlowySvg( + FlowySvgs.close_s, + size: Size.square(20), + ), + onPressed: onPressed, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input_span.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input_span.dart new file mode 100644 index 0000000000000..1a474c48824dc --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input_span.dart @@ -0,0 +1,74 @@ +import 'package:extended_text_library/extended_text_library.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import '../../application/chat_input_action_control.dart'; + +class ChatInputTextSpanBuilder extends SpecialTextSpanBuilder { + ChatInputTextSpanBuilder({ + required this.inputActionControl, + }); + + final ChatInputActionControl inputActionControl; + + @override + SpecialText? createSpecialText( + String flag, { + TextStyle? textStyle, + SpecialTextGestureTapCallback? onTap, + int? index, + }) { + if (flag == '') { + return null; + } + + //index is end index of start flag, so text start index should be index-(flag.length-1) + if (isStart(flag, AtText.flag)) { + return AtText( + inputActionControl, + textStyle, + onTap, + start: index! - (AtText.flag.length - 1), + ); + } + return null; + } +} + +class AtText extends SpecialText { + AtText( + this.inputActionControl, + TextStyle? textStyle, + SpecialTextGestureTapCallback? onTap, { + this.start, + }) : super(flag, '', textStyle, onTap: onTap); + static const String flag = '@'; + final int? start; + final ChatInputActionControl inputActionControl; + + @override + bool isEnd(String value) { + return inputActionControl.tags.contains(value); + } + + @override + InlineSpan finishText() { + final TextStyle? textStyle = + this.textStyle?.copyWith(color: Colors.blue, fontSize: 15.0); + + final String atText = toString(); + + return SpecialTextSpan( + text: atText, + actualText: atText, + start: start!, + style: textStyle, + recognizer: (TapGestureRecognizer() + ..onTap = () { + if (onTap != null) { + onTap!(atText); + } + }), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_send_button.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_send_button.dart new file mode 100644 index 0000000000000..2de77e9362004 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_send_button.dart @@ -0,0 +1,50 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/icon_button.dart'; +import 'package:flutter/material.dart'; + +class ChatInputSendButton extends StatelessWidget { + const ChatInputSendButton({ + required this.onSendPressed, + required this.onStopStreaming, + required this.isStreaming, + required this.enabled, + super.key, + }); + + final void Function() onSendPressed; + final void Function() onStopStreaming; + final bool isStreaming; + final bool enabled; + + @override + Widget build(BuildContext context) { + if (isStreaming) { + return FlowyIconButton( + icon: FlowySvg( + FlowySvgs.ai_stream_stop_s, + size: const Size.square(20), + color: Theme.of(context).colorScheme.primary, + ), + onPressed: onStopStreaming, + radius: BorderRadius.circular(18), + fillColor: AFThemeExtension.of(context).lightGreyHover, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + ); + } else { + return FlowyIconButton( + fillColor: AFThemeExtension.of(context).lightGreyHover, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + radius: BorderRadius.circular(18), + icon: FlowySvg( + FlowySvgs.send_s, + size: const Size.square(14), + color: enabled + ? Theme.of(context).colorScheme.primary + : Colors.grey.shade600, + ), + onPressed: onSendPressed, + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/layout_define.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/layout_define.dart new file mode 100644 index 0000000000000..74a66d0130d2a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/layout_define.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +import 'chat_input.dart'; + +const double sendButtonSize = 26; +const double attachButtonSize = 26; +const buttonPadding = EdgeInsets.symmetric(horizontal: 2); +const inputPadding = EdgeInsets.all(6); +final textPadding = isMobile + ? const EdgeInsets.only(left: 8.0, right: 4.0) + : const EdgeInsets.symmetric(horizontal: 16); +final borderRadius = BorderRadius.circular(30); +const color = Colors.transparent; diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input_action_menu.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input_action_menu.dart new file mode 100644 index 0000000000000..2378379e6eaf7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input_action_menu.dart @@ -0,0 +1,315 @@ +import 'dart:math'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_input_action_bloc.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_input_action_control.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'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:scroll_to_index/scroll_to_index.dart'; + +abstract class ChatActionHandler { + void onEnter(); + void onSelected(ChatInputMention page); + void onExit(); + ChatInputActionBloc get commandBloc; + void onFilter(String filter); + double actionMenuOffsetX(); +} + +abstract class ChatAnchor { + GlobalKey get anchorKey; + LayerLink get layerLink; +} + +const int _itemHeight = 34; +const int _itemVerticalPadding = 4; +const int _noPageHeight = 20; + +class ChatActionsMenu { + ChatActionsMenu({ + required this.anchor, + required this.context, + required this.handler, + required this.style, + }); + + final BuildContext context; + final ChatAnchor anchor; + final ChatActionsMenuStyle style; + final ChatActionHandler handler; + + OverlayEntry? _overlayEntry; + + void dismiss() { + _overlayEntry?.remove(); + _overlayEntry = null; + handler.onExit(); + } + + void show() { + WidgetsBinding.instance.addPostFrameCallback((_) => _show()); + } + + void _show() { + if (_overlayEntry != null) { + dismiss(); + } + + if (anchor.anchorKey.currentContext == null) { + return; + } + + handler.onEnter(); + const double maxHeight = 300; + + _overlayEntry = OverlayEntry( + builder: (context) => BlocProvider.value( + value: handler.commandBloc, + child: BlocBuilder( + builder: (context, state) { + final height = min( + max( + state.pages.length * (_itemHeight + _itemVerticalPadding), + _noPageHeight, + ), + maxHeight, + ); + final isLoading = + state.indicator == const ChatActionMenuIndicator.loading(); + + return Stack( + children: [ + CompositedTransformFollower( + link: anchor.layerLink, + showWhenUnlinked: false, + offset: Offset(handler.actionMenuOffsetX(), -height - 4), + child: Material( + elevation: 4.0, + child: ConstrainedBox( + constraints: const BoxConstraints( + minWidth: 200, + maxWidth: 200, + maxHeight: maxHeight, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + borderRadius: BorderRadius.circular(6.0), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 2, + vertical: 2, + ), + child: ActionList( + isLoading: isLoading, + handler: handler, + onDismiss: () => dismiss(), + pages: state.pages, + ), + ), + ), + ), + ), + ), + ], + ); + }, + ), + ), + ); + + Overlay.of(context).insert(_overlayEntry!); + } +} + +class _ActionItem extends StatelessWidget { + const _ActionItem({ + required this.item, + required this.onTap, + required this.isSelected, + }); + + final ChatInputMention item; + final VoidCallback? onTap; + final bool isSelected; + + @override + Widget build(BuildContext context) { + return Container( + height: _itemHeight.toDouble(), + padding: const EdgeInsets.symmetric(vertical: _itemVerticalPadding / 2.0), + decoration: BoxDecoration( + color: isSelected + ? Theme.of(context).colorScheme.primary.withOpacity(0.1) + : Colors.transparent, + borderRadius: BorderRadius.circular(4.0), + ), + child: FlowyButton( + leftIcon: item.icon, + margin: const EdgeInsets.symmetric(horizontal: 6), + iconPadding: 10.0, + text: FlowyText.regular( + lineHeight: 1.0, + item.title, + ), + onTap: onTap, + ), + ); + } +} + +class ActionList extends StatefulWidget { + const ActionList({ + super.key, + required this.handler, + required this.onDismiss, + required this.pages, + required this.isLoading, + }); + + final ChatActionHandler handler; + final VoidCallback? onDismiss; + final List pages; + final bool isLoading; + + @override + State createState() => _ActionListState(); +} + +class _ActionListState extends State { + int _selectedIndex = 0; + final _scrollController = AutoScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + KeyEventResult _handleKeyPress(logicalKey) { + bool isHandle = false; + setState(() { + if (logicalKey == PhysicalKeyboardKey.arrowDown) { + _selectedIndex = (_selectedIndex + 1) % widget.pages.length; + _scrollToSelectedIndex(); + isHandle = true; + } else if (logicalKey == PhysicalKeyboardKey.arrowUp) { + _selectedIndex = + (_selectedIndex - 1 + widget.pages.length) % widget.pages.length; + _scrollToSelectedIndex(); + isHandle = true; + } else if (logicalKey == PhysicalKeyboardKey.enter) { + widget.handler.onSelected(widget.pages[_selectedIndex]); + widget.onDismiss?.call(); + isHandle = true; + } else if (logicalKey == PhysicalKeyboardKey.escape) { + widget.onDismiss?.call(); + isHandle = true; + } + }); + return isHandle ? KeyEventResult.handled : KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listenWhen: (previous, current) => + previous.keyboardKey != current.keyboardKey, + listener: (context, state) { + if (state.keyboardKey != null) { + _handleKeyPress(state.keyboardKey!.physicalKey); + } + }, + child: ListView( + shrinkWrap: true, + controller: _scrollController, + padding: const EdgeInsets.all(4), + children: _buildPages(), + ), + ); + } + + List _buildPages() { + if (widget.isLoading) { + return [ + SizedBox( + height: _noPageHeight.toDouble(), + child: const Center(child: CircularProgressIndicator.adaptive()), + ), + ]; + } + + if (widget.pages.isEmpty) { + return [ + SizedBox( + height: _noPageHeight.toDouble(), + child: + Center(child: FlowyText(LocaleKeys.chat_inputActionNoPages.tr())), + ), + ]; + } + + return widget.pages.asMap().entries.map((entry) { + final index = entry.key; + final ChatInputMention item = entry.value; + return AutoScrollTag( + key: ValueKey(item.pageId), + index: index, + controller: _scrollController, + child: _ActionItem( + item: item, + onTap: () { + widget.handler.onSelected(item); + widget.onDismiss?.call(); + }, + isSelected: _selectedIndex == index, + ), + ); + }).toList(); + } + + void _scrollToSelectedIndex() { + _scrollController.scrollToIndex( + _selectedIndex, + duration: const Duration(milliseconds: 200), + preferPosition: AutoScrollPosition.begin, + ); + } +} + +class ChatActionsMenuStyle { + ChatActionsMenuStyle({ + required this.backgroundColor, + required this.groupTextColor, + required this.menuItemTextColor, + required this.menuItemSelectedColor, + required this.menuItemSelectedTextColor, + }); + + const ChatActionsMenuStyle.light() + : backgroundColor = Colors.white, + groupTextColor = const Color(0xFF555555), + menuItemTextColor = const Color(0xFF333333), + menuItemSelectedColor = const Color(0xFFE0F8FF), + menuItemSelectedTextColor = const Color.fromARGB(255, 56, 91, 247); + + const ChatActionsMenuStyle.dark() + : backgroundColor = const Color(0xFF282E3A), + groupTextColor = const Color(0xFFBBC3CD), + menuItemTextColor = const Color(0xFFBBC3CD), + menuItemSelectedColor = const Color(0xFF00BCF0), + menuItemSelectedTextColor = const Color(0xFF131720); + + final Color backgroundColor; + final Color groupTextColor; + final Color menuItemTextColor; + final Color menuItemSelectedColor; + final Color menuItemSelectedTextColor; +} 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 index a37a0824edb63..22c8fa90de467 100644 --- 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 @@ -1,12 +1,11 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; - class RelatedQuestionList extends StatelessWidget { const RelatedQuestionList({ required this.chatId, @@ -97,6 +96,7 @@ class _RelatedQuestionItemState extends State { style: TextStyle( color: _isHovered ? Theme.of(context).colorScheme.primary : null, fontSize: 14, + height: 1.5, ), ), onTap: () { diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_side_pannel.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_side_pannel.dart new file mode 100644 index 0000000000000..f3899308ce753 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_side_pannel.dart @@ -0,0 +1,53 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_side_pannel_bloc.dart'; +import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class ChatSidePannel extends StatelessWidget { + const ChatSidePannel({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return state.indicator.when( + loading: () { + return const CircularProgressIndicator.adaptive(); + }, + ready: (view) { + final plugin = view.plugin(); + plugin.init(); + + final pluginContext = PluginContext(); + final child = plugin.widgetBuilder + .buildWidget(context: pluginContext, shrinkWrap: false); + return Container( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: FlowyIconButton( + icon: const FlowySvg(FlowySvgs.show_menu_s), + onPressed: () { + context + .read() + .add(const ChatSidePannelEvent.close()); + }, + ), + ), + const VSpace(6), + Expanded(child: child), + ], + ), + ); + }, + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_user_invalid_message.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_user_invalid_message.dart index 8ae9b91d32a31..39ea8c29b9fa5 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_user_invalid_message.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_user_invalid_message.dart @@ -1,4 +1,4 @@ -import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_chat_types/flutter_chat_types.dart'; 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 index 6a819e8d53480..5524f1ffbe94d 100644 --- 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 @@ -1,66 +1,110 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.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:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; -import 'chat_input.dart'; +import 'chat_input/chat_input.dart'; + +class WelcomeQuestion { + WelcomeQuestion({ + required this.text, + required this.iconData, + }); + final String text; + final FlowySvgData iconData; +} class ChatWelcomePage extends StatelessWidget { - ChatWelcomePage({required this.onSelectedQuestion, super.key}); + ChatWelcomePage({ + required this.userProfile, + required this.onSelectedQuestion, + super.key, + }); final void Function(String) onSelectedQuestion; + final UserProfilePB userProfile; - final List items = [ - LocaleKeys.chat_question1.tr(), - LocaleKeys.chat_question2.tr(), - LocaleKeys.chat_question3.tr(), - LocaleKeys.chat_question4.tr(), + final List items = [ + WelcomeQuestion( + text: LocaleKeys.chat_question1.tr(), + iconData: FlowySvgs.chat_lightbulb_s, + ), + WelcomeQuestion( + text: LocaleKeys.chat_question2.tr(), + iconData: FlowySvgs.chat_scholar_s, + ), + WelcomeQuestion( + text: LocaleKeys.chat_question3.tr(), + iconData: FlowySvgs.chat_question_s, + ), + WelcomeQuestion( + text: LocaleKeys.chat_question4.tr(), + iconData: FlowySvgs.chat_feather_s, + ), ]; @override Widget build(BuildContext context) { return AnimatedOpacity( opacity: 1.0, duration: const Duration(seconds: 3), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const FlowySvg( - FlowySvgs.flowy_ai_chat_logo_s, - size: Size.square(44), - ), - const SizedBox(height: 40), - Wrap( - children: items - .map( - (i) => WelcomeQuestion( - question: i, - onSelected: onSelectedQuestion, - ), - ) - .toList(), - ), - ], + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Spacer(), + Opacity( + opacity: 0.8, + child: FlowyText( + fontSize: 15, + LocaleKeys.chat_questionDetail.tr(args: [userProfile.name]), + ), + ), + const VSpace(18), + Opacity( + opacity: 0.6, + child: FlowyText( + LocaleKeys.chat_questionTitle.tr(), + ), + ), + const VSpace(8), + Wrap( + direction: Axis.vertical, + spacing: isMobile ? 12.0 : 0.0, + children: items + .map( + (i) => WelcomeQuestionWidget( + question: i, + onSelected: onSelectedQuestion, + ), + ) + .toList(), + ), + const VSpace(20), + ], + ), ), ); } } -class WelcomeQuestion extends StatelessWidget { - const WelcomeQuestion({ +class WelcomeQuestionWidget extends StatelessWidget { + const WelcomeQuestionWidget({ required this.question, required this.onSelected, super.key, }); final void Function(String) onSelected; - final String question; + final WelcomeQuestion question; @override Widget build(BuildContext context) { return InkWell( - onTap: () => onSelected(question), + onTap: () => onSelected(question.text), child: GestureDetector( behavior: HitTestBehavior.opaque, child: FlowyHover( @@ -70,12 +114,18 @@ class WelcomeQuestion extends StatelessWidget { borderRadius: BorderRadius.circular(6), ), child: Padding( - padding: const EdgeInsets.all(10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, children: [ + FlowySvg( + question.iconData, + size: const Size.square(18), + blendMode: null, + ), + const HSpace(16), FlowyText( - question, + question.text, maxLines: null, ), ], 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 index 2d816bb5dba6b..e5359161b4445 100644 --- 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 @@ -1,11 +1,14 @@ +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/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy/util/theme_extension.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:markdown_widget/markdown_widget.dart'; import 'selectable_highlight.dart'; @@ -30,7 +33,11 @@ class AIMarkdownText extends StatelessWidget { Widget build(BuildContext context) { switch (type) { case AIMarkdownType.appflowyEditor: - return _AppFlowyEditorMarkdown(markdown: markdown); + return BlocProvider( + create: (context) => DocumentPageStyleBloc(view: ViewPB()) + ..add(const DocumentPageStyleEvent.initial()), + child: _AppFlowyEditorMarkdown(markdown: markdown), + ); case AIMarkdownType.markdownWidget: return _ThirdPartyMarkdown(markdown: markdown); } @@ -52,15 +59,6 @@ class _AppFlowyEditorMarkdown extends StatefulWidget { class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> { late EditorState editorState; - late final styleCustomizer = EditorStyleCustomizer( - context: context, - padding: EdgeInsets.zero, - ); - late final editorStyle = styleCustomizer.style().copyWith( - // hide the cursor - cursorColor: Colors.transparent, - cursorWidth: 0, - ); late EditorScrollController scrollController; @override @@ -99,6 +97,17 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> { @override Widget build(BuildContext context) { + // don't lazy load the styleCustomizer and blockBuilders, + // it needs the context to get the theme. + final styleCustomizer = EditorStyleCustomizer( + context: context, + padding: EdgeInsets.zero, + ); + final editorStyle = styleCustomizer.style().copyWith( + // hide the cursor + cursorColor: Colors.transparent, + cursorWidth: 0, + ); final blockBuilders = getEditorBuilderMap( context: context, editorState: editorState, @@ -115,6 +124,7 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> { editorScrollController: scrollController, blockComponentBuilders: blockBuilders, commandShortcutEvents: [customCopyCommand], + disableAutoScroll: true, editorState: editorState, ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/ai_message_bubble.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_bubble.dart similarity index 94% rename from frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/ai_message_bubble.dart rename to frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_bubble.dart index afea319d102be..d13cd940713d4 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/ai_message_bubble.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_bubble.dart @@ -2,9 +2,9 @@ import 'dart:convert'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:appflowy/plugins/ai_chat/presentation/chat_avatar.dart'; -import 'package:appflowy/plugins/ai_chat/presentation/chat_input.dart'; +import 'package:appflowy/plugins/ai_chat/presentation/chat_input/chat_input.dart'; import 'package:appflowy/plugins/ai_chat/presentation/chat_popmenu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/shared/markdown_to_document.dart'; @@ -49,8 +49,9 @@ class ChatAIMessageBubble extends StatelessWidget { children: [ const ChatBorderedCircleAvatar( child: FlowySvg( - FlowySvgs.flowy_ai_chat_logo_s, - size: Size.square(24), + FlowySvgs.flowy_logo_s, + size: Size.square(20), + blendMode: null, ), ), Expanded(child: widget), @@ -177,10 +178,9 @@ class CopyButton extends StatelessWidget { width: 24, hoverColor: AFThemeExtension.of(context).lightGreyHover, fillColor: Theme.of(context).cardColor, - icon: FlowySvg( - FlowySvgs.ai_copy_s, - size: const Size.square(14), - color: Theme.of(context).colorScheme.primary, + icon: const FlowySvg( + FlowySvgs.copy_s, + size: Size.square(20), ), onPressed: () async { final document = customMarkdownToDocument(textMessage.text); diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_metadata.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_metadata.dart new file mode 100644 index 0000000000000..6d4f85d616a17 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_metadata.dart @@ -0,0 +1,72 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_message_service.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'; + +class AIMessageMetadata extends StatelessWidget { + const AIMessageMetadata({ + required this.sources, + required this.onSelectedMetadata, + super.key, + }); + + final List sources; + final Function(ChatMessageRefSource metadata) onSelectedMetadata; + @override + Widget build(BuildContext context) { + final title = sources.length == 1 + ? LocaleKeys.chat_referenceSource.tr(args: [sources.length.toString()]) + : LocaleKeys.chat_referenceSources + .tr(args: [sources.length.toString()]); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (sources.isNotEmpty) + Opacity( + opacity: 0.5, + child: FlowyText(title, fontSize: 12), + ), + const VSpace(6), + Wrap( + spacing: 8.0, + runSpacing: 4.0, + children: sources + .map( + (m) => SizedBox( + height: 24, + child: FlowyButton( + margin: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 4, + ), + useIntrinsicWidth: true, + radius: BorderRadius.circular(6), + text: Opacity( + opacity: 0.5, + child: FlowyText( + m.name, + fontSize: 14, + lineHeight: 1.0, + overflow: TextOverflow.ellipsis, + ), + ), + disable: m.source != appflowySoruce, + onTap: () { + if (m.source != appflowySoruce) { + return; + } + onSelectedMetadata(m); + }, + ), + ), + ) + .toList(), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_text_message.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_text_message.dart index ba0bc3db432e2..4b711e4fdaf6c 100644 --- 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 @@ -1,6 +1,6 @@ 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/presentation/chat_loading.dart'; import 'package:appflowy/plugins/ai_chat/presentation/message/ai_markdown_text.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -12,51 +12,78 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_chat_types/flutter_chat_types.dart'; -class ChatAITextMessageWidget extends StatelessWidget { - const ChatAITextMessageWidget({ +import 'ai_metadata.dart'; + +class ChatAIMessageWidget extends StatelessWidget { + const ChatAIMessageWidget({ super.key, required this.user, required this.messageUserId, - required this.text, + required this.message, required this.questionId, required this.chatId, + required this.refSourceJsonString, + required this.onSelectedMetadata, }); final User user; final String messageUserId; - final dynamic text; + + /// message can be a striing or Stream + final dynamic message; final Int64? questionId; final String chatId; + final String? refSourceJsonString; + final void Function(ChatMessageRefSource metadata) onSelectedMetadata; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => ChatAIMessageBloc( - message: text, + message: message, + refSourceJsonString: refSourceJsonString, chatId: chatId, questionId: questionId, )..add(const ChatAIMessageEvent.initial()), child: BlocBuilder( builder: (context, state) { - if (state.error != null) { - return StreamingError( - onRetryPressed: () { - context.read().add( - const ChatAIMessageEvent.retry(), - ); - }, - ); - } - - if (state.retryState == const LoadingState.loading()) { - return const ChatAILoading(); - } - - if (state.text.isEmpty) { - return const ChatAILoading(); - } else { - return AIMarkdownText(markdown: state.text); - } + return state.messageState.when( + onError: (err) { + return StreamingError( + onRetryPressed: () { + context.read().add( + const ChatAIMessageEvent.retry(), + ); + }, + ); + }, + onAIResponseLimit: () { + return FlowyText( + LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(), + lineHeight: 1.5, + maxLines: 10, + ); + }, + ready: () { + if (state.text.isEmpty) { + return const ChatAILoading(); + } else { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AIMarkdownText(markdown: state.text), + AIMessageMetadata( + sources: state.sources, + onSelectedMetadata: onSelectedMetadata, + ), + ], + ); + } + }, + loading: () { + return const ChatAILoading(); + }, + ); }, ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/other_user_message_bubble.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/other_user_message_bubble.dart new file mode 100644 index 0000000000000..899c0bec3cc2d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/other_user_message_bubble.dart @@ -0,0 +1,211 @@ +import 'dart:convert'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_member_bloc.dart'; +import 'package:appflowy/plugins/ai_chat/presentation/chat_avatar.dart'; +import 'package:appflowy/plugins/ai_chat/presentation/chat_input/chat_input.dart'; +import 'package:appflowy/plugins/ai_chat/presentation/chat_popmenu.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/presentation/home/toast.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/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'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_chat_types/flutter_chat_types.dart'; +import 'package:styled_widget/styled_widget.dart'; + +const _leftPadding = 16.0; + +class OtherUserMessageBubble extends StatelessWidget { + const OtherUserMessageBubble({ + super.key, + required this.message, + required this.child, + }); + + final Message message; + final Widget child; + + @override + Widget build(BuildContext context) { + const padding = EdgeInsets.symmetric(horizontal: _leftPadding); + final childWithPadding = Padding(padding: padding, child: child); + final widget = isMobile + ? _wrapPopMenu(childWithPadding) + : _wrapHover(childWithPadding); + + if (context.read().state.members[message.author.id] == + null) { + context + .read() + .add(ChatMemberEvent.getMemberInfo(message.author.id)); + } + + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + BlocConsumer( + listenWhen: (previous, current) { + return previous.members[message.author.id] != + current.members[message.author.id]; + }, + listener: (context, state) {}, + builder: (context, state) { + final member = state.members[message.author.id]; + return ChatUserAvatar( + iconUrl: member?.info.avatarUrl ?? "", + name: member?.info.name ?? "", + defaultName: "", + ); + }, + ), + Expanded(child: widget), + ], + ); + } + + OtherUserMessageHover _wrapHover(Padding child) { + return OtherUserMessageHover( + message: message, + child: child, + ); + } + + ChatPopupMenu _wrapPopMenu(Padding childWithPadding) { + return ChatPopupMenu( + onAction: (action) { + if (action == ChatMessageAction.copy && message is TextMessage) { + Clipboard.setData(ClipboardData(text: (message as TextMessage).text)); + showMessageToast(LocaleKeys.grid_row_copyProperty.tr()); + } + }, + builder: (context) => childWithPadding, + ); + } +} + +class OtherUserMessageHover extends StatefulWidget { + const OtherUserMessageHover({ + super.key, + required this.child, + required this.message, + }); + + final Widget child; + final Message message; + final bool autoShowHover = true; + + @override + State createState() => _OtherUserMessageHoverState(); +} + +class _OtherUserMessageHoverState extends State { + bool _isHover = false; + + @override + void initState() { + super.initState(); + _isHover = widget.autoShowHover ? false : true; + } + + @override + Widget build(BuildContext context) { + final List children = [ + DecoratedBox( + decoration: const BoxDecoration( + color: Colors.transparent, + borderRadius: Corners.s6Border, + ), + child: Padding( + padding: const EdgeInsets.only(bottom: 30), + child: widget.child, + ), + ), + ]; + + if (_isHover) { + children.addAll(_buildOnHoverItems()); + } + + return MouseRegion( + cursor: SystemMouseCursors.click, + opaque: false, + onEnter: (p) => setState(() { + if (widget.autoShowHover) { + _isHover = true; + } + }), + onExit: (p) => setState(() { + if (widget.autoShowHover) { + _isHover = false; + } + }), + child: Stack( + alignment: AlignmentDirectional.centerStart, + children: children, + ), + ); + } + + List _buildOnHoverItems() { + final List children = []; + if (widget.message is TextMessage) { + children.add( + CopyButton( + textMessage: widget.message as TextMessage, + ).positioned(left: _leftPadding, bottom: 0), + ); + } + + return children; + } +} + +class CopyButton extends StatelessWidget { + const CopyButton({ + super.key, + required this.textMessage, + }); + final TextMessage textMessage; + + @override + Widget build(BuildContext context) { + return FlowyTooltip( + message: LocaleKeys.settings_menu_clickToCopy.tr(), + child: FlowyIconButton( + width: 24, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + fillColor: Theme.of(context).cardColor, + icon: FlowySvg( + FlowySvgs.ai_copy_s, + size: const Size.square(14), + color: Theme.of(context).colorScheme.primary, + ), + onPressed: () async { + final document = customMarkdownToDocument(textMessage.text); + await getIt().setData( + ClipboardServiceData( + plainText: textMessage.text, + inAppJson: jsonEncode(document.toJson()), + ), + ); + if (context.mounted) { + showToastNotification( + context, + message: LocaleKeys.grid_url_copiedNotification.tr(), + ); + } + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_message_bubble.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_message_bubble.dart new file mode 100644 index 0000000000000..08c627b0b7dbd --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_message_bubble.dart @@ -0,0 +1,157 @@ +import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_member_bloc.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_user_message_bubble_bloc.dart'; +import 'package:appflowy/plugins/ai_chat/presentation/chat_avatar.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_types/flutter_chat_types.dart'; + +class ChatUserMessageBubble extends StatelessWidget { + const ChatUserMessageBubble({ + super.key, + required this.message, + required this.child, + }); + + final Message message; + final Widget child; + + @override + Widget build(BuildContext context) { + const borderRadius = BorderRadius.all(Radius.circular(6)); + final backgroundColor = + Theme.of(context).colorScheme.surfaceContainerHighest; + if (context.read().state.members[message.author.id] == + null) { + context + .read() + .add(ChatMemberEvent.getMemberInfo(message.author.id)); + } + + return BlocProvider( + create: (context) => ChatUserMessageBubbleBloc( + message: message, + ), + child: BlocBuilder( + builder: (context, state) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (state.files.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.only(right: defaultAvatarSize + 32), + child: _MessageFileList(files: state.files), + ), + const VSpace(6), + ], + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: borderRadius, + color: backgroundColor, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + child: child, + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: BlocConsumer( + listenWhen: (previous, current) => + previous.members[message.author.id] != + current.members[message.author.id], + listener: (context, state) {}, + builder: (context, state) { + final member = state.members[message.author.id]; + return ChatUserAvatar( + iconUrl: member?.info.avatarUrl ?? "", + name: member?.info.name ?? "", + ); + }, + ), + ), + ], + ), + ], + ); + }, + ), + ); + } +} + +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: [ + SizedBox.square(dimension: 16, child: file.fileType.icon), + 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 index 0c507ace1659e..c804441188670 100644 --- 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 @@ -1,37 +1,48 @@ +import 'package:appflowy/plugins/ai_chat/application/chat_user_message_bloc.dart'; import 'package:flowy_infra/theme_extension.dart'; -import 'package:flowy_infra_ui/style_widget/text.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_types/flutter_chat_types.dart'; -class ChatTextMessageWidget extends StatelessWidget { - const ChatTextMessageWidget({ +class ChatUserMessageWidget extends StatelessWidget { + const ChatUserMessageWidget({ super.key, required this.user, - required this.messageUserId, - required this.text, + required this.message, }); final User user; - final String messageUserId; - final String text; + final dynamic message; @override Widget build(BuildContext context) { - return _textWidgetBuilder(user, context, text); - } + return BlocProvider( + create: (context) => ChatUserMessageBloc(message: message) + ..add(const ChatUserMessageEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + final List children = []; + children.add( + Flexible( + child: TextMessageText( + text: state.text, + ), + ), + ); + + if (!state.messageState.isFinish) { + children.add(const HSpace(6)); + children.add(const CircularProgressIndicator.adaptive()); + } - Widget _textWidgetBuilder( - User user, - BuildContext context, - String text, - ) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextMessageText( - text: text, - ), - ], + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: children, + ); + }, + ), ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/user_message_bubble.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/user_message_bubble.dart deleted file mode 100644 index 9adf3d593b306..0000000000000 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/user_message_bubble.dart +++ /dev/null @@ -1,171 +0,0 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/ai_chat/application/chat_user_message_bloc.dart'; -import 'package:appflowy/plugins/ai_chat/presentation/chat_avatar.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:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_chat_types/flutter_chat_types.dart'; -import 'package:styled_widget/styled_widget.dart'; - -class ChatUserMessageBubble extends StatelessWidget { - const ChatUserMessageBubble({ - super.key, - required this.message, - required this.child, - }); - - final Message message; - final Widget child; - - @override - Widget build(BuildContext context) { - const borderRadius = BorderRadius.all(Radius.circular(6)); - final backgroundColor = - Theme.of(context).colorScheme.surfaceContainerHighest; - - return BlocProvider( - create: (context) => ChatUserMessageBloc(message: message) - ..add(const ChatUserMessageEvent.initial()), - child: BlocBuilder( - builder: (context, state) { - return Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // _wrapHover( - Flexible( - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: borderRadius, - color: backgroundColor, - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - child: child, - ), - ), - ), - // ), - BlocBuilder( - builder: (context, state) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: ChatUserAvatar( - iconUrl: state.member?.avatarUrl ?? "", - name: state.member?.name ?? "", - size: 36, - ), - ); - }, - ), - ], - ); - }, - ), - ); - } -} - -class ChatUserMessageHover extends StatefulWidget { - const ChatUserMessageHover({ - super.key, - required this.child, - required this.message, - }); - - final Widget child; - final Message message; - final bool autoShowHover = true; - - @override - State createState() => _ChatUserMessageHoverState(); -} - -class _ChatUserMessageHoverState extends State { - bool _isHover = false; - - @override - void initState() { - super.initState(); - _isHover = widget.autoShowHover ? false : true; - } - - @override - Widget build(BuildContext context) { - final List children = [ - DecoratedBox( - decoration: const BoxDecoration( - color: Colors.transparent, - borderRadius: Corners.s6Border, - ), - child: Padding( - padding: const EdgeInsets.only(bottom: 30), - child: widget.child, - ), - ), - ]; - - if (_isHover) { - if (widget.message is TextMessage) { - children.add( - EditButton( - textMessage: widget.message as TextMessage, - ).positioned(right: 0, bottom: 0), - ); - } - } - - return MouseRegion( - cursor: SystemMouseCursors.click, - opaque: false, - onEnter: (p) => setState(() { - if (widget.autoShowHover) { - _isHover = true; - } - }), - onExit: (p) => setState(() { - if (widget.autoShowHover) { - _isHover = false; - } - }), - child: Stack( - alignment: AlignmentDirectional.centerStart, - children: children, - ), - ); - } -} - -class EditButton extends StatelessWidget { - const EditButton({ - super.key, - required this.textMessage, - }); - final TextMessage textMessage; - - @override - Widget build(BuildContext context) { - return FlowyTooltip( - message: LocaleKeys.settings_menu_clickToCopy.tr(), - child: FlowyIconButton( - width: 24, - hoverColor: AFThemeExtension.of(context).lightGreyHover, - fillColor: Theme.of(context).cardColor, - icon: FlowySvg( - FlowySvgs.ai_copy_s, - size: const Size.square(14), - color: Theme.of(context).colorScheme.primary, - ), - onPressed: () {}, - ), - ); - } -} 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 bcdcf9816d24d..43e7035e3896a 100644 --- a/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker.dart +++ b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker.dart @@ -1,10 +1,9 @@ -import 'package:flutter/material.dart'; - 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_skin_tone.dart'; +import 'package:appflowy/shared/icon_emoji_picker/emoji_search_bar.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; // use a global value to store the selected emoji to prevent reloading every time. @@ -65,36 +64,43 @@ class _FlowyEmojiPickerState extends State { perLine: widget.emojiPerLine, ), onEmojiSelected: widget.onEmojiSelected, + padding: const EdgeInsets.symmetric(horizontal: 16.0), headerBuilder: (context, category) { return FlowyEmojiHeader( category: category, ); }, itemBuilder: (context, emojiId, emoji, callback) { - return SizedBox( - width: 36, - height: 36, + final name = emojiData?.emojis[emojiId]?.name ?? ''; + return SizedBox.square( + dimension: 36.0, child: FlowyButton( margin: EdgeInsets.zero, radius: Corners.s8Border, - text: FlowyText.emoji( - emoji, - fontSize: 24.0, + text: FlowyTooltip( + message: name, + child: FlowyText.emoji( + emoji, + fontSize: 24.0, + ), ), onTap: () => 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!, + onKeywordChanged: (value) { + keyword.value = value; + }, + onSkinToneChanged: (value) { + skinTone.value = value; + }, + onRandomEmojiSelected: widget.onEmojiSelected, + ), ); }, ); 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 9f05c80f09ada..15014e2b6c9ec 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,3 +1,4 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -20,7 +21,7 @@ class FlowyEmojiHeader extends StatelessWidget { child: Padding( padding: const EdgeInsets.only(bottom: 4.0), child: FlowyText.regular( - category.id, + category.id.capitalize(), color: Theme.of(context).hintColor, ), ), 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 85e2197cb7645..ed9dccfcf92b8 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,5 +1,5 @@ -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:flutter/material.dart'; import 'package:go_router/go_router.dart'; 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 08e63251e0257..0000000000000 --- a/frontend/appflowy_flutter/lib/plugins/base/icon/icon_picker.dart +++ /dev/null @@ -1,111 +0,0 @@ -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:flutter/material.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) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const VSpace(8.0), - Row( - children: [ - FlowyText(LocaleKeys.newSettings_workplace_chooseAnIcon.tr()), - const Spacer(), - _RemoveIconButton( - onTap: () => onSelected(EmojiPickerResult.none()), - ), - ], - ), - const VSpace(12.0), - const Divider(height: 0.5), - Expanded( - child: FlowyEmojiPicker( - emojiPerLine: _getEmojiPerLine(context), - onEmojiSelected: (_, emoji) => - onSelected(EmojiPickerResult.emoji(emoji)), - ), - ), - ], - ), - ); - } - - int _getEmojiPerLine(BuildContext context) { - if (PlatformExtension.isDesktopOrWeb) { - return 9; - } - final width = MediaQuery.of(context).size.width; - return width ~/ 40.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: 24, - child: FlowyButton( - onTap: onTap, - useIntrinsicWidth: true, - text: FlowyText.regular( - LocaleKeys.button_remove.tr(), - color: Theme.of(context).hintColor, - ), - ), - ); - } -} 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 index 15cc8c59e0018..6977141956b2a 100644 --- a/frontend/appflowy_flutter/lib/plugins/base/icon/icon_picker_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/base/icon/icon_picker_page.dart @@ -1,9 +1,8 @@ -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:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; class IconPickerPage extends StatelessWidget { const IconPickerPage({ @@ -22,7 +21,7 @@ class IconPickerPage extends StatelessWidget { titleText: title ?? LocaleKeys.titleBar_pageIcon.tr(), ), body: SafeArea( - child: FlowyIconPicker(onSelected: onSelected), + child: FlowyIconEmojiPicker(onSelectedEmoji: onSelected), ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/media_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/media_cell_bloc.dart new file mode 100644 index 0000000000000..9026fbee65d5d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/media_cell_bloc.dart @@ -0,0 +1,220 @@ +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/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/media_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(); + } + + 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) { + emit(state.copyWith(fieldName: fieldName)); + }, + 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); + if (from < to) { + to--; + } + + 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)); + }, + ); + }, + ); + } + + 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 MediaUploadTypePB 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; +} + +@freezed +class MediaCellState with _$MediaCellState { + const factory MediaCellState({ + UserProfilePB? userProfile, + required String fieldName, + @Default([]) List files, + }) = _MediaCellState; + + factory MediaCellState.initial(MediaCellController cellController) { + final cellData = cellController.getCellData(); + + return MediaCellState( + fieldName: cellController.fieldInfo.field.name, + files: cellData?.files ?? const [], + ); + } +} 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 c0bfa48a857b4..f8ed915b6284a 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); @@ -353,10 +354,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 22baf2659996a..700cd86cc2936 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'; @@ -42,9 +43,6 @@ class TextCellBloc extends Bloc { emit(state.copyWith(wrap: wrap)); } }, - didUpdateEmoji: (String emoji) { - emit(state.copyWith(emoji: emoji)); - }, updateText: (String text) { if (state.content != text) { cellController.saveCellData(text, debounce: true); @@ -66,13 +64,6 @@ class TextCellBloc extends Bloc { } }, onFieldChanged: _onFieldChangedListener, - onRowMetaChanged: cellController.fieldInfo.isPrimary - ? () { - if (!isClosed) { - add(TextCellEvent.didUpdateEmoji(cellController.icon ?? "")); - } - } - : null, ); } @@ -91,14 +82,14 @@ class TextCellEvent with _$TextCellEvent { _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 ValueNotifier? emoji, + required ValueNotifier? hasDocument, required bool enableEdit, required bool wrap, }) = _TextCellState; @@ -106,13 +97,18 @@ class TextCellState with _$TextCellState { factory TextCellState.initial(TextCellController cellController) { 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/cell_controller.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller.dart index 858acadc5a9f7..2fd71d744feb2 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; } } 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 50ef7ccb74cd0..afe05e8b70bf5 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 @@ -18,6 +18,7 @@ typedef RelationCellController = CellController; typedef SummaryCellController = CellController; typedef TimeCellController = CellController; typedef TranslateCellController = CellController; +typedef MediaCellController = CellController; CellController makeCellController( DatabaseController databaseController, @@ -170,6 +171,18 @@ CellController makeCellController( ), 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 1c03239cde055..cfab4668ae1c8 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 @@ -196,3 +196,19 @@ class TimeCellDataParser implements CellDataParser { } } } + +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/field/field_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_editor_bloc.dart index b33f9f44eedb4..6f87403604b2f 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,11 +62,20 @@ 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)); }, updateTypeOption: (typeOptionData) async { final result = await FieldBackendService.updateFieldTypeOption( @@ -73,14 +86,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 +107,7 @@ class FieldEditorBloc extends Bloc { ? FieldVisibility.AlwaysShown : FieldVisibility.AlwaysHidden; final result = await fieldSettingsService.updateFieldSettings( - fieldId: state.field.id, + fieldId: fieldId, fieldVisibility: newVisibility, ); _logIfError(result); @@ -164,5 +177,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/row/related_row_detail_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/row/related_row_detail_bloc.dart index 06e1e2b70f3f1..b03a5c69fef9a 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 @@ -23,9 +23,9 @@ class RelatedRowDetailPageBloc @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(); @@ -35,9 +35,11 @@ class RelatedRowDetailPageBloc on((event, emit) async { event.when( didInitialize: (databaseController, rowController) { + rowController.initialize(); + state.maybeWhen( - ready: (_, oldRowController) { - oldRowController.dispose(); + ready: (_, oldRowController) async { + await oldRowController.dispose(); emit( RelatedRowDetailPageState.ready( databaseController: databaseController, @@ -93,6 +95,7 @@ class RelatedRowDetailPageBloc viewId: inlineView.id, rowCache: databaseController.rowCache, ); + add( RelatedRowDetailPageEvent.didInitialize( databaseController, 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 90f20b2fe7ea9..2c671ebcc7031 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 @@ -44,7 +44,8 @@ class RowCache { for (final fieldInfo in fieldInfos) { _cellMemCache.removeCellWithFieldId(fieldInfo.id); } - _changedNotifier.receive(const ChangedReason.fieldDidChange()); + + _changedNotifier?.receive(const ChangedReason.fieldDidChange()); }); } @@ -53,7 +54,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 +70,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 +82,32 @@ 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) { + var rowInfo = _rowList.get(rowMeta.id); + if (rowInfo != null) { + rowInfo.updateRowMeta(rowMeta); + } else { + rowInfo = buildGridRow(rowMeta); + _rowList.add(rowInfo); + } + + _changedNotifier?.receive(const ChangedReason.didFetchRow()); } void dispose() { + _rowList.dispose(); _rowLifeCycle.onRowDisposed(); - _changedNotifier.dispose(); + _changedNotifier?.dispose(); + _changedNotifier = null; _cellMemCache.dispose(); } @@ -94,13 +118,17 @@ class RowCache { } void applyRowsVisibility(RowsVisibilityChangePB changeset) { - _hideRows(changeset.invisibleRows); - _showRows(changeset.visibleRows); + if (_isInitialRows) { + _hideRows(changeset.invisibleRows); + _showRows(changeset.visibleRows); + } 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 +139,7 @@ class RowCache { reorderRow.oldIndex, reorderRow.newIndex, ); - _changedNotifier.receive( + _changedNotifier?.receive( ChangedReason.reorderSingleRow( reorderRow, rowInfo, @@ -124,7 +152,7 @@ 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)); } } } @@ -134,7 +162,7 @@ class RowCache { final insertedIndex = _rowList.insert(insertedRow.index, buildGridRow(insertedRow.rowMeta)); if (insertedIndex != null) { - _changedNotifier.receive(ChangedReason.insert(insertedIndex)); + _changedNotifier?.receive(ChangedReason.insert(insertedIndex)); } } } @@ -155,11 +183,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 +197,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 +207,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 +229,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 +249,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 +260,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 +268,7 @@ class RowCache { rowId: rowMetaPB.id, ); - _changedNotifier.receive(ChangedReason.update(updatedIndexs)); + _changedNotifier?.receive(ChangedReason.update(updatedIndexs)); } }, (err) => Log.error(err), @@ -277,19 +310,43 @@ class RowChangesetNotifier extends ChangeNotifier { reorderRows: (_) => notifyListeners(), reorderSingleRow: (_) => notifyListeners(), setInitialRows: (_) => notifyListeners(), + didFetchRow: (_) => notifyListeners(), ); } } -@unfreezed -class RowInfo with _$RowInfo { - const RowInfo._(); - factory RowInfo({ - required UnmodifiableListView fields, +class RowInfo { + 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; - String get rowId => rowMeta.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(); + } } typedef InsertedIndexs = List; @@ -305,6 +362,7 @@ class ChangedReason with _$ChangedReason { 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, 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 b34beba2755f7..4dd42d9200c13 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); + + /// 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; + } - List loadData() => _rowCache.loadCells(rowMeta); + 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}) { + 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 b31a3022e9c31..00b074544870b 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 1866891336ccf..2db78e7e19feb 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; @@ -111,7 +119,7 @@ class RowBackendService { String viewId, RowId rowId, ) { - final payload = RowIdPB( + final payload = DatabaseViewRowIdPB( viewId: viewId, rowId: rowId, ); 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 6a41e2f173f23..3f97304296ba8 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_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/board/application/board_bloc.dart index 0fa07a69c82cf..bec3419af4651 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 @@ -1,10 +1,13 @@ import 'dart:async'; import 'dart:collection'; +import 'package:flutter/foundation.dart'; + 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/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; @@ -13,17 +16,14 @@ import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:appflowy_result/appflowy_result.dart'; import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:protobuf/protobuf.dart' hide FieldInfo; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:calendar_view/calendar_view.dart'; import '../../application/database_controller.dart'; import '../../application/field/field_controller.dart'; import '../../application/row/row_cache.dart'; + import 'group_controller.dart'; part 'board_bloc.freezed.dart'; @@ -93,9 +93,9 @@ class BoardBloc extends Bloc { (event, emit) async { await event.when( initial: () async { - emit(BoardState.initial(viewId)); _startListening(); await _openDatabase(emit); + emit(BoardState.initial(viewId)); }, createRow: (groupId, position, title, targetRowId) async { final primaryField = databaseController.fieldController.fieldInfos @@ -202,11 +202,18 @@ class BoardBloc extends Bloc { ); }, 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( + fieldId: groupControllers.values.first.group.fieldId, + groupId: groupId, + name: groupName, + ); + } + } + state.maybeMap( ready: (state) => emit(state.copyWith(editingHeaderId: null)), orElse: () {}, @@ -429,7 +436,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); } @@ -509,7 +518,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, @@ -517,103 +526,6 @@ 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}"; - } - - 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 == 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: - final config = groupSettings?.content != null - ? DateGroupConfigurationPB.fromBuffer(groupSettings!.content) - : DateGroupConfigurationPB(); - final dateFormat = DateFormat("y/MM/dd"); - try { - final targetDateTime = dateFormat.parseLoose(group.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 ""; - } - } } @freezed @@ -702,10 +614,7 @@ class GroupItem extends AppFlowyGroupItem { GroupItem({ required this.row, required this.fieldInfo, - bool draggable = true, - }) { - super.draggable.value = draggable; - } + }); final RowMetaPB row; final FieldInfo fieldInfo; @@ -794,7 +703,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); diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/application/column_header_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/board/application/column_header_bloc.dart new file mode 100644 index 0000000000000..dcb1578930da7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/board/application/column_header_bloc.dart @@ -0,0 +1,113 @@ +import 'package:appflowy/plugins/database/board/group_ext.dart'; +import 'package:appflowy/plugins/database/domain/group_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/group.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../application/database_controller.dart'; +import '../../application/field/field_controller.dart'; + +part 'column_header_bloc.freezed.dart'; + +class ColumnHeaderBloc extends Bloc { + ColumnHeaderBloc({ + required this.databaseController, + required this.fieldId, + required this.group, + }) : super(const ColumnHeaderState.loading()) { + groupBackendSvc = GroupBackendService(viewId); + _dispatch(); + } + + final DatabaseController databaseController; + final String fieldId; + final GroupPB group; + + late final GroupBackendService groupBackendSvc; + + FieldController get fieldController => databaseController.fieldController; + String get viewId => databaseController.viewId; + + void _dispatch() { + on( + (event, emit) async { + await event.when( + initial: () { + final name = group.generateGroupName(databaseController); + emit(ColumnHeaderState.initial(name)); + }, + startEditing: () async { + state.maybeMap( + ready: (state) => emit(state.copyWith(isEditing: true)), + orElse: () {}, + ); + }, + endEditing: (String? groupName) async { + if (groupName != null) { + final stateGroupName = state.maybeMap( + ready: (state) => state.groupName, + orElse: () => null, + ); + + if (stateGroupName == null || stateGroupName == groupName) { + state.maybeMap( + ready: (state) => emit( + state.copyWith( + groupName: stateGroupName!, + isEditing: false, + ), + ), + orElse: () {}, + ); + } + + await groupBackendSvc.renameGroup( + groupId: group.groupId, + fieldId: fieldId, + name: groupName, + ); + state.maybeMap( + ready: (state) { + emit(state.copyWith(groupName: groupName, isEditing: false)); + }, + orElse: () {}, + ); + } + }, + ); + }, + ); + } +} + +@freezed +class ColumnHeaderEvent with _$ColumnHeaderEvent { + const factory ColumnHeaderEvent.initial() = _Initial; + const factory ColumnHeaderEvent.startEditing() = _StartEditing; + const factory ColumnHeaderEvent.endEditing(String? groupName) = _EndEditing; +} + +@freezed +class ColumnHeaderState with _$ColumnHeaderState { + const ColumnHeaderState._(); + + const factory ColumnHeaderState.loading() = _ColumnHeaderLoadingState; + + const factory ColumnHeaderState.error({ + required FlowyError error, + }) = _ColumnHeaderErrorState; + + const factory ColumnHeaderState.ready({ + required String groupName, + @Default(false) bool isEditing, + @Default(false) bool canEdit, + }) = _ColumnHeaderReadyState; + + factory ColumnHeaderState.initial(String name) => + ColumnHeaderState.ready(groupName: name); + + bool get isLoading => maybeMap(loading: (_) => true, orElse: () => false); + bool get isError => maybeMap(error: (_) => true, orElse: () => false); + bool get isReady => maybeMap(ready: (_) => true, orElse: () => false); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/group_ext.dart b/frontend/appflowy_flutter/lib/plugins/database/board/group_ext.dart new file mode 100644 index 0000000000000..5c69a66e62a0b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/board/group_ext.dart @@ -0,0 +1,106 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:calendar_view/calendar_view.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; + +extension GroupName on GroupPB { + String generateGroupName(DatabaseController databaseController) { + final fieldController = databaseController.fieldController; + final field = fieldController.getField(fieldId); + if (field == null) { + return ""; + } + + // if the group is the default group, then + if (isDefault) { + return "No ${field.name}"; + } + + final groupSettings = databaseController.fieldController.groupSettings + .firstWhereOrNull((gs) => gs.fieldId == field.id); + + switch (field.fieldType) { + case FieldType.SingleSelect: + final options = + SingleSelectTypeOptionPB.fromBuffer(field.field.typeOptionData) + .options; + final option = + options.firstWhereOrNull((option) => option.id == groupId); + return option == null ? "" : option.name; + case FieldType.MultiSelect: + final options = + MultiSelectTypeOptionPB.fromBuffer(field.field.typeOptionData) + .options; + final option = + options.firstWhereOrNull((option) => option.id == groupId); + return option == null ? "" : option.name; + case FieldType.Checkbox: + return groupId; + case FieldType.URL: + return groupId; + case FieldType.DateTime: + final config = groupSettings?.content != null + ? DateGroupConfigurationPB.fromBuffer(groupSettings!.content) + : DateGroupConfigurationPB(); + final dateFormat = DateFormat("y/MM/dd"); + try { + final targetDateTime = dateFormat.parseLoose(groupId); + switch (config.condition) { + case DateConditionPB.Day: + return DateFormat("MMM dd, y").format(targetDateTime); + case DateConditionPB.Week: + final beginningOfWeek = targetDateTime + .subtract(Duration(days: targetDateTime.weekday - 1)); + final endOfWeek = targetDateTime.add( + Duration(days: DateTime.daysPerWeek - targetDateTime.weekday), + ); + + final beginningOfWeekFormat = + beginningOfWeek.year != endOfWeek.year + ? "MMM dd y" + : "MMM dd"; + final endOfWeekFormat = beginningOfWeek.month != endOfWeek.month + ? "MMM dd y" + : "dd y"; + + return LocaleKeys.board_dateCondition_weekOf.tr( + args: [ + DateFormat(beginningOfWeekFormat).format(beginningOfWeek), + DateFormat(endOfWeekFormat).format(endOfWeek), + ], + ); + case DateConditionPB.Month: + return DateFormat("MMM y").format(targetDateTime); + case DateConditionPB.Year: + return DateFormat("y").format(targetDateTime); + case DateConditionPB.Relative: + final targetDateTimeDay = DateTime( + targetDateTime.year, + targetDateTime.month, + targetDateTime.day, + ); + final nowDay = DateTime.now().withoutTime; + final diff = targetDateTimeDay.difference(nowDay).inDays; + return switch (diff) { + 0 => LocaleKeys.board_dateCondition_today.tr(), + -1 => LocaleKeys.board_dateCondition_yesterday.tr(), + 1 => LocaleKeys.board_dateCondition_tomorrow.tr(), + -7 => LocaleKeys.board_dateCondition_lastSevenDays.tr(), + 2 => LocaleKeys.board_dateCondition_nextSevenDays.tr(), + -30 => LocaleKeys.board_dateCondition_lastThirtyDays.tr(), + 8 => LocaleKeys.board_dateCondition_nextThirtyDays.tr(), + _ => DateFormat("MMM y").format(targetDateTimeDay) + }; + default: + return ""; + } + } on FormatException { + return ""; + } + default: + return ""; + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart index c2e8273fd5ef1..1861d771d5c15 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 @@ -6,6 +6,7 @@ import 'package:appflowy/mobile/presentation/database/board/mobile_board_page.da import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_controller.dart'; import 'package:appflowy/plugins/database/board/application/board_actions_bloc.dart'; +import 'package:appflowy/plugins/database/board/application/column_header_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'; @@ -15,6 +16,7 @@ import 'package:appflowy/plugins/database/widgets/card/card_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell/card_cell_style_maps/desktop_board_card_cell_style.dart'; import 'package:appflowy/plugins/database/widgets/row/row_detail.dart'; import 'package:appflowy/shared/conditional_listenable_builder.dart'; +import 'package:appflowy/shared/flowy_error_page.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_board/appflowy_board.dart'; @@ -23,8 +25,6 @@ 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'; @@ -200,9 +200,10 @@ class _DesktopBoardPageState extends State { loading: (_) => const Center( child: CircularProgressIndicator.adaptive(), ), - error: (err) => FlowyErrorPage.message( - err.toString(), - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + error: (err) => Center( + child: AppFlowyErrorPage( + error: err.error, + ), ), orElse: () => _BoardContent( onEditStateChanged: widget.onEditStateChanged, @@ -340,8 +341,22 @@ class _BoardContentState extends State<_BoardContent> { false ? BoardTrailing(scrollController: scrollController) : const HSpace(40), - headerBuilder: (_, groupData) => BlocProvider.value( - value: context.read(), + headerBuilder: (_, groupData) => MultiBlocProvider( + providers: [ + BlocProvider.value( + value: context.read(), + ), + BlocProvider( + create: (context) => ColumnHeaderBloc( + databaseController: databaseController, + fieldId: (groupData.customData as GroupData).fieldInfo.id, + group: context + .read() + .groupControllers[groupData.headerData.groupId]! + .group, + )..add(const ColumnHeaderEvent.initial()), + ), + ], child: BoardColumnHeader( groupData: groupData, margin: config.groupHeaderPadding, @@ -557,10 +572,8 @@ class _BoardCardState extends State<_BoardCard> { @override Widget build(BuildContext context) { final boardBloc = context.read(); - final groupData = widget.afGroupData.customData as GroupData; final rowCache = boardBloc.rowCache; - final databaseController = boardBloc.databaseController; final rowMeta = rowCache.getRow(widget.groupItem.id)?.rowMeta ?? widget.groupItem.row; @@ -654,7 +667,7 @@ class _BoardCardState extends State<_BoardCard> { onTap: (context) => _openCard( context: context, databaseController: databaseController, - rowMeta: context.read().state.rowMeta, + rowMeta: context.read().rowController.rowMeta, ), onShiftTap: (_) { Focus.of(context).requestFocus(); 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 19f3a51bba2fd..bec286674b62f 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,7 +1,11 @@ import 'dart:ui' as ui; +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/board/application/board_bloc.dart'; +import 'package:appflowy/plugins/database/board/application/column_header_bloc.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/text_direction.dart'; @@ -11,9 +15,6 @@ 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'; class BoardColumnHeader extends StatefulWidget { @@ -68,12 +69,12 @@ class _BoardColumnHeaderState extends State { Widget build(BuildContext context) { final boardCustomData = widget.groupData.customData as GroupData; - return BlocBuilder( + return BlocBuilder( builder: (context, state) { return state.maybeMap( orElse: () => const SizedBox.shrink(), ready: (state) { - if (state.editingHeaderId != null) { + if (state.isEditing) { WidgetsBinding.instance.addPostFrameCallback((_) { _focusNode.requestFocus(); }); @@ -87,7 +88,7 @@ class _BoardColumnHeaderState extends State { Widget title = Expanded( child: FlowyText.medium( - widget.groupData.headerData.groupName, + state.groupName, overflow: TextOverflow.ellipsis, textDirection: lastDirection, ), @@ -102,11 +103,11 @@ class _BoardColumnHeaderState extends State { child: MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( - onTap: () => context.read().add( - BoardEvent.startEditingHeader(widget.groupData.id), - ), + onTap: () => context + .read() + .add(const ColumnHeaderEvent.startEditing()), child: FlowyText.medium( - widget.groupData.headerData.groupName, + state.groupName, overflow: TextOverflow.ellipsis, textDirection: lastDirection, ), @@ -116,7 +117,7 @@ class _BoardColumnHeaderState extends State { ); } - if (state.editingHeaderId == widget.groupData.id) { + if (state.isEditing) { title = _buildTextField(context); } @@ -207,9 +208,11 @@ class _BoardColumnHeaderState extends State { ); } - void _saveEdit() => context - .read() - .add(BoardEvent.endEditingHeader(widget.groupData.id, _controller.text)); + void _saveEdit() { + context + .read() + .add(ColumnHeaderEvent.endEditing(_controller.text)); + } Widget _buildHeaderIcon(GroupData customData) => switch (customData.fieldType) { @@ -254,6 +257,7 @@ class _BoardColumnHeaderState extends State { leftIcon: FlowySvg(action.icon), text: FlowyText.medium( action.text, + lineHeight: 1.0, overflow: TextOverflow.ellipsis, ), onTap: () { @@ -279,8 +283,8 @@ enum GroupOptions { switch (this) { case rename: context - .read() - .add(BoardEvent.startEditingHeader(group.groupId)); + .read() + .add(const ColumnHeaderEvent.startEditing()); break; case hide: context 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 acc6db2f0fd31..a013d370f8be2 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 @@ -1,5 +1,7 @@ import 'dart:io'; +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; @@ -7,7 +9,9 @@ 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'; @@ -17,8 +21,6 @@ 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 { @@ -42,6 +44,8 @@ class HiddenGroupsColumn extends StatelessWidget { return const SizedBox.shrink(); } final isCollapsed = layoutSettings.collapseHiddenGroups; + final leftPadding = margin.left + + context.read().horizontalPadding; return AnimatedSize( alignment: AlignmentDirectional.topStart, curve: Curves.easeOut, @@ -56,32 +60,29 @@ 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), - ], - ), + ), + _collapseExpandIcon(context, isCollapsed), + ], ), ), Expanded( @@ -201,31 +202,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, ), ); } @@ -283,7 +280,8 @@ class HiddenGroupButtonContent extends StatelessWidget { ), const HSpace(4), FlowyText.medium( - bloc.generateGroupNameFromGroup(group), + group + .generateGroupName(bloc.databaseController), overflow: TextOverflow.ellipsis, ), const HSpace(6), @@ -360,11 +358,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; @@ -385,11 +383,12 @@ class HiddenGroupPopupItemList extends StatelessWidget { if (group == null) { return const SizedBox.shrink(); } + final bloc = context.read(); final cells = [ Padding( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), child: FlowyText.medium( - context.read().generateGroupNameFromGroup(group), + group.generateGroupName(bloc.databaseController), fontSize: 10, color: Theme.of(context).hintColor, overflow: TextOverflow.ellipsis, @@ -402,6 +401,7 @@ class HiddenGroupPopupItemList extends StatelessWidget { viewId: viewId, rowCache: rowCache, ); + rowController.initialize(); final databaseController = context.read().databaseController; 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 8c9e36c2c36a4..3e9b4ac7d7785 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 @@ -220,7 +220,7 @@ class CalendarBloc extends Bloc { } 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) { return result.fold( (eventPB) => _calendarEventDataFromEventPB(eventPB), 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 303daff87ed51..48e159475c4c8 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), @@ -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 208906f00b348..e0bcc071ed9ff 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 @@ -77,7 +77,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, 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 10f078c805ae6..8a57bea2bd530 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,4 +1,3 @@ -import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; @@ -16,12 +15,12 @@ import 'package:appflowy/plugins/database/widgets/row/accessory/cell_accessory.d 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/application/view/view_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/widget/flowy_tooltip.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class CalendarEventEditor extends StatelessWidget { 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 82c43845b6484..65b4613204cfc 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'; @@ -20,13 +18,12 @@ 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 '../../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'; @@ -186,11 +183,17 @@ class _CalendarPageState extends State { return LayoutBuilder( // must specify MonthView width for useAvailableVerticalSpace to work properly builder: (context, constraints) { + EdgeInsets padding = PlatformExtension.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), 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 6cbf39e9e3398..9c7dd3bcd580d 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 @@ -173,12 +173,15 @@ class LayoutDateField extends StatelessWidget { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( - text: FlowyText.medium(fieldInfo.name), + text: FlowyText.medium( + 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, @@ -206,6 +209,7 @@ class LayoutDateField extends StatelessWidget { child: FlowyButton( margin: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 10.0), text: FlowyText.medium( + lineHeight: 1.0, LocaleKeys.calendar_settings_layoutDateField.tr(), ), ), @@ -307,6 +311,7 @@ class FirstDayOfWeek extends StatelessWidget { child: FlowyButton( margin: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 10.0), text: FlowyText.medium( + lineHeight: 1.0, LocaleKeys.calendar_settings_firstDayOfWeek.tr(), ), ), @@ -367,7 +372,10 @@ class StartFromButton extends StatelessWidget { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( - text: FlowyText.medium(title), + text: FlowyText.medium( + title, + lineHeight: 1.0, + ), onTap: () => onTap(dayIndex), rightIcon: isSelected ? const FlowySvg(FlowySvgs.check_s) : null, ), diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/field_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/field_service.dart index 9bc873f7f1e78..fe23916d2df8e 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/domain/field_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/field_service.dart @@ -110,12 +110,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 +183,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 e618da5de96b3..7434c5e49724f 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/domain/filter_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/filter_service.dart @@ -281,6 +281,30 @@ class FilterBackendService { ); } + 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 fieldId, required String filterId, 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 934bbba8d163d..36bb847356197 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/domain/group_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/group_service.dart @@ -60,4 +60,18 @@ class GroupBackendService { return DatabaseEventDeleteGroup(payload).send(); } + + Future> renameGroup({ + required String groupId, + required String fieldId, + required String name, + }) { + final payload = RenameGroupPB.create() + ..fieldId = fieldId + ..viewId = viewId + ..groupId = groupId + ..name = name; + + return DatabaseEventRenameGroup(payload).send(); + } } 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 93db3c703d8ba..2e0c24718e6ed 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/grid/application/calculations/calculations_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/calculations/calculations_bloc.dart index e41fa61b2fca6..a2b80a29df973 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/filter_create_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_create_bloc.dart index a27b0bf00074d..b4a8dc72b77d3 100644 --- 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 @@ -3,13 +3,7 @@ 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-database2/protobuf.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'; @@ -149,6 +143,11 @@ class GridCreateFilterBloc fieldId: fieldId, condition: TextFilterConditionPB.TextContains, ); + case FieldType.Media: + return _filterBackendSvc.insertMediaFilter( + fieldId: fieldId, + condition: MediaFilterConditionPB.MediaIsNotEmpty, + ); default: throw UnimplementedError(); } 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 a0c0467b95740..2267870b028b5 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(); } @@ -82,7 +83,7 @@ class RowBloc extends Bloc { 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 0d655a840bc9b..3fcaae3332b68 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 @@ -20,6 +20,8 @@ class RowDetailBloc extends Bloc { _dispatch(); _startListening(); _init(); + + rowController.initialize(); } final FieldController fieldController; @@ -29,7 +31,7 @@ class RowDetailBloc extends Bloc { @override Future close() async { - rowController.dispose(); + await rowController.dispose(); return super.close(); } @@ -80,6 +82,15 @@ 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: "")); + }, ); }, ); @@ -125,7 +136,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 +228,16 @@ 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; } @freezed @@ -226,6 +247,8 @@ class RowDetailState with _$RowDetailState { required List visibleCells, required bool showHiddenFields, required int numHiddenFields, + required String editingFieldId, + required String newFieldId, }) = _RowDetailState; factory RowDetailState.initial() => const RowDetailState( @@ -233,5 +256,7 @@ class RowDetailState with _$RowDetailState { visibleCells: [], showHiddenFields: false, numHiddenFields: 0, + editingFieldId: "", + newFieldId: "", ); } 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 50b67e7a8faa2..601c41db63b0e 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,22 +1,22 @@ -import 'package:appflowy/generated/locale_keys.g.dart'; +import 'dart:math'; + import 'package:appflowy/plugins/database/application/row/row_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/view/view_bloc.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_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'; @@ -149,17 +149,20 @@ class _GridPageState extends State { }, ), builder: (context, state) => state.loadingState.map( - loading: (_) => - const Center(child: CircularProgressIndicator.adaptive()), + loading: (_) => const Center( + child: CircularProgressIndicator.adaptive(), + ), finish: (result) => result.successOrFail.fold( (_) => GridShortcuts( child: GridPageContent( + key: ValueKey(widget.view.id), view: widget.view, ), ), - (err) => FlowyErrorPage.message( - err.toString(), - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + (err) => Center( + child: AppFlowyErrorPage( + error: err, + ), ), ), idle: (_) => const SizedBox.shrink(), @@ -305,22 +308,26 @@ class _GridRowsState extends State<_GridRows> { 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), - ); - }, - ), + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints layoutConstraits) { + return _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, layoutConstraits), + ); + }, + ), + ); + }, ), ); }, @@ -330,48 +337,28 @@ class _GridRowsState extends State<_GridRows> { Widget _renderList( BuildContext context, GridState state, + BoxConstraints layoutConstraints, ) { - 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, - ), - ); - } - - children.add(const SizedBox(key: Key('footer_padding'), height: 10)); - + // 1. GridRowBottomBar + // 2. GridCalculationsRow + // 3. Footer Padding + final itemCount = state.rowInfos.length + 3; return Stack( children: [ Positioned.fill( child: ReorderableListView.builder( /// This is a workaround related to /// https://github.com/flutter/flutter/issues/25652 - cacheExtent: 5000, + cacheExtent: max(layoutConstraints.maxHeight * 2, 500), 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), + proxyDecorator: (child, _, __) => Provider.value( + value: context.read(), + child: Material( + color: Colors.white.withOpacity(.1), + child: Opacity(opacity: .5, child: child), + ), ), onReorder: (fromIndex, newIndex) { final toIndex = newIndex > fromIndex ? newIndex - 1 : newIndex; @@ -381,8 +368,37 @@ class _GridRowsState extends State<_GridRows> { .add(GridEvent.moveRow(fromIndex, toIndex)); } }, - itemCount: children.length, - itemBuilder: (context, index) => children[index], + itemCount: itemCount, + itemBuilder: (context, index) { + if (index < state.rowInfos.length) { + return _renderRow( + context, + state.rowInfos[index].rowId, + isDraggable: state.reorderable, + index: index, + ); + } + + if (index == state.rowInfos.length) { + return const GridRowBottomBar(key: Key('grid_footer')); + } + + if (index == state.rowInfos.length + 1) { + if (showFloatingCalculations) { + return const SizedBox( + key: Key('calculations_bottom_padding'), + height: 36, + ); + } else { + return GridCalculationsRow( + key: const Key('grid_calculations'), + viewId: widget.viewId, + ); + } + } + + return const SizedBox(key: Key('footer_padding'), height: 10); + }, ), ), if (showFloatingCalculations) ...[ @@ -415,7 +431,7 @@ class _GridRowsState extends State<_GridRows> { ); final child = GridRow( - key: ValueKey(rowMeta.id), + key: ValueKey(rowId), fieldController: databaseController.fieldController, rowId: rowId, viewId: viewId, 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 c028df7ceda06..88facd39a76c1 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 @@ -6,6 +6,7 @@ class GridSize { static double get scrollBarSize => 8 * scale; static double get headerHeight => 40 * scale; + static double get buttonHeight => 38 * scale; static double get footerHeight => 40 * scale; static double get horizontalHeaderPadding => PlatformExtension.isDesktop ? 40 * scale : 16 * scale; @@ -15,6 +16,7 @@ class GridSize { 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, 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 80952f30f2709..f70d98ab7722d 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'; @@ -9,6 +7,7 @@ import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/shortcuts.dart'; import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; +import 'package:appflowy/shared/flowy_error_page.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; import 'package:appflowy_backend/log.dart'; @@ -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'; @@ -106,9 +105,10 @@ class _MobileGridPageState extends State { _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(), + (err) => Center( + child: AppFlowyErrorPage( + error: err, + ), ), ); }, 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 c8199ce135e9c..1040081d518bd 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'; @@ -15,7 +13,7 @@ import 'package:appflowy_backend/protobuf/flowy-database2/number_entities.pb.dar 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 +139,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 +167,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 +176,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 eb1a76fe18f20..872c9bcf5225e 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.medium( + 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 7899d5f56de9c..5524633a46617 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/filter/choicechip/choicechip.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/choicechip.dart index 2e080e8e68989..85af9b39344d0 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 @@ -39,6 +39,7 @@ class ChoiceChipButton extends StatelessWidget { decoration: decoration, useIntrinsicWidth: true, text: FlowyText( + lineHeight: 1.0, filterInfo.fieldInfo.field.name, color: AFThemeExtension.of(context).textColor, ), 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 736fdee63aa25..7ebe4e9f03fee 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 @@ -31,6 +31,7 @@ class ConditionButton extends StatelessWidget { child: FlowyButton( useIntrinsicWidth: true, text: FlowyText( + lineHeight: 1.0, conditionName, fontSize: 10, color: AFThemeExtension.of(context).textColor, 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 6c5437ef516b1..849a436e801a2 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 @@ -78,12 +78,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), ), ), ]; @@ -163,6 +160,7 @@ class GridFilterPropertyCell extends StatelessWidget { return FlowyButton( hoverColor: AFThemeExtension.of(context).lightGreyHover, text: FlowyText.medium( + lineHeight: 1.0, fieldInfo.field.name, color: AFThemeExtension.of(context).textColor, ), 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 52c361ab38af9..145c5aa1d9eee 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 @@ -87,6 +87,7 @@ class _AddFilterButtonState extends State { height: 28, child: FlowyButton( text: FlowyText( + lineHeight: 1.0, LocaleKeys.grid_settings_addFilter.tr(), color: AFThemeExtension.of(context).textColor, ), 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 fa78dd629eea7..2179c56604a12 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 { @@ -23,6 +23,7 @@ class GridAddRowButton extends StatelessWidget { ), ), text: FlowyText( + lineHeight: 1.0, LocaleKeys.grid_row_newRow.tr(), color: Theme.of(context).hintColor, ), @@ -41,8 +42,11 @@ 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(), ); 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 56c48261f3e7a..6c1809d6910e5 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 @@ -86,7 +86,8 @@ 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, @@ -229,6 +230,7 @@ class FieldCellButton extends StatelessWidget { radius: radius, text: FlowyText.medium( field.name, + lineHeight: 1.0, maxLines: maxLines, overflow: TextOverflow.ellipsis, color: AFThemeExtension.of(context).textColor, 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 3267c74ad7160..c4d446f2162be 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 @@ -22,3 +22,10 @@ extension FieldTypeListExtension on FieldType { _ => 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 baa62657b82f1..97532bcf3d04f 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 @@ -174,7 +174,7 @@ class _CellTrailing extends StatelessWidget { } } -class CreateFieldButton extends StatefulWidget { +class CreateFieldButton extends StatelessWidget { const CreateFieldButton({ super.key, required this.viewId, @@ -184,27 +184,23 @@ 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"), ); }, 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 90bfbbca1392a..8c7f931b40679 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 @@ -197,7 +197,7 @@ class _CreateFieldButtonState extends State { Widget build(BuildContext context) { return Container( constraints: BoxConstraints( - maxWidth: GridSize.newPropertyButtonWidth, + maxWidth: GridSize.mobileNewPropertyButtonWidth, minHeight: GridSize.headerHeight, ), decoration: _getDecoration(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 abdeb90e47bf6..71b20161b148b 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 @@ -51,7 +51,11 @@ class RowActionMenu extends StatelessWidget { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( - text: FlowyText.medium(action.text, overflow: TextOverflow.ellipsis), + text: FlowyText.medium( + action.text, + overflow: TextOverflow.ellipsis, + lineHeight: 1.0, + ), onTap: () { if (action == RowAction.delete) { NavigatorOkCancelDialog( @@ -82,7 +86,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, }; } 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 b6817fc8487c5..209c439ad164d 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 337aab4a705f3..b803fbbd2867a 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,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/cell/cell_controller.dart'; @@ -11,13 +13,13 @@ 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 '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 { @@ -98,7 +100,7 @@ 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: (_) { @@ -188,8 +190,14 @@ class _RowMenuButtonState extends State { richTooltipText: widget.isDragEnabled ? TextSpan( children: [ - TextSpan(text: '${LocaleKeys.tooltip_dragRow.tr()}\n'), - TextSpan(text: LocaleKeys.tooltip_openMenu.tr()), + TextSpan( + text: '${LocaleKeys.tooltip_dragRow.tr()}\n', + style: context.tooltipTextStyle(), + ), + TextSpan( + text: LocaleKeys.tooltip_openMenu.tr(), + style: context.tooltipTextStyle(), + ), ], ) : null, diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/create_sort_list.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/create_sort_list.dart index 69e46a04ff947..e85071a971c1c 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 @@ -120,6 +120,7 @@ class GridSortPropertyCell extends StatelessWidget { hoverColor: AFThemeExtension.of(context).lightGreyHover, text: FlowyText.medium( fieldInfo.name, + lineHeight: 1.0, color: AFThemeExtension.of(context).textColor, ), onTap: onTap, 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 a00bc1002f2f3..4d509b386284d 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/tab_bar/desktop/tab_bar_header.dart b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/tab_bar_header.dart index 9c853414f15ab..6fc3b6d85d593 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 @@ -222,6 +222,7 @@ class TabBarItemButton extends StatelessWidget { ), text: FlowyText( view.name, + lineHeight: 1.0, fontSize: FontSizes.s11, textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, @@ -290,4 +291,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/widgets/card/card.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card.dart index d5c73a11794e8..44f62d0d619bc 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card.dart @@ -1,20 +1,26 @@ +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/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_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'; 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 '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'; @@ -73,13 +79,18 @@ class _RowCardState extends State { @override void initState() { super.initState(); + final rowController = RowController( + viewId: widget.viewId, + rowMeta: widget.rowMeta, + rowCache: widget.rowCache, + ); + _cardBloc = CardBloc( fieldController: widget.fieldController, viewId: widget.viewId, groupFieldId: widget.groupingFieldId, isEditing: widget.isEditing, - rowMeta: widget.rowMeta, - rowCache: widget.rowCache, + rowController: rowController, )..add(const CardEvent.initial()); } @@ -140,13 +151,11 @@ 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, - ); - }, + popupBuilder: (_) => RowActionMenu.board( + viewId: _cardBloc.viewId, + rowId: _cardBloc.rowController.rowId, + groupId: widget.groupId, + ), child: RowCardContainer( buildAccessoryWhen: () => state.isEditing == false, accessories: accessories ?? [], @@ -190,11 +199,38 @@ class _CardContent extends StatelessWidget { @override Widget build(BuildContext context) { + final attachmentCount = rowMeta.attachmentCount.toInt(); final child = Padding( padding: styleConfiguration.cardPadding, child: Column( mainAxisSize: MainAxisSize.min, - children: _makeCells(context, rowMeta, cells), + children: [ + ..._makeCells(context, rowMeta, cells), + if (attachmentCount > 0) ...[ + const VSpace(2), + Padding( + padding: const EdgeInsets.only(left: 8), + child: Row( + children: [ + const FlowySvg( + FlowySvgs.media_s, + size: Size.square(12), + ), + const HSpace(4), + Flexible( + child: FlowyText.regular( + LocaleKeys.grid_media_attachmentsHint + .tr(args: ['$attachmentCount']), + fontSize: 11, + color: AFThemeExtension.of(context).secondaryTextColor, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + ], ), ); return styleConfiguration.hoverStyle == null 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 5bd4d6f5059c6..a8ede243f73ed 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,5 +1,6 @@ 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'; @@ -7,7 +8,6 @@ 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:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -19,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(); } @@ -85,20 +79,17 @@ 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)); } }, ); @@ -108,16 +99,18 @@ class CardBloc extends Bloc { 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 + return cellContext .map( (cellCtx) => CellMeta( fieldId: cellCtx.fieldId, @@ -157,19 +150,19 @@ class CellMeta with _$CellMeta { class CardState with _$CardState { const factory CardState({ required List cells, - required RowMetaPB rowMeta, required bool isEditing, + required RowMetaPB rowMeta, ChangedReason? changeReason, }) = _RowCardState; factory CardState.initial( - RowMetaPB rowMeta, 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/cell/card_cell_builder.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_builder.dart index aff11f6584cdd..d17c522de6a87 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 @@ -8,6 +8,7 @@ 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'; @@ -113,6 +114,12 @@ class CardCellBuilder { 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/date_card_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/date_card_cell.dart index 3b47971fdbe3e..746a0c677dcf1 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,7 +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/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'; @@ -44,7 +46,9 @@ class _DateCellState extends State { ); }, child: BlocBuilder( - buildWhen: (previous, current) => previous.dateStr != current.dateStr, + buildWhen: (previous, current) => + previous.dateStr != current.dateStr || + previous.data != current.data, builder: (context, state) { if (state.dateStr.isEmpty) { return const SizedBox.shrink(); @@ -53,9 +57,20 @@ class _DateCellState extends State { return Container( alignment: Alignment.centerLeft, padding: widget.style.padding, - child: Text( - state.dateStr, - style: widget.style.textStyle, + child: Row( + children: [ + Flexible( + child: Text( + state.dateStr, + style: widget.style.textStyle, + overflow: TextOverflow.ellipsis, + ), + ), + if (state.data?.reminderId.isNotEmpty ?? false) ...[ + const HSpace(4), + const FlowySvg(FlowySvgs.clock_alarm_s), + ], + ], ), ); }, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/media_card_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/media_card_cell.dart new file mode 100644 index 0000000000000..f3569a57dc842 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/media_card_cell.dart @@ -0,0 +1,39 @@ +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/card_cell.dart'; + +class MediaCardCellStyle extends CardCellStyle { + const MediaCardCellStyle({ + required super.padding, + required this.textStyle, + }); + + final TextStyle textStyle; +} + +// This is a placeholder for the MediaCardCell, it is not implemented +// as we use the [RowMetaPB.attachmentCount] to display cumulative attachments +// on a Card. +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 const SizedBox.shrink(); + } +} 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 61ecfd98218e8..f01670b96b2c3 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,16 +1,17 @@ import 'dart:ui' as ui; +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:appflowy/util/text_direction.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 '../editable_cell_builder.dart'; @@ -63,7 +64,6 @@ class _TextCellState extends State { @override void initState() { super.initState(); - _textEditingController = TextEditingController(text: cellBloc.state.content) ..addListener(() { if (_textEditingController.value.composing.isCollapsed) { @@ -82,15 +82,17 @@ class _TextCellState extends State { // 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) { - 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)); + } + } + void _bindEditableNotifier() { widget.editableNotifier?.isCellEditing.addListener(() { if (!mounted) { @@ -99,9 +101,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)); }); @@ -143,12 +144,13 @@ class _TextCellState extends State { } Widget? _buildIcon(TextCellState state) { - if (state.emoji.isNotEmpty) { + if (state.emoji?.value.isNotEmpty ?? false) { return Text( - state.emoji, + state.emoji?.value ?? '', style: widget.style.titleTextStyle, ); } + if (widget.showNotes) { return FlowyTooltip( message: LocaleKeys.board_notesTooltip.tr(), @@ -222,13 +224,16 @@ class _TextCellState extends State { bindings: { const SingleActivator(LogicalKeyboardKey.escape): () => focusNode.unfocus(), + const SimpleActivator(LogicalKeyboardKey.enter): () => + focusNode.unfocus(), }, child: TextField( controller: _textEditingController, textDirection: lastDirection, focusNode: focusNode, onEditingComplete: () => focusNode.unfocus(), - maxLines: isEditing ? null : 2, + onSubmitted: (_) => focusNode.unfocus(), + maxLines: null, minLines: 1, textInputAction: TextInputAction.done, readOnly: !isEditing, @@ -255,3 +260,27 @@ class _TextCellState extends State { ); } } + +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_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 df7bb72f60a20..23a8a2451feb9 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,18 +1,19 @@ import 'package:flutter/material.dart'; -import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/summary_card_cell.dart'; -import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/translate_card_cell.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import '../card_cell_builder.dart'; import '../card_cell_skeleton/checkbox_card_cell.dart'; import '../card_cell_skeleton/checklist_card_cell.dart'; import '../card_cell_skeleton/date_card_cell.dart'; +import '../card_cell_skeleton/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) { @@ -90,5 +91,9 @@ CardCellStyleMap desktopCalendarCardCellStyleMap(BuildContext context) { 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 7fcb289c1d884..6d9f08fb5b2a5 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,19 +1,20 @@ import 'package:flutter/material.dart'; -import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/summary_card_cell.dart'; -import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/translate_card_cell.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import '../card_cell_builder.dart'; import '../card_cell_skeleton/checkbox_card_cell.dart'; import '../card_cell_skeleton/checklist_card_cell.dart'; import '../card_cell_skeleton/date_card_cell.dart'; +import '../card_cell_skeleton/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) { @@ -95,5 +96,9 @@ CardCellStyleMap desktopBoardCardCellStyleMap(BuildContext context) { 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 3911678176210..93d98f013ee77 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,5 +1,6 @@ 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'; @@ -94,5 +95,9 @@ CardCellStyleMap mobileBoardCardCellStyleMap(BuildContext context) { 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_date_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_date_cell.dart index 537207c12c91a..5f30aff36e13a 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 @@ -7,7 +7,6 @@ import 'package:appflowy/plugins/database/widgets/cell_editor/date_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/widgets.dart'; import '../editable_cell_skeleton/date.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_media_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_media_cell.dart new file mode 100644 index 0000000000000..9c27546d753b5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_media_cell.dart @@ -0,0 +1,237 @@ +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/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/shared/appflowy_network_image.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/platform_extension.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 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 = PlatformExtension.isMobile; + + Widget child = BlocBuilder( + builder: (context, state) { + final filesToDisplay = state.files.take(4).toList(); + final extraCount = state.files.length - filesToDisplay.length; + + final wrapContent = context.read().wrapContent; + final children = [ + ...filesToDisplay.map((file) => _FilePreviewRender(file: file)), + if (extraCount > 0) _ExtraInfo(extraCount: extraCount), + ]; + + if (filesToDisplay.isEmpty && isMobile) { + children.add( + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + LocaleKeys.grid_row_textPlaceholder.tr(), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: 16, + color: Theme.of(context).hintColor, + ), + ), + ), + ); + } + + if (!isMobile && wrapContent) { + return Padding( + padding: const EdgeInsets.all(4), + child: SizedBox( + width: double.infinity, + child: Wrap( + runSpacing: 4, + children: children, + ), + ), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: SizedBox( + width: double.infinity, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SeparatedRow( + separatorBuilder: () => const HSpace(6), + children: children, + ), + ), + ), + ); + }, + ); + + 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: BorderRadius.circular(12), + onTap: () { + showMobileBottomSheet( + context, + builder: (_) => BlocProvider.value( + value: context.read(), + child: const MobileMediaCellEditor(), + ), + ); + }, + hoverColor: Colors.transparent, + child: child, + ); + } + + return BlocProvider.value( + value: bloc, + child: Builder(builder: (context) => child), + ); + } +} + +class _FilePreviewRender extends StatelessWidget { + const _FilePreviewRender({required this.file}); + + final MediaFilePB file; + + @override + Widget build(BuildContext context) { + Widget child; + if (file.fileType == MediaFileTypePB.Image) { + if (file.uploadType == MediaUploadTypePB.NetworkMedia) { + child = Image.network( + file.url, + height: 32, + width: 32, + fit: BoxFit.cover, + ); + } else if (file.uploadType == MediaUploadTypePB.LocalMedia) { + child = Image.file( + File(file.url), + height: 32, + width: 32, + fit: BoxFit.cover, + ); + } else { + // Cloud + child = FlowyNetworkImage( + url: file.url, + userProfilePB: context.read().state.userProfile, + height: 32, + width: 32, + ); + } + } else { + child = Container( + height: 32, + width: 32, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AFThemeExtension.of(context).greyHover, + ), + child: FlowySvg( + file.fileType.icon, + color: AFThemeExtension.of(context).textColor, + ), + ); + } + + return Container( + margin: const EdgeInsets.all(2), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + ), + child: child, + ); + } +} + +class _ExtraInfo extends StatelessWidget { + const _ExtraInfo({required this.extraCount}); + + final int extraCount; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(2), + child: Container( + height: 32, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AFThemeExtension.of(context).greyHover, + borderRadius: BorderRadius.circular(4), + ), + child: FlowyText.regular( + LocaleKeys.grid_media_moreFilesHint.tr(args: ['$extraCount']), + lineHeight: 1, + ), + ), + ); + } +} 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 4cfcfb2ddb321..8345cadc59310 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,3 +1,4 @@ +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'; @@ -20,26 +21,7 @@ class DesktopGridTextCellSkin extends IEditableTextCellSkin { 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), - ], - ), - ); - }, - ), + const _IconOrEmoji(), Expanded( child: TextField( controller: textEditingController, @@ -62,3 +44,49 @@ class DesktopGridTextCellSkin extends IEditableTextCellSkin { ); } } + +class _IconOrEmoji extends StatelessWidget { + const _IconOrEmoji(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (state.emoji != null) + ValueListenableBuilder( + valueListenable: state.emoji!, + builder: (context, value, child) { + if (value.isEmpty) { + return const SizedBox.shrink(); + } else { + return FlowyText( + value, + fontSize: 16, + ); + } + }, + ), + if (state.hasDocument != null) + ValueListenableBuilder( + valueListenable: state.hasDocument!, + builder: (context, hasDocument, child) { + if ((state.emoji?.value.isEmpty ?? true) && hasDocument) { + return FlowySvg( + FlowySvgs.notes_s, + color: Theme.of(context).hintColor, + ); + } else { + return const SizedBox.shrink(); + } + }, + ), + const HSpace(6), + ], + ); + }, + ); + } +} 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 8ccda9539102d..17a3519d3d9a8 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'; @@ -202,8 +203,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_checklist_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart index 39804c685168e..b8700406603cc 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 @@ -8,7 +8,6 @@ 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:provider/provider.dart'; 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 071ed0ab84f64..94e0159a02878 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 @@ -7,7 +7,6 @@ import 'package:appflowy/plugins/database/widgets/cell_editor/date_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'; class DesktopRowDetailDateCellSkin extends IEditableDateCellSkin { diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_media_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_media_cell.dart new file mode 100644 index 0000000000000..9b9334e101e40 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_media_cell.dart @@ -0,0 +1,533 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/cell/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/media_file_type_ext.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_block_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/appflowy_network_image.dart'; +import 'package:appflowy/util/theme_extension.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/media_entities.pb.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:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +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: Builder( + builder: (context) => BlocBuilder( + builder: (context, state) { + final filesToDisplay = state.files.take(4).toList(); + final extraCount = state.files.length - filesToDisplay.length; + + return SizedBox( + width: double.infinity, + child: LayoutBuilder( + builder: (context, constraints) { + if (state.files.isEmpty) { + return GestureDetector( + onTap: () => popoverController.show(), + child: AppFlowyPopover( + mutex: mutex, + controller: popoverController, + asBarrier: true, + constraints: const BoxConstraints( + minWidth: 250, + maxWidth: 250, + maxHeight: 400, + ), + offset: const Offset(0, 10), + margin: EdgeInsets.zero, + direction: PopoverDirection.bottomWithLeftAligned, + popupBuilder: (_) => BlocProvider.value( + value: context.read(), + child: const MediaCellEditor(), + ), + onClose: () => cellContainerNotifier.isFocus = false, + child: FlowyHover( + style: HoverStyle( + hoverColor: + AFThemeExtension.of(context).lightGreyHover, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 6, + ), + child: FlowyText.medium( + LocaleKeys.grid_row_textPlaceholder.tr(), + color: Theme.of(context).hintColor, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + ); + } + + final size = constraints.maxWidth / 2 - 6; + return Wrap( + runSpacing: 12, + spacing: 12, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + AppFlowyPopover( + mutex: mutex, + controller: popoverController, + asBarrier: true, + constraints: const BoxConstraints( + minWidth: 250, + maxWidth: 250, + maxHeight: 400, + ), + offset: const Offset(0, 10), + margin: EdgeInsets.zero, + direction: PopoverDirection.bottomWithLeftAligned, + popupBuilder: (_) => BlocProvider.value( + value: context.read(), + child: const MediaCellEditor(), + ), + onClose: () => + cellContainerNotifier.isFocus = false, + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + ), + child: FlowyHover( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: Row( + children: [ + const FlowySvg(FlowySvgs.edit_s), + const HSpace(4), + FlowyText.regular( + LocaleKeys.button_edit.tr(), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ...filesToDisplay.map( + (file) => _FilePreviewRender( + key: ValueKey(file.id), + file: file, + size: size, + mutex: mutex, + ), + ), + if (extraCount > 0) + _ExtraInfo( + extraCount: extraCount, + controller: popoverController, + mutex: mutex, + cellContainerNotifier: cellContainerNotifier, + ), + ], + ); + }, + ), + ); + }, + ), + ), + ); + } +} + +class _FilePreviewRender extends StatefulWidget { + const _FilePreviewRender({ + super.key, + required this.file, + required this.size, + required this.mutex, + }); + + final MediaFilePB file; + final double size; + final PopoverMutex mutex; + + @override + State<_FilePreviewRender> createState() => _FilePreviewRenderState(); +} + +class _FilePreviewRenderState extends State<_FilePreviewRender> { + final controller = PopoverController(); + + @override + void dispose() { + controller.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + Widget child; + if (widget.file.fileType == MediaFileTypePB.Image) { + if (widget.file.uploadType == MediaUploadTypePB.NetworkMedia) { + child = Image.network( + widget.file.url, + fit: BoxFit.cover, + ); + } else if (widget.file.uploadType == MediaUploadTypePB.LocalMedia) { + child = Image.file( + File(widget.file.url), + fit: BoxFit.cover, + ); + } else { + // Cloud + child = FlowyNetworkImage( + url: widget.file.url, + userProfilePB: context.read().state.userProfile, + ); + } + } else { + child = DecoratedBox( + decoration: BoxDecoration(color: widget.file.fileType.color), + child: Center( + child: Container( + padding: const EdgeInsets.all(8), + child: FlowySvg( + widget.file.fileType.icon, + color: AFThemeExtension.of(context).strongText, + size: const Size.square(32), + ), + ), + ), + ); + } + + return Stack( + children: [ + DecoratedBox( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Corners.s6Radius), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.15), + blurRadius: 2, + ), + ], + ), + child: Column( + children: [ + Container( + height: widget.size, + width: widget.size, + constraints: BoxConstraints( + maxHeight: widget.size < 150 ? 100 : 195, + minHeight: widget.size < 150 ? 100 : 195, + ), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: AFThemeExtension.of(context).greyHover, + borderRadius: const BorderRadius.only( + topLeft: Corners.s6Radius, + topRight: Corners.s6Radius, + ), + ), + child: child, + ), + Container( + height: 28, + width: widget.size, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: Theme.of(context).isLightMode + ? Theme.of(context).cardColor + : AFThemeExtension.of(context).greyHover, + borderRadius: const BorderRadius.only( + bottomLeft: Corners.s6Radius, + bottomRight: Corners.s6Radius, + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Center( + child: FlowyText.medium( + widget.file.name, + overflow: TextOverflow.ellipsis, + fontSize: 12, + color: AFThemeExtension.of(context).secondaryTextColor, + ), + ), + ), + ), + ], + ), + ), + Positioned(top: 5, right: 5, child: FileItemMenu(file: widget.file)), + ], + ); + } +} + +class FileItemMenu extends StatefulWidget { + const FileItemMenu({super.key, required this.file}); + + final MediaFilePB file; + + @override + State createState() => _FileItemMenuState(); +} + +class _FileItemMenuState extends State { + final popoverController = PopoverController(); + final nameController = TextEditingController(); + final errorMessage = ValueNotifier(null); + + @override + void initState() { + super.initState(); + nameController.text = widget.file.name; + } + + @override + void dispose() { + popoverController.close(); + errorMessage.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + controller: popoverController, + constraints: const BoxConstraints(maxWidth: 150), + direction: PopoverDirection.bottomWithRightAligned, + offset: const Offset(0, 5), + popupBuilder: (_) { + return SeparatedColumn( + separatorBuilder: () => const VSpace(4), + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.file.fileType == MediaFileTypePB.Image) ...[ + FlowyButton( + onTap: () { + popoverController.close(); + showDialog( + context: context, + builder: (_) => InteractiveImageViewer( + userProfile: + context.read().state.userProfile, + imageProvider: AFBlockImageProvider( + images: [ + ImageBlockData( + url: widget.file.url, + type: widget.file.uploadType.toCustomImageType(), + ), + ], + onDeleteImage: (_) => context + .read() + .deleteFile(widget.file.id), + ), + ), + ); + }, + leftIcon: FlowySvg( + FlowySvgs.full_view_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(18), + ), + text: FlowyText.regular( + LocaleKeys.settings_files_open.tr(), + color: AFThemeExtension.of(context).textColor, + ), + leftIconSize: const Size(18, 18), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + ), + ], + FlowyButton( + leftIcon: const FlowySvg(FlowySvgs.edit_s), + text: FlowyText.regular(LocaleKeys.grid_media_rename.tr()), + onTap: () { + popoverController.close(); + + nameController.text = widget.file.name; + nameController.selection = TextSelection( + baseOffset: 0, + extentOffset: nameController.text.length, + ); + + showCustomConfirmDialog( + context: context, + title: LocaleKeys.document_plugins_file_renameFile_title.tr(), + description: LocaleKeys + .document_plugins_file_renameFile_description + .tr(), + closeOnConfirm: false, + builder: (dialogContext) => FileRenameTextField( + nameController: nameController, + errorMessage: errorMessage, + onSubmitted: () => _saveName(context), + disposeController: false, + ), + confirmLabel: LocaleKeys.button_save.tr(), + onConfirm: () => _saveName(context), + ); + }, + ), + FlowyButton( + onTap: () async => downloadMediaFile( + context, + widget.file, + userProfile: context.read().state.userProfile, + ), + leftIcon: FlowySvg( + FlowySvgs.download_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(18), + ), + text: FlowyText.regular( + LocaleKeys.button_download.tr(), + color: AFThemeExtension.of(context).textColor, + ), + leftIconSize: const Size(18, 18), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + ), + FlowyButton( + onTap: () => context.read().add( + MediaCellEvent.removeFile( + fileId: widget.file.id, + ), + ), + leftIcon: FlowySvg( + FlowySvgs.delete_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(18), + ), + text: FlowyText.regular( + LocaleKeys.button_delete.tr(), + color: AFThemeExtension.of(context).textColor, + ), + leftIconSize: const Size(18, 18), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + ), + ], + ); + }, + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: const BorderRadius.all(Corners.s8Radius), + ), + child: Padding( + padding: const EdgeInsets.all(3), + child: FlowyIconButton( + width: 20, + radius: BorderRadius.circular(0), + icon: FlowySvg( + FlowySvgs.three_dots_s, + color: AFThemeExtension.of(context).textColor, + ), + ), + ), + ), + ); + } + + void _saveName(BuildContext context) { + final newName = nameController.text.trim(); + if (newName.isEmpty) { + return; + } + + context + .read() + .add(MediaCellEvent.renameFile(fileId: widget.file.id, name: newName)); + Navigator.of(context).pop(); + } +} + +class _ExtraInfo extends StatelessWidget { + const _ExtraInfo({ + required this.extraCount, + required this.controller, + required this.mutex, + required this.cellContainerNotifier, + }); + + final int extraCount; + final PopoverController controller; + final PopoverMutex mutex; + final CellContainerNotifier cellContainerNotifier; + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + key: const Key('extra_info'), + mutex: mutex, + controller: controller, + triggerActions: PopoverTriggerFlags.none, + constraints: const BoxConstraints( + minWidth: 250, + maxWidth: 250, + maxHeight: 400, + ), + margin: EdgeInsets.zero, + direction: PopoverDirection.bottomWithLeftAligned, + popupBuilder: (_) => BlocProvider.value( + value: context.read(), + child: const MediaCellEditor(), + ), + onClose: () => cellContainerNotifier.isFocus = false, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: controller.show, + child: FlowyHover( + resetHoverOnRebuild: false, + child: Container( + height: 38, + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AFThemeExtension.of(context).greyHover, + borderRadius: BorderRadius.circular(4), + ), + child: FlowyText.regular( + LocaleKeys.grid_media_showMore.tr(args: ['$extraCount']), + lineHeight: 1, + ), + ), + ), + ), + ); + } +} 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 31dc63abe4995..dff883db1f45b 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,3 +1,5 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; @@ -7,7 +9,6 @@ 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'; import '../editable_cell_skeleton/select_option.dart'; @@ -25,16 +26,13 @@ class DesktopRowDetailSelectOptionCellSkin controller: popoverController, constraints: const BoxConstraints.tightFor(width: 300), margin: EdgeInsets.zero, + 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( 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 155a6003ceb9e..9e0f334ec8fe0 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,7 @@ 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'; @@ -134,6 +135,13 @@ class EditableCellBuilder { skin: IEditableTranslateCellSkin.fromStyle(style), key: key, ), + FieldType.Media => EditableMediaCell( + databaseController: databaseController, + cellContext: cellContext, + skin: IEditableMediaCellSkin.fromStyle(style), + style: style, + key: key, + ), _ => throw UnimplementedError(), }; } @@ -226,6 +234,13 @@ class EditableCellBuilder { skin: skinMap.timeSkin!, key: key, ), + FieldType.Media => EditableMediaCell( + databaseController: databaseController, + cellContext: cellContext, + skin: skinMap.mediaSkin!, + style: EditableCellStyle.desktopGrid, + key: key, + ), _ => throw UnimplementedError(), }; } @@ -368,6 +383,12 @@ class SingleListenerFocusNode extends FocusNode { removeListener(_listener!); } } + + @override + void dispose() { + removeAllListener(); + super.dispose(); + } } class EditableCellSkinMap { @@ -382,6 +403,7 @@ class EditableCellSkinMap { this.urlSkin, this.relationSkin, this.timeSkin, + this.mediaSkin, }); final IEditableCheckboxCellSkin? checkboxSkin; @@ -394,6 +416,7 @@ class EditableCellSkinMap { final IEditableURLCellSkin? urlSkin; final IEditableRelationCellSkin? relationSkin; final IEditableTimeCellSkin? timeSkin; + final IEditableMediaCellSkin? mediaSkin; bool has(FieldType fieldType) { return switch (fieldType) { @@ -410,6 +433,7 @@ class EditableCellSkinMap { 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/media.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/media.dart new file mode 100644 index 0000000000000..55adb853345d0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/media.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/plugins/database/application/cell/bloc/media_cell_bloc.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/widgets/cell/desktop_grid/desktop_grid_media_cell.dart'; +import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_media_cell.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../application/cell/cell_controller_builder.dart'; + +abstract class IEditableMediaCellSkin { + const IEditableMediaCellSkin(); + + factory IEditableMediaCellSkin.fromStyle(EditableCellStyle style) { + return switch (style) { + EditableCellStyle.desktopGrid => const GridMediaCellSkin(), + EditableCellStyle.desktopRowDetail => DekstopRowDetailMediaCellSkin(), + EditableCellStyle.mobileGrid => const GridMediaCellSkin(), + EditableCellStyle.mobileRowDetail => + const GridMediaCellSkin(isMobileRowDetail: true), + }; + } + + bool autoShowPopover(EditableCellStyle style) => switch (style) { + EditableCellStyle.desktopGrid => true, + EditableCellStyle.desktopRowDetail => false, + EditableCellStyle.mobileGrid => false, + EditableCellStyle.mobileRowDetail => false, + }; + + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + PopoverController popoverController, + MediaCellBloc bloc, + ); + + void dispose(); +} + +class EditableMediaCell extends EditableCellWidget { + EditableMediaCell({ + super.key, + required this.databaseController, + required this.cellContext, + required this.skin, + required this.style, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + final IEditableMediaCellSkin skin; + final EditableCellStyle style; + + @override + GridEditableTextCell createState() => + _EditableMediaCellState(); +} + +class _EditableMediaCellState extends GridEditableTextCell { + final PopoverController popoverController = PopoverController(); + + late final cellBloc = MediaCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + + @override + void dispose() { + cellBloc.close(); + widget.skin.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: cellBloc..add(const MediaCellEvent.initial()), + child: Builder( + builder: (context) => widget.skin.build( + context, + widget.cellContainerNotifier, + popoverController, + cellBloc, + ), + ), + ); + } + + @override + SingleListenerFocusNode focusNode = SingleListenerFocusNode(); + + @override + void onRequestFocus() => widget.skin.autoShowPopover(widget.style) + ? popoverController.show() + : null; + + @override + String? onCopy() => null; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/summary.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/summary.dart index 4b291719bbe42..d3b43b0d1786c 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 @@ -1,3 +1,6 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/summary_cell_bloc.dart'; @@ -7,19 +10,18 @@ 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'; 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 IEditableSummaryCellSkin { @@ -149,7 +151,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,13 +186,13 @@ 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_aiGenerate.tr(), child: Container( 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 index 2d3fd33751671..b4ff26d946545 100644 --- 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 @@ -1,3 +1,6 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/translate_cell_bloc.dart'; @@ -7,19 +10,18 @@ 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_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/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'; 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 { @@ -150,7 +152,22 @@ class TranslateCellAccessory 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: [TranslateButton(), HSpace(6), CopyButton()], 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 bef6202934aa8..40e8c3531982c 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'; @@ -17,27 +17,28 @@ class MobileGridTextCellSkin extends IEditableTextCellSkin { ) { 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_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 2e9e4b1a24987..279e79091386e 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,8 +1,9 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/checkbox_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flowy_infra/theme_extension.dart'; -import 'package:flutter/material.dart'; import '../editable_cell_skeleton/checkbox.dart'; 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 a900cf62fbfaf..1e709bdeb95dc 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 @@ -13,42 +13,54 @@ class MobileRowDetailSummaryCellSkin extends IEditableSummaryCellSkin { FocusNode focusNode, TextEditingController textEditingController, ) { - return 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, - ), + 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_translate_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_translate_cell.dart index 84af6c7062eb4..a1e4b4bf294b2 100644 --- 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 @@ -13,42 +13,54 @@ class MobileRowDetailTranslateCellSkin extends IEditableTranslateCellSkin { FocusNode focusNode, TextEditingController textEditingController, ) { - return 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, - ), + 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: TranslateCellAccessory( - 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( + 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_editor/checklist_cell_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_editor.dart index ba2dc287020ce..e86a2c47c7f9d 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 @@ -174,6 +174,8 @@ class _ChecklistItemState extends State { meta: Platform.isMacOS, control: !Platform.isMacOS, ): const _SelectTaskIntent(), + const SingleActivator(LogicalKeyboardKey.enter): + const _EndEditingTaskIntent(), const SingleActivator(LogicalKeyboardKey.escape): const _EndEditingTaskIntent(), }; @@ -372,7 +374,7 @@ class _NewTaskItemState extends State { ? Theme.of(context).disabledColor : Theme.of(context).colorScheme.primaryContainer, fontColor: Theme.of(context).colorScheme.onPrimary, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), onPressed: _textEditingController.text.isEmpty ? null : () { 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 index abd519f31d1fb..9d013402268c2 100644 --- 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 @@ -61,6 +61,7 @@ class ChecklistCellTextfield extends StatelessWidget { controller: textController, focusNode: focusNode, style: Theme.of(context).textTheme.bodyMedium, + maxLines: null, decoration: InputDecoration( border: InputBorder.none, isCollapsed: true, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/media_cell_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/media_cell_editor.dart new file mode 100644 index 0000000000000..d4995c7f3609e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/media_cell_editor.dart @@ -0,0 +1,507 @@ +import 'dart:io'; + +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/appflowy_network_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/media_entities.pb.dart'; +import 'package:appflowy_popover/appflowy_popover.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) { + return Padding( + padding: const EdgeInsets.all(4), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (state.files.isNotEmpty) ...[ + ReorderableListView.builder( + shrinkWrap: true, + buildDefaultDragHandles: false, + itemBuilder: (_, index) => BlocProvider.value( + key: Key(state.files[index].id), + value: context.read(), + child: RenderMedia( + file: state.files[index], + index: index, + enableReordering: state.files.length > 1, + mutex: itemMutex, + ), + ), + itemCount: state.files.length, + onReorder: (from, to) => context + .read() + .add(MediaCellEvent.reorderFiles(from: from, to: to)), + proxyDecorator: (child, index, animation) => Material( + color: Colors.transparent, + child: SizeTransition( + sizeFactor: animation, + child: child, + ), + ), + ), + const Divider(height: 8), + ], + AppFlowyPopover( + controller: addFilePopoverController, + direction: PopoverDirection.bottomWithCenterAligned, + offset: const Offset(0, 10), + constraints: const BoxConstraints( + minWidth: 250, + maxWidth: 250, + ), + triggerActions: PopoverTriggerFlags.none, + popupBuilder: (popoverContext) => FileUploadMenu( + onInsertLocalFile: (file) async => insertLocalFile( + context, + file, + userProfile: + context.read().state.userProfile, + documentId: context.read().rowId, + onUploadSuccess: (path, isLocalMode) { + final mediaCellBloc = context.read(); + if (mediaCellBloc.isClosed) { + return; + } + + mediaCellBloc.add( + MediaCellEvent.addFile( + url: path, + name: file.name, + uploadType: isLocalMode + ? MediaUploadTypePB.LocalMedia + : MediaUploadTypePB.CloudMedia, + 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: MediaUploadTypePB.NetworkMedia, + fileType: fileType, + ), + ); + + addFilePopoverController.close(); + }, + ), + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: addFilePopoverController.show, + child: FlowyHover( + resetHoverOnRebuild: false, + child: Padding( + padding: const EdgeInsets.all(4.0), + child: Row( + children: [ + const FlowySvg( + FlowySvgs.add_s, + size: Size.square(18), + ), + const HSpace(8), + FlowyText( + LocaleKeys.grid_media_addFileOrImage.tr(), + lineHeight: 1.0, + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +extension ToCustomImageType on MediaUploadTypePB { + CustomImageType toCustomImageType() => switch (this) { + MediaUploadTypePB.NetworkMedia => CustomImageType.external, + MediaUploadTypePB.CloudMedia => CustomImageType.internal, + _ => CustomImageType.local, + }; +} + +@visibleForTesting +class RenderMedia extends StatefulWidget { + const RenderMedia({ + super.key, + required this.index, + required this.file, + required this.enableReordering, + required this.mutex, + }); + + final int index; + final MediaFilePB file; + final bool enableReordering; + final PopoverMutex mutex; + + @override + State createState() => _RenderMediaState(); +} + +class _RenderMediaState extends State { + bool isHovering = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => isHovering = true), + onExit: (_) => setState(() => isHovering = false), + cursor: SystemMouseCursors.click, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: isHovering + ? AFThemeExtension.of(context).greyHover + : Colors.transparent, + ), + child: Padding( + padding: const EdgeInsets.all(4), + child: Row( + children: [ + ReorderableDragStartListener( + index: widget.index, + enabled: widget.enableReordering, + child: const FlowySvg(FlowySvgs.drag_element_s), + ), + const HSpace(8), + if (widget.file.fileType == MediaFileTypePB.Image && + widget.file.uploadType == MediaUploadTypePB.CloudMedia) ...[ + Expanded( + child: _openInteractiveViewer( + context, + file: widget.file, + child: FlowyNetworkImage( + url: widget.file.url, + userProfilePB: + context.read().state.userProfile, + ), + ), + ), + ] else if (widget.file.fileType == MediaFileTypePB.Image) ...[ + Expanded( + child: _openInteractiveViewer( + context, + file: widget.file, + child: widget.file.uploadType == + MediaUploadTypePB.NetworkMedia + ? Image.network( + widget.file.url, + fit: BoxFit.cover, + alignment: Alignment.centerLeft, + ) + : Image.file( + File(widget.file.url), + fit: BoxFit.cover, + alignment: Alignment.centerLeft, + ), + ), + ), + ] else ...[ + Expanded( + child: GestureDetector( + onTap: () => afLaunchUrlString(widget.file.url), + child: Row( + children: [ + FlowySvg( + widget.file.fileType.icon, + color: AFThemeExtension.of(context).strongText, + size: const Size.square(18), + ), + const HSpace(8), + Flexible( + child: FlowyText( + widget.file.name, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ], + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: AppFlowyPopover( + mutex: widget.mutex, + asBarrier: true, + constraints: const BoxConstraints(maxWidth: 150), + direction: PopoverDirection.bottomWithRightAligned, + popupBuilder: (popoverContext) => BlocProvider.value( + value: context.read(), + child: MediaItemMenu( + file: widget.file, + closeContext: popoverContext, + ), + ), + child: FlowyIconButton( + width: 24, + icon: FlowySvg( + FlowySvgs.three_dots_s, + color: AFThemeExtension.of(context).textColor, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _openInteractiveViewer( + BuildContext context, { + required MediaFilePB file, + required Widget child, + }) => + GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => openInteractiveViewerFromFile( + context, + file, + onDeleteImage: (_) => + context.read().deleteFile(file.id), + userProfile: context.read().state.userProfile, + ), + child: child, + ); +} + +class MediaItemMenu extends StatefulWidget { + const MediaItemMenu({ + super.key, + required this.file, + this.closeContext, + }); + + final MediaFilePB file; + final BuildContext? closeContext; + + @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(4), + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.file.fileType == MediaFileTypePB.Image) ...[ + FlowyButton( + onTap: () => showDialog( + context: widget.closeContext ?? context, + builder: (_) => InteractiveImageViewer( + userProfile: context.read().state.userProfile, + imageProvider: AFBlockImageProvider( + images: [ + ImageBlockData( + url: widget.file.url, + type: widget.file.uploadType.toCustomImageType(), + ), + ], + onDeleteImage: (_) => + context.read().deleteFile(widget.file.id), + ), + ), + ), + leftIcon: FlowySvg( + FlowySvgs.full_view_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(18), + ), + text: FlowyText.regular( + LocaleKeys.settings_files_open.tr(), + color: AFThemeExtension.of(context).textColor, + ), + leftIconSize: const Size(18, 18), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + ), + ], + FlowyButton( + leftIcon: FlowySvg( + FlowySvgs.edit_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(18), + ), + leftIconSize: const Size(18, 18), + text: FlowyText.regular( + LocaleKeys.grid_media_rename.tr(), + color: AFThemeExtension.of(context).textColor, + ), + onTap: () { + nameController.selection = TextSelection( + baseOffset: 0, + extentOffset: nameController.text.length, + ); + + 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), + ); + }, + ), + FlowyButton( + onTap: () async => downloadMediaFile( + context, + widget.file, + userProfile: context.read().state.userProfile, + ), + leftIcon: FlowySvg( + FlowySvgs.download_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(18), + ), + text: FlowyText.regular( + LocaleKeys.button_download.tr(), + color: AFThemeExtension.of(context).textColor, + ), + leftIconSize: const Size(18, 18), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + ), + FlowyButton( + onTap: () => context.read().add( + MediaCellEvent.removeFile( + fileId: widget.file.id, + ), + ), + leftIcon: FlowySvg( + FlowySvgs.delete_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(18), + ), + text: FlowyText.regular( + LocaleKeys.button_delete.tr(), + color: AFThemeExtension.of(context).textColor, + ), + leftIconSize: const Size(18, 18), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + ), + ], + ); + } + + 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(); + } + } +} 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 fb8b11474c221..b8054bb7a79ce 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() => @@ -48,21 +47,8 @@ class _MobileChecklistCellEditScreenState } 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 +58,7 @@ class _MobileChecklistCellEditScreenState ), ), ), - ].map((e) => SizedBox(height: height, child: e)).toList(), + ], ); } } @@ -152,7 +138,7 @@ class _ChecklistItemState extends State<_ChecklistItem> { Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 5), - height: 44, + constraints: const BoxConstraints(minHeight: 44), child: Row( children: [ InkWell( @@ -178,6 +164,8 @@ class _ChecklistItemState extends State<_ChecklistItem> { controller: _textController, focusNode: _focusNode, style: Theme.of(context).textTheme.bodyMedium, + keyboardType: TextInputType.multiline, + maxLines: null, decoration: InputDecoration( border: InputBorder.none, enabledBorder: InputBorder.none, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_media_cell_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_media_cell_editor.dart new file mode 100644 index 0000000000000..825de97d643d0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_media_cell_editor.dart @@ -0,0 +1,376 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/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_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/plugins/document/presentation/editor_plugins/image/multi_image_block_component/image_render.dart'; +import 'package:appflowy/util/xfile_ext.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:cross_file/cross_file.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../document/presentation/editor_plugins/openai/widgets/loading.dart'; + +class MobileMediaCellEditor extends StatelessWidget { + const MobileMediaCellEditor({super.key}); + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + 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: 44.0, + child: Align( + child: FlowyText.medium( + LocaleKeys.grid_field_mediaFieldName.tr(), + fontSize: 18, + ), + ), + ), + const Divider(height: 0.5), + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: FlowyButton( + margin: const EdgeInsets.all(12), + 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: (file) async { + dialogContext.pop(); + + await insertLocalFile( + context, + file, + userProfile: context + .read() + .state + .userProfile, + documentId: + context.read().rowId, + onUploadSuccess: (path, isLocalMode) { + final mediaCellBloc = + context.read(); + if (mediaCellBloc.isClosed) { + return; + } + + mediaCellBloc.add( + MediaCellEvent.addFile( + url: path, + name: file.name, + uploadType: isLocalMode + ? MediaUploadTypePB.LocalMedia + : MediaUploadTypePB.CloudMedia, + fileType: + file.fileType.toMediaFileTypePB(), + ), + ); + }, + ); + }, + onInsertNetworkFile: (url) async => + _onInsertNetworkFile( + url, + dialogContext, + context, + ), + ), + ), + ), + text: const Row( + children: [ + FlowySvg( + FlowySvgs.add_s, + size: Size.square(20), + ), + HSpace(8), + FlowyText('Add a file or image', fontSize: 15), + ], + ), + ), + ), + if (state.files.isNotEmpty) const Divider(height: .5), + ...state.files.map( + (file) => _FileItem(key: Key(file.id), file: file), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + 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: MediaUploadTypePB.NetworkMedia, + fileType: fileType, + ), + ); + } +} + +class _FileItem extends StatelessWidget { + const _FileItem({super.key, required this.file}); + + final MediaFilePB file; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + ListTile( + title: Row( + children: [ + if (file.fileType != MediaFileTypePB.Image) ...[ + 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: 125), + child: GestureDetector( + onTap: () => openInteractiveViewer(context), + child: ImageRender( + userProfile: + context.read().state.userProfile, + fit: BoxFit.fitHeight, + image: ImageBlockData( + url: file.url, + type: file.uploadType.toCustomImageType(), + ), + ), + ), + ), + ), + ], + FlowyIconButton( + width: 40, + icon: const FlowySvg( + FlowySvgs.three_dots_s, + size: Size.square(20), + ), + onPressed: () => showMobileBottomSheet( + context, + showDragHandle: true, + builder: (_) => BlocProvider.value( + value: context.read(), + child: _EditFileSheet(file: file), + ), + ), + ), + const HSpace(6), + ], + ), + ), + const Divider(height: .5), + ], + ); + } + + 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), + _FileTextField( + file: file, + controller: controller, + onChanged: (name) => + context.read().renameFile(file.id, name), + ), + const VSpace(20), + if (file.fileType == MediaFileTypePB.Image) + FlowyOptionTile.text( + text: LocaleKeys.grid_media_open.tr(), + leftIcon: const FlowySvg( + FlowySvgs.full_view_s, + size: Size.square(20), + ), + onTap: () => openInteractiveViewer(context), + ), + FlowyOptionTile.text( + text: file.fileType == MediaFileTypePB.Link + ? LocaleKeys.grid_media_open.tr() + : LocaleKeys.grid_media_download.tr(), + leftIcon: FlowySvg( + file.fileType == MediaFileTypePB.Link + ? FlowySvgs.m_link_m + : FlowySvgs.import_s, + size: const Size.square(20), + ), + onTap: () async => downloadMediaFile( + context, + widget.file, + userProfile: context.read().state.userProfile, + onDownloadBegin: () { + loader?.stop(); + loader = Loading(context); + loader?.start(); + }, + onDownloadEnd: () => loader?.stop(), + ), + ), + 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, + ); +} + +class _FileTextField extends StatelessWidget { + const _FileTextField({ + required this.file, + required this.controller, + required this.onChanged, + }); + + final MediaFilePB file; + final TextEditingController controller; + final void Function(String) onChanged; + + @override + Widget build(BuildContext context) { + return FlowyOptionTile.textField( + controller: controller, + textFieldPadding: const EdgeInsets.symmetric(horizontal: 12), + onTextChanged: onChanged, + leftIcon: Container( + height: 38, + width: 38, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: file.fileType.color, + ), + child: Center( + child: FlowySvg( + file.fileType.icon, + size: const Size.square(22), + ), + ), + ), + ); + } +} 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 3750a9294b128..cba7194a9943c 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'; @@ -176,7 +177,7 @@ class _MobileSelectOptionEditorState extends State { } else { context .read() - .add(SelectOptionCellEditorEvent.unSelectOption(option.id)); + .add(SelectOptionCellEditorEvent.unselectOption(option.id)); } }, onMoreOptions: (option) { 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 63ea44008efe0..599ddeb017592 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,8 +2,15 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/field/type_option/relation_type_option_cubit.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.dart'; +import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy/plugins/database/widgets/row/relation_row_detail.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -126,7 +133,7 @@ class _RelationCellEditorContentState shrinkWrap: true, slivers: [ _CellEditorTitle( - databaseName: widget.relatedDatabaseMeta.databaseName, + databaseMeta: widget.relatedDatabaseMeta, ), _SearchField( focusNode: focusNode, @@ -204,10 +211,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 +230,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 +251,28 @@ class _CellEditorTitle extends StatelessWidget { ), ); } + + void _openRelatedDatbase(BuildContext context) { + FolderEventGetView(ViewIdPB(value: databaseMeta.inlineViewId)) + .send() + .then((result) { + result.fold( + (view) { + PopoverContainer.of(context).closeAll(); + Navigator.of(context).maybePop(); + getIt().add( + TabsEvent.openPlugin( + plugin: DatabaseTabBarViewPlugin( + view: view, + pluginType: view.pluginType, + ), + ), + ); + }, + (err) => Log.error(err), + ); + }); + } } class _SearchField extends StatelessWidget { 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 8fdb99769aec0..8f6f14f714620 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,6 +1,10 @@ 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'; @@ -10,14 +14,11 @@ 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 +74,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 +138,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 +151,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 +251,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 +268,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 +289,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, ), ); } @@ -326,16 +326,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( @@ -382,42 +393,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)); } } } @@ -472,13 +457,14 @@ class SelectOptionTagCell extends StatelessWidget { child: Align( alignment: AlignmentDirectional.centerStart, child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 6.0, - ), + padding: const EdgeInsets.symmetric(horizontal: 6.0), child: SelectOptionTag( + fontSize: 14, option: option, - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), ), ), ), @@ -492,16 +478,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( @@ -537,10 +521,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/database_layout_ext.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/database_layout_ext.dart index 4d79b2d075922..f8118a7e51cab 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 35169550f1c15..c979ee0829f63 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 @@ -56,7 +56,8 @@ class _DatabaseViewWidgetState extends State { shrinkWrap: widget.shrinkWrap, context: PluginContext(), data: { - kDatabasePluginWidgetBuilderHorizontalPadding: 40.0, + kDatabasePluginWidgetBuilderHorizontalPadding: + view.layout == ViewLayoutPB.Grid ? 40.0 : 0.0, }, ), ); 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 ad1214fdbab77..dbcf4b8cb0cc4 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 @@ -31,17 +31,19 @@ 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(); @@ -49,13 +51,13 @@ class FieldEditor extends StatefulWidget { class _FieldEditorState extends State { 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 @@ -69,13 +71,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(), ); } @@ -117,6 +120,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 +144,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 { @@ -158,6 +163,7 @@ class _EditFieldButton extends StatelessWidget { child: FlowyButton( leftIcon: const FlowySvg(FlowySvgs.edit_s), text: FlowyText.medium( + lineHeight: 1.0, LocaleKeys.grid_field_editProperty.tr(), ), onTap: onTap, @@ -193,6 +199,7 @@ class FieldActionCell extends StatelessWidget { disable: !enable, text: FlowyText.medium( action.title(fieldInfo), + lineHeight: 1.0, color: enable ? null : Theme.of(context).disabledColor, ), onHover: (_) => popoverMutex?.close(), @@ -317,32 +324,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 @@ -569,7 +577,10 @@ class _FieldNameTextFieldState extends State { } class SwitchFieldButton extends StatefulWidget { - const SwitchFieldButton({super.key, required this.popoverMutex}); + const SwitchFieldButton({ + super.key, + required this.popoverMutex, + }); final PopoverMutex popoverMutex; @@ -613,6 +624,7 @@ class _SwitchFieldButtonState extends State { }, text: FlowyText.medium( state.field.fieldType.i18n, + lineHeight: 1.0, color: isPrimary ? Theme.of(context).disabledColor : null, ), leftIcon: FlowySvg( 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 84d4c49177ea3..bb56bfa83eaf4 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 @@ -24,6 +24,7 @@ const List _supportedFieldTypes = [ FieldType.Summary, // FieldType.Time, FieldType.Translate, + FieldType.Media, ]; class FieldTypeList extends StatelessWidget with FlowyOverlayDelegate { @@ -75,9 +76,7 @@ class FieldTypeCell extends StatelessWidget { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( - text: FlowyText.medium( - fieldType.i18n, - ), + text: FlowyText.medium(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 e4bcdd4911ef5..624f9f1fb2d76 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 @@ -2,6 +2,7 @@ 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'; @@ -38,6 +39,7 @@ abstract class TypeOptionEditorFactory { 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/date_time_format.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/date/date_time_format.dart index 0040444bb46fe..c04bcab92b1ed 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 @@ -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.medium( + 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.medium( + LocaleKeys.grid_field_timeFormat.tr(), + lineHeight: 1.0, + ), onTap: onTap, onHover: onHover, rightIcon: const FlowySvg(FlowySvgs.more_s), @@ -114,7 +120,10 @@ class DateFormatCell extends StatelessWidget { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( - text: FlowyText.medium(dateFormat.title()), + text: FlowyText.medium( + dateFormat.title(), + lineHeight: 1.0, + ), rightIcon: checkmark, onTap: () => onSelected(dateFormat), ), @@ -199,7 +208,10 @@ class TimeFormatCell extends StatelessWidget { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( - text: FlowyText.medium(timeFormat.title()), + text: FlowyText.medium( + timeFormat.title(), + lineHeight: 1.0, + ), rightIcon: checkmark, onTap: () => onSelected(timeFormat), ), diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/media.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/media.dart new file mode 100644 index 0000000000000..ecd1ba73fb173 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/media.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/plugins/database/widgets/field/type_option_editor/builder.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; +import 'package:appflowy_popover/appflowy_popover.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, + }) => + null; +} 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 244f38326c890..feff29c59eac2 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 @@ -32,6 +32,7 @@ class NumberTypeOptionEditorFactory implements TypeOptionEditorFactory { child: FlowyButton( rightIcon: const FlowySvg(FlowySvgs.more_s), text: FlowyText.medium( + lineHeight: 1.0, typeOption.format.title(), ), ), @@ -167,7 +168,10 @@ class NumberFormatCell extends StatelessWidget { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( - text: FlowyText.medium(format.title()), + text: FlowyText.medium( + 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 9ca2729cb65b3..dc1a6ef5c70be 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 @@ -61,6 +61,7 @@ class RelationTypeOptionEditorFactory implements TypeOptionEditorFactory { (meta) => meta.databaseId == typeOption.databaseId, ); return FlowyText( + lineHeight: 1.0, databaseMeta == null ? LocaleKeys .grid_relation_relatedDatabasePlaceholder @@ -134,6 +135,7 @@ class _DatabaseList extends StatelessWidget { child: FlowyButton( onTap: () => onSelectDatabase(meta.databaseId), text: FlowyText.medium( + 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 4c561218903a6..3c439a0f7afb4 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 @@ -181,6 +181,7 @@ class _AddOptionButton extends StatelessWidget { height: GridSize.popoverItemHeight, child: FlowyButton( text: FlowyText.medium( + lineHeight: 1.0, LocaleKeys.grid_field_addSelectOption.tr(), ), onTap: () { 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 5df44f4b49887..9041b3bc60693 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 @@ -107,6 +107,7 @@ class _DeleteTag extends StatelessWidget { height: GridSize.popoverItemHeight, child: FlowyButton( text: FlowyText.medium( + lineHeight: 1.0, LocaleKeys.grid_selectOption_deleteTag.tr(), ), leftIcon: const FlowySvg(FlowySvgs.delete_s), @@ -230,6 +231,7 @@ class _SelectOptionColorCell extends StatelessWidget { child: FlowyButton( hoverColor: AFThemeExtension.of(context).lightGreyHover, text: FlowyText.medium( + lineHeight: 1.0, color.colorName(), color: AFThemeExtension.of(context).textColor, ), 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 index 4cb5b0d9a33d2..f39a0d83c40a3 100644 --- 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 @@ -97,7 +97,12 @@ class SelectLanguageButton extends StatelessWidget { Widget build(BuildContext context) { return SizedBox( height: 30, - child: FlowyButton(text: FlowyText(language)), + child: FlowyButton( + text: FlowyText( + language, + lineHeight: 1.0, + ), + ), ); } } @@ -159,7 +164,10 @@ class LanguageCell extends StatelessWidget { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( - text: FlowyText.medium(languageTypeToLanguage(languageType)), + text: FlowyText.medium( + 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 ee021d59d37a1..af8b60b8db901 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 @@ -193,6 +193,7 @@ class _GridGroupCell extends StatelessWidget { text: FlowyText.medium( name, color: AFThemeExtension.of(context).textColor, + lineHeight: 1.0, ), leftIcon: icon != null ? FlowySvg( diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/media_file_type_ext.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/media_file_type_ext.dart new file mode 100644 index 0000000000000..d4ae050dfa70d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/media_file_type_ext.dart @@ -0,0 +1,28 @@ +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.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.document_s, + }; + + Color get color => switch (this) { + MediaFileTypePB.Image => const Color(0xFF5465A1), + MediaFileTypePB.Link => const Color(0xFFA35F94), + MediaFileTypePB.Document => const Color(0xFFBAAC74), + MediaFileTypePB.Archive => const Color(0xFF40AAB8), + MediaFileTypePB.Video => const Color(0xFF5465A1), + MediaFileTypePB.Audio => const Color(0xFF5465A1), + MediaFileTypePB.Text => const Color(0xFF87B3A8), + _ => 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 febd6a6749144..437d125f53ec6 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 @@ -2,6 +2,7 @@ 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'; @@ -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( 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 22a9ce13811fe..7f0d850c209bb 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: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({ 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 c9f4a796c0f80..c0cf547a06e28 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,7 +50,10 @@ 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.deleteRows(viewId, [rowId]); @@ -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 e06ebe6b0a632..f2d254b4c3997 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,4 +1,7 @@ import 'dart:ui' as ui; +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'; @@ -6,6 +9,7 @@ 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/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'; @@ -15,8 +19,6 @@ import 'package:appflowy/util/text_direction.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_bloc/flutter_bloc.dart'; const _kBannerActionHeight = 40.0; @@ -229,6 +231,7 @@ class AddEmojiButton extends StatelessWidget { child: FlowyButton( useIntrinsicWidth: true, text: FlowyText.medium( + lineHeight: 1.0, LocaleKeys.document_plugins_cover_addIcon.tr(), ), leftIcon: const FlowySvg(FlowySvgs.emoji_s), @@ -251,6 +254,7 @@ class RemoveEmojiButton extends StatelessWidget { child: FlowyButton( useIntrinsicWidth: true, text: FlowyText.medium( + lineHeight: 1.0, LocaleKeys.document_plugins_cover_removeIcon.tr(), ), leftIcon: const FlowySvg(FlowySvgs.emoji_s), @@ -277,7 +281,7 @@ 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, ), ), ); @@ -298,66 +302,75 @@ class _TitleSkin extends IEditableTextCellSkin { FocusNode focusNode, TextEditingController textEditingController, ) { - return BlocBuilder( - buildWhen: (p, c) { - final cText = c.content.isNotEmpty - ? c.content - : LocaleKeys.grid_row_titlePlaceholder.tr(); - final cTextDirection = getTextDirectionBaseOnContext( - context, - cText, - lastDirection: lastDirection, - ); - - return lastDirection != cTextDirection; + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.escape): () => + focusNode.unfocus(), + const SimpleActivator(LogicalKeyboardKey.enter): () => + focusNode.unfocus(), }, - builder: (context, state) { - final text = textEditingController.text.isNotEmpty - ? textEditingController.text - : LocaleKeys.grid_row_titlePlaceholder.tr(); - lastDirection = getTextDirectionBaseOnContext( - context, - text, - lastDirection: lastDirection, - ); + child: BlocBuilder( + buildWhen: (p, c) { + final cText = c.content.isNotEmpty + ? c.content + : LocaleKeys.grid_row_titlePlaceholder.tr(); + final cTextDirection = getTextDirectionBaseOnContext( + context, + cText, + lastDirection: lastDirection, + ); - return Row( - textDirection: lastDirection, - children: [ - if (emojiWidget != null) emojiWidget!, - const HSpace(4), - Expanded( - child: TextField( - controller: textEditingController, - focusNode: focusNode, - autofocus: true, - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith(fontSize: 28), - textDirection: lastDirection, - 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(), - hintTextDirection: lastDirection, - isDense: true, - isCollapsed: true, + return lastDirection != cTextDirection; + }, + builder: (context, state) { + final text = textEditingController.text.isNotEmpty + ? textEditingController.text + : LocaleKeys.grid_row_titlePlaceholder.tr(); + lastDirection = getTextDirectionBaseOnContext( + context, + text, + lastDirection: lastDirection, + ); + + return Row( + textDirection: lastDirection, + children: [ + if (emojiWidget != null) emojiWidget!, + const HSpace(4), + Expanded( + child: TextField( + controller: textEditingController, + focusNode: focusNode, + autofocus: true, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(fontSize: 28), + maxLines: null, + textDirection: lastDirection, + 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(), + hintTextDirection: lastDirection, + isDense: true, + isCollapsed: true, + ), + onChanged: (text) { + if (textEditingController.value.composing.isCollapsed) { + bloc.add(TextCellEvent.updateText(text)); + } + }, ), - 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 430f81709596f..bdc1812926309 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 @@ -12,7 +12,6 @@ import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_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'; 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 4d58d6fc329ab..415e741ad1603 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,15 +1,14 @@ -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/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/shared/flowy_error_page.dart'; import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/widget/error_page.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class RowDocument extends StatelessWidget { @@ -27,17 +26,22 @@ 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( + finish: () => _RowEditor( viewPB: state.viewPB!, onIsEmptyChanged: (isEmpty) => context .read() @@ -50,9 +54,8 @@ class RowDocument extends StatelessWidget { } } -class RowEditor extends StatefulWidget { - const RowEditor({ - super.key, +class _RowEditor extends StatelessWidget { + const _RowEditor({ required this.viewPB, this.onIsEmptyChanged, }); @@ -60,36 +63,23 @@ class RowEditor extends StatefulWidget { final ViewPB viewPB; 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)], + return BlocProvider( + create: (context) => DocumentBloc(documentId: viewPB.id) + ..add(const DocumentEvent.initial()), child: BlocListener( 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( @@ -101,18 +91,18 @@ class _RowEditorState extends State { 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(), + return Center( + child: AppFlowyErrorPage( + error: error, + ), ); } - return IntrinsicHeight( - child: Container( - constraints: const BoxConstraints(minHeight: 300), - child: BlocProvider( - create: (context) => ViewInfoBloc(view: widget.viewPB), + return BlocProvider( + create: (context) => ViewInfoBloc(view: viewPB), + child: IntrinsicHeight( + child: Container( + constraints: const BoxConstraints(minHeight: 300), child: AppFlowyEditorPage( shrinkWrap: true, autoFocus: false, 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 4a6aa78adb060..300e84961c381 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 @@ -14,7 +14,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'; @@ -130,48 +129,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, @@ -208,52 +170,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), ], @@ -261,6 +183,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 { @@ -294,7 +306,11 @@ class ToggleHiddenFieldsVisibilityButton extends StatelessWidget { return SizedBox( height: 30, child: FlowyButton( - text: FlowyText.medium(text, color: Theme.of(context).hintColor), + text: FlowyText.medium( + text, + lineHeight: 1.0, + color: Theme.of(context).hintColor, + ), hoverColor: AFThemeExtension.of(context).lightGreyHover, leftIcon: RotatedBox( quarterTurns: quarterTurns, @@ -350,7 +366,7 @@ class ToggleHiddenFieldsVisibilityButton extends StatelessWidget { } } -class CreateRowFieldButton extends StatefulWidget { +class CreateRowFieldButton extends StatelessWidget { const CreateRowFieldButton({ super.key, required this.viewId, @@ -360,59 +376,35 @@ class CreateRowFieldButton extends StatefulWidget { final String viewId; final FieldController fieldController; - @override - State createState() => _CreateRowFieldButtonState(); -} - -class _CreateRowFieldButtonState extends State { - final PopoverController popoverController = PopoverController(); - FieldPB? createdField; - @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.medium( + 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 b8d15601418be..1564559eba1ce 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 @@ -80,6 +80,7 @@ class DatabaseViewLayoutCell extends StatelessWidget { child: FlowyButton( hoverColor: AFThemeExtension.of(context).lightGreyHover, text: FlowyText.medium( + 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 4d019704069d1..6b9fc5f90d59f 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 @@ -23,7 +23,7 @@ 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; case DatabaseSettingAction.showGroup: @@ -82,6 +82,7 @@ extension DatabaseSettingActionExtension on DatabaseSettingAction { hoverColor: AFThemeExtension.of(context).lightGreyHover, text: FlowyText.medium( title(), + lineHeight: 1.0, color: AFThemeExtension.of(context).textColor, ), leftIcon: FlowySvg( 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 a72a30f2eaf4f..87c7002e09e4b 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 @@ -150,6 +150,7 @@ class _DatabasePropertyCellState extends State { child: FlowyButton( hoverColor: AFThemeExtension.of(context).lightGreyHover, text: FlowyText.medium( + lineHeight: 1.0, widget.fieldInfo.name, color: AFThemeExtension.of(context).textColor, ), @@ -203,8 +204,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_document/database_document_page.dart b/frontend/appflowy_flutter/lib/plugins/database_document/database_document_page.dart index c08b32d9ec139..65b6542d4ffc9 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 @@ -1,4 +1,3 @@ -import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/row/related_row_detail_bloc.dart'; import 'package:appflowy/plugins/database/grid/application/row/row_detail_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.dart'; @@ -9,14 +8,13 @@ import 'package:appflowy/plugins/document/presentation/banner.dart'; import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; import 'package:appflowy/plugins/document/presentation/editor_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: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'; @@ -83,9 +81,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, + ), ); } 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 37905ac88be67..4005bfcfacbed 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,22 +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/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/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 'database_document_title_bloc.dart'; @@ -48,20 +45,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: _buildRowName(), - 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), + ), + ), ); }, ), @@ -72,16 +65,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(), ]; @@ -90,9 +89,9 @@ 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: () {}, ), ); @@ -181,11 +180,14 @@ class _TitleSkin extends IEditableTextCellSkin { onTap: () {}, text: Row( children: [ - EmojiText( - emoji: state.icon ?? "", - fontSize: 18.0, - ), - const HSpace(2.0), + if (state.icon != null) ...[ + FlowyText.emoji( + state.icon!, + fontSize: 14.0, + figmaLineHeight: 18.0, + ), + const HSpace(4.0), + ], ConstrainedBox( constraints: const BoxConstraints(maxWidth: 180), child: FlowyText.regular( @@ -205,106 +207,6 @@ class _TitleSkin extends IEditableTextCellSkin { } } -enum _ViewTitleBehavior { - editable, - uneditable, -} - -class _ViewTitle extends StatefulWidget { - const _ViewTitle({ - required this.view, - this.behavior = _ViewTitleBehavior.editable, - required this.onUpdated, - }) : maxTitleWidth = 180; - - 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, 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 5f8bf7ca08aec..f35f0ee8f6036 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'; @@ -87,6 +89,8 @@ class DatabaseDocumentTitleBloc viewId: view.id, rowCache: databaseController.rowCache, ); + unawaited(rowController.initialize()); + final primaryFieldId = await FieldBackendService.getPrimaryField(viewId: view.id).fold( (primaryField) => primaryField.id, 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 cc99dd60c5dfd..53b9084fabfc8 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'dart:convert'; +import 'package:flutter/foundation.dart'; + import 'package:appflowy/plugins/document/application/doc_sync_state_listener.dart'; import 'package:appflowy/plugins/document/application/document_awareness_metadata.dart'; import 'package:appflowy/plugins/document/application/document_collab_adapter.dart'; @@ -32,7 +34,6 @@ import 'package:appflowy_editor/appflowy_editor.dart' Position, paragraphNode; import 'package:appflowy_result/appflowy_result.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; 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 7e5e4eb528013..b6352b0430222 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 @@ -39,6 +39,10 @@ class DocumentCollaboratorsBloc if (userProfile != null) { _listener.start( onDocAwarenessUpdate: (states) { + if (isClosed) { + return; + } + add( DocumentCollaboratorsEvent.update( userProfile, 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 5118620d98852..57b86ce2d48c9 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_service.dart @@ -117,17 +117,19 @@ class DocumentService { required String documentId, }) async { final workspace = await FolderEventReadCurrentWorkspace().send(); - return workspace.fold((l) async { - final payload = UploadFileParamsPB( - workspaceId: l.id, - localFilePath: localFilePath, - documentId: documentId, - ); - 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. 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 f69d116df5185..172c3b2bc9124 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 @@ -18,7 +18,8 @@ import 'package:appflowy_editor/appflowy_editor.dart' Node, Path, Delta, - composeAttributes; + composeAttributes, + blockComponentDelta; import 'package:collection/collection.dart'; import 'package:nanoid/nanoid.dart'; @@ -81,6 +82,15 @@ class TransactionAdapter { } final blockActions = actions.map((e) => e.blockActionPB).toList(growable: false); + + for (final action in blockActions) { + if (enableDocumentInternalLog) { + Log.debug( + '[editor_transaction_adapter] action => ${action.toProto3Json()}', + ); + } + } + await documentService.applyAction( documentId: documentId, actions: blockActions, @@ -164,6 +174,7 @@ extension on InsertOperation { childrenId: nanoid(6), externalId: textId, externalType: textId != null ? _kExternalTextType : null, + attributes: {...node.attributes}..remove(blockComponentDelta), ) ..parentId = parentId ..prevId = prevId; @@ -234,10 +245,13 @@ extension on UpdateOperation { ) : null; + final composedAttributes = composeAttributes(oldAttributes, attributes); + composedAttributes?.remove(blockComponentDelta); + final payload = BlockActionPayloadPB() ..block = node.toBlock( parentId: parentId, - attributes: composeAttributes(oldAttributes, attributes), + attributes: composedAttributes, ) ..parentId = parentId; final blockActionPB = BlockActionPB() diff --git a/frontend/appflowy_flutter/lib/plugins/document/document.dart b/frontend/appflowy_flutter/lib/plugins/document/document.dart index aa0154bf92cb0..ec4dde94ae513 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/document.dart @@ -2,6 +2,7 @@ library document_plugin; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/plugins/document/document_page.dart'; import 'package:appflowy/plugins/document/presentation/document_collaborators.dart'; @@ -118,6 +119,8 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder } }); + final fixedTitle = data?[MobileDocumentScreen.viewFixedTitle]; + return BlocProvider.value( value: bloc, child: BlocBuilder( @@ -126,6 +129,7 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder view: view, onDeleted: () => context.onDeleted?.call(view, deletedViewIndex), initialSelection: initialSelection, + fixedTitle: fixedTitle, ), ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart index 097a23e5c9fec..de1a230ec7d4d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart @@ -1,14 +1,20 @@ 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_bloc.dart'; import 'package:appflowy/plugins/document/presentation/banner.dart'; +import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart'; import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/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/cover/document_immersive_cover.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/multi_image_block_component/multi_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/shared/flowy_error_page.dart'; +import 'package:appflowy/shared/patterns/file_type_patterns.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'; @@ -16,9 +22,17 @@ 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' hide Log; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/widget/error_page.dart'; +import 'package:cross_file/cross_file.dart'; +import 'package:desktop_drop/desktop_drop.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; + +const _excludeFromDropTarget = [ + ImageBlockKeys.type, + CustomImageBlockKeys.type, + MultiImageBlockKeys.type, + FileBlockKeys.type, +]; class DocumentPage extends StatefulWidget { const DocumentPage({ @@ -26,11 +40,13 @@ class DocumentPage extends StatefulWidget { required this.view, required this.onDeleted, this.initialSelection, + this.fixedTitle, }); final ViewPB view; final VoidCallback onDeleted; final Selection? initialSelection; + final String? fixedTitle; @override State createState() => _DocumentPageState(); @@ -69,46 +85,67 @@ class _DocumentPageState extends State @override Widget build(BuildContext context) { - return MultiBlocProvider( - providers: [ - BlocProvider.value(value: getIt()), - BlocProvider.value(value: documentBloc), - ], - child: BlocBuilder( - builder: (context, state) { - if (state.isLoading) { - return const Center(child: CircularProgressIndicator.adaptive()); - } + return ChangeNotifierProvider( + // 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 + // + create: (_) => EditorDropManagerState(), + child: MultiBlocProvider( + providers: [ + BlocProvider.value(value: getIt()), + BlocProvider.value(value: documentBloc), + ], + child: BlocBuilder( + buildWhen: _shouldRebuildDocument, + builder: (context, state) { + if (state.isLoading) { + return const Center(child: CircularProgressIndicator.adaptive()); + } - final editorState = state.editorState; - this.editorState = editorState; - final error = state.error; - if (error != null || editorState == null) { - Log.error(error); - return 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 BlocListener( + listenWhen: (_, curr) => curr.action != null, + listener: _onNotificationAction, + child: Consumer( + builder: (context, dropState, _) => + _buildEditorPage(context, state, dropState), + ), + ); + }, + ), ), ); } - Widget _buildEditorPage(BuildContext context, DocumentState state) { + Widget _buildEditorPage( + BuildContext context, + DocumentState state, + EditorDropManagerState dropState, + ) { final Widget child; - if (PlatformExtension.isMobile) { child = BlocBuilder( builder: (context, styleState) { @@ -125,15 +162,78 @@ class _DocumentPageState extends State }, ); } else { - child = AppFlowyEditorPage( - editorState: state.editorState!, - styleCustomizer: EditorStyleCustomizer( - context: context, - // the 44 is the width of the left action list - padding: EditorStyleCustomizer.documentPadding, + child = DropTarget( + enable: dropState.isDropEnabled, + onDragExited: (_) => + state.editorState!.selectionService.removeDropTarget(), + onDragUpdated: (details) { + final data = state.editorState!.selectionService + .getDropTargetRenderData(details.globalPosition); + + if (data != null && + data.dropTarget != 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 + state.editorState!.selectionService + .renderDropTargetForOffset(details.globalPosition); + } else { + state.editorState!.selectionService.removeDropTarget(); + } + }, + onDragDone: (details) async { + state.editorState!.selectionService.removeDropTarget(); + + final data = state.editorState!.selectionService + .getDropTargetRenderData(details.globalPosition); + + if (data != null) { + if (data.cursorNode != null) { + if (_excludeFromDropTarget.contains(data.cursorNode?.type)) { + return; + } + + final isLocalMode = context.read().isLocalMode; + final List imageFiles = []; + final List otherFiles = []; + + for (final file in details.files) { + final fileName = file.name.toLowerCase(); + if (file.mimeType?.startsWith('image/') ?? + false || imgExtensionRegex.hasMatch(fileName)) { + imageFiles.add(file); + } else { + otherFiles.add(file); + } + } + + await editorState!.dropImages( + data.dropTarget!, + imageFiles, + widget.view.id, + isLocalMode, + ); + await editorState!.dropFiles( + data.dropTarget!, + otherFiles, + widget.view.id, + isLocalMode, + ); + } + } + }, + child: 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, ), - header: _buildCoverAndIcon(context, state), - initialSelection: widget.initialSelection, ); } @@ -165,6 +265,7 @@ class _DocumentPageState extends State if (PlatformExtension.isMobile) { return DocumentImmersiveCover( + fixedTitle: widget.fixedTitle, view: widget.view, userProfilePB: userProfilePB, ); @@ -212,4 +313,31 @@ class _DocumentPageState extends State } } } + + 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; + } } 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 1e96e5648b0f0..42d05c0f9ea96 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/document_collaborators.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/document_collaborators.dart @@ -5,7 +5,6 @@ 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:string_validator/string_validator.dart'; 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 3cc466a44a79c..c4fa7e134c71a 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart @@ -1,9 +1,11 @@ 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/image/custom_image_block_component/custom_image_block_component.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/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -22,6 +24,7 @@ Map getEditorBuilderMap({ bool editable = true, ShowPlaceholder? showParagraphPlaceholder, String Function(Node)? placeholderText, + EdgeInsets? customHeadingPadding, }) { final standardActions = [OptionAction.delete, OptionAction.duplicate]; @@ -82,6 +85,10 @@ Map getEditorBuilderMap({ HeadingBlockKeys.type: HeadingBlockComponentBuilder( configuration: configuration.copyWith( padding: (node) { + if (customHeadingPadding != null) { + return customHeadingPadding; + } + if (PlatformExtension.isMobile) { final pageStyle = context.read().state; final factor = pageStyle.fontLayout.factor; @@ -107,13 +114,33 @@ Map getEditorBuilderMap({ ImageBlockKeys.type: CustomImageBlockComponentBuilder( configuration: configuration, showMenu: true, - menuBuilder: (Node node, CustomImageBlockComponentState state) => - Positioned( + menuBuilder: (node, state) => Positioned( top: 10, right: 10, child: ImageMenu(node: node, state: state), ), ), + MultiImageBlockKeys.type: 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, + ), + ), + ), TableBlockKeys.type: TableBlockComponentBuilder( menuBuilder: (node, editorState, position, dir, onBuild, onClose) => TableMenu( @@ -163,9 +190,9 @@ Map getEditorBuilderMap({ ), CalloutBlockKeys.type: CalloutBlockComponentBuilder( configuration: configuration.copyWith( - textStyle: (_) => styleCustomizer.calloutBlockStyleBuilder(), - placeholderTextStyle: (_) => styleCustomizer.calloutBlockStyleBuilder(), + padding: (node) => const EdgeInsets.symmetric(vertical: 10), ), + inlinePadding: const EdgeInsets.symmetric(vertical: 8.0), defaultColor: calloutBGColor, ), DividerBlockKeys.type: DividerBlockComponentBuilder( @@ -226,6 +253,7 @@ Map getEditorBuilderMap({ imageUrl: imageUrl, ), ), + FileBlockKeys.type: FileBlockComponentBuilder(configuration: configuration), errorBlockComponentBuilderKey: ErrorBlockComponentBuilder( configuration: configuration, ), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_drop_manager.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_drop_manager.dart new file mode 100644 index 0000000000000..728dee766d4e2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_drop_manager.dart @@ -0,0 +1,17 @@ +import 'package:flutter/widgets.dart'; + +class EditorDropManagerState extends ChangeNotifier { + final Set _draggedTypes = {}; + + void add(String type) { + _draggedTypes.add(type); + notifyListeners(); + } + + void remove(String type) { + _draggedTypes.remove(type); + notifyListeners(); + } + + bool get isDropEnabled => _draggedTypes.isEmpty; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart index bf2ad68e1696a..f5be9246b3a38 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -9,7 +9,6 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/base/forma 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/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/date_reference.dart'; @@ -130,8 +129,8 @@ class _AppFlowyEditorPageState extends State { final List toolbarItems = [ smartEditItem..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable, paragraphItem..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable, - ...headingItems - ..forEach((e) => e.isActive = onlyShowInSingleSelectionAndTextType), + headingsToolbarItem + ..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable, ...markdownFormatItems..forEach((e) => e.isActive = showInAnyTextType), quoteItem..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable, bulletedListItem @@ -146,7 +145,7 @@ class _AppFlowyEditorPageState extends State { customizeFontToolbarItem, ]; - late final List slashMenuItems; + late List slashMenuItems; List get characterShortcutEvents => [ // code block @@ -155,6 +154,9 @@ class _AppFlowyEditorPageState extends State { // callout block insertNewLineInCalloutBlock, + // quote block + insertNewLineInQuoteBlock, + // toggle list formatGreaterToToggleList, insertChildNodeInsideToggleList, @@ -282,9 +284,17 @@ class _AppFlowyEditorPageState extends State { focusManager = currFocusManager; focusManager?.loseFocusNotifier.addListener(_loseFocus); } + super.didChangeDependencies(); } + @override + void reassemble() { + super.reassemble(); + + slashMenuItems = _customSlashMenuItems(); + } + @override void dispose() { focusManager?.loseFocusNotifier.removeListener(_loseFocus); @@ -352,6 +362,10 @@ class _AppFlowyEditorPageState extends State { }, child: VSpace(PlatformExtension.isDesktopOrWeb ? 200 : 400), ), + dropTargetStyle: AppFlowyDropTargetStyle( + color: Theme.of(context).colorScheme.primary.withOpacity(0.8), + margin: const EdgeInsets.only(left: 44), + ), ), ); @@ -383,38 +397,48 @@ class _AppFlowyEditorPageState extends State { 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, + aiWriterSlashMenuItem, + textSlashMenuItem, + heading1SlashMenuItem, + heading2SlashMenuItem, + heading3SlashMenuItem, + imageSlashMenuItem, + bulletedListSlashMenuItem, + numberedListSlashMenuItem, + todoListSlashMenuItem, + dividerSlashMenuItem, + quoteSlashMenuItem, + tableSlashMenuItem, + referencedDocSlashMenuItem, + gridSlashMenuItem(documentBloc), + referencedGridSlashMenuItem, + kanbanSlashMenuItem(documentBloc), + referencedKanbanSlashMenuItem, + calendarSlashMenuItem(documentBloc), + referencedCalendarSlashMenuItem, + calloutSlashMenuItem, + outlineSlashMenuItem, + mathEquationSlashMenuItem, + codeBlockSlashMenuItem, + toggleListSlashMenuItem, + emojiSlashMenuItem, + dateOrReminderSlashMenuItem, + photoGallerySlashMenuItem, + fileSlashMenuItem, ]; } @@ -475,8 +499,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(); @@ -507,6 +535,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, 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 b58b0a5646484..6d01ed5f1b5e9 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 e6a88bc4a8bcb..0822d04db7d14 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 @@ -21,7 +21,6 @@ class BlockActionButton extends StatelessWidget { Widget build(BuildContext context) { return Align( child: FlowyTooltip( - preferBelow: false, richMessage: richMessage, child: MouseRegion( cursor: Platform.isWindows 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 a5617a55586ff..ed1295656d42c 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 @@ -4,9 +4,11 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/bl import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option_action.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_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_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -63,15 +65,18 @@ class BlockOptionButton extends StatelessWidget { }, onSelected: (action, controller) { if (action is OptionActionWrapper) { - _onSelectAction(action.inner); + _onSelectAction(context, action.inner); controller.close(); } }, - buildChild: (controller) => _buildOptionButton(controller), + buildChild: (controller) => _buildOptionButton(context, controller), ); } - Widget _buildOptionButton(PopoverController controller) { + Widget _buildOptionButton( + BuildContext context, + PopoverController controller, + ) { return BlockActionButton( svg: FlowySvgs.drag_element_s, richMessage: TextSpan( @@ -79,9 +84,11 @@ class BlockOptionButton extends StatelessWidget { TextSpan( // todo: customize the color to highlight the text. text: LocaleKeys.document_plugins_optionAction_click.tr(), + style: context.tooltipTextStyle(), ), TextSpan( text: LocaleKeys.document_plugins_optionAction_toOpenMenu.tr(), + style: context.tooltipTextStyle(), ), ], ), @@ -115,7 +122,7 @@ class BlockOptionButton extends StatelessWidget { ); } - void _onSelectAction(OptionAction action) { + void _onSelectAction(BuildContext context, OptionAction action) { final node = blockComponentContext.node; final transaction = editorState.transaction; switch (action) { @@ -123,10 +130,7 @@ class BlockOptionButton extends StatelessWidget { transaction.deleteNode(node); break; case OptionAction.duplicate: - transaction.insertNode( - node.path.next, - node.copyWith(), - ); + _duplicateBlock(context, transaction, node); break; case OptionAction.turnInto: break; @@ -144,4 +148,102 @@ class BlockOptionButton extends StatelessWidget { } editorState.apply(transaction); } + + void _duplicateBlock( + BuildContext context, + Transaction transaction, + Node node, + ) { + // 1. verify the node integrity + final type = node.type; + final builder = + context.read().renderer.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'); + } + + // 2. duplicate the node + // the _copyBlock will fix the table block + final newNode = _copyBlock(context, node); + + // 3. insert the node to the next of the current node + transaction.insertNode( + node.path.next, + newNode, + ); + } + + Node _copyBlock(BuildContext context, Node node) { + Node copiedNode = node.copyWith(); + + final type = node.type; + final builder = + context.read().renderer.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); + } + } + } + + 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.copyWith()); + } else { + newChildren.add( + tableCellNode('', i, j), + ); + } + } + } + + return node.copyWith( + children: newChildren, + attributes: { + ...node.attributes, + TableBlockKeys.colsLen: colsLen, + TableBlockKeys.rowsLen: rowsLen, + }, + ); + } } 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 a20618f96106d..8d15c0e6be5ae 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 @@ -4,18 +4,18 @@ 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 +38,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 +86,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 +104,12 @@ class _AlignmentButtonsState extends State<_AlignmentButtons> { onClose: () { keepEditorFocusNotifier.decrease(); }, - child: widget.child, + child: FlowyButton( + useIntrinsicWidth: true, + text: widget.child, + hoverColor: Colors.grey.withOpacity(0.3), + onTap: () => controller.show(), + ), ); } } @@ -117,7 +124,7 @@ class _AlignButtons extends StatelessWidget { @override Widget build(BuildContext context) { return SizedBox( - height: 32, + height: 28, child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -159,17 +166,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.withOpacity(0.3), + onTap: onTap, + text: FlowyTooltip( + message: tooltips, + child: FlowySvg( + icon, + size: const Size.square(16), + color: Colors.white, ), ), ); @@ -182,7 +188,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/base/emoji_picker_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart index 2a1101794a6ec..69e026f43f5f5 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,5 +1,5 @@ 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/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; @@ -19,6 +19,8 @@ class EmojiPickerButton extends StatelessWidget { this.direction, this.title, this.showBorder = true, + this.enable = true, + this.margin, }); final String emoji; @@ -31,6 +33,8 @@ class EmojiPickerButton extends StatelessWidget { final PopoverDirection? direction; final String? title; final bool showBorder; + final bool enable; + final EdgeInsets? margin; @override Widget build(BuildContext context) { @@ -42,6 +46,7 @@ class EmojiPickerButton extends StatelessWidget { height: emojiPickerSize.height, ), offset: offset, + margin: EdgeInsets.zero, direction: direction ?? PopoverDirection.rightWithTopAligned, popupBuilder: (_) => Container( width: emojiPickerSize.width, @@ -71,30 +76,34 @@ class EmojiPickerButton extends StatelessWidget { text: emoji.isEmpty && defaultIcon != null ? defaultIcon! : FlowyText.emoji(emoji, fontSize: emojiSize), - onTap: popoverController.show, + onTap: enable ? popoverController.show : null, ), ), ); } - 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 FlowyButton( + useIntrinsicWidth: true, + margin: + margin ?? const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0), + text: FlowyText.emoji( + emoji, + fontSize: emojiSize, + optimizeEmojiAlign: true, + ), + onTap: enable + ? () async { + final result = await context.push( + Uri( + path: MobileEmojiPickerScreen.routeName, + queryParameters: {MobileEmojiPickerScreen.pageTitle: title}, + ).toString(), + ); + if (result != null) { + onSubmitted(result.emoji, null); + } + } + : null, ); } } 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 c56fbd09e94ef..b4447e1f01279 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 @@ -53,7 +53,10 @@ class SelectableItem extends StatelessWidget { return SizedBox( height: 32, child: FlowyButton( - text: FlowyText.medium(item), + text: FlowyText.medium( + item, + lineHeight: 1.0, + ), rightIcon: isSelected ? const FlowySvg(FlowySvgs.check_s) : null, 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 8f3e4b7477324..f1b082e0a7705 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 532fc5e434d9c..254c3d53bfe1b 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/block_menu/block_menu_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart index 120343a277da4..735b6b15df8fe 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/bulleted_list/bulleted_list_icon.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/bulleted_list/bulleted_list_icon.dart index abb166ec12890..43b84ddc1c78b 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,22 +29,25 @@ 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 = PlatformExtension.isMobile - ? context.read().state.iconPadding - : 0.0; + 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, + constraints: BoxConstraints( + minWidth: size, + minHeight: size, ), - margin: EdgeInsets.only(top: iconPadding, right: 8.0), + 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 a7bfe2da41f28..d17f48e246be4 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 @@ -68,9 +68,11 @@ class CalloutBlockComponentBuilder extends BlockComponentBuilder { CalloutBlockComponentBuilder({ super.configuration, required this.defaultColor, + required this.inlinePadding, }); final Color defaultColor; + final EdgeInsets inlinePadding; @override BlockComponentWidget build(BlockComponentContext blockComponentContext) { @@ -79,6 +81,7 @@ class CalloutBlockComponentBuilder extends BlockComponentBuilder { key: node.key, node: node, defaultColor: defaultColor, + inlinePadding: inlinePadding, configuration: configuration, showActions: showActions(node), actionBuilder: (context, state) => actionBuilder( @@ -105,9 +108,11 @@ class CalloutBlockComponentWidget extends BlockComponentStatefulWidget { super.actionBuilder, super.configuration = const BlockComponentConfiguration(), required this.defaultColor, + required this.inlinePadding, }); final Color defaultColor; + final EdgeInsets inlinePadding; @override State createState() => @@ -176,6 +181,7 @@ class _CalloutBlockComponentWidgetState borderRadius: const BorderRadius.all(Radius.circular(8.0)), color: backgroundColor, ), + padding: widget.inlinePadding, width: double.infinity, alignment: alignment, child: Row( @@ -183,29 +189,25 @@ class _CalloutBlockComponentWidgetState mainAxisSize: MainAxisSize.min, textDirection: textDirection, children: [ + if (PlatformExtension.isDesktopOrWeb) const HSpace(4.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, - emojiSize: 16.0, - onSubmitted: (emoji, controller) { - setEmoji(emoji); - controller?.close(); - }, - ), + EmojiPickerButton( + key: ValueKey( + emoji.toString(), + ), // force to refresh the popover state + enable: editorState.editable, + title: '', + emoji: emoji, + emojiSize: 15.0, + onSubmitted: (emoji, controller) { + setEmoji(emoji); + controller?.close(); + }, ), + if (PlatformExtension.isDesktopOrWeb) const HSpace(4.0), Flexible( child: Padding( - padding: const EdgeInsets.symmetric(vertical: 6.0), + padding: const EdgeInsets.symmetric(vertical: 4.0), child: buildCalloutBlockComponent(context, textDirection), ), ), @@ -247,24 +249,21 @@ 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, + textSpanDecorator: (textSpan) => textSpan.updateTextStyle( + textStyle, + ), + placeholderTextSpanDecorator: (textSpan) => textSpan.updateTextStyle( + placeholderTextStyle, ), + textDirection: textDirection, + cursorColor: editorState.editorStyle.cursorColor, + selectionColor: editorState.editorStyle.selectionColor, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_menu_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_menu_item.dart new file mode 100644 index 0000000000000..c4e395f22f5ab --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_menu_item.dart @@ -0,0 +1,18 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:easy_localization/easy_localization.dart'; + +final codeBlockSelectionMenuItem = SelectionMenuItem.node( + getName: () => LocaleKeys.document_selectionMenu_codeBlock.tr(), + iconBuilder: (editorState, onSelected, style) => SelectableSvgWidget( + data: FlowySvgs.icon_code_block_s, + isSelected: onSelected, + style: style, + ), + keywords: ['code', 'codeblock'], + nodeBuilder: (_, __) => codeBlockNode(), + replace: (_, node) => node.delta?.isEmpty ?? false, +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart index e188e6b29f265..1dde980f03fff 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 @@ -40,6 +40,13 @@ class ClipboardServiceData { } 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; @@ -81,6 +88,10 @@ class ClipboardService { } Future getData() async { + if (_mockData != null) { + return _mockData!; + } + final reader = await SystemClipboard.instance?.read(); if (reader == null) { @@ -89,9 +100,7 @@ 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); @@ -104,6 +113,8 @@ 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( 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 e7f6fba1812de..a2f4442bd00fc 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,4 +1,3 @@ -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_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; 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 ff0957a318031..2e0cef8b8b6bc 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,12 +1,12 @@ 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/copy_and_paste/editor_state_paste_node_extension.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/startup/startup.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -42,6 +42,7 @@ CommandShortcutEventHandler _pasteCommandHandler = (editorState) { // paste as link preview if (await _pasteAsLinkPreview(editorState, plainText)) { + Log.info('Pasted as link preview'); return; } @@ -53,20 +54,16 @@ CommandShortcutEventHandler _pasteCommandHandler = (editorState) { // try to paste the content in order, if any of them is failed, then try the next one if (inAppJson != null && inAppJson.isNotEmpty) { - debugPrint('paste in app json: $inAppJson'); await editorState.deleteSelectionIfNeeded(); if (await editorState.pasteInAppJson(inAppJson)) { + Log.info('Pasted in app json'); return; } } - if (html != null && html.isNotEmpty) { - await editorState.deleteSelectionIfNeeded(); - if (await editorState.pasteHtml(html)) { - return; - } - } - + // 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(); @@ -79,13 +76,24 @@ CommandShortcutEventHandler _pasteCommandHandler = (editorState) { image.$1, image.$2!, documentId, + selection: selection, ); if (result) { + Log.info('Pasted image'); + return; + } + } + + if (html != null && html.isNotEmpty) { + await editorState.deleteSelectionIfNeeded(); + if (await editorState.pasteHtml(html)) { + Log.info('Pasted html'); return; } } if (plainText != null && plainText.isNotEmpty) { + Log.info('Pasted plain text'); await editorState.pastePlainText(plainText); } }(); 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 6cee6f1db7fd6..0000000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart +++ /dev/null @@ -1,188 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; - -final _listTypes = [ - BulletedListBlockKeys.type, - TodoListBlockKeys.type, - NumberedListBlockKeys.type, -]; - -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 (_listTypes.contains(node.type)) { - final convertedNode = insertedNode.copyWith(type: node.type); - final path = selection.start.path; - transaction - ..insertNode(path, convertedNode) - ..deleteNodesAtPath(path); - - // Set the afterSelection to the last child of the inserted node - final lastChildPath = calculatePath(path, [convertedNode]); - final lastChildOffset = calculateLength([convertedNode]); - transaction.afterSelection = Selection.collapsed( - Position(path: lastChildPath, offset: lastChildOffset), - ); - } 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_file.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_file.dart new file mode 100644 index 0000000000000..1ca77a96d8aee --- /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( + Node dropNode, + 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( + dropNode.path, + 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 e30bcf8a5dfdb..87259d598132b 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,4 +1,3 @@ -import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; extension PasteFromHtml on EditorState { 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 c6c8d2162c77c..b779735252f57 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,12 +3,16 @@ 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/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: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; @@ -19,23 +23,68 @@ extension PasteFromImage on EditorState { 'png', 'jpeg', 'gif', + 'webp', ]; - Future pasteImage( - String format, - Uint8List imageBytes, + Future dropImages( + Node dropNode, + List files, String documentId, + bool isLocalMode, ) async { - if (!supportedImageFormats.contains(format)) { - return false; + final imageFiles = files.where( + (file) => + file.mimeType?.startsWith('image/') ?? + false || imgExtensionRegex.hasMatch(file.name.toLowerCase()), + ); + + for (final file in imageFiles) { + String? path; + CustomImageType? type; + if (isLocalMode) { + path = await saveImageToLocalStorage(file.path); + type = CustomImageType.local; + } else { + (path, _) = await saveImageToCloudStorage(file.path, documentId); + type = CustomImageType.internal; + } + + if (path == null) { + continue; + } + + final t = transaction + ..insertNode( + dropNode.path, + 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 (!supportedImageFormats.contains(format)) { + Log.info('unsupported format: $format'); + if (PlatformExtension.isMobile) { + showToastNotification( + context, + message: LocaleKeys.document_imageBlock_error_invalidImageFormat.tr(), + ); + } + return false; + } + final isLocalMode = context.read().isLocalMode; final path = await getIt().getPath(); @@ -65,9 +114,9 @@ extension PasteFromImage on EditorState { final errorMessage = result.$2; if (errorMessage != null && context.mounted) { - showSnackBarMessage( + showToastNotification( context, - errorMessage, + message: errorMessage, ); return false; } @@ -76,7 +125,7 @@ extension PasteFromImage on EditorState { } if (path != null) { - await insertImageNode(path); + await insertImageNode(path, selection: selection); } await File(copyToPath).delete(); @@ -84,13 +133,55 @@ extension PasteFromImage on EditorState { } catch (e) { Log.error('cannot copy image file', e); if (context.mounted) { - showSnackBarMessage( + showToastNotification( context, - LocaleKeys.document_imageBlock_error_invalidImage.tr(), + message: LocaleKeys.document_imageBlock_error_invalidImage.tr(), ); } } return false; } + + Future insertImageNode( + String src, { + Selection? selection, + }) 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, + imageNode( + url: src, + ), + ) + ..deleteNode(node); + } else { + transaction.insertNode( + node.path.next, + imageNode( + url: src, + ), + ); + } + + 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 4cc17d599b33f..00a2a6c2f1b18 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,6 +1,5 @@ import 'dart:convert'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide Log; 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 114dde62a3759..b09c8c0dc4c6e 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,4 +1,3 @@ -import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart'; import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; 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 43539cea96456..3c04ce4301ff9 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 @@ -34,10 +34,12 @@ class DocumentImmersiveCover extends StatefulWidget { super.key, required this.view, required this.userProfilePB, + this.fixedTitle, }); final ViewPB view; final UserProfilePB userProfilePB; + final String? fixedTitle; @override State createState() => _DocumentImmersiveCoverState(); @@ -90,19 +92,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, + ), ), - ), - ], + ], + ), ); }, ), @@ -140,6 +145,18 @@ class _DocumentImmersiveCoverState extends State { fontFamily = getGoogleFontSafely(documentFontFamily).fontFamily; } + if (widget.fixedTitle != null) { + return FlowyText( + widget.fixedTitle!, + fontSize: 28.0, + fontWeight: FontWeight.w700, + fontFamily: fontFamily, + color: + state.cover.isNone || state.cover.isPresets ? null : Colors.white, + overflow: TextOverflow.ellipsis, + ); + } + return AutoSizeTextField( controller: textEditingController, focusNode: focusNode, 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 d780a1260e420..f3292226aaff6 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 @@ -84,7 +84,7 @@ class _DatabaseBlockComponentWidgetState child: FocusScope( skipTraversal: true, onFocusChange: (value) { - if (value) { + if (value && keepEditorFocusNotifier.value == 0) { context.read().selection = null; } }, 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 ba6f01e9088d5..92c19db47b59b 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,10 +1,11 @@ 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'; @@ -59,25 +60,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.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: PlatformExtension.isDesktopOrWeb - ? _buildDesktopErrorBlock(context) - : _buildMobileErrorBlock(context), - ), + child: PlatformExtension.isDesktopOrWeb + ? _buildDesktopErrorBlock(context) + : _buildMobileErrorBlock(context), ); child = Padding( @@ -107,40 +98,61 @@ class _ErrorBlockComponentWidgetState extends State Widget _buildDesktopErrorBlock(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 12), - child: Wrap( - crossAxisAlignment: WrapCrossAlignment.center, + child: Row( children: [ - const HSpace(4), + const HSpace(12), FlowyText.regular( - LocaleKeys.document_errorBlock_theBlockIsNotSupported.tr(), + LocaleKeys.document_errorBlock_parseError.tr(args: [node.type]), ), - const HSpace(4), - FlowyText.regular( - '(${LocaleKeys.document_errorBlock_clickToCopyTheBlockContent.tr()})', - color: Theme.of(context).hintColor, + const Spacer(), + OutlinedRoundedButton( + text: LocaleKeys.document_errorBlock_copyBlockContent.tr(), + onTap: _copyBlockContent, ), + const HSpace(12), ], ), ); } Widget _buildMobileErrorBlock(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FlowyText.regular( - LocaleKeys.document_errorBlock_theBlockIsNotSupported.tr(), - ), - const VSpace(6), - FlowyText.regular( - '(${LocaleKeys.document_errorBlock_clickToCopyTheBlockContent.tr()})', - color: Theme.of(context).hintColor, - fontSize: 12.0, - ), - ], + return AnimatedGestureDetector( + onTapUp: _copyBlockContent, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 4.0, right: 24.0), + child: FlowyText.regular( + LocaleKeys.document_errorBlock_parseError.tr(args: [node.type]), + maxLines: 3, + ), + ), + const VSpace(6), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: FlowyText.regular( + '(${LocaleKeys.document_errorBlock_clickToCopyTheBlockContent.tr()})', + color: Theme.of(context).hintColor, + fontSize: 12.0, + ), + ), + ], + ), ), ); } + + void _copyBlockContent() { + showToastNotification( + context, + message: LocaleKeys.document_errorBlock_blockContentHasBeenCopied.tr(), + ); + + getIt().setData( + ClipboardServiceData(plainText: jsonEncode(node.toJson())), + ); + } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block.dart new file mode 100644 index 0000000000000..31ead6370c946 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block.dart @@ -0,0 +1,2 @@ +export './file_block_component.dart'; +export './file_selection_menu.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart new file mode 100644 index 0000000000000..0e3a83ba7c501 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart @@ -0,0 +1,503 @@ +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/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/file/file_util.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: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:provider/provider.dart'; +import 'package:string_validator/string_validator.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'; +} + +enum FileUrlType { + local, + network, + cloud; + + static FileUrlType fromIntValue(int value) { + switch (value) { + case 0: + return FileUrlType.local; + case 1: + return FileUrlType.network; + case 2: + return FileUrlType.cloud; + default: + throw UnimplementedError(); + } + } + + int toIntValue() { + switch (this) { + case FileUrlType.local: + return 0; + case FileUrlType.network: + return 1; + case FileUrlType.cloud: + return 2; + } + } +} + +Node fileNode({ + required String url, + FileUrlType type = FileUrlType.local, + String? name, +}) { + return Node( + type: FileBlockKeys.type, + attributes: { + FileBlockKeys.url: url, + FileBlockKeys.urlType: type.toIntValue(), + FileBlockKeys.name: name, + FileBlockKeys.uploadedAt: DateTime.now().millisecondsSinceEpoch, + }, + ); +} + +class FileBlockComponentBuilder extends BlockComponentBuilder { + FileBlockComponentBuilder({super.configuration}); + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + return FileBlockComponent( + key: node.key, + node: node, + showActions: showActions(node), + configuration: configuration, + actionBuilder: (_, state) => actionBuilder(blockComponentContext, state), + ); + } + + @override + bool validate(Node node) => node.delta == null && node.children.isEmpty; +} + +class FileBlockComponent extends BlockComponentStatefulWidget { + const FileBlockComponent({ + super.key, + required super.node, + super.showActions, + super.actionBuilder, + super.configuration = const BlockComponentConfiguration(), + }); + + @override + State createState() => FileBlockComponentState(); +} + +class FileBlockComponentState extends State + with SelectableMixin, BlockComponentConfigurable { + @override + BlockComponentConfiguration get configuration => widget.configuration; + + @override + Node get node => widget.node; + + RenderBox? get _renderBox => context.findRenderObject() as RenderBox?; + + late EditorDropManagerState dropManagerState = + 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() { + dropManagerState = context.read(); + super.didChangeDependencies(); + } + + @override + Widget build(BuildContext context) { + final url = node.attributes[FileBlockKeys.url]; + + 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 + ? () => afLaunchUrlString(url) + : () { + controller.show(); + dropManagerState.add(FileBlockKeys.type); + }, + 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 (PlatformExtension.isDesktopOrWeb) { + if (url == null || url.isEmpty) { + child = DropTarget( + onDragEntered: (_) { + if (dropManagerState.isDropEnabled) { + setState(() => isDragging = true); + } + }, + onDragExited: (_) { + if (dropManagerState.isDropEnabled) { + setState(() => isDragging = false); + } + }, + onDragDone: (details) { + if (dropManagerState.isDropEnabled) { + insertFileFromLocal(details.files.first); + } + }, + 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 { + child = Padding(key: fileKey, padding: padding, child: child); + } + + if (widget.showActions && widget.actionBuilder != null) { + child = BlockComponentActionWrapper( + node: node, + actionBuilder: widget.actionBuilder!, + child: child, + ); + } + + if (!PlatformExtension.isDesktopOrWeb) { + // show a fixed menu on mobile + child = MobileBlockActionButtons( + showThreeDots: false, + node: node, + editorState: editorState, + child: child, + ); + } + + return child; + } + + 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 (PlatformExtension.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), + ], + ]; + } 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, + ), + ), + ]; + } + } + + Future insertFileFromLocal(XFile file) async { + 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 name = Uri.tryParse(url)?.pathSegments.last ?? url; + final transaction = editorState.transaction; + transaction.updateNode(widget.node, { + FileBlockKeys.url: url, + FileBlockKeys.urlType: FileUrlType.network.toIntValue(), + FileBlockKeys.name: name, + FileBlockKeys.uploadedAt: DateTime.now().millisecondsSinceEpoch, + }); + await editorState.apply(transaction); + } + + @override + Position start() => Position(path: widget.node.path); + + @override + Position end() => Position(path: widget.node.path, offset: 1); + + @override + Position getPositionInOffset(Offset start) => end(); + + @override + bool get shouldCursorBlink => false; + + @override + CursorStyle get cursorStyle => CursorStyle.cover; + + @override + Rect getBlockRect({bool shiftWithBaseOffset = false}) { + final renderBox = fileKey.currentContext?.findRenderObject(); + if (renderBox is RenderBox) { + return Offset.zero & renderBox.size; + } + return Rect.zero; + } + + @override + Rect? getCursorRectInPosition( + Position position, { + bool shiftWithBaseOffset = false, + }) { + final rects = getRectsInSelection(Selection.collapsed(position)); + return rects.firstOrNull; + } + + @override + List getRectsInSelection( + Selection selection, { + bool shiftWithBaseOffset = false, + }) { + if (_renderBox == null) { + return []; + } + final parentBox = context.findRenderObject(); + final renderBox = fileKey.currentContext?.findRenderObject(); + if (parentBox is RenderBox && renderBox is RenderBox) { + return [ + renderBox.localToGlobal(Offset.zero, ancestor: parentBox) & + renderBox.size, + ]; + } + return [Offset.zero & _renderBox!.size]; + } + + @override + Selection getSelectionInRange(Offset start, Offset end) => Selection.single( + path: widget.node.path, + startOffset: 0, + endOffset: 1, + ); + + @override + Offset localToGlobal( + Offset offset, { + bool shiftWithBaseOffset = false, + }) => + _renderBox!.localToGlobal(offset); +} + +@visibleForTesting +class FileMenuTrigger extends StatelessWidget { + const FileMenuTrigger({super.key}); + + @override + Widget build(BuildContext context) { + return const FlowyHover( + resetHoverOnRebuild: false, + child: Padding( + padding: EdgeInsets.all(4), + child: FlowySvg( + FlowySvgs.three_dots_s, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_menu.dart new file mode 100644 index 0000000000000..133a9fb77adca --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_menu.dart @@ -0,0 +1,209 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_block.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class FileBlockMenu extends StatefulWidget { + const FileBlockMenu({ + super.key, + required this.controller, + required this.node, + required this.editorState, + }); + + final PopoverController controller; + final Node node; + final EditorState editorState; + + @override + State createState() => _FileBlockMenuState(); +} + +class _FileBlockMenuState extends State { + final nameController = TextEditingController(); + final errorMessage = ValueNotifier(null); + BuildContext? renameContext; + + @override + void initState() { + super.initState(); + nameController.text = widget.node.attributes[FileBlockKeys.name] ?? ''; + nameController.selection = TextSelection( + baseOffset: 0, + extentOffset: nameController.text.length, + ); + } + + @override + void dispose() { + errorMessage.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final uploadedAtInMS = + widget.node.attributes[FileBlockKeys.uploadedAt] as int?; + final uploadedAt = uploadedAtInMS != null + ? DateTime.fromMillisecondsSinceEpoch(uploadedAtInMS) + : null; + final dateFormat = context.read().state.dateFormat; + final urlType = + FileUrlType.fromIntValue(widget.node.attributes[FileBlockKeys.urlType]); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + HoverButton( + itemHeight: 20, + leftIcon: const FlowySvg(FlowySvgs.edit_s), + name: LocaleKeys.document_plugins_file_renameFile_title.tr(), + onTap: () { + widget.controller.close(); + showCustomConfirmDialog( + context: context, + title: LocaleKeys.document_plugins_file_renameFile_title.tr(), + description: + LocaleKeys.document_plugins_file_renameFile_description.tr(), + closeOnConfirm: false, + builder: (context) { + renameContext = context; + return FileRenameTextField( + nameController: nameController, + errorMessage: errorMessage, + onSubmitted: _saveName, + ); + }, + confirmLabel: LocaleKeys.button_save.tr(), + onConfirm: _saveName, + ); + }, + ), + const VSpace(4), + HoverButton( + itemHeight: 20, + leftIcon: const FlowySvg(FlowySvgs.delete_s), + name: LocaleKeys.button_delete.tr(), + onTap: () { + final transaction = widget.editorState.transaction + ..deleteNode(widget.node); + widget.editorState.apply(transaction); + widget.controller.close(); + }, + ), + if (uploadedAt != null) ...[ + const Divider(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: FlowyText.regular( + [FileUrlType.cloud, FileUrlType.local].contains(urlType) + ? LocaleKeys.document_plugins_file_uploadedAt.tr( + args: [dateFormat.formatDate(uploadedAt, false)], + ) + : LocaleKeys.document_plugins_file_linkedAt.tr( + args: [dateFormat.formatDate(uploadedAt, false)], + ), + fontSize: 14, + maxLines: 2, + color: Theme.of(context).hintColor, + ), + ), + const VSpace(2), + ], + ], + ); + } + + void _saveName() { + if (nameController.text.isEmpty) { + errorMessage.value = + LocaleKeys.document_plugins_file_renameFile_nameEmptyError.tr(); + return; + } + + final attributes = widget.node.attributes; + attributes[FileBlockKeys.name] = nameController.text; + + final transaction = widget.editorState.transaction + ..updateNode(widget.node, attributes); + widget.editorState.apply(transaction); + + if (renameContext != null) { + Navigator.of(renameContext!).pop(); + } + } +} + +class FileRenameTextField extends StatefulWidget { + const FileRenameTextField({ + super.key, + required this.nameController, + required this.errorMessage, + required this.onSubmitted, + this.disposeController = true, + }); + + final TextEditingController nameController; + final ValueNotifier errorMessage; + final VoidCallback onSubmitted; + + final bool disposeController; + + @override + State createState() => _FileRenameTextFieldState(); +} + +class _FileRenameTextFieldState extends State { + @override + void initState() { + super.initState(); + widget.errorMessage.addListener(_setState); + } + + @override + void dispose() { + widget.errorMessage.removeListener(_setState); + if (widget.disposeController) { + widget.nameController.dispose(); + } + super.dispose(); + } + + void _setState() { + if (mounted) { + setState(() {}); + } + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyTextField( + controller: widget.nameController, + onSubmitted: (_) => widget.onSubmitted(), + ), + if (widget.errorMessage.value != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: FlowyText( + widget.errorMessage.value!, + color: Theme.of(context).colorScheme.error, + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_selection_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_selection_menu.dart new file mode 100644 index 0000000000000..16af5ba198c05 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_selection_menu.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; + +final fileMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_plugins_file_name.tr(), + icon: (_, isSelected, style) => SelectionMenuIconWidget( + icon: Icons.file_present_outlined, + isSelected: isSelected, + style: style, + ), + keywords: ['file upload', 'pdf', 'zip', 'archive', 'upload'], + handler: (editorState, _, __) async => editorState.insertEmptyFileBlock(), +); + +extension InsertFile on EditorState { + Future insertEmptyFileBlock() async { + final selection = this.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + final node = getNodeAtPath(selection.end.path); + if (node == null) { + return; + } + final file = fileNode(url: ''); + final transaction = this.transaction; + + // if the current node is empty paragraph, replace it with the file node + if (node.type == ParagraphBlockKeys.type && + (node.delta?.isEmpty ?? false)) { + transaction + ..insertNode(node.path, file) + ..deleteNode(node); + } else { + transaction.insertNode(node.path.next, file); + } + + transaction.afterSelection = + Selection.collapsed(Position(path: node.path.next)); + + return apply(transaction); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart new file mode 100644 index 0000000000000..e1762ba8260f4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart @@ -0,0 +1,281 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/patterns/common_patterns.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_editor/appflowy_editor.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_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'; + +class FileUploadMenu extends StatefulWidget { + const FileUploadMenu({ + super.key, + required this.onInsertLocalFile, + required this.onInsertNetworkFile, + }); + + final void Function(XFile file) onInsertLocalFile; + final void Function(String url) onInsertNetworkFile; + + @override + State createState() => _FileUploadMenuState(); +} + +class _FileUploadMenuState extends State { + int currentTab = 0; + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 2, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TabBar( + onTap: (value) => setState(() => currentTab = value), + isScrollable: true, + padding: EdgeInsets.zero, + overlayColor: WidgetStatePropertyAll( + PlatformExtension.isDesktop + ? Theme.of(context).colorScheme.secondary + : Colors.transparent, + ), + tabs: [ + _Tab( + title: LocaleKeys.document_plugins_file_uploadTab.tr(), + ), + _Tab( + title: LocaleKeys.document_plugins_file_networkTab.tr(), + ), + ], + ), + const Divider(height: 4), + if (currentTab == 0) ...[ + _FileUploadLocal( + onFilePicked: (file) { + if (file != null) { + widget.onInsertLocalFile(file); + } + }, + ), + ] else ...[ + _FileUploadNetwork(onSubmit: widget.onInsertNetworkFile), + ], + ], + ), + ); + } +} + +class _Tab extends StatelessWidget { + const _Tab({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + left: 12.0, + right: 12.0, + bottom: 8.0, + top: PlatformExtension.isMobile ? 0 : 8.0, + ), + child: FlowyText(title), + ); + } +} + +class _FileUploadLocal extends StatefulWidget { + const _FileUploadLocal({required this.onFilePicked}); + + final void Function(XFile?) onFilePicked; + + @override + State<_FileUploadLocal> createState() => _FileUploadLocalState(); +} + +class _FileUploadLocalState extends State<_FileUploadLocal> { + bool isDragging = false; + + @override + Widget build(BuildContext context) { + final constraints = + PlatformExtension.isMobile ? const BoxConstraints(minHeight: 92) : null; + + if (PlatformExtension.isMobile) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: SizedBox( + height: 32, + width: 300, + child: FlowyButton( + backgroundColor: Theme.of(context).colorScheme.primary, + hoverColor: Theme.of(context).colorScheme.primary.withOpacity(0.9), + showDefaultBoxDecorationOnMobile: true, + margin: const EdgeInsets.all(5), + text: FlowyText( + LocaleKeys.document_plugins_file_uploadMobile.tr(), + textAlign: TextAlign.center, + color: Theme.of(context).colorScheme.onPrimary, + ), + onTap: () => _uploadFile(context), + ), + ), + ); + } + + return Padding( + padding: const EdgeInsets.all(4), + child: DropTarget( + onDragEntered: (_) => setState(() => isDragging = true), + onDragExited: (_) => setState(() => isDragging = false), + onDragDone: (details) => widget.onFilePicked(details.files.first), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => _uploadFile(context), + child: FlowyHover( + resetHoverOnRebuild: false, + isSelected: () => isDragging, + style: HoverStyle( + borderRadius: BorderRadius.circular(12), + ), + child: Container( + padding: const EdgeInsets.all(8), + constraints: constraints, + child: DottedBorder( + dashPattern: const [3, 3], + radius: const Radius.circular(8), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 32, + ), + borderType: BorderType.RRect, + color: isDragging + ? Theme.of(context).colorScheme.primary + : Theme.of(context).hintColor, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (isDragging) ...[ + const VSpace(13.5), + FlowyText( + LocaleKeys.document_plugins_file_dropFileToUpload + .tr(), + fontSize: 16, + color: Theme.of(context).hintColor, + ), + const VSpace(13.5), + ] else ...[ + FlowyText( + LocaleKeys.document_plugins_file_fileUploadHint + .tr(), + fontSize: 16, + maxLines: 2, + lineHeight: 1.5, + textAlign: TextAlign.center, + color: Theme.of(context).hintColor, + ), + ], + ], + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + + Future _uploadFile(BuildContext context) async { + final result = await getIt().pickFiles(dialogTitle: ''); + final file = + result?.files.isNotEmpty ?? false ? result?.files.first.xFile : null; + widget.onFilePicked(file); + } +} + +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 = + PlatformExtension.isMobile ? const BoxConstraints(minHeight: 92) : null; + + return Container( + padding: const EdgeInsets.all(8), + constraints: constraints, + alignment: Alignment.center, + child: Column( + children: [ + const VSpace(12), + FlowyTextField( + hintText: LocaleKeys.document_plugins_file_networkHint.tr(), + onChanged: (value) => inputText = value, + onEditingComplete: submit, + ), + if (!isUrlValid) ...[ + const VSpace(8), + FlowyText( + LocaleKeys.document_plugins_file_networkUrlInvalid.tr(), + color: Theme.of(context).colorScheme.error, + ), + ], + const VSpace(20), + SizedBox( + height: 32, + width: 300, + child: FlowyButton( + backgroundColor: Theme.of(context).colorScheme.primary, + hoverColor: + Theme.of(context).colorScheme.primary.withOpacity(0.9), + showDefaultBoxDecorationOnMobile: true, + margin: const EdgeInsets.all(5), + text: FlowyText( + LocaleKeys.document_plugins_file_networkAction.tr(), + textAlign: TextAlign.center, + color: Theme.of(context).colorScheme.onPrimary, + ), + onTap: submit, + ), + ), + const VSpace(8), + ], + ), + ); + } + + void submit() { + if (checkUrlValidity(inputText)) { + return widget.onSubmit(inputText); + } + + setState(() => isUrlValid = false); + } + + bool checkUrlValidity(String url) => hrefRegex.hasMatch(url); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart new file mode 100644 index 0000000000000..5be1234e08564 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart @@ -0,0 +1,194 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package: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_backend/dispatch/error.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/auth.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:cross_file/cross_file.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/file_picker/file_picker_impl.dart'; +import 'package:flowy_infra/platform_extension.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as p; + +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 ([ + MediaUploadTypePB.NetworkMedia, + MediaUploadTypePB.LocalMedia, + ].contains(file.uploadType)) { + /// When the file is a network file or a local file, we can directly open the file. + await afLaunchUrl(Uri.parse(file.url)); + } else { + if (userProfile == null) { + return showSnapBar( + context, + "Failed to download file, could not find user token", + ); + } + + final uri = Uri.parse(file.url); + final token = jsonDecode(userProfile.token)['access_token']; + + if (PlatformExtension.isMobile) { + onDownloadBegin?.call(); + + final response = + await http.get(uri, headers: {'Authorization': 'Bearer $token'}); + + if (response.statusCode == 200) { + final tempFile = File(uri.pathSegments.last); + await FilePicker().saveFile( + fileName: p.basename(tempFile.path), + bytes: response.bodyBytes, + ); + } else if (context.mounted) { + showSnapBar( + context, + LocaleKeys.document_plugins_image_imageDownloadFailed.tr(), + ); + } + + onDownloadEnd?.call(); + } else { + final savePath = await FilePicker().saveFile(fileName: file.name); + if (savePath == null) { + return; + } + + 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(), + ); + } + } + } +} + +Future insertLocalFile( + BuildContext context, + XFile file, { + required String documentId, + UserProfilePB? userProfile, + void Function(String, bool)? onUploadSuccess, +}) async { + if (file.path.isEmpty) return; + + final fileType = file.fileType.toMediaFileTypePB(); + + // Check upload type + final isLocalMode = (userProfile?.authenticator ?? AuthenticatorPB.Local) == + AuthenticatorPB.Local; + + String? path; + String? errorMsg; + if (isLocalMode) { + path = await saveFileToLocalStorage(file.path); + } else { + (path, errorMsg) = await saveFileToCloudStorage( + file.path, + documentId, + fileType == MediaFileTypePB.Image, + ); + } + + if (errorMsg != null) { + return showSnackBarMessage(context, errorMsg); + } + + if (path == null) { + return; + } + + onUploadSuccess?.call(path, isLocalMode); +} 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 c610be4dbf670..da4e0f2546da2 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,6 +1,3 @@ -import 'package:appflowy/workspace/application/settings/appearance/base_appearance.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/document/application/document_appearance_cubit.dart'; @@ -9,6 +6,7 @@ import 'package:appflowy/util/font_family_extension.dart'; import 'package:appflowy/util/levenshtein.dart'; import 'package:appflowy/workspace/application/appearance_defaults.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:appflowy/workspace/presentation/settings/shared/setting_list_tile.dart'; import 'package:appflowy/workspace/presentation/settings/shared/setting_value_dropdown.dart'; import 'package:appflowy_backend/log.dart'; @@ -20,56 +18,66 @@ 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/flowy_tooltip.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:google_fonts/google_fonts.dart'; +const kFontToolbarItemId = 'editor.font'; + final customizeFontToolbarItem = ToolbarItem( - id: 'editor.font', + id: kFontToolbarItemId, group: 4, isActive: onlyShowInTextType, - builder: (context, editorState, highlightColor, _) { + builder: (context, editorState, highlightColor, _, tooltipBuilder) { final selection = editorState.selection!; final popoverController = PopoverController(); final String? currentFontFamily = editorState .getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.fontFamily); - return MouseRegion( - cursor: SystemMouseCursors.click, - child: FontFamilyDropDown( - currentFontFamily: 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 { - popoverController.close(); - await 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, - ), - ), + + Widget child = FontFamilyDropDown( + currentFontFamily: 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 { + popoverController.close(); + await editorState + .formatDelta(selection, {AppFlowyRichTextKeys.fontFamily: null}); + }, + child: FlowyButton( + useIntrinsicWidth: true, + hoverColor: Colors.grey.withOpacity(0.3), + onTap: () => popoverController.show(), + text: const FlowySvg( + FlowySvgs.font_family_s, + size: Size.square(16.0), + color: Colors.white, ), ), ); + + if (tooltipBuilder != null) { + child = tooltipBuilder( + context, + kFontToolbarItemId, + LocaleKeys.document_plugins_fonts.tr(), + child, + ); + } + + return child; }, ); 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 560661d1577af..087d9872625b3 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 @@ -247,6 +247,7 @@ class _CoverImagePreviewWidgetState extends State { size: Size(20, 20), ), text: FlowyText( + lineHeight: 1.0, LocaleKeys.document_plugins_cover_pickFromFiles.tr(), ), ), 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 index 40e4d54855a3f..21565493c126d 100644 --- 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 @@ -5,16 +5,16 @@ 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/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/plugins/document/presentation/editor_plugins/migration/editor_migration.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_listener.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; @@ -351,11 +351,12 @@ class _DocumentHeaderToolbarState extends State { 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 FlowyIconPicker( - onSelected: (result) { + return FlowyIconEmojiPicker( + onSelectedEmoji: (result) { widget.onIconOrCoverChanged(icon: result.emoji); _popoverController.close(); }, @@ -482,9 +483,12 @@ class DocumentCoverState extends State { UploadImageType.url, UploadImageType.unsplash, ], - onSelectedLocalImage: (path) async { + onSelectedLocalImages: (paths) async { context.pop(); - widget.onChangeCover(CoverType.file, path); + widget.onChangeCover( + CoverType.file, + paths.first, + ); }, onSelectedAIImage: (_) { throw UnimplementedError(); @@ -608,9 +612,9 @@ class DocumentCoverState extends State { UploadImageType.url, UploadImageType.unsplash, ], - onSelectedLocalImage: (path) { + onSelectedLocalImages: (paths) { popoverController.close(); - onCoverChanged(CoverType.file, path); + onCoverChanged(CoverType.file, paths.first); }, onSelectedAIImage: (_) { throw UnimplementedError(); @@ -721,10 +725,11 @@ class _DocumentIconState extends State { controller: _popoverController, offset: const Offset(0, 8), constraints: BoxConstraints.loose(const Size(360, 380)), + margin: EdgeInsets.zero, child: child, popupBuilder: (BuildContext popoverContext) { - return FlowyIconPicker( - onSelected: (result) { + return FlowyIconEmojiPicker( + onSelectedEmoji: (result) { widget.onChangeIcon(result.emoji); _popoverController.close(); }, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/heading/heading_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/heading/heading_toolbar_item.dart new file mode 100644 index 0000000000000..30395143e8a73 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/heading/heading_toolbar_item.dart @@ -0,0 +1,214 @@ +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: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; + + 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({ + required this.currentLevel, + required this.highlightColor, + required this.onLevelChanged, + required this.child, + }); + + final int currentLevel; + final Color highlightColor; + final Function(int level) onLevelChanged; + final Widget child; + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + windowPadding: const EdgeInsets.all(0), + margin: const EdgeInsets.symmetric(vertical: 2.0), + direction: PopoverDirection.bottomWithCenterAligned, + offset: const Offset(0, 10), + decorationColor: Theme.of(context).colorScheme.onTertiary, + borderRadius: BorderRadius.circular(6.0), + popupBuilder: (_) { + keepEditorFocusNotifier.increase(); + return _HeadingButtons( + currentLevel: currentLevel, + highlightColor: highlightColor, + onLevelChanged: onLevelChanged, + ); + }, + onClose: () { + keepEditorFocusNotifier.decrease(); + }, + child: FlowyButton( + useIntrinsicWidth: true, + hoverColor: Colors.grey.withOpacity(0.3), + text: child, + ), + ); + } +} + +class _HeadingButtons extends StatelessWidget { + const _HeadingButtons({ + required this.highlightColor, + required this.currentLevel, + required this.onLevelChanged, + }); + + final int currentLevel; + final Color highlightColor; + final Function(int level) onLevelChanged; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 28, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const HSpace(4), + ..._headingData.mapIndexed((index, data) { + final svg = data.$1; + final message = data.$2; + return [ + _HeadingButton( + icon: svg, + tooltip: message, + onTap: () => onLevelChanged(index + 1), + isHighlight: index + 1 == currentLevel, + highlightColor: highlightColor, + ), + index != _headingData.length - 1 + ? const _Divider() + : const SizedBox.shrink(), + ]; + }).flattened, + const HSpace(4), + ], + ), + ); + } +} + +class _HeadingButton extends StatelessWidget { + const _HeadingButton({ + required this.icon, + required this.tooltip, + required this.onTap, + required this.highlightColor, + required this.isHighlight, + }); + + final Color highlightColor; + final FlowySvgData icon; + final String tooltip; + final VoidCallback onTap; + final bool isHighlight; + + @override + Widget build(BuildContext context) { + return FlowyButton( + useIntrinsicWidth: true, + hoverColor: Colors.grey.withOpacity(0.3), + onTap: onTap, + text: FlowyTooltip( + message: tooltip, + preferBelow: true, + child: FlowySvg( + icon, + size: const Size.square(18), + color: isHighlight ? highlightColor : Colors.white, + ), + ), + ); + } +} + +class _Divider extends StatelessWidget { + const _Divider(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(4), + child: Container( + width: 1, + color: Colors.grey, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/common.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/common.dart new file mode 100644 index 0000000000000..24e10f229c2e4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/common.dart @@ -0,0 +1,64 @@ +import 'dart:io'; + +import 'package:flutter/widgets.dart'; + +enum CustomImageType { + local, + internal, // the images saved in self-host cloud + external; // the images linked from network, like unsplash, https://xxx/yyy/zzz.jpg + + static CustomImageType fromIntValue(int value) { + switch (value) { + case 0: + return CustomImageType.local; + case 1: + return CustomImageType.internal; + case 2: + return CustomImageType.external; + default: + throw UnimplementedError(); + } + } + + int toIntValue() { + switch (this) { + case CustomImageType.local: + return 0; + case CustomImageType.internal: + return 1; + case CustomImageType.external: + return 2; + } + } +} + +class ImageBlockData { + factory ImageBlockData.fromJson(Map json) { + return ImageBlockData( + url: json['url'] as String? ?? '', + type: CustomImageType.fromIntValue(json['type'] as int), + ); + } + + ImageBlockData({required this.url, required this.type}); + + final String url; + final CustomImageType type; + + bool get isLocal => type == CustomImageType.local; + bool get isNotInternal => type != CustomImageType.internal; + + Map toJson() { + return {'url': url, 'type': type.toIntValue()}; + } + + ImageProvider toImageProvider() { + switch (type) { + case CustomImageType.internal: + case CustomImageType.external: + return NetworkImage(url); + case CustomImageType.local: + return FileImage(File(url)); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart similarity index 85% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart rename to frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart index 722a79c2f549a..0aac17ce4cfaf 100644 --- 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/custom_image_block_component.dart @@ -4,52 +4,27 @@ 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/widgets/widgets.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/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/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: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(); - } - } +import '../common.dart'; - int toIntValue() { - switch (this) { - case CustomImageType.local: - return 0; - case CustomImageType.internal: - return 1; - case CustomImageType.external: - return 2; - } - } -} +const kImagePlaceholderKey = 'imagePlaceholderKey'; class CustomImageBlockKeys { const CustomImageBlockKeys._(); @@ -84,6 +59,25 @@ class CustomImageBlockKeys { 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, @@ -182,7 +176,7 @@ class CustomImageBlockComponentState extends State ); } else if (imageType != CustomImageType.internal && !_checkIfURLIsValid(src)) { - child = const UnSupportImageWidget(); + child = const UnsupportedImageWidget(); } else { child = ResizableImage( src: src, @@ -191,11 +185,22 @@ class CustomImageBlockComponentState extends State editable: editorState.editable, alignment: alignment, type: imageType, + onDoubleTap: () => showDialog( + context: context, + builder: (_) => InteractiveImageViewer( + userProfile: context.read().state.userProfilePB, + imageProvider: AFBlockImageProvider( + images: [ImageBlockData(url: src, type: imageType)], + onDeleteImage: (_) async { + final transaction = editorState.transaction..deleteNode(node); + await editorState.apply(transaction); + }, + ), + ), + ), onResize: (width) { final transaction = editorState.transaction - ..updateNode(node, { - CustomImageBlockKeys.width: width, - }); + ..updateNode(node, {CustomImageBlockKeys.width: width}); editorState.apply(transaction); }, ); @@ -207,21 +212,11 @@ class CustomImageBlockComponentState extends State delegate: this, listenable: editorState.selectionNotifier, blockColor: editorState.editorStyle.selectionColor, - supportTypes: const [ - BlockSelectionType.block, - ], - child: Padding( - key: imageKey, - padding: padding, - child: child, - ), + supportTypes: const [BlockSelectionType.block], + child: Padding(key: imageKey, padding: padding, child: child), ); } else { - child = Padding( - key: imageKey, - padding: padding, - child: child, - ); + child = Padding(key: imageKey, padding: padding, child: child); } if (widget.showActions && widget.actionBuilder != null) { @@ -246,7 +241,7 @@ class CustomImageBlockComponentState extends State opaque: false, child: ValueListenableBuilder( valueListenable: showActionsNotifier, - builder: (context, value, child) { + builder: (_, value, child) { final url = node.attributes[CustomImageBlockKeys.url]; return Stack( children: [ @@ -259,10 +254,7 @@ class CustomImageBlockComponentState extends State child: child!, ), if (value && url.isNotEmpty == true) - widget.menuBuilder!( - widget.node, - this, - ), + widget.menuBuilder!(widget.node, this), ], ); }, 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/custom_image_block_component/image_menu.dart similarity index 75% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_menu.dart rename to frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/image_menu.dart index ca765bc0ed7d0..06028b6e635df 100644 --- 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/custom_image_block_component/image_menu.dart @@ -1,26 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/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/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/plugins.dart'; import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; import 'package:appflowy_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, - }); + const ImageMenu({super.key, required this.node, required this.state}); final Node node; final CustomImageBlockComponentState state; @@ -30,7 +31,7 @@ class ImageMenu extends StatefulWidget { } class _ImageMenuState extends State { - late final String? url = widget.node.attributes[ImageBlockKeys.url]; + late final String? url = widget.node.attributes[CustomImageBlockKeys.url]; @override Widget build(BuildContext context) { @@ -50,6 +51,12 @@ class _ImageMenuState extends State { ), child: Row( children: [ + const HSpace(4), + MenuBlockButton( + tooltip: LocaleKeys.document_imageBlock_openFullScreen.tr(), + iconData: FlowySvgs.full_view_s, + onTap: openFullScreen, + ), 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 @@ -61,10 +68,7 @@ class _ImageMenuState extends State { ), const HSpace(4), ], - _ImageAlignButton( - node: widget.node, - state: widget.state, - ), + _ImageAlignButton(node: widget.node, state: widget.state), const _Divider(), MenuBlockButton( tooltip: LocaleKeys.button_delete.tr(), @@ -95,13 +99,34 @@ class _ImageMenuState extends State { transaction.afterSelection = null; await editorState.apply(transaction); } + + void openFullScreen() { + showDialog( + context: context, + builder: (_) => InteractiveImageViewer( + userProfile: context.read().state.userProfilePB, + imageProvider: AFBlockImageProvider( + images: [ + ImageBlockData( + url: url!, + type: CustomImageType.fromIntValue( + widget.node.attributes[CustomImageBlockKeys.imageType] ?? 2, + ), + ), + ], + onDeleteImage: (_) async { + final transaction = widget.state.editorState.transaction; + transaction.deleteNode(widget.node); + await widget.state.editorState.apply(transaction); + }, + ), + ), + ); + } } class _ImageAlignButton extends StatefulWidget { - const _ImageAlignButton({ - required this.node, - required this.state, - }); + const _ImageAlignButton({required this.node, required this.state}); final Node node; final CustomImageBlockComponentState state; @@ -110,30 +135,28 @@ class _ImageAlignButton extends StatefulWidget { State<_ImageAlignButton> createState() => _ImageAlignButtonState(); } -const interceptorKey = 'image-align'; +const _interceptorKey = 'image-align'; class _ImageAlignButtonState extends State<_ImageAlignButton> { final gestureInterceptor = SelectionGestureInterceptor( - key: interceptorKey, + key: _interceptorKey, canTap: (details) => false, ); String get align => - widget.node.attributes[ImageBlockKeys.align] ?? centerAlignmentKey; + 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(); } @@ -153,9 +176,7 @@ class _ImageAlignButtonState extends State<_ImageAlignButton> { ), popupBuilder: (_) { preventMenuClose(); - return _AlignButtons( - onAlignChanged: onAlignChanged, - ); + return _AlignButtons(onAlignChanged: onAlignChanged); }, ), ); @@ -165,9 +186,7 @@ class _ImageAlignButtonState extends State<_ImageAlignButton> { popoverController.close(); final transaction = editorState.transaction; - transaction.updateNode(widget.node, { - ImageBlockKeys.align: align, - }); + transaction.updateNode(widget.node, {CustomImageBlockKeys.align: align}); editorState.apply(transaction); allowMenuClose(); @@ -183,7 +202,7 @@ class _ImageAlignButtonState extends State<_ImageAlignButton> { void allowMenuClose() { widget.state.alwaysShowMenu = false; editorState.service.selectionService.unregisterGestureInterceptor( - interceptorKey, + _interceptorKey, ); } @@ -201,9 +220,7 @@ class _ImageAlignButtonState extends State<_ImageAlignButton> { } class _AlignButtons extends StatelessWidget { - const _AlignButtons({ - required this.onAlignChanged, - }); + const _AlignButtons({required this.onAlignChanged}); final Function(String align) onAlignChanged; @@ -246,10 +263,7 @@ class _Divider extends StatelessWidget { Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(8), - child: Container( - width: 1, - color: Colors.grey, - ), + child: Container(width: 1, color: Colors.grey), ); } } 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/custom_image_block_component/unsupport_image_widget.dart similarity index 75% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/unsupport_image_widget.dart rename to frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/unsupport_image_widget.dart index 017e5a94b287d..f0310a4aa57b0 100644 --- 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/custom_image_block_component/unsupport_image_widget.dart @@ -1,14 +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: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, - }); +class UnsupportedImageWidget extends StatelessWidget { + const UnsupportedImageWidget({super.key}); @override Widget build(BuildContext context) { @@ -18,9 +17,7 @@ class UnSupportImageWidget extends StatelessWidget { borderRadius: BorderRadius.circular(4), ), child: FlowyHover( - style: HoverStyle( - borderRadius: BorderRadius.circular(4), - ), + style: HoverStyle(borderRadius: BorderRadius.circular(4)), child: SizedBox( height: 52, child: Row( @@ -31,9 +28,7 @@ class UnSupportImageWidget extends StatelessWidget { size: Size.square(24), ), const HSpace(10), - FlowyText( - LocaleKeys.document_imageBlock_unableToLoadImage.tr(), - ), + FlowyText(LocaleKeys.document_imageBlock_unableToLoadImage.tr()), ], ), ), 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 45f5b78507a2f..0000000000000 --- 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_picker_screen.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_picker_screen.dart index 5ea7a56c40e0d..b1c6c94213557 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 6fe7822e682fe..b01b5a6939d97 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,24 +1,29 @@ 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: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'; @@ -26,10 +31,7 @@ import 'package:path/path.dart' as p; import 'package:string_validator/string_validator.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.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), @@ -85,18 +96,22 @@ 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: (paths) { controller.close(); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { - await insertLocalImage(path); + WidgetsBinding.instance.addPostFrameCallback((_) async { + final List items = List.from( + paths.where((url) => url != null && url.isNotEmpty), + ); + if (items.isNotEmpty) { + await insertMultipleLocalImages(items); + } }); }, onSelectedAIImage: (url) { @@ -113,7 +128,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 +168,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,8 +185,15 @@ class ImagePlaceholderState extends State { ]; } else { return [ - FlowyText( - LocaleKeys.document_plugins_image_addAnImage.tr(), + Flexible( + child: FlowyText( + PlatformExtension.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, + ), ), ]; } @@ -179,9 +224,14 @@ class ImagePlaceholderState extends State { UploadImageType.url, UploadImageType.unsplash, ], - onSelectedLocalImage: (path) async { + onSelectedLocalImages: (paths) async { context.pop(); - await insertLocalImage(path); + + final List items = List.from( + paths.where((url) => url != null && url.isNotEmpty), + ); + + await insertMultipleLocalImages(items); }, onSelectedAIImage: (url) async { context.pop(); @@ -198,77 +248,102 @@ class ImagePlaceholderState extends State { } } - Future insertLocalImage(String? url) async { + Future insertMultipleLocalImages(List urls) async { controller.close(); - if (url == null || url.isEmpty) { - return; - } - - final transaction = editorState.transaction; + setState(() { + showLoading = true; + errorMessage = null; + }); - String? path; - String? errorMessage; - CustomImageType imageType = CustomImageType.local; + bool hasError = false; - // 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); + 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 documentId = context.read().documentId; - if (documentId.isEmpty) { - return; + 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, + ), + ); + } + } } - // else we should save the image to cloud storage - setState(() { - showLoading = true; - this.errorMessage = null; - }); - (path, errorMessage) = await saveImageToCloudStorage(url, documentId); - setState(() { - showLoading = false; - this.errorMessage = errorMessage; - }); - imageType = CustomImageType.internal; + + await editorState.apply(transaction); } - if (mounted && path == null) { - showSnackBarMessage( + setState(() => showLoading = false); + + if (hasError && mounted) { + showSnapBar( context, - errorMessage == null - ? LocaleKeys.document_imageBlock_error_invalidImage.tr() - : ': $errorMessage', + LocaleKeys.document_imageBlock_error_multipleImagesFailed.tr(), ); - setState(() { - this.errorMessage = errorMessage; - }); - return; } - - transaction.updateNode(widget.node, { - CustomImageBlockKeys.url: path, - CustomImageBlockKeys.imageType: imageType.toIntValue(), - }); - - await editorState.apply(transaction); } 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); @@ -283,7 +358,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); @@ -293,16 +368,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 c42e4f8147966..2dfc2bdaefac0 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,27 +1,56 @@ -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.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' hide Log; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.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; @@ -33,31 +62,49 @@ extension InsertImage on EditorState { return; } final emptyImage = imageNode(url: '') - ..extraInfos = { - kImagePlaceholderKey: key, - }; + ..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, - ) + ..insertNode(node.path, emptyImage) ..deleteNode(node); } else { - transaction.insertNode( - node.path.next, - emptyImage, - ); + transaction.insertNode(node.path.next, emptyImage); } - transaction.afterSelection = Selection.collapsed( - Position( - path: node.path.next, - ), - ); + transaction.afterSelection = + Selection.collapsed(Position(path: node.path.next)); + transaction.selectionExtraInfo = {}; + + return apply(transaction); + } + + Future insertEmptyMultiImageBlock(GlobalKey key) async { + final selection = this.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + final node = getNodeAtPath(selection.end.path); + if (node == null) { + return; + } + final emptyBlock = multiImageNode() + ..extraInfos = {kMultiImagePlaceholderKey: key}; + final transaction = this.transaction; + // if the current node is empty paragraph, replace it with image node + if (node.type == ParagraphBlockKeys.type && + (node.delta?.isEmpty ?? false)) { + transaction + ..insertNode(node.path, emptyBlock) + ..deleteNode(node); + } else { + transaction.insertNode(node.path.next, emptyBlock); + } + + transaction.afterSelection = + Selection.collapsed(Position(path: node.path.next)); transaction.selectionExtraInfo = {}; return apply(transaction); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_util.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_util.dart index 5935cf6eaf775..efa5721382fc3 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,14 +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:appflowy_backend/protobuf/flowy-error/code.pb.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 { @@ -42,14 +45,6 @@ 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( @@ -65,11 +60,60 @@ Future<(String? path, String? errorMessage)> saveImageToCloudStorage( return (s.url, null); }, (err) { - if (err.code == ErrorCode.FileStorageLimitExceeded) { - return (null, LocaleKeys.sideBar_storageLimitDialogTitle.tr()); + final message = Platform.isIOS + ? LocaleKeys.sideBar_storageLimitDialogTitleIOS.tr() + : LocaleKeys.sideBar_storageLimitDialogTitle.tr(); + if (err.isStorageLimitExceeded) { + return (null, message); } else { return (null, err.msg); } }, ); } + +Future> extractAndUploadImages( + BuildContext context, + List urls, + bool isLocalMode, +) async { + final List images = []; + + bool hasError = false; + for (final url in urls) { + if (url == null || url.isEmpty) { + continue; + } + + String? path; + String? errorMsg; + CustomImageType imageType = CustomImageType.local; + + // If the user is using local authenticator, we save the image to local storage + if (isLocalMode) { + path = await saveImageToLocalStorage(url); + } else { + // Else we save the image to cloud storage + (path, errorMsg) = await saveImageToCloudStorage( + url, + context.read().documentId, + ); + imageType = CustomImageType.internal; + } + + if (path != null && errorMsg == null) { + images.add(ImageBlockData(url: path, type: imageType)); + } else { + hasError = true; + } + } + + if (context.mounted && hasError) { + showSnackBarMessage( + context, + LocaleKeys.document_imageBlock_error_multipleImagesFailed.tr(), + ); + } + + return images; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/mobile_image_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/mobile_image_toolbar_item.dart index aa8c6fe496282..cb7fd457e0456 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 0000000000000..66a14d2c4ae41 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/image_render.dart @@ -0,0 +1,42 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:flowy_infra/size.dart'; + +@visibleForTesting +class ImageRender extends StatelessWidget { + const ImageRender({ + super.key, + required this.image, + this.userProfile, + this.fit = BoxFit.cover, + this.borderRadius = Corners.s6Border, + }); + + final ImageBlockData image; + final UserProfilePB? userProfile; + final BoxFit fit; + final BorderRadius? borderRadius; + + @override + Widget build(BuildContext context) { + final child = switch (image.type) { + CustomImageType.internal || CustomImageType.external => FlowyNetworkImage( + url: image.url, + userProfilePB: userProfile, + fit: fit, + ), + CustomImageType.local => Image.file(File(image.url), fit: fit), + }; + + return Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration(borderRadius: borderRadius), + child: child, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart new file mode 100644 index 0000000000000..9f6b10cf3c725 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart @@ -0,0 +1,408 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy/shared/patterns/file_type_patterns.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:collection/collection.dart'; +import 'package:desktop_drop/desktop_drop.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:provider/provider.dart'; + +import '../image_render.dart'; + +const _thumbnailItemSize = 100.0; + +class ImageBrowserLayout extends ImageBlockMultiLayout { + const ImageBrowserLayout({ + super.key, + required super.node, + required super.editorState, + required super.images, + required super.indexNotifier, + required super.isLocalMode, + required this.onIndexChanged, + }); + + final void Function(int) onIndexChanged; + + @override + State createState() => _ImageBrowserLayoutState(); +} + +class _ImageBrowserLayoutState extends State { + UserProfilePB? _userProfile; + bool isDraggingFiles = false; + + @override + void initState() { + super.initState(); + _userProfile = context.read().state.userProfilePB; + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 400, + width: MediaQuery.of(context).size.width, + child: GestureDetector( + onDoubleTap: () => _openInteractiveViewer(context), + child: ImageRender( + image: widget.images[widget.indexNotifier.value], + userProfile: _userProfile, + fit: BoxFit.contain, + ), + ), + ), + const VSpace(8), + LayoutBuilder( + builder: (context, constraints) { + final maxItems = + (constraints.maxWidth / (_thumbnailItemSize + 4)).floor(); + final items = widget.images.take(maxItems).toList(); + + return Center( + child: Wrap( + children: items.mapIndexed((index, image) { + final isLast = items.last == image; + final amountLeft = widget.images.length - items.length; + if (isLast && amountLeft > 0) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => _openInteractiveViewer( + context, + maxItems - 1, + ), + child: Container( + width: _thumbnailItemSize, + height: _thumbnailItemSize, + padding: const EdgeInsets.all(2), + margin: const EdgeInsets.all(2), + decoration: BoxDecoration( + borderRadius: Corners.s8Border, + border: Border.all( + width: 2, + color: Theme.of(context).dividerColor, + ), + ), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: Corners.s6Border, + image: image.type == CustomImageType.local + ? DecorationImage( + image: FileImage(File(image.url)), + fit: BoxFit.cover, + opacity: 0.5, + ) + : null, + ), + child: Stack( + children: [ + if (image.type != CustomImageType.local) + Positioned.fill( + child: Container( + clipBehavior: Clip.antiAlias, + decoration: const BoxDecoration( + borderRadius: Corners.s6Border, + ), + child: FlowyNetworkImage( + url: image.url, + userProfilePB: _userProfile, + ), + ), + ), + DecoratedBox( + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.5), + ), + child: Center( + child: FlowyText( + '+$amountLeft', + color: AFThemeExtension.of(context) + .strongText, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => widget.onIndexChanged(index), + child: ThumbnailItem( + images: widget.images, + index: index, + selectedIndex: widget.indexNotifier.value, + userProfile: _userProfile, + onDeleted: () async { + final transaction = + widget.editorState.transaction; + + final images = widget.images.toList(); + images.removeAt(index); + + transaction.updateNode( + widget.node, + { + MultiImageBlockKeys.images: + images.map((e) => e.toJson()).toList(), + MultiImageBlockKeys.layout: widget.node + .attributes[MultiImageBlockKeys.layout], + }, + ); + + await widget.editorState.apply(transaction); + + widget.onIndexChanged( + widget.indexNotifier.value > 0 + ? widget.indexNotifier.value - 1 + : 0, + ); + }, + ), + ), + ); + }).toList(), + ), + ); + }, + ), + ], + ), + Positioned.fill( + child: DropTarget( + onDragEntered: (_) => setState(() => isDraggingFiles = true), + onDragExited: (_) => setState(() => isDraggingFiles = false), + onDragDone: (details) { + setState(() => isDraggingFiles = false); + // Only accept files where the mimetype is an image, + // or the file extension is a known image format, + // otherwise we assume it's a file we cannot display. + final imageFiles = details.files + .where( + (file) => + file.mimeType?.startsWith('image/') ?? + false || imgExtensionRegex.hasMatch(file.name), + ) + .toList(); + final paths = imageFiles.map((file) => file.path).toList(); + WidgetsBinding.instance.addPostFrameCallback( + (_) async => insertLocalImages(paths), + ); + }, + child: !isDraggingFiles + ? const SizedBox.shrink() + : SizedBox.expand( + child: DecoratedBox( + decoration: + BoxDecoration(color: Colors.white.withOpacity(0.5)), + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const FlowySvg( + FlowySvgs.download_s, + size: Size.square(28), + ), + const HSpace(12), + Flexible( + child: FlowyText( + LocaleKeys + .document_plugins_image_dropImageToInsert + .tr(), + color: AFThemeExtension.of(context).strongText, + fontSize: 22, + fontWeight: FontWeight.w500, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ], + ); + } + + void _openInteractiveViewer(BuildContext context, [int? index]) => showDialog( + context: context, + builder: (_) => InteractiveImageViewer( + userProfile: _userProfile, + imageProvider: AFBlockImageProvider( + images: widget.images, + initialIndex: index ?? widget.indexNotifier.value, + onDeleteImage: (index) async { + final transaction = widget.editorState.transaction; + final newImages = widget.images.toList(); + newImages.removeAt(index); + + widget.onIndexChanged( + widget.indexNotifier.value > 0 + ? widget.indexNotifier.value - 1 + : 0, + ); + + if (newImages.isNotEmpty) { + transaction.updateNode( + widget.node, + { + MultiImageBlockKeys.images: + newImages.map((e) => e.toJson()).toList(), + MultiImageBlockKeys.layout: + widget.node.attributes[MultiImageBlockKeys.layout], + }, + ); + } else { + transaction.deleteNode(widget.node); + } + + await widget.editorState.apply(transaction); + }, + ), + ), + ); + + Future insertLocalImages(List urls) async { + if (urls.isEmpty || urls.every((path) => path?.isEmpty ?? true)) { + return; + } + + final isLocalMode = context.read().isLocalMode; + final transaction = widget.editorState.transaction; + final images = await extractAndUploadImages(context, urls, isLocalMode); + if (images.isEmpty) { + return; + } + + final newImages = [...widget.images, ...images]; + final imagesJson = newImages.map((image) => image.toJson()).toList(); + + transaction.updateNode(widget.node, { + MultiImageBlockKeys.images: imagesJson, + MultiImageBlockKeys.layout: + widget.node.attributes[MultiImageBlockKeys.layout], + }); + + await widget.editorState.apply(transaction); + } +} + +@visibleForTesting +class ThumbnailItem extends StatefulWidget { + const ThumbnailItem({ + super.key, + required this.images, + required this.index, + required this.selectedIndex, + required this.onDeleted, + this.userProfile, + }); + + final List images; + final int index; + final int selectedIndex; + final VoidCallback onDeleted; + final UserProfilePB? userProfile; + + @override + State createState() => _ThumbnailItemState(); +} + +class _ThumbnailItemState extends State { + bool isHovering = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => isHovering = true), + onExit: (_) => setState(() => isHovering = false), + child: Container( + width: _thumbnailItemSize, + height: _thumbnailItemSize, + padding: const EdgeInsets.all(2), + margin: const EdgeInsets.all(2), + decoration: BoxDecoration( + borderRadius: Corners.s8Border, + border: Border.all( + width: 2, + color: widget.index == widget.selectedIndex + ? Theme.of(context).colorScheme.primary + : Theme.of(context).dividerColor, + ), + ), + child: Stack( + children: [ + Positioned.fill( + child: ImageRender( + image: widget.images[widget.index], + userProfile: widget.userProfile, + ), + ), + Positioned( + top: 4, + right: 4, + child: AnimatedOpacity( + opacity: isHovering ? 1 : 0, + duration: const Duration(milliseconds: 100), + child: FlowyTooltip( + message: LocaleKeys.button_delete.tr(), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onDeleted, + child: FlowyHover( + resetHoverOnRebuild: false, + style: HoverStyle( + backgroundColor: Colors.black.withOpacity(0.6), + hoverColor: Colors.black.withOpacity(0.9), + ), + child: const Padding( + padding: EdgeInsets.all(4), + child: FlowySvg( + FlowySvgs.delete_s, + color: Colors.white, + ), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_grid_layout.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_grid_layout.dart new file mode 100644 index 0000000000000..1abe57146ebc0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_grid_layout.dart @@ -0,0 +1,323 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/image_render.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:provider/provider.dart'; + +class ImageGridLayout extends ImageBlockMultiLayout { + const ImageGridLayout({ + super.key, + required super.node, + required super.editorState, + required super.images, + required super.indexNotifier, + required super.isLocalMode, + }); + + @override + State createState() => _ImageGridLayoutState(); +} + +class _ImageGridLayoutState extends State { + @override + Widget build(BuildContext context) { + return StaggeredGridBuilder( + images: widget.images, + onImageDoubleTapped: (index) { + _openInteractiveViewer(context, index); + }, + ); + } + + void _openInteractiveViewer(BuildContext context, int index) => showDialog( + context: context, + builder: (_) => InteractiveImageViewer( + userProfile: context.read().state.userProfilePB, + imageProvider: AFBlockImageProvider( + images: widget.images, + initialIndex: index, + onDeleteImage: (index) async { + final transaction = widget.editorState.transaction; + final newImages = widget.images.toList(); + newImages.removeAt(index); + + if (newImages.isNotEmpty) { + transaction.updateNode( + widget.node, + { + MultiImageBlockKeys.images: + newImages.map((e) => e.toJson()).toList(), + MultiImageBlockKeys.layout: + widget.node.attributes[MultiImageBlockKeys.layout], + }, + ); + } else { + transaction.deleteNode(widget.node); + } + + await widget.editorState.apply(transaction); + }, + ), + ), + ); +} + +/// Draws a staggered grid of images, where the pattern is based +/// on the amount of images to fill the grid at all times. +/// +/// They will be alternating depending on the current index of the images, such that +/// the layout is reversed in odd segments. +/// +/// If there are 4 images in the last segment, this layout will be used: +/// ┌─────┐┌─┐┌─┐ +/// │ │└─┘└─┘ +/// │ │┌────┐ +/// └─────┘└────┘ +/// +/// If there are 3 images in the last segment, this layout will be used: +/// ┌─────┐┌────┐ +/// │ │└────┘ +/// │ │┌────┐ +/// └─────┘└────┘ +/// +/// If there are 2 images in the last segment, this layout will be used: +/// ┌─────┐┌─────┐ +/// │ ││ │ +/// └─────┘└─────┘ +/// +/// If there is 1 image in the last segment, this layout will be used: +/// ┌──────────┐ +/// │ │ +/// └──────────┘ +class StaggeredGridBuilder extends StatefulWidget { + const StaggeredGridBuilder({ + super.key, + required this.images, + required this.onImageDoubleTapped, + }); + + final List images; + final void Function(int) onImageDoubleTapped; + + @override + State createState() => _StaggeredGridBuilderState(); +} + +class _StaggeredGridBuilderState extends State { + late final UserProfilePB? _userProfile; + final List> _splitImages = []; + + @override + void initState() { + super.initState(); + _userProfile = context.read().state.userProfilePB; + + for (int i = 0; i < widget.images.length; i += 4) { + final end = (i + 4 < widget.images.length) ? i + 4 : widget.images.length; + _splitImages.add(widget.images.sublist(i, end)); + } + } + + @override + void didUpdateWidget(covariant StaggeredGridBuilder oldWidget) { + if (widget.images.length != oldWidget.images.length) { + _splitImages.clear(); + for (int i = 0; i < widget.images.length; i += 4) { + final end = + (i + 4 < widget.images.length) ? i + 4 : widget.images.length; + _splitImages.add(widget.images.sublist(i, end)); + } + } + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return StaggeredGrid.count( + crossAxisCount: 4, + mainAxisSpacing: 6, + crossAxisSpacing: 6, + children: + _splitImages.indexed.map(_buildTilesForImages).flattened.toList(), + ); + } + + List _buildTilesForImages((int, List) data) { + final index = data.$1; + final images = data.$2; + + final isReversed = index.isOdd; + + if (images.length == 4) { + return [ + StaggeredGridTile.count( + crossAxisCellCount: isReversed ? 1 : 2, + mainAxisCellCount: isReversed ? 1 : 2, + child: GestureDetector( + onDoubleTap: () { + final imageIndex = index * 4; + widget.onImageDoubleTapped(imageIndex); + }, + child: ImageRender( + image: images[0], + userProfile: _userProfile, + borderRadius: BorderRadius.zero, + ), + ), + ), + StaggeredGridTile.count( + crossAxisCellCount: 1, + mainAxisCellCount: 1, + child: GestureDetector( + onDoubleTap: () { + final imageIndex = index * 4 + 1; + widget.onImageDoubleTapped(imageIndex); + }, + child: ImageRender( + image: images[1], + userProfile: _userProfile, + borderRadius: BorderRadius.zero, + ), + ), + ), + StaggeredGridTile.count( + crossAxisCellCount: isReversed ? 2 : 1, + mainAxisCellCount: isReversed ? 2 : 1, + child: GestureDetector( + onDoubleTap: () { + final imageIndex = index * 4 + 2; + widget.onImageDoubleTapped(imageIndex); + }, + child: ImageRender( + image: images[2], + userProfile: _userProfile, + borderRadius: BorderRadius.zero, + ), + ), + ), + StaggeredGridTile.count( + crossAxisCellCount: 2, + mainAxisCellCount: 1, + child: GestureDetector( + onDoubleTap: () { + final imageIndex = index * 4 + 3; + widget.onImageDoubleTapped(imageIndex); + }, + child: ImageRender( + image: images[3], + userProfile: _userProfile, + borderRadius: BorderRadius.zero, + ), + ), + ), + ]; + } else if (images.length == 3) { + return [ + StaggeredGridTile.count( + crossAxisCellCount: 2, + mainAxisCellCount: isReversed ? 1 : 2, + child: GestureDetector( + onDoubleTap: () { + final imageIndex = index * 4; + widget.onImageDoubleTapped(imageIndex); + }, + child: ImageRender( + image: images[0], + userProfile: _userProfile, + borderRadius: BorderRadius.zero, + ), + ), + ), + StaggeredGridTile.count( + crossAxisCellCount: 2, + mainAxisCellCount: isReversed ? 2 : 1, + child: GestureDetector( + onDoubleTap: () { + final imageIndex = index * 4 + 1; + widget.onImageDoubleTapped(imageIndex); + }, + child: ImageRender( + image: images[1], + userProfile: _userProfile, + borderRadius: BorderRadius.zero, + ), + ), + ), + StaggeredGridTile.count( + crossAxisCellCount: 2, + mainAxisCellCount: 1, + child: GestureDetector( + onDoubleTap: () { + final imageIndex = index * 4 + 2; + widget.onImageDoubleTapped(imageIndex); + }, + child: ImageRender( + image: images[2], + userProfile: _userProfile, + borderRadius: BorderRadius.zero, + ), + ), + ), + ]; + } else if (images.length == 2) { + return [ + StaggeredGridTile.count( + crossAxisCellCount: 2, + mainAxisCellCount: 2, + child: GestureDetector( + onDoubleTap: () { + final imageIndex = index * 4; + widget.onImageDoubleTapped(imageIndex); + }, + child: ImageRender( + image: images[0], + userProfile: _userProfile, + borderRadius: BorderRadius.zero, + ), + ), + ), + StaggeredGridTile.count( + crossAxisCellCount: 2, + mainAxisCellCount: 2, + child: GestureDetector( + onDoubleTap: () { + final imageIndex = index * 4 + 1; + widget.onImageDoubleTapped(imageIndex); + }, + child: ImageRender( + image: images[1], + userProfile: _userProfile, + borderRadius: BorderRadius.zero, + ), + ), + ), + ]; + } else { + return [ + StaggeredGridTile.count( + crossAxisCellCount: 4, + mainAxisCellCount: 2, + child: GestureDetector( + onDoubleTap: () { + final imageIndex = index * 4; + widget.onImageDoubleTapped(imageIndex); + }, + child: ImageRender( + image: images[0], + userProfile: _userProfile, + borderRadius: BorderRadius.zero, + ), + ), + ), + ]; + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart new file mode 100644 index 0000000000000..00919a20cc0ad --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_grid_layout.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide ResizableImage; + +abstract class ImageBlockMultiLayout extends StatefulWidget { + const ImageBlockMultiLayout({ + super.key, + required this.node, + required this.editorState, + required this.images, + required this.indexNotifier, + required this.isLocalMode, + }); + + final Node node; + final EditorState editorState; + final List images; + final ValueNotifier indexNotifier; + final bool isLocalMode; +} + +class ImageLayoutRender extends StatelessWidget { + const ImageLayoutRender({ + super.key, + required this.node, + required this.editorState, + required this.images, + required this.indexNotifier, + required this.isLocalMode, + required this.onIndexChanged, + }); + + final Node node; + final EditorState editorState; + final List images; + final ValueNotifier indexNotifier; + final bool isLocalMode; + final void Function(int) onIndexChanged; + + @override + Widget build(BuildContext context) { + final layout = _getLayout(); + + return _buildLayout(layout); + } + + MultiImageLayout _getLayout() { + return MultiImageLayout.fromIntValue( + node.attributes[MultiImageBlockKeys.layout] ?? 0, + ); + } + + Widget _buildLayout(MultiImageLayout layout) { + switch (layout) { + case MultiImageLayout.grid: + return ImageGridLayout( + node: node, + editorState: editorState, + images: images, + indexNotifier: indexNotifier, + isLocalMode: isLocalMode, + ); + case MultiImageLayout.browser: + default: + return ImageBrowserLayout( + node: node, + editorState: editorState, + images: images, + indexNotifier: indexNotifier, + isLocalMode: isLocalMode, + onIndexChanged: onIndexChanged, + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart new file mode 100644 index 0000000000000..648f5f978c7a3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart @@ -0,0 +1,371 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/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:provider/provider.dart'; + +const kMultiImagePlaceholderKey = 'multiImagePlaceholderKey'; + +Node multiImageNode() => Node( + type: MultiImageBlockKeys.type, + attributes: { + MultiImageBlockKeys.images: MultiImageData(images: []).toJson(), + MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(), + }, + ); + +class MultiImageBlockKeys { + const MultiImageBlockKeys._(); + + static const String type = 'multi_image'; + + /// The image data for the block, stored as a JSON encoded list of [ImageBlockData]. + /// + static const String images = 'images'; + + /// The layout of the images. + /// + /// The value is a MultiImageLayout enum. + /// + static const String layout = 'layout'; +} + +typedef MultiImageBlockComponentMenuBuilder = Widget Function( + Node node, + MultiImageBlockComponentState state, + ValueNotifier indexNotifier, + VoidCallback onImageDeleted, +); + +class MultiImageBlockComponentBuilder extends BlockComponentBuilder { + MultiImageBlockComponentBuilder({ + super.configuration, + this.showMenu = false, + this.menuBuilder, + }); + + final bool showMenu; + final MultiImageBlockComponentMenuBuilder? menuBuilder; + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + return MultiImageBlockComponent( + key: node.key, + node: node, + showActions: showActions(node), + configuration: configuration, + actionBuilder: (_, state) => actionBuilder(blockComponentContext, state), + showMenu: showMenu, + menuBuilder: menuBuilder, + ); + } + + @override + bool validate(Node node) => node.delta == null && node.children.isEmpty; +} + +class MultiImageBlockComponent extends BlockComponentStatefulWidget { + const MultiImageBlockComponent({ + super.key, + required super.node, + super.showActions, + this.showMenu = false, + this.menuBuilder, + super.configuration = const BlockComponentConfiguration(), + super.actionBuilder, + }); + + final bool showMenu; + + final MultiImageBlockComponentMenuBuilder? menuBuilder; + + @override + State createState() => + MultiImageBlockComponentState(); +} + +class MultiImageBlockComponentState extends State + with SelectableMixin, BlockComponentConfigurable { + @override + BlockComponentConfiguration get configuration => widget.configuration; + + @override + Node get node => widget.node; + + final multiImageKey = GlobalKey(); + + RenderBox? get _renderBox => context.findRenderObject() as RenderBox?; + + late final editorState = Provider.of(context, listen: false); + + final showActionsNotifier = ValueNotifier(false); + + ValueNotifier indexNotifier = ValueNotifier(0); + + bool alwaysShowMenu = false; + + static const _interceptorKey = 'multi-image-block-interceptor'; + + late final interceptor = SelectionGestureInterceptor( + key: _interceptorKey, + canTap: (details) => _isTapInBounds(details.globalPosition), + canPanStart: (details) => _isTapInBounds(details.globalPosition), + ); + + @override + void initState() { + super.initState(); + editorState.selectionService.registerGestureInterceptor(interceptor); + } + + @override + void dispose() { + editorState.selectionService.unregisterGestureInterceptor(_interceptorKey); + super.dispose(); + } + + bool _isTapInBounds(Offset offset) { + if (_renderBox == null) { + // We shouldn't block any actions if the render box is not available. + // This has the potential to break taps on the editor completely if we + // accidentally return false here. + return true; + } + + final localPosition = _renderBox!.globalToLocal(offset); + return !_renderBox!.paintBounds.contains(localPosition); + } + + @override + Widget build(BuildContext context) { + final data = MultiImageData.fromJson( + node.attributes[MultiImageBlockKeys.images], + ); + + Widget child; + if (data.images.isEmpty) { + final multiImagePlaceholderKey = + node.extraInfos?[kMultiImagePlaceholderKey]; + + child = MultiImagePlaceholder( + key: multiImagePlaceholderKey is GlobalKey + ? multiImagePlaceholderKey + : null, + node: node, + ); + } else { + child = ImageLayoutRender( + node: node, + images: data.images, + editorState: editorState, + indexNotifier: indexNotifier, + isLocalMode: context.read().isLocalMode, + onIndexChanged: (index) => setState(() => indexNotifier.value = index), + ); + } + + if (PlatformExtension.isDesktopOrWeb) { + child = BlockSelectionContainer( + node: node, + delegate: this, + listenable: editorState.selectionNotifier, + blockColor: editorState.editorStyle.selectionColor, + supportTypes: const [BlockSelectionType.block], + child: Padding(key: multiImageKey, padding: padding, child: child), + ); + } else { + child = Padding(key: multiImageKey, padding: padding, child: child); + } + + if (widget.showActions && widget.actionBuilder != null) { + child = BlockComponentActionWrapper( + node: node, + actionBuilder: widget.actionBuilder!, + child: child, + ); + } + + if (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) { + return Stack( + children: [ + BlockSelectionContainer( + node: node, + delegate: this, + listenable: editorState.selectionNotifier, + cursorColor: editorState.editorStyle.cursorColor, + selectionColor: editorState.editorStyle.selectionColor, + child: child!, + ), + if (value && data.images.isNotEmpty) + widget.menuBuilder!( + widget.node, + this, + indexNotifier, + () => setState( + () => indexNotifier.value = indexNotifier.value > 0 + ? indexNotifier.value - 1 + : 0, + ), + ), + ], + ); + }, + child: child, + ), + ); + } + } else { + // show a fixed menu on mobile + child = MobileBlockActionButtons( + showThreeDots: false, + node: node, + editorState: editorState, + child: child, + ); + } + + return child; + } + + @override + Position start() => Position(path: widget.node.path); + + @override + Position end() => Position(path: widget.node.path, offset: 1); + + @override + Position getPositionInOffset(Offset start) => end(); + + @override + bool get shouldCursorBlink => false; + + @override + CursorStyle get cursorStyle => CursorStyle.cover; + + @override + Rect getBlockRect({ + bool shiftWithBaseOffset = false, + }) { + final imageBox = multiImageKey.currentContext?.findRenderObject(); + if (imageBox is RenderBox) { + return Offset.zero & imageBox.size; + } + return Rect.zero; + } + + @override + Rect? getCursorRectInPosition( + Position position, { + bool shiftWithBaseOffset = false, + }) { + final rects = getRectsInSelection(Selection.collapsed(position)); + return rects.firstOrNull; + } + + @override + List getRectsInSelection( + Selection selection, { + bool shiftWithBaseOffset = false, + }) { + if (_renderBox == null) { + return []; + } + final parentBox = context.findRenderObject(); + final imageBox = multiImageKey.currentContext?.findRenderObject(); + if (parentBox is RenderBox && imageBox is RenderBox) { + return [ + imageBox.localToGlobal(Offset.zero, ancestor: parentBox) & + imageBox.size, + ]; + } + return [Offset.zero & _renderBox!.size]; + } + + @override + Selection getSelectionInRange(Offset start, Offset end) => Selection.single( + path: widget.node.path, + startOffset: 0, + endOffset: 1, + ); + + @override + Offset localToGlobal( + Offset offset, { + bool shiftWithBaseOffset = false, + }) => + _renderBox!.localToGlobal(offset); +} + +/// The data for a multi-image block, primarily used for +/// serializing and deserializing the block's images. +/// +class MultiImageData { + factory MultiImageData.fromJson(List json) { + final images = json + .map((e) => ImageBlockData.fromJson(e as Map)) + .toList(); + return MultiImageData(images: images); + } + + MultiImageData({required this.images}); + + final List images; + + List toJson() => images.map((e) => e.toJson()).toList(); +} + +enum MultiImageLayout { + browser, + grid; + + int toIntValue() { + switch (this) { + case MultiImageLayout.browser: + return 0; + case MultiImageLayout.grid: + return 1; + } + } + + static MultiImageLayout fromIntValue(int value) { + switch (value) { + case 0: + return MultiImageLayout.browser; + case 1: + return MultiImageLayout.grid; + default: + throw UnimplementedError(); + } + } + + String get label => switch (this) { + browser => LocaleKeys.document_plugins_photoGallery_browserLayout.tr(), + grid => LocaleKeys.document_plugins_photoGallery_gridLayout.tr(), + }; + + FlowySvgData get icon => switch (this) { + browser => FlowySvgs.photo_layout_browser_s, + grid => FlowySvgs.photo_layout_grid_s, + }; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_menu.dart new file mode 100644 index 0000000000000..a8534b6fd3873 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_menu.dart @@ -0,0 +1,437 @@ +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/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, Log; +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/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.withOpacity(0.1), + ), + ], + borderRadius: BorderRadius.circular(4.0), + ), + child: Row( + children: [ + const HSpace(4), + AppFlowyPopover( + controller: controller, + direction: PopoverDirection.bottomWithRightAligned, + onClose: allowMenuClose, + constraints: const BoxConstraints( + maxWidth: 540, + maxHeight: 360, + minHeight: 80, + ), + offset: const Offset(0, 10), + popupBuilder: (context) { + preventMenuClose(); + return UploadImageMenu( + allowMultipleImages: true, + supportTypes: const [ + UploadImageType.local, + UploadImageType.url, + UploadImageType.unsplash, + + ], + onSelectedLocalImages: insertLocalImages, + onSelectedAIImage: insertAIImage, + onSelectedNetworkImage: insertNetworkImage, + ); + }, + child: MenuBlockButton( + tooltip: + LocaleKeys.document_plugins_photoGallery_addImageTooltip.tr(), + iconData: FlowySvgs.add_s, + onTap: () {}, + ), + ), + const HSpace(4), + AppFlowyPopover( + controller: layoutController, + onClose: allowMenuClose, + direction: PopoverDirection.bottomWithRightAligned, + offset: const Offset(0, 10), + constraints: const BoxConstraints( + maxHeight: 300, + maxWidth: 300, + ), + popupBuilder: (context) { + preventMenuClose(); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _LayoutSelector( + selectedLayout: layout, + onSelected: (layout) { + allowMenuClose(); + layoutController.close(); + final transaction = editorState.transaction; + transaction.updateNode(widget.node, { + MultiImageBlockKeys.images: + widget.node.attributes[MultiImageBlockKeys.images], + MultiImageBlockKeys.layout: layout.toIntValue(), + }); + editorState.apply(transaction); + }, + ), + ], + ); + }, + child: MenuBlockButton( + tooltip: LocaleKeys + .document_plugins_photoGallery_changeLayoutTooltip + .tr(), + iconData: FlowySvgs.edit_layout_s, + onTap: () {}, + ), + ), + const HSpace(4), + MenuBlockButton( + tooltip: LocaleKeys.document_imageBlock_openFullScreen.tr(), + iconData: FlowySvgs.full_view_s, + onTap: openFullScreen, + ), + + // disable the copy link button if the image is hosted on appflowy cloud + // because the url needs the verification token to be accessible + if (layout == MultiImageLayout.browser && + !images[widget.indexNotifier.value].url.isAppFlowyCloudUrl) ...[ + const HSpace(4), + MenuBlockButton( + tooltip: LocaleKeys.editor_copyLink.tr(), + iconData: FlowySvgs.copy_s, + onTap: copyImageLink, + ), + ], + const _Divider(), + MenuBlockButton( + tooltip: LocaleKeys.document_plugins_photoGallery_deleteBlockTooltip + .tr(), + iconData: FlowySvgs.delete_s, + onTap: deleteImage, + ), + const HSpace(4), + ], + ), + ); + } + + void copyImageLink() { + Clipboard.setData( + ClipboardData(text: images[widget.indexNotifier.value].url), + ); + showSnackBarMessage( + context, + LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(), + ); + } + + Future deleteImage() async { + final node = widget.node; + final editorState = context.read(); + final transaction = editorState.transaction; + transaction.deleteNode(node); + transaction.afterSelection = null; + await editorState.apply(transaction); + } + + void openFullScreen() { + showDialog( + context: context, + builder: (_) => InteractiveImageViewer( + userProfile: context.read().state.userProfilePB, + imageProvider: AFBlockImageProvider( + images: images, + initialIndex: widget.indexNotifier.value, + onDeleteImage: (index) async { + final transaction = editorState.transaction; + final newImages = List.from(images); + newImages.removeAt(index); + + images = newImages; + widget.onImageDeleted(); + + final imagesJson = + newImages.map((image) => image.toJson()).toList(); + transaction.updateNode(widget.node, { + MultiImageBlockKeys.images: imagesJson, + MultiImageBlockKeys.layout: + widget.node.attributes[MultiImageBlockKeys.layout], + }); + + await editorState.apply(transaction); + }, + ), + ), + ); + } + + void preventMenuClose() { + widget.state.alwaysShowMenu = true; + editorState.service.selectionService.registerGestureInterceptor( + gestureInterceptor, + ); + } + + void allowMenuClose() { + widget.state.alwaysShowMenu = false; + editorState.service.selectionService.unregisterGestureInterceptor( + _interceptorKey, + ); + } + + Future insertLocalImages(List urls) async { + controller.close(); + + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (urls.isEmpty || urls.every((path) => path?.isEmpty ?? true)) { + 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([copyToPath]); + await File(copyToPath).delete(); + } catch (e) { + Log.error('cannot save image file', e); + } + } + + Future insertNetworkImage(String url) async { + controller.close(); + + if (url.isEmpty || !isURL(url)) { + // show error + return showSnackBarMessage( + context, + LocaleKeys.document_imageBlock_error_invalidImage.tr(), + ); + } + + final transaction = editorState.transaction; + + final newImages = [ + ...images, + ImageBlockData(url: url, type: CustomImageType.external), + ]; + + final imagesJson = newImages.map((image) => image.toJson()).toList(); + transaction.updateNode(widget.node, { + MultiImageBlockKeys.images: imagesJson, + MultiImageBlockKeys.layout: + widget.node.attributes[MultiImageBlockKeys.layout], + }); + + await editorState.apply(transaction); + setState(() => images = newImages); + } +} + +class _Divider extends StatelessWidget { + const _Divider(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8), + child: Container(width: 1, color: Colors.grey), + ); + } +} + +class _LayoutSelector extends StatelessWidget { + const _LayoutSelector({ + required this.selectedLayout, + required this.onSelected, + }); + + final MultiImageLayout selectedLayout; + final Function(MultiImageLayout) onSelected; + + @override + Widget build(BuildContext context) { + return SeparatedRow( + separatorBuilder: () => const HSpace(6), + mainAxisSize: MainAxisSize.min, + children: MultiImageLayout.values + .map( + (layout) => MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => onSelected(layout), + child: Container( + height: 80, + width: 80, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + border: Border.all( + width: 2, + color: selectedLayout == layout + ? Theme.of(context).colorScheme.primary + : Theme.of(context).dividerColor, + ), + borderRadius: Corners.s8Border, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FlowySvg( + layout.icon, + color: AFThemeExtension.of(context).strongText, + size: const Size.square(24), + ), + const VSpace(6), + FlowyText(layout.label), + ], + ), + ), + ), + ), + ) + .toList(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart new file mode 100644 index 0000000000000..57e8c1142f8da --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart @@ -0,0 +1,301 @@ +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/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, Log; +import 'package:appflowy_popover/appflowy_popover.dart'; +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: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'; + +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( + PlatformExtension.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 (PlatformExtension.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: (paths) { + controller.close(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { + 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 (PlatformExtension.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: (paths) async { + context.pop(); + await insertLocalImages(paths); + }, + 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 20ef4593a9820..0000000000000 --- 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/ai_client.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/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_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, AIError>>? 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 58d5454b4bb11..defcb8b354620 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 @@ -1,14 +1,15 @@ import 'dart:io'; import 'dart:math'; +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/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_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:string_validator/string_validator.dart'; @@ -23,6 +24,7 @@ class ResizableImage extends StatefulWidget { required this.width, required this.src, this.height, + this.onDoubleTap, }); final String src; @@ -31,6 +33,7 @@ class ResizableImage extends StatefulWidget { final double? height; final Alignment alignment; final bool editable; + final VoidCallback? onDoubleTap; final void Function(double width) onResize; @@ -41,26 +44,23 @@ 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 void initState() { super.initState(); - imageWidth = widget.width; - _userProfilePB = context.read().state.userProfilePB; } @@ -72,13 +72,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), + ), ), ), ); @@ -97,12 +96,11 @@ class _ResizableImageState extends State { url: widget.src, width: imageWidth - moveDistance, userProfilePB: _userProfilePB, - errorWidgetBuilder: (context, url, error) => _ImageLoadFailedWidget( + progressIndicatorBuilder: (context, _, __) => _buildLoading(context), + errorWidgetBuilder: (_, __, error) => _ImageLoadFailedWidget( width: imageWidth, error: error, ), - progressIndicatorBuilder: (context, url, progress) => - _buildLoading(context), ); child = _cacheImage!; @@ -121,11 +119,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 +127,7 @@ class _ResizableImageState extends State { right: 5, bottom: 0, width: 5, - onUpdate: (distance) { - setState(() { - moveDistance = -distance; - }); - }, + onUpdate: (distance) => setState(() => moveDistance = -distance), ), ], ], @@ -154,9 +144,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 +172,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; } @@ -222,10 +210,7 @@ class _ResizableImageState extends State { } class _ImageLoadFailedWidget extends StatelessWidget { - const _ImageLoadFailedWidget({ - required this.width, - required this.error, - }); + const _ImageLoadFailedWidget({required this.width, required this.error}); final double width; final Object error; @@ -240,9 +225,7 @@ class _ImageLoadFailedWidget extends StatelessWidget { padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(4.0)), - border: Border.all( - color: Colors.grey.withOpacity(0.6), - ), + border: Border.all(color: Colors.grey.withOpacity(0.6)), ), child: Column( mainAxisSize: MainAxisSize.min, @@ -251,9 +234,7 @@ class _ImageLoadFailedWidget extends StatelessWidget { FlowySvgs.broken_image_xl, size: Size.square(48), ), - FlowyText( - AppFlowyEditorL10n.current.imageLoadFailed, - ), + FlowyText(AppFlowyEditorL10n.current.imageLoadFailed), const VSpace(6), if (error != null) FlowyText( 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 0d5d986d10b2a..0000000000000 --- 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 eda320bdb399d..2a71f44f57740 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 @@ -1,6 +1,7 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_search_text_field.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; import 'package:unsplash_client/unsplash_client.dart'; const _accessKeyA = 'YyD-LbW5bVolHWZBq5fWRM_'; @@ -48,7 +49,6 @@ class _UnsplashImageWidgetState extends State { @override void initState() { super.initState(); - randomPhotos = unsplash.photos .random(count: 18, orientation: PhotoOrientation.landscape) .goAndGet(); @@ -57,7 +57,6 @@ class _UnsplashImageWidgetState extends State { @override void dispose() { unsplash.close(); - super.dispose(); } @@ -132,18 +131,16 @@ class _UnsplashImagesState extends State<_UnsplashImages> { @override Widget build(BuildContext context) { + const mainAxisSpacing = 16.0; final crossAxisCount = switch (widget.type) { UnsplashImageType.halfScreen => 3, UnsplashImageType.fullScreen => 2, }; - final mainAxisSpacing = switch (widget.type) { - UnsplashImageType.halfScreen => 16.0, - UnsplashImageType.fullScreen => 16.0, - }; final crossAxisSpacing = switch (widget.type) { UnsplashImageType.halfScreen => 10.0, UnsplashImageType.fullScreen => 16.0, }; + return GridView.count( crossAxisCount: crossAxisCount, mainAxisSpacing: mainAxisSpacing, @@ -155,15 +152,11 @@ class _UnsplashImagesState extends State<_UnsplashImages> { return _UnsplashImage( type: widget.type, photo: photo, + isSelected: index == _selectedPhotoIndex, onTap: () { - widget.onSelectUnsplashImage( - photo.urls.regular.toString(), - ); - setState(() { - _selectedPhotoIndex = index; - }); + widget.onSelectUnsplashImage(photo.urls.regular.toString()); + setState(() => _selectedPhotoIndex = index); }, - isSelected: index == _selectedPhotoIndex, ); }).toList(), ); @@ -219,10 +212,7 @@ class _UnsplashImage extends StatelessWidget { ), ), const HSpace(2.0), - FlowyText( - 'by ${photo.name}', - fontSize: 10.0, - ), + FlowyText('by ${photo.name}', fontSize: 10.0), ], ); } @@ -233,14 +223,12 @@ class _UnsplashImage extends StatelessWidget { child: Stack( children: [ LayoutBuilder( - builder: (context, constraints) { - return Image.network( - photo.urls.thumb.toString(), - fit: BoxFit.cover, - width: constraints.maxWidth, - height: constraints.maxHeight, - ); - }, + builder: (_, constraints) => Image.network( + photo.urls.thumb.toString(), + fit: BoxFit.cover, + width: constraints.maxWidth, + height: constraints.maxHeight, + ), ), Positioned( bottom: 9, @@ -261,13 +249,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/upload_image_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart similarity index 61% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart rename to frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart index ee8f681ff5b4f..a81abf368bd12 100644 --- 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/upload_image_menu.dart @@ -1,24 +1,23 @@ +import 'package:flutter/material.dart'; + 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/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/image/upload_image_menu/widgets/stability_ai_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/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'; + +import 'widgets/embed_image_url_widget.dart'; enum UploadImageType { local, url, unsplash, - stabilityAI, - // openAI, color; String get description { @@ -29,10 +28,6 @@ enum UploadImageType { 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(); } @@ -42,20 +37,22 @@ enum UploadImageType { class UploadImageMenu extends StatefulWidget { const UploadImageMenu({ super.key, - required this.onSelectedLocalImage, + required this.onSelectedLocalImages, required this.onSelectedAIImage, required this.onSelectedNetworkImage, this.onSelectedColor, this.supportTypes = UploadImageType.values, this.limitMaximumImageSize = false, + this.allowMultipleImages = false, }); - final void Function(String? path) onSelectedLocalImage; + 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(); @@ -64,33 +61,12 @@ class UploadImageMenu extends StatefulWidget { 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 @@ -133,9 +109,7 @@ class _UploadImageMenuState extends State { }, ).toList(), ), - const Divider( - height: 2, - ), + const Divider(height: 2), _buildTab(), ], ), @@ -148,26 +122,36 @@ class _UploadImageMenuState extends State { 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, - ), - ], - ], - ), + Widget child = UploadImageFileWidget( + allowMultipleImages: widget.allowMultipleImages, + onPickFiles: widget.onSelectedLocalImages, ); + if (PlatformExtension.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), @@ -185,40 +169,6 @@ class _UploadImageMenuState extends State { ), ), ); - // 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 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/upload_image_menu/widgets/embed_image_url_widget.dart similarity index 52% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/embed_image_url_widget.dart rename to frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart index d34a4fc3a8fd0..4fa4ec7319b8a 100644 --- 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/upload_image_menu/widgets/embed_image_url_widget.dart @@ -1,9 +1,9 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/patterns/common_patterns.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'; class EmbedImageUrlWidget extends StatefulWidget { const EmbedImageUrlWidget({ @@ -23,33 +23,58 @@ class _EmbedImageUrlWidgetState extends State { @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: [ - FlowyTextField( - hintText: LocaleKeys.document_imageBlock_embedLink_placeholder.tr(), - onChanged: (value) => inputText = value, - onEditingComplete: submit, - ), + const VSpace(12), + PlatformExtension.isDesktop + ? textField + : SizedBox( + height: 42, + child: textField, + ), if (!isUrlValid) ...[ - const VSpace(8), + const VSpace(12), FlowyText( LocaleKeys.document_plugins_cover_invalidImageUrl.tr(), color: Theme.of(context).colorScheme.error, ), ], - const VSpace(8), + const VSpace(20), SizedBox( - width: 160, + height: PlatformExtension.isMobile ? 36 : 32, + width: 300, child: FlowyButton( + backgroundColor: Theme.of(context).colorScheme.primary, + hoverColor: Theme.of(context).colorScheme.primary.withOpacity(0.9), showDefaultBoxDecorationOnMobile: true, - margin: const EdgeInsets.all(8.0), + radius: + PlatformExtension.isMobile ? BorderRadius.circular(8) : null, + margin: const EdgeInsets.all(5), text: FlowyText( LocaleKeys.document_imageBlock_embedLink_label.tr(), + lineHeight: 1, textAlign: TextAlign.center, + color: PlatformExtension.isMobile + ? null + : Theme.of(context).colorScheme.onPrimary, + fontSize: PlatformExtension.isMobile ? 14 : null, ), onTap: submit, ), ), + const VSpace(8), ], ); } 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_menu/widgets/upload_image_file_widget.dart similarity index 75% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_file_widget.dart rename to frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/upload_image_file_widget.dart index d4d94be091e1b..991a7cb0b953f 100644 --- 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_menu/widgets/upload_image_file_widget.dart @@ -13,17 +13,20 @@ import 'package:image_picker/image_picker.dart'; class UploadImageFileWidget extends StatelessWidget { const UploadImageFileWidget({ super.key, - required this.onPickFile, + required this.onPickFiles, this.allowedExtensions = const ['jpg', 'png', 'jpeg'], + this.allowMultipleImages = false, }); - final void Function(String? path) onPickFile; + final void Function(List) onPickFiles; final List allowedExtensions; + final bool allowMultipleImages; @override Widget build(BuildContext context) { - final child = FlowyButton( + Widget child = FlowyButton( showDefaultBoxDecorationOnMobile: true, + radius: PlatformExtension.isMobile ? BorderRadius.circular(8.0) : null, text: Container( margin: const EdgeInsets.all(4.0), alignment: Alignment.center, @@ -35,7 +38,10 @@ class UploadImageFileWidget extends StatelessWidget { ); if (PlatformExtension.isDesktopOrWeb) { - return FlowyHover( + child = FlowyHover(child: child); + } else { + child = Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), child: child, ); } @@ -50,8 +56,9 @@ class UploadImageFileWidget extends StatelessWidget { dialogTitle: '', type: FileType.custom, allowedExtensions: allowedExtensions, + allowMultiple: allowMultipleImages, ); - onPickFile(result?.files.firstOrNull?.path); + onPickFiles(result?.files.map((f) => f.path).toList() ?? const []); } else { final photoPermission = await PermissionChecker.checkPhotoPermission(context); @@ -60,8 +67,8 @@ class UploadImageFileWidget extends StatelessWidget { 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); + final result = await ImagePicker().pickMultiImage(); + onPickFiles(result.map((f) => f.path).toList()); } } } 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 08c23df05b684..a66a9ee317524 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', + id: _kInlineMathEquationToolbarItemId, group: 2, 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) { @@ -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/link_preview/link_preview_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart index a83fbed58946e..d2c84fe456238 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,3 +1,6 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart'; @@ -7,10 +10,10 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import '../image/custom_image_block_component/custom_image_block_component.dart'; + class LinkPreviewMenu extends StatefulWidget { const LinkPreviewMenu({ super.key, @@ -72,7 +75,7 @@ class _LinkPreviewMenuState extends State { } void copyImageLink() { - final url = widget.node.attributes[ImageBlockKeys.url]; + final url = widget.node.attributes[CustomImageBlockKeys.url]; if (url != null) { Clipboard.setData(ClipboardData(text: url)); showSnackBarMessage( 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 c7d298ff09add..b5631735e565a 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,7 @@ +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_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -37,7 +39,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, @@ -167,11 +173,12 @@ 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(), - ), + LocaleKeys.document_plugins_mathEquation_addMathEquation.tr(), + color: Theme.of(context).hintColor,), ], ), ); 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 29007ada9804f..c66f553bc2cc2 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,10 +1,9 @@ -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'; enum MentionType { @@ -74,7 +73,11 @@ 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(); + } + return MentionPageBlock( key: ValueKey(pageId), editorState: editorState, @@ -94,6 +97,7 @@ class MentionBlock extends StatelessWidget { editorState: editorState, date: date, node: node, + textStyle: textStyle, index: index, reminderId: mention[MentionBlockKeys.reminderId], reminderOption: reminderOption, 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 75261263d3c1a..9f2a16b0cf22d 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 @@ -1,14 +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/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'; @@ -27,6 +25,8 @@ 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'; @@ -37,6 +37,7 @@ class MentionDateBlock extends StatefulWidget { required this.date, required this.index, required this.node, + this.textStyle, this.reminderId, this.reminderOption, this.includeTime = false, @@ -55,6 +56,8 @@ class MentionDateBlock extends StatefulWidget { final bool includeTime; + final TextStyle? textStyle; + @override State createState() => _MentionDateBlockState(); } @@ -77,8 +80,6 @@ class _MentionDateBlockState extends State { return const SizedBox.shrink(); } - final fontSize = context.read().state.fontSize; - return MultiBlocProvider( providers: [ BlocProvider.value(value: context.read()), @@ -163,97 +164,54 @@ class _MentionDateBlockState extends State { _updateReminder(reminderOption, reminder), ); + Color? color; + if (reminder != null) { + if (reminder.type == ReminderType.today) { + color = Theme.of(context).isLightMode + ? const Color(0xFFFE0299) + : Theme.of(context).colorScheme.error; + } + } + final textStyle = widget.textStyle?.copyWith( + color: color, + leadingDistribution: TextLeadingDistribution.even, + ); + + // when font size equals 14, the icon size is 16.0. + // scale the icon size based on the font size. + final iconSize = (widget.textStyle?.fontSize ?? 14.0) / 14.0 * 16.0; + return GestureDetector( onTapDown: (details) { - 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); - } - } + _showDatePicker( + context: context, + offset: details.globalPosition, + reminder: reminder, + timeStr: timeStr, + 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, - ), - ], - ), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '@$formattedDate', + style: textStyle, + strutStyle: textStyle != null + ? StrutStyle.fromTextStyle(textStyle) + : null, + ), + const HSpace(4), + FlowySvg( + widget.reminderId != null + ? FlowySvgs.reminder_clock_s + : FlowySvgs.date_s, + size: Size.square(iconSize), + color: textStyle?.color, + ), + ], ), ), ); @@ -350,6 +308,7 @@ class _MentionDateBlockState extends State { ReminderUpdate( id: widget.reminderId!, scheduledAt: reminderOption.fromDate(parsedDate!), + date: parsedDate!, ), ), ); @@ -375,6 +334,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()), @@ -382,4 +343,109 @@ class _MentionDateBlockState extends State { ), ); } + + void _showDatePicker({ + required BuildContext context, + required DatePickerOptions options, + required Offset offset, + String? timeStr, + ReminderPB? reminder, + }) { + if (!widget.editorState.editable) { + return; + } + if (PlatformExtension.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, + timeStr: timeStr, + 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.timeStr, + required this.options, + required this.includeTime, + this.reminderOption, + required this.onReminderSelected, + }); + + final ScrollController controller; + final DateTime? parsedDate; + final String? timeStr; + final DatePickerOptions options; + final bool includeTime; + final ReminderOption? reminderOption; + final void Function(ReminderOption) onReminderSelected; + + @override + Widget build(BuildContext context) { + final appearance = context.read().state; + + 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( + 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: reminderOption, + onDaySelected: options.onDaySelected, + onStartTimeChanged: (time) => + options.onStartTimeChanged?.call(time ?? ""), + onIncludeTimeChanged: options.onIncludeTimeChanged, + liveDateFormatter: (selected) => appearance.dateFormat.formatDate( + selected, + false, + appearance.timeFormat, + ), + onReminderSelected: onReminderSelected, + ), + ], + ), + ); + } } 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 6755be96905ae..39084c7fa30a7 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,6 +1,6 @@ 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/mention/mention_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mobile_page_selector_sheet.dart'; @@ -21,6 +21,7 @@ import 'package:appflowy_editor/appflowy_editor.dart' TextTransaction, paragraphNode; import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; @@ -104,53 +105,27 @@ class _MentionPageBlockState extends State { final view = state.data; // memorize the result pageMemorizer[widget.pageId] = view; + if (view == null) { - return const SizedBox.shrink(); + return _NoAccessMentionPageBlock( + textStyle: widget.textStyle, + ); } - final iconSize = widget.textStyle?.fontSize ?? 16.0; - final 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 (PlatformExtension.isMobile) { - return child; + return _MobileMentionPageBlock( + view: view, + textStyle: widget.textStyle, + handleTap: handleTap, + handleDoubleTap: handleDoubleTap, + ); + } else { + return _DesktopMentionPageBlock( + view: view, + textStyle: widget.textStyle, + handleTap: handleTap, + ); } - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: FlowyHover( - cursor: SystemMouseCursors.click, - child: child, - ), - ); }, ); } @@ -232,3 +207,127 @@ class _MentionPageBlockState extends State { }); } } + +class _MentionPageBlockContent extends StatelessWidget { + const _MentionPageBlockContent({ + required this.view, + required this.textStyle, + }); + + final ViewPB view; + final TextStyle? textStyle; + + @override + Widget build(BuildContext context) { + final emojiSize = textStyle?.fontSize ?? 12.0; + final iconSize = textStyle?.fontSize ?? 16.0; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + const HSpace(4), + view.icon.value.isNotEmpty + ? FlowyText.emoji( + view.icon.value, + fontSize: emojiSize, + lineHeight: textStyle?.height, + optimizeEmojiAlign: true, + ) + : FlowySvg( + view.layout.icon, + size: Size.square(iconSize + 2.0), + ), + const HSpace(2), + FlowyText( + view.name, + decoration: TextDecoration.underline, + fontSize: textStyle?.fontSize, + fontWeight: textStyle?.fontWeight, + lineHeight: textStyle?.height, + ), + const HSpace(4), + ], + ); + } +} + +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 _MobileMentionPageBlock extends StatelessWidget { + const _MobileMentionPageBlock({ + required this.view, + required this.textStyle, + required this.handleTap, + required this.handleDoubleTap, + }); + + final TextStyle? textStyle; + final ViewPB view; + final VoidCallback handleTap; + final VoidCallback handleDoubleTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: handleTap, + onDoubleTap: handleDoubleTap, + behavior: HitTestBehavior.opaque, + child: _MentionPageBlockContent( + view: view, + textStyle: textStyle, + ), + ); + } +} + +class _DesktopMentionPageBlock extends StatelessWidget { + const _DesktopMentionPageBlock({ + required this.view, + required this.textStyle, + required this.handleTap, + }); + + final TextStyle? textStyle; + final ViewPB view; + final VoidCallback handleTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: handleTap, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: FlowyHover( + cursor: SystemMouseCursors.click, + child: _MentionPageBlockContent( + view: view, + textStyle: textStyle, + ), + ), + ), + ); + } +} 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 d15d24aab7d12..ae493d402a6c5 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,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/base/flowy_search_text_field.dart'; @@ -11,13 +9,17 @@ 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, @@ -32,16 +34,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 +87,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); } @@ -118,7 +129,7 @@ class _MobilePageSelectorBodyState extends State<_MobilePageSelectorBody> { ), text: view.name, isSelected: view.id == widget.selectedViewId, - onTap: () => Navigator.of(context).pop(view.id), + onTap: () => Navigator.of(context).pop(view), ), ) .toList(), 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 index 8216ea5ab3bd5..1a53c63ca2dcf 100644 --- 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 @@ -14,10 +14,10 @@ SelectionMenuItem dateMenuItem = SelectionMenuItem( ), keywords: ['insert date', 'date', 'time'], handler: (editorState, menuService, context) => - _insertDateReference(editorState), + insertDateReference(editorState), ); -Future _insertDateReference(EditorState editorState) async { +Future insertDateReference(EditorState editorState) async { final selection = editorState.selection; if (selection == null || !selection.isCollapsed) { return; 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 e3b320a63db9e..ba170e8d24c07 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,6 +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'; @@ -23,7 +24,7 @@ List buildMobileFloatingToolbarItems( ContextMenuButtonItem( label: LocaleKeys.editor_copy.tr(), onPressed: () { - copyCommand.execute(editorState); + customCopyCommand.execute(editorState); closeToolbar(); }, ), @@ -34,7 +35,7 @@ List buildMobileFloatingToolbarItems( ContextMenuButtonItem( label: LocaleKeys.editor_paste.tr(), onPressed: () { - pasteCommand.execute(editorState); + customPasteCommand.execute(editorState); closeToolbar(); }, ), 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 b1004a3eae82b..96431996f5a4d 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_block_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart index d0be5af466aee..d09e2be349f7e 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,12 +1,12 @@ 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/image/multi_image_block_component/multi_image_block_component.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_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'; @@ -19,6 +19,7 @@ import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; final addBlockToolbarItem = AppFlowyMobileToolbarItem( @@ -196,6 +197,19 @@ class _AddBlockMenu extends StatelessWidget { }); }, ), + 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); + }); + }, + ), // date TypeOptionMenuItemValue( @@ -215,16 +229,16 @@ class _AddBlockMenu extends StatelessWidget { AppGlobals.rootNavKey.currentContext?.pop(true); final currentViewId = getIt().latestOpenView?.id; - final viewId = await showPageSelectorSheet( + final view = await showPageSelectorSheet( context, currentViewId: currentViewId, ); - if (viewId != null) { + if (view != null) { Future.delayed(const Duration(milliseconds: 100), () { editorState.insertBlockAfterCurrentSelection( selection, - pageMentionNode(viewId), + pageMentionNode(view.id), ); }); } 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 894dde0442395..6d7d0860ef1a4 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 @@ -245,12 +245,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), ], 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 d138e644cd9a5..12e7d1bef7a9c 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/numbered_list/numbered_list_icon.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/numbered_list/numbered_list_icon.dart index 8e638736416f0..211d6740b1db4 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 @@ -16,18 +16,22 @@ class NumberedListIcon extends StatelessWidget { @override Widget build(BuildContext context) { final textStyle = - context.read().editorStyle.textStyleConfiguration.text; + context.read().editorStyle.textStyleConfiguration; + final fontSize = textStyle.text.fontSize ?? 16.0; + final height = textStyle.text.height ?? textStyle.lineHeight; + final size = fontSize * height; return Container( - constraints: const BoxConstraints( - minWidth: 22, - minHeight: 22, + constraints: BoxConstraints( + minWidth: size, + minHeight: size, ), margin: const EdgeInsets.only(right: 8.0), alignment: Alignment.center, child: Center( child: Text( node.levelString, - style: textStyle, + style: textStyle.text, + strutStyle: StrutStyle.fromTextStyle(textStyle.text), textDirection: textDirection, ), ), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/ai_client.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/ai_client.dart index 19d68f58ba936..801f15a77f658 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/ai_client.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/ai_client.dart @@ -1,6 +1,6 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/error.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/text_completion.dart'; -import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; abstract class AIRepository { 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 index b2115ff5d73b3..3b52963125324 100644 --- 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 @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/ai_client.dart'; -import 'package:appflowy_backend/protobuf/flowy-chat/entities.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbenum.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:http/http.dart' as http; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ai_limit_dialog.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ai_limit_dialog.dart index 218240ab63365..3a57a7f49c789 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ai_limit_dialog.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ai_limit_dialog.dart @@ -1,35 +1,13 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; - -class _AILimitDialog extends StatelessWidget { - const _AILimitDialog({ - required this.message, - required this.onOkPressed, - }); - final VoidCallback onOkPressed; - final String message; - - @override - Widget build(BuildContext context) { - return NavigatorOkCancelDialog( - message: message, - okTitle: LocaleKeys.button_ok.tr(), - onOkPressed: onOkPressed, - titleUpperCase: false, - ); - } -} void showAILimitDialog(BuildContext context, String message) { - showDialog( + showConfirmDialog( context: context, - barrierDismissible: false, - useRootNavigator: false, - builder: (dialogContext) => _AILimitDialog( - message: message, - onOkPressed: () {}, - ), + title: LocaleKeys.sideBar_aiResponseLimitDialogTitle.tr(), + description: message, ); } 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 index 5534ca94b8f75..63735fe3b67d0 100644 --- 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 @@ -1,5 +1,7 @@ +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/build_context_extension.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/text_robot.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/error.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/discard_dialog.dart'; @@ -7,14 +9,10 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/wid import 'package:appflowy/user/application/ai_service.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; -import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/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:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -45,8 +43,12 @@ Node autoCompletionNode({ SelectionMenuItem autoGeneratorMenuItem = SelectionMenuItem.node( getName: LocaleKeys.document_plugins_autoGeneratorMenuItemName.tr, - iconData: Icons.generating_tokens, - keywords: ['ai', 'openai' 'writer', 'autogenerator'], + iconBuilder: (editorState, onSelected, style) => SelectableSvgWidget( + data: FlowySvgs.menu_item_ai_writer_s, + isSelected: onSelected, + style: style, + ), + keywords: ['ai', 'openai', 'writer', 'ai writer', 'autogenerator'], nodeBuilder: (editorState, _) { final node = autoCompletionNode(start: editorState.selection!); return node; @@ -130,14 +132,20 @@ class _AutoCompletionBlockComponentState _unsubscribeSelectionGesture(); controller.dispose(); textFieldFocusNode.dispose(); - super.dispose(); } @override Widget build(BuildContext context) { - return Card( + if (PlatformExtension.isMobile) { + return const SizedBox.shrink(); + } + + final child = Card( elevation: 5, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), color: Theme.of(context).colorScheme.surface, child: Container( margin: const EdgeInsets.all(10), @@ -164,6 +172,11 @@ class _AutoCompletionBlockComponentState ), ), ); + + return Padding( + padding: const EdgeInsets.only(left: 40), + child: child, + ); } Widget _buildInputWidget(BuildContext context) { @@ -181,9 +194,8 @@ class _AutoCompletionBlockComponentState final transaction = editorState.transaction..deleteNode(widget.node); await editorState.apply( transaction, - options: const ApplyOptions( - recordUndo: false, - ), + options: const ApplyOptions(recordUndo: false), + withUpdateSelection: false, ); } @@ -230,6 +242,7 @@ class _AutoCompletionBlockComponentState if (mounted) { if (error.isLimitExceeded) { showAILimitDialog(context, error.message); + await _onDiscard(); } else { showSnackBarMessage( context, @@ -417,12 +430,10 @@ class _AutoCompletionBlockComponentState // show dialog showDialog( context: context, - builder: (context) { - return DiscardDialog( - onConfirm: () => _onDiscard(), - onCancel: () {}, - ); - }, + builder: (_) => DiscardDialog( + onConfirm: _onDiscard, + onCancel: () {}, + ), ); } else if (controller.text.isEmpty) { _onExit(); @@ -445,9 +456,7 @@ class _AutoCompletionBlockComponentState } class AutoCompletionHeader extends StatelessWidget { - const AutoCompletionHeader({ - super.key, - }); + const AutoCompletionHeader({super.key}); @override Widget build(BuildContext context) { @@ -471,23 +480,34 @@ class AutoCompletionInputFooter extends StatelessWidget { @override Widget build(BuildContext context) { return Row( + mainAxisSize: MainAxisSize.min, children: [ - PrimaryTextButton( - LocaleKeys.button_generate.tr(), - onPressed: onGenerate, + PrimaryRoundedButton( + text: LocaleKeys.button_generate.tr(), + margin: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 10.0, + ), + radius: 8.0, + onTap: onGenerate, ), const Space(10, 0), - SecondaryTextButton( - LocaleKeys.button_cancel.tr(), - onPressed: onExit, + OutlinedRoundedButton( + text: LocaleKeys.button_cancel.tr(), + margin: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 10.0, + ), + onTap: onExit, ), - Expanded( + Flexible( child: Container( alignment: Alignment.centerRight, child: FlowyText.regular( LocaleKeys.document_plugins_warning.tr(), color: Theme.of(context).hintColor, overflow: TextOverflow.ellipsis, + fontSize: 12, ), ), ), @@ -512,19 +532,23 @@ class AutoCompletionFooter extends StatelessWidget { Widget build(BuildContext context) { return Row( children: [ - PrimaryTextButton( - LocaleKeys.button_keep.tr(), - onPressed: onKeep, + PrimaryRoundedButton( + text: LocaleKeys.button_keep.tr(), + margin: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 9.0, + ), + onTap: onKeep, ), - const Space(10, 0), - SecondaryTextButton( - LocaleKeys.document_plugins_autoGeneratorRewrite.tr(), - onPressed: onRewrite, + const HSpace(10), + OutlinedRoundedButton( + text: LocaleKeys.document_plugins_autoGeneratorRewrite.tr(), + onTap: onRewrite, ), - const Space(10, 0), - SecondaryTextButton( - LocaleKeys.button_discard.tr(), - onPressed: onDiscard, + const HSpace(10), + OutlinedRoundedButton( + text: LocaleKeys.button_discard.tr(), + onTap: onDiscard, ), ], ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart index 66ce0bef5f174..1215a66dae791 100644 --- 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 @@ -8,12 +8,22 @@ class Loading { 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!).pop(); + loadingContext = null; + }); + } + return const SimpleDialog( elevation: 0.0, backgroundColor: @@ -33,6 +43,8 @@ class Loading { Navigator.of(loadingContext!).pop(); loadingContext = null; } + + hasStopped = true; } } 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 index 299aa3a6d1d37..04e83fb758bf7 100644 --- 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 @@ -12,7 +12,6 @@ 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/decoration.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:provider/provider.dart'; @@ -118,10 +117,6 @@ class _SmartEditBlockComponentWidgetState triggerActions: PopoverTriggerFlags.none, margin: EdgeInsets.zero, constraints: BoxConstraints(maxWidth: width), - decoration: FlowyDecoration.decoration( - Colors.transparent, - Colors.transparent, - ), child: const SizedBox( width: double.infinity, ), 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 index 84d122cf1864a..f50dcd5fbd08d 100644 --- 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 @@ -1,3 +1,4 @@ +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/smart_edit_action.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; @@ -8,15 +9,18 @@ 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:flowy_infra_ui/style_widget/icon_button.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; +const _kSmartEditToolbarItemId = 'appflowy.editor.smart_edit'; + final ToolbarItem smartEditItem = ToolbarItem( - id: 'appflowy.editor.smart_edit', + id: _kSmartEditToolbarItemId, group: 0, isActive: onlyShowInSingleSelectionAndTextType, - builder: (context, editorState, _, __) => SmartEditActionList( + builder: (context, editorState, _, __, tooltipBuilder) => SmartEditActionList( editorState: editorState, + tooltipBuilder: tooltipBuilder, ), ); @@ -24,9 +28,11 @@ class SmartEditActionList extends StatefulWidget { const SmartEditActionList({ super.key, required this.editorState, + this.tooltipBuilder, }); final EditorState editorState; + final ToolbarTooltipBuilder? tooltipBuilder; @override State createState() => _SmartEditActionListState(); @@ -60,18 +66,21 @@ class _SmartEditActionListState extends State { onClosed: () => keepEditorFocusNotifier.decrease(), buildChild: (controller) { keepEditorFocusNotifier.increase(); - return FlowyIconButton( + final child = FlowyButton( + text: FlowyText.regular( + LocaleKeys.document_plugins_smartEdit.tr(), + fontSize: 13.0, + figmaLineHeight: 16.0, + color: Colors.white, + ), hoverColor: Colors.transparent, - tooltipText: isAIEnabled - ? LocaleKeys.document_plugins_smartEdit.tr() - : LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(), - preferBelow: false, - icon: const Icon( - Icons.lightbulb_outline, - size: 15, + useIntrinsicWidth: true, + leftIcon: const FlowySvg( + FlowySvgs.toolbar_item_ai_s, + size: Size.square(16.0), color: Colors.white, ), - onPressed: () { + onTap: () { if (isAIEnabled) { controller.show(); } else { @@ -83,6 +92,19 @@ class _SmartEditActionListState extends State { } }, ); + + if (widget.tooltipBuilder != null) { + return widget.tooltipBuilder!( + context, + _kSmartEditToolbarItemId, + isAIEnabled + ? LocaleKeys.document_plugins_smartEdit.tr() + : LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(), + child, + ); + } + + return child; }, onSelected: (action, controller) { controller.close(); 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 c90e30ac34c32..27498cc65e8e9 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,6 +1,8 @@ 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'; @@ -23,7 +25,6 @@ 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'; @@ -239,19 +240,15 @@ class PageStyleCoverImage extends StatelessWidget { 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), ), ); } @@ -282,10 +279,7 @@ class PageStyleCoverImage extends StatelessWidget { }, builder: (_) { return ConstrainedBox( - constraints: BoxConstraints( - maxHeight: maxHeight, - minHeight: 80, - ), + constraints: BoxConstraints(maxHeight: maxHeight, minHeight: 80), child: BlocProvider.value( value: pageStyleBloc, child: Padding( 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 a33d99fd827e3..91398302ed8c0 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,5 +1,7 @@ import 'package:appflowy_editor/appflowy_editor.dart'; +import '../image/custom_image_block_component/custom_image_block_component.dart'; + class CustomImageNodeParser extends NodeParser { const CustomImageNodeParser(); @@ -9,7 +11,7 @@ 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'; } 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 8afef3ec0fe90..abc05d242cb76 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 @@ -5,6 +5,7 @@ export 'base/toolbar_extension.dart'; export 'bulleted_list/bulleted_list_icon.dart'; export 'callout/callout_block_component.dart'; export 'code_block/code_block_language_selector.dart'; +export 'code_block/code_block_menu_item.dart'; export 'context_menu/custom_context_menu.dart'; export 'copy_and_paste/custom_copy_command.dart'; export 'copy_and_paste/custom_cut_command.dart'; @@ -14,14 +15,17 @@ 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 'heading/heading_toolbar_item.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_menu.dart'; export 'inline_math_equation/inline_math_equation.dart'; export 'inline_math_equation/inline_math_equation_toolbar_item.dart'; export 'link_preview/custom_link_preview.dart'; @@ -47,6 +51,8 @@ export 'openai/widgets/smart_edit_node_widget.dart'; export 'openai/widgets/smart_edit_toolbar_item.dart'; export 'outline/outline_block_component.dart'; export 'parsers/markdown_parsers.dart'; +export 'quote/quote_block_shortcuts.dart'; +export 'slash_menu/slash_menu_items.dart'; export 'table/table_menu.dart'; export 'table/table_option_action.dart'; export 'todo_list/todo_list_icon.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/quote/quote_block_shortcuts.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/quote/quote_block_shortcuts.dart new file mode 100644 index 0000000000000..ba3ad6e7dfd68 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/quote/quote_block_shortcuts.dart @@ -0,0 +1,39 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/services.dart'; + +/// Pressing Enter in a quote block will insert a newline (\n) within the quote, +/// while pressing Shift+Enter in a quote will insert a new paragraph next to the quote. +/// +/// - support +/// - desktop +/// - mobile +/// - web +/// +final CharacterShortcutEvent insertNewLineInQuoteBlock = CharacterShortcutEvent( + key: 'insert a new line in quote block', + character: '\n', + handler: _insertNewLineHandler, +); + +CharacterShortcutEventHandler _insertNewLineHandler = (editorState) async { + final selection = editorState.selection?.normalized; + if (selection == null) { + return false; + } + + final node = editorState.getNodeAtPath(selection.start.path); + if (node == null || node.type != QuoteBlockKeys.type) { + return false; + } + + // delete the selection + await editorState.deleteSelection(selection); + + if (HardwareKeyboard.instance.isShiftPressed) { + await editorState.insertNewLine(); + } else { + await editorState.insertTextAtCurrentSelection('\n'); + } + + return true; +}; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items.dart new file mode 100644 index 0000000000000..09ea9faa859da --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items.dart @@ -0,0 +1,560 @@ +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/plugins/document/presentation/editor_plugins/image/image_placeholder.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/workspace/application/view/view_service.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_menu_item.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/style_widget/text.dart'; +import 'package:flutter/material.dart'; + +// text menu item +final textSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_text.tr(), + nameBuilder: _slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_text_s, + isSelected: isSelected, + style: style, + ), + keywords: ['text', 'paragraph'], + handler: (editorState, _, __) { + insertNodeAfterSelection(editorState, paragraphNode()); + }, +); + +// heading 1 - 3 menu items +final heading1SlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_heading1.tr(), + nameBuilder: _slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_h1_s, + isSelected: isSelected, + style: style, + ), + keywords: ['heading 1', 'h1', 'heading1'], + handler: (editorState, _, __) { + insertHeadingAfterSelection(editorState, 1); + }, +); + +final heading2SlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_heading2.tr(), + nameBuilder: _slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_h2_s, + isSelected: isSelected, + style: style, + ), + keywords: ['heading 2', 'h2', 'heading2'], + handler: (editorState, _, __) { + insertHeadingAfterSelection(editorState, 2); + }, +); + +final heading3SlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_heading3.tr(), + nameBuilder: _slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_h3_s, + isSelected: isSelected, + style: style, + ), + keywords: ['heading 3', 'h3', 'heading3'], + handler: (editorState, _, __) { + insertHeadingAfterSelection(editorState, 3); + }, +); + +// image menu item +final imageSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_image.tr(), + nameBuilder: _slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_image_s, + isSelected: isSelected, + style: style, + ), + keywords: ['image', 'photo', 'picture', 'img'], + handler: (editorState, menuService, context) async { + // use the key to retrieve the state of the image block to show the popover automatically + final imagePlaceholderKey = GlobalKey(); + await editorState.insertEmptyImageBlock(imagePlaceholderKey); + + WidgetsBinding.instance.addPostFrameCallback((_) { + imagePlaceholderKey.currentState?.controller.show(); + }); + }, +); + +// bulleted list menu item +final bulletedListSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_bulletedList.tr(), + nameBuilder: _slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_bulleted_list_s, + isSelected: isSelected, + style: style, + ), + keywords: ['bulleted list', 'list', 'unordered list'], + handler: (editorState, _, __) { + insertBulletedListAfterSelection(editorState); + }, +); + +// numbered list menu item +final numberedListSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_numberedList.tr(), + nameBuilder: _slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_numbered_list_s, + isSelected: isSelected, + style: style, + ), + keywords: ['numbered list', 'list', 'ordered list'], + handler: (editorState, _, __) { + insertNumberedListAfterSelection(editorState); + }, +); + +// todo list menu item +final todoListSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_todoList.tr(), + nameBuilder: _slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_checkbox_s, + isSelected: isSelected, + style: style, + ), + keywords: ['checkbox', 'todo', 'list', 'to-do', 'task'], + handler: (editorState, _, __) { + insertCheckboxAfterSelection(editorState); + }, +); + +// quote menu item +final quoteSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_quote.tr(), + nameBuilder: _slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_quote_s, + isSelected: isSelected, + style: style, + ), + keywords: ['quote', 'refer', 'blockquote', 'citation'], + handler: (editorState, _, __) { + insertQuoteAfterSelection(editorState); + }, +); + +// divider menu item +final dividerSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_divider.tr(), + nameBuilder: _slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_divider_s, + isSelected: isSelected, + style: style, + ), + keywords: ['divider', 'separator', 'line', 'break', 'horizontal line'], + handler: (editorState, _, __) { + final selection = editorState.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + final path = selection.end.path; + final node = editorState.getNodeAtPath(path); + final delta = node?.delta; + if (node == null || delta == null) { + return; + } + final insertedPath = delta.isEmpty ? path : path.next; + final transaction = editorState.transaction + ..insertNode(insertedPath, dividerNode()) + ..insertNode(insertedPath, paragraphNode()) + ..afterSelection = Selection.collapsed(Position(path: insertedPath.next)); + editorState.apply(transaction); + }, +); + +// grid & board & calendar menu item +SelectionMenuItem gridSlashMenuItem(DocumentBloc documentBloc) { + return SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_grid.tr(), + nameBuilder: _slashMenuItemNameBuilder, + icon: (editorState, onSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_grid_s, + isSelected: onSelected, + style: style, + ), + keywords: ['grid', 'database'], + handler: (editorState, menuService, context) async { + // create the view inside current page + final parentViewId = documentBloc.documentId; + final value = await ViewBackendService.createView( + parentViewId: parentViewId, + name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), + layoutType: ViewLayoutPB.Grid, + ); + value.map((r) => editorState.insertInlinePage(parentViewId, r)); + }, + ); +} + +SelectionMenuItem kanbanSlashMenuItem(DocumentBloc documentBloc) { + return SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_kanban.tr(), + nameBuilder: _slashMenuItemNameBuilder, + icon: (editorState, onSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_kanban_s, + isSelected: onSelected, + style: style, + ), + keywords: ['board', 'kanban', 'database'], + handler: (editorState, menuService, context) async { + // create the view inside current page + final parentViewId = documentBloc.documentId; + final value = await ViewBackendService.createView( + parentViewId: parentViewId, + name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), + layoutType: ViewLayoutPB.Board, + ); + value.map((r) => editorState.insertInlinePage(parentViewId, r)); + }, + ); +} + +SelectionMenuItem calendarSlashMenuItem(DocumentBloc documentBloc) { + return SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_calendar.tr(), + nameBuilder: _slashMenuItemNameBuilder, + icon: (editorState, onSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_calendar_s, + isSelected: onSelected, + style: style, + ), + keywords: ['calendar', 'database'], + handler: (editorState, menuService, context) async { + // create the view inside current page + final parentViewId = documentBloc.documentId; + final value = await ViewBackendService.createView( + parentViewId: parentViewId, + name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), + layoutType: ViewLayoutPB.Calendar, + ); + value.map((r) => editorState.insertInlinePage(parentViewId, r)); + }, + ); +} + +// linked doc menu item +final referencedDocSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_linkedDoc.tr(), + nameBuilder: _slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_doc_s, + isSelected: isSelected, + style: style, + ), + keywords: [ + 'page', + 'notes', + 'referenced page', + 'referenced document', + 'link to page', + ], + handler: (editorState, menuService, context) => showLinkToPageMenu( + editorState, + menuService, + ViewLayoutPB.Document, + ), +); + +// linked grid & board & calendar menu item +SelectionMenuItem referencedGridSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_linkedGrid.tr(), + nameBuilder: _slashMenuItemNameBuilder, + icon: (editorState, onSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_grid_s, + isSelected: onSelected, + style: style, + ), + keywords: ['referenced', 'grid', 'database', 'linked'], + handler: (editorState, menuService, context) => + showLinkToPageMenu(editorState, menuService, ViewLayoutPB.Grid), +); + +SelectionMenuItem referencedKanbanSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_linkedKanban.tr(), + nameBuilder: _slashMenuItemNameBuilder, + icon: (editorState, onSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_kanban_s, + isSelected: onSelected, + style: style, + ), + keywords: ['referenced', 'board', 'kanban', 'linked'], + handler: (editorState, menuService, context) => + showLinkToPageMenu(editorState, menuService, ViewLayoutPB.Board), +); + +SelectionMenuItem referencedCalendarSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_linkedCalendar.tr(), + nameBuilder: _slashMenuItemNameBuilder, + icon: (editorState, onSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_calendar_s, + isSelected: onSelected, + style: style, + ), + keywords: ['referenced', 'calendar', 'database', 'linked'], + handler: (editorState, menuService, context) => + showLinkToPageMenu(editorState, menuService, ViewLayoutPB.Calendar), +); + +// callout menu item +SelectionMenuItem calloutSlashMenuItem = SelectionMenuItem.node( + getName: LocaleKeys.document_plugins_callout.tr, + nameBuilder: _slashMenuItemNameBuilder, + iconBuilder: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_callout_s, + isSelected: isSelected, + style: style, + ), + keywords: [CalloutBlockKeys.type], + nodeBuilder: (editorState, context) => + calloutNode(defaultColor: Colors.transparent), + replace: (_, node) => node.delta?.isEmpty ?? false, + updateSelection: (_, path, __, ___) { + return Selection.single(path: path, startOffset: 0); + }, +); + +// outline menu item +SelectionMenuItem outlineSlashMenuItem = SelectionMenuItem.node( + getName: () => LocaleKeys.document_slashMenu_name_outline.tr(), + nameBuilder: _slashMenuItemNameBuilder, + iconBuilder: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_outline_s, + isSelected: isSelected, + style: style, + ), + keywords: ['outline', 'table of contents'], + nodeBuilder: (editorState, _) => outlineBlockNode(), + replace: (_, node) => node.delta?.isEmpty ?? false, +); + +// math equation +SelectionMenuItem mathEquationSlashMenuItem = SelectionMenuItem.node( + getName: () => LocaleKeys.document_slashMenu_name_mathEquation.tr(), + nameBuilder: _slashMenuItemNameBuilder, + iconBuilder: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_math_equation_s, + isSelected: isSelected, + style: style, + ), + keywords: ['tex', 'latex', 'katex', 'math equation', 'formula'], + nodeBuilder: (editorState, _) => mathEquationNode(), + replace: (_, node) => node.delta?.isEmpty ?? false, + updateSelection: (editorState, path, __, ___) { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + final mathEquationState = + editorState.getNodeAtPath(path)?.key.currentState; + if (mathEquationState != null && + mathEquationState is MathEquationBlockComponentWidgetState) { + mathEquationState.showEditingDialog(); + } + }); + return null; + }, +); + +// code block menu item +SelectionMenuItem codeBlockSlashMenuItem = SelectionMenuItem.node( + getName: () => LocaleKeys.document_slashMenu_name_code.tr(), + nameBuilder: _slashMenuItemNameBuilder, + iconBuilder: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_code_block_s, + isSelected: isSelected, + style: style, + ), + keywords: ['code', 'code block'], + nodeBuilder: (_, __) => codeBlockNode(), + replace: (_, node) => node.delta?.isEmpty ?? false, +); + +// toggle menu item +SelectionMenuItem toggleListSlashMenuItem = SelectionMenuItem.node( + getName: () => LocaleKeys.document_slashMenu_name_toggleList.tr(), + nameBuilder: _slashMenuItemNameBuilder, + iconBuilder: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_toggle_s, + isSelected: isSelected, + style: style, + ), + keywords: ['collapsed list', 'toggle list', 'list', 'dropdown'], + nodeBuilder: (editorState, _) => toggleListBlockNode(), + replace: (_, node) => node.delta?.isEmpty ?? false, +); + +// emoji menu item +SelectionMenuItem emojiSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_emoji.tr(), + nameBuilder: _slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_emoji_picker_s, + isSelected: isSelected, + style: style, + ), + keywords: ['emoji', 'reaction', 'emoticon'], + handler: (editorState, menuService, context) { + final container = Overlay.of(context); + menuService.dismiss(); + showEmojiPickerMenu( + container, + editorState, + menuService.alignment, + menuService.offset, + ); + }, +); + +// auto generate menu item +SelectionMenuItem aiWriterSlashMenuItem = SelectionMenuItem.node( + getName: () => LocaleKeys.document_slashMenu_name_aiWriter.tr(), + nameBuilder: _slashMenuItemNameBuilder, + iconBuilder: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_ai_writer_s, + isSelected: isSelected, + style: style, + ), + keywords: ['ai', 'openai', 'writer', 'ai writer', 'autogenerator'], + nodeBuilder: (editorState, _) { + final node = autoCompletionNode(start: editorState.selection!); + return node; + }, + replace: (_, node) => false, +); + +// table menu item +SelectionMenuItem tableSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_table.tr(), + nameBuilder: _slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_simple_table_s, + isSelected: isSelected, + style: style, + ), + keywords: ['table', 'rows', 'columns', 'data'], + handler: (editorState, _, __) async { + final selection = editorState.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + + final currentNode = editorState.getNodeAtPath(selection.end.path); + if (currentNode == null) { + return; + } + + final tableNode = TableNode.fromList([ + ['', ''], + ['', ''], + ]); + + final transaction = editorState.transaction; + final delta = currentNode.delta; + if (delta != null && delta.isEmpty) { + transaction + ..insertNode(selection.end.path, tableNode.node) + ..deleteNode(currentNode); + transaction.afterSelection = Selection.collapsed( + Position( + path: selection.end.path + [0, 0], + ), + ); + } else { + transaction.insertNode(selection.end.path.next, tableNode.node); + transaction.afterSelection = Selection.collapsed( + Position( + path: selection.end.path.next + [0, 0], + ), + ); + } + + await editorState.apply(transaction); + }, +); + +// date or reminder menu item +SelectionMenuItem dateOrReminderSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_dateOrReminder.tr(), + nameBuilder: _slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_date_or_reminder_s, + isSelected: isSelected, + style: style, + ), + keywords: ['insert date', 'date', 'time', 'reminder', 'schedule'], + handler: (editorState, menuService, context) => + insertDateReference(editorState), +); + +// photo gallery menu item +SelectionMenuItem photoGallerySlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_photoGallery.tr(), + nameBuilder: _slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_photo_gallery_s, + 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(), + ); + }, +); + +// file menu item +SelectionMenuItem fileSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_file.tr(), + nameBuilder: _slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_file_s, + isSelected: isSelected, + style: style, + ), + keywords: ['file upload', 'pdf', 'zip', 'archive', 'upload', 'attachment'], + handler: (editorState, _, __) async => editorState.insertEmptyFileBlock(), +); + +Widget _slashMenuItemNameBuilder( + String name, + SelectionMenuStyle style, + bool isSelected, +) { + return FlowyText.regular( + name, + fontSize: 12.0, + figmaLineHeight: 15.0, + color: isSelected + ? style.selectionMenuItemSelectedTextColor + : style.selectionMenuItemTextColor, + ); +} 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 c3cbd11c97c6b..0000000000000 --- 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 c699237762bf6..0000000000000 --- 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/todo_list/todo_list_icon.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/todo_list/todo_list_icon.dart index 85972a3c2ce71..95841051d7397 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,9 +16,13 @@ class TodoListIcon extends StatelessWidget { @override Widget build(BuildContext context) { - final iconPadding = PlatformExtension.isMobile - ? context.read().state.iconPadding - : 0.0; + // 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, @@ -28,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_style.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart index c691e7d821f57..19e0dcaa00088 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart @@ -1,4 +1,7 @@ +import 'dart:io'; + import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/font_colors.dart'; @@ -13,7 +16,10 @@ import 'package:appflowy/workspace/application/settings/appearance/appearance_cu import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide Log; 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'; @@ -100,7 +106,8 @@ class EditorStyleCustomizer { 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 = @@ -130,7 +137,6 @@ class EditorStyleCustomizer { textStyle: baseTextStyle.copyWith( fontSize: fontSize, fontWeight: FontWeight.normal, - fontStyle: FontStyle.italic, color: Colors.red, backgroundColor: Colors.grey.withOpacity(0.3), ), @@ -185,11 +191,23 @@ class EditorStyleCustomizer { } TextStyle calloutBlockStyleBuilder() { - final fontSize = context.read().state.fontSize; - return baseTextStyle(null).copyWith( - fontSize: fontSize, - height: 1.5, - ); + if (PlatformExtension.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() { @@ -382,4 +400,83 @@ class EditorStyleCustomizer { after, ); } + + Widget buildToolbarItemTooltip( + BuildContext context, + String id, + String message, + Widget child, + ) { + final tooltipMessage = _buildTooltipMessage(id, message); + child = FlowyTooltip( + richMessage: tooltipMessage, + preferBelow: false, + verticalOffset: 20, + child: child, + ); + + // the align/font toolbar item doesn't need the hover effect + final toolbarItemsWithoutHover = { + kFontToolbarItemId, + kAlignToolbarItemId, + }; + + if (!toolbarItemsWithoutHover.contains(id)) { + child = Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: FlowyHover( + style: HoverStyle( + hoverColor: Colors.grey.withOpacity(0.3), + ), + child: child, + ), + ); + } + + return child; + } + + TextSpan _buildTooltipMessage(String id, String message) { + final markdownItemTooltips = { + 'underline': (LocaleKeys.toolbar_underline.tr(), 'U'), + 'bold': (LocaleKeys.toolbar_bold.tr(), 'B'), + 'italic': (LocaleKeys.toolbar_italic.tr(), 'I'), + 'strikethrough': (LocaleKeys.toolbar_strike.tr(), 'Shift+S'), + 'code': (LocaleKeys.toolbar_inlineCode.tr(), 'E'), + }; + + final markdownItemIds = markdownItemTooltips.keys.toSet(); + // the items without shortcuts + if (!markdownItemIds.contains(id)) { + return TextSpan( + text: message, + style: context.tooltipTextStyle(), + ); + } + + final tooltip = markdownItemTooltips[id]; + if (tooltip == null) { + return TextSpan( + text: message, + style: context.tooltipTextStyle(), + ); + } + + final textSpan = TextSpan( + children: [ + TextSpan( + text: '${tooltip.$1}\n', + style: context.tooltipTextStyle(), + ), + TextSpan( + text: (Platform.isMacOS ? '⌘+' : 'Ctrl+\\') + tooltip.$2, + style: context + .tooltipTextStyle() + ?.copyWith(color: Theme.of(context).hintColor), + ), + ], + ); + + return textSpan; + } } diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/inline_page_reference.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/inline_page_reference.dart index fdb157974f9cb..da7ce92106b1b 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,13 +1,13 @@ import 'dart:async'; 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/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/list_extension.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/recent/cached_recent_service.dart'; @@ -17,8 +17,8 @@ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.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"; @@ -177,9 +177,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, ), ); } @@ -228,11 +227,11 @@ class InlinePageReferenceService extends InlineActionsDelegate { 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, + ? FlowyText.emoji( + view.icon.value, + fontSize: 14, + figmaLineHeight: 18.0, + // optimizeEmojiAlign: true, ) : view.defaultIcon(), onSelected: (context, editorState, menu, replace) => insertPage 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 319e6091c8d04..c46c3a453d513 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 @@ -219,6 +219,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/widgets/inline_actions_handler.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_handler.dart index 94c9d62a74d97..edf34a3dc19f8 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({ @@ -81,8 +81,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 +88,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 +106,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 +143,6 @@ class _InlineActionsHandlerState extends State { void dispose() { _scrollController.dispose(); _focusNode.dispose(); - _debounce?.cancel(); super.dispose(); } @@ -153,7 +152,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), 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 1392dd9b21293..8e72415309607 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'; @@ -92,11 +93,15 @@ class _InlineActionsWidgetState extends State { 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: FlowyText.regular( + widget.item.label, + figmaLineHeight: 18, + ), onTap: _onPressed, ), ), diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/_shared.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/_shared.dart index 391b0836d75d2..23bcb88395b70 100644 --- a/frontend/appflowy_flutter/lib/plugins/shared/share/_shared.dart +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/_shared.dart @@ -5,7 +5,6 @@ import 'package:appflowy/plugins/shared/share/share_menu.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/rounded_button.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -42,27 +41,12 @@ class ShareMenuButton extends StatelessWidget { tabs: tabs, ), ), - child: const _ShareButton(), + child: PrimaryRoundedButton( + text: LocaleKeys.shareAction_buttonText.tr(), + figmaLineHeight: 16, + ), ), ), ); } } - -class _ShareButton extends StatelessWidget { - const _ShareButton(); - - @override - Widget build(BuildContext context) { - return RoundedTextButton( - title: LocaleKeys.shareAction_buttonText.tr(), - padding: const EdgeInsets.symmetric(horizontal: 14.0), - fontSize: 14.0, - fontWeight: FontWeight.w500, - borderRadius: const BorderRadius.all( - Radius.circular(10.0), - ), - textColor: Theme.of(context).colorScheme.onPrimary, - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/export_tab.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/export_tab.dart index 223db0e0e11ee..ff95fe6acc90a 100644 --- a/frontend/appflowy_flutter/lib/plugins/shared/share/export_tab.dart +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/export_tab.dart @@ -11,6 +11,7 @@ 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'; @@ -51,6 +52,14 @@ class ExportTab extends StatelessWidget { svg: FlowySvgs.duplicate_s, onTap: () => _exportToClipboard(context), ), + if (kDebugMode) ...[ + const VSpace(10), + _ExportButton( + title: 'JSON (Debug Mode)', + svg: FlowySvgs.duplicate_s, + onTap: () => _exportJSON(context), + ), + ], ], ); } @@ -64,6 +73,14 @@ class ExportTab extends StatelessWidget { svg: FlowySvgs.database_layout_m, onTap: () => _exportCSV(context), ), + if (kDebugMode) ...[ + const VSpace(10), + _ExportButton( + title: 'Raw Database Data (Debug Mode)', + svg: FlowySvgs.duplicate_s, + onTap: () => _exportRawDatabaseData(context), + ), + ], ], ); } @@ -100,6 +117,22 @@ class ExportTab extends StatelessWidget { } } + 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( @@ -116,6 +149,22 @@ class ExportTab extends StatelessWidget { } } + 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); @@ -161,7 +210,10 @@ class _ExportButton extends StatelessWidget { borderRadius: radius, ), radius: radius, - text: FlowyText(title), + text: FlowyText( + title, + lineHeight: 1.0, + ), leftIcon: FlowySvg(svg), onTap: onTap, ); diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/publish_tab.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/publish_tab.dart index a1baf32e30ba6..220cb92717c37 100644 --- a/frontend/appflowy_flutter/lib/plugins/shared/share/publish_tab.dart +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/publish_tab.dart @@ -15,7 +15,6 @@ import 'package:appflowy_editor/appflowy_editor.dart' hide Log; 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:flowy_infra_ui/widget/rounded_button.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -174,6 +173,7 @@ class _PublishedWidgetState extends State<_PublishedWidget> { ), radius: BorderRadius.circular(10), text: FlowyText.regular( + lineHeight: 1.0, LocaleKeys.shareAction_unPublish.tr(), textAlign: TextAlign.center, ), @@ -190,6 +190,7 @@ class _PublishedWidgetState extends State<_PublishedWidget> { title: LocaleKeys.shareAction_visitSite.tr(), borderRadius: const BorderRadius.all(Radius.circular(10)), fillColor: Theme.of(context).colorScheme.primary, + hoverColor: Theme.of(context).colorScheme.primary.withOpacity(0.9), textColor: Theme.of(context).colorScheme.onPrimary, ); } @@ -258,13 +259,13 @@ class _PublishButton extends StatelessWidget { @override Widget build(BuildContext context) { - return RoundedTextButton( - height: 36, - title: LocaleKeys.shareAction_publish.tr(), - padding: const EdgeInsets.symmetric(vertical: 9.0), + return PrimaryRoundedButton( + text: LocaleKeys.shareAction_publish.tr(), + useIntrinsicWidth: false, + margin: const EdgeInsets.symmetric(vertical: 9.0), fontSize: 14.0, - textColor: Theme.of(context).colorScheme.onPrimary, - onPressed: onPublish, + figmaLineHeight: 18.0, + onTap: onPublish, ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/share_bloc.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/share_bloc.dart index 16d50d22127be..af6adc4f68f0a 100644 --- a/frontend/appflowy_flutter/lib/plugins/shared/share/share_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/share_bloc.dart @@ -179,6 +179,14 @@ class ShareBloc extends Bloc { (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); } @@ -189,6 +197,8 @@ class ShareBloc extends Bloc { case ShareType.markdown: case ShareType.html: case ShareType.csv: + case ShareType.json: + case ShareType.rawDatabaseData: File(path).writeAsStringSync(s); return FlowyResult.success(type); default: @@ -208,9 +218,11 @@ enum ShareType { html, text, link, + json, // only available in database - csv; + csv, + rawDatabaseData; static List get unimplemented => [link]; @@ -222,10 +234,16 @@ enum ShareType { 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', + ); } } } diff --git a/frontend/appflowy_flutter/lib/plugins/trash/trash_page.dart b/frontend/appflowy_flutter/lib/plugins/trash/trash_page.dart index b50f95342abfe..3de35d5537085 100644 --- a/frontend/appflowy_flutter/lib/plugins/trash/trash_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/trash/trash_page.dart @@ -101,7 +101,10 @@ 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( @@ -118,7 +121,10 @@ class _TrashPageState extends State { 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( diff --git a/frontend/appflowy_flutter/lib/shared/flowy_error_page.dart b/frontend/appflowy_flutter/lib/shared/flowy_error_page.dart new file mode 100644 index 0000000000000..30e5bf9c3cc94 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/flowy_error_page.dart @@ -0,0 +1,168 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' show PlatformExtension; +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'; + +class AppFlowyErrorPage extends StatelessWidget { + const AppFlowyErrorPage({ + super.key, + this.error, + }); + + final FlowyError? error; + + @override + Widget build(BuildContext context) { + if (PlatformExtension.isMobile) { + return _MobileSyncErrorPage(error: error); + } else { + return _DesktopSyncErrorPage(error: error); + } + } +} + +class _MobileSyncErrorPage extends StatelessWidget { + const _MobileSyncErrorPage({ + this.error, + }); + + final FlowyError? error; + + @override + Widget build(BuildContext context) { + return AnimatedGestureDetector( + scaleFactor: 0.99, + onTapUp: () { + getIt().setPlainText(error.toString()); + showToastNotification( + context, + message: LocaleKeys.message_copy_success.tr(), + bottomPadding: 0, + ); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const FlowySvg( + FlowySvgs.icon_warning_xl, + blendMode: null, + ), + const VSpace(16.0), + FlowyText.medium( + LocaleKeys.error_syncError.tr(), + fontSize: 15, + ), + const VSpace(8.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: FlowyText.regular( + LocaleKeys.error_syncErrorHint.tr(), + fontSize: 13, + color: Theme.of(context).hintColor, + textAlign: TextAlign.center, + maxLines: 10, + ), + ), + const VSpace(2.0), + FlowyText.regular( + '(${LocaleKeys.error_clickToCopy.tr()})', + fontSize: 13, + color: Theme.of(context).hintColor, + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} + +class _DesktopSyncErrorPage extends StatelessWidget { + const _DesktopSyncErrorPage({ + this.error, + }); + + final FlowyError? error; + + @override + Widget build(BuildContext context) { + return AnimatedGestureDetector( + scaleFactor: 0.995, + onTapUp: () { + getIt().setPlainText(error.toString()); + showToastNotification( + context, + message: LocaleKeys.message_copy_success.tr(), + bottomPadding: 0, + ); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const FlowySvg( + FlowySvgs.icon_warning_xl, + blendMode: null, + ), + const VSpace(16.0), + FlowyText.medium( + error?.code.toString() ?? '', + fontSize: 16, + ), + const VSpace(8.0), + RichText( + text: TextSpan( + children: [ + TextSpan( + text: LocaleKeys.errorDialog_howToFixFallbackHint1.tr(), + style: TextStyle( + fontSize: 14, + color: Theme.of(context).hintColor, + ), + ), + TextSpan( + text: 'Github', + style: TextStyle( + fontSize: 14, + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + afLaunchUrlString( + 'https://github.com/AppFlowy-IO/AppFlowy/issues/new?template=bug_report.yaml', + ); + }, + ), + TextSpan( + text: LocaleKeys.errorDialog_howToFixFallbackHint2.tr(), + style: TextStyle( + fontSize: 14, + color: Theme.of(context).hintColor, + ), + ), + ], + ), + ), + const VSpace(8.0), + FlowyText.regular( + '(${LocaleKeys.error_clickToCopy.tr()})', + fontSize: 14, + color: Theme.of(context).hintColor, + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/shared/google_fonts_extension.dart b/frontend/appflowy_flutter/lib/shared/google_fonts_extension.dart index c5cb5df786f5d..3e6a69153afee 100644 --- a/frontend/appflowy_flutter/lib/shared/google_fonts_extension.dart +++ b/frontend/appflowy_flutter/lib/shared/google_fonts_extension.dart @@ -1,5 +1,4 @@ import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; -import 'package:appflowy_backend/log.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -38,11 +37,7 @@ TextStyle getGoogleFontSafely( letterSpacing: letterSpacing, height: lineHeight, ); - } catch (e) { - Log.error( - 'Font family $fontFamily is not available, using default font family instead', - ); - } + } 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 0000000000000..8728c3be4a6b1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/colors.dart @@ -0,0 +1,26 @@ +import 'package:appflowy/util/theme_extension.dart'; +import 'package:flutter/material.dart'; + +extension PickerColors on BuildContext { + Color get pickerTextColor { + return Theme.of(this).isLightMode + ? const Color(0x80171717) + : Colors.white.withOpacity(0.5); + } + + Color get pickerIconColor { + return Theme.of(this).isLightMode ? const Color(0xFF171717) : Colors.white; + } + + Color get pickerSearchBarBorderColor { + return Theme.of(this).isLightMode + ? const Color(0x1E171717) + : Colors.white.withOpacity(0.12); + } + + Color get pickerButtonBoarderColor { + return Theme.of(this).isLightMode + ? const Color(0x1E171717) + : Colors.white.withOpacity(0.12); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_search_bar.dart b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/emoji_search_bar.dart similarity index 95% rename from frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_search_bar.dart rename to frontend/appflowy_flutter/lib/shared/icon_emoji_picker/emoji_search_bar.dart index c6cec89ecc8bc..8dbee9ec29f6d 100644 --- a/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_search_bar.dart +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/emoji_search_bar.dart @@ -1,13 +1,14 @@ 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/shared/icon_emoji_picker/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'; +import 'colors.dart'; + typedef EmojiKeywordChangedCallback = void Function(String keyword); typedef EmojiSkinToneChanged = void Function(EmojiSkinTone skinTone); @@ -83,7 +84,7 @@ class _RandomEmojiButton extends StatelessWidget { height: 36, decoration: ShapeDecoration( shape: RoundedRectangleBorder( - side: const BorderSide(color: Color(0x1E171717)), + side: BorderSide(color: context.pickerButtonBoarderColor), borderRadius: BorderRadius.circular(8), ), ), @@ -142,6 +143,7 @@ class _SearchTextFieldState extends State<_SearchTextField> { fontWeight: FontWeight.w400, color: Theme.of(context).hintColor, ), + enableBorderColor: context.pickerSearchBarBorderColor, controller: controller, onChanged: widget.onKeywordChanged, prefixIcon: const Padding( 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 96% 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 3add90773de81..e802e0dba0ab3 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 @@ -2,10 +2,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; @@ -69,7 +70,7 @@ class _FlowyEmojiSkinToneSelectorState width: 36, height: 36, decoration: BoxDecoration( - border: Border.all(color: const Color(0x1E171717)), + border: Border.all(color: context.pickerButtonBoarderColor), borderRadius: BorderRadius.circular(8), ), child: FlowyButton( diff --git a/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart new file mode 100644 index 0000000000000..ede5b245b7176 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart @@ -0,0 +1,178 @@ +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.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/material.dart' hide Icon; + +import 'icon.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 FlowyIconEmojiPicker extends StatefulWidget { + const FlowyIconEmojiPicker({ + super.key, + this.onSelectedEmoji, + this.onSelectedIcon, + this.tabs = const [PickerTabType.emoji], + }); + + final void Function(EmojiPickerResult result)? onSelectedEmoji; + final void Function(IconGroup? group, Icon? icon, String? color)? + onSelectedIcon; + final List tabs; + + @override + State createState() => _FlowyIconEmojiPickerState(); +} + +class _FlowyIconEmojiPickerState extends State + with SingleTickerProviderStateMixin { + late final controller = TabController( + length: widget.tabs.length, + vsync: this, + ); + int currentIndex = 0; + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + height: 46, + padding: const EdgeInsets.only(left: 4.0, right: 12.0), + child: Row( + children: [ + Expanded( + child: PickerTab( + controller: controller, + tabs: widget.tabs, + onTap: (index) => currentIndex = index, + ), + ), + _RemoveIconButton( + onTap: () { + final currentTab = widget.tabs[currentIndex]; + if (currentTab == PickerTabType.emoji) { + widget.onSelectedEmoji?.call( + EmojiPickerResult.none(), + ); + } else { + widget.onSelectedIcon?.call(null, null, null); + } + }, + ), + ], + ), + ), + const FlowyDivider(), + Expanded( + child: TabBarView( + controller: controller, + children: widget.tabs.map((tab) { + switch (tab) { + case PickerTabType.emoji: + return _buildEmojiPicker(); + case PickerTabType.icon: + return _buildIconPicker(); + } + }).toList(), + ), + ), + ], + ); + } + + Widget _buildEmojiPicker() { + return FlowyEmojiPicker( + emojiPerLine: _getEmojiPerLine(context), + onEmojiSelected: (_, emoji) => widget.onSelectedEmoji?.call( + EmojiPickerResult.emoji(emoji), + ), + ); + } + + int _getEmojiPerLine(BuildContext context) { + if (PlatformExtension.isDesktopOrWeb) { + return 9; + } + final width = MediaQuery.of(context).size.width; + return width ~/ 40.0; // the size of the emoji + } + + Widget _buildIconPicker() { + return FlowyIconPicker( + onSelectedIcon: (iconGroup, icon, color) { + debugPrint('icon: ${icon.toJson()}, color: $color'); + widget.onSelectedIcon?.call(iconGroup, icon, color); + }, + ); + } +} + +class _RemoveIconButton extends StatelessWidget { + const _RemoveIconButton({required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 32, + child: FlowyButton( + onTap: onTap, + useIntrinsicWidth: true, + text: FlowyText( + fontSize: 14.0, + figmaLineHeight: 16.0, + fontWeight: FontWeight.w500, + LocaleKeys.button_remove.tr(), + color: Theme.of(context).hintColor, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon.dart b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon.dart new file mode 100644 index 0000000000000..d1f3dd9d42c92 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon.dart @@ -0,0 +1,62 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'icon.g.dart'; + +@JsonSerializable() +class IconGroup { + factory IconGroup.fromJson(Map json) => + _$IconGroupFromJson(json); + + factory IconGroup.fromMapEntry(MapEntry entry) => + IconGroup.fromJson({ + 'name': entry.key, + 'icons': entry.value, + }); + + IconGroup({ + required this.name, + required this.icons, + }); + + final String name; + final List icons; + + String get displayName => name.replaceAll('_', ' '); + + IconGroup filter(String keyword) { + final filteredIcons = icons + .where( + (icon) => icon.keywords.any((k) => k.contains(keyword.toLowerCase())), + ) + .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; + + String get displayName => name.replaceAll('-', ' '); + + Map toJson() => _$IconToJson(this); +} diff --git a/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_color_picker.dart b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_color_picker.dart new file mode 100644 index 0000000000000..b4221ff42afe0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_color_picker.dart @@ -0,0 +1,44 @@ +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; + +class IconColorPicker extends StatelessWidget { + const IconColorPicker({ + super.key, + required this.onSelected, + }); + + final void Function(String color) onSelected; + + @override + Widget build(BuildContext context) { + return GridView.count( + shrinkWrap: true, + crossAxisCount: 6, + mainAxisSpacing: 4.0, + children: builtInSpaceColors.map((color) { + return FlowyHover( + style: HoverStyle(borderRadius: BorderRadius.circular(8.0)), + child: GestureDetector( + onTap: () => onSelected(color), + child: Container( + width: 34, + height: 34, + padding: const EdgeInsets.all(5.0), + child: Container( + clipBehavior: Clip.antiAlias, + decoration: ShapeDecoration( + color: Color(int.parse(color)), + shape: RoundedRectangleBorder( + side: const BorderSide(color: Color(0x2D333333)), + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ), + ); + }).toList(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_picker.dart b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_picker.dart new file mode 100644 index 0000000000000..f18b8d43f3812 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_picker.dart @@ -0,0 +1,317 @@ +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/icon.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon_search_bar.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: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/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; + +extension IconGroupFilter on List { + String? findSvgContent(String key) { + final values = key.split('/'); + if (values.length != 2) { + return null; + } + final groupName = values[0]; + final iconName = values[1]; + final svgString = kIconGroups + ?.firstWhereOrNull( + (group) => group.name == groupName, + ) + ?.icons + .firstWhereOrNull( + (icon) => icon.name == iconName, + ) + ?.content; + return svgString; + } + + (IconGroup, Icon) randomIcon() { + final random = Random(); + final group = this[random.nextInt(length)]; + final icon = group.icons[random.nextInt(group.icons.length)]; + return (group, icon); + } +} + +Future> loadIconGroups() async { + if (kIconGroups != null) { + return kIconGroups!; + } + + final stopwatch = Stopwatch()..start(); + final jsonString = await rootBundle.loadString('assets/icons/icons.json'); + try { + final json = jsonDecode(jsonString) as Map; + final iconGroups = json.entries.map(IconGroup.fromMapEntry).toList(); + kIconGroups = iconGroups; + return iconGroups; + } catch (e) { + Log.error('Failed to decode icons.json', e); + return []; + } finally { + stopwatch.stop(); + Log.info('Loaded icon groups in ${stopwatch.elapsedMilliseconds}ms'); + } +} + +class FlowyIconPicker extends StatefulWidget { + const FlowyIconPicker({ + super.key, + required this.onSelectedIcon, + }); + + final void Function(IconGroup group, Icon icon, String color) onSelectedIcon; + + @override + State createState() => _FlowyIconPickerState(); +} + +class _FlowyIconPickerState extends State { + late final Future> iconGroups; + final ValueNotifier keyword = ValueNotifier(''); + final debounce = Debounce(duration: const Duration(milliseconds: 150)); + + @override + void initState() { + super.initState(); + + iconGroups = loadIconGroups(); + } + + @override + void dispose() { + keyword.dispose(); + debounce.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: IconSearchBar( + onRandomTap: () { + final value = kIconGroups?.randomIcon(); + if (value == null) { + return; + } + final color = generateRandomSpaceColor(); + widget.onSelectedIcon(value.$1, value.$2, color); + }, + onKeywordChanged: (keyword) => { + debounce.call(() { + this.keyword.value = keyword; + }), + }, + ), + ), + Expanded( + child: kIconGroups != null + ? _buildIcons(kIconGroups!) + : FutureBuilder( + future: iconGroups, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center( + child: SizedBox.square( + dimension: 24.0, + child: CircularProgressIndicator( + strokeWidth: 2.0, + ), + ), + ); + } + final iconGroups = snapshot.data as List; + return _buildIcons(iconGroups); + }, + ), + ), + ], + ); + } + + 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, + onSelectedIcon: widget.onSelectedIcon, + ); + } + return IconPicker( + iconGroups: iconGroups, + onSelectedIcon: widget.onSelectedIcon, + ); + }, + ); + } +} + +class IconPicker extends StatefulWidget { + const IconPicker({ + super.key, + required this.onSelectedIcon, + required this.iconGroups, + }); + + final List iconGroups; + final void Function(IconGroup group, Icon icon, String color) onSelectedIcon; + + @override + State createState() => _IconPickerState(); +} + +class _IconPickerState extends State { + final mutex = PopoverMutex(); + + @override + Widget build(BuildContext context) { + return ListView.builder( + itemCount: widget.iconGroups.length, + padding: const EdgeInsets.symmetric(horizontal: 16.0), + itemBuilder: (context, index) { + final iconGroup = widget.iconGroups[index]; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText( + iconGroup.displayName.capitalize(), + fontSize: 12, + figmaLineHeight: 18.0, + color: context.pickerTextColor, + ), + const VSpace(4.0), + Wrap( + children: iconGroup.icons.map( + (icon) { + return _Icon( + icon: icon, + mutex: mutex, + onSelectedColor: (context, color) { + widget.onSelectedIcon(iconGroup, icon, color); + PopoverContainer.of(context).close(); + }, + ); + }, + ).toList(), + ), + const VSpace(12.0), + if (index == widget.iconGroups.length - 1) ...[ + const _StreamlinePermit(), + const VSpace(12.0), + ], + ], + ); + }, + ); + } +} + +class _Icon extends StatelessWidget { + const _Icon({ + required this.icon, + required this.mutex, + required this.onSelectedColor, + }); + + final Icon icon; + final PopoverMutex mutex; + final void Function(BuildContext context, String color) onSelectedColor; + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + direction: PopoverDirection.bottomWithCenterAligned, + offset: const Offset(0, 6), + mutex: mutex, + child: FlowyTooltip( + message: icon.displayName, + preferBelow: false, + child: FlowyButton( + useIntrinsicWidth: true, + margin: const EdgeInsets.all(8.0), + text: Center( + child: FlowySvg.string( + icon.content, + size: const Size.square(20), + color: context.pickerIconColor, + opacity: 0.7, + ), + ), + ), + ), + popupBuilder: (context) { + return Container( + padding: const EdgeInsets.all(6.0), + child: IconColorPicker( + onSelected: (color) => onSelectedColor(context, color), + ), + ); + }, + ); + } +} + +class _StreamlinePermit extends StatelessWidget { + const _StreamlinePermit(); + + @override + Widget build(BuildContext context) { + // Open source icons from Streamline + final textStyle = TextStyle( + fontSize: 12.0, + height: 18.0 / 12.0, + fontWeight: FontWeight.w500, + color: context.pickerTextColor, + ); + return RichText( + text: TextSpan( + children: [ + TextSpan( + text: '${LocaleKeys.emoji_openSourceIconsFrom.tr()} ', + style: textStyle, + ), + TextSpan( + text: 'Streamline', + style: textStyle.copyWith( + decoration: TextDecoration.underline, + color: Theme.of(context).colorScheme.primary, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + afLaunchUrlString('https://www.streamlinehq.com/'); + }, + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_search_bar.dart b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_search_bar.dart new file mode 100644 index 0000000000000..31e1e285c0a4f --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_search_bar.dart @@ -0,0 +1,164 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package: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_emoji_mart/flutter_emoji_mart.dart'; + +import 'colors.dart'; + +typedef IconKeywordChangedCallback = void Function(String keyword); +typedef EmojiSkinToneChanged = void Function(EmojiSkinTone skinTone); + +class IconSearchBar extends StatefulWidget { + const IconSearchBar({ + super.key, + required this.onRandomTap, + required this.onKeywordChanged, + }); + + final VoidCallback onRandomTap; + final IconKeywordChangedCallback onKeywordChanged; + + @override + State createState() => _IconSearchBarState(); +} + +class _IconSearchBarState extends State { + final TextEditingController controller = TextEditingController(); + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric( + vertical: 12.0, + horizontal: PlatformExtension.isDesktopOrWeb ? 0.0 : 8.0, + ), + child: Row( + children: [ + Expanded( + child: _SearchTextField( + onKeywordChanged: widget.onKeywordChanged, + ), + ), + const HSpace(8.0), + _RandomIconButton( + onRandomTap: widget.onRandomTap, + ), + ], + ), + ); + } +} + +class _RandomIconButton extends StatelessWidget { + const _RandomIconButton({ + required this.onRandomTap, + }); + + final VoidCallback onRandomTap; + + @override + Widget build(BuildContext context) { + return Container( + width: 36, + height: 36, + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + side: BorderSide(color: context.pickerButtonBoarderColor), + borderRadius: BorderRadius.circular(8), + ), + ), + child: FlowyTooltip( + message: LocaleKeys.emoji_random.tr(), + child: FlowyButton( + useIntrinsicWidth: true, + text: const FlowySvg( + FlowySvgs.icon_shuffle_s, + ), + onTap: onRandomTap, + ), + ), + ); + } +} + +class _SearchTextField extends StatefulWidget { + const _SearchTextField({ + required this.onKeywordChanged, + }); + + final IconKeywordChangedCallback onKeywordChanged; + + @override + State<_SearchTextField> createState() => _SearchTextFieldState(); +} + +class _SearchTextFieldState extends State<_SearchTextField> { + final TextEditingController controller = TextEditingController(); + final FocusNode focusNode = FocusNode(); + + @override + void dispose() { + controller.dispose(); + focusNode.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 36.0, + child: FlowyTextField( + focusNode: focusNode, + hintText: LocaleKeys.search_label.tr(), + hintStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 14.0, + fontWeight: FontWeight.w400, + color: Theme.of(context).hintColor, + ), + enableBorderColor: context.pickerSearchBarBorderColor, + controller: controller, + onChanged: widget.onKeywordChanged, + prefixIcon: const Padding( + padding: EdgeInsets.only( + left: 14.0, + right: 8.0, + ), + child: FlowySvg( + FlowySvgs.search_s, + ), + ), + prefixIconConstraints: const BoxConstraints( + maxHeight: 20.0, + ), + suffixIcon: Padding( + padding: const EdgeInsets.all(4.0), + child: FlowyButton( + text: const FlowySvg( + FlowySvgs.m_app_bar_close_s, + ), + margin: EdgeInsets.zero, + useIntrinsicWidth: true, + onTap: () { + if (controller.text.isNotEmpty) { + controller.clear(); + widget.onKeywordChanged(''); + } else { + focusNode.unfocus(); + } + }, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/tab.dart b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/tab.dart new file mode 100644 index 0000000000000..56a363132c4d7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/tab.dart @@ -0,0 +1,67 @@ +import 'package:appflowy/mobile/presentation/home/tab/_round_underline_tab_indicator.dart'; +import 'package:flutter/material.dart'; + +enum PickerTabType { + emoji, + icon; + + String get tr { + switch (this) { + case PickerTabType.emoji: + return 'Emojis'; + case PickerTabType.icon: + return 'Icons'; + } + } +} + +class PickerTab extends StatelessWidget { + const PickerTab({ + super.key, + this.onTap, + required this.controller, + required this.tabs, + }); + + final List tabs; + final TabController controller; + final ValueChanged? onTap; + + @override + Widget build(BuildContext context) { + final baseStyle = Theme.of(context).textTheme.bodyMedium; + final style = baseStyle?.copyWith( + fontWeight: FontWeight.w500, + fontSize: 14.0, + height: 16.0 / 14.0, + ); + return TabBar( + controller: controller, + indicatorSize: TabBarIndicatorSize.label, + indicatorColor: Theme.of(context).colorScheme.primary, + isScrollable: true, + labelStyle: style, + labelColor: baseStyle?.color, + labelPadding: const EdgeInsets.symmetric(horizontal: 12.0), + unselectedLabelStyle: style?.copyWith( + color: Theme.of(context).hintColor, + ), + overlayColor: WidgetStateProperty.all(Colors.transparent), + indicator: RoundUnderlineTabIndicator( + width: 34.0, + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 3, + ), + ), + onTap: onTap, + tabs: tabs + .map( + (tab) => Tab( + text: tab.tr, + ), + ) + .toList(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/shared/patterns/file_type_patterns.dart b/frontend/appflowy_flutter/lib/shared/patterns/file_type_patterns.dart new file mode 100644 index 0000000000000..418dd47f3d5b4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/patterns/file_type_patterns.dart @@ -0,0 +1,29 @@ +/// This pattern matches a file extension that is an image. +/// +const _imgExtensionPattern = r'\.(gif|jpe?g|tiff?|png|webp|bmp)$'; +final imgExtensionRegex = RegExp(_imgExtensionPattern); + +/// This pattern matches a file extension that is a video. +/// +const _videoExtensionPattern = r'\.(mp4|mov|avi|webm|flv|m4v|mpeg|h264)$'; +final videoExtensionRegex = RegExp(_videoExtensionPattern); + +/// This pattern matches a file extension that is an audio. +/// +const _audioExtensionPattern = r'\.(mp3|wav|ogg|flac|aac|wma|alac|aiff)$'; +final audioExtensionRegex = RegExp(_audioExtensionPattern); + +/// This pattern matches a file extension that is a document. +/// +const _documentExtensionPattern = r'\.(pdf|doc|docx)$'; +final documentExtensionRegex = RegExp(_documentExtensionPattern); + +/// This pattern matches a file extension that is an archive. +/// +const _archiveExtensionPattern = r'\.(zip|tar|gz|7z|rar)$'; +final archiveExtensionRegex = RegExp(_archiveExtensionPattern); + +/// This pattern matches a file extension that is a text. +/// +const _textExtensionPattern = r'\.(txt|md|html|css|js|json|xml|csv)$'; +final textExtensionRegex = RegExp(_textExtensionPattern); diff --git a/frontend/appflowy_flutter/lib/shared/popup_menu/appflowy_popup_menu.dart b/frontend/appflowy_flutter/lib/shared/popup_menu/appflowy_popup_menu.dart new file mode 100644 index 0000000000000..0de43a84e848a --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/popup_menu/appflowy_popup_menu.dart @@ -0,0 +1,1646 @@ +// 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(() => setState(() {/* animation changed */})); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + void handleTap() { + // This fades the checkmark in or out when tapped. + if (widget.checked) { + _controller.reverse(); + } else { + _controller.forward(); + } + super.handleTap(); + } + + @override + Widget buildChild() { + final ThemeData theme = Theme.of(context); + final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); + final PopupMenuThemeData defaults = theme.useMaterial3 + ? _PopupMenuDefaultsM3(context) + : _PopupMenuDefaultsM2(context); + final Set states = { + if (widget.checked) WidgetState.selected, + }; + final WidgetStateProperty? effectiveLabelTextStyle = + widget.labelTextStyle ?? + popupMenuTheme.labelTextStyle ?? + defaults.labelTextStyle; + return IgnorePointer( + child: ListTileTheme.merge( + contentPadding: EdgeInsets.zero, + child: ListTile( + enabled: widget.enabled, + titleTextStyle: effectiveLabelTextStyle?.resolve(states), + leading: FadeTransition( + opacity: _opacity, + child: Icon(_controller.isDismissed ? null : Icons.done), + ), + title: widget.child, + ), + ), + ); + } +} + +class _PopupMenu extends StatefulWidget { + const _PopupMenu({ + super.key, + required this.itemKeys, + required this.route, + required this.semanticLabel, + this.constraints, + required this.clipBehavior, + }); + + final List itemKeys; + final _PopupMenuRoute route; + final String? semanticLabel; + final BoxConstraints? constraints; + final Clip clipBehavior; + + @override + State<_PopupMenu> createState() => _PopupMenuState(); +} + +class _PopupMenuState extends State<_PopupMenu> { + @override + Widget build(BuildContext context) { + final double unit = 1.0 / + (widget.route.items.length + + 1.5); // 1.0 for the width and 0.5 for the last item's fade. + final List children = []; + final ThemeData theme = Theme.of(context); + final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); + final PopupMenuThemeData defaults = theme.useMaterial3 + ? _PopupMenuDefaultsM3(context) + : _PopupMenuDefaultsM2(context); + + for (int i = 0; i < widget.route.items.length; i += 1) { + final double start = (i + 1) * unit; + final double end = clampDouble(start + 1.5 * unit, 0.0, 1.0); + final CurvedAnimation opacity = CurvedAnimation( + parent: widget.route.animation!, + curve: Interval(start, end), + ); + Widget item = widget.route.items[i]; + if (widget.route.initialValue != null && + widget.route.items[i].represents(widget.route.initialValue)) { + item = ColoredBox( + color: Theme.of(context).highlightColor, + child: item, + ); + } + children.add( + _MenuItem( + onLayout: (Size size) { + widget.route.itemSizes[i] = size; + }, + child: FadeTransition( + key: widget.itemKeys[i], + opacity: opacity, + child: item, + ), + ), + ); + } + + final CurveTween opacity = + CurveTween(curve: const Interval(0.0, 1.0 / 3.0)); + final CurveTween width = CurveTween(curve: Interval(0.0, unit)); + final CurveTween height = + CurveTween(curve: Interval(0.0, unit * widget.route.items.length)); + + final Widget child = ConstrainedBox( + constraints: widget.constraints ?? + const BoxConstraints( + minWidth: _kMenuMinWidth, + maxWidth: _kMenuMaxWidth, + ), + child: IntrinsicWidth( + stepWidth: _kMenuWidthStep, + child: Semantics( + scopesRoute: true, + namesRoute: true, + explicitChildNodes: true, + label: widget.semanticLabel, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + vertical: _kMenuVerticalPadding, + ), + child: ListBody(children: children), + ), + ), + ), + ); + + return AnimatedBuilder( + animation: widget.route.animation!, + builder: (BuildContext context, Widget? child) { + return FadeTransition( + opacity: opacity.animate(widget.route.animation!), + child: Material( + shape: widget.route.shape ?? popupMenuTheme.shape ?? defaults.shape, + color: widget.route.color ?? popupMenuTheme.color ?? defaults.color, + clipBehavior: widget.clipBehavior, + type: MaterialType.card, + elevation: widget.route.elevation ?? + popupMenuTheme.elevation ?? + defaults.elevation!, + shadowColor: widget.route.shadowColor ?? + popupMenuTheme.shadowColor ?? + defaults.shadowColor, + surfaceTintColor: widget.route.surfaceTintColor ?? + popupMenuTheme.surfaceTintColor ?? + defaults.surfaceTintColor, + child: Align( + alignment: AlignmentDirectional.topEnd, + widthFactor: width.evaluate(widget.route.animation!), + heightFactor: height.evaluate(widget.route.animation!), + child: child, + ), + ), + ); + }, + child: child, + ); + } + + @override + void dispose() { + _kPopupMenuKey = null; + super.dispose(); + } + + void dismiss() { + if (_kPopupMenuKey == null) { + return; + } + + Navigator.of(context).pop(); + _kPopupMenuKey = null; + } +} + +// Positioning of the menu on the screen. +class _PopupMenuRouteLayout extends SingleChildLayoutDelegate { + _PopupMenuRouteLayout( + this.position, + this.itemSizes, + this.selectedItemIndex, + this.textDirection, + this.padding, + this.avoidBounds, + ); + + // Rectangle of underlying button, relative to the overlay's dimensions. + final RelativeRect position; + + // The sizes of each item are computed when the menu is laid out, and before + // the route is laid out. + List itemSizes; + + // The index of the selected item, or null if PopupMenuButton.initialValue + // was not specified. + final int? selectedItemIndex; + + // Whether to prefer going to the left or to the right. + final TextDirection textDirection; + + // The padding of unsafe area. + EdgeInsets padding; + + // List of rectangles that we should avoid overlapping. Unusable screen area. + final Set avoidBounds; + + // We put the child wherever position specifies, so long as it will fit within + // the specified parent size padded (inset) by 8. If necessary, we adjust the + // child's position so that it fits. + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + // The menu can be at most the size of the overlay minus 8.0 pixels in each + // direction. + return BoxConstraints.loose(constraints.biggest).deflate( + const EdgeInsets.all(_kMenuScreenPadding) + padding, + ); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + final double y = position.top; + + // Find the ideal horizontal position. + // size: The size of the overlay. + // childSize: The size of the menu, when fully open, as determined by + // getConstraintsForChild. + double x; + if (position.left > position.right) { + // Menu button is closer to the right edge, so grow to the left, aligned to the right edge. + x = size.width - position.right - childSize.width; + } else if (position.left < position.right) { + // Menu button is closer to the left edge, so grow to the right, aligned to the left edge. + x = position.left; + } else { + // Menu button is equidistant from both edges, so grow in reading direction. + x = switch (textDirection) { + TextDirection.rtl => size.width - position.right - childSize.width, + TextDirection.ltr => position.left, + }; + } + final Offset wantedPosition = Offset(x, y); + final Offset originCenter = position.toRect(Offset.zero & size).center; + final Iterable subScreens = + DisplayFeatureSubScreen.subScreensInBounds( + Offset.zero & size, + avoidBounds, + ); + final Rect subScreen = _closestScreen(subScreens, originCenter); + return _fitInsideScreen(subScreen, childSize, wantedPosition); + } + + Rect _closestScreen(Iterable screens, Offset point) { + Rect closest = screens.first; + for (final Rect screen in screens) { + if ((screen.center - point).distance < + (closest.center - point).distance) { + closest = screen; + } + } + return closest; + } + + Offset _fitInsideScreen(Rect screen, Size childSize, Offset wantedPosition) { + double x = wantedPosition.dx; + double y = wantedPosition.dy; + // Avoid going outside an area defined as the rectangle 8.0 pixels from the + // edge of the screen in every direction. + if (x < screen.left + _kMenuScreenPadding + padding.left) { + x = screen.left + _kMenuScreenPadding + padding.left; + } else if (x + childSize.width > + screen.right - _kMenuScreenPadding - padding.right) { + x = screen.right - childSize.width - _kMenuScreenPadding - padding.right; + } + if (y < screen.top + _kMenuScreenPadding + padding.top) { + y = _kMenuScreenPadding + padding.top; + } else if (y + childSize.height > + screen.bottom - _kMenuScreenPadding - padding.bottom) { + y = screen.bottom - + childSize.height - + _kMenuScreenPadding - + padding.bottom; + } + + return Offset(x, y); + } + + @override + bool shouldRelayout(_PopupMenuRouteLayout oldDelegate) { + // If called when the old and new itemSizes have been initialized then + // we expect them to have the same length because there's no practical + // way to change length of the items list once the menu has been shown. + assert(itemSizes.length == oldDelegate.itemSizes.length); + + return position != oldDelegate.position || + selectedItemIndex != oldDelegate.selectedItemIndex || + textDirection != oldDelegate.textDirection || + !listEquals(itemSizes, oldDelegate.itemSizes) || + padding != oldDelegate.padding || + !setEquals(avoidBounds, oldDelegate.avoidBounds); + } +} + +class _PopupMenuRoute extends PopupRoute { + _PopupMenuRoute({ + required this.position, + required this.items, + required this.itemKeys, + this.initialValue, + this.elevation, + this.surfaceTintColor, + this.shadowColor, + required this.barrierLabel, + this.semanticLabel, + this.shape, + this.color, + required this.capturedThemes, + this.constraints, + required this.clipBehavior, + super.settings, + this.popUpAnimationStyle, + }) : itemSizes = List.filled(items.length, null), + // Menus always cycle focus through their items irrespective of the + // focus traversal edge behavior set in the Navigator. + super(traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop); + + final RelativeRect position; + final List> items; + final List itemKeys; + final List itemSizes; + final T? initialValue; + final double? elevation; + final Color? surfaceTintColor; + final Color? shadowColor; + final String? semanticLabel; + final ShapeBorder? shape; + final Color? color; + final CapturedThemes capturedThemes; + final BoxConstraints? constraints; + final Clip clipBehavior; + final AnimationStyle? popUpAnimationStyle; + + @override + Animation createAnimation() { + if (popUpAnimationStyle != AnimationStyle.noAnimation) { + return CurvedAnimation( + parent: super.createAnimation(), + curve: popUpAnimationStyle?.curve ?? Curves.easeInBack, + reverseCurve: popUpAnimationStyle?.reverseCurve ?? + const Interval(0.0, _kMenuCloseIntervalEnd), + ); + } + return super.createAnimation(); + } + + void scrollTo(int selectedItemIndex) { + SchedulerBinding.instance.addPostFrameCallback((_) { + if (itemKeys[selectedItemIndex].currentContext != null) { + Scrollable.ensureVisible(itemKeys[selectedItemIndex].currentContext!); + } + }); + } + + @override + Duration get transitionDuration => + popUpAnimationStyle?.duration ?? _kMenuDuration; + + @override + bool get barrierDismissible => true; + + @override + Color? get barrierColor => null; + + @override + final String barrierLabel; + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + if (!animation.isCompleted) { + final screenWidth = MediaQuery.of(context).size.width; + final screenHeight = MediaQuery.of(context).size.height; + final size = position.toSize(Size(screenWidth, screenHeight)); + final center = size.width / 2.0; + final alignment = FractionalOffset( + (screenWidth - position.right - center) / screenWidth, + (screenHeight - position.bottom - center) / screenHeight, + ); + child = FadeTransition( + opacity: animation, + child: ScaleTransition( + alignment: alignment, + scale: animation, + child: child, + ), + ); + } + return child; + } + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + int? selectedItemIndex; + if (initialValue != null) { + for (int index = 0; + selectedItemIndex == null && index < items.length; + index += 1) { + if (items[index].represents(initialValue)) { + selectedItemIndex = index; + } + } + } + if (selectedItemIndex != null) { + scrollTo(selectedItemIndex); + } + + _kPopupMenuKey ??= GlobalKey<_PopupMenuState>(); + final Widget menu = _PopupMenu( + key: _kPopupMenuKey, + route: this, + itemKeys: itemKeys, + semanticLabel: semanticLabel, + constraints: constraints, + clipBehavior: clipBehavior, + ); + final MediaQueryData mediaQuery = MediaQuery.of(context); + return MediaQuery.removePadding( + context: context, + removeTop: true, + removeBottom: true, + removeLeft: true, + removeRight: true, + child: Builder( + builder: (BuildContext context) { + return CustomSingleChildLayout( + delegate: _PopupMenuRouteLayout( + position, + itemSizes, + selectedItemIndex, + Directionality.of(context), + mediaQuery.padding, + _avoidBounds(mediaQuery), + ), + child: capturedThemes.wrap(menu), + ); + }, + ), + ); + } + + Set _avoidBounds(MediaQueryData mediaQuery) { + return DisplayFeatureSubScreen.avoidBounds(mediaQuery).toSet(); + } +} + +/// Show a popup menu that contains the `items` at `position`. +/// +/// The `items` parameter must not be empty. +/// +/// If `initialValue` is specified then the first item with a matching value +/// will be highlighted and the value of `position` gives the rectangle whose +/// vertical center will be aligned with the vertical center of the highlighted +/// item (when possible). +/// +/// If `initialValue` is not specified then the top of the menu will be aligned +/// with the top of the `position` rectangle. +/// +/// In both cases, the menu position will be adjusted if necessary to fit on the +/// screen. +/// +/// Horizontally, the menu is positioned so that it grows in the direction that +/// has the most room. For example, if the `position` describes a rectangle on +/// the left edge of the screen, then the left edge of the menu is aligned with +/// the left edge of the `position`, and the menu grows to the right. If both +/// edges of the `position` are equidistant from the opposite edge of the +/// screen, then the ambient [Directionality] is used as a tie-breaker, +/// preferring to grow in the reading direction. +/// +/// The positioning of the `initialValue` at the `position` is implemented by +/// iterating over the `items` to find the first whose +/// [PopupMenuEntry.represents] method returns true for `initialValue`, and then +/// summing the values of [PopupMenuEntry.height] for all the preceding widgets +/// in the list. +/// +/// The `elevation` argument specifies the z-coordinate at which to place the +/// menu. The elevation defaults to 8, the appropriate elevation for popup +/// menus. +/// +/// The `context` argument is used to look up the [Navigator] and [Theme] for +/// the menu. It is only used when the method is called. Its corresponding +/// widget can be safely removed from the tree before the popup menu is closed. +/// +/// The `useRootNavigator` argument is used to determine whether to push the +/// menu to the [Navigator] furthest from or nearest to the given `context`. It +/// is `false` by default. +/// +/// The `semanticLabel` argument is used by accessibility frameworks to +/// announce screen transitions when the menu is opened and closed. If this +/// label is not provided, it will default to +/// [MaterialLocalizations.popupMenuLabel]. +/// +/// The `clipBehavior` argument is used to clip the shape of the menu. Defaults to +/// [Clip.none]. +/// +/// See also: +/// +/// * [PopupMenuItem], a popup menu entry for a single value. +/// * [PopupMenuDivider], a popup menu entry that is just a horizontal line. +/// * [CheckedPopupMenuItem], a popup menu item with a checkmark. +/// * [PopupMenuButton], which provides an [IconButton] that shows a menu by +/// calling this method automatically. +/// * [SemanticsConfiguration.namesRoute], for a description of edge triggered +/// semantics. +Future showMenu({ + required BuildContext context, + required RelativeRect position, + required List> items, + T? initialValue, + double? elevation, + Color? shadowColor, + Color? surfaceTintColor, + String? semanticLabel, + ShapeBorder? shape, + Color? color, + bool useRootNavigator = false, + BoxConstraints? constraints, + Clip clipBehavior = Clip.none, + RouteSettings? routeSettings, + AnimationStyle? popUpAnimationStyle, +}) { + assert(items.isNotEmpty); + assert(debugCheckHasMaterialLocalizations(context)); + + switch (Theme.of(context).platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + semanticLabel ??= MaterialLocalizations.of(context).popupMenuLabel; + } + + final List menuItemKeys = + List.generate(items.length, (int index) => GlobalKey()); + final NavigatorState navigator = + Navigator.of(context, rootNavigator: useRootNavigator); + return navigator.push( + _PopupMenuRoute( + position: position, + items: items, + itemKeys: menuItemKeys, + initialValue: initialValue, + elevation: elevation, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + semanticLabel: semanticLabel, + barrierLabel: MaterialLocalizations.of(context).menuDismissLabel, + shape: shape, + color: color, + capturedThemes: + InheritedTheme.capture(from: context, to: navigator.context), + constraints: constraints, + clipBehavior: clipBehavior, + settings: routeSettings, + popUpAnimationStyle: popUpAnimationStyle, + ), + ); +} + +/// Signature for the callback invoked when a menu item is selected. The +/// argument is the value of the [PopupMenuItem] that caused its menu to be +/// dismissed. +/// +/// Used by [PopupMenuButton.onSelected]. +typedef PopupMenuItemSelected = void Function(T value); + +/// Signature for the callback invoked when a [PopupMenuButton] is dismissed +/// without selecting an item. +/// +/// Used by [PopupMenuButton.onCanceled]. +typedef PopupMenuCanceled = void Function(); + +/// Signature used by [PopupMenuButton] to lazily construct the items shown when +/// the button is pressed. +/// +/// Used by [PopupMenuButton.itemBuilder]. +typedef PopupMenuItemBuilder = List> Function( + BuildContext context, +); + +/// Displays a menu when pressed and calls [onSelected] when the menu is dismissed +/// because an item was selected. The value passed to [onSelected] is the value of +/// the selected menu item. +/// +/// One of [child] or [icon] may be provided, but not both. If [icon] is provided, +/// then [PopupMenuButton] behaves like an [IconButton]. +/// +/// If both are null, then a standard overflow icon is created (depending on the +/// platform). +/// +/// ## Updating to [MenuAnchor] +/// +/// There is a Material 3 component, +/// [MenuAnchor] that is preferred for applications that are configured +/// for Material 3 (see [ThemeData.useMaterial3]). +/// The [MenuAnchor] widget's visuals +/// are a little bit different, see the Material 3 spec at +/// for +/// more details. +/// +/// The [MenuAnchor] widget's API is also slightly different. +/// [MenuAnchor]'s were built to be lower level interface for +/// creating menus that are displayed from an anchor. +/// +/// There are a few steps you would take to migrate from +/// [PopupMenuButton] to [MenuAnchor]: +/// +/// 1. Instead of using the [PopupMenuButton.itemBuilder] to build +/// a list of [PopupMenuEntry]s, you would use the [MenuAnchor.menuChildren] +/// which takes a list of [Widget]s. Usually, you would use a list of +/// [MenuItemButton]s as shown in the example below. +/// +/// 2. Instead of using the [PopupMenuButton.onSelected] callback, you would +/// set individual callbacks for each of the [MenuItemButton]s using the +/// [MenuItemButton.onPressed] property. +/// +/// 3. To anchor the [MenuAnchor] to a widget, you would use the [MenuAnchor.builder] +/// to return the widget of choice - usually a [TextButton] or an [IconButton]. +/// +/// 4. You may want to style the [MenuItemButton]s, see the [MenuItemButton] +/// documentation for details. +/// +/// Use the sample below for an example of migrating from [PopupMenuButton] to +/// [MenuAnchor]. +/// +/// {@tool dartpad} +/// This example shows a menu with three items, selecting between an enum's +/// values and setting a `selectedMenu` field based on the selection. +/// +/// ** See code in examples/api/lib/material/popup_menu/popup_menu.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how to migrate the above to a [MenuAnchor]. +/// +/// ** See code in examples/api/lib/material/menu_anchor/menu_anchor.2.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample shows the creation of a popup menu, as described in: +/// https://m3.material.io/components/menus/overview +/// +/// ** See code in examples/api/lib/material/popup_menu/popup_menu.1.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample showcases how to override the [PopupMenuButton] animation +/// curves and duration using [AnimationStyle]. +/// +/// ** See code in examples/api/lib/material/popup_menu/popup_menu.2.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [PopupMenuItem], a popup menu entry for a single value. +/// * [PopupMenuDivider], a popup menu entry that is just a horizontal line. +/// * [CheckedPopupMenuItem], a popup menu item with a checkmark. +/// * [showMenu], a method to dynamically show a popup menu at a given location. +class PopupMenuButton extends StatefulWidget { + /// Creates a button that shows a popup menu. + const PopupMenuButton({ + super.key, + required this.itemBuilder, + this.initialValue, + this.onOpened, + this.onSelected, + this.onCanceled, + this.tooltip, + this.elevation, + this.shadowColor, + this.surfaceTintColor, + this.padding = const EdgeInsets.all(8.0), + this.child, + this.splashRadius, + this.icon, + this.iconSize, + this.offset = Offset.zero, + this.enabled = true, + this.shape, + this.color, + this.iconColor, + this.enableFeedback, + this.constraints, + this.position, + this.clipBehavior = Clip.none, + this.useRootNavigator = false, + this.popUpAnimationStyle, + this.routeSettings, + this.style, + }) : assert( + !(child != null && icon != null), + 'You can only pass [child] or [icon], not both.', + ); + + /// Called when the button is pressed to create the items to show in the menu. + final PopupMenuItemBuilder itemBuilder; + + /// The value of the menu item, if any, that should be highlighted when the menu opens. + final T? initialValue; + + /// Called when the popup menu is shown. + final VoidCallback? onOpened; + + /// Called when the user selects a value from the popup menu created by this button. + /// + /// If the popup menu is dismissed without selecting a value, [onCanceled] is + /// called instead. + final PopupMenuItemSelected? onSelected; + + /// Called when the user dismisses the popup menu without selecting an item. + /// + /// If the user selects a value, [onSelected] is called instead. + final PopupMenuCanceled? onCanceled; + + /// Text that describes the action that will occur when the button is pressed. + /// + /// This text is displayed when the user long-presses on the button and is + /// used for accessibility. + final String? tooltip; + + /// The z-coordinate at which to place the menu when open. This controls the + /// size of the shadow below the menu. + /// + /// Defaults to 8, the appropriate elevation for popup menus. + final double? elevation; + + /// The color used to paint the shadow below the menu. + /// + /// If null then the ambient [PopupMenuThemeData.shadowColor] is used. + /// If that is null too, then the overall theme's [ThemeData.shadowColor] + /// (default black) is used. + final Color? shadowColor; + + /// The color used as an overlay on [color] to indicate elevation. + /// + /// This is not recommended for use. [Material 3 spec](https://m3.material.io/styles/color/the-color-system/color-roles) + /// introduced a set of tone-based surfaces and surface containers in its [ColorScheme], + /// which provide more flexibility. The intention is to eventually remove surface tint color from + /// the framework. + /// + /// If null, [PopupMenuThemeData.surfaceTintColor] is used. If that + /// is also null, the default value is [Colors.transparent]. + /// + /// See [Material.surfaceTintColor] for more details on how this + /// overlay is applied. + final Color? surfaceTintColor; + + /// Matches IconButton's 8 dps padding by default. In some cases, notably where + /// this button appears as the trailing element of a list item, it's useful to be able + /// to set the padding to zero. + final EdgeInsetsGeometry padding; + + /// The splash radius. + /// + /// If null, default splash radius of [InkWell] or [IconButton] is used. + final double? splashRadius; + + /// If provided, [child] is the widget used for this button + /// and the button will utilize an [InkWell] for taps. + final Widget? child; + + /// If provided, the [icon] is used for this button + /// and the button will behave like an [IconButton]. + final Widget? icon; + + /// The offset is applied relative to the initial position + /// set by the [position]. + /// + /// When not set, the offset defaults to [Offset.zero]. + final Offset offset; + + /// Whether this popup menu button is interactive. + /// + /// Defaults to true. + /// + /// If true, the button will respond to presses by displaying the menu. + /// + /// If false, the button is styled with the disabled color from the + /// current [Theme] and will not respond to presses or show the popup + /// menu and [onSelected], [onCanceled] and [itemBuilder] will not be called. + /// + /// This can be useful in situations where the app needs to show the button, + /// but doesn't currently have anything to show in the menu. + final bool enabled; + + /// If provided, the shape used for the menu. + /// + /// If this property is null, then [PopupMenuThemeData.shape] is used. + /// If [PopupMenuThemeData.shape] is also null, then the default shape for + /// [MaterialType.card] is used. This default shape is a rectangle with + /// rounded edges of BorderRadius.circular(2.0). + final ShapeBorder? shape; + + /// If provided, the background color used for the menu. + /// + /// If this property is null, then [PopupMenuThemeData.color] is used. + /// If [PopupMenuThemeData.color] is also null, then + /// [ThemeData.cardColor] is used in Material 2. In Material3, defaults to + /// [ColorScheme.surfaceContainer]. + final Color? color; + + /// If provided, this color is used for the button icon. + /// + /// If this property is null, then [PopupMenuThemeData.iconColor] is used. + /// If [PopupMenuThemeData.iconColor] is also null then defaults to + /// [IconThemeData.color]. + final Color? iconColor; + + /// Whether detected gestures should provide acoustic and/or haptic feedback. + /// + /// For example, on Android a tap will produce a clicking sound and a + /// long-press will produce a short vibration, when feedback is enabled. + /// + /// See also: + /// + /// * [Feedback] for providing platform-specific feedback to certain actions. + final bool? enableFeedback; + + /// If provided, the size of the [Icon]. + /// + /// If this property is null, then [IconThemeData.size] is used. + /// If [IconThemeData.size] is also null, then + /// default size is 24.0 pixels. + final double? iconSize; + + /// Optional size constraints for the menu. + /// + /// When unspecified, defaults to: + /// ```dart + /// const BoxConstraints( + /// minWidth: 2.0 * 56.0, + /// maxWidth: 5.0 * 56.0, + /// ) + /// ``` + /// + /// The default constraints ensure that the menu width matches maximum width + /// recommended by the Material Design guidelines. + /// Specifying this parameter enables creation of menu wider than + /// the default maximum width. + final BoxConstraints? constraints; + + /// Whether the popup menu is positioned over or under the popup menu button. + /// + /// [offset] is used to change the position of the popup menu relative to the + /// position set by this parameter. + /// + /// If this property is `null`, then [PopupMenuThemeData.position] is used. If + /// [PopupMenuThemeData.position] is also `null`, then the position defaults + /// to [PopupMenuPosition.over] which makes the popup menu appear directly + /// over the button that was used to create it. + final PopupMenuPosition? position; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// The [clipBehavior] argument is used the clip shape of the menu. + /// + /// Defaults to [Clip.none]. + final Clip clipBehavior; + + /// Used to determine whether to push the menu to the [Navigator] furthest + /// from or nearest to the given `context`. + /// + /// Defaults to false. + final bool useRootNavigator; + + /// Used to override the default animation curves and durations of the popup + /// menu's open and close transitions. + /// + /// If [AnimationStyle.curve] is provided, it will be used to override + /// the default popup animation curve. Otherwise, defaults to [Curves.linear]. + /// + /// If [AnimationStyle.reverseCurve] is provided, it will be used to + /// override the default popup animation reverse curve. Otherwise, defaults to + /// `Interval(0.0, 2.0 / 3.0)`. + /// + /// If [AnimationStyle.duration] is provided, it will be used to override + /// the default popup animation duration. Otherwise, defaults to 300ms. + /// + /// To disable the theme animation, use [AnimationStyle.noAnimation]. + /// + /// If this is null, then the default animation will be used. + final AnimationStyle? popUpAnimationStyle; + + /// Optional route settings for the menu. + /// + /// See [RouteSettings] for details. + final RouteSettings? routeSettings; + + /// Customizes this icon button's appearance. + /// + /// The [style] is only used for Material 3 [IconButton]s. If [ThemeData.useMaterial3] + /// is set to true, [style] is preferred for icon button customization, and any + /// parameters defined in [style] will override the same parameters in [IconButton]. + /// + /// Null by default. + final ButtonStyle? style; + + @override + PopupMenuButtonState createState() => PopupMenuButtonState(); +} + +/// The [State] for a [PopupMenuButton]. +/// +/// See [showButtonMenu] for a way to programmatically open the popup menu +/// of your button state. +class PopupMenuButtonState extends State> { + /// A method to show a popup menu with the items supplied to + /// [PopupMenuButton.itemBuilder] at the position of your [PopupMenuButton]. + /// + /// By default, it is called when the user taps the button and [PopupMenuButton.enabled] + /// is set to `true`. Moreover, you can open the button by calling the method manually. + /// + /// You would access your [PopupMenuButtonState] using a [GlobalKey] and + /// show the menu of the button with `globalKey.currentState.showButtonMenu`. + void showButtonMenu() { + final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); + final RenderBox button = context.findRenderObject()! as RenderBox; + final RenderBox overlay = + Navigator.of(context).overlay!.context.findRenderObject()! as RenderBox; + final PopupMenuPosition popupMenuPosition = + widget.position ?? popupMenuTheme.position ?? PopupMenuPosition.over; + late Offset offset; + switch (popupMenuPosition) { + case PopupMenuPosition.over: + offset = widget.offset; + case PopupMenuPosition.under: + offset = Offset(0.0, button.size.height) + widget.offset; + if (widget.child == null) { + // Remove the padding of the icon button. + offset -= Offset(0.0, widget.padding.vertical / 2); + } + } + final RelativeRect position = RelativeRect.fromRect( + Rect.fromPoints( + button.localToGlobal(offset, ancestor: overlay), + button.localToGlobal( + button.size.bottomRight(Offset.zero) + offset, + ancestor: overlay, + ), + ), + Offset.zero & overlay.size, + ); + final List> items = widget.itemBuilder(context); + // Only show the menu if there is something to show + if (items.isNotEmpty) { + var popUpAnimationStyle = widget.popUpAnimationStyle; + if (popUpAnimationStyle == null && + defaultTargetPlatform == TargetPlatform.iOS) { + popUpAnimationStyle = AnimationStyle( + curve: Curves.easeInOut, + duration: const Duration(milliseconds: 300), + ); + } + widget.onOpened?.call(); + showMenu( + context: context, + elevation: widget.elevation ?? popupMenuTheme.elevation, + shadowColor: widget.shadowColor ?? popupMenuTheme.shadowColor, + surfaceTintColor: + widget.surfaceTintColor ?? popupMenuTheme.surfaceTintColor, + items: items, + initialValue: widget.initialValue, + position: position, + shape: widget.shape ?? popupMenuTheme.shape, + color: widget.color ?? popupMenuTheme.color, + constraints: widget.constraints, + clipBehavior: widget.clipBehavior, + useRootNavigator: widget.useRootNavigator, + popUpAnimationStyle: popUpAnimationStyle, + routeSettings: widget.routeSettings, + ).then((T? newValue) { + if (!mounted) { + return null; + } + if (newValue == null) { + widget.onCanceled?.call(); + return null; + } + widget.onSelected?.call(newValue); + }); + } + } + + @override + Widget build(BuildContext context) { + final IconThemeData iconTheme = IconTheme.of(context); + final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); + final bool enableFeedback = widget.enableFeedback ?? + PopupMenuTheme.of(context).enableFeedback ?? + true; + + assert(debugCheckHasMaterialLocalizations(context)); + + if (widget.child != null) { + return AnimatedGestureDetector( + scaleFactor: 0.95, + onTapUp: widget.enabled ? showButtonMenu : null, + child: widget.child!, + ); + } + + return IconButton( + icon: widget.icon ?? Icon(Icons.adaptive.more), + padding: widget.padding, + splashRadius: widget.splashRadius, + iconSize: widget.iconSize ?? popupMenuTheme.iconSize ?? iconTheme.size, + color: widget.iconColor ?? popupMenuTheme.iconColor ?? iconTheme.color, + tooltip: + widget.tooltip ?? MaterialLocalizations.of(context).showMenuTooltip, + onPressed: widget.enabled ? showButtonMenu : null, + enableFeedback: enableFeedback, + style: widget.style, + ); + } +} + +class _PopupMenuDefaultsM2 extends PopupMenuThemeData { + _PopupMenuDefaultsM2(this.context) : super(elevation: 8.0); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final TextTheme _textTheme = _theme.textTheme; + + @override + TextStyle? get textStyle => _textTheme.titleMedium; + + static EdgeInsets menuHorizontalPadding = + const EdgeInsets.symmetric(horizontal: 16.0); +} + +// BEGIN GENERATED TOKEN PROPERTIES - PopupMenu + +// Do not edit by hand. The code between the "BEGIN GENERATED" and +// "END GENERATED" comments are generated from data in the Material +// Design token database by the script: +// dev/tools/gen_defaults/bin/gen_defaults.dart. + +class _PopupMenuDefaultsM3 extends PopupMenuThemeData { + _PopupMenuDefaultsM3(this.context) : super(elevation: 3.0); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final ColorScheme _colors = _theme.colorScheme; + late final TextTheme _textTheme = _theme.textTheme; + + @override + WidgetStateProperty? get labelTextStyle { + return WidgetStateProperty.resolveWith((Set states) { + final TextStyle style = _textTheme.labelLarge!; + if (states.contains(WidgetState.disabled)) { + return style.apply(color: _colors.onSurface.withOpacity(0.38)); + } + return style.apply(color: _colors.onSurface); + }); + } + + @override + Color? get color => _colors.surfaceContainer; + + @override + Color? get shadowColor => _colors.shadow; + + @override + Color? get surfaceTintColor => Colors.transparent; + + @override + ShapeBorder? get shape => const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(4.0)), + ); + + // TODO(tahatesser): This is taken from https://m3.material.io/components/menus/specs + // Update this when the token is available. + static EdgeInsets menuHorizontalPadding = + const EdgeInsets.symmetric(horizontal: 12.0); +} +// END GENERATED TOKEN PROPERTIES - PopupMenu + +extension PopupMenuColors on BuildContext { + Color get popupMenuBackgroundColor { + if (Theme.of(this).brightness == Brightness.light) { + return Theme.of(this).colorScheme.surface; + } + return const Color(0xFF23262B); + } +} diff --git a/frontend/appflowy_flutter/lib/shared/red_dot.dart b/frontend/appflowy_flutter/lib/shared/red_dot.dart new file mode 100644 index 0000000000000..149cadae04ec6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/red_dot.dart @@ -0,0 +1,25 @@ +import 'package:flutter/widgets.dart'; + +class NotificationRedDot extends StatelessWidget { + const NotificationRedDot({ + super.key, + this.size = 6, + }); + + final double size; + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + clipBehavior: Clip.antiAlias, + decoration: ShapeDecoration( + color: const Color(0xFFFF2214), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/shared/time_format.dart b/frontend/appflowy_flutter/lib/shared/time_format.dart new file mode 100644 index 0000000000000..450f9954af182 --- /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 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 && prefix != null) { + return '$prefix $date'; + } + + return date; +} diff --git a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart index 59fa0b1025b20..4136cfd07db03 100644 --- a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart +++ b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart @@ -4,7 +4,6 @@ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/ai_client.dart'; -import 'package:appflowy/plugins/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'; @@ -13,7 +12,6 @@ import 'package:appflowy/startup/tasks/appflowy_cloud_task.dart'; import 'package:appflowy/user/application/ai_service.dart'; import 'package:appflowy/user/application/auth/af_cloud_auth_service.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; -import 'package:appflowy/user/application/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'; @@ -43,7 +41,6 @@ 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; class DependencyResolver { static Future resolve( @@ -100,23 +97,6 @@ void _resolveCommonService( }, ); - 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(), ); @@ -143,9 +123,6 @@ void _resolveUserDeps(GetIt getIt, IntegrationMode mode) { ), ); break; - case AuthenticatorType.supabase: - getIt.registerFactory(() => SupabaseAuthService()); - break; case AuthenticatorType.appflowyCloud: case AuthenticatorType.appflowyCloudSelfHost: case AuthenticatorType.appflowyCloudDevelop: @@ -208,11 +185,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/startup.dart b/frontend/appflowy_flutter/lib/startup/startup.dart index 3dac4f229c6f3..3741380567c84 100644 --- a/frontend/appflowy_flutter/lib/startup/startup.dart +++ b/frontend/appflowy_flutter/lib/startup/startup.dart @@ -14,6 +14,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; @@ -109,7 +110,9 @@ 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(), @@ -124,6 +127,7 @@ class FlowyRunner { InitRustSDKTask(customApplicationPath: applicationDataDirectory), // Load Plugins, like document, grid ... const PluginLoadTask(), + const FileStorageTask(), // init the app widget // ignore in test mode @@ -132,7 +136,6 @@ class FlowyRunner { // It is unable to get the device information from the test environment. const ApplicationInfoTask(), const HotKeyTask(), - if (isSupabaseEnabled) InitSupabaseTask(), if (isAppFlowyCloudEnabled) InitAppFlowyCloudTask(), const InitAppWidgetTask(), const InitPlatformServiceTask(), diff --git a/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart b/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart index 31d610e278470..5574749d64611 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart @@ -1,11 +1,9 @@ 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/feature_flags.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/user_settings_service.dart'; import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; @@ -24,8 +22,11 @@ import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:toastification/toastification.dart'; import 'prelude.dart'; @@ -41,6 +42,8 @@ class InitAppWidgetTask extends LaunchTask { await NotificationService.initialize(); + await loadIconGroups(); + final widget = context.getIt().create(context.config); final appearanceSetting = await UserSettingsBackendService().getAppearanceSetting(); @@ -187,9 +190,12 @@ class _ApplicationWidgetState extends State { 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, + }, + ); } } }); @@ -197,31 +203,33 @@ 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 ToastificationWrapper( + child: MaterialApp.router( + builder: (context, child) => MediaQuery( + // use the 1.0 as the textScaleFactor to avoid the text size + // affected by the system setting. + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear(state.textScaleFactor), + ), + child: overlayManagerBuilder( + context, + !PlatformExtension.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, ), - debugShowCheckedModeBanner: false, - theme: state.lightTheme, - darkTheme: state.darkTheme, - themeMode: state.themeMode, - localizationsDelegates: context.localizationDelegates, - supportedLocales: context.supportedLocales, - locale: state.locale, - routerConfig: routerConfig, ); }, ), 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 c0a683df0dc7c..2a61b7288457e 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 @@ -35,7 +35,7 @@ class WindowSizeManager { Future getSize() async { final defaultWindowSize = jsonEncode( - {WindowSizeManager.height: 600.0, WindowSizeManager.width: 800.0}, + {WindowSizeManager.height: minWindowHeight, WindowSizeManager.width: minWindowWidth}, ); final windowSize = await getIt().get(KVKeys.windowSize); final size = json.decode( 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 542e8b75a213d..5aad45b3c59f8 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart @@ -7,7 +7,6 @@ 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'; @@ -22,6 +21,8 @@ import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:url_protocol/url_protocol.dart'; +const appflowyDeepLinkSchema = 'appflowy-flutter'; + class AppFlowyCloudDeepLink { AppFlowyCloudDeepLink() { if (_deeplinkSubscription == null) { diff --git a/frontend/appflowy_flutter/lib/startup/tasks/file_storage_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/file_storage_task.dart new file mode 100644 index 0000000000000..ffa331f9be2e9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/file_storage_task.dart @@ -0,0 +1,148 @@ +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( + "Upload progress: file: ${fileProgress.fileUrl} ${fileProgress.progress}", + ); + final notifier = _notifierList[fileProgress.fileUrl]; + if (notifier != null) { + notifier.value = fileProgress; + } + } + }, + ); + + 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 65096984bd83a..0c32a8f6ac3ad 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart @@ -1,5 +1,8 @@ import 'dart:convert'; +import 'package:flutter/foundation.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/card/card.dart'; @@ -10,12 +13,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'; @@ -30,7 +35,6 @@ import 'package:appflowy/workspace/presentation/settings/widgets/feature_flags/m 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/material.dart'; import 'package:go_router/go_router.dart'; import 'package:sheet/route.dart'; @@ -92,6 +96,12 @@ GoRouter generateRouter(Widget child) { _mobileCalendarEventsPageRoute(), _mobileBlockSettingsPageRoute(), + + // notifications + _mobileNotificationMultiSelectPageRoute(), + + // invite members + _mobileInviteMembersPageRoute(), ], // Desktop and Mobile @@ -159,33 +169,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(), ), ], ), @@ -203,6 +191,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, @@ -482,9 +494,20 @@ 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]; return MaterialExtendedPage( - child: MobileDocumentScreen(id: id, title: title), + child: MobileDocumentScreen( + id: id, + title: title, + showMoreButton: showMoreButton ?? true, + fixedTitle: fixedTitle, + ), ); }, ); @@ -564,10 +587,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( @@ -635,3 +673,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/platform_error_catcher.dart b/frontend/appflowy_flutter/lib/startup/tasks/platform_error_catcher.dart index 9d088bb5d40dc..c2c64536b2dc4 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 84c379da243a3..4be5f0f6f7acd 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/prelude.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/prelude.dart @@ -9,7 +9,7 @@ export 'localization.dart'; export 'memory_leak_detector.dart'; export 'platform_error_catcher.dart'; export 'platform_service.dart'; +export 'recent_service_task.dart'; export 'rust_sdk.dart'; -export 'supabase_task.dart'; +export 'sentry.dart'; export 'windows.dart'; -export 'recent_service_task.dart'; diff --git a/frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart b/frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart index c02b450d79cb4..58d6aacbc31ae 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart @@ -63,7 +63,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, ); diff --git a/frontend/appflowy_flutter/lib/startup/tasks/sentry.dart b/frontend/appflowy_flutter/lib/startup/tasks/sentry.dart new file mode 100644 index 0000000000000..9076569a9c172 --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/sentry.dart @@ -0,0 +1,31 @@ +import 'package:appflowy/env/env.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +import '../startup.dart'; + +class InitSentryTask extends LaunchTask { + const InitSentryTask(); + + @override + Future initialize(LaunchContext context) async { + const dsn = Env.sentryDsn; + if (dsn.isEmpty) { + Log.info('Sentry DSN is not set, skipping initialization'); + return; + } + + Log.info('Initializing Sentry'); + + await SentryFlutter.init( + (options) { + options.dsn = dsn; + options.tracesSampleRate = 0.1; + options.profilesSampleRate = 0.1; + }, + ); + } + + @override + Future dispose() async {} +} diff --git a/frontend/appflowy_flutter/lib/startup/tasks/supabase_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/supabase_task.dart deleted file mode 100644 index cb8981acddaa7..0000000000000 --- 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/user/application/ai_service.dart b/frontend/appflowy_flutter/lib/user/application/ai_service.dart index 46f0748641fe8..175cb6f1fea0d 100644 --- a/frontend/appflowy_flutter/lib/user/application/ai_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/ai_service.dart @@ -8,7 +8,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/ser import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/text_completion.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_action.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; -import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.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; @@ -59,7 +59,7 @@ class AppFlowyAIService implements AIRepository { ); // ignore: unawaited_futures - ChatEventCompleteText(payload).send(); + AIEventCompleteText(payload).send(); return stream; } } @@ -90,7 +90,7 @@ class CompletionStream { if (event == "AI_RESPONSE_LIMIT") { onError( AIError( - message: LocaleKeys.sideBar_aiResponseLitmit.tr(), + message: LocaleKeys.sideBar_aiResponseLimit.tr(), code: AIErrorCode.aiResponseLimitExceeded, ), ); 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 81dd8ed9cfc2e..ae7fe379823c7 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 @@ -121,6 +121,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 deba0f37003fa..fac655b7fcaa6 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(AuthenticatorPB.AppFlowyCloud); @override Future> signUp({ @@ -64,12 +65,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("Error: $err"); + Log.error(err); + }, + ); return value; }); }, (r) { - Log.error(r); + debugPrint("Error: $r"); return FlowyResult.failure(r); }, ); 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 0dc48d7ef7bfa..0000000000000 --- 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 bd2620caaa504..0000000000000 --- 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/reminder/reminder_bloc.dart b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart index d50c6fc7953ac..24f53e48e80d9 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'); + + final result = await _reminderService.fetchReminders(); - remindersOrFailure.fold( - (reminders) => emit(state.copyWith(reminders: reminders)), - (error) => Log.error(error), + 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(); + }); + } + + final result = await _reminderService.addReminder( + reminder: reminder, + ); - return unitOrFailure.fold( + 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 94bf638de595b..1b5aeaeb43efd 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 52a103899c045..6fda156567956 100644 --- a/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart +++ b/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart @@ -1,16 +1,16 @@ -import 'package:flutter/foundation.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/startup/tasks/appflowy_cloud_task.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' show UserProfilePB; import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -208,6 +208,8 @@ class SignInBloc extends Bloc { } SignInState _stateFromCode(FlowyError error) { + Log.error('SignInState _stateFromCode: ${error.msg}'); + switch (error.code) { case ErrorCode.EmailFormatInvalid: return state.copyWith( diff --git a/frontend/appflowy_flutter/lib/user/application/user_service.dart b/frontend/appflowy_flutter/lib/user/application/user_service.dart index 25eec30992839..5a75a4df3edf4 100644 --- a/frontend/appflowy_flutter/lib/user/application/user_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/user_service.dart @@ -1,7 +1,5 @@ import 'dart:async'; -import 'package:appflowy/env/cloud_env.dart'; -import 'package:appflowy/startup/startup.dart'; 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'; @@ -9,11 +7,13 @@ import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:fixnum/fixnum.dart'; +import 'package:flutter/foundation.dart'; abstract class IUserBackendService { Future> cancelSubscription( String workspaceId, SubscriptionPlanPB plan, + String? reason, ); Future> createSubscription( String workspaceId, @@ -21,6 +21,9 @@ abstract class IUserBackendService { ); } +const _baseBetaUrl = 'https://beta.appflowy.com'; +const _baseProdUrl = 'https://appflowy.com'; + class UserBackendService implements IUserBackendService { UserBackendService({required this.userId}); @@ -255,19 +258,24 @@ class UserBackendService implements IUserBackendService { ..recurringInterval = RecurringIntervalPB.Year ..workspaceSubscriptionPlan = plan ..successUrl = - '${getIt().appflowyCloudConfig.base_url}/web/payment-success?plan=${plan.toRecognizable()}'; + '${kDebugMode ? _baseBetaUrl : _baseProdUrl}/after-payment?plan=${plan.toRecognizable()}'; return UserEventSubscribeWorkspace(request).send(); } @override Future> cancelSubscription( String workspaceId, - SubscriptionPlanPB plan, - ) { + SubscriptionPlanPB plan, [ + String? reason, + ]) { final request = CancelWorkspaceSubscriptionPB() ..workspaceId = workspaceId ..plan = plan; + if (reason != null) { + request.reason = reason; + } + return UserEventCancelWorkspaceSubscription(request).send(); } @@ -283,4 +291,9 @@ class UserBackendService implements IUserBackendService { 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/presentation/helpers/handle_open_workspace_error.dart b/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_open_workspace_error.dart index 3ecacf0961d8e..a6375e0c4ef44 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,11 +1,12 @@ 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'; +import 'package:toastification/toastification.dart'; void handleOpenWorkspaceError(BuildContext context, FlowyError error) { Log.error(error); @@ -15,24 +16,24 @@ void handleOpenWorkspaceError(BuildContext context, FlowyError error) { getIt().pushWorkspaceErrorScreen(context, userFolder, error); break; case ErrorCode.InvalidEncryptSecret: - showSnapBar( - context, - error.msg, - ); - break; case ErrorCode.HttpError: - showSnapBar( + showToastNotification( context, - error.msg, + message: error.msg, + type: ToastificationType.error, ); + break; default: - showSnapBar( + showToastNotification( context, - error.msg, - onClosed: () { - getIt().signOut(); - runAppFlowy(); - }, + message: error.msg, + type: ToastificationType.error, + callbacks: ToastificationCallbacks( + onDismissed: (_) { + getIt().signOut(); + runAppFlowy(); + }, + ), ); } } 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 89b782d2317be..0d39757201319 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,5 +1,3 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/core/frameless_window.dart'; import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; @@ -10,10 +8,13 @@ import 'package:appflowy/user/presentation/widgets/widgets.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 DesktopSignInScreen extends StatelessWidget { - const DesktopSignInScreen({super.key}); + const DesktopSignInScreen({ + super.key, + }); @override Widget build(BuildContext context) { @@ -21,38 +22,32 @@ class DesktopSignInScreen extends StatelessWidget { return BlocBuilder( builder: (context, state) { return Scaffold( - appBar: PreferredSize( - preferredSize: - Size.fromHeight(PlatformExtension.isWindows ? 40 : 60), - child: PlatformExtension.isWindows - ? const WindowTitleBar() - : const MoveWindowDetector(), - ), + appBar: _buildAppBar(), body: Center( child: AuthFormContainer( children: [ + const Spacer(), + const VSpace(20), + + // logo and title FlowyLogoTitle( title: LocaleKeys.welcomeText.tr(), logoSize: const Size(60, 60), ), const VSpace(20), + // magic link sign in const SignInWithMagicLinkButtons(), - - // third-party sign in. const VSpace(20), + // third-party sign in. if (isAuthEnabled) ...[ const _OrDivider(), - const VSpace(10), + const VSpace(20), const ThirdPartySignInButtons(), + const VSpace(20), ], - const VSpace(20), - - // anonymous sign in - const SignInAnonymousButtonV2(), - const VSpace(10), // sign in agreement const SignInAgreement(), @@ -65,6 +60,12 @@ class DesktopSignInScreen extends StatelessWidget { ) : const VSpace(indicatorMinHeight), const VSpace(20), + + const Spacer(), + + // anonymous sign in + const SignInAnonymousButtonV2(), + const VSpace(16), ], ), ), @@ -72,6 +73,15 @@ class DesktopSignInScreen extends StatelessWidget { }, ); } + + PreferredSize _buildAppBar() { + return PreferredSize( + preferredSize: Size.fromHeight(PlatformExtension.isWindows ? 40 : 60), + child: PlatformExtension.isWindows + ? const WindowTitleBar() + : const MoveWindowDetector(), + ); + } } class _OrDivider extends StatelessWidget { 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 ce0e959aca4a9..727062a10870d 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,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'dart:io'; import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; @@ -8,6 +8,7 @@ import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -23,26 +24,27 @@ class MobileSignInScreen extends StatelessWidget { return BlocBuilder( builder: (context, state) { 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(), + const VSpace(spacing), _buildAppNameText(colorScheme), const VSpace(spacing * 2), const SignInWithMagicLinkButtons(), const VSpace(spacing), if (isAuthEnabled) _buildThirdPartySignInButtons(colorScheme), - const VSpace(spacing), - const SignInAnonymousButtonV2(), - const VSpace(spacing), + const VSpace(spacing * 1.5), const SignInAgreement(), const VSpace(spacing), - _buildSettingsButton(context), if (!isAuthEnabled) const Spacer(flex: 2), + const Spacer(flex: 2), + const Spacer(), + Expanded(child: _buildSettingsButton(context)), + if (Platform.isAndroid) const Spacer(), ], ), ), @@ -51,19 +53,10 @@ 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), + size: Size.square(56), blendMode: null, ); } @@ -72,7 +65,7 @@ class MobileSignInScreen extends StatelessWidget { return FlowyText( LocaleKeys.appName.tr(), textAlign: TextAlign.center, - fontSize: 32, + fontSize: 28, color: const Color(0xFF00BCF0), fontWeight: FontWeight.w700, ); @@ -89,6 +82,7 @@ class MobileSignInScreen extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 8), child: FlowyText( LocaleKeys.signIn_or.tr(), + fontSize: 12, color: colorScheme.onSecondary, ), ), @@ -96,23 +90,45 @@ class MobileSignInScreen extends StatelessWidget { ], ), 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); - }, + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyButton( + useIntrinsicWidth: true, + text: FlowyText( + LocaleKeys.signIn_settings.tr(), + textAlign: TextAlign.center, + fontSize: 12.0, + // fontWeight: FontWeight.w500, + color: Colors.grey, + decoration: TextDecoration.underline, + ), + onTap: () { + context.push(MobileLaunchSettingsPage.routeName); + }, + ), + const HSpace(24), + SignInAnonymousButtonV2( + child: FlowyText( + LocaleKeys.signIn_anonymous.tr(), + textAlign: TextAlign.center, + fontSize: 12.0, + // fontWeight: FontWeight.w500, + color: Colors.grey, + decoration: TextDecoration.underline, + ), + ), + ], ); } } 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 731faed73e91c..89350016240df 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 @@ -1,5 +1,3 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy/user/presentation/router.dart'; @@ -7,6 +5,7 @@ import 'package:appflowy/user/presentation/screens/sign_in_screen/desktop_sign_i 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:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../helpers/helpers.dart'; @@ -21,16 +20,7 @@ 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) { @@ -43,4 +33,15 @@ class SignInScreen extends StatelessWidget { ), ); } + + void _showSignInError(BuildContext context, SignInState state) { + final successOrFail = state.successOrFail; + if (successOrFail != null) { + handleUserProfileResult( + successOrFail, + context, + getIt(), + ); + } + } } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart index e32a9e2908ebf..30ff0addd9e95 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 @@ -21,10 +21,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(); } @@ -34,13 +36,23 @@ class _SignInWithMagicLinkButtonsState crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( - height: 48.0, + height: PlatformExtension.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), @@ -88,14 +100,14 @@ class _ConfirmButton extends StatelessWidget { if (PlatformExtension.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 { @@ -108,6 +120,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 index 7976e63667c03..7351871b6a314 100644 --- 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 @@ -21,7 +21,11 @@ class SignInAgreement extends StatelessWidget { ), TextSpan( text: '${LocaleKeys.web_termOfUse.tr()} ', - style: const TextStyle(color: Colors.blue, fontSize: 12), + style: const TextStyle( + color: Colors.grey, + fontSize: 12, + decoration: TextDecoration.underline, + ), mouseCursor: SystemMouseCursors.click, recognizer: TapGestureRecognizer() ..onTap = () => afLaunchUrlString('https://appflowy.io/terms'), @@ -32,7 +36,11 @@ class SignInAgreement extends StatelessWidget { ), TextSpan( text: LocaleKeys.web_privacyPolicy.tr(), - style: const TextStyle(color: Colors.blue, fontSize: 12), + style: const TextStyle( + color: Colors.grey, + fontSize: 12, + decoration: TextDecoration.underline, + ), mouseCursor: SystemMouseCursors.click, recognizer: TapGestureRecognizer() ..onTap = () => afLaunchUrlString('https://appflowy.io/privacy'), diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_anonymous_button.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_anonymous_button.dart index e8d6bac536703..e12582b9cd41b 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 @@ -89,8 +89,11 @@ class SignInAnonymousButton extends StatelessWidget { class SignInAnonymousButtonV2 extends StatelessWidget { const SignInAnonymousButtonV2({ super.key, + this.child, }); + final Widget? child; + @override Widget build(BuildContext context) { return BlocBuilder( @@ -126,11 +129,12 @@ class SignInAnonymousButtonV2 extends StatelessWidget { cursor: SystemMouseCursors.click, child: GestureDetector( onTap: onTap, - child: FlowyText( - text, - color: Colors.blue, - fontSize: 12, - ), + child: child ?? + FlowyText( + text, + color: Colors.blue, + fontSize: 12, + ), ), ); }, diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_or_logout_button.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_or_logout_button.dart index e25fcf3a35841..5146e29962740 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 @@ -2,16 +2,18 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:flowy_infra_ui/flowy_infra_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 @@ -20,13 +22,13 @@ class MobileSignInOrLogoutButton extends StatelessWidget { return GestureDetector( onTap: onPressed, child: Container( - height: 48, + height: 38, decoration: BoxDecoration( borderRadius: const BorderRadius.all( Radius.circular(4), ), border: Border.all( - color: style.colorScheme.outline, + color: textColor ?? style.colorScheme.outline, width: 0.5, ), ), @@ -52,9 +54,10 @@ class MobileSignInOrLogoutButton extends StatelessWidget { const HSpace(8), ], FlowyText( - labelText, + text, fontSize: 14.0, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w400, + color: textColor, ), ], ), diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button.dart new file mode 100644 index 0000000000000..36d83ea3bcd3a --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button.dart @@ -0,0 +1,220 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; +import 'package:appflowy/user/presentation/widgets/widgets.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +enum ThirdPartySignInButtonType { + apple, + google, + github, + discord, + anonymous; + + String get provider { + switch (this) { + case ThirdPartySignInButtonType.apple: + return 'apple'; + case ThirdPartySignInButtonType.google: + return 'google'; + case ThirdPartySignInButtonType.github: + return 'github'; + case ThirdPartySignInButtonType.discord: + return 'discord'; + case ThirdPartySignInButtonType.anonymous: + throw UnsupportedError('Anonymous session does not have a provider'); + } + } + + FlowySvgData get icon { + switch (this) { + case ThirdPartySignInButtonType.apple: + return FlowySvgs.m_apple_icon_xl; + case ThirdPartySignInButtonType.google: + return FlowySvgs.m_google_icon_xl; + case ThirdPartySignInButtonType.github: + return FlowySvgs.m_github_icon_xl; + case ThirdPartySignInButtonType.discord: + return FlowySvgs.m_discord_icon_xl; + case ThirdPartySignInButtonType.anonymous: + return FlowySvgs.m_discord_icon_xl; + } + } + + String get labelText { + switch (this) { + case ThirdPartySignInButtonType.apple: + return LocaleKeys.signIn_signInWithApple.tr(); + case ThirdPartySignInButtonType.google: + return LocaleKeys.signIn_signInWithGoogle.tr(); + case ThirdPartySignInButtonType.github: + return LocaleKeys.signIn_signInWithGithub.tr(); + case ThirdPartySignInButtonType.discord: + return LocaleKeys.signIn_signInWithDiscord.tr(); + case ThirdPartySignInButtonType.anonymous: + return 'Anonymous session'; + } + } + + // https://developer.apple.com/design/human-interface-guidelines/sign-in-with-apple + Color backgroundColor(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + switch (this) { + case ThirdPartySignInButtonType.apple: + return isDarkMode ? Colors.white : Colors.black; + case ThirdPartySignInButtonType.google: + case ThirdPartySignInButtonType.github: + case ThirdPartySignInButtonType.discord: + case ThirdPartySignInButtonType.anonymous: + return isDarkMode ? Colors.black : Colors.grey.shade100; + } + } + + Color textColor(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + switch (this) { + case ThirdPartySignInButtonType.apple: + return isDarkMode ? Colors.black : Colors.white; + case ThirdPartySignInButtonType.google: + case ThirdPartySignInButtonType.github: + case ThirdPartySignInButtonType.discord: + case ThirdPartySignInButtonType.anonymous: + return isDarkMode ? Colors.white : Colors.black; + } + } + + BlendMode? get blendMode { + switch (this) { + case ThirdPartySignInButtonType.apple: + case ThirdPartySignInButtonType.github: + return BlendMode.srcIn; + default: + return null; + } + } +} + +class MobileThirdPartySignInButton extends StatelessWidget { + const MobileThirdPartySignInButton({ + super.key, + this.height = 38, + this.fontSize = 14.0, + required this.onPressed, + required this.type, + }); + + final VoidCallback onPressed; + final double height; + final double fontSize; + final ThirdPartySignInButtonType type; + + @override + Widget build(BuildContext context) { + final style = Theme.of(context); + + return AnimatedGestureDetector( + scaleFactor: 1.0, + onTapUp: onPressed, + child: Container( + height: height, + decoration: BoxDecoration( + color: type.backgroundColor(context), + borderRadius: const BorderRadius.all( + Radius.circular(4), + ), + border: Border.all( + color: style.colorScheme.outline, + width: 0.5, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (type != ThirdPartySignInButtonType.anonymous) + FlowySvg( + type.icon, + size: Size.square(fontSize), + blendMode: type.blendMode, + color: type.textColor(context), + ), + const HSpace(8.0), + FlowyText( + type.labelText, + fontSize: fontSize, + color: type.textColor(context), + ), + ], + ), + ), + ); + } +} + + +class DesktopSignInButton extends StatelessWidget { + const DesktopSignInButton({ + super.key, + required this.type, + required this.onPressed, + }); + + final ThirdPartySignInButtonType type; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final style = Theme.of(context); + // In desktop, the width of button is limited by [AuthFormContainer] + return SizedBox( + height: 48, + width: AuthFormContainer.width, + child: OutlinedButton.icon( + // In order to align all the labels vertically in a relatively centered position to the button, we use a fixed width container to wrap the icon(align to the right), then use another container to align the label to left. + icon: Container( + width: AuthFormContainer.width / 4, + alignment: Alignment.centerRight, + child: SizedBox( + // Some icons are not square, so we just use a fixed width here. + width: 24, + child: FlowySvg( + type.icon, + blendMode: type.blendMode, + ), + ), + ), + label: Container( + padding: const EdgeInsets.only(left: 8), + alignment: Alignment.centerLeft, + child: FlowyText( + type.labelText, + fontSize: 14, + ), + ), + style: ButtonStyle( + overlayColor: WidgetStateProperty.resolveWith( + (states) { + if (states.contains(WidgetState.hovered)) { + return style.colorScheme.onSecondaryContainer; + } + return null; + }, + ), + shape: WidgetStateProperty.all( + const RoundedRectangleBorder( + borderRadius: Corners.s6Border, + ), + ), + side: WidgetStateProperty.all( + BorderSide( + color: style.dividerColor, + ), + ), + ), + onPressed: onPressed, + ), + ); + } +} \ No newline at end of file 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 index b7fe53a8eac97..bd84dfbc1c87a 100644 --- 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 @@ -1,195 +1,199 @@ -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/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}); +import 'third_party_sign_in_button.dart'; - @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); - }, - ), - ], - ); - }, - ); - } -} +typedef _SignInCallback = void Function(ThirdPartySignInButtonType signInType); -class _ThirdPartySignInButton extends StatelessWidget { - /// Build button based on current Platform(mobile or desktop). - const _ThirdPartySignInButton({ +@visibleForTesting +const Key signInWithGoogleButtonKey = Key('signInWithGoogleButton'); + +class ThirdPartySignInButtons extends StatelessWidget { + /// Used in DesktopSignInScreen, MobileSignInScreen and SettingThirdPartyLogin + const ThirdPartySignInButtons({ super.key, - required this.icon, - required this.labelText, - required this.onPressed, + this.expanded = false, }); - final FlowySvgData icon; - final String labelText; - - final VoidCallback onPressed; + final bool expanded; @override Widget build(BuildContext context) { - if (PlatformExtension.isMobile) { - return MobileSignInOrLogoutButton( - icon: icon, - labelText: labelText, - onPressed: onPressed, + if (PlatformExtension.isDesktopOrWeb) { + return _DesktopThirdPartySignIn( + onSignIn: (type) => _signIn(context, type.provider), ); } else { - return _DesktopSignInButton( - icon: icon, - labelText: labelText, - onPressed: onPressed, + return _MobileThirdPartySignIn( + isExpanded: expanded, + onSignIn: (type) => _signIn(context, type.provider), ); } } + + void _signIn(BuildContext context, String provider) { + context.read().add( + SignInEvent.signedInWithOAuth(provider), + ); + } } -class _DesktopSignInButton extends StatelessWidget { - const _DesktopSignInButton({ - required this.icon, - required this.labelText, - required this.onPressed, +class _DesktopThirdPartySignIn extends StatefulWidget { + const _DesktopThirdPartySignIn({ + required this.onSignIn, }); - final FlowySvgData icon; - final String labelText; + final _SignInCallback onSignIn; + + @override + State<_DesktopThirdPartySignIn> createState() => + _DesktopThirdPartySignInState(); +} + +class _DesktopThirdPartySignInState extends State<_DesktopThirdPartySignIn> { + static const padding = 12.0; - final VoidCallback onPressed; + bool isExpanded = false; @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, - ), - ), + return Column( + children: [ + DesktopSignInButton( + key: signInWithGoogleButtonKey, + type: ThirdPartySignInButtonType.google, + onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.google), ), - label: Container( - padding: const EdgeInsets.only(left: 8), - alignment: Alignment.centerLeft, - child: FlowyText( - labelText, - fontSize: 14, - ), + const VSpace(padding), + DesktopSignInButton( + type: ThirdPartySignInButtonType.apple, + onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.apple), ), - style: ButtonStyle( - overlayColor: WidgetStateProperty.resolveWith( - (states) { - if (states.contains(WidgetState.hovered)) { - return style.colorScheme.onSecondaryContainer; - } - return null; - }, - ), - shape: WidgetStateProperty.all( - const RoundedRectangleBorder( - borderRadius: Corners.s6Border, - ), - ), - side: WidgetStateProperty.all( - BorderSide( - color: style.dividerColor, - ), - ), + ...isExpanded ? _buildExpandedButtons() : _buildCollapsedButtons(), + ], + ); + } + + List _buildExpandedButtons() { + return [ + const VSpace(padding * 1.5), + DesktopSignInButton( + type: ThirdPartySignInButtonType.github, + onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.github), + ), + const VSpace(padding), + DesktopSignInButton( + type: ThirdPartySignInButtonType.discord, + onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.discord), + ), + ]; + } + + List _buildCollapsedButtons() { + return [ + const VSpace(padding), + GestureDetector( + onTap: () { + setState(() { + isExpanded = !isExpanded; + }); + }, + child: FlowyText( + LocaleKeys.signIn_continueAnotherWay.tr(), + color: Theme.of(context).colorScheme.onSurface, + decoration: TextDecoration.underline, + fontSize: 14, ), - onPressed: onPressed, ), - ); + ]; } } -void _signInWithGoogle(BuildContext context) { - context.read().add( - const SignInEvent.signedInWithOAuth('google'), - ); -} +class _MobileThirdPartySignIn extends StatefulWidget { + const _MobileThirdPartySignIn({ + required this.isExpanded, + required this.onSignIn, + }); + + final bool isExpanded; + final _SignInCallback onSignIn; -void _signInWithGithub(BuildContext context) { - context.read().add(const SignInEvent.signedInWithOAuth('github')); + @override + State<_MobileThirdPartySignIn> createState() => + _MobileThirdPartySignInState(); } -void _signInWithDiscord(BuildContext context) { - context - .read() - .add(const SignInEvent.signedInWithOAuth('discord')); +class _MobileThirdPartySignInState extends State<_MobileThirdPartySignIn> { + static const padding = 8.0; + + bool isExpanded = false; + + @override + void initState() { + super.initState(); + + isExpanded = widget.isExpanded; + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // only display apple sign in button on iOS + if (Platform.isIOS) ...[ + MobileThirdPartySignInButton( + type: ThirdPartySignInButtonType.apple, + onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.apple), + ), + const VSpace(padding), + ], + MobileThirdPartySignInButton( + type: ThirdPartySignInButtonType.google, + onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.google), + ), + ...isExpanded ? _buildExpandedButtons() : _buildCollapsedButtons(), + ], + ); + } + + List _buildExpandedButtons() { + return [ + const VSpace(padding), + MobileThirdPartySignInButton( + type: ThirdPartySignInButtonType.github, + onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.github), + ), + const VSpace(padding), + MobileThirdPartySignInButton( + type: ThirdPartySignInButtonType.discord, + onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.discord), + ), + ]; + } + + List _buildCollapsedButtons() { + return [ + const VSpace(padding * 2), + GestureDetector( + onTap: () { + setState(() { + isExpanded = !isExpanded; + }); + }, + child: FlowyText( + LocaleKeys.signIn_continueAnotherWay.tr(), + color: Theme.of(context).colorScheme.onSurface, + decoration: TextDecoration.underline, + fontSize: 14, + ), + ), + ]; + } } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/widgets.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/widgets.dart index c1c2bfecf9286..18e260a472c68 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,7 +1,7 @@ export 'magic_link_sign_in_buttons.dart'; export 'sign_in_anonymous_button.dart'; export 'sign_in_or_logout_button.dart'; - +export 'third_party_sign_in_button.dart'; // export 'switch_sign_in_sign_up_button.dart'; export 'third_party_sign_in_buttons.dart'; export 'sign_in_agreement.dart'; diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/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 2be9ed6484fc5..af5e7367e5611 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 b3c1f1cd0afe9..59b61aa54b9f9 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'; @@ -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/widgets/auth_form_container.dart b/frontend/appflowy_flutter/lib/user/presentation/widgets/auth_form_container.dart index 9927ee24570c4..8ce09a5b7f08c 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,7 +1,10 @@ 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; @@ -11,14 +14,10 @@ class AuthFormContainer extends StatelessWidget { 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/util/field_type_extension.dart b/frontend/appflowy_flutter/lib/util/field_type_extension.dart index f36c2fe2649f4..12d06da7b2387 100644 --- a/frontend/appflowy_flutter/lib/util/field_type_extension.dart +++ b/frontend/appflowy_flutter/lib/util/field_type_extension.dart @@ -24,6 +24,7 @@ extension FieldTypeExtension on FieldType { 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(), }; @@ -36,12 +37,13 @@ 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.last_edited_time_s, + FieldType.CreatedTime => FlowySvgs.created_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(), }; @@ -66,6 +68,7 @@ extension FieldTypeExtension on FieldType { FieldType.Summary => const Color(0xFFBECCFF), FieldType.Time => const Color(0xFFFDEDA7), FieldType.Translate => const Color(0xFFBECCFF), + FieldType.Media => const Color(0xFFFCBEBE), _ => throw UnimplementedError(), }; diff --git a/frontend/appflowy_flutter/lib/util/xfile_ext.dart b/frontend/appflowy_flutter/lib/util/xfile_ext.dart new file mode 100644 index 0000000000000..593ea337c1a3b --- /dev/null +++ b/frontend/appflowy_flutter/lib/util/xfile_ext.dart @@ -0,0 +1,109 @@ +import 'package:appflowy/shared/patterns/file_type_patterns.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pbenum.dart'; +import 'package:cross_file/cross_file.dart'; + +enum FileType { + other, + image, + link, + document, + archive, + video, + audio, + text; +} + +extension TypeRecognizer on XFile { + FileType get fileType { + // Prefer mime over using regexp as it is more reliable. + // Refer to Microsoft Documentation for common mime types: https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types + if (mimeType?.isNotEmpty == true) { + if (mimeType!.contains('image')) { + return FileType.image; + } + if (mimeType!.contains('video')) { + return FileType.video; + } + if (mimeType!.contains('audio')) { + return FileType.audio; + } + if (mimeType!.contains('text')) { + return FileType.text; + } + if (mimeType!.contains('application')) { + if (mimeType!.contains('pdf') || + mimeType!.contains('doc') || + mimeType!.contains('docx')) { + return FileType.document; + } + if (mimeType!.contains('zip') || + mimeType!.contains('tar') || + mimeType!.contains('gz') || + mimeType!.contains('7z') || + // archive is used in eg. Java archives (jar) + mimeType!.contains('archive') || + mimeType!.contains('rar')) { + return FileType.archive; + } + if (mimeType!.contains('rtf')) { + return FileType.text; + } + } + + return FileType.other; + } + + // Check if the file is an image + if (imgExtensionRegex.hasMatch(path)) { + return FileType.image; + } + + // Check if the file is a video + if (videoExtensionRegex.hasMatch(path)) { + return FileType.video; + } + + // Check if the file is an audio + if (audioExtensionRegex.hasMatch(path)) { + return FileType.audio; + } + + // Check if the file is a document + if (documentExtensionRegex.hasMatch(path)) { + return FileType.document; + } + + // Check if the file is an archive + if (archiveExtensionRegex.hasMatch(path)) { + return FileType.archive; + } + + // Check if the file is a text + if (textExtensionRegex.hasMatch(path)) { + return FileType.text; + } + + return FileType.other; + } +} + +extension ToMediaFileTypePB on FileType { + MediaFileTypePB toMediaFileTypePB() { + switch (this) { + case FileType.image: + return MediaFileTypePB.Image; + case FileType.video: + return MediaFileTypePB.Video; + case FileType.audio: + return MediaFileTypePB.Audio; + case FileType.document: + return MediaFileTypePB.Document; + case FileType.archive: + return MediaFileTypePB.Archive; + case FileType.text: + return MediaFileTypePB.Text; + default: + return MediaFileTypePB.Other; + } + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/recent/cached_recent_service.dart b/frontend/appflowy_flutter/lib/workspace/application/recent/cached_recent_service.dart index 7361ab6da239f..a5381ce17fe52 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 @@ -33,7 +33,7 @@ class CachedRecentService { final _listener = RecentViewsListener(); Future> recentViews() async { - if (_isInitialized) return _recentViews; + if (_isInitialized || _completer.isCompleted) return _recentViews; _isInitialized = true; @@ -76,7 +76,10 @@ class CachedRecentService { (recentViews) { return FlowyResult.success( RepeatedRecentViewPB( - items: recentViews.items.where((e) => !e.item.isSpace), + // filter the space view and the orphan view + items: recentViews.items.where( + (e) => !e.item.isSpace && e.item.id != e.item.parentViewId, + ), ), ); }, diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_model_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_model_bloc.dart index e53915e95d1dc..0a1fce251b4fa 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_model_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_model_bloc.dart @@ -2,18 +2,20 @@ import 'dart:async'; import 'dart:ffi'; import 'dart:isolate'; -import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:bloc/bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:fixnum/fixnum.dart'; part 'download_model_bloc.freezed.dart'; class DownloadModelBloc extends Bloc { DownloadModelBloc(LLMModelPB model) - : super(DownloadModelState(model: model)) { + : super(DownloadModelState.initial(model)) { on(_handleEvent); } @@ -47,17 +49,21 @@ class DownloadModelBloc extends Bloc { final payload = DownloadLLMPB(progressStream: Int64(downloadStream.nativePort)); - final result = await ChatEventDownloadLLMResource(payload).send(); + final result = await AIEventDownloadLLMResource(payload).send(); result.fold((_) { emit( state.copyWith( downloadStream: downloadStream, - loadingState: const LoadingState.finish(), + loadingState: const ChatLoadingState.finish(), downloadError: null, ), ); }, (err) { - emit(state.copyWith(loadingState: LoadingState.finish(error: err))); + emit( + state.copyWith( + loadingState: ChatLoadingState.finish(error: err), + ), + ); }); }, updatePercent: (String object, double percent) { @@ -95,8 +101,21 @@ class DownloadModelState with _$DownloadModelState { @Default("") String object, @Default(0) double percent, @Default(false) bool isFinish, - @Default(LoadingState.loading()) LoadingState loadingState, + String? bigFileDownloadPrompt, + @Default(ChatLoadingState.loading()) ChatLoadingState loadingState, }) = _DownloadModelState; + + factory DownloadModelState.initial(LLMModelPB model) { + // bigger than 1 GB then show download big file prompt + String? bigFileDownloadPrompt; + if (model.fileSize > 1 * 1024 * 1024 * 1024) { + bigFileDownloadPrompt = LocaleKeys.settings_aiPage_keys_downloadBigFilePrompt.tr(); + } + return DownloadModelState( + model: model, + bigFileDownloadPrompt: bigFileDownloadPrompt, + ); + } } class DownloadingStream { diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_offline_ai_app_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_offline_ai_app_bloc.dart new file mode 100644 index 0000000000000..829bd2f62a136 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_offline_ai_app_bloc.dart @@ -0,0 +1,41 @@ +import 'dart:async'; + +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:url_launcher/url_launcher.dart' show launchUrl; +part 'download_offline_ai_app_bloc.freezed.dart'; + +class DownloadOfflineAIBloc + extends Bloc { + DownloadOfflineAIBloc() : super(const DownloadOfflineAIState()) { + on(_handleEvent); + } + + Future _handleEvent( + DownloadOfflineAIEvent event, + Emitter emit, + ) async { + await event.when( + started: () async { + final result = await AIEventGetOfflineAIAppLink().send(); + await result.fold( + (app) async { + await launchUrl(Uri.parse(app.link)); + }, + (err) {}, + ); + }, + ); + } +} + +@freezed +class DownloadOfflineAIEvent with _$DownloadOfflineAIEvent { + const factory DownloadOfflineAIEvent.started() = _Started; +} + +@freezed +class DownloadOfflineAIState with _$DownloadOfflineAIState { + const factory DownloadOfflineAIState() = _DownloadOfflineAIState; +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_bloc.dart index 6159e5b5aa702..3c3d20039dcff 100644 --- 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 @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:appflowy_backend/dispatch/dispatch.dart'; -import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; @@ -19,7 +19,7 @@ class LocalAIToggleBloc extends Bloc { ) async { await event.when( started: () async { - final result = await ChatEventGetLocalAIState().send(); + final result = await AIEventGetLocalAIState().send(); _handleResult(emit, result); }, toggle: () async { @@ -29,7 +29,7 @@ class LocalAIToggleBloc extends Bloc { ), ); unawaited( - ChatEventToggleLocalAI().send().then( + AIEventToggleLocalAI().send().then( (result) { if (!isClosed) { add(LocalAIToggleEvent.handleResult(result)); diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_chat_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_chat_bloc.dart index 18d1a2bcdca20..7f1df258ea759 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_chat_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_chat_bloc.dart @@ -1,10 +1,10 @@ import 'dart:async'; -import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; @@ -20,7 +20,7 @@ class LocalAIChatSettingBloc listener.start( stateCallback: (newState) { if (!isClosed) { - add(LocalAIChatSettingEvent.updateLLMRunningState(newState.state)); + add(LocalAIChatSettingEvent.updatePluginState(newState)); } }, ); @@ -46,21 +46,21 @@ class LocalAIChatSettingBloc modelInfo: modelInfo, models: modelInfo.models, selectedLLMModel: modelInfo.selectedModel, - fetchModelInfoState: const LoadingState.finish(), + aiModelProgress: const AIModelProgress.finish(), ), ); }, (err) { emit( state.copyWith( - fetchModelInfoState: LoadingState.finish(error: err), + aiModelProgress: AIModelProgress.finish(error: err), ), ); }, ); }, selectLLMConfig: (LLMModelPB llmModel) async { - final result = await ChatEventUpdateLocalLLM(llmModel).send(); + final result = await AIEventUpdateLocalLLM(llmModel).send(); result.fold( (llmResource) { // If all resources are downloaded, show reload plugin @@ -68,19 +68,19 @@ class LocalAIChatSettingBloc emit( state.copyWith( selectedLLMModel: llmModel, - localAIInfo: LocalAIProgress.showDownload( + progressIndicator: LocalAIProgress.showDownload( llmResource, llmModel, ), - selectLLMState: const LoadingState.finish(), + selectLLMState: const ChatLoadingState.finish(), ), ); } else { emit( state.copyWith( selectedLLMModel: llmModel, - selectLLMState: const LoadingState.finish(), - localAIInfo: const LocalAIProgress.checkPluginState(), + selectLLMState: const ChatLoadingState.finish(), + progressIndicator: const LocalAIProgress.checkPluginState(), ), ); } @@ -88,7 +88,7 @@ class LocalAIChatSettingBloc (err) { emit( state.copyWith( - selectLLMState: LoadingState.finish(error: err), + selectLLMState: ChatLoadingState.finish(error: err), ), ); }, @@ -106,7 +106,7 @@ class LocalAIChatSettingBloc if (llmResource.pendingResources.isEmpty) { emit( state.copyWith( - localAIInfo: const LocalAIProgress.checkPluginState(), + progressIndicator: const LocalAIProgress.checkPluginState(), ), ); } else { @@ -115,20 +115,20 @@ class LocalAIChatSettingBloc if (llmResource.isDownloading) { emit( state.copyWith( - localAIInfo: + progressIndicator: LocalAIProgress.startDownloading(state.selectedLLMModel!), - selectLLMState: const LoadingState.finish(), + selectLLMState: const ChatLoadingState.finish(), ), ); return; } else { emit( state.copyWith( - localAIInfo: LocalAIProgress.showDownload( + progressIndicator: LocalAIProgress.showDownload( llmResource, state.selectedLLMModel!, ), - selectLLMState: const LoadingState.finish(), + selectLLMState: const ChatLoadingState.finish(), ), ); } @@ -138,37 +138,57 @@ class LocalAIChatSettingBloc startDownloadModel: (LLMModelPB llmModel) { emit( state.copyWith( - localAIInfo: LocalAIProgress.startDownloading(llmModel), - selectLLMState: const LoadingState.finish(), + progressIndicator: LocalAIProgress.startDownloading(llmModel), + selectLLMState: const ChatLoadingState.finish(), ), ); }, cancelDownload: () async { - final _ = await ChatEventCancelDownloadLLMResource().send(); + final _ = await AIEventCancelDownloadLLMResource().send(); _fetchCurremtLLMState(); }, finishDownload: () async { emit( - state.copyWith(localAIInfo: const LocalAIProgress.finishDownload()), + state.copyWith( + progressIndicator: const LocalAIProgress.finishDownload(), + ), ); }, - updateLLMRunningState: (RunningStatePB newRunningState) { - if (newRunningState == RunningStatePB.Stopped) { + updatePluginState: (LocalAIPluginStatePB pluginState) { + if (pluginState.offlineAiReady) { + AIEventRefreshLocalAIModelInfo().send().then((result) { + if (!isClosed) { + add(LocalAIChatSettingEvent.didLoadModelInfo(result)); + } + }); + + if (pluginState.state == RunningStatePB.Stopped) { + emit( + state.copyWith( + runningState: pluginState.state, + progressIndicator: const LocalAIProgress.checkPluginState(), + ), + ); + } else { + emit( + state.copyWith( + runningState: pluginState.state, + ), + ); + } + } else { emit( state.copyWith( - runningState: newRunningState, - localAIInfo: const LocalAIProgress.checkPluginState(), + progressIndicator: const LocalAIProgress.startOfflineAIApp(), ), ); - } else { - emit(state.copyWith(runningState: newRunningState)); } }, ); } void _fetchCurremtLLMState() async { - final result = await ChatEventGetLocalLLMState().send(); + final result = await AIEventGetLocalLLMState().send(); result.fold( (llmResource) { if (!isClosed) { @@ -183,10 +203,21 @@ class LocalAIChatSettingBloc /// Handles the event to fetch local AI settings when the application starts. Future _handleStarted() async { - final result = await ChatEventRefreshLocalAIModelInfo().send(); - if (!isClosed) { - add(LocalAIChatSettingEvent.didLoadModelInfo(result)); - } + final result = await AIEventGetLocalAIPluginState().send(); + result.fold( + (pluginState) async { + if (!isClosed) { + add(LocalAIChatSettingEvent.updatePluginState(pluginState)); + if (pluginState.offlineAiReady) { + final result = await AIEventRefreshLocalAIModelInfo().send(); + if (!isClosed) { + add(LocalAIChatSettingEvent.didLoadModelInfo(result)); + } + } + } + }, + (err) => Log.error(err.toString()), + ); } @override @@ -214,9 +245,9 @@ class LocalAIChatSettingEvent with _$LocalAIChatSettingEvent { const factory LocalAIChatSettingEvent.cancelDownload() = _CancelDownload; const factory LocalAIChatSettingEvent.finishDownload() = _FinishDownload; - const factory LocalAIChatSettingEvent.updateLLMRunningState( - RunningStatePB newRunningState, - ) = _RunningState; + const factory LocalAIChatSettingEvent.updatePluginState( + LocalAIPluginStatePB pluginState, + ) = _PluginState; } @freezed @@ -224,29 +255,16 @@ class LocalAIChatSettingState with _$LocalAIChatSettingState { const factory LocalAIChatSettingState({ LLMModelInfoPB? modelInfo, LLMModelPB? selectedLLMModel, - LocalAIProgress? localAIInfo, - @Default(LoadingState.loading()) LoadingState fetchModelInfoState, - @Default(LoadingState.loading()) LoadingState selectLLMState, + LocalAIProgress? progressIndicator, + @Default(AIModelProgress.init()) AIModelProgress aiModelProgress, + @Default(ChatLoadingState.loading()) ChatLoadingState selectLLMState, @Default([]) List models, @Default(RunningStatePB.Connecting) RunningStatePB runningState, }) = _LocalAIChatSettingState; } -// @freezed -// class LocalChatAIStateIndicator with _$LocalChatAIStateIndicator { -// // when start downloading the model -// const factory LocalChatAIStateIndicator.error(FlowyError error) = _OnError; -// const factory LocalChatAIStateIndicator.ready(bool isEnabled) = _Ready; -// } - @freezed class LocalAIProgress with _$LocalAIProgress { - // when user select a new model, it will call requestDownload - const factory LocalAIProgress.requestDownloadInfo( - LocalModelResourcePB llmResource, - LLMModelPB llmModel, - ) = _RequestDownload; - // when user comes back to the setting page, it will auto detect current llm state const factory LocalAIProgress.showDownload( LocalModelResourcePB llmResource, @@ -257,5 +275,13 @@ class LocalAIProgress with _$LocalAIProgress { const factory LocalAIProgress.startDownloading(LLMModelPB llmModel) = _Downloading; const factory LocalAIProgress.finishDownload() = _Finish; - const factory LocalAIProgress.checkPluginState() = _PluginState; + const factory LocalAIProgress.checkPluginState() = _CheckPluginState; + const factory LocalAIProgress.startOfflineAIApp() = _StartOfflineAIApp; +} + +@freezed +class AIModelProgress with _$AIModelProgress { + const factory AIModelProgress.init() = _AIModelProgressInit; + const factory AIModelProgress.loading() = _AIModelDownloading; + const factory AIModelProgress.finish({FlowyError? error}) = _AIModelFinish; } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_chat_toggle_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_chat_toggle_bloc.dart index aea17c36ccb9f..4feac1247a600 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_chat_toggle_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_chat_toggle_bloc.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:appflowy_backend/dispatch/dispatch.dart'; -import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; @@ -20,7 +20,7 @@ class LocalAIChatToggleBloc ) async { await event.when( started: () async { - final result = await ChatEventGetLocalAIChatState().send(); + final result = await AIEventGetLocalAIChatState().send(); _handleResult(emit, result); }, toggle: () async { @@ -30,7 +30,7 @@ class LocalAIChatToggleBloc ), ); unawaited( - ChatEventToggleLocalAIChat().send().then( + AIEventToggleLocalAIChat().send().then( (result) { if (!isClosed) { add(LocalAIChatToggleEvent.handleResult(result)); diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_on_boarding_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_on_boarding_bloc.dart new file mode 100644 index 0000000000000..902cb948b8751 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_on_boarding_bloc.dart @@ -0,0 +1,123 @@ +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.member, + this.workspaceId, + ) : super(const LocalAIOnBoardingState()) { + _userService = UserBackendService(userId: userProfile.id); + _successListenable = getIt(); + _successListenable.addListener(_onPaymentSuccessful); + _dispatch(); + } + + Future _onPaymentSuccessful() async { + if (isClosed) { + return; + } + + add( + LocalAIOnBoardingEvent.paymentSuccessful( + _successListenable.subscribedPlan, + ), + ); + } + + final UserProfilePB userProfile; + final WorkspaceMemberPB member; + final String workspaceId; + late final IUserBackendService _userService; + late final SubscriptionSuccessListenable _successListenable; + + 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 index dfbe9cc771ff1..a7778d7d99564 100644 --- 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 @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:typed_data'; import 'package:appflowy/plugins/ai_chat/application/chat_notification.dart'; -import 'package:appflowy_backend/protobuf/flowy-chat/protobuf.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'; diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart index 4ca9522208f72..41070e2fe4bb9 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart @@ -1,11 +1,13 @@ import 'dart:async'; +import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:bloc/bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:url_launcher/url_launcher.dart' show launchUrl; part 'plugin_state_bloc.freezed.dart'; class PluginStateBloc extends Bloc { @@ -41,7 +43,7 @@ class PluginStateBloc extends Bloc { ) async { await event.when( started: () async { - final result = await ChatEventGetLocalAIPluginState().send(); + final result = await AIEventGetLocalAIPluginState().send(); result.fold( (pluginState) { if (!isClosed) { @@ -52,23 +54,56 @@ class PluginStateBloc extends Bloc { ); }, updateState: (LocalAIPluginStatePB pluginState) { - switch (pluginState.state) { - case RunningStatePB.Connecting: - emit( - const PluginStateState(action: PluginStateAction.loadingPlugin()), - ); - case RunningStatePB.Running: - emit(const PluginStateState(action: PluginStateAction.ready())); - break; - default: - emit( - state.copyWith(action: const PluginStateAction.restart()), - ); - break; + // if the offline ai is not started, ask user to start it + if (pluginState.offlineAiReady) { + // Chech state of the plugin + switch (pluginState.state) { + case RunningStatePB.Connecting: + emit( + const PluginStateState( + action: PluginStateAction.loadingPlugin(), + ), + ); + case RunningStatePB.Running: + emit(const PluginStateState(action: PluginStateAction.ready())); + break; + default: + emit( + state.copyWith(action: const PluginStateAction.restartPlugin()), + ); + break; + } + } else { + emit( + const PluginStateState( + action: PluginStateAction.startAIOfflineApp(), + ), + ); } }, - restartLocalAI: () { - ChatEventRestartLocalAIChat().send(); + restartLocalAI: () async { + emit( + const PluginStateState(action: PluginStateAction.loadingPlugin()), + ); + unawaited(AIEventRestartLocalAIChat().send()); + }, + openModelDirectory: () async { + final result = await AIEventGetModelStorageDirectory().send(); + result.fold( + (data) { + afLaunchUrl(Uri.file(data.filePath)); + }, + (err) => Log.error(err.toString()), + ); + }, + downloadOfflineAIApp: () async { + final result = await AIEventGetOfflineAIAppLink().send(); + await result.fold( + (app) async { + await launchUrl(Uri.parse(app.link)); + }, + (err) {}, + ); }, ); } @@ -80,12 +115,16 @@ class PluginStateEvent with _$PluginStateEvent { const factory PluginStateEvent.updateState(LocalAIPluginStatePB pluginState) = _UpdatePluginState; const factory PluginStateEvent.restartLocalAI() = _RestartLocalAI; + const factory PluginStateEvent.openModelDirectory() = + _OpenModelStorageDirectory; + const factory PluginStateEvent.downloadOfflineAIApp() = _DownloadOfflineAIApp; } @freezed class PluginStateState with _$PluginStateState { - const factory PluginStateState({required PluginStateAction action}) = - _PluginStateState; + const factory PluginStateState({ + required PluginStateAction action, + }) = _PluginStateState; } @freezed @@ -93,5 +132,6 @@ class PluginStateAction with _$PluginStateAction { const factory PluginStateAction.init() = _Init; const factory PluginStateAction.loadingPlugin() = _LoadingPlugin; const factory PluginStateAction.ready() = _Ready; - const factory PluginStateAction.restart() = _Restart; + const factory PluginStateAction.restartPlugin() = _RestartPlugin; + const factory PluginStateAction.startAIOfflineApp() = _StartAIOfflineApp; } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart index 7adfc43a8121f..af0b390ebdb49 100644 --- 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 @@ -1,4 +1,5 @@ import 'package:appflowy/user/application/user_listener.dart'; +import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; @@ -10,14 +11,35 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'settings_ai_bloc.freezed.dart'; class SettingsAIBloc extends Bloc { - SettingsAIBloc(this.userProfile) - : _userListener = UserListener(userProfile: userProfile), - super(SettingsAIState(userProfile: userProfile)) { + SettingsAIBloc( + this.userProfile, + this.workspaceId, + WorkspaceMemberPB? member, + ) : _userListener = UserListener(userProfile: userProfile), + _userService = UserBackendService(userId: userProfile.id), + super(SettingsAIState(userProfile: userProfile, member: member)) { _dispatch(); + + if (member == null) { + _userService.getWorkspaceMember().then((result) { + result.fold( + (member) { + if (!isClosed) { + add(SettingsAIEvent.refreshMember(member)); + } + }, + (err) { + Log.error(err); + }, + ); + }); + } } final UserListener _userListener; final UserProfilePB userProfile; + final UserBackendService _userService; + final String workspaceId; @override Future close() async { @@ -43,6 +65,9 @@ class SettingsAIBloc extends Bloc { emit(state.copyWith(userProfile: userProfile)); }, toggleAISearch: () { + emit( + state.copyWith(enableSearchIndexing: !state.enableSearchIndexing), + ); _updateUserWorkspaceSetting( disableSearchIndexing: !(state.aiSettings?.disableSearchIndexing ?? false), @@ -59,6 +84,9 @@ class SettingsAIBloc extends Bloc { ), ); }, + refreshMember: (member) { + emit(state.copyWith(member: member)); + }, ); }); } @@ -68,7 +96,7 @@ class SettingsAIBloc extends Bloc { AIModelPB? model, }) { final payload = UpdateUserWorkspaceSettingPB( - workspaceId: userProfile.workspaceId, + workspaceId: workspaceId, ); if (disableSearchIndexing != null) { payload.disableSearchIndexing = disableSearchIndexing; @@ -88,7 +116,7 @@ class SettingsAIBloc extends Bloc { ); void _loadUserWorkspaceSetting() { - final payload = UserWorkspaceIdPB(workspaceId: userProfile.workspaceId); + final payload = UserWorkspaceIdPB(workspaceId: workspaceId); UserEventGetWorkspaceSetting(payload).send().then((result) { result.fold((settings) { if (!isClosed) { @@ -109,6 +137,8 @@ class SettingsAIEvent with _$SettingsAIEvent { ) = _DidLoadWorkspaceSetting; const factory SettingsAIEvent.toggleAISearch() = _toggleAISearch; + const factory SettingsAIEvent.refreshMember(WorkspaceMemberPB member) = + _RefreshMember; const factory SettingsAIEvent.selectModel(AIModelPB model) = _SelectAIModel; @@ -122,6 +152,7 @@ class SettingsAIState with _$SettingsAIState { const factory SettingsAIState({ required UserProfilePB userProfile, UseAISettingPB? aiSettings, + WorkspaceMemberPB? member, @Default(true) bool enableSearchIndexing, }) = _SettingsAIState; } 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 f09e08a3d1ca2..c6cf43bd1655a 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 @@ -1,9 +1,8 @@ -import 'package:flutter/material.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 DesktopAppearance extends BaseAppearance { @override @@ -75,18 +74,12 @@ class DesktopAppearance extends BaseAppearance { contentTextStyle: TextStyle(color: colorScheme.onSurface), ), scrollbarTheme: ScrollbarThemeData( - thumbColor: WidgetStateProperty.resolveWith((states) { - if (states.any(scrollbarInteractiveStates.contains)) { - return theme.shader3; - } - return theme.shader5; - }), - thickness: WidgetStateProperty.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, @@ -147,6 +140,9 @@ class DesktopAppearance extends BaseAppearance { ), onBackground: theme.text, background: theme.surface, + borderColor: theme.borderColor, + scrollbarColor: theme.scrollbarColor, + scrollbarHoverColor: theme.scrollbarHoverColor, ), ], ); 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 e2f1ee0006e3b..b1ea4fa78967d 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 @@ -276,6 +275,9 @@ class MobileAppearance extends BaseAppearance { ), onBackground: onBackground, background: background, + borderColor: theme.borderColor, + scrollbarColor: theme.scrollbarColor, + scrollbarHoverColor: theme.scrollbarHoverColor, ), ToolbarColorExtension.fromBrightness(brightness), ], 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 index 5e8636e1db61b..ab324df87f564 100644 --- 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 @@ -115,7 +115,7 @@ class SettingsBillingBloc (f) => Log.error(f.msg, f), ); }, - cancelSubscription: (plan) async { + cancelSubscription: (plan, reason) async { final s = state.mapOrNull(ready: (s) => s); if (s == null) { return; @@ -124,7 +124,7 @@ class SettingsBillingBloc emit(s.copyWith(isLoading: true)); final result = - await _userService.cancelSubscription(workspaceId, plan); + await _userService.cancelSubscription(workspaceId, plan, reason); final successOrNull = result.fold( (_) => true, (f) { @@ -276,8 +276,9 @@ class SettingsBillingEvent with _$SettingsBillingEvent { _AddSubscription; const factory SettingsBillingEvent.cancelSubscription( - SubscriptionPlanPB plan, - ) = _CancelSubscription; + SubscriptionPlanPB plan, { + @Default(null) String? reason, + }) = _CancelSubscription; const factory SettingsBillingEvent.paymentSuccessful({ SubscriptionPlanPB? plan, 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 863482cca8756..8d2a65c0298bc 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,11 +1,11 @@ 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); 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 index af2b5e3aaf44a..f7512a834e7f0 100644 --- 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 @@ -95,7 +95,7 @@ class SettingsPlanBloc extends Bloc { ), ); }, - cancelSubscription: () async { + cancelSubscription: (reason) async { final newState = state .mapOrNull(ready: (state) => state) ?.copyWith(downgradeProcessing: true); @@ -106,6 +106,7 @@ class SettingsPlanBloc extends Bloc { final result = await _userService.cancelSubscription( workspaceId, SubscriptionPlanPB.Pro, + reason, ); final successOrNull = result.fold( @@ -206,7 +207,9 @@ class SettingsPlanEvent with _$SettingsPlanEvent { const factory SettingsPlanEvent.addSubscription(SubscriptionPlanPB plan) = _AddSubscription; - const factory SettingsPlanEvent.cancelSubscription() = _CancelSubscription; + const factory SettingsPlanEvent.cancelSubscription({ + @Default(null) String? reason, + }) = _CancelSubscription; const factory SettingsPlanEvent.paymentSuccessful({ @Default(null) SubscriptionPlanPB? plan, 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 index e232915b8aee5..9d91ade4d3291 100644 --- 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 @@ -4,7 +4,7 @@ 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 SubscriptionLabels on WorkspaceSubscriptionInfoPB { +extension SubscriptionInfoHelpers on WorkspaceSubscriptionInfoPB { String get label => switch (plan) { WorkspacePlanPB.FreePlan => LocaleKeys.settings_planPage_planUsage_currentPlan_freeTitle.tr(), @@ -24,6 +24,14 @@ extension SubscriptionLabels on WorkspaceSubscriptionInfoPB { 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 { 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 4e622fa9c9def..f28900d18a9e8 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,7 +1,12 @@ +import 'package:flutter/foundation.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-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/auth.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -27,7 +32,8 @@ enum SettingsPage { class SettingsDialogBloc extends Bloc { SettingsDialogBloc( - this.userProfile, { + this.userProfile, + this.workspaceMember, { SettingsPage? initPage, }) : _userListener = UserListener(userProfile: userProfile), super(SettingsDialogState.initial(userProfile, initPage)) { @@ -35,6 +41,7 @@ class SettingsDialogBloc } final UserProfilePB userProfile; + final WorkspaceMemberPB? workspaceMember; final UserListener _userListener; @override @@ -49,6 +56,12 @@ class SettingsDialogBloc await event.when( initial: () async { _userListener.start(onProfileUpdated: _profileUpdated); + + final isBillingEnabled = + await _isBillingEnabled(userProfile, workspaceMember); + if (isBillingEnabled) { + emit(state.copyWith(isBillingEnabled: true)); + } }, didReceiveUserProfile: (UserProfilePB newUserProfile) { emit(state.copyWith(userProfile: newUserProfile)); @@ -70,6 +83,41 @@ class SettingsDialogBloc (err) => Log.error(err), ); } + + Future _isBillingEnabled( + UserProfilePB userProfile, [ + WorkspaceMemberPB? member, + ]) async { + if ([ + AuthenticatorPB.Local, + ].contains(userProfile.authenticator)) { + return false; + } + + if (member == null || member.role != 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 @@ -87,6 +135,7 @@ class SettingsDialogState with _$SettingsDialogState { const factory SettingsDialogState({ required UserProfilePB userProfile, required SettingsPage page, + required bool isBillingEnabled, }) = _SettingsDialogState; factory SettingsDialogState.initial( @@ -96,5 +145,6 @@ class SettingsDialogState with _$SettingsDialogState { SettingsDialogState( userProfile: userProfile, 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 7cf81b3bfb99c..e890959949688 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/supabase_cloud_setting_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/supabase_cloud_setting_bloc.dart deleted file mode 100644 index 9308a06a985ee..0000000000000 --- 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 fdd4cbef21f54..0000000000000 --- 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/sidebar/billing/sidebar_plan_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/sidebar/billing/sidebar_plan_bloc.dart index 563b47389b360..feb4afe7e88c6 100644 --- 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 @@ -16,7 +16,7 @@ part 'sidebar_plan_bloc.freezed.dart'; class SidebarPlanBloc extends Bloc { SidebarPlanBloc() : super(const SidebarPlanState()) { - // After user pays for the subscription, the subscription success listenable will be triggered + // 1. Listen to user subscription payment callback. After user client 'Open AppFlowy', this listenable will be triggered. final subscriptionListener = getIt(); subscriptionListener.addListener(() { final plan = subscriptionListener.subscribedPlan; @@ -49,6 +49,7 @@ class SidebarPlanBloc extends Bloc { } }); + // 2. Listen to the storage notification _storageListener = StoreageNotificationListener( onError: (error) { if (!isClosed) { @@ -57,6 +58,7 @@ class SidebarPlanBloc extends Bloc { }, ); + // 3. Listen to specific error codes _globalErrorListener = GlobalErrorCodeNotifier.add( onError: (error) { if (!isClosed) { @@ -92,11 +94,21 @@ class SidebarPlanBloc extends Bloc { ) async { await event.when( receiveError: (FlowyError error) async { - emit( - state.copyWith( - tierIndicator: const SidebarToastTierIndicator.storageLimitHit(), - ), - ); + 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 { + Log.error("Unhandle Unexpected error: $error"); + } }, init: (String workspaceId, UserProfilePB userProfile) { emit( @@ -105,6 +117,7 @@ class SidebarPlanBloc extends Bloc { userProfile: userProfile, ), ); + _checkWorkspaceUsage(); }, updateWorkspaceUsage: (WorkspaceUsagePB usage) { 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 index 9d79ca8550b20..3d493f536c059 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart @@ -68,6 +68,8 @@ class SpaceBloc extends Bloc { (event, emit) async { await event.when( initial: (userProfile, workspaceId, openFirstPage) async { + this.openFirstPage = openFirstPage; + _initial(userProfile, workspaceId); final (spaces, publicViews, privateViews) = await _getSpaces(); @@ -183,6 +185,21 @@ class SpaceBloc extends Bloc { } 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), + ); + } catch (e) { + Log.error('Failed to migrating cover: $e'); + } } if (permission != null) { @@ -279,7 +296,7 @@ class SpaceBloc extends Bloc { ), ); }, - reset: (userProfile, workspaceId) async { + reset: (userProfile, workspaceId, openFirstPage) async { if (workspaceId == _workspaceId) { return; } @@ -290,7 +307,7 @@ class SpaceBloc extends Bloc { SpaceEvent.initial( userProfile, workspaceId, - openFirstPage: true, + openFirstPage: openFirstPage, ), ); }, @@ -338,6 +355,7 @@ class SpaceBloc extends Bloc { String? _workspaceId; late UserProfilePB userProfile; WorkspaceSectionsListener? _listener; + bool openFirstPage = false; @override Future close() async { @@ -434,6 +452,9 @@ class SpaceBloc extends Bloc { )..start( sectionChanged: (result) async { Log.info('did receive section views changed'); + if (isClosed) { + return; + } add(const SpaceEvent.didReceiveSpaceUpdate()); }, ); @@ -541,7 +562,10 @@ class SpaceBloc extends Bloc { return true; } - final viewId = fixedUuid(user.id.toInt(), UuidType.publicSpace); + final viewId = fixedUuid( + user.id.toInt() + (_workspaceId?.hashCode ?? 0), + UuidType.publicSpace, + ); final publicSpace = await _createSpace( name: 'Shared', icon: builtInSpaceIcons.first, @@ -691,8 +715,10 @@ class SpaceEvent with _$SpaceEvent { required bool createNewPageByDefault, }) = _Create; const factory SpaceEvent.rename(ViewPB space, String name) = _Rename; - const factory SpaceEvent.changeIcon(String icon, String iconColor) = - _ChangeIcon; + const factory SpaceEvent.changeIcon( + String? icon, + String? iconColor, + ) = _ChangeIcon; const factory SpaceEvent.duplicate() = _Duplicate; const factory SpaceEvent.update({ String? name, @@ -712,6 +738,7 @@ class SpaceEvent with _$SpaceEvent { const factory SpaceEvent.reset( UserProfilePB userProfile, String workspaceId, + bool openFirstPage, ) = _Reset; const factory SpaceEvent.migrate() = _Migrate; const factory SpaceEvent.switchToNextSpace() = _SwitchToNextSpace; 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 36d64e6989c71..07f325f6c7bef 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart @@ -1,14 +1,12 @@ -import 'package:flutter/foundation.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/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/home_stack.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:bloc/bloc.dart'; +import 'package:flutter/foundation.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'tabs_bloc.freezed.dart'; @@ -92,8 +90,5 @@ class TabsBloc extends Bloc { view: view, ), ); - - // Update recent views - getIt().updateRecentViews([view.id], true); } } 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 e1ca000939830..16fed97f7c452 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 @@ -1,5 +1,3 @@ -import 'package:flutter/foundation.dart'; - import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/user/application/user_listener.dart'; @@ -12,6 +10,7 @@ import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:protobuf/protobuf.dart'; @@ -83,10 +82,19 @@ 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)); + } }, createWorkspace: (name) async { emit( @@ -159,13 +167,11 @@ 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((_) { @@ -177,6 +183,11 @@ class UserWorkspaceBloc extends Bloc { }) ..onFailure((f) { Log.error('delete workspace error: $f'); + // if the workspace is deleted but return an error, we need to + // open the first workspace + if (!containsDeletedWorkspace) { + add(OpenWorkspace(workspaces.first.workspaceId)); + } }); emit( state.copyWith( @@ -482,6 +493,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_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart index 3dc87b0d78ce3..18bc296c391b0 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart @@ -9,10 +9,12 @@ import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; import 'package:appflowy/plugins/database/grid/presentation/mobile_grid_page.dart'; import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy/plugins/document/document.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; class PluginArgumentKeys { @@ -48,7 +50,7 @@ class ViewExtKeys { } extension ViewExtension on ViewPB { - Widget defaultIcon() => FlowySvg( + Widget defaultIcon({Size? size}) => FlowySvg( switch (layout) { ViewLayoutPB.Board => FlowySvgs.icon_board_s, ViewLayoutPB.Calendar => FlowySvgs.icon_calendar_s, @@ -57,6 +59,7 @@ extension ViewExtension on ViewPB { ViewLayoutPB.Chat => FlowySvgs.chat_ai_page_s, _ => FlowySvgs.document_s, }, + size: size, ); PluginType get pluginType => switch (layout) { @@ -115,6 +118,10 @@ extension ViewExtension on ViewPB { bool get isSpace { try { + if (extra.isEmpty) { + return false; + } + final ext = jsonDecode(extra); final isSpace = ext[ViewExtKeys.isSpaceKey] ?? false; return isSpace; @@ -133,18 +140,48 @@ extension ViewExtension on ViewPB { } } - FlowySvg? get spaceIconSvg { + 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; } - return FlowySvg( - FlowySvgData('assets/flowy_icons/16x/$icon.svg'), - color: Color(int.parse(color)), - blendMode: BlendMode.srcOut, + // 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; @@ -185,6 +222,11 @@ extension ViewExtension on ViewPB { if (layout != ViewLayoutPB.Document) { return null; } + + if (extra.isEmpty) { + return null; + } + try { final ext = jsonDecode(extra); final cover = ext[ViewExtKeys.coverKey] ?? {}; 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 index 35f13d3de2e77..45f3eebaca5cf 100644 --- 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 @@ -10,19 +10,19 @@ class ViewTitleBarBloc extends Bloc { ViewTitleBarBloc({ required this.view, }) : super(ViewTitleBarState.initial()) { + viewListener = ViewListener( + viewId: view.id, + )..start( + onViewChildViewsUpdated: (p0) { + add(const ViewTitleBarEvent.reload()); + }, + ); + on( (event, emit) async { await event.when( initial: () async { add(const ViewTitleBarEvent.reload()); - - viewListener = ViewListener( - viewId: view.id, - )..start( - onViewUpdated: (p0) { - add(const ViewTitleBarEvent.reload()); - }, - ); }, reload: () async { final List ancestors = 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 916ef0a774af0..a8d768aa79b01 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'; @@ -27,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'; @@ -57,6 +56,7 @@ class DesktopHomeScreen extends StatelessWidget { (workspaceSettingPB) => workspaceSettingPB as WorkspaceSettingPB, (error) => null, ); + final userProfile = snapshots.data?[1].fold( (userProfilePB) => userProfilePB as UserProfilePB, (error) => null, @@ -68,13 +68,20 @@ class DesktopHomeScreen extends StatelessWidget { return const WorkspaceFailedScreen(); } + Sentry.configureScope( + (scope) => scope.setUser( + SentryUser( + id: userProfile.id.toString(), + ), + ), + ); + return AFFocusManager( child: MultiBlocProvider( key: ValueKey(userProfile.id), providers: [ BlocProvider.value( - value: getIt() - ..add(const ReminderEvent.started()), + value: getIt(), ), BlocProvider.value(value: getIt()), BlocProvider( @@ -164,7 +171,9 @@ class DesktopHomeScreen extends StatelessWidget { 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( @@ -218,39 +227,6 @@ 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 sidebar, @@ -296,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 1da2ca32fab2d..fb108b702e869 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'; @@ -16,6 +17,8 @@ class HomeLayout { menuWidth = Sizes.sideBarWidth; menuWidth += homeSetting.resizeOffset; + menuWidth = max(menuWidth, HomeSizes.minimumSidebarWidth); + final screenWidthPx = context.widthPx; context .read() 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 b35ae64ac5774..18d76057a275d 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_sizes.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_sizes.dart @@ -8,6 +8,7 @@ class HomeSizes { static const double workspaceSectionHeight = 32; static const double searchSectionHeight = 30; static const double newPageSectionHeight = 30; + static const double minimumSidebarWidth = 268; } class HomeInsets { 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 405360f55b7a9..d1e1b2c221fdd 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart @@ -1,8 +1,16 @@ -import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + import 'package:appflowy/core/frameless_window.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/blank/blank.dart'; +import 'package:appflowy/shared/window_title_bar.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/home/home_setting_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy/workspace/presentation/home/navigation.dart'; @@ -10,9 +18,10 @@ 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:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; import 'package:time/time.dart'; @@ -47,6 +56,17 @@ class HomeStack extends StatelessWidget { builder: (context, state) { return Column( children: [ + if (Platform.isWindows) + Column( + mainAxisSize: MainAxisSize.min, + children: [ + WindowTitleBar( + leftChildren: [ + _buildToggleMenuButton(context), + ], + ), + ], + ), Padding( padding: EdgeInsets.only(left: layout.menuSpacing), child: TabsManager(pageController: pageController), @@ -73,6 +93,47 @@ class HomeStack extends StatelessWidget { ), ); } + + Widget _buildToggleMenuButton(BuildContext context) { + if (!context.read().state.isMenuCollapsed) { + return const SizedBox.shrink(); + } + + final textSpan = TextSpan( + children: [ + TextSpan( + text: '${LocaleKeys.sideBar_openSidebar.tr()}\n', + style: context.tooltipTextStyle(), + ), + TextSpan( + text: Platform.isMacOS ? '⌘+.' : 'Ctrl+\\', + style: context + .tooltipTextStyle() + ?.copyWith(color: Theme.of(context).hintColor), + ), + ], + ); + + return FlowyTooltip( + richMessage: textSpan, + child: Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (_) => context + .read() + .add(const HomeSettingEvent.collapseMenu()), + child: FlowyHover( + child: Container( + width: 24, + padding: const EdgeInsets.all(4), + child: const RotatedBox( + quarterTurns: 2, + child: FlowySvg(FlowySvgs.hide_menu_s), + ), + ), + ), + ), + ); + } } class PageStack extends StatefulWidget { @@ -230,7 +291,6 @@ class PageManager { child: Selector( selector: (context, notifier) => notifier.titleWidget, builder: (_, __, child) => MoveWindowDetector( - showTitleBar: true, child: HomeTopBar(layout: layout), ), ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/hotkeys.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/hotkeys.dart index 1fb9a00658e1f..b5e2e3ec91979 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/hotkeys.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/hotkeys.dart @@ -1,7 +1,5 @@ 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'; @@ -11,6 +9,7 @@ import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:flutter/material.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; import 'package:provider/provider.dart'; import 'package:scaled_app/scaled_app.dart'; @@ -18,6 +17,7 @@ import 'package:scaled_app/scaled_app.dart'; typedef KeyDownHandler = void Function(HotKey hotKey); ValueNotifier switchToTheNextSpace = ValueNotifier(0); +ValueNotifier createNewPageNotifier = ValueNotifier(0); /// Helper class that utilizes the global [HotKeyManager] to easily /// add a [HotKey] with different handlers. @@ -180,6 +180,16 @@ class _HomeHotKeysState extends State { 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), ]; 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 index f73fa04cd05e5..1bfc3240888a6 100644 --- 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 @@ -11,7 +11,6 @@ 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:flowy_infra_ui/style_widget/decoration.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -172,11 +171,6 @@ class FavoriteMoreButton extends StatelessWidget { constraints: const BoxConstraints( minWidth: minWidth, ), - decoration: FlowyDecoration.decoration( - Theme.of(context).cardColor, - Theme.of(context).colorScheme.shadow, - borderRadius: 10.0, - ), popupBuilder: (_) { return MultiBlocProvider( providers: [ 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 index 4449558bb65fb..2255e544edc9f 100644 --- 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 @@ -137,6 +137,7 @@ class _FavoriteGroups extends StatelessWidget { state.otherViews, LocaleKeys.sideBar_others.tr(), ); + return Container( width: minWidth - 2 * _kHorizontalPadding, constraints: const BoxConstraints( @@ -149,15 +150,18 @@ class _FavoriteGroups extends StatelessWidget { children: [ if (today.isNotEmpty) ...[ ...today, - const VSpace(8), - const Divider(height: 1), - const VSpace(8), ], if (thisWeek.isNotEmpty) ...[ + if (today.isNotEmpty) ...[ + const FlowyDivider(), + const VSpace(16), + ], ...thisWeek, - const VSpace(8), - const Divider(height: 1), - const VSpace(8), + ], + if ((thisWeek.isNotEmpty || today.isNotEmpty) && + others.isNotEmpty) ...[ + const FlowyDivider(), + const VSpace(16), ], ...others.isNotEmpty && (today.isNotEmpty || thisWeek.isNotEmpty) ? others @@ -182,13 +186,10 @@ class _FavoriteGroups extends StatelessWidget { return [ if (views.isNotEmpty) ...[ if (showHeader) - SizedBox( - height: 24, - child: FlowyText( - title, - fontSize: 12.0, - color: Theme.of(context).hintColor, - ), + FlowyText( + title, + fontSize: 12.0, + color: Theme.of(context).hintColor, ), const VSpace(2), _FavoriteGroupedViews(views: views), 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 index 2fc1b311adb53..55ac449159b11 100644 --- 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 @@ -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'; @@ -14,6 +12,7 @@ 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/widget/flowy_tooltip.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class FavoriteMoreActions extends StatelessWidget { @@ -28,6 +27,7 @@ class FavoriteMoreActions extends StatelessWidget { child: ViewMoreActionButton( view: view, spaceType: FolderSpaceType.favorite, + isExpanded: false, onEditing: (value) => context.read().add(ViewEvent.setIsEditing(value)), onAction: (action, _) { 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 index 2a849b7e8d0ae..3bd2ffe67f8b1 100644 --- 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 @@ -5,7 +5,6 @@ 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:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -21,8 +20,8 @@ class FavoritePinAction extends StatelessWidget { : LocaleKeys.favorite_addToSidebar.tr(); final icon = FlowySvg( view.isPinned - ? FlowySvgs.favorite_section_pin_s - : FlowySvgs.favorite_section_unpin_s, + ? FlowySvgs.favorite_section_unpin_s + : FlowySvgs.favorite_section_pin_s, ); return FlowyTooltip( message: tooltip, 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 index 8af1c0eb9ec2e..ff33a79683381 100644 --- 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 @@ -1,65 +1,94 @@ +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/home_sizes.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_ui/flowy_infra_ui.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 const Row( + return Column( children: [ - Expanded(child: SidebarTrashButton()), - // Enable it when the widget button is ready - // SizedBox( - // height: 16, - // child: VerticalDivider(width: 1, color: Color(0x141F2329)), - // ), - // Expanded(child: SidebarWidgetButton()), + 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 SidebarTrashButton extends StatelessWidget { - const SidebarTrashButton({ - super.key, - }); +class SidebarTemplateButton extends StatelessWidget { + const SidebarTemplateButton({super.key}); @override Widget build(BuildContext context) { - return SizedBox( - height: HomeSizes.workspaceSectionHeight, - child: ValueListenableBuilder( - valueListenable: getIt().notifier, - builder: (context, value, child) { - return FlowyButton( - leftIcon: const FlowySvg(FlowySvgs.sidebar_footer_trash_m), - leftIconSize: const Size.square(24.0), - iconPadding: 8.0, - margin: const EdgeInsets.all(4.0), - text: FlowyText.regular( - LocaleKeys.trash_text.tr(), - lineHeight: 1.15, - ), - onTap: () { - getIt().latestOpenView = null; - getIt().add( - TabsEvent.openPlugin( - plugin: makePlugin(pluginType: PluginType.trash), - ), - ); - }, - ); - }, + return SidebarFooterButton( + leftIconSize: const Size.square(18.0), + leftIcon: const FlowySvg( + FlowySvgs.icon_template_s, ), + text: LocaleKeys.template_label.tr(), + onTap: () => afLaunchUrlString('https://appflowy.io/templates'), + ); + } +} + +class SidebarTrashButton extends StatelessWidget { + const SidebarTrashButton({super.key}); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: getIt().notifier, + builder: (context, value, child) { + return SidebarFooterButton( + leftIconSize: const Size.square(18.0), + leftIcon: const FlowySvg( + FlowySvgs.icon_delete_s, + ), + text: LocaleKeys.trash_text.tr(), + onTap: () { + getIt().latestOpenView = null; + getIt().add( + TabsEvent.openPlugin( + plugin: makePlugin(pluginType: PluginType.trash), + ), + ); + }, + ); + }, ); } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_footer_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_footer_button.dart new file mode 100644 index 0000000000000..cbb969d191d11 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_footer_button.dart @@ -0,0 +1,43 @@ +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +// This button style is used in +// - Trash button +// - Template button +class SidebarFooterButton extends StatelessWidget { + const SidebarFooterButton({ + super.key, + required this.leftIcon, + required this.leftIconSize, + required this.text, + required this.onTap, + }); + + final Widget leftIcon; + final Size leftIconSize; + final String text; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: HomeSizes.workspaceSectionHeight, + child: FlowyButton( + leftIcon: leftIcon, + leftIconSize: leftIconSize, + margin: const EdgeInsets.all(4.0), + expandText: false, + text: Padding( + padding: const EdgeInsets.only(right: 6.0), + child: FlowyText( + text, + fontWeight: FontWeight.w400, + figmaLineHeight: 18.0, + ), + ), + onTap: onTap, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_toast.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_toast.dart index 2adbdb81a636f..5d581a913696f 100644 --- 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 @@ -1,4 +1,9 @@ +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'; @@ -6,125 +11,118 @@ import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar 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: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: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 StatefulWidget { +class SidebarToast extends StatelessWidget { const SidebarToast({super.key}); - @override - State createState() => _SidebarToastState(); -} - -class _SidebarToastState extends State { @override Widget build(BuildContext context) { return BlocConsumer( - listener: (context, state) { + 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), - debugLabel: 'Sidebar.showStorageLimit', - ); - }, - orElse: () { - // Do nothing - }, + storageLimitHit: () => WidgetsBinding.instance.addPostFrameCallback( + (_) => _showStorageLimitDialog(context), + ), + orElse: () {}, ); }, - builder: (context, state) { - return BlocBuilder( - builder: (context, state) { - return state.tierIndicator.when( - storageLimitHit: () => Column( - children: [ - const Divider(height: 0.6), - PlanIndicator( - planName: "Pro", - text: LocaleKeys.sideBar_upgradeToPro.tr(), - onTap: () { - _hanldeOnTap(context, SubscriptionPlanPB.Pro); - }, - reason: LocaleKeys.sideBar_storageLimitDialogTitle.tr(), - ), - ], - ), - aiMaxiLimitHit: () => Column( - children: [ - const Divider(height: 0.6), - PlanIndicator( - planName: "AI Max", - text: LocaleKeys.sideBar_upgradeToAIMax.tr(), - onTap: () { - _hanldeOnTap(context, SubscriptionPlanPB.AiMax); - }, - reason: LocaleKeys.sideBar_aiResponseLitmitDialogTitle.tr(), - ), - ], - ), - loading: () { - return const SizedBox.shrink(); - }, - ); - }, + 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(), + ), ); }, ); } - void _showStorageLimitDialog(BuildContext context) { - showDialog( - context: context, - barrierDismissible: false, - useRootNavigator: false, - builder: (dialogContext) => _StorageLimitDialog( - onOkPressed: () { - final userProfile = context.read().state.userProfile; - final userWorkspaceBloc = context.read(); - if (userProfile != null) { - showSettingsDialog( - context, - userProfile, - userWorkspaceBloc, - SettingsPage.plan, - ); - } else { - Log.error( - "UserProfile is null. It should not happen. If you see this error, it's a bug.", - ); - } + 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 _hanldeOnTap(BuildContext context, SubscriptionPlanPB plan) { + 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(); - if (userProfile != null) { + final member = userWorkspaceBloc.state.currentWorkspaceMember; + if (member == 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 (member.role.isOwner) { showSettingsDialog( context, userProfile, userWorkspaceBloc, SettingsPage.plan, ); + } else { + final String message; + if (plan == SubscriptionPlanPB.AiMax) { + message = Platform.isIOS + ? LocaleKeys.sideBar_askOwnerToUpgradeToAIMaxIOS.tr() + : LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(); + } else { + message = Platform.isIOS + ? LocaleKeys.sideBar_askOwnerToUpgradeToProIOS.tr() + : LocaleKeys.sideBar_askOwnerToUpgradeToPro.tr(); + } + + showDialog( + context: context, + barrierDismissible: false, + useRootNavigator: false, + builder: (dialogContext) => _AskOwnerToChangePlan( + message: message, + onOkPressed: () {}, + ), + ); } } } -class PlanIndicator extends StatelessWidget { +class PlanIndicator extends StatefulWidget { const PlanIndicator({ + super.key, required this.planName, required this.text, required this.onTap, required this.reason, - super.key, }); final String planName; @@ -132,62 +130,150 @@ class PlanIndicator extends StatelessWidget { final String text; final Function() onTap; - final textColor = const Color(0xFFE8E2EE); - final secondaryColor = const Color(0xFF653E8C); + @override + State createState() => _PlanIndicatorState(); +} + +class _PlanIndicatorState extends State { + final popoverController = PopoverController(); + + @override + void dispose() { + popoverController.close(); + super.dispose(); + } @override Widget build(BuildContext context) { - return Column( - children: [ - FlowyButton( - margin: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), - text: FlowyText( - text, - color: textColor, - fontSize: 12, + const textGradient = LinearGradient( + begin: Alignment.bottomLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF8032FF), Color(0xFFEF35FF)], + stops: [0.1545, 0.8225], + ); + + final backgroundGradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + const Color(0xFF8032FF).withOpacity(.1), + const Color(0xFFEF35FF).withOpacity(.1), + ], + ); + + return AppFlowyPopover( + controller: popoverController, + direction: PopoverDirection.rightWithBottomAligned, + offset: const Offset(10, -12), + popupBuilder: (context) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText( + widget.text, + color: AFThemeExtension.of(context).strongText, + ), + const VSpace(12), + Opacity( + opacity: 0.7, + child: FlowyText.regular( + widget.reason, + maxLines: null, + lineHeight: 1.3, + textAlign: TextAlign.center, + ), + ), + const VSpace(12), + Row( + children: [ + Expanded( + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + popoverController.close(); + widget.onTap(); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(9), + ), + child: Center( + child: FlowyText( + LocaleKeys + .settings_comparePlanDialog_actions_upgrade + .tr(), + color: Colors.white, + fontSize: 12, + strutStyle: const StrutStyle( + forceStrutHeight: true, + ), + ), + ), + ), + ), + ), + ), + ], + ), + ], ), - radius: BorderRadius.zero, - leftIconSize: const Size(40, 20), - leftIcon: Badge( - padding: const EdgeInsets.symmetric(horizontal: 6), - backgroundColor: secondaryColor, - label: FlowyText.semibold( - planName, - fontSize: 12, - color: textColor, - ), + ); + }, + 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), ), - onTap: onTap, - ), - Padding( - padding: const EdgeInsets.only(left: 10, right: 10, bottom: 6), - child: Opacity( - opacity: 0.4, - child: FlowyText( - reason, - textAlign: TextAlign.start, - color: textColor, - fontSize: 8, - maxLines: 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 _StorageLimitDialog extends StatelessWidget { - const _StorageLimitDialog({ +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: LocaleKeys.sideBar_storageLimitDialogTitle.tr(), - okTitle: LocaleKeys.sideBar_purchaseStorageSpace.tr(), + message: message, + okTitle: LocaleKeys.button_ok.tr(), onOkPressed: onOkPressed, titleUpperCase: false, ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/header/sidebar_top_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/header/sidebar_top_menu.dart index 674febd61029f..7c8db674b0ad8 100644 --- 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 @@ -3,7 +3,6 @@ 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/util/theme_extension.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'; @@ -65,20 +64,17 @@ class SidebarTopMenu extends StatelessWidget { } Widget _buildCollapseMenuButton(BuildContext context) { - final color = Theme.of(context).isLightMode ? Colors.white : Colors.black; final textSpan = TextSpan( children: [ TextSpan( text: '${LocaleKeys.sideBar_closeSidebar.tr()}\n', - style: - Theme.of(context).tooltipTheme.textStyle!.copyWith(color: color), + style: context.tooltipTextStyle(), ), TextSpan( text: Platform.isMacOS ? '⌘+.' : 'Ctrl+\\', - style: Theme.of(context) - .tooltipTheme - .textStyle! - .copyWith(color: Theme.of(context).hintColor), + style: context + .tooltipTextStyle() + ?.copyWith(color: Theme.of(context).hintColor), ), ], ); 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 3728cbee7b1aa..5465bb0533c35 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 @@ -48,6 +48,8 @@ enum ImportType { bool get enableOnRelease { switch (this) { + case ImportType.historyDatabase: + case ImportType.historyDocument: case ImportType.databaseRawData: return kDebugMode; default: 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 index 7fb3c6902f5ea..d16c11b963caf 100644 --- 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 @@ -1,3 +1,4 @@ +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'; @@ -5,8 +6,10 @@ 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_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'; @@ -123,12 +126,15 @@ class _MovePageMenuState extends State { expand: true, height: 30, showCreateButton: false, - child: CurrentSpace( - onTapBlankArea: () { - // move the page to current space - widget.onSelected(space, space); - }, - space: space, + child: FlowyTooltip( + message: LocaleKeys.space_switchSpace.tr(), + child: CurrentSpace( + onTapBlankArea: () { + // move the page to current space + widget.onSelected(space, space); + }, + space: space, + ), ), ), Expanded( 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 index 8f072356d1f9e..6fdd0d65b7fd4 100644 --- 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 @@ -4,6 +4,7 @@ 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/workspace/presentation/home/menu/sidebar/shared/rename_view_dialog.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -11,18 +12,35 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -class SidebarNewPageButton extends StatelessWidget { +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(context), + onTap: () async => _createNewPage(), leftIcon: const FlowySvg( FlowySvgs.new_app_m, blendMode: null, @@ -38,7 +56,7 @@ class SidebarNewPageButton extends StatelessWidget { ); } - Future _createNewPage(BuildContext context) async { + Future _createNewPage() async { return createViewAndShowRenameDialogIfNeeded( context, LocaleKeys.newPageText.tr(), 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 index 2cf35e9dade85..db631d2b348d8 100644 --- 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 @@ -1,10 +1,10 @@ -import 'package:appflowy/workspace/application/settings/settings_dialog_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/document/application/document_appearance_cubit.dart'; import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy/workspace/presentation/home/af_focus_manager.dart'; import 'package:appflowy/workspace/presentation/home/hotkeys.dart'; @@ -15,7 +15,6 @@ import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' import 'package:appflowy_editor/appflowy_editor.dart' hide Log; 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_bloc/flutter_bloc.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; 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 136516253eea5..4ff8649767a48 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,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; @@ -33,9 +34,7 @@ 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/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'; @@ -197,6 +196,7 @@ class HomeSideBar extends StatelessWidget { userProfile, state.currentWorkspace?.workspaceId ?? workspaceSetting.workspaceId, + true, ), ); } @@ -277,7 +277,6 @@ class _SidebarState extends State<_Sidebar> { @override Widget build(BuildContext context) { const menuHorizontalInset = EdgeInsets.symmetric(horizontal: 8); - final userState = context.read().state; return MouseRegion( onEnter: (_) => _isHovered.value = true, onExit: (_) => _isHovered.value = false, @@ -299,15 +298,19 @@ class _SidebarState extends State<_Sidebar> { ), ), // user or workspace, setting - Container( - height: HomeSizes.workspaceSectionHeight, - padding: menuHorizontalInset - const EdgeInsets.only(right: 6), - child: - // if the workspaces are empty, show the user profile instead - userState.isCollabWorkspaceOn && - userState.workspaces.isNotEmpty - ? SidebarWorkspace(userProfile: widget.userProfile) - : SidebarUser(userProfile: widget.userProfile), + BlocBuilder( + builder: (context, state) { + return Container( + height: HomeSizes.workspaceSectionHeight, + padding: + menuHorizontalInset - const EdgeInsets.only(right: 6), + child: + // if the workspaces are empty, show the user profile instead + state.isCollabWorkspaceOn && state.workspaces.isNotEmpty + ? SidebarWorkspace(userProfile: widget.userProfile) + : SidebarUser(userProfile: widget.userProfile), + ); + }, ), if (FeatureFlag.search.isOn) ...[ const VSpace(6), @@ -332,10 +335,7 @@ class _SidebarState extends State<_Sidebar> { child: child, ); }, - child: const Divider( - color: Color(0x141F2329), - height: 0.5, - ), + child: const FlowyDivider(), ), ), @@ -345,7 +345,7 @@ class _SidebarState extends State<_Sidebar> { Padding( padding: menuHorizontalInset + const EdgeInsets.symmetric(horizontal: 4.0), - child: const Divider(height: 0.5, color: Color(0x141F2329)), + child: const FlowyDivider(), ), const VSpace(8), @@ -358,9 +358,6 @@ class _SidebarState extends State<_Sidebar> { child: const SidebarFooter(), ), const VSpace(14), - - // toast - // const SidebarToast(), ], ), ), @@ -405,13 +402,16 @@ class _SidebarState extends State<_Sidebar> { : Expanded( child: Padding( padding: menuHorizontalInset - const EdgeInsets.only(right: 6), - child: SingleChildScrollView( - padding: const EdgeInsets.only(right: 6), + child: FlowyScrollbar( controller: _scrollController, - physics: const ClampingScrollPhysics(), - child: SidebarSpace( - userProfile: widget.userProfile, - isHoverEnabled: !_isScrolling, + child: SingleChildScrollView( + padding: const EdgeInsets.only(right: 6), + controller: _scrollController, + physics: const ClampingScrollPhysics(), + child: SidebarSpace( + userProfile: widget.userProfile, + isHoverEnabled: !_isScrolling, + ), ), ), ), @@ -457,12 +457,28 @@ class _SidebarSearchButton extends StatelessWidget { @override Widget build(BuildContext context) { - return FlowyButton( - onTap: () => CommandPalette.of(context).toggle(), - leftIcon: const FlowySvg(FlowySvgs.search_s), - iconPadding: 12.0, - margin: const EdgeInsets.only(left: 8.0), - text: FlowyText.regular(LocaleKeys.search_label.tr()), + return FlowyTooltip( + richMessage: TextSpan( + children: [ + TextSpan( + text: '${LocaleKeys.search_sidebarSearchIcon.tr()}\n', + style: context.tooltipTextStyle(), + ), + TextSpan( + text: Platform.isMacOS ? '⌘+P' : 'Ctrl+P', + style: context + .tooltipTextStyle() + ?.copyWith(color: Theme.of(context).hintColor), + ), + ], + ), + child: FlowyButton( + onTap: () => CommandPalette.of(context).toggle(), + leftIcon: const FlowySvg(FlowySvgs.search_s), + iconPadding: 12.0, + margin: const EdgeInsets.only(left: 8.0), + text: FlowyText.regular(LocaleKeys.search_label.tr()), + ), ); } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/_extension.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/_extension.dart new file mode 100644 index 0000000000000..40f20f098a688 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/_extension.dart @@ -0,0 +1,8 @@ +import 'package:appflowy/util/theme_extension.dart'; +import 'package:flutter/material.dart'; + +extension SpacePermissionColorExtension on BuildContext { + Color get enableBorderColor => Theme.of(this).isLightMode + ? const Color(0x1E171717) + : const Color(0xFF3A3F49); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/create_space_popup.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/create_space_popup.dart index 328c9697293a7..88c65a002cb54 100644 --- 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 @@ -1,6 +1,8 @@ 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'; @@ -16,33 +18,37 @@ class CreateSpacePopup extends StatefulWidget { class _CreateSpacePopupState extends State { String spaceName = LocaleKeys.space_defaultSpaceName.tr(); - String spaceIcon = builtInSpaceIcons.first; - String spaceIconColor = builtInSpaceColors.first; + 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: 500, + width: 524, child: Column( mainAxisSize: MainAxisSize.min, children: [ FlowyText( LocaleKeys.space_createNewSpace.tr(), fontSize: 18.0, + figmaLineHeight: 24.0, ), - const VSpace(6.0), - FlowyText.regular( + 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; @@ -76,8 +82,9 @@ class _CreateSpacePopupState extends State { context.read().add( SpaceEvent.create( name: spaceName, - icon: spaceIcon, - iconColor: spaceIconColor, + // fixme: space issue + icon: spaceIcon!, + iconColor: spaceIconColor!, permission: spacePermission, createNewPageByDefault: true, ), @@ -106,14 +113,16 @@ class _SpaceNameTextField extends StatelessWidget { 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_spaceName.tr(), + 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 index b677b4056cba4..eb8c54025d72d 100644 --- 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 @@ -77,7 +77,7 @@ class _SpaceNameTextField extends StatelessWidget { }); final void Function(String name) onNameChanged; - final void Function(String icon, String color) onIconChanged; + final void Function(String? icon, String? color) onIconChanged; @override Widget build(BuildContext context) { @@ -99,6 +99,7 @@ class _SpaceNameTextField extends StatelessWidget { SizedBox.square( dimension: 40, child: SpaceIconPopup( + space: space, cornerRadius: 12, icon: space?.spaceIcon, iconColor: space?.spaceIconColor, 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 index 97b4f18bcf195..586120000a941 100644 --- 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 @@ -1,7 +1,3 @@ -import 'package:flutter/cupertino.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/util/theme_extension.dart'; @@ -10,6 +6,7 @@ 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'; @@ -19,9 +16,10 @@ 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/decoration.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; -import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SpacePermissionSwitch extends StatefulWidget { @@ -55,6 +53,7 @@ class _SpacePermissionSwitchState extends State { LocaleKeys.space_permission.tr(), fontSize: 14.0, color: Theme.of(context).hintColor, + figmaLineHeight: 18.0, ), const VSpace(6.0), AppFlowyPopover( @@ -63,16 +62,11 @@ class _SpacePermissionSwitchState extends State { constraints: const BoxConstraints(maxWidth: 500), offset: const Offset(0, 4), margin: EdgeInsets.zero, - decoration: FlowyDecoration.decoration( - Theme.of(context).cardColor, - Theme.of(context).colorScheme.shadow, - borderRadius: 10, - ), popupBuilder: (_) => _buildPermissionButtons(), child: DecoratedBox( decoration: ShapeDecoration( shape: RoundedRectangleBorder( - side: BorderSide(color: Theme.of(context).colorScheme.outline), + side: BorderSide(color: context.enableBorderColor), borderRadius: BorderRadius.circular(10), ), ), @@ -148,9 +142,13 @@ class SpacePermissionButton extends StatelessWidget { 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: [ @@ -187,19 +185,9 @@ class SpaceCancelOrConfirmButton extends StatelessWidget { return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - 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_cancel.tr()), - onTap: onCancel, - ), + OutlinedRoundedButton( + text: LocaleKeys.button_cancel.tr(), + onTap: onCancel, ), const HSpace(12.0), DecoratedBox( @@ -215,7 +203,8 @@ class SpaceCancelOrConfirmButton extends StatelessWidget { radius: BorderRadius.circular(8), text: FlowyText.regular( confirmButtonName, - color: Colors.white, + lineHeight: 1.0, + color: Theme.of(context).colorScheme.onPrimary, ), onTap: onConfirm, ), @@ -242,23 +231,11 @@ class SpaceOkButton extends StatelessWidget { return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - 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, - color: Colors.white, - ), - onTap: onConfirm, - ), + PrimaryRoundedButton( + text: confirmButtonName, + margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 9.0), + radius: 8.0, + onTap: onConfirm, ), ], ); @@ -280,9 +257,9 @@ class ConfirmPopupColor { static Color descriptionColor(BuildContext context) { if (Theme.of(context).isLightMode) { - return const Color(0xFF171717).withOpacity(0.8); + return const Color(0xFF171717).withOpacity(0.7); } - return const Color(0xFFffffff).withOpacity(0.72); + return const Color(0xFFffffff).withOpacity(0.7); } } @@ -293,13 +270,17 @@ class ConfirmPopup extends StatefulWidget { required this.title, required this.description, required this.onConfirm, + this.onCancel, this.confirmLabel, this.confirmButtonColor, + this.child, + this.closeOnAction = true, }); final String title; final String description; final VoidCallback onConfirm; + final VoidCallback? onCancel; final Color? confirmButtonColor; final ConfirmPopupStyle style; @@ -310,6 +291,18 @@ class ConfirmPopup extends StatefulWidget { /// final String? confirmLabel; + /// Allows to add a child to the popup. + /// + /// This is useful when you want to add more content to the popup. + /// The child will be placed below the description. + /// + final Widget? child; + + /// Decides whether the popup should be closed when the confirm button is clicked. + /// Defaults to true. + /// + final bool closeOnAction; + @override State createState() => _ConfirmPopupState(); } @@ -328,19 +321,28 @@ class _ConfirmPopupState extends State { Navigator.of(context).pop(); } }, - child: Padding( + child: Container( padding: const EdgeInsets.symmetric( vertical: 20.0, horizontal: 20.0, ), + color: PlatformExtension.isDesktop + ? null + : Theme.of(context).colorScheme.surface, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildTitle(), - const VSpace(6.0), - _buildDescription(), - const VSpace(20.0), + if (widget.description.isNotEmpty) ...[ + const VSpace(6), + _buildDescription(), + ], + if (widget.child != null) ...[ + const VSpace(12), + widget.child!, + ], + const VSpace(20), _buildStyledButton(context), ], ), @@ -354,15 +356,21 @@ class _ConfirmPopupState extends State { Expanded( child: FlowyText( widget.title, - fontSize: 14.0, + fontSize: 16.0, + figmaLineHeight: 22.0, + fontWeight: FontWeight.w500, overflow: TextOverflow.ellipsis, color: ConfirmPopupColor.titleColor(context), ), ), const HSpace(6.0), FlowyButton( + margin: const EdgeInsets.all(3), useIntrinsicWidth: true, - text: const FlowySvg(FlowySvgs.upgrade_close_s), + text: const FlowySvg( + FlowySvgs.upgrade_close_s, + size: Size.square(18.0), + ), onTap: () => Navigator.of(context).pop(), ), ], @@ -370,12 +378,16 @@ class _ConfirmPopupState extends State { } Widget _buildDescription() { + if (widget.description.isEmpty) { + return const SizedBox.shrink(); + } + return FlowyText.regular( widget.description, - fontSize: 12.0, + fontSize: 16.0, color: ConfirmPopupColor.descriptionColor(context), - maxLines: 3, - lineHeight: 1.4, + maxLines: 5, + figmaLineHeight: 22.0, ); } @@ -385,7 +397,9 @@ class _ConfirmPopupState extends State { return SpaceOkButton( onConfirm: () { widget.onConfirm(); - Navigator.of(context).pop(); + if (widget.closeOnAction) { + Navigator.of(context).pop(); + } }, confirmButtonName: widget.confirmLabel ?? LocaleKeys.button_ok.tr(), confirmButtonColor: widget.confirmButtonColor ?? @@ -393,10 +407,15 @@ class _ConfirmPopupState extends State { ); case ConfirmPopupStyle.cancelAndOk: return SpaceCancelOrConfirmButton( - onCancel: () => Navigator.of(context).pop(), + onCancel: () { + widget.onCancel?.call(); + Navigator.of(context).pop(); + }, onConfirm: () { widget.onConfirm(); - Navigator.of(context).pop(); + if (widget.closeOnAction) { + Navigator.of(context).pop(); + } }, confirmButtonName: widget.confirmLabel ?? LocaleKeys.space_delete.tr(), @@ -462,32 +481,31 @@ class CurrentSpace extends StatelessWidget { @override Widget build(BuildContext context) { - final child = FlowyTooltip( - message: LocaleKeys.space_switchSpace.tr(), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SpaceIcon( - dimension: 20, - space: space, - cornerRadius: 6.0, - ), - const HSpace(10), - Flexible( - child: FlowyText.medium( - space.name, - fontSize: 14.0, - overflow: TextOverflow.ellipsis, - ), - ), - const HSpace(4.0), - FlowySvg( - context.read().state.isExpanded - ? FlowySvgs.workspace_drop_down_menu_show_s - : FlowySvgs.workspace_drop_down_menu_hide_s, + 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, ), - ], - ), + ), + const HSpace(4.0), + FlowySvg( + context.read().state.isExpanded + ? FlowySvgs.workspace_drop_down_menu_show_s + : FlowySvgs.workspace_drop_down_menu_hide_s, + ), + ], ); if (onTapBlankArea != null) { 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 index e7ca76e533440..288f42c064398 100644 --- 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 @@ -3,6 +3,7 @@ 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'; @@ -31,7 +32,6 @@ class SidebarSpace extends StatelessWidget { @override Widget build(BuildContext context) { - // const sectionPadding = 16.0; return ValueListenableBuilder( valueListenable: getIt().notifier, builder: (context, value, child) { @@ -89,6 +89,8 @@ class _SpaceState extends State<_Space> { @override Widget build(BuildContext context) { + final currentWorkspace = + context.watch().state.currentWorkspace; return BlocBuilder( builder: (context, state) { if (state.spaces.isEmpty) { @@ -115,7 +117,12 @@ class _SpaceState extends State<_Space> { onEnter: (_) => isHovered.value = true, onExit: (_) => isHovered.value = false, child: SpacePages( - key: ValueKey(currentSpace.id), + key: ValueKey( + Object.hashAll([ + currentWorkspace?.workspaceId ?? '', + currentSpace.id, + ]), + ), isExpandedNotifier: isExpandedNotifier, space: currentSpace, isHovered: isHovered, 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 index 2eaafe90f9676..9d6e41ca08489 100644 --- 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 @@ -1,7 +1,9 @@ import 'dart:io'; +import 'package:flutter/material.dart' hide Icon; + import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon.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'; @@ -14,8 +16,6 @@ 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_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 SidebarSpaceHeader extends StatefulWidget { @@ -64,6 +64,7 @@ class _SidebarSpaceHeaderState extends State { .read() .add(SpaceEvent.expand(widget.space, !widget.isExpanded)), child: FlowyHoverContainer( + isHovering: isHovered, style: style, child: _buildSpaceName(), ), @@ -105,20 +106,17 @@ class _SidebarSpaceHeaderState extends State { } Widget _buildChild() { - final color = Theme.of(context).isLightMode ? Colors.white : Colors.black; final textSpan = TextSpan( children: [ TextSpan( text: '${LocaleKeys.space_quicklySwitch.tr()}\n', - style: - Theme.of(context).tooltipTheme.textStyle!.copyWith(color: color), + style: context.tooltipTextStyle(), ), TextSpan( text: Platform.isMacOS ? '⌘+O' : 'Ctrl+O', - style: Theme.of(context) - .tooltipTheme - .textStyle! - .copyWith(color: Theme.of(context).hintColor), + style: context + .tooltipTextStyle() + ?.copyWith(color: Theme.of(context).hintColor), ), ], ); @@ -145,20 +143,23 @@ class _SidebarSpaceHeaderState extends State { onAction: _onAction, ), const HSpace(8.0), - ViewAddButton( - parentViewId: widget.space.id, - onEditing: (_) {}, - onSelected: ( - pluginBuilder, - name, - initialDataBytes, - openAfterCreated, - createNewView, - ) { - if (createNewView) { - widget.onAdded(pluginBuilder.layoutType!); - } - }, + FlowyTooltip( + message: LocaleKeys.sideBar_addAPage.tr(), + child: ViewAddButton( + parentViewId: widget.space.id, + onEditing: (_) {}, + onSelected: ( + pluginBuilder, + name, + initialDataBytes, + openAfterCreated, + createNewView, + ) { + if (createNewView) { + widget.onAdded(pluginBuilder.layoutType!); + } + }, + ), ), ], ), @@ -172,8 +173,19 @@ class _SidebarSpaceHeaderState extends State { await _showRenameDialog(); break; case SpaceMoreActionType.changeIcon: - final (String icon, String iconColor) = data; - context.read().add(SpaceEvent.changeIcon(icon, iconColor)); + final (IconGroup? group, Icon? icon, String? iconColor) = data; + + final groupName = group?.name; + final iconName = icon?.name; + final name = groupName != null && iconName != null + ? '$groupName/$iconName' + : null; + context.read().add( + SpaceEvent.changeIcon( + name, + iconColor, + ), + ); break; case SpaceMoreActionType.manage: _showManageSpaceDialog(context); 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 index 9ff798ee5de81..057a63669834e 100644 --- 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 @@ -9,7 +9,6 @@ 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_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'; @@ -40,9 +39,7 @@ class SidebarSpaceMenu extends StatelessWidget { if (showCreateButton) ...[ const Padding( padding: EdgeInsets.symmetric(vertical: 8.0), - child: Divider( - height: 0.5, - ), + child: FlowyDivider(), ), const SizedBox( height: HomeSpaceViewSizes.viewHeight, @@ -90,6 +87,7 @@ class _SidebarSpaceMenuItem extends StatelessWidget { leftIcon: SpaceIcon( dimension: 20, space: space, + svgSize: 12.0, cornerRadius: 6.0, ), leftIconSize: const Size.square(20), 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 index 5edf2d82b626c..be0eadd8ed15d 100644 --- 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 @@ -36,24 +36,24 @@ extension ViewMoreActionTypeExtension on SpaceMoreActionType { } } - Widget get leftIcon { + FlowySvgData get leftIconSvg { switch (this) { case SpaceMoreActionType.delete: - return const FlowySvg(FlowySvgs.trash_s, blendMode: null); + return FlowySvgs.trash_s; case SpaceMoreActionType.rename: - return const FlowySvg(FlowySvgs.view_item_rename_s); + return FlowySvgs.view_item_rename_s; case SpaceMoreActionType.changeIcon: - return const FlowySvg(FlowySvgs.change_icon_s); + return FlowySvgs.change_icon_s; case SpaceMoreActionType.collapseAllPages: - return const FlowySvg(FlowySvgs.collapse_all_page_s); + return FlowySvgs.collapse_all_page_s; case SpaceMoreActionType.addNewSpace: - return const FlowySvg(FlowySvgs.space_add_s); + return FlowySvgs.space_add_s; case SpaceMoreActionType.manage: - return const FlowySvg(FlowySvgs.space_manage_s); + return FlowySvgs.space_manage_s; case SpaceMoreActionType.duplicate: - return const FlowySvg(FlowySvgs.duplicate_s); + return FlowySvgs.duplicate_s; case SpaceMoreActionType.divider: - return const SizedBox.shrink(); + throw UnsupportedError('Divider does not have an icon'); } } 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 index 871dc0e47527c..f55757fb30d32 100644 --- 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 @@ -1,26 +1,122 @@ +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/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) { - return SizedBox.square( - dimension: dimension, - child: ClipRRect( - borderRadius: BorderRadius.circular(cornerRadius), - child: space.spaceIconSvg, + // if space icon is null, use the first character of space name as icon + + final Color color; + final Widget icon; + + if (space.spaceIcon == null) { + final name = space.name.isNotEmpty ? space.name.capitalize()[0] : ''; + icon = FlowyText.medium( + name, + color: Theme.of(context).colorScheme.surface, + fontSize: svgSize, + figmaLineHeight: textDimension ?? dimension, + ); + color = Color(int.parse(builtInSpaceColors.first)); + } else { + final spaceIconColor = space.spaceIconColor; + color = spaceIconColor != null + ? Color(int.parse(spaceIconColor)) + : Colors.transparent; + final svg = space.buildSpaceIconSvg( + context, + size: svgSize != null ? Size.square(svgSize!) : null, + ); + if (svg == null) { + icon = const SizedBox.shrink(); + } else { + icon = + svgSize == null || space.spaceIcon?.contains('space_icon') == true + ? svg + : SizedBox.square(dimension: svgSize!, child: svg); + } + } + + return ClipRRect( + borderRadius: BorderRadius.circular(cornerRadius), + child: Container( + width: dimension, + height: dimension, + color: color, + child: Center( + child: icon, + ), + ), + ); + } +} + +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 index feeea05e8cf7f..fc9e462e9bc98 100644 --- 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 @@ -1,10 +1,16 @@ +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/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/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/style_widget/decoration.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide Icon; final builtInSpaceColors = [ '0xFFA34AFD', @@ -21,6 +27,11 @@ final builtInSpaceColors = [ '0xFFFF8933', ]; +String generateRandomSpaceColor() { + final random = Random(); + return builtInSpaceColors[random.nextInt(builtInSpaceColors.length)]; +} + final builtInSpaceIcons = List.generate(15, (index) => 'space_icon_${index + 1}'); @@ -30,12 +41,14 @@ class SpaceIconPopup extends StatefulWidget { this.icon, this.iconColor, this.cornerRadius = 16, + this.space, required this.onIconChanged, }); final String? icon; final String? iconColor; - final void Function(String icon, String color) onIconChanged; + final ViewPB? space; + final void Function(String? icon, String? color) onIconChanged; final double cornerRadius; @override @@ -43,10 +56,12 @@ class SpaceIconPopup extends StatefulWidget { } class _SpaceIconPopupState extends State { - late ValueNotifier selectedColor = - ValueNotifier(widget.iconColor ?? builtInSpaceColors.first); - late ValueNotifier selectedIcon = - ValueNotifier(widget.icon ?? builtInSpaceIcons.first); + late ValueNotifier selectedIcon = ValueNotifier( + widget.icon, + ); + late ValueNotifier selectedColor = ValueNotifier( + widget.iconColor ?? builtInSpaceColors.first, + ); @override void dispose() { @@ -59,24 +74,30 @@ class _SpaceIconPopupState extends State { Widget build(BuildContext context) { return AppFlowyPopover( offset: const Offset(0, 4), - decoration: FlowyDecoration.decoration( - Theme.of(context).cardColor, - Theme.of(context).colorScheme.shadow, - borderRadius: 10, - ), - constraints: const BoxConstraints(maxWidth: 220), - margin: const EdgeInsets.symmetric(horizontal: 14.0, vertical: 12.0), + constraints: BoxConstraints.loose(const Size(380, 432)), + margin: const EdgeInsets.all(0), direction: PopoverDirection.bottomWithCenterAligned, child: _buildPreview(), - popupBuilder: (_) => SpaceIconPicker( - icon: selectedIcon.value, - iconColor: selectedColor.value, - onIconChanged: (icon, iconColor) { - selectedIcon.value = icon; - selectedColor.value = iconColor; - widget.onIconChanged(icon, iconColor); - }, - ), + popupBuilder: (context) { + return FlowyIconEmojiPicker( + tabs: const [PickerTabType.icon], + onSelectedIcon: (group, icon, color) { + if (group == null || icon == null) { + selectedIcon.value = null; + } else { + selectedIcon.value = '${group.name}/${icon.name}'; + } + + if (color != null) { + selectedColor.value = color; + } + + widget.onIconChanged(selectedIcon.value, selectedColor.value); + + PopoverContainer.of(context).close(); + }, + ); + }, ); } @@ -93,15 +114,59 @@ class _SpaceIconPopupState extends State { builder: (_, color, __) { return ValueListenableBuilder( valueListenable: selectedIcon, - builder: (_, icon, __) { - final child = ClipRRect( - borderRadius: BorderRadius.circular(widget.cornerRadius), - child: FlowySvg( - FlowySvgData('assets/flowy_icons/16x/$icon.svg'), - color: Color(int.parse(color)), - blendMode: BlendMode.srcOut, - ), - ); + 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: [ 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 index 905082721d109..10ef94ba018be 100644 --- 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 @@ -1,10 +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/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/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SpaceMigration extends StatefulWidget { @@ -70,14 +71,8 @@ class _SpaceMigrationState extends State { const linearGradient = LinearGradient( begin: Alignment.bottomLeft, end: Alignment.bottomRight, - colors: [ - Color(0xFF8032FF), - Color(0xFFEF35FF), - ], - stops: [ - 0.1545, - 0.8225, - ], + colors: [Color(0xFF8032FF), Color(0xFFEF35FF)], + stops: [0.1545, 0.8225], ); return GestureDetector( behavior: HitTestBehavior.translucent, 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 index 3ee4f215db958..d773e8976f44c 100644 --- 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 @@ -1,16 +1,15 @@ 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/tab.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_popup.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: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_bloc/flutter_bloc.dart'; @@ -108,20 +107,19 @@ class SpaceMoreActionTypeWrapper extends CustomActionCell { PopoverController controller, ) { final child = _buildActionButton(context, null); - final spaceBloc = context.read(); - final color = spaceBloc.state.currentSpace?.spaceIconColor; - return AppFlowyPopover( - constraints: BoxConstraints.loose(const Size(216, 256)), - margin: const EdgeInsets.symmetric(horizontal: 14.0, vertical: 12.0), + constraints: BoxConstraints.loose(const Size(360, 432)), + margin: const EdgeInsets.all(0), clickHandler: PopoverClickHandler.gestureDetector, - popupBuilder: (_) => SpaceIconPicker( - iconColor: color, - skipFirstNotification: true, - onIconChanged: (icon, color) { - onTap(controller, (icon, color)); - }, - ), + offset: const Offset(0, -40), + popupBuilder: (context) { + return FlowyIconEmojiPicker( + tabs: const [PickerTabType.icon], + onSelectedIcon: (group, icon, color) { + onTap(controller, (group, icon, color)); + }, + ); + }, child: child, ); } @@ -129,7 +127,7 @@ class SpaceMoreActionTypeWrapper extends CustomActionCell { Widget _buildDivider() { return const Padding( padding: EdgeInsets.all(8.0), - child: Divider(height: 1.0), + child: FlowyDivider(), ); } @@ -158,22 +156,26 @@ class SpaceMoreActionTypeWrapper extends CustomActionCell { padding: const EdgeInsets.symmetric(vertical: 2.0), child: Opacity( opacity: disable ? 0.3 : 1.0, - child: FlowyButton( + child: FlowyIconTextButton( disable: disable, margin: const EdgeInsets.symmetric(horizontal: 6), - leftIcon: inner.leftIcon, - rightIcon: inner.rightIcon, iconPadding: 10.0, - text: SizedBox( - // height: 16.0, - child: FlowyText.regular( - inner.name, - color: inner == SpaceMoreActionType.delete - ? Theme.of(context).colorScheme.error - : null, - ), - ), 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, + ), ), ), ); 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 1df1b0fe2a4a1..fa2893535af2b 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 @@ -85,14 +85,16 @@ class _WorkspaceMoreActionWrapper extends CustomActionCell { BuildContext context, PopoverController controller, ) { - return FlowyButton( - leftIcon: buildLeftIcon(context), + return FlowyIconTextButton( + leftIconBuilder: (onHover) => buildLeftIcon(context, onHover), iconPadding: 10.0, - text: FlowyText.regular( + textBuilder: (onHover) => FlowyText.regular( name, fontSize: 14.0, + figmaLineHeight: 18.0, color: [WorkspaceMoreAction.delete, WorkspaceMoreAction.leave] - .contains(inner) + .contains(inner) && + onHover ? Theme.of(context).colorScheme.error : null, ), @@ -131,17 +133,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), + ); + }, ); } }, @@ -161,19 +163,19 @@ class _WorkspaceMoreActionWrapper extends CustomActionCell { } } - Widget buildLeftIcon(BuildContext context) { + Widget buildLeftIcon(BuildContext context, bool onHover) { switch (inner) { case WorkspaceMoreAction.delete: return FlowySvg( FlowySvgs.delete_s, - color: Theme.of(context).colorScheme.error, + 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: Theme.of(context).colorScheme.error, + 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 491e1c42cf3d0..5163bfd56a560 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,6 +1,6 @@ import 'dart:math'; -import 'package:appflowy/plugins/base/icon/icon_picker.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/util/color_generator/color_generator.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; @@ -18,6 +18,7 @@ class WorkspaceIcon extends StatefulWidget { this.borderRadius = 4, this.emojiSize, this.alignment, + required this.figmaLineHeight, }); final UserWorkspacePB workspace; @@ -28,6 +29,7 @@ class WorkspaceIcon extends StatefulWidget { final void Function(EmojiPickerResult) onSelected; final double borderRadius; final Alignment? alignment; + final double figmaLineHeight; @override State createState() => _WorkspaceIconState(); @@ -45,6 +47,8 @@ class _WorkspaceIconState extends State { child: FlowyText.emoji( widget.workspace.icon, fontSize: widget.emojiSize ?? widget.iconSize, + figmaLineHeight: widget.figmaLineHeight, + optimizeEmojiAlign: true, ), ) : Container( @@ -75,8 +79,9 @@ class _WorkspaceIconState extends State { direction: PopoverDirection.bottomWithLeftAligned, constraints: BoxConstraints.loose(const Size(364, 356)), clickHandler: PopoverClickHandler.gestureDetector, - popupBuilder: (_) => FlowyIconPicker( - onSelected: (result) { + margin: const EdgeInsets.all(0), + popupBuilder: (_) => FlowyIconEmojiPicker( + onSelectedEmoji: (result) { widget.onSelected(result); controller.close(); }, 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 8a1b172aff241..97ee729e52e67 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,3 @@ -import 'dart:io'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; @@ -14,7 +12,6 @@ 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:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -172,6 +169,7 @@ class _WorkspaceMenuItemState extends State { workspace: widget.workspace, iconSize: 22, fontSize: 16, + figmaLineHeight: 32.0, enableEdit: true, onSelected: (result) => context.read().add( UserWorkspaceEvent.updateWorkspaceIcon( @@ -250,10 +248,10 @@ class _WorkspaceInfo extends StatelessWidget { FlowyText.medium( workspace.name, fontSize: 14.0, + figmaLineHeight: 17.0, overflow: TextOverflow.ellipsis, withTooltip: true, ), - if (Platform.isMacOS) const VSpace(2.0), // workspace members count FlowyText.regular( state.isLoading @@ -263,6 +261,7 @@ class _WorkspaceInfo extends StatelessWidget { members.length, ), fontSize: 10.0, + figmaLineHeight: 12.0, color: Theme.of(context).hintColor, ), ], 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 index 2fa91a8da1b91..f6b1931570e81 100644 --- 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 @@ -1,3 +1,4 @@ +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'; @@ -28,6 +29,15 @@ class SidebarWorkspace extends StatefulWidget { 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( @@ -39,19 +49,38 @@ class _SidebarWorkspaceState extends State { if (currentWorkspace == null) { return const SizedBox.shrink(); } - return Row( - children: [ - Expanded( - child: SidebarSwitchWorkspaceButton( - userProfile: widget.userProfile, - currentWorkspace: currentWorkspace, - ), + 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: child, + ); + }, + child: Row( + children: [ + Expanded( + child: SidebarSwitchWorkspaceButton( + userProfile: widget.userProfile, + currentWorkspace: currentWorkspace, + ), + ), + UserSettingButton(userProfile: widget.userProfile), + const HSpace(8.0), + const NotificationButton(), + const HSpace(4.0), + ], ), - UserSettingButton(userProfile: widget.userProfile), - const HSpace(8.0), - const NotificationButton(), - const HSpace(12.0), - ], + ), ); }, ); @@ -198,9 +227,10 @@ class _SidebarSwitchWorkspaceButtonState ), ); }, - child: FlowyButton( + child: FlowyIconTextButton( margin: EdgeInsets.zero, - text: SizedBox( + hoverColor: Colors.transparent, + textBuilder: (onHover) => SizedBox( height: 30, child: Row( children: [ @@ -212,6 +242,7 @@ class _SidebarSwitchWorkspaceButtonState emojiSize: 18, enableEdit: false, borderRadius: 8.0, + figmaLineHeight: 21.0, onSelected: (result) => context.read().add( UserWorkspaceEvent.updateWorkspaceIcon( widget.currentWorkspace.workspaceId, @@ -229,6 +260,10 @@ class _SidebarSwitchWorkspaceButtonState ), ), const HSpace(4), + if (onHover) + const FlowySvg( + FlowySvgs.workspace_drop_down_menu_show_s, + ), ], ), ), 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 3065eb7495368..fe2e5def48cb4 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 @@ -49,32 +49,31 @@ extension ViewMoreActionTypeExtension on ViewMoreActionType { } } - Widget get leftIcon { + FlowySvgData get leftIconSvg { switch (this) { case ViewMoreActionType.delete: - return const FlowySvg(FlowySvgs.trash_s, blendMode: null); + return FlowySvgs.trash_s; case ViewMoreActionType.favorite: - return const FlowySvg(FlowySvgs.favorite_s); + return FlowySvgs.favorite_s; case ViewMoreActionType.unFavorite: - return const FlowySvg(FlowySvgs.unfavorite_s); + return FlowySvgs.unfavorite_s; case ViewMoreActionType.duplicate: - return const FlowySvg(FlowySvgs.duplicate_s); - case ViewMoreActionType.copyLink: - return const Icon(Icons.copy); + return FlowySvgs.duplicate_s; case ViewMoreActionType.rename: - return const FlowySvg(FlowySvgs.view_item_rename_s); + return FlowySvgs.view_item_rename_s; case ViewMoreActionType.moveTo: - return const FlowySvg(FlowySvgs.move_to_s); + return FlowySvgs.move_to_s; case ViewMoreActionType.openInNewTab: - return const FlowySvg(FlowySvgs.view_item_open_in_new_tab_s); + return FlowySvgs.view_item_open_in_new_tab_s; case ViewMoreActionType.changeIcon: - return const FlowySvg(FlowySvgs.change_icon_s); + return FlowySvgs.change_icon_s; case ViewMoreActionType.collapseAllPages: - return const FlowySvg(FlowySvgs.collapse_all_page_s); + return FlowySvgs.collapse_all_page_s; case ViewMoreActionType.divider: case ViewMoreActionType.lastModified: + case ViewMoreActionType.copyLink: case ViewMoreActionType.created: - return const SizedBox.shrink(); + throw UnsupportedError('No left icon for $this'); } } 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 5ad37d0e490a9..9371f11b40eaf 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 @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.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/startup/startup.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; @@ -28,7 +28,6 @@ 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/hover.dart'; -import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -491,6 +490,8 @@ class _SingleInnerViewItemState extends State { final name = FlowyText.regular( widget.view.name, overflow: TextOverflow.ellipsis, + fontSize: 14.0, + figmaLineHeight: 18.0, ); final children = [ const HSpace(2), @@ -572,6 +573,7 @@ class _SingleInnerViewItemState extends State { ? FlowyText.emoji( widget.view.icon.value, fontSize: 16.0, + figmaLineHeight: 21.0, ) : Opacity(opacity: 0.6, child: widget.view.defaultIcon()); @@ -580,6 +582,7 @@ class _SingleInnerViewItemState extends State { controller: controller, direction: PopoverDirection.rightWithCenterAligned, constraints: BoxConstraints.loose(const Size(364, 356)), + margin: const EdgeInsets.all(0), onClose: () => setState(() => isIconPickerOpened = false), child: GestureDetector( // prevent the tap event from being passed to the parent widget @@ -591,8 +594,8 @@ class _SingleInnerViewItemState extends State { ), popupBuilder: (context) { isIconPickerOpened = true; - return FlowyIconPicker( - onSelected: (result) { + return FlowyIconEmojiPicker( + onSelectedEmoji: (result) { ViewBackendService.updateViewIcon( viewId: widget.view.id, viewIcon: result.emoji, @@ -692,6 +695,7 @@ class _SingleInnerViewItemState extends State { message: LocaleKeys.menuAppHeader_moreButtonToolTip.tr(), child: ViewMoreActionButton( view: widget.view, + isExpanded: widget.isExpanded, spaceType: widget.spaceType, onEditing: (value) => context.read().add(ViewEvent.setIsEditing(value)), 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 9b472fc57de51..6cb0e2754784a 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,5 +1,5 @@ import 'package:appflowy/generated/flowy_svgs.g.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/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'; @@ -19,12 +19,14 @@ class ViewMoreActionButton extends StatelessWidget { required this.onEditing, required this.onAction, required this.spaceType, + required this.isExpanded, }); final ViewPB view; final void Function(bool value) onEditing; final void Function(ViewMoreActionType type, dynamic data) onAction; final FolderSpaceType spaceType; + final bool isExpanded; @override Widget build(BuildContext context) { @@ -101,7 +103,10 @@ class ViewMoreActionButton extends StatelessWidget { ]); // Chat doesn't change collapse - if (view.layout != ViewLayoutPB.Chat) { + // 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); } @@ -156,10 +161,10 @@ class ViewMoreActionTypeWrapper extends CustomActionCell { return AppFlowyPopover( constraints: BoxConstraints.loose(const Size(364, 356)), - margin: const EdgeInsets.symmetric(horizontal: 14.0, vertical: 12.0), + margin: const EdgeInsets.all(0), clickHandler: PopoverClickHandler.gestureDetector, - popupBuilder: (_) => FlowyIconPicker( - onSelected: (result) => onTap(controller, result), + popupBuilder: (_) => FlowyIconEmojiPicker( + onSelectedEmoji: (result) => onTap(controller, result), ), child: child, ); @@ -204,7 +209,7 @@ class ViewMoreActionTypeWrapper extends CustomActionCell { Widget _buildDivider() { return const Padding( padding: EdgeInsets.all(8.0), - child: Divider(height: 1.0), + child: FlowyDivider(), ); } @@ -237,18 +242,27 @@ class ViewMoreActionTypeWrapper extends CustomActionCell { return Container( height: 34, padding: const EdgeInsets.symmetric(vertical: 2.0), - child: FlowyButton( + child: FlowyIconTextButton( margin: const EdgeInsets.symmetric(horizontal: 6), - leftIcon: inner.leftIcon, - rightIcon: inner.rightIcon, + 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, - text: FlowyText.regular( + textBuilder: (onHover) => FlowyText.regular( inner.name, - color: inner == ViewMoreActionType.delete + fontSize: 14.0, + lineHeight: 1.0, + figmaLineHeight: 18.0, + color: inner == ViewMoreActionType.delete && onHover ? Theme.of(context).colorScheme.error : null, ), - onTap: onTap, ), ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/navigation.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/navigation.dart index a90c12565ae6e..820fae729483b 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/navigation.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/navigation.dart @@ -2,7 +2,6 @@ import 'dart:io'; 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/home/home_setting_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_stack.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -65,23 +64,17 @@ class FlowyNavigation extends StatelessWidget { buildWhen: (p, c) => p.isMenuCollapsed != c.isMenuCollapsed, builder: (context, state) { if (!PlatformExtension.isWindows && state.isMenuCollapsed) { - final color = - Theme.of(context).isLightMode ? Colors.white : Colors.black; final textSpan = TextSpan( children: [ TextSpan( text: '${LocaleKeys.sideBar_openSidebar.tr()}\n', - style: Theme.of(context) - .tooltipTheme - .textStyle! - .copyWith(color: color), + style: context.tooltipTextStyle(), ), TextSpan( text: Platform.isMacOS ? '⌘+.' : 'Ctrl+\\', - style: Theme.of(context) - .tooltipTheme - .textStyle! - .copyWith(color: Theme.of(context).hintColor), + style: context + .tooltipTextStyle() + ?.copyWith(color: Theme.of(context).hintColor), ), ], ); 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 4760378f4b7c4..a871d915650ee 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 @@ -2,7 +2,7 @@ import 'package:appflowy/generated/flowy_svgs.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/hover.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -22,26 +22,22 @@ class FlowyTab extends StatefulWidget { } class _FlowyTabState extends State { - bool _isHovering = false; - @override Widget build(BuildContext context) { - return GestureDetector( - onTertiaryTapUp: _closeTab, - child: MouseRegion( - onEnter: (_) => _setHovering(true), - onExit: (_) => _setHovering(), - child: Container( - width: HomeSizes.tabBarWidth, - height: HomeSizes.tabBarHeight, - decoration: BoxDecoration( - color: _getBackgroundColor(), - ), - child: ChangeNotifierProvider.value( - value: widget.pageManager.notifier, - child: Consumer( - builder: (context, value, child) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), + return FlowyHover( + isSelected: () => widget.isCurrent, + style: const HoverStyle( + borderRadius: BorderRadius.zero, + ), + builder: (context, onHover) { + return ChangeNotifierProvider.value( + value: widget.pageManager.notifier, + child: Consumer( + builder: (context, value, child) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: SizedBox( + width: HomeSizes.tabBarWidth, + height: HomeSizes.tabBarHeight, child: Row( children: [ Expanded( @@ -49,15 +45,16 @@ class _FlowyTabState extends State { .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), + visible: onHover, + child: SizedBox( + width: 26, + height: 26, + child: FlowyIconButton( + onPressed: _closeTab, + icon: const FlowySvg( + FlowySvgs.close_s, + size: Size.square(22), + ), ), ), ), @@ -66,29 +63,11 @@ 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.surfaceContainerHighest; - } - void _closeTab([TapUpDetails? details]) => context .read() .add(TabsEvent.closeTab(widget.pageManager.plugin.id)); 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 07870a152d7f3..51f3e0b766cac 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,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/af_focus_manager.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy/workspace/presentation/home/tabs/flowy_tab.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -74,8 +75,10 @@ class _TabsManagerState extends State dividerColor: Colors.transparent, isScrollable: true, controller: _controller, - onTap: (newIndex) => - context.read().add(TabsEvent.selectTab(newIndex)), + onTap: (newIndex) { + AFFocusManager.of(context).notifyLoseFocus(); + context.read().add(TabsEvent.selectTab(newIndex)); + }, tabs: state.pageManagers .map( (pm) => FlowyTab( 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 022e6b3876a5d..d0392efb55d09 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_dialog.dart @@ -1,5 +1,3 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/notification_filter/notification_filter_bloc.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; @@ -11,6 +9,7 @@ import 'package:appflowy/workspace/presentation/notifications/widgets/notificati import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class NotificationDialog extends StatefulWidget { @@ -64,12 +63,10 @@ class _NotificationDialogState extends State builder: (context, filterState) => BlocBuilder( builder: (context, state) { - final List pastReminders = state.pastReminders - .where((r) => filterState.showUnreadsOnly ? !r.isRead : true) - .sortByScheduledAt(); - - final List upcomingReminders = + final reminders = state.reminders.sortByScheduledAt(); + final upcomingReminders = state.upcomingReminders.sortByScheduledAt(); + final hasUnreads = reminders.any((r) => !r.isRead); return Column( mainAxisSize: MainAxisSize.min, @@ -82,14 +79,14 @@ class _NotificationDialogState extends State controller: _controller, children: [ NotificationsView( - shownReminders: pastReminders, + shownReminders: reminders, reminderBloc: _reminderBloc, views: widget.views, onDelete: _onDelete, onAction: _onAction, onReadChanged: _onReadChanged, actionBar: InboxActionBar( - hasUnreads: state.hasUnreads, + hasUnreads: hasUnreads, showUnreadsOnly: filterState.showUnreadsOnly, ), ), 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 eae3c29d763ed..e0747fc6841f5 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,7 +1,6 @@ -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/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'; @@ -9,9 +8,8 @@ import 'package:appflowy/workspace/application/settings/notifications/notificati 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 StatefulWidget { @@ -24,6 +22,12 @@ class NotificationButton extends StatefulWidget { class _NotificationButtonState extends State { final mutex = PopoverMutex(); + @override + void initState() { + super.initState(); + getIt().add(const ReminderEvent.started()); + } + @override void dispose() { mutex.dispose(); @@ -39,31 +43,35 @@ class _NotificationButtonState extends State { child: BlocBuilder( builder: (notificationSettingsContext, notificationSettingsState) { return BlocBuilder( - builder: (context, state) => 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, state.hasUnreads), + builder: (context, state) { + final hasUnreads = state.reminders.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(), + ) + : const SizedBox.shrink(); + }, ); }, ), @@ -80,15 +88,11 @@ class _NotificationButtonState extends State { ), ), 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 fed0bf4390e95..029f6e11457bc 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,10 +1,10 @@ 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'; @@ -18,7 +18,7 @@ import 'package:provider/provider.dart'; class NotificationItem extends StatefulWidget { const NotificationItem({ super.key, - required this.reminderId, + required this.reminder, required this.title, required this.scheduled, required this.body, @@ -32,7 +32,7 @@ class NotificationItem extends StatefulWidget { this.view, }); - final String reminderId; + final ReminderPB reminder; final String title; final Int64 scheduled; final String body; @@ -169,6 +169,7 @@ class _NotificationItemState extends State { ), child: _NotificationContent( block: widget.block, + reminder: widget.reminder, body: widget.body, ), ), @@ -214,10 +215,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 @@ -229,29 +232,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, ), ); }, 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 42e5d50bfdf83..645be8b05507b 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 @@ -76,7 +76,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, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account.dart new file mode 100644 index 0000000000000..7b337d8d789b7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account.dart @@ -0,0 +1,3 @@ +export 'account_deletion.dart'; +export 'account_sign_in_out.dart'; +export 'account_user_profile.dart'; diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart new file mode 100644 index 0000000000000..f59053b55f271 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart @@ -0,0 +1,267 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/user/application/user_service.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_editor/appflowy_editor.dart' show PlatformExtension; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:toastification/toastification.dart'; + +const _confirmText = 'DELETE MY ACCOUNT'; +const _acceptableConfirmTexts = [ + 'delete my account', + 'deletemyaccount', + 'DELETE MY ACCOUNT', + 'DELETEMYACCOUNT', +]; + +class AccountDeletionButton extends StatefulWidget { + const AccountDeletionButton({ + super.key, + }); + + @override + State createState() => _AccountDeletionButtonState(); +} + +class _AccountDeletionButtonState extends State { + final textEditingController = TextEditingController(); + final isCheckedNotifier = ValueNotifier(false); + + @override + void dispose() { + textEditingController.dispose(); + isCheckedNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final textColor = Theme.of(context).brightness == Brightness.light + ? const Color(0xFF4F4F4F) + : const Color(0xFFB0B0B0); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText( + LocaleKeys.button_deleteAccount.tr(), + fontSize: 14.0, + fontWeight: FontWeight.w500, + figmaLineHeight: 21.0, + color: textColor, + ), + const VSpace(8), + Row( + children: [ + Flexible( + child: FlowyText.regular( + LocaleKeys.newSettings_myAccount_deleteAccount_description.tr(), + fontSize: 12.0, + figmaLineHeight: 13.0, + maxLines: 2, + color: textColor, + ), + ), + const HSpace(32), + FlowyTextButton( + LocaleKeys.button_deleteAccount.tr(), + constraints: const BoxConstraints(minHeight: 32), + padding: const EdgeInsets.symmetric(horizontal: 26, vertical: 10), + fillColor: Colors.transparent, + radius: Corners.s8Border, + hoverColor: Theme.of(context).colorScheme.error.withOpacity(0.1), + fontColor: Theme.of(context).colorScheme.error, + fontHoverColor: Colors.white, + fontSize: 12, + isDangerous: true, + lineHeight: 18.0 / 12.0, + onPressed: () { + isCheckedNotifier.value = false; + textEditingController.clear(); + + showCancelAndDeleteDialog( + context: context, + title: + LocaleKeys.newSettings_myAccount_deleteAccount_title.tr(), + description: '', + builder: (_) => _AccountDeletionDialog( + controller: textEditingController, + isChecked: isCheckedNotifier, + ), + onDelete: () => deleteMyAccount( + context, + textEditingController.text.trim(), + isCheckedNotifier.value, + onSuccess: () { + Navigator.of(context).popUntil((route) { + if (route.settings.name == '/') { + return true; + } + return false; + }); + }, + ), + ); + }, + ), + ], + ), + ], + ); + } +} + +class _AccountDeletionDialog extends StatelessWidget { + const _AccountDeletionDialog({ + required this.controller, + required this.isChecked, + }); + + final TextEditingController controller; + final ValueNotifier isChecked; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText.regular( + LocaleKeys.newSettings_myAccount_deleteAccount_confirmHint1.tr(), + fontSize: 14.0, + figmaLineHeight: 18.0, + maxLines: 2, + color: ConfirmPopupColor.descriptionColor(context), + ), + const VSpace(12.0), + FlowyTextField( + hintText: _confirmText, + controller: controller, + ), + const VSpace(16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: () => isChecked.value = !isChecked.value, + child: ValueListenableBuilder( + valueListenable: isChecked, + builder: (context, isChecked, _) { + return FlowySvg( + isChecked ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, + size: const Size.square(16.0), + blendMode: isChecked ? null : BlendMode.srcIn, + ); + }, + ), + ), + const HSpace(6.0), + Expanded( + child: FlowyText.regular( + LocaleKeys.newSettings_myAccount_deleteAccount_confirmHint2 + .tr(), + fontSize: 14.0, + figmaLineHeight: 16.0, + maxLines: 3, + color: ConfirmPopupColor.descriptionColor(context), + ), + ), + ], + ), + ], + ); + } +} + +bool _isConfirmTextValid(String text) { + // don't convert the text to lower case or upper case, + // just check if the text is in the list + return _acceptableConfirmTexts.contains(text); +} + +Future deleteMyAccount( + BuildContext context, + String confirmText, + bool isChecked, { + VoidCallback? onSuccess, + VoidCallback? onFailure, +}) async { + final bottomPadding = PlatformExtension.isMobile + ? MediaQuery.of(context).viewInsets.bottom + : 0.0; + + if (!isChecked) { + showToastNotification( + context, + type: ToastificationType.warning, + bottomPadding: bottomPadding, + message: LocaleKeys + .newSettings_myAccount_deleteAccount_checkToConfirmError + .tr(), + ); + return; + } + + if (!context.mounted) { + return; + } + + if (confirmText.isEmpty || !_isConfirmTextValid(confirmText)) { + showToastNotification( + context, + type: ToastificationType.warning, + bottomPadding: bottomPadding, + message: LocaleKeys + .newSettings_myAccount_deleteAccount_confirmTextValidationFailed + .tr(), + ); + return; + } + + final loading = Loading(context)..start(); + + await UserBackendService.deleteCurrentAccount().fold( + (s) { + Log.info('account deletion success'); + + loading.stop(); + showToastNotification( + context, + message: LocaleKeys + .newSettings_myAccount_deleteAccount_deleteAccountSuccess + .tr(), + bottomPadding: bottomPadding, + ); + + // delay 1 second to make sure the toast notification is shown + Future.delayed(const Duration(seconds: 1), () async { + onSuccess?.call(); + + // restart the application + await getIt().signOut(); + await runAppFlowy(); + }); + }, + (f) { + Log.error('account deletion failed, error: $f'); + + loading.stop(); + showToastNotification( + context, + type: ToastificationType.error, + bottomPadding: bottomPadding, + message: f.msg, + ); + + onFailure?.call(); + }, + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_sign_in_out.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_sign_in_out.dart new file mode 100644 index 0000000000000..d897a7931bd3c --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_sign_in_out.dart @@ -0,0 +1,185 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/user/application/prelude.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/setting_third_party_login.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class AccountSignInOutButton extends StatelessWidget { + const AccountSignInOutButton({ + super.key, + required this.userProfile, + required this.onAction, + this.signIn = true, + }); + + final UserProfilePB userProfile; + final VoidCallback onAction; + final bool signIn; + + @override + Widget build(BuildContext context) { + return PrimaryRoundedButton( + text: signIn + ? LocaleKeys.settings_accountPage_login_loginLabel.tr() + : LocaleKeys.settings_accountPage_login_logoutLabel.tr(), + margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + fontWeight: FontWeight.w600, + radius: 12.0, + onTap: () => + signIn ? _showSignInDialog(context) : _showLogoutDialog(context), + ); + } + + void _showLogoutDialog(BuildContext context) { + showConfirmDialog( + context: context, + title: LocaleKeys.settings_accountPage_login_logoutLabel.tr(), + description: userProfile.encryptionType == EncryptionTypePB.Symmetric + ? LocaleKeys.settings_menu_selfEncryptionLogoutPrompt.tr() + : LocaleKeys.settings_menu_logoutPrompt.tr(), + onConfirm: () async { + await getIt().signOut(); + onAction(); + }, + ); + } + + Future _showSignInDialog(BuildContext context) async { + await showDialog( + context: context, + builder: (context) => BlocProvider( + create: (context) => getIt(), + child: const FlowyDialog( + constraints: BoxConstraints(maxHeight: 485, maxWidth: 375), + child: _SignInDialogContent(), + ), + ), + ); + } +} + +class _SignInDialogContent extends StatelessWidget { + const _SignInDialogContent(); + + @override + Widget build(BuildContext context) { + return ScaffoldMessenger( + child: Scaffold( + body: Padding( + padding: const EdgeInsets.all(24), + child: SingleChildScrollView( + child: Column( + children: [ + const _DialogHeader(), + const _DialogTitle(), + const VSpace(16), + const SignInWithMagicLinkButtons(), + if (isAuthEnabled) ...[ + const VSpace(20), + const _OrDivider(), + const VSpace(10), + SettingThirdPartyLogin( + didLogin: () {}, + ), // TODO: Pass onAction + ], + ], + ), + ), + ), + ), + ); + } +} + +class _DialogHeader extends StatelessWidget { + const _DialogHeader(); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildBackButton(context), + _buildCloseButton(context), + ], + ); + } + + Widget _buildBackButton(BuildContext context) { + return GestureDetector( + onTap: Navigator.of(context).pop, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Row( + children: [ + const FlowySvg(FlowySvgs.arrow_back_m, size: Size.square(24)), + const HSpace(8), + FlowyText.semibold(LocaleKeys.button_back.tr(), fontSize: 16), + ], + ), + ), + ); + } + + Widget _buildCloseButton(BuildContext context) { + return GestureDetector( + onTap: Navigator.of(context).pop, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: FlowySvg( + FlowySvgs.m_close_m, + size: const Size.square(20), + color: Theme.of(context).colorScheme.outline, + ), + ), + ); + } +} + +class _DialogTitle extends StatelessWidget { + const _DialogTitle(); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: FlowyText.medium( + LocaleKeys.settings_accountPage_login_loginLabel.tr(), + fontSize: 22, + color: Theme.of(context).colorScheme.tertiary, + maxLines: null, + ), + ), + ], + ); + } +} + +class _OrDivider extends StatelessWidget { + const _OrDivider(); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const Flexible(child: Divider(thickness: 1)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: FlowyText.regular(LocaleKeys.signIn_or.tr()), + ), + const Flexible(child: Divider(thickness: 1)), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_user_profile.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_user_profile.dart new file mode 100644 index 0000000000000..90c4b6c2ba30b --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_user_profile.dart @@ -0,0 +1,179 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/workspace/application/user/settings_user_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_input_field.dart'; +import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +// Account name and account avatar +class AccountUserProfile extends StatefulWidget { + const AccountUserProfile({ + super.key, + required this.name, + required this.iconUrl, + this.onSave, + }); + + final String name; + final String iconUrl; + final void Function(String)? onSave; + + @override + State createState() => _AccountUserProfileState(); +} + +class _AccountUserProfileState extends State { + late final TextEditingController nameController = + TextEditingController(text: widget.name); + final FocusNode focusNode = FocusNode(); + bool isEditing = false; + bool isHovering = false; + + @override + void initState() { + super.initState(); + + focusNode + ..addListener(_handleFocusChange) + ..onKeyEvent = _handleKeyEvent; + } + + @override + void dispose() { + nameController.dispose(); + focusNode.removeListener(_handleFocusChange); + focusNode.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildAvatar(), + const HSpace(16), + Flexible( + child: isEditing ? _buildEditingField() : _buildNameDisplay(), + ), + ], + ); + } + + Widget _buildAvatar() { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _showIconPickerDialog(context), + child: FlowyHover( + resetHoverOnRebuild: false, + onHover: (state) => setState(() => isHovering = state), + style: HoverStyle( + hoverColor: Colors.transparent, + borderRadius: BorderRadius.circular(100), + ), + child: FlowyTooltip( + message: + LocaleKeys.settings_accountPage_general_changeProfilePicture.tr(), + child: UserAvatar( + iconUrl: widget.iconUrl, + name: widget.name, + size: 48, + fontSize: 20, + isHovering: isHovering, + ), + ), + ), + ); + } + + Widget _buildNameDisplay() { + return Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: FlowyText.medium( + widget.name, + overflow: TextOverflow.ellipsis, + ), + ), + const HSpace(4), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => setState(() => isEditing = true), + child: const FlowyHover( + resetHoverOnRebuild: false, + child: Padding( + padding: EdgeInsets.all(4), + child: FlowySvg(FlowySvgs.edit_s), + ), + ), + ), + ], + ), + ); + } + + Widget _buildEditingField() { + return SettingsInputField( + textController: nameController, + value: widget.name, + focusNode: focusNode..requestFocus(), + onCancel: () => setState(() => isEditing = false), + onSave: (_) => _saveChanges(), + ); + } + + Future _showIconPickerDialog(BuildContext context) { + return showDialog( + context: context, + builder: (dialogContext) => SimpleDialog( + children: [ + Container( + height: 380, + width: 360, + margin: const EdgeInsets.all(0), + child: FlowyIconEmojiPicker( + onSelectedEmoji: (r) { + context + .read() + .add(SettingsUserEvent.updateUserIcon(iconUrl: r.emoji)); + Navigator.of(dialogContext).pop(); + }, + ), + ), + ], + ), + ); + } + + void _handleFocusChange() { + if (!focusNode.hasFocus && isEditing && mounted) { + _saveChanges(); + } + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.escape && + isEditing && + mounted) { + setState(() => isEditing = false); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + void _saveChanges() { + widget.onSave?.call(nameController.text); + setState(() => isEditing = false); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/fix_data_widget.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/fix_data_widget.dart index 6f556ec5b6202..2cecb25b3049f 100644 --- 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 @@ -118,6 +118,8 @@ class WorkspaceDataManager { final List unlistedChildViews = []; // Views whose parent is not in allViews final List orphanViews = []; + // Row pages + final List rowPageViews = []; try { if (workspace == null || allViews == null) { @@ -145,6 +147,11 @@ class WorkspaceDataManager { continue; } + if (parentView.id == view.id) { + rowPageViews.add(view); + continue; + } + final childViewsOfParent = await ViewBackendService.getChildViews(viewId: parentView.id) .getOrThrow(); @@ -165,7 +172,11 @@ class WorkspaceDataManager { } for (final view in orphanViews) { - Log.debug('[workspace] orphanViews: ${view.toProto3Json()}'); + Log.info('[workspace] orphanViews: ${view.toProto3Json()}'); + } + + for (final view in rowPageViews) { + Log.info('[workspace] rowPageViews: ${view.toProto3Json()}'); } if (!dryRun && unlistedChildViews.isNotEmpty) { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/downloading.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/downloading_model.dart similarity index 76% rename from frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/downloading.dart rename to frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/downloading_model.dart index 08611542a4bfd..a7ed782aea371 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/downloading.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/downloading_model.dart @@ -1,6 +1,6 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/ai/download_model_bloc.dart'; -import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; @@ -37,15 +37,23 @@ class DownloadingIndicator extends StatelessWidget { color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Column( - children: [ - // const DownloadingPrompt(), - // const VSpace(12), - DownloadingProgressBar(onCancel: onCancel), - ], - ), + child: BlocBuilder( + builder: (context, state) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DownloadingProgressBar(onCancel: onCancel), + if (state.bigFileDownloadPrompt != null) ...[ + const VSpace(2), + Opacity( + opacity: 0.6, + child: + FlowyText(state.bigFileDownloadPrompt!, fontSize: 11), + ), + ], + ], + ); + }, ), ), ), @@ -65,9 +73,12 @@ class DownloadingProgressBar extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - FlowyText( - "${LocaleKeys.settings_aiPage_keys_downloadingModel.tr()}: ${state.object}", - fontSize: 11, + Opacity( + opacity: 0.6, + child: FlowyText( + "${LocaleKeys.settings_aiPage_keys_downloadingModel.tr()}: ${state.object}", + fontSize: 11, + ), ), IntrinsicHeight( child: Row( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart index e509263ba26ac..d924b468250f2 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart @@ -1,7 +1,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/ai/local_ai_chat_bloc.dart'; -import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; @@ -38,20 +38,23 @@ class InitLocalAIIndicator extends StatelessWidget { ], ); case RunningStatePB.Running: - return Row( - children: [ - const HSpace(8), - const FlowySvg( - FlowySvgs.download_success_s, - color: Color(0xFF2E7D32), - ), - const HSpace(6), - FlowyText( - LocaleKeys.settings_aiPage_keys_localAILoaded.tr(), - fontSize: 11, - color: const Color(0xFF1E4620), - ), - ], + return SizedBox( + height: 30, + child: Row( + children: [ + const HSpace(8), + const FlowySvg( + FlowySvgs.download_success_s, + color: Color(0xFF2E7D32), + ), + const HSpace(6), + FlowyText( + LocaleKeys.settings_aiPage_keys_localAILoaded.tr(), + fontSize: 11, + color: const Color(0xFF1E4620), + ), + ], + ), ); case RunningStatePB.Stopped: return Row( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_chat_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_chat_setting.dart index 98f48df89cb17..fb66dad7a5694 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_chat_setting.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_chat_setting.dart @@ -1,12 +1,13 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/workspace/application/settings/ai/local_ai_chat_bloc.dart'; import 'package:appflowy/workspace/application/settings/ai/local_ai_chat_toggle_bloc.dart'; -import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/downloading.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/downloading_model.dart'; import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart'; import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; -import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:expandable/expandable.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -67,53 +68,53 @@ class LocalAIChatSetting extends StatelessWidget { tapBodyToExpand: false, tapHeaderToExpand: false, ), - header: const LocalAIChatSettingHeader(), + header: const SizedBox.shrink(), collapsed: const SizedBox.shrink(), expanded: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: const EdgeInsets.symmetric(vertical: 6), + // child: _LocalLLMInfoWidget(), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible( - child: FlowyText.medium( - LocaleKeys.settings_aiPage_keys_llmModel.tr(), - fontSize: 14, - ), - ), - const Spacer(), - BlocBuilder( - builder: (context, state) { - return state.fetchModelInfoState.when( - loading: () => Expanded( - child: Row( - children: [ - Flexible( - child: FlowyText( - LocaleKeys - .settings_aiPage_keys_fetchLocalModel - .tr(), - ), - ), - const Spacer(), - const CircularProgressIndicator.adaptive(), - ], + BlocBuilder( + builder: (context, state) { + // If the progress indicator is startOfflineAIApp, then don't show the LLM model. + if (state.progressIndicator == + const LocalAIProgress.startOfflineAIApp()) { + return const SizedBox.shrink(); + } else { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: FlowyText.medium( + LocaleKeys.settings_aiPage_keys_llmModel.tr(), + fontSize: 14, ), ), - finish: (err) { - return (err == null) + const Spacer(), + state.aiModelProgress.when( + init: () => const SizedBox.shrink(), + loading: () { + return const Expanded( + child: Row( + children: [ + Spacer(), + CircularProgressIndicator.adaptive(), + ], + ), + ); + }, + finish: (err) => (err == null) ? const _SelectLocalModelDropdownMenu() - : const SizedBox.shrink(); - }, - ); - }, - ), - ], + : const SizedBox.shrink(), + ), + ], + ); + } + }, ), - const IntrinsicHeight(child: _LocalLLMInfoWidget()), + const IntrinsicHeight(child: _LocalAIStateWidget()), ], ), ), @@ -189,7 +190,6 @@ class _SelectLocalModelDropdownMenu extends StatelessWidget { context, value: llm, label: llm.chatModel, - padding: const EdgeInsets.symmetric(vertical: 8), ), ) .toList(), @@ -200,8 +200,8 @@ class _SelectLocalModelDropdownMenu extends StatelessWidget { } } -class _LocalLLMInfoWidget extends StatelessWidget { - const _LocalLLMInfoWidget(); +class _LocalAIStateWidget extends StatelessWidget { + const _LocalAIStateWidget(); @override Widget build(BuildContext context) { @@ -210,15 +210,8 @@ class _LocalLLMInfoWidget extends StatelessWidget { final error = errorFromState(state); if (error == null) { // If the error is null, handle selected llm model. - if (state.localAIInfo != null) { - final child = state.localAIInfo!.when( - requestDownloadInfo: ( - LocalModelResourcePB llmResource, - LLMModelPB llmModel, - ) { - _showDownloadDialog(context, llmResource, llmModel); - return const SizedBox.shrink(); - }, + if (state.progressIndicator != null) { + final child = state.progressIndicator!.when( showDownload: ( LocalModelResourcePB llmResource, LLMModelPB llmModel, @@ -240,7 +233,14 @@ class _LocalLLMInfoWidget extends StatelessWidget { ); }, finishDownload: () => const InitLocalAIIndicator(), - checkPluginState: () => const CheckPluginStateIndicator(), + checkPluginState: () => const PluginStateIndicator(), + startOfflineAIApp: () => OpenOrDownloadOfflineAIApp( + onRetry: () { + context + .read() + .add(const LocalAIChatSettingEvent.refreshAISetting()); + }, + ), ); return Padding( @@ -253,9 +253,12 @@ class _LocalLLMInfoWidget extends StatelessWidget { } else { return Opacity( opacity: 0.5, - child: FlowyText( - error.msg, - maxLines: 10, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: FlowyText( + error.msg, + maxLines: 10, + ), ), ); } @@ -263,44 +266,11 @@ class _LocalLLMInfoWidget extends StatelessWidget { ); } - void _showDownloadDialog( - BuildContext context, - LocalModelResourcePB llmResource, - LLMModelPB llmModel, - ) { - WidgetsBinding.instance.addPostFrameCallback( - (_) { - showDialog( - context: context, - barrierDismissible: false, - useRootNavigator: false, - builder: (dialogContext) { - return _LLMModelDownloadDialog( - llmResource: llmResource, - onOkPressed: () { - context.read().add( - LocalAIChatSettingEvent.startDownloadModel( - llmModel, - ), - ); - }, - onCancelPressed: () { - context.read().add( - const LocalAIChatSettingEvent.cancelDownload(), - ); - }, - ); - }, - ); - }, - debugLabel: 'localModel.download', - ); - } - FlowyError? errorFromState(LocalAIChatSettingState state) { - final err = state.fetchModelInfoState.when( + final err = state.aiModelProgress.when( loading: () => null, finish: (err) => err, + init: () {}, ); if (err == null) { @@ -314,39 +284,48 @@ class _LocalLLMInfoWidget extends StatelessWidget { } } -class _LLMModelDownloadDialog extends StatelessWidget { - const _LLMModelDownloadDialog({ - required this.llmResource, - required this.onOkPressed, - required this.onCancelPressed, - }); - final LocalModelResourcePB llmResource; - final VoidCallback onOkPressed; - final VoidCallback onCancelPressed; +void _showDownloadDialog( + BuildContext context, + LocalModelResourcePB llmResource, + LLMModelPB llmModel, +) { + if (llmResource.pendingResources.isEmpty) { + return; + } - @override - Widget build(BuildContext context) { - return NavigatorOkCancelDialog( - title: LocaleKeys.settings_aiPage_keys_downloadLLMPrompt.tr( + final res = llmResource.pendingResources.first; + String desc = ""; + switch (res.resType) { + case PendingResourceTypePB.AIModel: + desc = LocaleKeys.settings_aiPage_keys_downloadLLMPromptDetail.tr( args: [ llmResource.pendingResources[0].name, + llmResource.pendingResources[0].fileSize, ], - ), - message: llmResource.pendingResources[0].fileSize == 0 - ? "" - : LocaleKeys.settings_aiPage_keys_downloadLLMPromptDetail.tr( - args: [ - llmResource.pendingResources[0].name, - llmResource.pendingResources[0].fileSize.toString(), - ], - ), - okTitle: LocaleKeys.button_confirm.tr(), - cancelTitle: LocaleKeys.button_cancel.tr(), - onOkPressed: onOkPressed, - onCancelPressed: onCancelPressed, - titleUpperCase: false, - ); + ); + break; + case PendingResourceTypePB.OfflineApp: + desc = LocaleKeys.settings_aiPage_keys_downloadAppFlowyOfflineAI.tr(); + break; } + + showConfirmDialog( + context: context, + style: ConfirmPopupStyle.cancelAndOk, + title: LocaleKeys.settings_aiPage_keys_downloadLLMPrompt.tr( + args: [res.name], + ), + description: desc, + confirmLabel: LocaleKeys.button_confirm.tr(), + onConfirm: () => context.read().add( + LocalAIChatSettingEvent.startDownloadModel( + llmModel, + ), + ), + onCancel: () => context.read().add( + const LocalAIChatSettingEvent.cancelDownload(), + ), + ); } class _ShowDownloadIndicator extends StatelessWidget { @@ -378,29 +357,7 @@ class _ShowDownloadIndicator extends StatelessWidget { color: Color(0xFF005483), ), onTap: () { - showDialog( - context: context, - barrierDismissible: false, - useRootNavigator: false, - builder: (dialogContext) { - return _LLMModelDownloadDialog( - llmResource: llmResource, - onOkPressed: () { - context.read().add( - LocalAIChatSettingEvent.startDownloadModel( - llmModel, - ), - ); - }, - onCancelPressed: () { - context.read().add( - const LocalAIChatSettingEvent - .cancelDownload(), - ); - }, - ); - }, - ); + _showDownloadDialog(context, llmResource, llmModel); }, ), ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart index 8f58d20b17a57..041dd117e0fab 100644 --- 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 @@ -1,14 +1,15 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/ai/local_ai_bloc.dart'; +import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/local_ai_chat_setting.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:expandable/expandable.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flutter/material.dart'; - -import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class LocalAISetting extends StatefulWidget { @@ -55,6 +56,7 @@ class _LocalAISettingState extends State { collapsed: const SizedBox.shrink(), expanded: Column( children: [ + const VSpace(6), DecoratedBox( decoration: BoxDecoration( color: Theme.of(context) @@ -64,11 +66,8 @@ class _LocalAISettingState extends State { const BorderRadius.all(Radius.circular(4)), ), child: const Padding( - padding: EdgeInsets.only( - left: 12.0, - top: 6, - bottom: 6, - ), + padding: + EdgeInsets.symmetric(horizontal: 12, vertical: 6), child: LocalAIChatSetting(), ), ), @@ -109,20 +108,18 @@ class LocalAISettingHeader extends StatelessWidget { value: isEnabled, onChanged: (value) { if (isEnabled) { - showDialog( + showConfirmDialog( context: context, - barrierDismissible: false, - useRootNavigator: false, - builder: (dialogContext) { - return _ToggleLocalAIDialog( - onOkPressed: () { - context - .read() - .add(const LocalAIToggleEvent.toggle()); - }, - onCancelPressed: () {}, - ); - }, + title: LocaleKeys + .settings_aiPage_keys_disableLocalAITitle + .tr(), + description: LocaleKeys + .settings_aiPage_keys_disableLocalAIDescription + .tr(), + confirmLabel: LocaleKeys.button_confirm.tr(), + onConfirm: () => context + .read() + .add(const LocalAIToggleEvent.toggle()), ); } else { context @@ -139,24 +136,3 @@ class LocalAISettingHeader extends StatelessWidget { ); } } - -class _ToggleLocalAIDialog extends StatelessWidget { - const _ToggleLocalAIDialog({ - required this.onOkPressed, - required this.onCancelPressed, - }); - final VoidCallback onOkPressed; - final VoidCallback onCancelPressed; - - @override - Widget build(BuildContext context) { - return NavigatorOkCancelDialog( - message: LocaleKeys.settings_aiPage_keys_disableLocalAIDialog.tr(), - okTitle: LocaleKeys.button_confirm.tr(), - cancelTitle: LocaleKeys.button_cancel.tr(), - onOkPressed: onOkPressed, - onCancelPressed: onCancelPressed, - titleUpperCase: false, - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart index b06fb4ae39d2b..bf601b618405a 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart @@ -1,15 +1,19 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/settings/ai/download_offline_ai_app_bloc.dart'; import 'package:appflowy/workspace/application/settings/ai/plugin_state_bloc.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -class CheckPluginStateIndicator extends StatelessWidget { - const CheckPluginStateIndicator({super.key}); +class PluginStateIndicator extends StatelessWidget { + const PluginStateIndicator({super.key}); @override Widget build(BuildContext context) { @@ -20,9 +24,16 @@ class CheckPluginStateIndicator extends StatelessWidget { builder: (context, state) { return state.action.when( init: () => const _InitPlugin(), - ready: () => const _ReadyToUse(), - restart: () => const _ReloadButton(), + ready: () => const _LocalAIReadyToUse(), + restartPlugin: () => const _ReloadButton(), loadingPlugin: () => const _InitPlugin(), + startAIOfflineApp: () => OpenOrDownloadOfflineAIApp( + onRetry: () { + context + .read() + .add(const PluginStateEvent.started()); + }, + ), ); }, ), @@ -35,9 +46,15 @@ class _InitPlugin extends StatelessWidget { @override Widget build(BuildContext context) { - return const SizedBox( - height: 20, - child: CircularProgressIndicator.adaptive(), + return Row( + children: [ + FlowyText(LocaleKeys.settings_aiPage_keys_localAIStart.tr()), + const Spacer(), + const SizedBox( + height: 20, + child: CircularProgressIndicator.adaptive(), + ), + ], ); } } @@ -74,8 +91,8 @@ class _ReloadButton extends StatelessWidget { } } -class _ReadyToUse extends StatelessWidget { - const _ReadyToUse(); +class _LocalAIReadyToUse extends StatelessWidget { + const _LocalAIReadyToUse(); @override Widget build(BuildContext context) { @@ -87,19 +104,45 @@ class _ReadyToUse extends StatelessWidget { ), ), child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.symmetric(vertical: 4), child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const HSpace(8), - const FlowySvg( - FlowySvgs.download_success_s, - color: Color(0xFF2E7D32), + Flexible( + child: Row( + children: [ + const HSpace(8), + const FlowySvg( + FlowySvgs.download_success_s, + color: Color(0xFF2E7D32), + ), + const HSpace(6), + Flexible( + child: FlowyText( + LocaleKeys.settings_aiPage_keys_localAILoaded.tr(), + fontSize: 11, + color: const Color(0xFF1E4620), + maxLines: 3, + ), + ), + ], + ), ), - const HSpace(6), - FlowyText( - LocaleKeys.settings_aiPage_keys_localAILoaded.tr(), - fontSize: 11, - color: const Color(0xFF1E4620), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: FlowyButton( + useIntrinsicWidth: true, + text: FlowyText( + LocaleKeys.settings_aiPage_keys_openModelDirectory.tr(), + fontSize: 11, + color: const Color(0xFF1E4620), + ), + onTap: () { + context.read().add( + const PluginStateEvent.openModelDirectory(), + ); + }, + ), ), ], ), @@ -107,3 +150,106 @@ class _ReadyToUse extends StatelessWidget { ); } } + +class OpenOrDownloadOfflineAIApp extends StatelessWidget { + const OpenOrDownloadOfflineAIApp({required this.onRetry, super.key}); + + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => DownloadOfflineAIBloc(), + child: BlocBuilder( + builder: (context, state) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RichText( + maxLines: 3, + textAlign: TextAlign.left, + text: TextSpan( + children: [ + TextSpan( + text: + "${LocaleKeys.settings_aiPage_keys_offlineAIInstruction1.tr()} ", + style: Theme.of(context) + .textTheme + .bodySmall! + .copyWith(height: 1.5), + ), + TextSpan( + text: + " ${LocaleKeys.settings_aiPage_keys_offlineAIInstruction2.tr()} ", + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: FontSizes.s14, + color: Theme.of(context).colorScheme.primary, + height: 1.5, + ), + recognizer: TapGestureRecognizer() + ..onTap = () => afLaunchUrlString( + "https://docs.appflowy.io/docs/appflowy/product/appflowy-ai-offline", + ), + ), + TextSpan( + text: + " ${LocaleKeys.settings_aiPage_keys_offlineAIInstruction3.tr()} ", + style: Theme.of(context) + .textTheme + .bodySmall! + .copyWith(height: 1.5), + ), + TextSpan( + text: + "${LocaleKeys.settings_aiPage_keys_offlineAIDownload1.tr()} ", + style: Theme.of(context) + .textTheme + .bodySmall! + .copyWith(height: 1.5), + ), + TextSpan( + text: + " ${LocaleKeys.settings_aiPage_keys_offlineAIDownload2.tr()} ", + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: FontSizes.s14, + color: Theme.of(context).colorScheme.primary, + height: 1.5, + ), + recognizer: TapGestureRecognizer() + ..onTap = + () => context.read().add( + const DownloadOfflineAIEvent.started(), + ), + ), + TextSpan( + text: + " ${LocaleKeys.settings_aiPage_keys_offlineAIDownload3.tr()} ", + style: Theme.of(context) + .textTheme + .bodySmall! + .copyWith(height: 1.5), + ), + ], + ), + ), + // const SizedBox( + // height: 6, + // ), // Replaced VSpace with SizedBox for simplicity + // SizedBox( + // height: 30, + // child: FlowyButton( + // useIntrinsicWidth: true, + // margin: const EdgeInsets.symmetric(horizontal: 12), + // text: FlowyText( + // LocaleKeys.settings_aiPage_keys_activeOfflineAI.tr(), + // ), + // onTap: onRetry, + // ), + // ), + ], + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/settings_ai_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/settings_ai_view.dart index af6a6ed2d165a..0c3965c73149e 100644 --- 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 @@ -1,4 +1,12 @@ +import 'package:appflowy/shared/af_role_pb_extension.dart'; +import 'package:appflowy/shared/feature_flags.dart'; +import 'package:appflowy/workspace/application/settings/ai/local_ai_on_boarding_bloc.dart'; +import 'package:appflowy/workspace/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/widgets/setting_appflowy_cloud.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; @@ -28,25 +36,40 @@ class AIFeatureOnlySupportedWhenUsingAppFlowyCloud extends StatelessWidget { } class SettingsAIView extends StatelessWidget { - const SettingsAIView({super.key, required this.userProfile}); + const SettingsAIView({ + super.key, + required this.userProfile, + required this.member, + required this.workspaceId, + }); final UserProfilePB userProfile; + final WorkspaceMemberPB? member; + final String workspaceId; @override Widget build(BuildContext context) { return BlocProvider( - create: (_) => - SettingsAIBloc(userProfile)..add(const SettingsAIEvent.started()), + create: (_) => SettingsAIBloc(userProfile, workspaceId, member) + ..add(const SettingsAIEvent.started()), child: BlocBuilder( builder: (context, state) { final children = [ const AIModelSelection(), ]; - // children.add(const LocalAISetting()); - children.add(const _AISearchToggle(value: false)); + if (state.member != null) { + children.add( + _LocalAIOnBoarding( + userProfile: userProfile, + member: state.member!, + workspaceId: workspaceId, + ), + ); + } + return SettingsBody( title: LocaleKeys.settings_aiPage_title.tr(), description: @@ -101,3 +124,120 @@ class _AISearchToggle extends StatelessWidget { ); } } + +// ignore: unused_element +class _LocalAIOnBoarding extends StatelessWidget { + const _LocalAIOnBoarding({ + required this.userProfile, + required this.member, + required this.workspaceId, + }); + final UserProfilePB userProfile; + final WorkspaceMemberPB member; + final String workspaceId; + + @override + Widget build(BuildContext context) { + if (FeatureFlag.planBilling.isOn) { + return BillingGateGuard( + builder: (context) { + return BlocProvider( + create: (context) => + LocalAIOnBoardingBloc(userProfile, member, workspaceId) + ..add(const LocalAIOnBoardingEvent.started()), + child: BlocBuilder( + builder: (context, state) { + // Show the local AI settings if the user has purchased the AI Local plan + if (kDebugMode || state.isPurchaseAILocal) { + return const LocalAISetting(); + } else { + if (member.role.isOwner) { + // Show the upgrade to AI Local plan button if the user has not purchased the AI Local plan + return _UpgradeToAILocalPlan( + onTap: () { + context.read().add( + const LocalAIOnBoardingEvent.addSubscription( + SubscriptionPlanPB.AiLocal, + ), + ); + }, + ); + } else { + return const _AskOwnerUpgradeToLocalAI(); + } + } + }, + ), + ); + }, + ); + } else { + return const SizedBox.shrink(); + } + } +} + +class _AskOwnerUpgradeToLocalAI extends StatelessWidget { + const _AskOwnerUpgradeToLocalAI(); + + @override + Widget build(BuildContext context) { + return FlowyText( + LocaleKeys.sideBar_askOwnerToUpgradeToLocalAI.tr(), + color: AFThemeExtension.of(context).strongText, + ); + } +} + +class _UpgradeToAILocalPlan extends StatefulWidget { + const _UpgradeToAILocalPlan({required this.onTap}); + + final VoidCallback onTap; + + @override + State<_UpgradeToAILocalPlan> createState() => _UpgradeToAILocalPlanState(); +} + +class _UpgradeToAILocalPlanState extends State<_UpgradeToAILocalPlan> { + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.medium( + LocaleKeys.sideBar_upgradeToAILocal.tr(), + maxLines: 10, + lineHeight: 1.5, + ), + const VSpace(4), + Opacity( + opacity: 0.6, + child: FlowyText( + LocaleKeys.sideBar_upgradeToAILocalDesc.tr(), + fontSize: 12, + maxLines: 10, + lineHeight: 1.5, + ), + ), + ], + ), + ), + BlocBuilder( + builder: (context, state) { + if (state.isLoading) { + return const CircularProgressIndicator.adaptive(); + } else { + return Toggle( + value: false, + onChanged: (_) => widget.onTap(), + ); + } + }, + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart index a82199d98d7ac..bb0e4aac9f317 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,27 +1,15 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.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/plugins/base/icon/icon_picker.dart'; import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy/user/application/auth/auth_service.dart'; -import 'package:appflowy/user/application/prelude.dart'; -import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart'; import 'package:appflowy/workspace/application/user/settings_user_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/account.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_category.dart'; -import 'package:appflowy/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: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 SettingsAccountView extends StatefulWidget { @@ -58,10 +46,11 @@ class _SettingsAccountViewState extends State { return SettingsBody( title: LocaleKeys.settings_accountPage_title.tr(), children: [ + // user profile SettingsCategory( title: LocaleKeys.settings_accountPage_general_title.tr(), children: [ - UserProfileSetting( + AccountUserProfile( name: userName, iconUrl: state.userProfile.iconUrl, onSave: (newName) { @@ -77,6 +66,7 @@ class _SettingsAccountViewState extends State { ], ), + // user email // Only show email if the user is authenticated and not using local auth if (isAuthEnabled && state.userProfile.authenticator != AuthenticatorPB.Local) ...[ @@ -84,62 +74,15 @@ class _SettingsAccountViewState extends State { title: LocaleKeys.settings_accountPage_email_title.tr(), children: [ FlowyText.regular(state.userProfile.email), - // Enable when/if we need change email feature - // 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), - // ), ], ), ], - /// 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: () {}, - // ), - // ], - // ), - + // user sign in/out SettingsCategory( title: LocaleKeys.settings_accountPage_login_title.tr(), children: [ - SignInOutButton( + AccountSignInOutButton( userProfile: state.userProfile, onAction: state.userProfile.authenticator == AuthenticatorPB.Local @@ -151,22 +94,10 @@ class _SettingsAccountViewState extends State { ], ), - /// 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.authenticator == + AuthenticatorPB.AppFlowyCloud) + const AccountDeletionButton(), ], ); }, @@ -174,307 +105,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: () { - if (signIn) { - _showSignInDialog(context); - } else { - showConfirmDialog( - context: context, - title: LocaleKeys.settings_accountPage_login_logoutLabel.tr(), - description: switch (userProfile.encryptionType) { - EncryptionTypePB.Symmetric => - LocaleKeys.settings_menu_selfEncryptionLogoutPrompt.tr(), - _ => LocaleKeys.settings_menu_logoutPrompt.tr(), - }, - onConfirm: () async { - await getIt().signOut(); - onAction(); - }, - ); - } - }, - ), - ), - ], - ); - } - - Future _showSignInDialog(BuildContext context) async { - await showDialog( - context: context, - builder: (context) => BlocProvider( - create: (context) => getIt(), - child: FlowyDialog( - constraints: const BoxConstraints( - maxHeight: 485, - maxWidth: 375, - ), - child: ScaffoldMessenger( - child: Scaffold( - body: Padding( - padding: const EdgeInsets.all(24), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - GestureDetector( - onTap: Navigator.of(context).pop, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: Row( - children: [ - const FlowySvg( - FlowySvgs.arrow_back_m, - size: Size.square(24), - ), - const HSpace(8), - FlowyText.semibold( - LocaleKeys.button_back.tr(), - fontSize: 16, - ), - ], - ), - ), - ), - const Spacer(), - GestureDetector( - onTap: Navigator.of(context).pop, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: FlowySvg( - FlowySvgs.m_close_m, - size: const Size.square(20), - color: Theme.of(context).colorScheme.outline, - ), - ), - ), - ], - ), - 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, - ), - ), - ], - ), - const VSpace(16), - const SignInWithMagicLinkButtons(), - if (isAuthEnabled) ...[ - const VSpace(20), - 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)), - ], - ), - const VSpace(10), - SettingThirdPartyLogin(didLogin: onAction), - ], - ], - ), - ), - ), - ), - ), - ), - ); - } -} - -@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, - size: 48, - fontSize: 20, - isHovering: isHovering, - ), - ), - ), - ), - const HSpace(16), - if (!isEditing) ...[ - Padding( - padding: const EdgeInsets.only(top: 12), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: FlowyText.medium( - widget.name, - overflow: TextOverflow.ellipsis, - ), - ), - const HSpace(4), - GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => setState(() => isEditing = true), - child: const FlowyHover( - resetHoverOnRebuild: false, - child: Padding( - padding: EdgeInsets.all(4), - child: FlowySvg(FlowySvgs.edit_s), - ), - ), - ), - ], - ), - ), - ] 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: (r) { - context - .read() - .add(SettingsUserEvent.updateUserIcon(iconUrl: r.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 index acbd701867f54..8bccf2b9daeec 100644 --- 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 @@ -1,5 +1,6 @@ -import 'package:flutter/material.dart'; +import 'dart:io'; +import 'package:appflowy/shared/flowy_error_page.dart'; import 'package:appflowy/util/int64_extension.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/billing/settings_billing_bloc.dart'; @@ -19,8 +20,8 @@ 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/error_page.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'; @@ -80,9 +81,10 @@ class _SettingsBillingViewState extends State { if (state.error != null) { return Padding( padding: const EdgeInsets.all(16), - child: FlowyErrorPage.message( - state.error!.msg, - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + child: Center( + child: AppFlowyErrorPage( + error: state.error!, + ), ), ); } @@ -91,7 +93,7 @@ class _SettingsBillingViewState extends State { }, ready: (state) { final billingPortalEnabled = - state.subscriptionInfo.plan != WorkspacePlanPB.FreePlan; + state.subscriptionInfo.isBillingPortalEnabled; return SettingsBody( title: LocaleKeys.settings_billingPage_title.tr(), @@ -209,22 +211,26 @@ class _SettingsBillingViewState extends State { ), ), const SettingsDashedDivider(), - _AITile( - plan: SubscriptionPlanPB.AiLocal, - label: LocaleKeys - .settings_billingPage_addons_aiOnDevice_label - .tr(), - description: LocaleKeys - .settings_billingPage_addons_aiOnDevice_description, - activeDescription: LocaleKeys - .settings_billingPage_addons_aiOnDevice_activeDescription, - canceledDescription: LocaleKeys - .settings_billingPage_addons_aiOnDevice_canceledDescription, - subscriptionInfo: - state.subscriptionInfo.addOns.firstWhereOrNull( - (a) => a.type == WorkspaceAddOnPBType.AddOnAiLocal, + + // Currently, the AI Local tile is only available on macOS + // TODO(nathan): enable windows and linux + if (Platform.isMacOS) + _AITile( + plan: SubscriptionPlanPB.AiLocal, + label: LocaleKeys + .settings_billingPage_addons_aiOnDevice_label + .tr(), + description: LocaleKeys + .settings_billingPage_addons_aiOnDevice_description, + activeDescription: LocaleKeys + .settings_billingPage_addons_aiOnDevice_activeDescription, + canceledDescription: LocaleKeys + .settings_billingPage_addons_aiOnDevice_canceledDescription, + subscriptionInfo: + state.subscriptionInfo.addOns.firstWhereOrNull( + (a) => a.type == WorkspaceAddOnPBType.AddOnAiLocal, + ), ), - ), ], ), ], @@ -327,14 +333,9 @@ class _AITileState extends State<_AITile> { : LocaleKeys.settings_billingPage_addons_addLabel.tr(), fontWeight: FontWeight.w500, minWidth: _buttonsMinWidth, - onPressed: () { - if (widget.subscriptionInfo != null && isCanceled) { - // Show customer portal to renew - context - .read() - .add(const SettingsBillingEvent.openCustomerPortal()); - } else if (widget.subscriptionInfo != null) { - showConfirmDialog( + onPressed: () async { + if (widget.subscriptionInfo != null) { + await showConfirmDialog( context: context, style: ConfirmPopupStyle.cancelAndOk, title: LocaleKeys.settings_billingPage_addons_removeDialog_title @@ -343,11 +344,9 @@ class _AITileState extends State<_AITile> { .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), - ); - }, + onConfirm: () => context + .read() + .add(SettingsBillingEvent.cancelSubscription(widget.plan)), ); } else { // Add the addon 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 index 180aee2848d02..2aa1a40037e6c 100644 --- 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 @@ -1,8 +1,5 @@ import 'dart:async'; -import 'package:flutter/foundation.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'; @@ -25,10 +22,10 @@ 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/style_widget/button.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fluttertoast/fluttertoast.dart'; @@ -65,6 +62,7 @@ class SettingsManageDataView extends StatelessWidget { label: LocaleKeys.settings_common_reset.tr(), onPressed: () => showConfirmDialog( context: context, + confirmLabel: LocaleKeys.button_confirm.tr(), title: LocaleKeys .settings_manageDataPage_dataStorage_resetDialog_title .tr(), @@ -362,9 +360,9 @@ class _CurrentPathState extends State<_CurrentPath> { resetHoverOnRebuild: false, builder: (_, isHovering) => FlowyText.regular( widget.path, - lineHeight: 1.5, maxLines: 2, overflow: TextOverflow.ellipsis, + lineHeight: 1.5, decoration: isHovering ? TextDecoration.underline : null, color: isLM ? const Color(0xFF005483) @@ -432,15 +430,13 @@ class _DataPathActions extends StatelessWidget { children: [ SizedBox( height: 42, - child: FlowyTextButton( - LocaleKeys.settings_manageDataPage_dataStorage_actions_change.tr(), - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + child: PrimaryRoundedButton( + text: LocaleKeys.settings_manageDataPage_dataStorage_actions_change + .tr(), + margin: const EdgeInsets.symmetric(horizontal: 24), fontWeight: FontWeight.w600, - radius: BorderRadius.circular(12), - fillColor: Theme.of(context).colorScheme.primary, - hoverColor: const Color(0xFF005483), - fontHoverColor: Colors.white, - onPressed: () async { + radius: 12.0, + onTap: () async { final path = await getIt().getDirectoryPath(); if (!context.mounted || path == null || currentPath == path) { return; 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 index 7e0465cb7aac1..3ce6c6aca2646 100644 --- 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 @@ -5,12 +5,12 @@ 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:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../generated/locale_keys.g.dart'; @@ -141,7 +141,7 @@ class _SettingsPlanComparisonDialogState children: [ const VSpace(30), SizedBox( - height: 100, + height: 116, child: FlowyText.semibold( LocaleKeys .settings_comparePlanDialog_planFeatures @@ -153,7 +153,7 @@ class _SettingsPlanComparisonDialogState : const Color(0xFFE8E0FF), ), ), - const SizedBox(height: 96), + const SizedBox(height: 116), const SizedBox(height: 56), ..._planLabels.map( (e) => _ComparisonCell( @@ -184,17 +184,9 @@ class _SettingsPlanComparisonDialogState cells: _freeLabels, isCurrent: currentInfo.plan == WorkspacePlanPB.FreePlan, - canDowngrade: - currentInfo.plan != WorkspacePlanPB.FreePlan, - currentCanceled: currentInfo.isCanceled || - (context - .watch() - .state - .mapOrNull( - loading: (_) => true, - ready: (s) => s.downgradeProcessing, - ) ?? - false), + buttonType: WorkspacePlanPB.FreePlan.buttonTypeFor( + currentInfo.plan, + ), onSelected: () async { if (currentInfo.plan == WorkspacePlanPB.FreePlan || @@ -202,6 +194,12 @@ class _SettingsPlanComparisonDialogState return; } + final reason = + await showCancelSurveyDialog(context); + if (reason == null || !context.mounted) { + return; + } + await showConfirmDialog( context: context, title: LocaleKeys @@ -216,8 +214,9 @@ class _SettingsPlanComparisonDialogState style: ConfirmPopupStyle.cancelAndOk, onConfirm: () => context.read().add( - const SettingsPlanEvent - .cancelSubscription(), + SettingsPlanEvent.cancelSubscription( + reason: reason, + ), ), ); }, @@ -242,9 +241,9 @@ class _SettingsPlanComparisonDialogState cells: _proLabels, isCurrent: currentInfo.plan == WorkspacePlanPB.ProPlan, - canUpgrade: - currentInfo.plan == WorkspacePlanPB.FreePlan, - currentCanceled: currentInfo.isCanceled, + buttonType: WorkspacePlanPB.ProPlan.buttonTypeFor( + currentInfo.plan, + ), onSelected: () => context.read().add( const SettingsPlanEvent.addSubscription( @@ -266,6 +265,35 @@ class _SettingsPlanComparisonDialogState } } +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, @@ -275,9 +303,7 @@ class _PlanTable extends StatelessWidget { required this.cells, required this.isCurrent, required this.onSelected, - this.canUpgrade = false, - this.canDowngrade = false, - this.currentCanceled = false, + this.buttonType = _PlanButtonType.none, }); final String title; @@ -288,13 +314,11 @@ class _PlanTable extends StatelessWidget { final List<_CellItem> cells; final bool isCurrent; final VoidCallback onSelected; - final bool canUpgrade; - final bool canDowngrade; - final bool currentCanceled; + final _PlanButtonType buttonType; @override Widget build(BuildContext context) { - final highlightPlan = !isCurrent && !canDowngrade && canUpgrade; + final highlightPlan = !isCurrent && buttonType == _PlanButtonType.upgrade; final isLM = Theme.of(context).isLightMode; return Container( @@ -336,37 +360,29 @@ class _PlanTable extends StatelessWidget { title: price, description: priceInfo, isPrimary: !highlightPlan, - height: 96, ), - if (canUpgrade || canDowngrade) ...[ + if (buttonType == _PlanButtonType.none) ...[ + const SizedBox(height: 56), + ] else ...[ Opacity( - opacity: canDowngrade && currentCanceled ? 0.5 : 1, + opacity: 1, child: Padding( padding: EdgeInsets.only( - left: 12 + (canUpgrade && !canDowngrade ? 12 : 0), + left: 12 + (buttonType.isUpgrade ? 12 : 0), ), child: _ActionButton( - label: canUpgrade && !canDowngrade + label: buttonType.isUpgrade ? LocaleKeys.settings_comparePlanDialog_actions_upgrade .tr() : LocaleKeys .settings_comparePlanDialog_actions_downgrade .tr(), - onPressed: !canUpgrade && canDowngrade && currentCanceled - ? null - : onSelected, - tooltip: !canUpgrade && canDowngrade && currentCanceled - ? LocaleKeys - .settings_comparePlanDialog_actions_downgradeDisabledTooltip - .tr() - : null, - isUpgrade: canUpgrade && !canDowngrade, - useGradientBorder: !isCurrent && canUpgrade, + onPressed: onSelected, + isUpgrade: buttonType.isUpgrade, + useGradientBorder: buttonType.isUpgrade, ), ), ), - ] else ...[ - const SizedBox(height: 56), ], ...cells.map( (cell) => _ComparisonCell( @@ -410,13 +426,13 @@ class _CurrentBadge extends StatelessWidget { class _ComparisonCell extends StatelessWidget { const _ComparisonCell({ - required this.label, + this.label, this.icon, this.tooltip, this.isHighlighted = false, }); - final String label; + final String? label; final FlowySvgData? icon; final String? tooltip; final bool isHighlighted; @@ -441,10 +457,10 @@ class _ComparisonCell extends StatelessWidget { icon!, color: AFThemeExtension.of(context).strongText, ), - ] else ...[ + ] else if (label != null) ...[ Expanded( child: FlowyText.medium( - label, + label!, lineHeight: 1.2, color: AFThemeExtension.of(context).strongText, ), @@ -467,14 +483,12 @@ class _ComparisonCell extends StatelessWidget { class _ActionButton extends StatelessWidget { const _ActionButton({ required this.label, - this.tooltip, required this.onPressed, required this.isUpgrade, this.useGradientBorder = false, }); final String label; - final String? tooltip; final VoidCallback? onPressed; final bool isUpgrade; final bool useGradientBorder; @@ -487,30 +501,27 @@ class _ActionButton extends StatelessWidget { height: 56, child: Row( children: [ - FlowyTooltip( - message: tooltip, - child: 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)), + 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)), ), ), ), @@ -538,10 +549,7 @@ class _ActionButton extends StatelessWidget { shaderCallback: (bounds) => const LinearGradient( transform: GradientRotation(-1.55), stops: [0.4, 1], - colors: [ - Color(0xFF251D37), - Color(0xFF7547C0), - ], + colors: [Color(0xFF251D37), Color(0xFF7547C0)], ).createShader(Rect.fromLTWH(0, 0, bounds.width, bounds.height)), child: child, ); @@ -579,19 +587,17 @@ class _Heading extends StatelessWidget { required this.title, this.description, this.isPrimary = true, - this.height = 100, }); final String title; final String? description; final bool isPrimary; - final double height; @override Widget build(BuildContext context) { return SizedBox( width: 185, - height: height, + height: 116, child: Padding( padding: EdgeInsets.only(left: 12 + (!isPrimary ? 12 : 0)), child: Column( @@ -615,11 +621,13 @@ class _Heading extends StatelessWidget { ), if (description != null && description!.isNotEmpty) ...[ const VSpace(4), - FlowyText.regular( - description!, - fontSize: 12, - maxLines: 3, - lineHeight: 1.5, + Flexible( + child: FlowyText.regular( + description!, + fontSize: 12, + maxLines: 5, + lineHeight: 1.5, + ), ), ], ], @@ -652,72 +660,84 @@ final _planLabels = [ _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_tooltipSeven.tr(), + label: LocaleKeys.settings_comparePlanDialog_planLabels_itemFileUpload.tr(), ), ]; class _CellItem { - const _CellItem(this.label, {this.icon}); + const _CellItem({this.label, this.icon}); - final String label; + final String? label; final FlowySvgData? icon; } final List<_CellItem> _freeLabels = [ _CellItem( - LocaleKeys.settings_comparePlanDialog_freeLabels_itemOne.tr(), + label: LocaleKeys.settings_comparePlanDialog_freeLabels_itemOne.tr(), ), _CellItem( - LocaleKeys.settings_comparePlanDialog_freeLabels_itemTwo.tr(), + label: LocaleKeys.settings_comparePlanDialog_freeLabels_itemTwo.tr(), ), _CellItem( - LocaleKeys.settings_comparePlanDialog_freeLabels_itemThree.tr(), + label: LocaleKeys.settings_comparePlanDialog_freeLabels_itemThree.tr(), + ), + _CellItem( + label: LocaleKeys.settings_comparePlanDialog_freeLabels_itemFour.tr(), + icon: FlowySvgs.check_m, ), _CellItem( - LocaleKeys.settings_comparePlanDialog_freeLabels_itemFour.tr(), + label: LocaleKeys.settings_comparePlanDialog_freeLabels_itemFive.tr(), icon: FlowySvgs.check_m, ), _CellItem( - LocaleKeys.settings_comparePlanDialog_freeLabels_itemFive.tr(), + label: + LocaleKeys.settings_comparePlanDialog_freeLabels_intelligentSearch.tr(), icon: FlowySvgs.check_m, ), _CellItem( - LocaleKeys.settings_comparePlanDialog_freeLabels_itemSix.tr(), + label: LocaleKeys.settings_comparePlanDialog_freeLabels_itemSix.tr(), ), _CellItem( - LocaleKeys.settings_comparePlanDialog_freeLabels_itemSeven.tr(), + label: LocaleKeys.settings_comparePlanDialog_freeLabels_itemFileUpload.tr(), ), ]; final List<_CellItem> _proLabels = [ _CellItem( - LocaleKeys.settings_comparePlanDialog_proLabels_itemOne.tr(), + label: LocaleKeys.settings_comparePlanDialog_proLabels_itemOne.tr(), ), _CellItem( - LocaleKeys.settings_comparePlanDialog_proLabels_itemTwo.tr(), + label: LocaleKeys.settings_comparePlanDialog_proLabels_itemTwo.tr(), ), _CellItem( - LocaleKeys.settings_comparePlanDialog_proLabels_itemThree.tr(), + label: LocaleKeys.settings_comparePlanDialog_proLabels_itemThree.tr(), ), _CellItem( - LocaleKeys.settings_comparePlanDialog_proLabels_itemFour.tr(), + label: LocaleKeys.settings_comparePlanDialog_proLabels_itemFour.tr(), icon: FlowySvgs.check_m, ), _CellItem( - LocaleKeys.settings_comparePlanDialog_proLabels_itemFive.tr(), + label: LocaleKeys.settings_comparePlanDialog_proLabels_itemFive.tr(), icon: FlowySvgs.check_m, ), _CellItem( - LocaleKeys.settings_comparePlanDialog_proLabels_itemSix.tr(), + label: + LocaleKeys.settings_comparePlanDialog_proLabels_intelligentSearch.tr(), + icon: FlowySvgs.check_m, ), _CellItem( - LocaleKeys.settings_comparePlanDialog_proLabels_itemSeven.tr(), - icon: FlowySvgs.check_m, + label: LocaleKeys.settings_comparePlanDialog_proLabels_itemSix.tr(), + ), + _CellItem( + label: LocaleKeys.settings_comparePlanDialog_proLabels_itemFileUpload.tr(), ), ]; 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 index ebbc27d2d9ef1..24e35ce454bf5 100644 --- 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 @@ -1,7 +1,8 @@ -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/shared/flowy_error_page.dart'; import 'package:appflowy/util/int64_extension.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; @@ -19,7 +20,7 @@ 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/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../plugins/document/presentation/editor_plugins/openai/widgets/loading.dart'; @@ -74,9 +75,10 @@ class _SettingsPlanViewState extends State { if (state.error != null) { return Padding( padding: const EdgeInsets.all(16), - child: FlowyErrorPage.message( - state.error!.msg, - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + child: Center( + child: AppFlowyErrorPage( + error: state.error!, + ), ), ); } @@ -119,11 +121,7 @@ class _SettingsPlanViewState extends State { priceInfo: LocaleKeys .settings_planPage_planUsage_addons_aiMax_priceInfo .tr(), - billingInfo: LocaleKeys - .settings_planPage_planUsage_addons_aiMax_billingInfo - .tr( - args: [SubscriptionPlanPB.AiMax.priceMonthBilling], - ), + recommend: '', buttonText: state.subscriptionInfo.hasAIMax ? LocaleKeys .settings_planPage_planUsage_addons_activeLabel @@ -136,38 +134,46 @@ class _SettingsPlanViewState extends State { ), ), const HSpace(8), - Flexible( - child: _AddOnBox( - title: LocaleKeys - .settings_planPage_planUsage_addons_aiOnDevice_title - .tr(), - description: LocaleKeys - .settings_planPage_planUsage_addons_aiOnDevice_description - .tr(), - price: LocaleKeys - .settings_planPage_planUsage_addons_aiOnDevice_price - .tr( - args: [SubscriptionPlanPB.AiLocal.priceAnnualBilling], - ), - priceInfo: LocaleKeys - .settings_planPage_planUsage_addons_aiOnDevice_priceInfo - .tr(), - billingInfo: LocaleKeys - .settings_planPage_planUsage_addons_aiOnDevice_billingInfo - .tr( - args: [SubscriptionPlanPB.AiLocal.priceMonthBilling], + + // Currently, the AI Local tile is only available on macOS + // TODO(nathan): enable windows and linux + if (Platform.isMacOS) + Flexible( + child: _AddOnBox( + title: LocaleKeys + .settings_planPage_planUsage_addons_aiOnDevice_title + .tr(), + description: LocaleKeys + .settings_planPage_planUsage_addons_aiOnDevice_description + .tr(), + price: LocaleKeys + .settings_planPage_planUsage_addons_aiOnDevice_price + .tr( + args: [ + SubscriptionPlanPB.AiLocal.priceAnnualBilling, + ], + ), + priceInfo: LocaleKeys + .settings_planPage_planUsage_addons_aiOnDevice_priceInfo + .tr(), + recommend: LocaleKeys + .settings_planPage_planUsage_addons_aiOnDevice_recommend + .tr( + args: [ + SubscriptionPlanPB.AiLocal.priceMonthBilling, + ], + ), + buttonText: state.subscriptionInfo.hasAIOnDevice + ? LocaleKeys + .settings_planPage_planUsage_addons_activeLabel + .tr() + : LocaleKeys + .settings_planPage_planUsage_addons_addLabel + .tr(), + isActive: state.subscriptionInfo.hasAIOnDevice, + plan: SubscriptionPlanPB.AiLocal, ), - buttonText: state.subscriptionInfo.hasAIOnDevice - ? LocaleKeys - .settings_planPage_planUsage_addons_activeLabel - .tr() - : LocaleKeys - .settings_planPage_planUsage_addons_addLabel - .tr(), - isActive: state.subscriptionInfo.hasAIOnDevice, - plan: SubscriptionPlanPB.AiLocal, ), - ), ], ), ], @@ -232,7 +238,7 @@ class _CurrentPlanBoxState extends State<_CurrentPlanBox> { const VSpace(8), FlowyText.regular( widget.subscriptionInfo.info, - fontSize: 16, + fontSize: 14, color: AFThemeExtension.of(context).strongText, maxLines: 3, ), @@ -644,7 +650,7 @@ class _AddOnBox extends StatelessWidget { required this.description, required this.price, required this.priceInfo, - required this.billingInfo, + required this.recommend, required this.buttonText, required this.isActive, required this.plan, @@ -654,7 +660,7 @@ class _AddOnBox extends StatelessWidget { final String description; final String price; final String priceInfo; - final String billingInfo; + final String recommend; final String buttonText; final bool isActive; final SubscriptionPlanPB plan; @@ -707,7 +713,7 @@ class _AddOnBox extends StatelessWidget { children: [ Expanded( child: FlowyText( - billingInfo, + recommend, color: AFThemeExtension.of(context).secondaryTextColor, fontSize: 11, 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 index 972e818b55233..6860a811d6ace 100644 --- 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 @@ -1,6 +1,3 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/align_toolbar_item/custom_text_align_command.dart'; @@ -23,7 +20,8 @@ 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'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SettingsShortcutsView extends StatefulWidget { 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 index 7119761694752..7758a375b76f7 100644 --- 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 @@ -357,6 +357,7 @@ class _WorkspaceIconSetting extends StatelessWidget { workspace: workspace!, iconSize: workspace!.icon.isNotEmpty == true ? 46 : 20, fontSize: 16.0, + figmaLineHeight: 46, enableEdit: true, onSelected: (r) => context .read() @@ -879,16 +880,7 @@ class _FontSelectorDropdownState extends State<_FontSelectorDropdown> { maxHeight: 150, maxWidth: constraints.maxWidth - 90, ), - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - borderRadius: const BorderRadius.all(Radius.circular(4.0)), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.10), - blurRadius: 6, - ), - ], - ), + borderRadius: const BorderRadius.all(Radius.circular(4.0)), popupBuilder: (_) => _FontListPopup( currentFont: appearance.font, scrollController: _scrollController, 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 40ca1c48161ac..c633aa3577d8f 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.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/settings_account_view.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'; @@ -25,7 +25,7 @@ class SettingsDialog extends StatelessWidget { required this.dismissDialog, required this.didLogout, required this.restartApp, - this.initPage, + this.initPage, }) : super(key: ValueKey(user.id)); final VoidCallback dismissDialog; @@ -39,6 +39,7 @@ class SettingsDialog extends StatelessWidget { return BlocProvider( create: (context) => SettingsDialogBloc( user, + context.read().state.currentWorkspaceMember, initPage: initPage, )..add(const SettingsDialogEvent.initial()), child: BlocBuilder( @@ -60,6 +61,7 @@ class SettingsDialog extends StatelessWidget { .add(SettingsDialogEvent.setSelectedPage(index)), currentPage: context.read().state.page, + isBillingEnabled: state.isBillingEnabled, member: context .read() .state @@ -118,7 +120,11 @@ class SettingsDialog extends StatelessWidget { return const SettingsShortcutsView(); case SettingsPage.ai: if (user.authenticator == AuthenticatorPB.AppFlowyCloud) { - return SettingsAIView(userProfile: user); + return SettingsAIView( + userProfile: user, + member: member, + workspaceId: workspaceId, + ); } else { return const AIFeatureOnlySupportedWhenUsingAppFlowyCloud(); } 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 index 919db8329b6c9..0d59dc7daa330 100644 --- 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 @@ -13,7 +13,7 @@ DropdownMenuEntry buildDropdownMenuEntry( Widget? leadingWidget, Widget? trailingWidget, String? fontFamily, - EdgeInsets padding = const EdgeInsets.symmetric(vertical: 4), + EdgeInsets padding = const EdgeInsets.symmetric(vertical: 6), }) { final fontFamilyUsed = fontFamily != null ? getGoogleFontSafely(fontFamily).fontFamily ?? defaultFontFamily 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 34f25dd41e88d..8ec9c72510ac4 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 @@ -2,7 +2,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:flowy_infra_ui/widget/flowy_tooltip.dart'; /// Renders a simple category taking a title and the list /// of children (settings) to be rendered. 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 a41e98dfc828c..d5a81655a5ec1 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 /// 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 646f712c3a0b9..a356e3fd50e63 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,10 +1,9 @@ -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, @@ -113,6 +112,7 @@ class SingleSettingAction extends StatelessWidget { fontSize: 12, isDangerous: buttonType.isDangerous, onPressed: onPressed, + lineHeight: 1.0, ), ), ], @@ -127,8 +127,12 @@ class SingleSettingAction extends StatelessWidget { } Color? hoverColor(BuildContext context) { + if (buttonType.isDangerous) { + return Theme.of(context).colorScheme.error.withOpacity(0.1); + } + if (buttonType.isPrimary) { - return const Color(0xFF005483); + return Theme.of(context).colorScheme.primary.withOpacity(0.9); } if (buttonType.isHighlight) { @@ -146,7 +150,7 @@ class SingleSettingAction extends StatelessWidget { return const Color(0xFF5C3699); } - return null; + return Theme.of(context).colorScheme.onPrimary; } Color? fontHoverColor(BuildContext context) { 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 13c9e80bd90d6..ee73768c9dd5b 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,10 +1,9 @@ -import 'package:flutter/material.dart'; - 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'; class RestartButton extends StatelessWidget { const RestartButton({ @@ -40,15 +39,12 @@ class RestartButton extends StatelessWidget { children: [ SizedBox( height: 42, - child: FlowyTextButton( - LocaleKeys.settings_menu_restartApp.tr(), - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + child: PrimaryRoundedButton( + text: LocaleKeys.settings_menu_restartApp.tr(), + margin: const EdgeInsets.symmetric(horizontal: 24), fontWeight: FontWeight.w600, - radius: BorderRadius.circular(12), - fillColor: Theme.of(context).colorScheme.primary, - hoverColor: const Color(0xFF005483), - fontHoverColor: Colors.white, - onPressed: onClick, + radius: 12.0, + onTap: onClick, ), ), ], @@ -71,8 +67,8 @@ class RestartButton extends StatelessWidget { // ], // ); } 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 0000000000000..e60629257209c --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/cancel_plan_survey_dialog.dart @@ -0,0 +1,431 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; + +Future showCancelSurveyDialog(BuildContext context) { + return showDialog( + context: context, + builder: (_) => const _Survey(), + ); +} + +class _Survey extends StatefulWidget { + const _Survey(); + + @override + State<_Survey> createState() => _SurveyState(); +} + +class _SurveyState extends State<_Survey> { + final PageController pageController = PageController(); + final Map answers = {}; + + @override + void dispose() { + pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + child: SizedBox( + width: 674, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 20), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Survey title + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: FlowyText( + LocaleKeys.settings_cancelSurveyDialog_title.tr(), + fontSize: 22.0, + overflow: TextOverflow.ellipsis, + color: AFThemeExtension.of(context).strongText, + ), + ), + FlowyButton( + useIntrinsicWidth: true, + text: const FlowySvg(FlowySvgs.upgrade_close_s), + onTap: () => Navigator.of(context).pop(), + ), + ], + ), + const VSpace(12), + // Survey explanation + FlowyText( + LocaleKeys.settings_cancelSurveyDialog_description.tr(), + maxLines: 3, + ), + const VSpace(8), + const Divider(), + const VSpace(8), + // Question "sheet" + SizedBox( + height: 400, + width: 650, + child: PageView.builder( + controller: pageController, + itemCount: _questionsAndAnswers.length, + itemBuilder: (context, index) => _QAPage( + qa: _questionsAndAnswers[index], + isFirstQuestion: index == 0, + isFinalQuestion: + index == _questionsAndAnswers.length - 1, + selectedAnswer: + answers[_questionsAndAnswers[index].question], + onPrevious: () { + if (index > 0) { + pageController.animateToPage( + index - 1, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + }, + onAnswerChanged: (answer) { + answers[_questionsAndAnswers[index].question] = + answer; + }, + onAnswerSelected: (answer) { + answers[_questionsAndAnswers[index].question] = + answer; + + if (index == _questionsAndAnswers.length - 1) { + Navigator.of(context).pop(jsonEncode(answers)); + } else { + pageController.animateToPage( + index + 1, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + }, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class _QAPage extends StatefulWidget { + const _QAPage({ + required this.qa, + required this.onAnswerSelected, + required this.onAnswerChanged, + required this.onPrevious, + this.selectedAnswer, + this.isFirstQuestion = false, + this.isFinalQuestion = false, + }); + + final _QA qa; + final String? selectedAnswer; + + /// Called when "Next" is pressed + /// + final Function(String) onAnswerSelected; + + /// Called whenever an answer is selected or changed + /// + final Function(String) onAnswerChanged; + final VoidCallback onPrevious; + final bool isFirstQuestion; + final bool isFinalQuestion; + + @override + State<_QAPage> createState() => _QAPageState(); +} + +class _QAPageState extends State<_QAPage> { + final otherController = TextEditingController(); + + int _selectedIndex = -1; + String? answer; + + @override + void initState() { + super.initState(); + if (widget.selectedAnswer != null) { + answer = widget.selectedAnswer; + _selectedIndex = widget.qa.answers.indexOf(widget.selectedAnswer!); + if (_selectedIndex == -1) { + // We assume the last question is "Other" + _selectedIndex = widget.qa.answers.length - 1; + otherController.text = widget.selectedAnswer!; + } + } + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText( + widget.qa.question, + fontSize: 16.0, + color: AFThemeExtension.of(context).strongText, + ), + const VSpace(18), + SeparatedColumn( + separatorBuilder: () => const VSpace(6), + crossAxisAlignment: CrossAxisAlignment.start, + children: widget.qa.answers + .mapIndexed( + (index, option) => _AnswerOption( + prefix: _indexToLetter(index), + option: option, + isSelected: _selectedIndex == index, + onTap: () => setState(() { + _selectedIndex = index; + if (_selectedIndex == widget.qa.answers.length - 1 && + widget.qa.lastIsOther) { + answer = otherController.text; + } else { + answer = option; + } + widget.onAnswerChanged(option); + }), + ), + ) + .toList(), + ), + if (widget.qa.lastIsOther && + _selectedIndex == widget.qa.answers.length - 1) ...[ + const VSpace(8), + FlowyTextField( + controller: otherController, + hintText: LocaleKeys.settings_cancelSurveyDialog_otherHint.tr(), + onChanged: (value) => setState(() { + answer = value; + widget.onAnswerChanged(value); + }), + ), + ], + const VSpace(20), + Row( + children: [ + if (!widget.isFirstQuestion) ...[ + DecoratedBox( + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + side: const BorderSide(color: Color(0x1E14171B)), + borderRadius: BorderRadius.circular(8), + ), + ), + child: FlowyButton( + useIntrinsicWidth: true, + margin: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 9.0, + ), + text: FlowyText.regular(LocaleKeys.button_previous.tr()), + onTap: widget.onPrevious, + ), + ), + const HSpace(12.0), + ], + DecoratedBox( + decoration: ShapeDecoration( + color: Theme.of(context).colorScheme.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: FlowyButton( + useIntrinsicWidth: true, + margin: + const EdgeInsets.symmetric(horizontal: 16.0, vertical: 9.0), + radius: BorderRadius.circular(8), + text: FlowyText.regular( + widget.isFinalQuestion + ? LocaleKeys.button_submit.tr() + : LocaleKeys.button_next.tr(), + color: Colors.white, + ), + disable: !canProceed(), + onTap: canProceed() + ? () => widget.onAnswerSelected( + answer ?? widget.qa.answers[_selectedIndex], + ) + : null, + ), + ), + ], + ), + ], + ); + } + + bool canProceed() { + if (_selectedIndex == widget.qa.answers.length - 1 && + widget.qa.lastIsOther) { + return answer != null && + answer!.isNotEmpty && + answer != LocaleKeys.settings_cancelSurveyDialog_commonOther.tr(); + } + + return _selectedIndex != -1; + } +} + +class _AnswerOption extends StatelessWidget { + const _AnswerOption({ + required this.prefix, + required this.option, + required this.onTap, + this.isSelected = false, + }); + + final String prefix; + final String option; + final VoidCallback onTap; + final bool isSelected; + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + borderRadius: Corners.s8Border, + border: Border.all( + width: 2, + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).dividerColor, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const HSpace(2), + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).dividerColor, + borderRadius: Corners.s6Border, + ), + child: Center( + child: FlowyText( + prefix, + color: isSelected ? Colors.white : null, + ), + ), + ), + const HSpace(8), + FlowyText( + option, + fontWeight: FontWeight.w400, + fontSize: 16.0, + color: AFThemeExtension.of(context).strongText, + ), + const HSpace(6), + ], + ), + ), + ), + ); + } +} + +final _questionsAndAnswers = [ + _QA( + question: LocaleKeys.settings_cancelSurveyDialog_questionOne_question.tr(), + answers: [ + LocaleKeys.settings_cancelSurveyDialog_questionOne_answerOne.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionOne_answerTwo.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionOne_answerThree.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionOne_answerFour.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionOne_answerFive.tr(), + LocaleKeys.settings_cancelSurveyDialog_commonOther.tr(), + ], + lastIsOther: true, + ), + _QA( + question: LocaleKeys.settings_cancelSurveyDialog_questionTwo_question.tr(), + answers: [ + LocaleKeys.settings_cancelSurveyDialog_questionTwo_answerOne.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionTwo_answerTwo.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionTwo_answerThree.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionTwo_answerFour.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionTwo_answerFive.tr(), + ], + ), + _QA( + question: + LocaleKeys.settings_cancelSurveyDialog_questionThree_question.tr(), + answers: [ + LocaleKeys.settings_cancelSurveyDialog_questionThree_answerOne.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionThree_answerTwo.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionThree_answerThree.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionThree_answerFour.tr(), + LocaleKeys.settings_cancelSurveyDialog_commonOther.tr(), + ], + lastIsOther: true, + ), + _QA( + question: LocaleKeys.settings_cancelSurveyDialog_questionFour_question.tr(), + answers: [ + LocaleKeys.settings_cancelSurveyDialog_questionFour_answerOne.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionFour_answerTwo.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionFour_answerThree.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionFour_answerFour.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionFour_answerFive.tr(), + ], + ), +]; + +class _QA { + const _QA({ + required this.question, + required this.answers, + this.lastIsOther = false, + }); + + final String question; + final List answers; + final bool lastIsOther; +} + +/// Returns the letter corresponding to the index. +/// +/// Eg. 0 -> A, 1 -> B, 2 -> C, ..., and so forth. +/// +String _indexToLetter(int index) { + return String.fromCharCode(65 + index); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_page.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_page.dart index ae4cc082b68ba..8d61254008a77 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,5 +1,3 @@ -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'; @@ -10,14 +8,14 @@ import 'package:appflowy/workspace/presentation/settings/shared/settings_categor 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'; @@ -36,7 +34,8 @@ class WorkspaceMembersPage extends StatelessWidget { return BlocProvider( create: (context) => WorkspaceMemberBloc(userProfile: userProfile) ..add(const WorkspaceMemberEvent.initial()), - child: BlocBuilder( + child: BlocConsumer( + listener: _showResultDialog, builder: (context, state) { return SettingsBody( title: LocaleKeys.settings_appearance_members_title.tr(), @@ -72,8 +71,6 @@ class WorkspaceMembersPage extends StatelessWidget { final actionResult = state.actionResult!.result; final actionType = state.actionResult!.actionType; - debugPrint("Plan: ${state.subscriptionInfo?.plan}"); - if (actionType == WorkspaceMemberActionType.invite && actionResult.isFailure) { final error = actionResult.getFailure().code; @@ -163,6 +160,63 @@ class WorkspaceMembersPage extends StatelessWidget { return const SizedBox.shrink(); } + + void _showResultDialog(BuildContext context, WorkspaceMemberState state) { + final actionResult = state.actionResult; + if (actionResult == null) { + return; + } + + final actionType = actionResult.actionType; + final result = actionResult.result; + + // only show the result dialog when the action is WorkspaceMemberActionType.add + if (actionType == WorkspaceMemberActionType.add) { + result.fold( + (s) { + showSnackBarMessage( + context, + LocaleKeys.settings_appearance_members_addMemberSuccess.tr(), + ); + }, + (f) { + Log.error('add workspace member failed: $f'); + final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded + ? LocaleKeys.settings_appearance_members_memberLimitExceeded.tr() + : LocaleKeys.settings_appearance_members_failedToAddMember.tr(); + showDialog( + context: context, + builder: (context) => NavigatorOkCancelDialog(message: message), + ); + }, + ); + } else if (actionType == WorkspaceMemberActionType.invite) { + result.fold( + (s) { + showSnackBarMessage( + context, + LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(), + ); + }, + (f) { + Log.error('invite workspace member failed: $f'); + final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded + ? LocaleKeys.settings_appearance_members_inviteFailedMemberLimit + .tr() + : LocaleKeys.settings_appearance_members_failedToInviteMember + .tr(); + showConfirmDialog( + context: context, + title: LocaleKeys + .settings_appearance_members_inviteFailedDialogTitle + .tr(), + description: message, + confirmLabel: LocaleKeys.button_ok.tr(), + ); + }, + ); + } + } } class _InviteMember extends StatefulWidget { @@ -211,11 +265,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 8716c8ca89229..4ef6e068b4d78 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,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -11,7 +12,9 @@ import 'package:appflowy/workspace/presentation/settings/widgets/_restart_app_bu 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'; @@ -327,3 +330,51 @@ class AppFlowyCloudEnableSync extends StatelessWidget { ); } } + +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 7191e2cc9de60..1b8248d3761b6 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 @@ -22,7 +22,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'setting_appflowy_cloud.dart'; -import 'setting_supabase_cloud.dart'; class SettingCloud extends StatelessWidget { const SettingCloud({required this.restartAppFlowy, super.key}); @@ -80,8 +79,6 @@ class SettingCloud extends StatelessWidget { switch (cloudType) { case AuthenticatorType.local: return SettingLocalCloud(restartAppFlowy: restartAppFlowy); - case AuthenticatorType.supabase: - return SettingSupabaseCloudView(restartAppFlowy: restartAppFlowy); case AuthenticatorType.appflowyCloud: return AppFlowyCloudViewSetting(restartAppFlowy: restartAppFlowy); case AuthenticatorType.appflowyCloudSelfHost: @@ -112,9 +109,6 @@ class CloudTypeSwitcher extends StatelessWidget { // 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(); @@ -218,8 +212,6 @@ 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 e54de4d9c5e19..0000000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_supabase_cloud.dart +++ /dev/null @@ -1,340 +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/theme_extension.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 final _controller = TextEditingController(text: widget.url); - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return TextField( - controller: _controller, - style: const TextStyle(fontSize: 12.0), - decoration: InputDecoration( - contentPadding: const EdgeInsets.symmetric(vertical: 6), - labelText: widget.title, - labelStyle: Theme.of(context) - .textTheme - .titleMedium! - .copyWith(fontWeight: FontWeight.w400, fontSize: 16), - enabledBorder: UnderlineInputBorder( - borderSide: - BorderSide(color: AFThemeExtension.of(context).onBackground), - ), - focusedBorder: UnderlineInputBorder( - borderSide: BorderSide(color: Theme.of(context).colorScheme.primary), - ), - hintText: widget.hint, - errorText: 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/settings_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart index 7ccd1a67c320f..38cd49831db1d 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 @@ -16,12 +16,14 @@ class SettingsMenu extends StatelessWidget { required this.changeSelectedPage, required this.currentPage, required this.userProfile, + required this.isBillingEnabled, this.member, }); final Function changeSelectedPage; final SettingsPage currentPage; final UserProfilePB userProfile; + final bool isBillingEnabled; final WorkspaceMemberPB? member; @override @@ -111,26 +113,22 @@ class SettingsMenu extends StatelessWidget { ), changeSelectedPage: changeSelectedPage, ), - // if (FeatureFlag.planBilling.isOn && - // userProfile.authenticator == - // AuthenticatorPB.AppFlowyCloud && - // member != null && - // member!.role.isOwner) ...[ - // 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 (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 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 index 12714e04dfae9..5047b73e634f6 100644 --- 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 @@ -1,6 +1,3 @@ -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'; @@ -13,8 +10,9 @@ import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/mobi 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:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; class MobileAppFlowyDatePicker extends StatefulWidget { @@ -389,57 +387,107 @@ class _IncludeTimePickerState extends State<_IncludeTimePicker> { children.addAll([ Expanded(child: FlowyText(dateStr, textAlign: TextAlign.center)), Container(width: 1, height: 16, color: Colors.grey), - Expanded(child: FlowyText(timeStr ?? '', textAlign: TextAlign.center)), + Expanded( + child: GestureDetector( + onTap: () => _showTimePicker( + context, + use24hFormat: use24hFormat, + isStartDay: isStartDay, + ), + 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); + 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), + ); + } - 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, + Future _showTimePicker( + BuildContext context, { + required bool use24hFormat, + required bool isStartDay, + }) async { + String? selectedTime = isStartDay ? _timeStr : _endTimeStr; + final initialDateTime = selectedTime != null + ? _convertTimeStringToDateTime(selectedTime) + : null; + + return showMobileBottomSheet( + context, + builder: (context) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 300), + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.time, + initialDateTime: initialDateTime, + use24hFormat: use24hFormat, + onDateTimeChanged: (dateTime) { + selectedTime = use24hFormat + ? DateFormat('HH:mm').format(dateTime) + : DateFormat('hh:mm a').format(dateTime); + }, + ), ), - ), - child: Row(children: children), + 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: () { + if (isStartDay) { + widget.onStartTimeChanged(selectedTime); + + if (widget.rebuildOnTimeChanged && mounted) { + setState(() => _timeStr = selectedTime); + } + } else { + widget.onEndTimeChanged?.call(selectedTime); + + if (widget.rebuildOnTimeChanged && mounted) { + setState(() => _endTimeStr = selectedTime); + } + } + + Navigator.of(context).pop(); + }, + ), + ), + const VSpace(18.0), + ], ), ); } + + DateTime _convertTimeStringToDateTime(String timeString) { + final DateTime now = DateTime.now(); + + final List timeParts = timeString.split(':'); + + if (timeParts.length != 2) { + return now; + } + + final int hour = int.parse(timeParts[0]); + final int minute = int.parse(timeParts[1]); + + return DateTime(now.year, now.month, now.day, hour, minute); + } } class _EndDateSwitch extends StatelessWidget { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart index 188303c3f7a97..e993c5f79e949 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart @@ -1,8 +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/workspace/presentation/home/menu/sidebar/space/shared_widget.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'; @@ -11,6 +11,7 @@ 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'; export 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; @@ -96,6 +97,13 @@ class _NavigatorTextFieldDialogState extends State { VSpace(Insets.xl), OkCancelButton( onOkPressed: () { + if (newValue.isEmpty) { + showToastNotification( + context, + message: LocaleKeys.space_spaceNameCannotBeEmpty.tr(), + ); + return; + } widget.onConfirm(newValue, context); Navigator.of(context).pop(); }, @@ -302,7 +310,23 @@ void showToastNotification( required String message, String? description, ToastificationType type = ToastificationType.success, + ToastificationCallbacks? callbacks, + double bottomPadding = 100, }) { + if (PlatformExtension.isMobile) { + toastification.showCustom( + alignment: Alignment.bottomCenter, + autoCloseDuration: const Duration(milliseconds: 3000), + callbacks: callbacks ?? const ToastificationCallbacks(), + builder: (_, __) => _MToast( + message: message, + type: type, + bottomPadding: bottomPadding, + ), + ); + return; + } + toastification.show( context: context, type: type, @@ -329,6 +353,55 @@ void showToastNotification( ); } +class _MToast extends StatelessWidget { + const _MToast({ + required this.message, + this.type = ToastificationType.success, + this.bottomPadding = 100, + }); + + final String message; + final ToastificationType type; + final double bottomPadding; + + @override + Widget build(BuildContext context) { + final hintText = FlowyText.regular( + message, + fontSize: 16.0, + figmaLineHeight: 18.0, + color: Colors.white, + maxLines: 10, + ); + 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 + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (type == ToastificationType.success) ...[ + const FlowySvg( + FlowySvgs.success_s, + blendMode: null, + ), + const HSpace(8.0), + ], + Expanded(child: hintText), + ], + ) + : hintText, + ), + ); + } +} + Future showConfirmDeletionDialog({ required BuildContext context, required String name, @@ -361,6 +434,7 @@ Future showConfirmDialog({ required String title, required String description, VoidCallback? onConfirm, + VoidCallback? onCancel, String? confirmLabel, ConfirmPopupStyle style = ConfirmPopupStyle.onlyOk, }) { @@ -377,6 +451,7 @@ Future showConfirmDialog({ title: title, description: description, onConfirm: () => onConfirm?.call(), + onCancel: () => onCancel?.call(), confirmLabel: confirmLabel, style: style, ), @@ -414,3 +489,69 @@ Future showCancelAndConfirmDialog({ }, ); } + +Future showCustomConfirmDialog({ + required BuildContext context, + required String title, + required String description, + required Widget Function(BuildContext) builder, + VoidCallback? onConfirm, + String? confirmLabel, + ConfirmPopupStyle style = ConfirmPopupStyle.onlyOk, + bool closeOnConfirm = true, +}) { + return showDialog( + context: context, + builder: (context) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + child: SizedBox( + width: 440, + child: ConfirmPopup( + title: title, + description: description, + onConfirm: () => onConfirm?.call(), + confirmLabel: confirmLabel, + style: style, + closeOnAction: closeOnConfirm, + child: builder(context), + ), + ), + ); + }, + ); +} + +Future showCancelAndDeleteDialog({ + required BuildContext context, + required String title, + required String description, + required Widget Function(BuildContext) builder, + VoidCallback? onDelete, + 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: () => onDelete?.call(), + closeOnAction: false, + confirmLabel: confirmLabel, + confirmButtonColor: Theme.of(context).colorScheme.error, + child: builder(context), + ), + ), + ); + }, + ); +} 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 0f7a5d06230bc..d666e606f6829 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,24 +1,18 @@ import 'package:appflowy/core/helpers/url_launcher.dart'; -import 'package:appflowy/env/env.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/document/application/document_bloc.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/dialogs.dart'; +import 'package:appflowy/workspace/presentation/widgets/float_bubble/social_media_section.dart'; +import 'package:appflowy/workspace/presentation/widgets/float_bubble/version_section.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:styled_widget/styled_widget.dart'; class QuestionBubble extends StatelessWidget { const QuestionBubble({super.key}); @@ -26,7 +20,7 @@ class QuestionBubble extends StatelessWidget { @override Widget build(BuildContext context) { return const SizedBox.square( - dimension: 36.0, + dimension: 32.0, child: BubbleActionList(), ); } @@ -62,7 +56,9 @@ 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 @@ -83,6 +79,11 @@ class _BubbleActionListState extends State { direction: PopoverDirection.topWithRightAligned, actions: actions, offset: const Offset(0, -8), + constraints: const BoxConstraints( + minWidth: 200, + maxWidth: 460, + maxHeight: 400, + ), buildChild: (controller) { return FlowyTooltip( message: LocaleKeys.questionBubble_help.tr(), @@ -90,7 +91,7 @@ class _BubbleActionListState extends State { cursor: SystemMouseCursors.click, child: GestureDetector( child: Container( - padding: const EdgeInsets.all(10.0), + padding: const EdgeInsets.all(8.0), decoration: ShapeDecoration( color: color, shape: RoundedRectangleBorder( @@ -178,75 +179,21 @@ class _DebugToast { } } -class FlowyVersionDescription extends CustomActionCell { - @override - Widget buildWithContext(BuildContext context, PopoverController controller) { - return FutureBuilder( - future: PackageInfo.fromPlatform(), - builder: (BuildContext context, AsyncSnapshot snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - if (snapshot.hasError) { - return FlowyText( - "Error: ${snapshot.error}", - color: Theme.of(context).disabledColor, - ); - } - - final PackageInfo packageInfo = snapshot.data; - final String appName = packageInfo.appName; - final String version = packageInfo.version; - - return SizedBox( - height: 30, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Divider( - height: 1, - color: Theme.of(context).dividerColor, - thickness: 1.0, - ), - const VSpace(6), - GestureDetector( - behavior: HitTestBehavior.opaque, - onDoubleTap: () { - if (Env.internalBuild != '1' && !kDebugMode) { - return; - } - enableDocumentInternalLog = !enableDocumentInternalLog; - showToastNotification( - context, - message: enableDocumentInternalLog - ? 'Enabled Internal Log' - : 'Disabled Internal Log', - ); - }, - child: FlowyText( - '$appName $version', - color: Theme.of(context).hintColor, - ), - ), - ], - ).padding( - horizontal: ActionListSizes.itemHPadding, - ), - ); - } else { - return const SizedBox(height: 30); - } - }, - ); - } +enum BubbleAction { + whatsNews, + help, + 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; @@ -270,26 +217,20 @@ extension QuestionBubbleExtension on BubbleAction { } } - Widget get emoji { + Widget? get icons { switch (this) { case BubbleAction.whatsNews: - return const FlowyText.regular('🆕'); + return const FlowySvg(FlowySvgs.star_s); case BubbleAction.help: - return const FlowyText.regular('👥'); + return const FlowySvg(FlowySvgs.message_support_s); case BubbleAction.debug: - return const FlowyText.regular('🐛'); + return const FlowySvg(FlowySvgs.debug_s); case BubbleAction.shortcuts: - return const FlowyText.regular('📋'); + return const FlowySvg(FlowySvgs.keyboard_s); case BubbleAction.markdown: - return const FlowyText.regular('✨'); + return const FlowySvg(FlowySvgs.number_s); case BubbleAction.github: - return const Padding( - padding: EdgeInsets.all(3.0), - child: FlowySvg( - FlowySvgs.archive_m, - size: Size.square(12), - ), - ); + return const FlowySvg(FlowySvgs.share_feedback_s); } } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/social_media_section.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/social_media_section.dart new file mode 100644 index 0000000000000..d5b70cd233aca --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/social_media_section.dart @@ -0,0 +1,105 @@ +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) { + final List children = [ + Divider( + height: 1, + color: Theme.of(context).dividerColor, + thickness: 1.0, + ), + ]; + + children.addAll( + SocialMedia.values.map( + (social) { + return ActionCellWidget( + action: SocialMediaWrapper(social), + itemHeight: ActionListSizes.itemHeight, + onSelected: (action) { + switch (action.inner) { + case SocialMedia.reddit: + afLaunchUrlString( + 'https://www.reddit.com/r/AppFlowy/', + ); + case SocialMedia.twitter: + afLaunchUrlString( + 'https://x.com/appflowy', + ); + case SocialMedia.forum: + afLaunchUrlString('https://forum.appflowy.io/'); + } + }, + ); + }, + ), + ); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Column( + children: children, + ), + ); + } +} + +enum SocialMedia { forum, twitter, reddit } + +class SocialMediaWrapper extends ActionCell { + SocialMediaWrapper(this.inner); + + final SocialMedia inner; + @override + Widget? leftIcon(Color iconColor) => inner.icons; + + @override + String get name => inner.name; + + @override + Color? textColor(BuildContext context) => inner.textColor(context); +} + +extension QuestionBubbleExtension on SocialMedia { + Color? textColor(BuildContext context) { + switch (this) { + case SocialMedia.reddit: + return Theme.of(context).hintColor; + + case SocialMedia.twitter: + return Theme.of(context).hintColor; + + case SocialMedia.forum: + return Theme.of(context).hintColor; + + default: + return null; + } + } + + String get name { + switch (this) { + case SocialMedia.forum: + return "Community Forum"; + case SocialMedia.twitter: + return "Twitter – @appflowy"; + case SocialMedia.reddit: + return "Reddit – r/appflowy"; + } + } + + Widget? get icons { + switch (this) { + case SocialMedia.reddit: + return null; + case SocialMedia.twitter: + return null; + case SocialMedia.forum: + return null; + } + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/version_section.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/version_section.dart new file mode 100644 index 0000000000000..6363ee08e5ad7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/version_section.dart @@ -0,0 +1,73 @@ +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) { + return FutureBuilder( + future: PackageInfo.fromPlatform(), + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + if (snapshot.hasError) { + return FlowyText( + "Error: ${snapshot.error}", + color: Theme.of(context).disabledColor, + ); + } + + final PackageInfo packageInfo = snapshot.data; + final String appName = packageInfo.appName; + final String version = packageInfo.version; + + return SizedBox( + height: 30, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Divider( + height: 1, + color: Theme.of(context).dividerColor, + thickness: 1.0, + ), + const VSpace(6), + GestureDetector( + behavior: HitTestBehavior.opaque, + onDoubleTap: () { + if (Env.internalBuild != '1' && !kDebugMode) { + return; + } + enableDocumentInternalLog = !enableDocumentInternalLog; + showToastNotification( + context, + message: enableDocumentInternalLog + ? 'Enabled Internal Log' + : 'Disabled Internal Log', + ); + }, + child: FlowyText( + '$appName $version', + color: Theme.of(context).hintColor, + fontSize: 12, + ).padding( + horizontal: ActionListSizes.itemHPadding, + ), + ), + ], + ), + ); + } else { + return const SizedBox(height: 30); + } + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/image_provider.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/image_provider.dart new file mode 100644 index 0000000000000..359458c6e469b --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/image_provider.dart @@ -0,0 +1,67 @@ +import 'package:flutter/widgets.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'; + +/// Abstract class for providing images to the [InteractiveImageViewer]. +/// +abstract class AFImageProvider { + const AFImageProvider({this.onDeleteImage}); + + /// Provide this callback if you want it to be possible to + /// delete the Image through the [InteractiveImageViewer]. + /// + final Function(int index)? onDeleteImage; + + int get imageCount; + int get initialIndex; + + ImageBlockData getImage(int index); + Widget renderImage( + BuildContext context, + int index, [ + UserProfilePB? userProfile, + ]); +} + +class AFBlockImageProvider implements AFImageProvider { + const AFBlockImageProvider({ + required this.images, + this.initialIndex = 0, + required this.onDeleteImage, + }); + + final List images; + + @override + final Function(int) onDeleteImage; + + @override + final int initialIndex; + + @override + int get imageCount => images.length; + + @override + ImageBlockData getImage(int index) => images[index]; + + @override + Widget renderImage( + BuildContext context, + int index, [ + UserProfilePB? userProfile, + ]) { + final image = getImage(index); + + if (image.type == CustomImageType.local) { + return Image(image: image.toImageProvider()); + } + + return FlowyNetworkImage( + url: image.url, + userProfilePB: userProfile, + fit: BoxFit.contain, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart new file mode 100644 index 0000000000000..9897a0c55a1fa --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart @@ -0,0 +1,339 @@ +import 'dart:convert'; +import 'dart:io'; + +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/document/presentation/editor_plugins/image/common.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: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:http/http.dart' as http; +import 'package:path/path.dart'; + +class InteractiveImageToolbar extends StatelessWidget { + const InteractiveImageToolbar({ + super.key, + required this.currentImage, + required this.imageCount, + required this.isFirstIndex, + required this.isLastIndex, + required this.currentScale, + required this.onPrevious, + required this.onNext, + required this.onZoomIn, + required this.onZoomOut, + required this.onScaleChanged, + this.onDelete, + this.userProfile, + }); + + final ImageBlockData currentImage; + final int imageCount; + final bool isFirstIndex; + final bool isLastIndex; + final int currentScale; + + final VoidCallback onPrevious; + final VoidCallback onNext; + final VoidCallback onZoomIn; + final VoidCallback onZoomOut; + final Function(double scale) onScaleChanged; + final UserProfilePB? userProfile; + final VoidCallback? onDelete; + + @override + Widget build(BuildContext context) { + return Positioned( + bottom: 16, + left: 0, + right: 0, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: SizedBox( + width: 200, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (imageCount > 1) + _renderToolbarItems( + children: [ + _ToolbarItem( + isDisabled: isFirstIndex, + tooltip: LocaleKeys + .document_imageBlock_interactiveViewer_toolbar_previousImageTooltip + .tr(), + icon: FlowySvgs.arrow_left_s, + onTap: () { + if (!isFirstIndex) { + onPrevious(); + } + }, + ), + _ToolbarItem( + isDisabled: isLastIndex, + tooltip: LocaleKeys + .document_imageBlock_interactiveViewer_toolbar_nextImageTooltip + .tr(), + icon: FlowySvgs.arrow_right_s, + onTap: () { + if (!isLastIndex) { + onNext(); + } + }, + ), + ], + ), + const HSpace(10), + _renderToolbarItems( + children: [ + _ToolbarItem( + tooltip: LocaleKeys + .document_imageBlock_interactiveViewer_toolbar_zoomOutTooltip + .tr(), + icon: FlowySvgs.minus_s, + onTap: onZoomOut, + ), + AppFlowyPopover( + offset: const Offset(0, -8), + decorationColor: Colors.transparent, + direction: PopoverDirection.topWithCenterAligned, + constraints: const BoxConstraints(maxHeight: 50), + popupBuilder: (context) => _renderToolbarItems( + children: [ + _ScaleSlider( + currentScale: currentScale, + onScaleChanged: onScaleChanged, + ), + ], + ), + child: FlowyTooltip( + message: LocaleKeys + .document_imageBlock_interactiveViewer_toolbar_changeZoomLevelTooltip + .tr(), + child: FlowyHover( + resetHoverOnRebuild: false, + style: HoverStyle( + hoverColor: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Padding( + padding: const EdgeInsets.all(6), + child: SizedBox( + width: 40, + child: Center( + child: FlowyText( + LocaleKeys + .document_imageBlock_interactiveViewer_toolbar_scalePercentage + .tr(args: [currentScale.toString()]), + color: Colors.white, + ), + ), + ), + ), + ), + ), + ), + _ToolbarItem( + tooltip: LocaleKeys + .document_imageBlock_interactiveViewer_toolbar_zoomInTooltip + .tr(), + icon: FlowySvgs.add_s, + onTap: onZoomIn, + ), + ], + ), + const HSpace(10), + _renderToolbarItems( + children: [ + if (onDelete != null) + _ToolbarItem( + tooltip: LocaleKeys + .document_imageBlock_interactiveViewer_toolbar_deleteImageTooltip + .tr(), + icon: FlowySvgs.delete_s, + onTap: () { + onDelete!(); + Navigator.of(context).pop(); + }, + ), + if (!PlatformExtension.isMobile) ...[ + _ToolbarItem( + tooltip: currentImage.isNotInternal + ? LocaleKeys + .document_imageBlock_interactiveViewer_toolbar_openLocalImage + .tr() + : LocaleKeys + .document_imageBlock_interactiveViewer_toolbar_downloadImage + .tr(), + icon: currentImage.isNotInternal + ? currentImage.isLocal + ? FlowySvgs.folder_m + : FlowySvgs.m_aa_link_s + : FlowySvgs.download_s, + onTap: () => _locateOrDownloadImage(context), + ), + ], + ], + ), + const HSpace(10), + _renderToolbarItems( + children: [ + _ToolbarItem( + tooltip: LocaleKeys + .document_imageBlock_interactiveViewer_toolbar_closeViewer + .tr(), + icon: FlowySvgs.close_viewer_s, + onTap: () => Navigator.of(context).pop(), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _renderToolbarItems({required List children}) { + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: Colors.black.withOpacity(0.6), + ), + child: Padding( + padding: const EdgeInsets.all(4), + child: SeparatedRow( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const HSpace(4), + children: children, + ), + ), + ); + } + + Future _locateOrDownloadImage(BuildContext context) async { + if (currentImage.isLocal) { + /// If the image type is local, we simply open the image + await afLaunchUrl(Uri.file(currentImage.url)); + } else if (currentImage.isNotInternal) { + // 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 afLaunchUrl(Uri.parse(currentImage.url)); + } else { + if (userProfile == null) { + return showSnapBar( + context, + LocaleKeys.document_plugins_image_imageDownloadFailedToken.tr(), + ); + } + + final uri = Uri.parse(currentImage.url); + final imgFile = File(uri.pathSegments.last); + final savePath = await FilePicker().saveFile( + fileName: basename(imgFile.path), + ); + + if (savePath != null) { + final uri = Uri.parse(currentImage.url); + + final token = jsonDecode(userProfile!.token)['access_token']; + final response = await http.get( + uri, + headers: {'Authorization': 'Bearer $token'}, + ); + if (response.statusCode == 200) { + final imgFile = File(savePath); + await imgFile.writeAsBytes(response.bodyBytes); + } else if (context.mounted) { + showSnapBar( + context, + LocaleKeys.document_plugins_image_imageDownloadFailed.tr(), + ); + } + } + } + } +} + +class _ToolbarItem extends StatelessWidget { + const _ToolbarItem({ + required this.tooltip, + required this.icon, + required this.onTap, + this.isDisabled = false, + }); + + final String tooltip; + final FlowySvgData icon; + final VoidCallback onTap; + final bool isDisabled; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: FlowyTooltip( + message: tooltip, + child: FlowyHover( + resetHoverOnRebuild: false, + style: HoverStyle( + hoverColor: + isDisabled ? Colors.transparent : Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Container( + width: 32, + height: 32, + padding: const EdgeInsets.all(8), + child: FlowySvg( + icon, + color: isDisabled ? Colors.grey : Colors.white, + ), + ), + ), + ), + ); + } +} + +class _ScaleSlider extends StatefulWidget { + const _ScaleSlider({ + required this.currentScale, + required this.onScaleChanged, + }); + + final int currentScale; + final Function(double scale) onScaleChanged; + + @override + State<_ScaleSlider> createState() => __ScaleSliderState(); +} + +class __ScaleSliderState extends State<_ScaleSlider> { + late int _currentScale = widget.currentScale; + + @override + Widget build(BuildContext context) { + return Slider( + max: 5.0, + min: 0.5, + value: _currentScale / 100, + onChanged: (scale) { + widget.onScaleChanged(scale); + setState( + () => _currentScale = (scale * 100).toInt(), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart new file mode 100644 index 0000000000000..7ea37e3ce2e32 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart @@ -0,0 +1,213 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:appflowy/plugins/database/widgets/cell_editor/media_cell_editor.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:provider/provider.dart'; + +const double _minScaleFactor = .5; +const double _maxScaleFactor = 5; + +class InteractiveImageViewer extends StatefulWidget { + const InteractiveImageViewer({ + super.key, + this.userProfile, + required this.imageProvider, + }); + + final UserProfilePB? userProfile; + final AFImageProvider imageProvider; + + @override + State createState() => _InteractiveImageViewerState(); +} + +class _InteractiveImageViewerState extends State { + final TransformationController controller = TransformationController(); + final focusNode = FocusNode(); + + int currentScale = 100; + late int currentIndex = widget.imageProvider.initialIndex; + + bool get isLastIndex => currentIndex == widget.imageProvider.imageCount - 1; + bool get isFirstIndex => currentIndex == 0; + + late ImageBlockData currentImage; + + UserProfilePB? userProfile; + + @override + void initState() { + super.initState(); + controller.addListener(_onControllerChanged); + currentImage = widget.imageProvider.getImage(currentIndex); + userProfile = + widget.userProfile ?? context.read().state.userProfilePB; + focusNode.requestFocus(); + } + + void _onControllerChanged() { + final scale = controller.value.getMaxScaleOnAxis(); + final percentage = (scale * 100).toInt(); + setState(() => currentScale = percentage); + } + + @override + void dispose() { + controller.removeListener(_onControllerChanged); + controller.dispose(); + focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + + return KeyboardListener( + focusNode: focusNode, + onKeyEvent: (event) { + if (event is! KeyDownEvent) { + return; + } + + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + _move(-1); + } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + _move(1); + } else if ([ + LogicalKeyboardKey.add, + LogicalKeyboardKey.numpadAdd, + ].contains(event.logicalKey)) { + _zoom(1.1, size); + } else if ([ + LogicalKeyboardKey.minus, + LogicalKeyboardKey.numpadSubtract, + ].contains(event.logicalKey)) { + _zoom(.9, size); + } else if ([ + LogicalKeyboardKey.numpad0, + LogicalKeyboardKey.digit0, + ].contains(event.logicalKey)) { + controller.value = Matrix4.identity(); + _onControllerChanged(); + } + }, + child: Stack( + fit: StackFit.expand, + children: [ + SizedBox.expand( + child: InteractiveViewer( + boundaryMargin: const EdgeInsets.all(double.infinity), + transformationController: controller, + constrained: false, + minScale: _minScaleFactor, + maxScale: _maxScaleFactor, + scaleFactor: 500, + child: SizedBox( + height: size.height, + width: size.width, + child: GestureDetector( + // We can consider adding zoom behavior instead in a later iteration + onDoubleTap: () => Navigator.of(context).pop(), + child: widget.imageProvider.renderImage( + context, + currentIndex, + userProfile, + ), + ), + ), + ), + ), + InteractiveImageToolbar( + currentImage: currentImage, + imageCount: widget.imageProvider.imageCount, + isFirstIndex: isFirstIndex, + isLastIndex: isLastIndex, + currentScale: currentScale, + userProfile: userProfile, + onPrevious: () => _move(-1), + onNext: () => _move(1), + onZoomIn: () => _zoom(1.1, size), + onZoomOut: () => _zoom(.9, size), + onScaleChanged: (scale) { + final currentScale = controller.value.getMaxScaleOnAxis(); + final scaleStep = scale / currentScale; + _zoom(scaleStep, size); + }, + onDelete: () => + widget.imageProvider.onDeleteImage?.call(currentIndex), + ), + ], + ), + ); + } + + void _move(int steps) { + setState(() { + final index = currentIndex + steps; + currentIndex = index.clamp(0, widget.imageProvider.imageCount - 1); + currentImage = widget.imageProvider.getImage(currentIndex); + }); + } + + void _zoom(double scaleStep, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final scenePointBefore = controller.toScene(center); + final currentScale = controller.value.getMaxScaleOnAxis(); + final newScale = (currentScale * scaleStep).clamp( + _minScaleFactor, + _maxScaleFactor, + ); + + // Create a new transformation + final newMatrix = Matrix4.identity() + ..translate(scenePointBefore.dx, scenePointBefore.dy) + ..scale(newScale / currentScale) + ..translate(-scenePointBefore.dx, -scenePointBefore.dy); + + // Apply the new transformation + controller.value = newMatrix * controller.value; + + // Convert the center point to scene coordinates after scaling + final scenePointAfter = controller.toScene(center); + + // Compute difference to keep the same center point + final dx = scenePointAfter.dx - scenePointBefore.dx; + final dy = scenePointAfter.dy - scenePointBefore.dy; + + // Apply the translation + controller.value = Matrix4.identity() + ..translate(-dx, -dy) + ..multiply(controller.value); + + _onControllerChanged(); + } +} + +void openInteractiveViewerFromFile( + BuildContext context, + MediaFilePB file, { + required void Function(int) onDeleteImage, + UserProfilePB? userProfile, +}) => + showDialog( + context: context, + builder: (_) => InteractiveImageViewer( + userProfile: userProfile, + imageProvider: AFBlockImageProvider( + images: [ + ImageBlockData( + url: file.url, + type: file.uploadType.toCustomImageType(), + ), + ], + onDeleteImage: onDeleteImage, + ), + ), + ); 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 f9f46cfba937a..0a9dc6dd26a30 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 @@ -11,7 +11,6 @@ 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/hover.dart'; -import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; 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 379104b94a99c..5ce8ee88df9bc 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,8 +1,7 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy_popover/appflowy_popover.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:styled_widget/styled_widget.dart'; class PopoverActionList extends StatefulWidget { @@ -54,7 +53,6 @@ class _PopoverActionListState @override Widget build(BuildContext context) { final child = widget.buildChild(popoverController); - return AppFlowyPopover( asBarrier: widget.asBarrier, controller: popoverController, @@ -105,6 +103,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( @@ -160,6 +161,7 @@ class ActionCellWidget extends StatelessWidget { leftIcon: leftIcon, rightIcon: rightIcon, name: actionCell.name, + textColor: actionCell.textColor(context), onTap: () => onSelected(action), ); } @@ -223,6 +225,7 @@ class HoverButton extends StatelessWidget { this.leftIcon, required this.name, this.rightIcon, + this.textColor, }); final VoidCallback onTap; @@ -230,6 +233,7 @@ class HoverButton extends StatelessWidget { final Widget? leftIcon; final Widget? rightIcon; final String name; + final Color? textColor; @override Widget build(BuildContext context) { @@ -250,6 +254,7 @@ class HoverButton extends StatelessWidget { name, overflow: TextOverflow.visible, lineHeight: 1.15, + color: textColor, ), ), if (rightIcon != null) ...[ diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/sidebar_resizer.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/sidebar_resizer.dart new file mode 100644 index 0000000000000..f229951e04aab --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/sidebar_resizer.dart @@ -0,0 +1,84 @@ +import 'package:appflowy/workspace/application/home/home_setting_bloc.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SidebarResizer extends StatefulWidget { + const SidebarResizer({super.key}); + + @override + State createState() => _SidebarResizerState(); +} + +class _SidebarResizerState extends State { + final ValueNotifier isHovered = ValueNotifier(false); + final ValueNotifier isDragging = ValueNotifier(false); + + @override + void dispose() { + isHovered.dispose(); + isDragging.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.resizeLeftRight, + onEnter: (_) => isHovered.value = true, + onExit: (_) => isHovered.value = false, + child: GestureDetector( + dragStartBehavior: DragStartBehavior.down, + behavior: HitTestBehavior.translucent, + onHorizontalDragStart: (details) { + isDragging.value = true; + + context + .read() + .add(const HomeSettingEvent.editPanelResizeStart()); + }, + onHorizontalDragUpdate: (details) { + isDragging.value = true; + + context + .read() + .add(HomeSettingEvent.editPanelResized(details.localPosition.dx)); + }, + onHorizontalDragEnd: (details) { + isDragging.value = false; + + context + .read() + .add(const HomeSettingEvent.editPanelResizeEnd()); + }, + onHorizontalDragCancel: () { + isDragging.value = false; + + context + .read() + .add(const HomeSettingEvent.editPanelResizeEnd()); + }, + child: ValueListenableBuilder( + valueListenable: isHovered, + builder: (context, isHovered, _) { + return ValueListenableBuilder( + valueListenable: isDragging, + builder: (context, isDragging, _) { + return Container( + width: 2, + // increase the width of the resizer to make it easier to drag + margin: const EdgeInsets.only(right: 2.0), + height: MediaQuery.of(context).size.height, + color: isHovered || isDragging + ? const Color(0xFF00B5FF) + : Colors.transparent, + ); + }, + ); + }, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/view_title_bar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/view_title_bar.dart index 7f068499081ed..68849661b5cd4 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 @@ -8,7 +8,6 @@ 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: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'; @@ -80,11 +79,11 @@ class ViewTitleBar extends StatelessWidget { final child = FlowyTooltip( key: ValueKey(view.id), message: view.name, - child: _ViewTitle( + child: ViewTitle( view: view, behavior: i == views.length - 1 - ? _ViewTitleBehavior.editable // only the last one is editable - : _ViewTitleBehavior.uneditable, // others are not editable + ? ViewTitleBehavior.editable // only the last one is editable + : ViewTitleBehavior.uneditable, // others are not editable onUpdated: () { context .read() @@ -104,27 +103,28 @@ class ViewTitleBar extends StatelessWidget { } } -enum _ViewTitleBehavior { +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.behavior = ViewTitleBehavior.editable, required this.onUpdated, }); final ViewPB view; - final _ViewTitleBehavior behavior; + 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(); @@ -138,12 +138,19 @@ class _ViewTitleState extends State<_ViewTitle> { @override Widget build(BuildContext context) { - final isEditable = widget.behavior == _ViewTitleBehavior.editable; + final isEditable = widget.behavior == ViewTitleBehavior.editable; return BlocProvider( create: (_) => ViewTitleBloc(view: widget.view)..add(const ViewTitleEvent.initial()), child: BlocConsumer( + listenWhen: (previous, current) { + if (previous.view == null || current.view == null) { + return false; + } + + return previous.view != current.view; + }, listener: (_, state) { _resetTextEditingController(state); widget.onUpdated(); @@ -173,7 +180,7 @@ class _ViewTitleState extends State<_ViewTitle> { return Container( alignment: Alignment.center, margin: const EdgeInsets.symmetric(horizontal: 6.0), - child: _buildIconAndName(state, false), + child: _buildIconAndName(context, state, false), ); } @@ -185,7 +192,7 @@ class _ViewTitleState extends State<_ViewTitle> { child: FlowyButton( useIntrinsicWidth: true, margin: const EdgeInsets.symmetric(horizontal: 6.0), - text: _buildIconAndName(state, false), + text: _buildIconAndName(context, state, false), ), ), ); @@ -216,13 +223,18 @@ class _ViewTitleState extends State<_ViewTitle> { child: FlowyButton( useIntrinsicWidth: true, margin: const EdgeInsets.symmetric(horizontal: 6.0), - text: _buildIconAndName(state, true), + text: _buildIconAndName(context, state, true), ), ), ); } - Widget _buildIconAndName(ViewTitleState state, bool isEditable) { + Widget _buildIconAndName( + BuildContext context, + ViewTitleState state, + bool isEditable, + ) { + final spaceIcon = state.view?.buildSpaceIconSvg(context); return SingleChildScrollView( child: Row( children: [ @@ -230,13 +242,14 @@ class _ViewTitleState extends State<_ViewTitle> { FlowyText.emoji( state.icon, fontSize: 14.0, + figmaLineHeight: 18.0, ), const HSpace(4.0), ], - if (state.view?.isSpace == true && - state.view?.spaceIconSvg != null) ...[ + if (state.view?.isSpace == true && spaceIcon != null) ...[ SpaceIcon( dimension: 14, + svgSize: 8.5, space: state.view!, cornerRadius: 4, ), @@ -246,7 +259,9 @@ class _ViewTitleState extends State<_ViewTitle> { opacity: isEditable ? 1.0 : 0.5, child: FlowyText.regular( state.name, + fontSize: 14.0, overflow: TextOverflow.ellipsis, + figmaLineHeight: 18.0, ), ), ], diff --git a/frontend/appflowy_flutter/linux/my_application.cc b/frontend/appflowy_flutter/linux/my_application.cc index 25b07c8d9cd19..2a3a02cac477d 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/macos/.gitignore b/frontend/appflowy_flutter/macos/.gitignore index 9aad20e46dce0..d2fd3772308cc 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 0000000000000..0fc6b09590a8d --- /dev/null +++ b/frontend/appflowy_flutter/macos/Podfile.lock @@ -0,0 +1,161 @@ +PODS: + - app_links (1.0.0): + - FlutterMacOS + - appflowy_backend (0.0.1): + - FlutterMacOS + - bitsdojo_window_macos (0.0.1): + - FlutterMacOS + - connectivity_plus (0.0.1): + - FlutterMacOS + - ReachabilitySwift + - desktop_drop (0.0.1): + - FlutterMacOS + - device_info_plus (0.0.1): + - FlutterMacOS + - file_selector_macos (0.0.1): + - FlutterMacOS + - flowy_infra_ui (0.0.1): + - FlutterMacOS + - FlutterMacOS (1.0.0) + - HotKey (0.2.0) + - hotkey_manager (0.0.1): + - FlutterMacOS + - HotKey + - irondash_engine_context (0.0.1): + - FlutterMacOS + - local_notifier (0.1.0): + - FlutterMacOS + - package_info_plus (0.0.1): + - FlutterMacOS + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - ReachabilitySwift (5.2.3) + - screen_retriever (0.0.1): + - FlutterMacOS + - Sentry/HybridSDK (8.35.1) + - sentry_flutter (8.8.0): + - Flutter + - FlutterMacOS + - Sentry/HybridSDK (= 8.35.1) + - share_plus (0.0.1): + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite (0.0.3): + - Flutter + - FlutterMacOS + - super_native_extensions (0.0.1): + - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS + - window_manager (0.2.0): + - FlutterMacOS + +DEPENDENCIES: + - app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`) + - appflowy_backend (from `Flutter/ephemeral/.symlinks/plugins/appflowy_backend/macos`) + - bitsdojo_window_macos (from `Flutter/ephemeral/.symlinks/plugins/bitsdojo_window_macos/macos`) + - connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`) + - desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`) + - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) + - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) + - flowy_infra_ui (from `Flutter/ephemeral/.symlinks/plugins/flowy_infra_ui/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + - hotkey_manager (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager/macos`) + - irondash_engine_context (from `Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos`) + - local_notifier (from `Flutter/ephemeral/.symlinks/plugins/local_notifier/macos`) + - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) + - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - screen_retriever (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos`) + - sentry_flutter (from `Flutter/ephemeral/.symlinks/plugins/sentry_flutter/macos`) + - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/darwin`) + - super_native_extensions (from `Flutter/ephemeral/.symlinks/plugins/super_native_extensions/macos`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) + +SPEC REPOS: + trunk: + - HotKey + - ReachabilitySwift + - Sentry + +EXTERNAL SOURCES: + app_links: + :path: Flutter/ephemeral/.symlinks/plugins/app_links/macos + appflowy_backend: + :path: Flutter/ephemeral/.symlinks/plugins/appflowy_backend/macos + bitsdojo_window_macos: + :path: Flutter/ephemeral/.symlinks/plugins/bitsdojo_window_macos/macos + connectivity_plus: + :path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos + desktop_drop: + :path: Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos + device_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos + file_selector_macos: + :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos + flowy_infra_ui: + :path: Flutter/ephemeral/.symlinks/plugins/flowy_infra_ui/macos + FlutterMacOS: + :path: Flutter/ephemeral + hotkey_manager: + :path: Flutter/ephemeral/.symlinks/plugins/hotkey_manager/macos + irondash_engine_context: + :path: Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos + local_notifier: + :path: Flutter/ephemeral/.symlinks/plugins/local_notifier/macos + package_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos + path_provider_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + screen_retriever: + :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos + sentry_flutter: + :path: Flutter/ephemeral/.symlinks/plugins/sentry_flutter/macos + share_plus: + :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + sqflite: + :path: Flutter/ephemeral/.symlinks/plugins/sqflite/darwin + super_native_extensions: + :path: Flutter/ephemeral/.symlinks/plugins/super_native_extensions/macos + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + window_manager: + :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos + +SPEC CHECKSUMS: + app_links: 4481ed4d71f384b0c3ae5016f4633aa73d32ff67 + appflowy_backend: 865496343de667fc8c600e04b9fd05234e130cf9 + bitsdojo_window_macos: 44e3b8fe3dd463820e0321f6256c5b1c16bb6a00 + connectivity_plus: 18d3c32514c886e046de60e9c13895109866c747 + desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898 + device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720 + file_selector_macos: 468fb6b81fac7c0e88d71317f3eec34c3b008ff9 + flowy_infra_ui: 03301a39ad118771adbf051a664265c61c507f38 + FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + HotKey: e96d8a2ddbf4591131e2bb3f54e69554d90cdca6 + hotkey_manager: c32bf0bfe8f934b7bc17ab4ad5c4c142960b023c + irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478 + local_notifier: c6c371695f914641ab7bc8601944f7e358632d0b + package_info_plus: fa739dd842b393193c5ca93c26798dff6e3d0e0c + path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c + ReachabilitySwift: 7f151ff156cea1481a8411701195ac6a984f4979 + screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 + Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1 + sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737 + share_plus: 76dd39142738f7a68dd57b05093b5e8193f220f7 + shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 + sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec + super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3 + url_launcher_macos: d2691c7dd33ed713bf3544850a623080ec693d95 + window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 + +PODFILE CHECKSUM: 0532f3f001ca3110b8be345d6491fff690e95823 + +COCOAPODS: 1.15.2 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 621b0e3154cb3..552c6a268f202 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart +++ b/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart @@ -16,7 +16,8 @@ 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-chat/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'; @@ -37,7 +38,8 @@ 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-chat/dart_event.dart'; +part 'dart_event/flowy-ai/dart_event.dart'; +part 'dart_event/flowy-storage/dart_event.dart'; enum FFIException { RequestIsEmpty, 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 b8b62b63ea087..639945f102b39 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/error.dart +++ b/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/error.dart @@ -1,5 +1,6 @@ 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'; @@ -52,6 +53,8 @@ class StackTraceError { 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 = @@ -107,3 +110,10 @@ class GlobalErrorCodeNotifier extends ChangeNotifier { _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/pubspec.yaml b/frontend/appflowy_flutter/packages/appflowy_backend/pubspec.yaml index 51ad23fde4b8a..9ff267929a1b0 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.4.0 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_result/pubspec.yaml b/frontend/appflowy_flutter/packages/appflowy_result/pubspec.yaml index 241f437d9bd3f..fa2e35f329216 100644 --- a/frontend/appflowy_flutter/packages/appflowy_result/pubspec.yaml +++ b/frontend/appflowy_flutter/packages/appflowy_result/pubspec.yaml @@ -4,16 +4,10 @@ version: 0.0.1 homepage: 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 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 14941e5477cf6..0155e73240d12 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'; @@ -87,6 +86,9 @@ class FlowyColorScheme { required this.toggleButtonBGColor, required this.calendarWeekendBGColor, required this.gridRowCountColor, + required this.borderColor, + required this.scrollbarColor, + required this.scrollbarHoverColor, }); final Color surface; @@ -145,6 +147,11 @@ class FlowyColorScheme { //grid bottom count color final Color gridRowCountColor; + final Color borderColor; + + final Color scrollbarColor; + final Color scrollbarHoverColor; + factory FlowyColorScheme.fromJson(Map json) => _$FlowyColorSchemeFromJson(json); 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 9ca6bf104503e..ddb64c70ec318 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'; @@ -81,6 +82,9 @@ class DandelionColorScheme extends FlowyColorScheme { toggleButtonBGColor: _lightDandelionYellow, calendarWeekendBGColor: const Color(0xFFFBFBFC), gridRowCountColor: _black, + borderColor: ColorSchemeConstants.lightBorderColor, + scrollbarColor: const Color(0x3F171717), + scrollbarHoverColor: const Color(0x7F171717), ); const DandelionColorScheme.dark() @@ -135,5 +139,8 @@ class DandelionColorScheme extends FlowyColorScheme { toggleButtonBGColor: _darkShader1, calendarWeekendBGColor: const Color(0xff121212), gridRowCountColor: _darkMain1, + borderColor: ColorSchemeConstants.darkBorderColor, + scrollbarColor: const Color(0x40FFFFFF), + scrollbarHoverColor: const Color(0x80FFFFFF), ); } 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 94c59302f11fa..a58e257c22cd4 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,44 +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(0x0F1F2329); -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 _darkMain2 = 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), @@ -50,51 +54,54 @@ 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), strongText: Colors.black, - input: _white, - hint: _lightShader3, - primary: _lightMain1, - onPrimary: _white, - hoverBG1: _lightBg2, - hoverBG2: _lightHover, - hoverBG3: _lightShader6, - hoverFG: _lightShader1, - questionBubbleBG: _lightSelector, - progressBarBGColor: _lightTint9, - toolbarColor: _lightShader1, - toggleButtonBGColor: _lightShader5, + 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), ); 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), @@ -105,29 +112,32 @@ class DefaultColorScheme extends FlowyColorScheme { tint7: const Color(0x5900BD2A), tint8: const Color(0x80008890), tint9: const Color(0x4d0029FF), - main1: _darkMain2, + 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, + divider: ColorSchemeConstants.darkShader3, + topbarBg: ColorSchemeConstants.darkShader1, + icon: ColorSchemeConstants.darkShader5, + text: ColorSchemeConstants.darkShader5, + secondaryText: ColorSchemeConstants.darkShader5, strongText: Colors.white, - input: _darkInput, + input: ColorSchemeConstants.darkInput, hint: const Color(0xFF59647a), - primary: _darkMain2, - onPrimary: _darkShader1, + primary: ColorSchemeConstants.darkMain2, + onPrimary: ColorSchemeConstants.darkShader1, hoverBG1: const Color(0x1AFFFFFF), - hoverBG2: _darkMain1, - hoverBG3: _darkShader3, + hoverBG2: ColorSchemeConstants.darkMain1, + hoverBG3: ColorSchemeConstants.darkShader3, hoverFG: const Color(0xE5FFFFFF), - questionBubbleBG: _darkShader3, - progressBarBGColor: _darkShader3, - toolbarColor: _darkInput, + questionBubbleBG: ColorSchemeConstants.darkShader3, + progressBarBGColor: ColorSchemeConstants.darkShader3, + toolbarColor: ColorSchemeConstants.darkInput, toggleButtonBGColor: const Color(0xFF828282), - calendarWeekendBGColor: _darkShader1, - gridRowCountColor: _darkShader5, + calendarWeekendBGColor: ColorSchemeConstants.darkShader1, + gridRowCountColor: ColorSchemeConstants.darkShader5, + borderColor: ColorSchemeConstants.darkBorderColor, + scrollbarColor: const Color(0x40FFFFFF), + scrollbarHoverColor: const Color(0x80FFFFFF), ); } 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 01eb66b02d5be..ddadc7302ea9d 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'; @@ -77,6 +78,9 @@ class LavenderColorScheme extends FlowyColorScheme { toggleButtonBGColor: _lightSelector, calendarWeekendBGColor: const Color(0xFFFBFBFC), gridRowCountColor: _black, + borderColor: ColorSchemeConstants.lightBorderColor, + scrollbarColor: const Color(0x3F171717), + scrollbarHoverColor: const Color(0x7F171717), ); const LavenderColorScheme.dark() @@ -131,5 +135,8 @@ class LavenderColorScheme extends FlowyColorScheme { toggleButtonBGColor: _darkShader1, calendarWeekendBGColor: const Color(0xff121212), gridRowCountColor: _darkMain1, + borderColor: ColorSchemeConstants.darkBorderColor, + scrollbarColor: const Color(0x40FFFFFF), + scrollbarHoverColor: const Color(0x80FFFFFF), ); } 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 115de1e44217f..2bb6cb68c5ff6 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'; @@ -83,6 +84,9 @@ class LemonadeColorScheme extends FlowyColorScheme { toggleButtonBGColor: _lightDandelionYellow, calendarWeekendBGColor: const Color(0xFFFBFBFC), gridRowCountColor: _black, + borderColor: ColorSchemeConstants.lightBorderColor, + scrollbarColor: const Color(0x3F171717), + scrollbarHoverColor: const Color(0x7F171717), ); const LemonadeColorScheme.dark() @@ -137,5 +141,8 @@ class LemonadeColorScheme extends FlowyColorScheme { toggleButtonBGColor: _darkShader1, calendarWeekendBGColor: const Color(0xff121212), gridRowCountColor: _darkMain1, + borderColor: ColorSchemeConstants.darkBorderColor, + scrollbarColor: const Color(0x40FFFFFF), + scrollbarHoverColor: const Color(0x80FFFFFF), ); } 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 2e4d082761392..1e6f6a99e29d6 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/platform_extension.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/platform_extension.dart new file mode 100644 index 0000000000000..9e2085e3e4c1f --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/platform_extension.dart @@ -0,0 +1,57 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +extension PlatformExtension on Platform { + /// Returns true if the operating system is macOS and not running on Web platform. + static bool get isMacOS { + if (kIsWeb) { + return false; + } + return Platform.isMacOS; + } + + /// Returns true if the operating system is Windows and not running on Web platform. + static bool get isWindows { + if (kIsWeb) { + return false; + } + return Platform.isWindows; + } + + /// Returns true if the operating system is Linux and not running on Web platform. + static bool get isLinux { + if (kIsWeb) { + return false; + } + return Platform.isLinux; + } + + static bool get isDesktopOrWeb { + if (kIsWeb) { + return true; + } + return isDesktop; + } + + static bool get isDesktop { + if (kIsWeb) { + return false; + } + return Platform.isWindows || Platform.isLinux || Platform.isMacOS; + } + + static bool get isMobile { + if (kIsWeb) { + return false; + } + return Platform.isAndroid || Platform.isIOS; + } + + static bool get isNotMobile { + if (kIsWeb) { + return false; + } + return !isMobile; + } +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension.dart index 48b4dfe86de37..49195f2011ba9 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension.dart @@ -38,6 +38,9 @@ class AFThemeExtension extends ThemeExtension { required this.gridRowCountColor, required this.background, required this.onBackground, + required this.borderColor, + required this.scrollbarColor, + required this.scrollbarHoverColor, }); final Color? warning; @@ -74,6 +77,14 @@ class AFThemeExtension extends ThemeExtension { 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; + @override AFThemeExtension copyWith({ Color? warning, @@ -105,6 +116,9 @@ class AFThemeExtension extends ThemeExtension { TextStyle? caption, Color? background, Color? onBackground, + Color? borderColor, + Color? scrollbarColor, + Color? scrollbarHoverColor, }) => AFThemeExtension( warning: warning ?? this.warning, @@ -137,6 +151,9 @@ class AFThemeExtension extends ThemeExtension { caption: caption ?? this.caption, onBackground: onBackground ?? this.onBackground, background: background ?? this.background, + borderColor: borderColor ?? this.borderColor, + scrollbarColor: scrollbarColor ?? this.scrollbarColor, + scrollbarHoverColor: scrollbarHoverColor ?? this.scrollbarHoverColor, ); @override @@ -188,6 +205,10 @@ class AFThemeExtension extends ThemeExtension { 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)!, ); } } diff --git a/frontend/appflowy_flutter/packages/flowy_infra/pubspec.yaml b/frontend/appflowy_flutter/packages/flowy_infra/pubspec.yaml index 0653aacaa5fd4..ffe46b0abadb9 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/pubspec.yaml +++ b/frontend/appflowy_flutter/packages/flowy_infra/pubspec.yaml @@ -10,11 +10,9 @@ 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 @@ -23,8 +21,6 @@ dependencies: file: ^7.0.0 dev_dependencies: - flutter_test: - sdk: flutter build_runner: ^2.2.0 flutter_lints: ^3.0.1 freezed: ^2.4.7 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 bbdeda8de3d4d..e1f58189b18ba 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,4 +1,5 @@ // Basis +export '/widget/flowy_tooltip.dart'; export '/widget/separated_flex.dart'; export '/widget/spacing.dart'; export 'basis.dart'; @@ -12,7 +13,10 @@ export 'src/flowy_overlay/option_overlay.dart'; 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'; 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 3014d393ddf42..035f41f35987a 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,7 +1,8 @@ -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:flowy_infra_ui/style_widget/decoration.dart'; import 'package:flutter/material.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flowy_infra/colorscheme/default_colorscheme.dart'; + class AppFlowyPopover extends StatelessWidget { final Widget child; final PopoverController? controller; @@ -17,7 +18,8 @@ class AppFlowyPopover extends StatelessWidget { final bool asBarrier; final EdgeInsets margin; final EdgeInsets windowPadding; - final Decoration? decoration; + final Color? decorationColor; + final BorderRadius? borderRadius; /// The widget that will be used to trigger the popover. /// @@ -46,9 +48,10 @@ 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, }); @override @@ -70,7 +73,8 @@ class AppFlowyPopover extends StatelessWidget { return _PopoverContainer( constraints: constraints, margin: margin, - decoration: decoration, + decorationColor: decorationColor, + borderRadius: borderRadius, child: popupBuilder(context), ); }, @@ -81,33 +85,79 @@ class AppFlowyPopover extends StatelessWidget { class _PopoverContainer extends StatelessWidget { const _PopoverContainer({ + this.decorationColor, + this.borderRadius, required this.child, required this.margin, required this.constraints, - required this.decoration, }); final Widget child; final BoxConstraints constraints; final EdgeInsets margin; - final Decoration? decoration; + final Color? decorationColor; + final BorderRadius? borderRadius; @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: context.getPopoverDecoration( + color: decorationColor, + borderRadius: borderRadius, + ), constraints: constraints, child: child, ), ); } } + +extension on BuildContext { + /// The decoration of the popover. + /// + /// Don't customize the entire decoration of the popover, + /// use the built-in popoverDecoration instead and ask the designer before changing it. + ShapeDecoration getPopoverDecoration({ + Color? color, + BorderRadius? borderRadius, + }) { + final borderColor = Theme.of(this).brightness == Brightness.light + ? ColorSchemeConstants.lightBorderColor + : ColorSchemeConstants.darkBorderColor; + final shadows = [ + const BoxShadow( + color: Color(0x0A1F2329), + blurRadius: 24, + offset: Offset(0, 8), + spreadRadius: 8, + ), + const BoxShadow( + color: Color(0x0A1F2329), + blurRadius: 12, + offset: Offset(0, 6), + spreadRadius: 0, + ), + const BoxShadow( + color: Color(0x0F1F2329), + blurRadius: 8, + offset: Offset(0, 4), + spreadRadius: -8, + ) + ]; + return ShapeDecoration( + color: color ?? Theme.of(this).cardColor, + shape: RoundedRectangleBorder( + side: BorderSide( + width: 1, + strokeAlign: BorderSide.strokeAlignOutside, + color: color != Colors.transparent ? borderColor : color!, + ), + borderRadius: borderRadius ?? BorderRadius.circular(10), + ), + shadows: shadows, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart index a548a1a9bea95..1db56b826792a 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,12 +1,141 @@ 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/style_widget/text.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; + + 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, + }); + + @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( + cursor: + disable ? SystemMouseCursors.forbidden : SystemMouseCursors.click, + style: HoverStyle( + borderRadius: radius ?? Corners.s6Border, + hoverColor: color, + borderColor: borderColor ?? Colors.transparent, + ), + onHover: disable ? null : onHover, + isSelected: () => isSelected, + builder: (context, onHover) => _render(context, onHover), + ), + ); + } + + Widget _render(BuildContext context, bool onHover) { + final List children = []; + + if (leftIconBuilder != null) { + children.add( + SizedBox.fromSize( + size: leftIconSize, + child: leftIconBuilder!(onHover), + ), + ); + children.add(HSpace(iconPadding)); + } + + if (expandText) { + children.add(Expanded(child: textBuilder(onHover))); + } else { + children.add(textBuilder(onHover)); + } + + if (rightIconBuilder != null) { + children.add(HSpace(iconPadding)); + // No need to define the size of rightIcon. Just use its intrinsic width + children.add(rightIconBuilder!(onHover)); + } + + 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; @@ -29,6 +158,8 @@ class FlowyButton extends StatelessWidget { final bool showDefaultBoxDecorationOnMobile; final double iconPadding; final bool expand; + final Color? borderColor; + final Color? backgroundColor; const FlowyButton({ super.key, @@ -52,6 +183,8 @@ class FlowyButton extends StatelessWidget { this.showDefaultBoxDecorationOnMobile = false, this.iconPadding = 6, this.expand = false, + this.borderColor, + this.backgroundColor, }); @override @@ -79,6 +212,8 @@ class FlowyButton extends StatelessWidget { style: HoverStyle( borderRadius: radius ?? Corners.s6Border, hoverColor: color, + borderColor: borderColor ?? Colors.transparent, + backgroundColor: backgroundColor ?? Colors.transparent, ), onHover: disable ? null : onHover, isSelected: () => isSelected, @@ -123,21 +258,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.surfaceContainerHighest, - 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, ), ); @@ -166,8 +321,40 @@ class FlowyTextButton extends StatelessWidget { 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; @@ -190,6 +377,7 @@ class FlowyTextButton extends StatelessWidget { final String? fontFamily; final bool isDangerous; final Color? borderColor; + final double? lineHeight; @override Widget build(BuildContext context) { @@ -198,10 +386,13 @@ class FlowyTextButton extends StatelessWidget { children.add(heading!); children.add(const HSpace(8)); } - children.add(Text( + children.add(FlowyText( text, overflow: overflow, + color: fontColor ?? Theme.of(context).colorScheme.onPrimary, textAlign: TextAlign.center, + lineHeight: lineHeight, + fontSize: fontSize, )); Widget child = Row( @@ -238,7 +429,7 @@ class FlowyTextButton extends StatelessWidget { fontSize: fontSize, decoration: decoration, fontFamily: fontFamily, - height: 1.1, + height: lineHeight ?? 1.1, ), ), backgroundColor: WidgetStateProperty.resolveWith( 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 0902d78835805..a3f80cb41c5b9 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 @@ -8,6 +8,7 @@ class FlowyDecoration { double blurRadius = 20, Offset offset = Offset.zero, double borderRadius = 6, + BoxBorder? border, }) { return BoxDecoration( color: boxColor, @@ -20,6 +21,7 @@ class FlowyDecoration { offset: offset, ), ], + border: border, ); } } diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/divider.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/divider.dart new file mode 100644 index 0000000000000..7f4b630386f27 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/divider.dart @@ -0,0 +1,23 @@ +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flutter/material.dart'; + +class FlowyDivider extends StatelessWidget { + const FlowyDivider({ + super.key, + this.padding, + }); + + final EdgeInsets? padding; + + @override + Widget build(BuildContext context) { + return Padding( + padding: padding ?? EdgeInsets.zero, + child: Divider( + height: 1.0, + thickness: 1.0, + color: AFThemeExtension.of(context).borderColor, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/hover.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/hover.dart index 27bff59045b9a..80ff69d259e0d 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( + isHovering: _onHover || (widget.isSelected?.call() ?? false), + style: widget.style ?? + HoverStyle(hoverColor: Theme.of(context).colorScheme.secondary), + 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,31 +79,6 @@ 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( - decoration: BoxDecoration( - color: style.backgroundColor, - borderRadius: style.borderRadius, - ), - child: child, - ); - } - } } class HoverStyle { @@ -140,15 +113,27 @@ class HoverStyle { class FlowyHoverContainer extends StatelessWidget { final HoverStyle style; final Widget child; + final bool isHovering; const FlowyHoverContainer({ super.key, required this.child, required this.style, + required this.isHovering, }); @override Widget build(BuildContext context) { + if (!isHovering) { + return Container( + decoration: BoxDecoration( + color: style.backgroundColor, + borderRadius: style.borderRadius, + ), + child: child, + ); + } + final hoverBorder = Border.all( color: style.borderColor, width: style.borderWidth, 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 b4a2553814e21..997038f5320c3 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; @@ -82,7 +83,6 @@ class FlowyIconButton extends StatelessWidget { preferBelow: preferBelow, message: tooltipMessage, richMessage: richTooltipText, - showDuration: Duration.zero, child: RawMaterialButton( clipBehavior: Clip.antiAlias, visualDensity: VisualDensity.compact, diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/primary_rounded_button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/primary_rounded_button.dart new file mode 100644 index 0000000000000..af76b2cd6abac --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/primary_rounded_button.dart @@ -0,0 +1,99 @@ +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, + }); + + 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; + + @override + Widget build(BuildContext context) { + return FlowyButton( + useIntrinsicWidth: useIntrinsicWidth, + text: FlowyText( + text, + fontSize: fontSize ?? 14.0, + fontWeight: fontWeight ?? FontWeight.w500, + lineHeight: lineHeight ?? 1.0, + figmaLineHeight: figmaLineHeight, + color: Theme.of(context).colorScheme.onPrimary, + textAlign: TextAlign.center, + ), + margin: margin ?? const EdgeInsets.symmetric(horizontal: 14.0), + backgroundColor: backgroundColor ?? Theme.of(context).colorScheme.primary, + hoverColor: + hoverColor ?? Theme.of(context).colorScheme.primary.withOpacity(0.9), + radius: BorderRadius.circular(radius ?? 10.0), + onTap: onTap, + ); + } +} + +class OutlinedRoundedButton extends StatelessWidget { + const OutlinedRoundedButton({ + super.key, + required this.text, + this.onTap, + this.margin, + this.radius, + }); + + final String text; + final VoidCallback? onTap; + final EdgeInsets? margin; + final double? radius; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + side: Theme.of(context).brightness == Brightness.light + ? const BorderSide(color: Color(0x1E14171B)) + : const BorderSide(color: Colors.white10), + borderRadius: BorderRadius.circular(radius ?? 8), + ), + ), + child: FlowyButton( + useIntrinsicWidth: true, + margin: margin ?? + const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 9.0, + ), + radius: BorderRadius.circular(radius ?? 8), + text: FlowyText.regular( + text, + lineHeight: 1.0, + ), + 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 0000000000000..01111293ecf62 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrollbar.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +class FlowyScrollbar extends StatefulWidget { + const FlowyScrollbar({ + super.key, + this.controller, + required this.child, + }); + + final ScrollController? controller; + final Widget child; + + @override + State createState() => _FlowyScrollbarState(); +} + +class _FlowyScrollbarState extends State { + final ValueNotifier isHovered = ValueNotifier(false); + + @override + void dispose() { + isHovered.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => isHovered.value = true, + onExit: (_) => isHovered.value = false, + child: ValueListenableBuilder( + valueListenable: isHovered, + builder: (context, isHovered, child) { + return Scrollbar( + thumbVisibility: isHovered, + // the radius should be fixed to 12 + radius: const Radius.circular(12), + controller: widget.controller, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + scrollbars: false, + ), + child: child!, + ), + ); + }, + child: widget.child, + ), + ); + } +} diff --git a/frontend/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 da226741529d4..ece1801098b43 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/text.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text.dart index 29dc6683cdbdb..688f20a1a4792 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,7 +1,7 @@ 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 { @@ -16,12 +16,19 @@ class FlowyText extends StatelessWidget { final bool selectable; final String? fontFamily; final List? fallbackFontFamily; - final double? lineHeight; final bool withTooltip; final StrutStyle? strutStyle; final TextDirection? textDirection; final bool isEmoji; + /// this is used to control the line height in Flutter. + final double? lineHeight; + + /// this is used to control the line height from Figma. + final double? figmaLineHeight; + + final bool optimizeEmojiAlign; + const FlowyText( this.text, { super.key, @@ -35,11 +42,14 @@ class FlowyText extends StatelessWidget { this.selectable = false, this.fontFamily, this.fallbackFontFamily, + // // https://api.flutter.dev/flutter/painting/TextStyle/height.html this.lineHeight, + this.figmaLineHeight, this.withTooltip = false, this.isEmoji = false, this.strutStyle, this.textDirection, + this.optimizeEmojiAlign = false, }); FlowyText.small( @@ -58,6 +68,8 @@ class FlowyText extends StatelessWidget { this.isEmoji = false, this.strutStyle, this.textDirection, + this.figmaLineHeight, + this.optimizeEmojiAlign = false, }) : fontWeight = FontWeight.w400, fontSize = (Platform.isIOS || Platform.isAndroid) ? 14 : 12; @@ -78,6 +90,8 @@ class FlowyText extends StatelessWidget { this.isEmoji = false, this.strutStyle, this.textDirection, + this.figmaLineHeight, + this.optimizeEmojiAlign = false, }) : fontWeight = FontWeight.w400; const FlowyText.medium( @@ -97,6 +111,8 @@ class FlowyText extends StatelessWidget { this.isEmoji = false, this.strutStyle, this.textDirection, + this.figmaLineHeight, + this.optimizeEmojiAlign = false, }) : fontWeight = FontWeight.w500; const FlowyText.semibold( @@ -116,6 +132,8 @@ class FlowyText extends StatelessWidget { this.isEmoji = false, this.strutStyle, this.textDirection, + this.figmaLineHeight, + this.optimizeEmojiAlign = false, }) : fontWeight = FontWeight.w600; // Some emojis are not supported on Linux and Android, fallback to noto color emoji @@ -135,6 +153,8 @@ class FlowyText extends StatelessWidget { this.textDirection, this.isEmoji = true, this.fontFamily, + this.figmaLineHeight, + this.optimizeEmojiAlign = false, }) : fontWeight = FontWeight.w400, fallbackFontFamily = null; @@ -153,8 +173,20 @@ class FlowyText extends StatelessWidget { } } + 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)) { - fontSize = fontSize * 0.8; + const scaleFactor = 0.9; + fontSize *= scaleFactor; + if (lineHeight != null) { + lineHeight /= scaleFactor; + } } final textStyle = Theme.of(context).textTheme.bodyMedium!.copyWith( @@ -165,6 +197,9 @@ class FlowyText extends StatelessWidget { fontFamily: fontFamily, fontFamilyFallback: fallbackFontFamily, height: lineHeight, + leadingDistribution: isEmoji && optimizeEmojiAlign + ? TextLeadingDistribution.even + : null, ); if (selectable) { @@ -174,7 +209,7 @@ class FlowyText extends StatelessWidget { maxLines: maxLines, textAlign: textAlign, style: textStyle, - textDirection: textDirection, + textDirection: textDirection, ), ); } else { @@ -185,19 +220,19 @@ class FlowyText extends StatelessWidget { overflow: overflow ?? TextOverflow.clip, style: textStyle, textDirection: textDirection, - strutStyle: (Platform.isMacOS || Platform.isLinux) & !isEmoji + strutStyle: !isEmoji || (isEmoji && optimizeEmojiAlign) ? StrutStyle.fromTextStyle( textStyle, forceStrutHeight: true, leadingDistribution: TextLeadingDistribution.even, - height: lineHeight ?? 1.1, + height: lineHeight, ) : null, ); } if (withTooltip) { - child = Tooltip( + child = FlowyTooltip( message: text, child: child, ); 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 32cc2fa1fc29c..aa408f73e1df1 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 @@ -38,6 +38,8 @@ class FlowyTextField extends StatefulWidget { final bool isDense; final bool readOnly; final Color? enableBorderColor; + final BorderRadius? borderRadius; + final Function(PointerDownEvent)? onTapOutside; const FlowyTextField({ super.key, @@ -74,6 +76,8 @@ class FlowyTextField extends StatefulWidget { this.isDense = true, this.readOnly = false, this.enableBorderColor, + this.borderRadius, + this.onTapOutside, }); @override @@ -159,6 +163,7 @@ class FlowyTextFieldState extends State { }, onSubmitted: _onSubmitted, onEditingComplete: widget.onEditingComplete, + onTapOutside: widget.onTapOutside, minLines: 1, maxLines: widget.maxLines, maxLength: widget.maxLength, @@ -180,7 +185,7 @@ 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: widget.enableBorderColor ?? Theme.of(context).colorScheme.outline, @@ -202,7 +207,7 @@ class FlowyTextFieldState extends State { suffixText: widget.showCounter ? _suffixText() : "", counterText: "", focusedBorder: OutlineInputBorder( - borderRadius: Corners.s8Border, + borderRadius: widget.borderRadius ?? Corners.s8Border, borderSide: BorderSide( color: widget.readOnly ? widget.enableBorderColor ?? @@ -214,13 +219,13 @@ class FlowyTextFieldState extends State { 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/widget/error_page.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/error_page.dart index fd6b7347156f3..d395873bd7a63 100644 --- 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 @@ -7,7 +7,6 @@ import 'package:flutter/services.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; -import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flowy_svg/flowy_svg.dart'; import 'package:url_launcher/url_launcher.dart'; 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 1eb8bd00469bb..61ed4b1f181f8 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,17 +8,17 @@ class FlowyTooltip extends StatelessWidget { this.message, this.richMessage, this.preferBelow, - this.showDuration, this.margin, + this.verticalOffset, this.child, }); final String? message; final InlineSpan? richMessage; final bool? preferBelow; - final Duration? showDuration; final EdgeInsetsGeometry? margin; final Widget? child; + final double? verticalOffset; @override Widget build(BuildContext context) { @@ -26,21 +26,46 @@ class FlowyTooltip extends StatelessWidget { return child ?? const SizedBox.shrink(); } - final isLightMode = Theme.of(context).brightness == Brightness.light; return Tooltip( margin: margin, - verticalOffset: 16.0, - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + verticalOffset: verticalOffset ?? 16.0, + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 8.0, + ), decoration: BoxDecoration( - color: isLightMode ? const Color(0xE5171717) : const Color(0xE5E5E5E5), - borderRadius: BorderRadius.circular(8.0), + 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, ); } } + +extension FlowyToolTipExtension on BuildContext { + double tooltipFontSize() => 14.0; + double tooltipHeight() => 20.0 / tooltipFontSize(); + Color tooltipFontColor() => Theme.of(this).brightness == Brightness.light + ? Colors.white + : Colors.black; + + TextStyle? tooltipTextStyle({Color? fontColor}) { + return Theme.of(this).textTheme.bodyMedium?.copyWith( + color: fontColor ?? tooltipFontColor(), + fontSize: tooltipFontSize(), + fontWeight: FontWeight.w400, + height: tooltipHeight(), + leadingDistribution: TextLeadingDistribution.even, + ); + } + + Color tooltipBackgroundColor() => + Theme.of(this).brightness == Brightness.light + ? const Color(0xFF1D2129) + : const Color(0xE5E5E5E5); +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/pubspec.yaml b/frontend/appflowy_flutter/packages/flowy_infra_ui/pubspec.yaml index 5eb1ba066e775..c90679b0cd558 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/pubspec.yaml +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/pubspec.yaml @@ -13,9 +13,8 @@ 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: @@ -25,8 +24,6 @@ dependencies: # 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: @@ -35,6 +32,7 @@ dependencies: path: ../flowy_svg dev_dependencies: + 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 315807278ec47..543c78a7d412c 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/lib/src/flowy_svg.dart b/frontend/appflowy_flutter/packages/flowy_svg/lib/src/flowy_svg.dart index aa70ee9d1f132..a69a4bb90d848 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 @@ -24,8 +24,29 @@ class FlowySvg extends StatelessWidget { this.color, this.blendMode = BlendMode.srcIn, this.opacity = 1.0, + 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 = 1.0, + }) { + 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; @@ -33,6 +54,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 @@ -57,22 +81,41 @@ class FlowySvg extends StatelessWidget { final iconColor = (color ?? Theme.of(context).iconTheme.color)?.withOpacity(opacity); final textScaleFactor = MediaQuery.textScalerOf(context).scale(1); + + final Widget svg; + + if (svgString != null) { + svg = SvgPicture.string( + svgString!, + width: size?.width, + height: size?.height, + colorFilter: iconColor != null && blendMode != null + ? ColorFilter.mode( + iconColor, + blendMode!, + ) + : null, + ); + } else { + svg = SvgPicture.asset( + _normalized(), + width: size?.width, + height: size?.height, + colorFilter: iconColor != null && blendMode != null + ? ColorFilter.mode( + iconColor, + blendMode!, + ) + : null, + ); + } + return Transform.scale( scale: textScaleFactor, child: SizedBox( width: size?.width, height: size?.height, - child: 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 3d524efc45df2..fc1f023a98407 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -10,7 +10,7 @@ packages: source: hosted version: "67.0.0" analyzer: - dependency: "direct dev" + dependency: transitive description: name: analyzer sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" @@ -53,11 +53,11 @@ packages: dependency: "direct main" description: path: "." - ref: e0d673a - resolved-ref: e0d673afbbbcaf9df0276f7e0b6405d8f6e98112 + ref: "44989c5" + resolved-ref: "44989c568e71fbf41970ec390cbb62f0db99b6e5" url: "https://github.com/AppFlowy-IO/appflowy-editor.git" source: git - version: "3.1.0" + version: "3.2.0" appflowy_editor_plugins: dependency: "direct main" description: @@ -133,10 +133,10 @@ packages: dependency: transitive description: name: bidi - sha256: "1a7d0c696324b2089f72e7671fd1f1f64fef44c980f3cebc84e803967c597b63" + sha256: "9a712c7ddf708f7c41b1923aa83648a3ed44cfd75b04f72d598c45e5be287f9d" url: "https://pub.dev" source: hosted - version: "2.0.10" + version: "2.0.12" bitsdojo_window: dependency: "direct main" description: @@ -307,7 +307,7 @@ packages: source: hosted version: "1.3.0" charcode: - dependency: "direct main" + dependency: transitive description: name: charcode sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 @@ -322,14 +322,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: @@ -387,7 +379,7 @@ packages: source: hosted version: "1.7.2" cross_file: - dependency: transitive + dependency: "direct main" description: name: cross_file sha256: "55d7b444feb71301ef6b8838dbc1ae02e63dd48c8773f3810ff53bb1e2945b32" @@ -410,14 +402,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - custom_sliding_segmented_control: - dependency: "direct main" - description: - name: custom_sliding_segmented_control - sha256: "53c3e931c3ae1f696085d1ec70ac8e934da836595a9b7d9b88fdd0fcbf2a5574" - url: "https://pub.dev" - source: hosted - version: "1.8.3" dart_style: dependency: transitive description: @@ -538,6 +522,22 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.1" + extended_text_field: + dependency: "direct main" + description: + name: extended_text_field + sha256: "954c7eea1e82728a742f7ddf09b9a51cef087d4f52b716ba88cb3eb78ccd7c6e" + url: "https://pub.dev" + source: hosted + version: "15.0.0" + extended_text_library: + dependency: "direct main" + description: + name: extended_text_library + sha256: "55d09098ec56fab0d9a8a68950ca0bbf2efa1327937f7cec6af6dfa066234829" + url: "https://pub.dev" + source: hosted + version: "12.0.0" fake_async: dependency: transitive description: @@ -622,10 +622,10 @@ packages: dependency: transitive description: name: flex_seed_scheme - sha256: "6c595e545b0678e1fe17e8eec3d1fbca7237482da194fadc20ad8607dc7a7f3d" + sha256: cb5b7ec4ba525d9846d8992858a1c6cfc88f9466d96b8850e2a061aa5f682539 url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "3.1.1" flowy_infra: dependency: "direct main" description: @@ -647,13 +647,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: @@ -707,14 +700,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.6.13" - flutter_colorpicker: - dependency: "direct main" - description: - name: flutter_colorpicker - sha256: "458a6ed8ea480eb16ff892aedb4b7092b2804affd7e046591fb03127e8d8ef8b" - url: "https://pub.dev" - source: hosted - version: "1.0.3" flutter_driver: dependency: transitive description: flutter @@ -724,13 +709,13 @@ packages: dependency: "direct main" description: path: "." - ref: "4a5cac" - resolved-ref: "4a5cac57e31c0ffd49cd6257a9e078f084ae342c" + ref: "38c2c42" + resolved-ref: "38c2c429212af6b72a0af829bb0dd3f3eb4ce2c7" url: "https://github.com/LucasXu0/emoji_mart.git" source: git version: "1.0.2" flutter_highlight: - dependency: "direct main" + dependency: transitive description: name: flutter_highlight sha256: "7b96333867aa07e122e245c033b8ad622e4e3a42a1a2372cbb098a2541d8782c" @@ -757,12 +742,12 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: e2a421b7e59244faef694ba7b30562e489c2b489866e505074eb005cd7060db7 + sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "4.0.0" flutter_localizations: - dependency: "direct main" + dependency: transitive description: flutter source: sdk version: "0.0.0" @@ -806,6 +791,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" + flutter_staggered_grid_view: + dependency: "direct main" + description: + name: flutter_staggered_grid_view + sha256: "19e7abb550c96fbfeb546b23f3ff356ee7c59a019a651f8f102a4ba9b7349395" + url: "https://pub.dev" + source: hosted + version: "0.7.0" flutter_sticky_header: dependency: transitive description: @@ -815,7 +808,7 @@ packages: source: hosted version: "0.6.5" flutter_svg: - dependency: "direct main" + dependency: transitive description: name: flutter_svg sha256: d39e7f95621fc84376bc0f7d504f05c3a41488c562f4a8ad410569127507402c @@ -836,10 +829,10 @@ packages: dependency: "direct main" description: name: fluttertoast - sha256: dfdde255317af381bfc1c486ed968d5a43a2ded9c931e87cbecd88767d6a71c1 + sha256: "7eae679e596a44fdf761853a706f74979f8dd3cd92cf4e23cae161fda091b847" url: "https://pub.dev" source: hosted - version: "8.2.4" + version: "8.2.6" freezed: dependency: "direct dev" description: @@ -897,10 +890,10 @@ packages: dependency: "direct main" description: name: go_router - sha256: "170c46e237d6eb0e6e9f0e8b3f56101e14fb64f787016e42edd74c39cf8b176a" + sha256: ddc16d34b0d74cb313986918c0f0885a7ba2fc24d8fb8419de75f0015144ccfe url: "https://pub.dev" source: hosted - version: "13.2.0" + version: "14.2.3" google_fonts: dependency: "direct main" description: @@ -942,7 +935,7 @@ packages: source: hosted version: "0.7.0" hive: - dependency: "direct main" + dependency: transitive description: name: hive sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" @@ -1013,14 +1006,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.2.0" - image_gallery_saver: - dependency: "direct main" - description: - name: image_gallery_saver - sha256: "0aba74216a4d9b0561510cb968015d56b701ba1bd94aace26aacdd8ae5761816" - url: "https://pub.dev" - source: hosted - version: "2.0.3" image_picker: dependency: "direct main" description: @@ -1098,14 +1083,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.19.0" - intl_utils: - dependency: transitive - description: - name: intl_utils - sha256: c2b1f5c72c25512cbeef5ab015c008fc50fe7e04813ba5541c25272300484bf4 - url: "https://pub.dev" - source: hosted - version: "2.8.7" io: dependency: transitive description: @@ -1118,20 +1095,20 @@ packages: 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: "direct main" + dependency: transitive description: name: isolates sha256: ce89e4141b27b877326d3715be2dceac7a7ba89f3229785816d2d318a75ddf28 @@ -1174,10 +1151,10 @@ packages: 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: @@ -1230,10 +1207,10 @@ packages: dependency: transitive description: name: lints - sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "4.0.0" loading_indicator: dependency: transitive description: @@ -1251,7 +1228,7 @@ packages: source: hosted version: "0.1.5" logger: - dependency: "direct main" + dependency: transitive description: name: logger sha256: "697d067c60c20999686a0add96cf6aba723b3aa1f83ecf806a8097231529ec32" @@ -1307,13 +1284,13 @@ packages: source: hosted version: "1.12.0" mime: - dependency: transitive + dependency: "direct main" description: name: mime - sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" + sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" url: "https://pub.dev" source: hosted - version: "1.0.5" + version: "1.0.6" mockito: dependency: transitive description: @@ -1323,7 +1300,7 @@ packages: source: hosted version: "5.4.4" mocktail: - dependency: "direct main" + dependency: "direct dev" description: name: mocktail sha256: c4b5007d91ca4f67256e720cb1b6d704e79a510183a12fa551021f652577dce6 @@ -1486,18 +1463,10 @@ packages: dependency: transitive description: name: pdf - sha256: "81d5522bddc1ef5c28e8f0ee40b71708761753c163e0c93a40df56fd515ea0f0" + sha256: "05df53f8791587402493ac97b9869d3824eccbc77d97855f4545cf72df3cae07" url: "https://pub.dev" source: hosted - version: "3.11.0" - pdf_widget_wrapper: - dependency: transitive - description: - name: pdf_widget_wrapper - sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5 - url: "https://pub.dev" - source: hosted - version: "1.0.4" + version: "3.11.1" percent_indicator: dependency: "direct main" description: @@ -1618,14 +1587,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" - printing: - dependency: transitive - description: - name: printing - sha256: cc4b256a5a89d5345488e3318897b595867f5181b8c5ed6fc63bfa5f2044aec3 - url: "https://pub.dev" - source: hosted - version: "5.13.1" process: dependency: transitive description: @@ -1670,10 +1631,10 @@ packages: dependency: transitive description: name: qr - sha256: "64957a3930367bf97cc211a5af99551d630f2f4625e38af10edd6b19131b64b3" + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" realtime_client: dependency: transitive description: @@ -1747,7 +1708,7 @@ packages: source: hosted version: "0.1.9" scroll_to_index: - dependency: transitive + dependency: "direct main" description: name: scroll_to_index sha256: b707546e7500d9f070d63e5acf74fd437ec7eeeb68d3412ef7b0afada0b4f176 @@ -1762,6 +1723,22 @@ 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: @@ -2053,18 +2030,18 @@ packages: dependency: "direct main" description: name: super_clipboard - sha256: "15d25eb88df8e904e0c2ef77378c6010cc57bbfc0b6f91f2416d08fad5fcca92" + sha256: c72d2ae8c3a66b20a104523add86b7c2813b1d4cced893a9764b84fb97ac8e2a url: "https://pub.dev" source: hosted - version: "0.8.5" + version: "0.8.18" super_native_extensions: dependency: transitive description: name: super_native_extensions - sha256: "530a2118d032483b192713c68ed7105fe64418f22492165f87ed01f9b01d4965" + sha256: b03f19e54744b65940a7c2cb4f93abd4819b5355aa3464d7b3c9a013b6b76db1 url: "https://pub.dev" source: hosted - version: "0.8.12" + version: "0.8.18" sync_http: dependency: transitive description: @@ -2129,14 +2106,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" - textstyle_extensions: - dependency: transitive - description: - name: textstyle_extensions - sha256: b0538352844fb4d1d0eea82f7bc6b96e4dae03a3a071247e4dcc85ec627b2c6c - url: "https://pub.dev" - source: hosted - version: "2.0.0-nullsafety" time: dependency: "direct main" description: @@ -2443,5 +2412,5 @@ packages: source: hosted version: "2.0.0" sdks: - dart: ">=3.3.0 <4.0.0" + dart: ">=3.4.0 <4.0.0" flutter: ">=3.22.0" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 2e339373ad437..09ffe383efb2c 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # 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.6.4 +version: 0.6.9 environment: flutter: ">=3.22.0" @@ -30,8 +30,6 @@ environment: dependencies: flutter: sdk: flutter - flutter_localizations: - sdk: flutter appflowy_backend: path: packages/appflowy_backend flowy_infra_ui: @@ -65,25 +63,21 @@ dependencies: sized_context: ^1.0.0+4 styled_widget: ^0.4.1 expandable: ^5.0.1 - flutter_colorpicker: ^1.0.3 flex_color_picker: ^3.5.1 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 + fluttertoast: ^8.2.6 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 @@ -95,23 +89,22 @@ dependencies: ref: "6fe0c98" 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 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 + go_router: ^14.2.0 string_validator: ^1.0.0 unsplash_client: ^2.1.1 flutter_emoji_mart: git: url: https://github.com/LucasXu0/emoji_mart.git - ref: "4a5cac" + ref: "38c2c42" # Notifications # TODO: Consider implementing custom package @@ -121,10 +114,9 @@ dependencies: 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 + keyboard_height_plugin: ^0.1.5 scrollable_positioned_list: ^0.3.8 flutter_cache_manager: ^3.3.1 share_plus: ^7.2.1 @@ -140,11 +132,14 @@ dependencies: auto_size_text_field: ^2.2.3 reorderable_tabbar: ^1.0.6 shimmer: ^3.0.0 - isolates: ^3.0.3+8 markdown_widget: ^2.3.2+6 - desktop_drop: ^0.4.4 markdown: - logger: ^2.4.0 + + # Desktop Drop uses Cross File (XFile) data type + desktop_drop: ^0.4.4 + cross_file: ^0.3.4+1 + + flutter_staggered_grid_view: ^0.7.0 # Window Manager for MacOS and Linux window_manager: ^0.3.9 @@ -152,13 +147,16 @@ dependencies: # BitsDojo Window for Windows bitsdojo_window: ^0.1.6 - flutter_highlight: ^0.7.0 - custom_sliding_segmented_control: ^1.8.3 toastification: ^2.0.0 + scroll_to_index: ^3.0.1 + extended_text_field: ^15.0.0 + extended_text_library: ^12.0.0 + sentry_flutter: ^8.7.0 + sentry: ^8.8.0 + mime: ^1.0.6 dev_dependencies: - flutter_lints: ^3.0.1 - analyzer: ^6.3.0 + flutter_lints: ^4.0.0 flutter_test: sdk: flutter @@ -174,6 +172,8 @@ dev_dependencies: url_launcher_platform_interface: any run_with_network_images: ^0.0.1 + mocktail: ^1.0.1 + dependency_overrides: http: ^1.0.0 @@ -191,7 +191,7 @@ dependency_overrides: appflowy_editor: git: url: https://github.com/AppFlowy-IO/appflowy-editor.git - ref: "e0d673a" + ref: "44989c5" appflowy_editor_plugins: git: @@ -275,6 +275,7 @@ flutter: - assets/images/emoji/ - assets/images/login/ - assets/translations/ + - assets/icons/icons.json # The following assets will be excluded in release. # BEGIN: EXCLUDE_IN_RELEASE 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 752ff272daedf..9131446347f88 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 @@ -34,8 +34,9 @@ void main() { final editorBloc = FieldEditorBloc( viewId: context.gridView.id, - field: fieldInfo.field, + fieldInfo: fieldInfo, fieldController: context.fieldController, + isNew: false, ); await boardResponseFuture(); 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 e29c6a4a7345e..01b5c0d5fc28a 100644 --- a/frontend/appflowy_flutter/test/bloc_test/board_test/util.dart +++ b/frontend/appflowy_flutter/test/bloc_test/board_test/util.dart @@ -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/grid_test/cell/select_option_cell_test.dart b/frontend/appflowy_flutter/test/bloc_test/grid_test/cell/select_option_cell_test.dart index cb5a652c7c3a0..fa2e6fb71ce2c 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 @@ -123,7 +123,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); 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 index 32b1d603d9ac9..64113fc55066a 100644 --- 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 @@ -10,7 +10,8 @@ Future createEditorBloc(AppFlowyGridTest gridTest) async { return FieldEditorBloc( viewId: context.gridView.id, fieldController: context.fieldController, - field: fieldInfo.field, + fieldInfo: fieldInfo, + isNew: false, ); } 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 66502b44cfdef..28c267f5702ea 100644 --- a/frontend/appflowy_flutter/test/bloc_test/grid_test/util.dart +++ b/frontend/appflowy_flutter/test/bloc_test/grid_test/util.dart @@ -102,7 +102,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), diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.lock b/frontend/appflowy_tauri/src-tauri/Cargo.lock index 88ce19eca45c7..274c9d6a1314b 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.lock +++ b/frontend/appflowy_tauri/src-tauri/Cargo.lock @@ -172,7 +172,7 @@ checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "bincode", @@ -192,11 +192,12 @@ dependencies = [ [[package]] name = "appflowy-ai-client" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "bytes", "futures", + "pin-project", "serde", "serde_json", "serde_repr", @@ -206,7 +207,7 @@ dependencies = [ [[package]] name = "appflowy-local-ai" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=c4ab1db44e96348f9b0770dd8ecc990f68ac415d#c4ab1db44e96348f9b0770dd8ecc990f68ac415d" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=6f064efe232268f8d396edbb4b84d57fbb640f13#6f064efe232268f8d396edbb4b84d57fbb640f13" dependencies = [ "anyhow", "appflowy-plugin", @@ -225,7 +226,7 @@ dependencies = [ [[package]] name = "appflowy-plugin" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=c4ab1db44e96348f9b0770dd8ecc990f68ac415d#c4ab1db44e96348f9b0770dd8ecc990f68ac415d" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=6f064efe232268f8d396edbb4b84d57fbb640f13#6f064efe232268f8d396edbb4b84d57fbb640f13" dependencies = [ "anyhow", "cfg-if", @@ -248,7 +249,7 @@ version = "0.0.0" dependencies = [ "bytes", "dotenv", - "flowy-chat", + "flowy-ai", "flowy-config", "flowy-core", "flowy-date", @@ -290,6 +291,17 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" +[[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-stream" version = "0.3.5" @@ -314,9 +326,9 @@ dependencies = [ [[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", @@ -415,17 +427,16 @@ 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", @@ -826,11 +837,12 @@ dependencies = [ [[package]] name = "client-api" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "again", "anyhow", "app-error", + "arc-swap", "async-trait", "bincode", "brotli", @@ -876,19 +888,20 @@ dependencies = [ [[package]] name = "client-api-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" 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=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "futures-channel", "futures-util", @@ -962,15 +975,16 @@ dependencies = [ [[package]] name = "collab" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=20f7814#20f7814beb265ea76e85ea7a9d392b9fe18a2a63" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b987647c190b337d0dca2718c93ff918e94b3730#b987647c190b337d0dca2718c93ff918e94b3730" dependencies = [ "anyhow", + "arc-swap", "async-trait", "bincode", "bytes", "chrono", "js-sys", - "parking_lot 0.12.1", + "lazy_static", "serde", "serde_json", "serde_repr", @@ -986,20 +1000,19 @@ dependencies = [ [[package]] name = "collab-database" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=20f7814#20f7814beb265ea76e85ea7a9d392b9fe18a2a63" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b987647c190b337d0dca2718c93ff918e94b3730#b987647c190b337d0dca2718c93ff918e94b3730" dependencies = [ "anyhow", "async-trait", "chrono", "collab", "collab-entity", - "collab-plugins", - "dashmap", + "dashmap 5.5.3", + "futures", "getrandom 0.2.10", "js-sys", "lazy_static", "nanoid", - "parking_lot 0.12.1", "rayon", "serde", "serde_json", @@ -1016,14 +1029,14 @@ dependencies = [ [[package]] name = "collab-document" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=20f7814#20f7814beb265ea76e85ea7a9d392b9fe18a2a63" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b987647c190b337d0dca2718c93ff918e94b3730#b987647c190b337d0dca2718c93ff918e94b3730" dependencies = [ "anyhow", + "arc-swap", "collab", "collab-entity", "getrandom 0.2.10", "nanoid", - "parking_lot 0.12.1", "serde", "serde_json", "thiserror", @@ -1036,29 +1049,34 @@ dependencies = [ [[package]] name = "collab-entity" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=20f7814#20f7814beb265ea76e85ea7a9d392b9fe18a2a63" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b987647c190b337d0dca2718c93ff918e94b3730#b987647c190b337d0dca2718c93ff918e94b3730" dependencies = [ "anyhow", "bytes", "collab", "getrandom 0.2.10", + "prost", + "prost-build", + "protoc-bin-vendored", "serde", "serde_json", "serde_repr", "uuid", + "walkdir", ] [[package]] name = "collab-folder" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=20f7814#20f7814beb265ea76e85ea7a9d392b9fe18a2a63" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b987647c190b337d0dca2718c93ff918e94b3730#b987647c190b337d0dca2718c93ff918e94b3730" 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", @@ -1073,13 +1091,17 @@ name = "collab-integrate" version = "0.1.0" dependencies = [ "anyhow", + "arc-swap", "async-trait", "collab", + "collab-database", + "collab-document", "collab-entity", + "collab-folder", "collab-plugins", + "collab-user", "futures", "lib-infra", - "parking_lot 0.12.1", "serde", "serde_json", "tokio", @@ -1089,7 +1111,7 @@ dependencies = [ [[package]] name = "collab-plugins" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=20f7814#20f7814beb265ea76e85ea7a9d392b9fe18a2a63" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b987647c190b337d0dca2718c93ff918e94b3730#b987647c190b337d0dca2718c93ff918e94b3730" dependencies = [ "anyhow", "async-stream", @@ -1105,7 +1127,6 @@ dependencies = [ "indexed_db_futures", "js-sys", "lazy_static", - "parking_lot 0.12.1", "rand 0.8.5", "rocksdb", "serde", @@ -1128,7 +1149,7 @@ dependencies = [ [[package]] name = "collab-rt-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "bincode", @@ -1153,7 +1174,7 @@ dependencies = [ [[package]] name = "collab-rt-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "async-trait", @@ -1170,13 +1191,12 @@ dependencies = [ [[package]] name = "collab-user" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=20f7814#20f7814beb265ea76e85ea7a9d392b9fe18a2a63" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b987647c190b337d0dca2718c93ff918e94b3730#b987647c190b337d0dca2718c93ff918e94b3730" dependencies = [ "anyhow", "collab", "collab-entity", "getrandom 0.2.10", - "parking_lot 0.12.1", "serde", "serde_json", "tokio", @@ -1200,6 +1220,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console" version = "0.14.1" @@ -1519,6 +1548,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" @@ -1528,7 +1571,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "app-error", @@ -1841,6 +1884,27 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "event-listener" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f214dc438f977e6d4e3500aaa277f5ad94ca83fbbd9b1a15713ce2344ccc5a1" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "faccess" version = "0.2.4" @@ -1920,14 +1984,14 @@ dependencies = [ [[package]] name = "filetime" -version = "0.2.21" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cbc844cecaee9d4443931972e1289c8ff485cb4cc2767cb03ca139ed6885153" +checksum = "1ee447700ac8aa0b2f2bd7bc4462ad686ba06baa6727ac149a2d6277f0d240fd" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.2.16", - "windows-sys 0.48.0", + "redox_syscall 0.4.1", + "windows-sys 0.52.0", ] [[package]] @@ -1947,38 +2011,32 @@ dependencies = [ ] [[package]] -name = "flowy-ast" -version = "0.1.0" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "flowy-chat" +name = "flowy-ai" version = "0.1.0" dependencies = [ "allo-isolate", "anyhow", "appflowy-local-ai", "appflowy-plugin", + "arc-swap", "base64 0.21.5", "bytes", - "dashmap", - "flowy-chat-pub", + "dashmap 6.0.1", + "flowy-ai-pub", "flowy-codegen", "flowy-derive", "flowy-error", "flowy-notification", "flowy-sqlite", + "flowy-storage-pub", "futures", "futures-util", "lib-dispatch", "lib-infra", "log", "md5", - "parking_lot 0.12.1", + "notify", + "pin-project", "protobuf", "reqwest", "serde", @@ -1996,7 +2054,7 @@ dependencies = [ ] [[package]] -name = "flowy-chat-pub" +name = "flowy-ai-pub" version = "0.1.0" dependencies = [ "bytes", @@ -2004,6 +2062,16 @@ dependencies = [ "flowy-error", "futures", "lib-infra", + "serde_json", +] + +[[package]] +name = "flowy-ast" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", ] [[package]] @@ -2049,6 +2117,8 @@ name = "flowy-core" version = "0.1.0" dependencies = [ "anyhow", + "appflowy-local-ai", + "arc-swap", "base64 0.21.5", "bytes", "client-api", @@ -2056,9 +2126,10 @@ dependencies = [ "collab-entity", "collab-integrate", "collab-plugins", + "dashmap 6.0.1", "diesel", - "flowy-chat", - "flowy-chat-pub", + "flowy-ai", + "flowy-ai-pub", "flowy-config", "flowy-database-pub", "flowy-database2", @@ -2082,7 +2153,6 @@ dependencies = [ "lib-dispatch", "lib-infra", "lib-log", - "parking_lot 0.12.1", "semver", "serde", "serde_json", @@ -2112,6 +2182,7 @@ name = "flowy-database2" version = "0.1.0" dependencies = [ "anyhow", + "arc-swap", "async-stream", "async-trait", "bytes", @@ -2123,7 +2194,7 @@ dependencies = [ "collab-integrate", "collab-plugins", "csv", - "dashmap", + "dashmap 6.0.1", "fancy-regex 0.11.0", "flowy-codegen", "flowy-database-pub", @@ -2136,7 +2207,6 @@ dependencies = [ "lib-dispatch", "lib-infra", "nanoid", - "parking_lot 0.12.1", "protobuf", "rayon", "rust_decimal", @@ -2147,6 +2217,7 @@ dependencies = [ "strum", "strum_macros 0.25.2", "tokio", + "tokio-util", "tracing", "url", "validator", @@ -2173,7 +2244,7 @@ dependencies = [ name = "flowy-derive" version = "0.1.0" dependencies = [ - "dashmap", + "dashmap 6.0.1", "flowy-ast", "flowy-codegen", "lazy_static", @@ -2195,7 +2266,7 @@ dependencies = [ "collab-entity", "collab-integrate", "collab-plugins", - "dashmap", + "dashmap 6.0.1", "flowy-codegen", "flowy-derive", "flowy-document-pub", @@ -2208,7 +2279,6 @@ dependencies = [ "lib-dispatch", "lib-infra", "nanoid", - "parking_lot 0.12.1", "protobuf", "scraper 0.18.1", "serde", @@ -2279,6 +2349,7 @@ dependencies = [ name = "flowy-folder" version = "0.1.0" dependencies = [ + "arc-swap", "async-trait", "bytes", "chrono", @@ -2300,7 +2371,6 @@ dependencies = [ "lib-dispatch", "lib-infra", "nanoid", - "parking_lot 0.12.1", "protobuf", "regex", "serde", @@ -2332,7 +2402,7 @@ name = "flowy-notification" version = "0.1.0" dependencies = [ "bytes", - "dashmap", + "dashmap 6.0.1", "flowy-codegen", "flowy-derive", "lazy_static", @@ -2395,15 +2465,18 @@ name = "flowy-server" version = "0.1.0" dependencies = [ "anyhow", + "arc-swap", "bytes", "chrono", "client-api", "collab", + "collab-database", "collab-document", "collab-entity", "collab-folder", "collab-plugins", - "flowy-chat-pub", + "dashmap 6.0.1", + "flowy-ai-pub", "flowy-database-pub", "flowy-document-pub", "flowy-encrypt", @@ -2422,7 +2495,6 @@ dependencies = [ "lib-dispatch", "lib-infra", "mime_guess", - "parking_lot 0.12.1", "postgrest", "rand 0.8.5", "reqwest", @@ -2458,7 +2530,6 @@ dependencies = [ "diesel_derives", "diesel_migrations", "libsqlite3-sys", - "parking_lot 0.12.1", "r2d2", "scheduled-thread-pool", "serde", @@ -2471,22 +2542,27 @@ dependencies = [ name = "flowy-storage" version = "0.1.0" dependencies = [ + "allo-isolate", "anyhow", "async-trait", "bytes", "chrono", + "dashmap 6.0.1", "flowy-codegen", "flowy-derive", "flowy-error", "flowy-notification", "flowy-sqlite", "flowy-storage-pub", + "futures-util", "fxhash", + "lib-dispatch", "lib-infra", "mime_guess", "protobuf", "serde", "serde_json", + "strum_macros 0.25.2", "tokio", "tracing", "url", @@ -2507,6 +2583,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "tracing", ] [[package]] @@ -2514,6 +2591,7 @@ name = "flowy-user" version = "0.1.0" dependencies = [ "anyhow", + "arc-swap", "base64 0.21.5", "bytes", "chrono", @@ -2526,6 +2604,7 @@ dependencies = [ "collab-integrate", "collab-plugins", "collab-user", + "dashmap 6.0.1", "diesel", "diesel_derives", "fancy-regex 0.11.0", @@ -2542,7 +2621,6 @@ dependencies = [ "lib-dispatch", "lib-infra", "once_cell", - "parking_lot 0.12.1", "protobuf", "semver", "serde", @@ -2620,6 +2698,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "funty" version = "2.0.0" @@ -3034,7 +3121,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "futures-util", @@ -3051,7 +3138,7 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "app-error", @@ -3483,7 +3570,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "bytes", @@ -3494,6 +3581,26 @@ dependencies = [ "tracing", ] +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.3" @@ -3651,6 +3758,26 @@ dependencies = [ "simple_asn1", ] +[[package]] +name = "kqueue" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7447f1ca1b7b563588a205fe93dea8df60fd981423a768bc1c0ded35ed147d0c" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "kuchiki" version = "0.8.1" @@ -3708,7 +3835,6 @@ dependencies = [ "futures-util", "getrandom 0.2.10", "nanoid", - "parking_lot 0.12.1", "pin-project", "protobuf", "serde", @@ -3735,6 +3861,7 @@ dependencies = [ "chrono", "futures", "futures-core", + "futures-util", "md5", "pin-project", "tempfile", @@ -3785,9 +3912,9 @@ checksum = "f7012b1bbb0719e1097c47611d3898568c546d597c2e74d66f6087edd5233ff4" [[package]] name = "librocksdb-sys" -version = "0.11.0+8.1.1" +version = "0.16.0+8.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3386f101bcb4bd252d8e9d2fb41ec3b0862a15a62b478c355b2982efa469e3e" +checksum = "ce3d60bc059831dc1c83903fb45c103f75db65c5a7bf22272764d9cc683e348c" dependencies = [ "bindgen", "bzip2-sys", @@ -4112,6 +4239,7 @@ 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", ] @@ -4205,6 +4333,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", + "walkdir", + "windows-sys 0.48.0", +] + [[package]] name = "ntapi" version = "0.4.1" @@ -4514,6 +4661,12 @@ dependencies = [ "system-deps 6.1.1", ] +[[package]] +name = "parking" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae" + [[package]] name = "parking_lot" version = "0.11.2" @@ -4610,12 +4763,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" @@ -5546,9 +5693,9 @@ dependencies = [ [[package]] name = "rocksdb" -version = "0.21.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb6f170a4041d50a0ce04b0d2e14916d6ca863ea2e422689a5b694395d299ffe" +checksum = "6bd13e55d6d7b8cd0ea569161127567cd587676c99f4472f779a0279aa60a7a7" dependencies = [ "libc", "librocksdb-sys", @@ -6027,7 +6174,7 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "app-error", @@ -6045,6 +6192,7 @@ dependencies = [ "serde_json", "serde_repr", "thiserror", + "tracing", "uuid", ] @@ -8305,12 +8453,14 @@ dependencies = [ [[package]] name = "yrs" -version = "0.19.2" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8ca5126331b9a5ef5bb10f3f1c3d01b05f298d348c66f8fb15497d83ee73176" +checksum = "a8fc56b25e3aaf4b81a73f2a9a68ceae1e02d9005552e24058cfb9f96db73f33" dependencies = [ "arc-swap", - "atomic_refcell", + "async-lock", + "async-trait", + "dashmap 6.0.1", "fastrand", "serde", "serde_json", diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.toml b/frontend/appflowy_tauri/src-tauri/Cargo.toml index f128965d6d958..05c822cd962fd 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.toml +++ b/frontend/appflowy_tauri/src-tauri/Cargo.toml @@ -20,7 +20,12 @@ bytes = "1.5.0" serde = "1.0" serde_json = "1.0.108" protobuf = { version = "2.28.0" } -diesel = { version = "2.1.0", features = ["sqlite", "chrono", "r2d2", "serde_json"] } +diesel = { version = "2.1.0", features = [ + "sqlite", + "chrono", + "r2d2", + "serde_json", +] } uuid = { version = "1.5.0", features = ["serde", "v4"] } serde_repr = "0.1" parking_lot = "0.12" @@ -53,7 +58,7 @@ collab-user = { version = "0.2" } # Run the script: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "c2a839ba8bf9ead44679eb08f3a9680467b767ca" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "886376e8bd95ef633aa4b3d7a8377f1003764748" } [dependencies] serde_json.workspace = true @@ -70,13 +75,11 @@ tracing.workspace = true lib-dispatch = { path = "../../rust-lib/lib-dispatch", features = [ "use_serde", ] } -flowy-core = { path = "../../rust-lib/flowy-core", features = [ - "ts", -] } +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-chat = { path = "../../rust-lib/flowy-chat", features = ["tauri_ts"] } +flowy-ai = { path = "../../rust-lib/flowy-ai", features = ["tauri_ts"] } flowy-error = { path = "../../rust-lib/flowy-error", features = [ "impl_from_sqlite", "impl_from_dispatch_error", @@ -116,17 +119,17 @@ custom-protocol = ["tauri/custom-protocol"] # 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 = "20f7814" } -collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "20f7814" } -collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "20f7814" } -collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "20f7814" } -collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "20f7814" } -collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "20f7814" } -collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "20f7814" } +collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b987647c190b337d0dca2718c93ff918e94b3730" } +collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b987647c190b337d0dca2718c93ff918e94b3730" } +collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b987647c190b337d0dca2718c93ff918e94b3730" } +collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b987647c190b337d0dca2718c93ff918e94b3730" } +collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b987647c190b337d0dca2718c93ff918e94b3730" } +collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b987647c190b337d0dca2718c93ff918e94b3730" } +collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b987647c190b337d0dca2718c93ff918e94b3730" } # Working directory: frontend # To update the commit ID, run: # scripts/tool/update_local_ai_rev.sh new_rev_id # ⚠️⚠️⚠️️ -appflowy-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "c4ab1db44e96348f9b0770dd8ecc990f68ac415d" } -appflowy-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "c4ab1db44e96348f9b0770dd8ecc990f68ac415d" } +appflowy-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "6f064efe232268f8d396edbb4b84d57fbb640f13" } +appflowy-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "6f064efe232268f8d396edbb4b84d57fbb640f13" } diff --git a/frontend/appflowy_tauri/src-tauri/src/init.rs b/frontend/appflowy_tauri/src-tauri/src/init.rs index 7591ba37ffb13..4903e1fe343e1 100644 --- a/frontend/appflowy_tauri/src-tauri/src/init.rs +++ b/frontend/appflowy_tauri/src-tauri/src/init.rs @@ -1,9 +1,8 @@ +use dotenv::dotenv; use flowy_core::config::AppFlowyCoreConfig; use flowy_core::{AppFlowyCore, DEFAULT_NAME}; use lib_dispatch::runtime::AFPluginRuntime; -use std::sync::Arc; - -use dotenv::dotenv; +use std::sync::Mutex; pub fn read_env() { dotenv().ok(); @@ -25,7 +24,7 @@ pub fn read_env() { } } -pub fn init_flowy_core() -> AppFlowyCore { +pub(crate) fn init_appflowy_core() -> MutexAppFlowyCore { let config_json = include_str!("../tauri.conf.json"); let config: tauri_utils::config::Config = serde_json::from_str(config_json).unwrap(); @@ -35,7 +34,8 @@ pub fn init_flowy_core() -> AppFlowyCore { .clone() .map(|v| v.to_string()) .unwrap_or_else(|| "0.5.8".to_string()); - let app_version = semver::Version::parse(&app_version).unwrap_or_else(|_| semver::Version::new(0, 5, 8)); + let app_version = + semver::Version::parse(&app_version).unwrap_or_else(|_| semver::Version::new(0, 5, 8)); let mut data_path = tauri::api::path::app_local_data_dir(&config).unwrap(); if cfg!(debug_assertions) { data_path.push("data_dev"); @@ -62,5 +62,17 @@ pub fn init_flowy_core() -> AppFlowyCore { let runtime = Arc::new(AFPluginRuntime::new().unwrap()); let cloned_runtime = runtime.clone(); - runtime.block_on(async move { AppFlowyCore::new(config, cloned_runtime, None).await }) + runtime.block_on(async move { + MutexAppFlowyCore::new(AppFlowyCore::new(config, cloned_runtime, None).await) + }) +} + +pub struct MutexAppFlowyCore(pub Arc>); + +impl MutexAppFlowyCore { + fn new(appflowy_core: AppFlowyCore) -> Self { + Self(Arc::new(Mutex::new(appflowy_core))) + } } +unsafe impl Sync for MutexAppFlowyCore {} +unsafe impl Send for MutexAppFlowyCore {} diff --git a/frontend/appflowy_tauri/src-tauri/src/main.rs b/frontend/appflowy_tauri/src-tauri/src/main.rs index 6a69de07fd322..5f12d1be81126 100644 --- a/frontend/appflowy_tauri/src-tauri/src/main.rs +++ b/frontend/appflowy_tauri/src-tauri/src/main.rs @@ -11,17 +11,18 @@ mod init; mod notification; mod request; +use crate::init::init_appflowy_core; +use crate::request::invoke_request; use flowy_notification::{register_notification_sender, unregister_all_notification_sender}; -use 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(); + let flowy_core = init_appflowy_core(); tauri::Builder::default() .invoke_handler(tauri::generate_handler![invoke_request]) .manage(flowy_core) diff --git a/frontend/appflowy_tauri/src-tauri/src/request.rs b/frontend/appflowy_tauri/src-tauri/src/request.rs index 029e71c18cd9c..ff69a438c9c19 100644 --- a/frontend/appflowy_tauri/src-tauri/src/request.rs +++ b/frontend/appflowy_tauri/src-tauri/src/request.rs @@ -1,4 +1,4 @@ -use flowy_core::AppFlowyCore; +use crate::init::MutexAppFlowyCore; use lib_dispatch::prelude::{ AFPluginDispatcher, AFPluginEventResponse, AFPluginRequest, StatusCode, }; @@ -38,8 +38,8 @@ pub async fn invoke_request( 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; + let state: State = app_handler.state(); + let dispatcher = state.0.lock().unwrap().dispatcher(); + let response = AFPluginDispatcher::sync_send(dispatcher, request); response.into() } diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/row/row_service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/row/row_service.ts index 029da3b0c9646..7993f709b73c0 100644 --- 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 @@ -3,12 +3,13 @@ import { MoveGroupRowPayloadPB, MoveRowPayloadPB, OrderObjectPositionTypePB, + RepeatedRowIdPB, RowIdPB, UpdateRowMetaChangesetPB, } from '@/services/backend'; import { DatabaseEventCreateRow, - DatabaseEventDeleteRow, + DatabaseEventDeleteRows, DatabaseEventDuplicateRow, DatabaseEventGetRowMeta, DatabaseEventMoveGroupRow, @@ -51,13 +52,12 @@ export async function duplicateRow(viewId: string, rowId: string, groupId?: stri } export async function deleteRow(viewId: string, rowId: string, groupId?: string): Promise { - const payload = RowIdPB.fromObject({ + const payload = RepeatedRowIdPB.fromObject({ view_id: viewId, - row_id: rowId, - group_id: groupId, + row_ids: [rowId], }); - const result = await DatabaseEventDeleteRow(payload); + const result = await DatabaseEventDeleteRows(payload); return result.unwrap(); } 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 index a6b66a4c1fbdb..d39da68caf921 100644 --- 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 @@ -11,15 +11,13 @@ export function UploadImage({ onDone }: { onDone?: (url: string) => void }) { 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; - } + // 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; }, diff --git a/frontend/appflowy_tauri/src/services/backend/index.ts b/frontend/appflowy_tauri/src/services/backend/index.ts index 1cc17c1e1bff2..3e02ff7183a73 100644 --- a/frontend/appflowy_tauri/src/services/backend/index.ts +++ b/frontend/appflowy_tauri/src/services/backend/index.ts @@ -6,3 +6,4 @@ export * from "./models/flowy-error"; export * from "./models/flowy-config"; export * from "./models/flowy-date"; export * from "./models/flowy-search"; +export * from "./models/flowy-storage"; diff --git a/frontend/appflowy_web_app/deploy/nginx.conf b/frontend/appflowy_web_app/deploy/nginx.conf index e0a658c31039d..2ae55fae7b13c 100644 --- a/frontend/appflowy_web_app/deploy/nginx.conf +++ b/frontend/appflowy_web_app/deploy/nginx.conf @@ -54,10 +54,7 @@ http { root /usr/share/nginx/html; expires 30d; access_log off; - location ~* \.wasm$ { - types { application/wasm wasm; } - default_type application/wasm; - } + } location /appflowy.svg { @@ -78,6 +75,12 @@ http { access_log off; } + location /af_icons/ { + root /usr/share/nginx/html; + expires 30d; + access_log off; + } + error_page 404 /404.html; location = /404.html { root /usr/share/nginx/html; diff --git a/frontend/appflowy_web_app/deploy/server.ts b/frontend/appflowy_web_app/deploy/server.ts index f2fb31619f74c..221116aa3ab1a 100644 --- a/frontend/appflowy_web_app/deploy/server.ts +++ b/frontend/appflowy_web_app/deploy/server.ts @@ -68,14 +68,25 @@ const createServer = async (req: Request) => { logger.info(`Request URL: ${hostname}${reqUrl.pathname}`); - if (reqUrl.pathname === '/after-payment') { + if (['/after-payment', '/login', '/as-template'].includes(reqUrl.pathname)) { timer(); const htmlData = fs.readFileSync(indexPath, 'utf8'); const $ = load(htmlData); - $('title').text('Payment Success | AppFlowy'); - $('link[rel="icon"]').attr('href', '/appflowy.svg'); - setOrUpdateMetaTag($, 'meta[name="description"]', 'name', 'Payment success on AppFlowy'); + let title, description; + + if (reqUrl.pathname === '/after-payment') { + title = 'Payment Success | AppFlowy'; + description = 'Payment success on AppFlowy'; + } + + if (reqUrl.pathname === '/login') { + title = 'Login | AppFlowy'; + description = 'Login to AppFlowy'; + } + + if (title) $('title').text(title); + if (description) setOrUpdateMetaTag($, 'meta[name="description"]', 'name', description); return new Response($.html(), { headers: { 'Content-Type': 'text/html' }, @@ -117,7 +128,7 @@ const createServer = async (req: Request) => { try { if (metaData && metaData.view) { const view = metaData.view; - const emoji = view.icon.value; + const emoji = view.icon?.ty === 0 && view.icon?.value; const titleList = []; if (emoji) { diff --git a/frontend/appflowy_web_app/index.html b/frontend/appflowy_web_app/index.html index 49615f8cb0898..4c8514b049fd7 100644 --- a/frontend/appflowy_web_app/index.html +++ b/frontend/appflowy_web_app/index.html @@ -31,7 +31,6 @@
- - - - + + diff --git a/frontend/appflowy_web_app/package.json b/frontend/appflowy_web_app/package.json index 22bf0cb6317d8..3c72bc1a26434 100644 --- a/frontend/appflowy_web_app/package.json +++ b/frontend/appflowy_web_app/package.json @@ -24,7 +24,6 @@ "coverage": "pnpm run test:unit && pnpm run test:components" }, "dependencies": { - "@appflowyinc/client-api-wasm": "0.1.2", "@atlaskit/primitives": "^5.5.3", "@emoji-mart/data": "^1.1.2", "@emoji-mart/react": "^1.1.1", @@ -49,6 +48,7 @@ "emoji-regex": "^10.2.1", "events": "^3.3.0", "google-protobuf": "^3.15.12", + "highlight.js": "^11.10.0", "i18next": "^22.4.10", "i18next-browser-languagedetector": "^7.0.1", "i18next-resources-to-backend": "^1.1.4", @@ -56,6 +56,7 @@ "jest": "^29.5.0", "js-base64": "^3.7.5", "katex": "^0.16.7", + "lightgallery": "^2.7.2", "lodash-es": "^4.17.21", "nanoid": "^4.0.0", "notistack": "^3.0.1", @@ -73,6 +74,8 @@ "react-datepicker": "^4.23.0", "react-dom": "^18.2.0", "react-error-boundary": "^4.0.13", + "react-helmet": "^6.1.0", + "react-hook-form": "^7.52.2", "react-hot-toast": "^2.4.1", "react-i18next": "^14.1.0", "react-katex": "^3.0.1", @@ -84,6 +87,7 @@ "react-virtualized-auto-sizer": "^1.0.20", "react-vtree": "^2.0.4", "react-window": "^1.8.10", + "react-zoom-pan-pinch": "^3.6.1", "react18-input-otp": "^1.1.2", "redux": "^4.2.1", "rxjs": "^7.8.0", @@ -126,6 +130,7 @@ "@types/react-custom-scrollbars": "^4.0.13", "@types/react-datepicker": "^4.19.3", "@types/react-dom": "^18.2.22", + "@types/react-helmet": "^6.1.11", "@types/react-katex": "^3.0.0", "@types/react-measure": "^2.0.12", "@types/react-transition-group": "^4.4.6", @@ -137,6 +142,7 @@ "@typescript-eslint/parser": "^7.2.0", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.13", + "axios-mock-adapter": "^2.0.0", "babel-jest": "^29.6.2", "chalk": "^4.1.2", "cheerio": "1.0.0-rc.12", diff --git a/frontend/appflowy_web_app/pnpm-lock.yaml b/frontend/appflowy_web_app/pnpm-lock.yaml index 584d748c790dc..d021b0ff7ef36 100644 --- a/frontend/appflowy_web_app/pnpm-lock.yaml +++ b/frontend/appflowy_web_app/pnpm-lock.yaml @@ -5,9 +5,6 @@ settings: excludeLinksFromLockfile: false dependencies: - '@appflowyinc/client-api-wasm': - specifier: 0.1.2 - version: 0.1.2 '@atlaskit/primitives': specifier: ^5.5.3 version: 5.7.0(@types/react@18.2.66)(react@18.2.0) @@ -80,6 +77,9 @@ dependencies: google-protobuf: specifier: ^3.15.12 version: 3.21.2 + highlight.js: + specifier: ^11.10.0 + version: 11.10.0 i18next: specifier: ^22.4.10 version: 22.5.1 @@ -101,6 +101,9 @@ dependencies: katex: specifier: ^0.16.7 version: 0.16.10 + lightgallery: + specifier: ^2.7.2 + version: 2.7.2 lodash-es: specifier: ^4.17.21 version: 4.17.21 @@ -152,6 +155,12 @@ dependencies: react-error-boundary: specifier: ^4.0.13 version: 4.0.13(react@18.2.0) + react-helmet: + specifier: ^6.1.0 + version: 6.1.0(react@18.2.0) + react-hook-form: + specifier: ^7.52.2 + version: 7.52.2(react@18.2.0) react-hot-toast: specifier: ^2.4.1 version: 2.4.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0) @@ -185,6 +194,9 @@ dependencies: react-window: specifier: ^1.8.10 version: 1.8.10(react-dom@18.2.0)(react@18.2.0) + react-zoom-pan-pinch: + specifier: ^3.6.1 + version: 3.6.1(react-dom@18.2.0)(react@18.2.0) react18-input-otp: specifier: ^1.1.2 version: 1.1.4(react-dom@18.2.0)(react@18.2.0) @@ -307,6 +319,9 @@ devDependencies: '@types/react-dom': specifier: ^18.2.22 version: 18.2.22 + '@types/react-helmet': + specifier: ^6.1.11 + version: 6.1.11 '@types/react-katex': specifier: ^3.0.0 version: 3.0.0 @@ -340,6 +355,9 @@ devDependencies: autoprefixer: specifier: ^10.4.13 version: 10.4.13(postcss@8.4.21) + axios-mock-adapter: + specifier: ^2.0.0 + version: 2.0.0(axios@1.7.2) babel-jest: specifier: ^29.6.2 version: 29.6.2(@babel/core@7.24.3) @@ -451,10 +469,6 @@ packages: '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 - /@appflowyinc/client-api-wasm@0.1.2: - resolution: {integrity: sha512-+v0hs7/7BVKtgev/Bcbr0u2HLDhUuw4ZvZTaMddI+06HK8vt5S52dMaZKUcMvh1eUjVX8hjC6Mfe0X/yHqvFgA==} - dev: false - /@atlaskit/analytics-next-stable-react-context@1.0.1(react@18.2.0): resolution: {integrity: sha512-iO6+hIp09dF4iAZQarVz3vKY1kM5Ij5CExYcK9jgc2q+OH8nv8n+BPFeJTdzGOGopmbUZn5Opj9pYQvge1Gr4Q==} peerDependencies: @@ -4208,6 +4222,12 @@ packages: dependencies: '@types/react': 18.2.66 + /@types/react-helmet@6.1.11: + resolution: {integrity: sha512-0QcdGLddTERotCXo3VFlUSWO3ztraw8nZ6e3zJSgG7apwV5xt+pJUS8ewPBqT4NYB1optGLprNQzFleIY84u/g==} + dependencies: + '@types/react': 18.2.66 + dev: true + /@types/react-katex@3.0.0: resolution: {integrity: sha512-AiHHXh71a2M7Z6z1wj6iA23SkiRF9r0neHUdu8zjU/cT3MyLxDefYHbcceKhV/gjDEZgF3YaiNHyPNtoGUjPvg==} dependencies: @@ -4949,6 +4969,16 @@ packages: /aws4@1.12.0: resolution: {integrity: sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==} + /axios-mock-adapter@2.0.0(axios@1.7.2): + resolution: {integrity: sha512-D/K0J5Zm6KvaMTnsWrBQZWLzKN9GxUFZEa0mx2qeEHXDeTugCoplWehy8y36dj5vuSjhe1u/Dol8cZ8lzzmDew==} + peerDependencies: + axios: '>= 0.17.0' + dependencies: + axios: 1.7.2 + fast-deep-equal: 3.1.3 + is-buffer: 2.0.5 + dev: true + /axios@1.7.2: resolution: {integrity: sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==} dependencies: @@ -4957,7 +4987,6 @@ packages: proxy-from-env: 1.1.0 transitivePeerDependencies: - debug - dev: false /b4a@1.6.6: resolution: {integrity: sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg==} @@ -6713,7 +6742,6 @@ packages: peerDependenciesMeta: debug: optional: true - dev: false /for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} @@ -7068,6 +7096,11 @@ packages: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} dev: true + /highlight.js@11.10.0: + resolution: {integrity: sha512-SYVnVFswQER+zu1laSya563s+F8VDGt7o35d4utbamowvUNLLMovFqwCLSocpZTz3MgaSRA1IbqRWZv97dtErQ==} + engines: {node: '>=12.0.0'} + dev: false + /hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} dependencies: @@ -7288,6 +7321,11 @@ packages: resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==} dev: false + /is-buffer@2.0.5: + resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==} + engines: {node: '>=4'} + dev: true + /is-callable@1.2.7: resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} engines: {node: '>= 0.4'} @@ -8207,6 +8245,11 @@ packages: isomorphic.js: 0.2.5 dev: false + /lightgallery@2.7.2: + resolution: {integrity: sha512-Ewdcg9UPDqV0HGZeD7wNE4uYejwH2u0fMo5VAr6GHzlPYlhItJvjhLTR0cL0V1HjhMsH39PAom9iv69ewitLWw==} + engines: {node: '>=6.0.0'} + dev: false + /lilconfig@2.1.0: resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} engines: {node: '>=10'} @@ -9257,7 +9300,6 @@ packages: /proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - dev: false /psl@1.9.0: resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} @@ -9511,6 +9553,27 @@ packages: /react-fast-compare@3.2.2: resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + /react-helmet@6.1.0(react@18.2.0): + resolution: {integrity: sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw==} + peerDependencies: + react: '>=16.3.0' + dependencies: + object-assign: 4.1.1 + prop-types: 15.8.1 + react: 18.2.0 + react-fast-compare: 3.2.2 + react-side-effect: 2.1.2(react@18.2.0) + dev: false + + /react-hook-form@7.52.2(react@18.2.0): + resolution: {integrity: sha512-pqfPEbERnxxiNMPd0bzmt1tuaPcVccywFDpyk2uV5xCIBphHV5T8SVnX9/o3kplPE1zzKt77+YIoq+EMwJp56A==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + dependencies: + react: 18.2.0 + dev: false + /react-hot-toast@2.4.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==} engines: {node: '>=10'} @@ -9708,6 +9771,14 @@ packages: react: 18.2.0 dev: false + /react-side-effect@2.1.2(react@18.2.0): + resolution: {integrity: sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw==} + peerDependencies: + react: ^16.3.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + dev: false + /react-swipeable-views-core@0.14.0: resolution: {integrity: sha512-0W/e9uPweNEOSPjmYtuKSC/SvKKg1sfo+WtPdnxeLF3t2L82h7jjszuOHz9C23fzkvLfdgkaOmcbAxE9w2GEjA==} engines: {node: '>=6.0.0'} @@ -9796,6 +9867,17 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /react-zoom-pan-pinch@3.6.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-SdPqdk7QDSV7u/WulkFOi+cnza8rEZ0XX4ZpeH7vx3UZEg7DoyuAy3MCmm+BWv/idPQL2Oe73VoC0EhfCN+sZQ==} + engines: {node: '>=8', npm: '>=5'} + peerDependencies: + react: '*' + react-dom: '*' + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /react18-input-otp@1.1.4(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-35xvmTeuPWIxd0Z0Opx4z3OoMaTmKN4ubirQCx1YMZiNoe+2h1hsOSUco4aKPlGXWZCtXrfOFieAh46vqiK9mA==} peerDependencies: diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-chip-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-chip-spark.svg new file mode 100644 index 0000000000000..57bb666ba95d3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-chip-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-cloud-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-cloud-spark.svg new file mode 100644 index 0000000000000..385aaf5a03635 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-cloud-spark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-edit-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-edit-spark.svg new file mode 100644 index 0000000000000..96fddfc558523 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-edit-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-email-generator-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-email-generator-spark.svg new file mode 100644 index 0000000000000..8238d694428d6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-email-generator-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-gaming-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-gaming-spark.svg new file mode 100644 index 0000000000000..a74a6eabb088e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-gaming-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-generate-landscape-image-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-generate-landscape-image-spark.svg new file mode 100644 index 0000000000000..0759443d47d93 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-generate-landscape-image-spark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-generate-music-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-generate-music-spark.svg new file mode 100644 index 0000000000000..98adcabbe6dc7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-generate-music-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-generate-portrait-image-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-generate-portrait-image-spark.svg new file mode 100644 index 0000000000000..ebd118dd629e8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-generate-portrait-image-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-generate-variation-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-generate-variation-spark.svg new file mode 100644 index 0000000000000..c4400c215a17a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-generate-variation-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-navigation-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-navigation-spark.svg new file mode 100644 index 0000000000000..943e8354bda28 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-navigation-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-network-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-network-spark.svg new file mode 100644 index 0000000000000..ec21b6dc5207c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-network-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-prompt-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-prompt-spark.svg new file mode 100644 index 0000000000000..ecfcb6ad63fd1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-prompt-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-redo-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-redo-spark.svg new file mode 100644 index 0000000000000..d67e5e3f1fcc2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-redo-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-science-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-science-spark.svg new file mode 100644 index 0000000000000..e9a0af9957b8a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-science-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-settings-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-settings-spark.svg new file mode 100644 index 0000000000000..c0a1d6588b907 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-settings-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-technology-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-technology-spark.svg new file mode 100644 index 0000000000000..27b27f152aa57 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-technology-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-upscale-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-upscale-spark.svg new file mode 100644 index 0000000000000..91975ee23c370 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-upscale-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-vehicle-spark-1.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-vehicle-spark-1.svg new file mode 100644 index 0000000000000..48f3eda8dd687 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-vehicle-spark-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/artificial-intelligence-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/artificial-intelligence-spark.svg new file mode 100644 index 0000000000000..c4c7907937a02 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/artificial-intelligence-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/VPN-connection.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/VPN-connection.svg new file mode 100644 index 0000000000000..c8f7a2fcb0dd8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/VPN-connection.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/adobe.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/adobe.svg new file mode 100644 index 0000000000000..877de4e09492b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/adobe.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/alt.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/alt.svg new file mode 100644 index 0000000000000..6e08a0f9a347b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/alt.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/amazon.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/amazon.svg new file mode 100644 index 0000000000000..d02e8a27c2eab --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/amazon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/android.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/android.svg new file mode 100644 index 0000000000000..acc983a1a7680 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/android.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/app-store.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/app-store.svg new file mode 100644 index 0000000000000..59f17cc19752d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/app-store.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/apple.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/apple.svg new file mode 100644 index 0000000000000..94bfcf6cecf6d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/apple.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/asterisk-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/asterisk-1.svg new file mode 100644 index 0000000000000..c6b49a655d80a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/asterisk-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-alert-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-alert-1.svg new file mode 100644 index 0000000000000..dbfd40fefbdf5 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-alert-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-charging.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-charging.svg new file mode 100644 index 0000000000000..22aa568e4beb1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-charging.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-empty-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-empty-1.svg new file mode 100644 index 0000000000000..d64921afe274b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-empty-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-empty-2.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-empty-2.svg new file mode 100644 index 0000000000000..d7bac48cc846c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-empty-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-full-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-full-1.svg new file mode 100644 index 0000000000000..4c7e68f5b5063 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-full-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-low-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-low-1.svg new file mode 100644 index 0000000000000..6524eb33001e4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-low-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-medium-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-medium-1.svg new file mode 100644 index 0000000000000..4620aa3da4c53 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-medium-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/bluetooth-disabled.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/bluetooth-disabled.svg new file mode 100644 index 0000000000000..47487db565231 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/bluetooth-disabled.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/bluetooth-searching.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/bluetooth-searching.svg new file mode 100644 index 0000000000000..4535898788fd0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/bluetooth-searching.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/bluetooth.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/bluetooth.svg new file mode 100644 index 0000000000000..281960065f817 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/bluetooth.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/browser-wifi.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/browser-wifi.svg new file mode 100644 index 0000000000000..a81eccedf237a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/browser-wifi.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/chrome.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/chrome.svg new file mode 100644 index 0000000000000..56fc7af710f26 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/chrome.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/command.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/command.svg new file mode 100644 index 0000000000000..367a6e61170ad --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/command.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/computer-chip-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/computer-chip-1.svg new file mode 100644 index 0000000000000..2a70e75274cf9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/computer-chip-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/computer-chip-2.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/computer-chip-2.svg new file mode 100644 index 0000000000000..0ef3150becafb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/computer-chip-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/computer-pc-desktop.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/computer-pc-desktop.svg new file mode 100644 index 0000000000000..98bea051965ed --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/computer-pc-desktop.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/controller-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/controller-1.svg new file mode 100644 index 0000000000000..bf7bab0923767 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/controller-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/controller-wireless.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/controller-wireless.svg new file mode 100644 index 0000000000000..53045de283a51 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/controller-wireless.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/controller.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/controller.svg new file mode 100644 index 0000000000000..87ba8122dbeb9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/controller.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/cursor-click.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/cursor-click.svg new file mode 100644 index 0000000000000..2ca4ede8d0016 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/cursor-click.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/cyborg-2.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/cyborg-2.svg new file mode 100644 index 0000000000000..f90dbd9ce303d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/cyborg-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/cyborg.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/cyborg.svg new file mode 100644 index 0000000000000..cbdb10ea877de --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/cyborg.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/database-check.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-check.svg new file mode 100644 index 0000000000000..462f928903bed --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-check.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/database-lock.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-lock.svg new file mode 100644 index 0000000000000..60ee0c76ba167 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-lock.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/database-refresh.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-refresh.svg new file mode 100644 index 0000000000000..0aeb96c4996f7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-refresh.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/database-remove.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-remove.svg new file mode 100644 index 0000000000000..d4e9971017d1d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-remove.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/database-server-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-server-1.svg new file mode 100644 index 0000000000000..0ca3030d20466 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-server-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/database-server-2.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-server-2.svg new file mode 100644 index 0000000000000..15196de131c3f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-server-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/database-setting.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-setting.svg new file mode 100644 index 0000000000000..ec6b34e6c10d2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-setting.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/database-subtract-2-raid-storage-code-disk-programming-database-array-hard-disc-minus.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-subtract-2-raid-storage-code-disk-programming-database-array-hard-disc-minus.svg new file mode 100644 index 0000000000000..e0c0e75c52374 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-subtract-2-raid-storage-code-disk-programming-database-array-hard-disc-minus.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/database.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/database.svg new file mode 100644 index 0000000000000..31f57ca895dcf --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/database.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/delete-keyboard.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/delete-keyboard.svg new file mode 100644 index 0000000000000..cb25e3d0b7993 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/delete-keyboard.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-chat.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-chat.svg new file mode 100644 index 0000000000000..774d3464f0bce --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-chat.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-check.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-check.svg new file mode 100644 index 0000000000000..3f2f30e2e81bf --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-check.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-code.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-code.svg new file mode 100644 index 0000000000000..a4f0873ffc9a0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-code.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-delete.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-delete.svg new file mode 100644 index 0000000000000..45038bb01ae0a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-delete.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-dollar.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-dollar.svg new file mode 100644 index 0000000000000..161a456ba04f0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-dollar.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-emoji.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-emoji.svg new file mode 100644 index 0000000000000..dd4cadfd51cf6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-emoji.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-favorite-star.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-favorite-star.svg new file mode 100644 index 0000000000000..276cc7833e3df --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-favorite-star.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-game.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-game.svg new file mode 100644 index 0000000000000..fa98bc4d46181 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-game.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-help.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-help.svg new file mode 100644 index 0000000000000..d651603e718b0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-help.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/device-database-encryption-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/device-database-encryption-1.svg new file mode 100644 index 0000000000000..230e5f79a15cc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/device-database-encryption-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/discord.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/discord.svg new file mode 100644 index 0000000000000..2cb14a8e6ce81 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/discord.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/drone.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/drone.svg new file mode 100644 index 0000000000000..8ad4a4f775338 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/drone.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/dropbox.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/dropbox.svg new file mode 100644 index 0000000000000..89f0cf0b8edcc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/dropbox.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/eject.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/eject.svg new file mode 100644 index 0000000000000..acea3c2839ba4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/eject.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/electric-cord-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/electric-cord-1.svg new file mode 100644 index 0000000000000..ef4bae5915996 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/electric-cord-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/electric-cord-3.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/electric-cord-3.svg new file mode 100644 index 0000000000000..59a85fabda463 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/electric-cord-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/facebook-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/facebook-1.svg new file mode 100644 index 0000000000000..7687d0331af2f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/facebook-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/figma.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/figma.svg new file mode 100644 index 0000000000000..316aacd34eb52 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/figma.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/floppy-disk.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/floppy-disk.svg new file mode 100644 index 0000000000000..be1351ba032cb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/floppy-disk.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/gmail.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/gmail.svg new file mode 100644 index 0000000000000..ce9a3c7d360dc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/gmail.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/google-drive.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/google-drive.svg new file mode 100644 index 0000000000000..521fe55ad84ed --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/google-drive.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/google.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/google.svg new file mode 100644 index 0000000000000..624af07bbb7a8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/google.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/hand-held-tablet-drawing.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/hand-held-tablet-drawing.svg new file mode 100644 index 0000000000000..c9117d691654c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/hand-held-tablet-drawing.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/hand-held-tablet-writing.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/hand-held-tablet-writing.svg new file mode 100644 index 0000000000000..d619e9d69af6c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/hand-held-tablet-writing.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/hand-held.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/hand-held.svg new file mode 100644 index 0000000000000..2cff3d5e043db --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/hand-held.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/hard-disk.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/hard-disk.svg new file mode 100644 index 0000000000000..46a25c5d5a876 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/hard-disk.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/hard-drive-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/hard-drive-1.svg new file mode 100644 index 0000000000000..929887c741724 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/hard-drive-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/instagram.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/instagram.svg new file mode 100644 index 0000000000000..2a0750b273d88 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/instagram.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/keyboard-virtual.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/keyboard-virtual.svg new file mode 100644 index 0000000000000..914dddf994627 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/keyboard-virtual.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/keyboard-wireless-2.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/keyboard-wireless-2.svg new file mode 100644 index 0000000000000..c3fb38cc92455 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/keyboard-wireless-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/keyboard.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/keyboard.svg new file mode 100644 index 0000000000000..9a32238860793 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/keyboard.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/laptop-charging.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/laptop-charging.svg new file mode 100644 index 0000000000000..bbc233360c379 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/laptop-charging.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/linkedin.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/linkedin.svg new file mode 100644 index 0000000000000..6ed8fd3d8ccec --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/linkedin.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/local-storage-folder.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/local-storage-folder.svg new file mode 100644 index 0000000000000..cb0673ab60fde --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/local-storage-folder.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/meta.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/meta.svg new file mode 100644 index 0000000000000..d0937137b632b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/meta.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/mouse-wireless-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/mouse-wireless-1.svg new file mode 100644 index 0000000000000..697fb76677e27 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/mouse-wireless-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/mouse-wireless.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/mouse-wireless.svg new file mode 100644 index 0000000000000..a2c554d6fb1e3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/mouse-wireless.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/mouse.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/mouse.svg new file mode 100644 index 0000000000000..972f69c52f58b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/mouse.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/netflix.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/netflix.svg new file mode 100644 index 0000000000000..6691a310864c3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/netflix.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/network.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/network.svg new file mode 100644 index 0000000000000..d33f91d839e2f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/network.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/next.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/next.svg new file mode 100644 index 0000000000000..1c68f75e7e08a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/next.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/paypal.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/paypal.svg new file mode 100644 index 0000000000000..e366f8e86e364 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/paypal.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/play-store.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/play-store.svg new file mode 100644 index 0000000000000..c84f1ca4c12be --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/play-store.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/printer.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/printer.svg new file mode 100644 index 0000000000000..79eefa06a404f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/printer.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/return-2.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/return-2.svg new file mode 100644 index 0000000000000..45666d72b4f0f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/return-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/screen-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/screen-1.svg new file mode 100644 index 0000000000000..e5007b7f5b739 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/screen-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/screen-2.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/screen-2.svg new file mode 100644 index 0000000000000..4b87d934f4728 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/screen-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/screen-curve.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/screen-curve.svg new file mode 100644 index 0000000000000..dc6418c205217 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/screen-curve.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/screensaver-monitor-wallpaper.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/screensaver-monitor-wallpaper.svg new file mode 100644 index 0000000000000..cc0431f192a63 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/screensaver-monitor-wallpaper.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/shift.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/shift.svg new file mode 100644 index 0000000000000..3dfc9de387a60 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/shift.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/shredder.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/shredder.svg new file mode 100644 index 0000000000000..f6c9f4bffa77f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/shredder.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/signal-loading.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/signal-loading.svg new file mode 100644 index 0000000000000..d04c5d1c44bf6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/signal-loading.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/slack.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/slack.svg new file mode 100644 index 0000000000000..266a0018c4fcc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/slack.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/spotify.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/spotify.svg new file mode 100644 index 0000000000000..f0f0365ae8606 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/spotify.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/telegram.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/telegram.svg new file mode 100644 index 0000000000000..4bccbe1779e7f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/telegram.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/tiktok.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/tiktok.svg new file mode 100644 index 0000000000000..8f03d36c6b933 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/tiktok.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/tinder.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/tinder.svg new file mode 100644 index 0000000000000..ca0e251a7fdb1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/tinder.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/twitter.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/twitter.svg new file mode 100644 index 0000000000000..f8e13c447cc13 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/twitter.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/usb-drive.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/usb-drive.svg new file mode 100644 index 0000000000000..417555a5c3276 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/usb-drive.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/virtual-reality.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/virtual-reality.svg new file mode 100644 index 0000000000000..6521aa766132f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/virtual-reality.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/voice-mail-off.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/voice-mail-off.svg new file mode 100644 index 0000000000000..175d036b30c10 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/voice-mail-off.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/voice-mail.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/voice-mail.svg new file mode 100644 index 0000000000000..78d4bd13b5d79 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/voice-mail.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-1.svg new file mode 100644 index 0000000000000..54039f5b8e757 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-2.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-2.svg new file mode 100644 index 0000000000000..87f6e84bf831c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-circle-charging.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-circle-charging.svg new file mode 100644 index 0000000000000..95bf9a6a1930c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-circle-charging.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-circle-heartbeat-monitor-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-circle-heartbeat-monitor-1.svg new file mode 100644 index 0000000000000..7e0a9419ed73b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-circle-heartbeat-monitor-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-circle-heartbeat-monitor-2.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-circle-heartbeat-monitor-2.svg new file mode 100644 index 0000000000000..575a4cdaf14f3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-circle-heartbeat-monitor-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-circle-menu.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-circle-menu.svg new file mode 100644 index 0000000000000..f79637bcd5927 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-circle-menu.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-circle-time.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-circle-time.svg new file mode 100644 index 0000000000000..7b3145988dced --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-circle-time.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/webcam-video-circle.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/webcam-video-circle.svg new file mode 100644 index 0000000000000..d583495165e8f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/webcam-video-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/webcam-video-off.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/webcam-video-off.svg new file mode 100644 index 0000000000000..9750416e9941d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/webcam-video-off.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/webcam-video.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/webcam-video.svg new file mode 100644 index 0000000000000..30407900c1cdc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/webcam-video.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/webcam.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/webcam.svg new file mode 100644 index 0000000000000..67007be1ac4bb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/webcam.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/whatsapp.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/whatsapp.svg new file mode 100644 index 0000000000000..bb7da75eb6268 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/whatsapp.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/wifi-antenna.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/wifi-antenna.svg new file mode 100644 index 0000000000000..b41ae562a48e1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/wifi-antenna.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/wifi-disabled.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/wifi-disabled.svg new file mode 100644 index 0000000000000..a561d55e842b4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/wifi-disabled.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/wifi-horizontal.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/wifi-horizontal.svg new file mode 100644 index 0000000000000..9f0f3f20a6037 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/wifi-horizontal.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/wifi-router.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/wifi-router.svg new file mode 100644 index 0000000000000..d7d9490b1a48b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/wifi-router.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/wifi.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/wifi.svg new file mode 100644 index 0000000000000..c6ebd0432cbab --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/wifi.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/windows.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/windows.svg new file mode 100644 index 0000000000000..b1923cc5f99e0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/windows.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/christian-cross-1.svg b/frontend/appflowy_web_app/public/af_icons/culture/christian-cross-1.svg new file mode 100644 index 0000000000000..8dea5f0109138 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/christian-cross-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/christian-cross-2.svg b/frontend/appflowy_web_app/public/af_icons/culture/christian-cross-2.svg new file mode 100644 index 0000000000000..4ac9b8ede7437 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/christian-cross-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/christianity.svg b/frontend/appflowy_web_app/public/af_icons/culture/christianity.svg new file mode 100644 index 0000000000000..1a083b53298a7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/christianity.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/dhammajak.svg b/frontend/appflowy_web_app/public/af_icons/culture/dhammajak.svg new file mode 100644 index 0000000000000..00ad06208139b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/dhammajak.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/hexagram.svg b/frontend/appflowy_web_app/public/af_icons/culture/hexagram.svg new file mode 100644 index 0000000000000..e9a5fbe4289d3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/hexagram.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/hinduism.svg b/frontend/appflowy_web_app/public/af_icons/culture/hinduism.svg new file mode 100644 index 0000000000000..cca81645928c8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/hinduism.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/islam.svg b/frontend/appflowy_web_app/public/af_icons/culture/islam.svg new file mode 100644 index 0000000000000..c2af2b380e6e0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/islam.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/news-paper.svg b/frontend/appflowy_web_app/public/af_icons/culture/news-paper.svg new file mode 100644 index 0000000000000..24d109d27d920 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/news-paper.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/peace-symbol.svg b/frontend/appflowy_web_app/public/af_icons/culture/peace-symbol.svg new file mode 100644 index 0000000000000..0249f8402e363 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/peace-symbol.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/politics-compaign.svg b/frontend/appflowy_web_app/public/af_icons/culture/politics-compaign.svg new file mode 100644 index 0000000000000..2333d4f883ebc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/politics-compaign.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/politics-speech.svg b/frontend/appflowy_web_app/public/af_icons/culture/politics-speech.svg new file mode 100644 index 0000000000000..e199c705cb8ad --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/politics-speech.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/politics-vote-2.svg b/frontend/appflowy_web_app/public/af_icons/culture/politics-vote-2.svg new file mode 100644 index 0000000000000..846d1522e9033 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/politics-vote-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/ticket-1.svg b/frontend/appflowy_web_app/public/af_icons/culture/ticket-1.svg new file mode 100644 index 0000000000000..67ecf103280d9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/ticket-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/tickets.svg b/frontend/appflowy_web_app/public/af_icons/culture/tickets.svg new file mode 100644 index 0000000000000..e06e36633f397 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/tickets.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/yin-yang-symbol.svg b/frontend/appflowy_web_app/public/af_icons/culture/yin-yang-symbol.svg new file mode 100644 index 0000000000000..e645e68433178 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/yin-yang-symbol.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/zodiac-1.svg b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-1.svg new file mode 100644 index 0000000000000..721204e5e4870 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/zodiac-10.svg b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-10.svg new file mode 100644 index 0000000000000..4fdf248b38627 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-10.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/zodiac-11.svg b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-11.svg new file mode 100644 index 0000000000000..447b9c56c9e48 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-11.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/zodiac-12.svg b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-12.svg new file mode 100644 index 0000000000000..fb2b1cb991b7a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-12.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/zodiac-2.svg b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-2.svg new file mode 100644 index 0000000000000..d4425722d9c07 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/zodiac-3.svg b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-3.svg new file mode 100644 index 0000000000000..0208aea702b2e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/zodiac-4.svg b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-4.svg new file mode 100644 index 0000000000000..0469f30ae0f44 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-4.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/zodiac-5.svg b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-5.svg new file mode 100644 index 0000000000000..218ba4a391f8b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-5.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/zodiac-6.svg b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-6.svg new file mode 100644 index 0000000000000..f02c49ee738f9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-6.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/zodiac-7.svg b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-7.svg new file mode 100644 index 0000000000000..b9de613da2035 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-7.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/zodiac-8.svg b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-8.svg new file mode 100644 index 0000000000000..646ba98ea8b14 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-8.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/zodiac-9.svg b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-9.svg new file mode 100644 index 0000000000000..062bf1140ffee --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-9.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/balloon.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/balloon.svg new file mode 100644 index 0000000000000..328aaaaaf15ec --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/balloon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/bow.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/bow.svg new file mode 100644 index 0000000000000..2864709ca8071 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/bow.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/button-fast-forward-1.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/button-fast-forward-1.svg new file mode 100644 index 0000000000000..dd04b7e8c6cc9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/button-fast-forward-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/button-fast-forward-2.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/button-fast-forward-2.svg new file mode 100644 index 0000000000000..f3d3dc72bc58c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/button-fast-forward-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/button-next.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/button-next.svg new file mode 100644 index 0000000000000..c3b1a23a065f8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/button-next.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/button-pause-2.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/button-pause-2.svg new file mode 100644 index 0000000000000..983544897aebd --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/button-pause-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/button-play.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/button-play.svg new file mode 100644 index 0000000000000..a07ab94655767 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/button-play.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/button-power-1.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/button-power-1.svg new file mode 100644 index 0000000000000..ef9e77f8775e6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/button-power-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/button-previous.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/button-previous.svg new file mode 100644 index 0000000000000..1f376dc16f7bb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/button-previous.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/button-record-3.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/button-record-3.svg new file mode 100644 index 0000000000000..0e9332cb25a9a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/button-record-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/button-rewind-1.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/button-rewind-1.svg new file mode 100644 index 0000000000000..d36b320fd977f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/button-rewind-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/button-rewind-2.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/button-rewind-2.svg new file mode 100644 index 0000000000000..beb36d9804aaf --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/button-rewind-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/button-stop.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/button-stop.svg new file mode 100644 index 0000000000000..a3339d0b1b871 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/button-stop.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/camera-video.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/camera-video.svg new file mode 100644 index 0000000000000..1dc4e57ea7ad2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/camera-video.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/cards.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/cards.svg new file mode 100644 index 0000000000000..aa54a4dcc6f47 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/cards.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/chess-bishop.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/chess-bishop.svg new file mode 100644 index 0000000000000..f667a4e84c603 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/chess-bishop.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/chess-king.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/chess-king.svg new file mode 100644 index 0000000000000..6cdbf1a76ed5d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/chess-king.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/chess-knight.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/chess-knight.svg new file mode 100644 index 0000000000000..027afaedcc8b1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/chess-knight.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/chess-pawn.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/chess-pawn.svg new file mode 100644 index 0000000000000..9e995acb97968 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/chess-pawn.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/cloud-gaming-1.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/cloud-gaming-1.svg new file mode 100644 index 0000000000000..874cac202333d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/cloud-gaming-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/clubs-symbol.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/clubs-symbol.svg new file mode 100644 index 0000000000000..23207373a1d38 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/clubs-symbol.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/diamonds-symbol.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/diamonds-symbol.svg new file mode 100644 index 0000000000000..d184ba745521d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/diamonds-symbol.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/dice-1.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/dice-1.svg new file mode 100644 index 0000000000000..adfab0f74c023 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/dice-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/dice-2.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/dice-2.svg new file mode 100644 index 0000000000000..94ad5db18a4b9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/dice-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/dice-3.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/dice-3.svg new file mode 100644 index 0000000000000..0e7571ae958a1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/dice-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/dice-4.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/dice-4.svg new file mode 100644 index 0000000000000..37d68fcffcd30 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/dice-4.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/dice-5.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/dice-5.svg new file mode 100644 index 0000000000000..eabbd0ed3b1bc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/dice-5.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/dice-6.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/dice-6.svg new file mode 100644 index 0000000000000..36a19135ae795 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/dice-6.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/dices-entertainment-gaming-dices.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/dices-entertainment-gaming-dices.svg new file mode 100644 index 0000000000000..ea1f1d84adf7e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/dices-entertainment-gaming-dices.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/earpods.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/earpods.svg new file mode 100644 index 0000000000000..890a89753e5c1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/earpods.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/epic-games-1.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/epic-games-1.svg new file mode 100644 index 0000000000000..d1eb2af8fe520 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/epic-games-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/esports.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/esports.svg new file mode 100644 index 0000000000000..3f7bcd4c410ec --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/esports.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/fireworks-rocket.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/fireworks-rocket.svg new file mode 100644 index 0000000000000..fcc4d96bcdf64 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/fireworks-rocket.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/gameboy.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/gameboy.svg new file mode 100644 index 0000000000000..402531f20a3a7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/gameboy.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/gramophone.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/gramophone.svg new file mode 100644 index 0000000000000..0ed2f0b26fd16 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/gramophone.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/hearts-symbol.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/hearts-symbol.svg new file mode 100644 index 0000000000000..fc6cce023f387 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/hearts-symbol.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/music-equalizer.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/music-equalizer.svg new file mode 100644 index 0000000000000..9fbd4aba84791 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/music-equalizer.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/music-note-1.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/music-note-1.svg new file mode 100644 index 0000000000000..644ba5553d936 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/music-note-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/music-note-2.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/music-note-2.svg new file mode 100644 index 0000000000000..96efe68daa720 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/music-note-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/music-note-off-1.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/music-note-off-1.svg new file mode 100644 index 0000000000000..5f5be24b3734e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/music-note-off-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/music-note-off-2.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/music-note-off-2.svg new file mode 100644 index 0000000000000..8e6cffcfbd195 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/music-note-off-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/nintendo-switch.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/nintendo-switch.svg new file mode 100644 index 0000000000000..31a17e97f1d6b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/nintendo-switch.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/one-vesus-one.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/one-vesus-one.svg new file mode 100644 index 0000000000000..31c3d7e26567f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/one-vesus-one.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/pacman.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/pacman.svg new file mode 100644 index 0000000000000..a42ee2bf02c36 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/pacman.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/party-popper.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/party-popper.svg new file mode 100644 index 0000000000000..2d7033ddb32c9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/party-popper.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/play-list-4.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/play-list-4.svg new file mode 100644 index 0000000000000..6655dfb7d60ce --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/play-list-4.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/play-list-5.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/play-list-5.svg new file mode 100644 index 0000000000000..747bb3d86f43c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/play-list-5.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/play-list-8.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/play-list-8.svg new file mode 100644 index 0000000000000..cda68aa414873 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/play-list-8.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/play-list-9.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/play-list-9.svg new file mode 100644 index 0000000000000..eb9df98361068 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/play-list-9.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/play-list-folder.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/play-list-folder.svg new file mode 100644 index 0000000000000..f6226c0d62b49 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/play-list-folder.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/play-station.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/play-station.svg new file mode 100644 index 0000000000000..eb281023b8f89 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/play-station.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/radio.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/radio.svg new file mode 100644 index 0000000000000..068af297a279d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/radio.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/recording-tape-bubble-circle.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/recording-tape-bubble-circle.svg new file mode 100644 index 0000000000000..fa5ba15b9e0ec --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/recording-tape-bubble-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/recording-tape-bubble-square.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/recording-tape-bubble-square.svg new file mode 100644 index 0000000000000..69d0897329363 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/recording-tape-bubble-square.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/song-recommendation.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/song-recommendation.svg new file mode 100644 index 0000000000000..a53a018dae167 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/song-recommendation.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/spades-symbol.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/spades-symbol.svg new file mode 100644 index 0000000000000..36a510d14ba6d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/spades-symbol.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/speaker-1.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/speaker-1.svg new file mode 100644 index 0000000000000..105430d2ad587 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/speaker-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/speaker-2.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/speaker-2.svg new file mode 100644 index 0000000000000..79cf8682b6418 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/speaker-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/stream.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/stream.svg new file mode 100644 index 0000000000000..188e0c1a8f8c3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/stream.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/tape-cassette-record.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/tape-cassette-record.svg new file mode 100644 index 0000000000000..1ecc8cb52f3c2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/tape-cassette-record.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/volume-down.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/volume-down.svg new file mode 100644 index 0000000000000..c86a4fd7d94bb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/volume-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/volume-level-high.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/volume-level-high.svg new file mode 100644 index 0000000000000..b560324f28046 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/volume-level-high.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/volume-level-low.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/volume-level-low.svg new file mode 100644 index 0000000000000..726d2adef3c95 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/volume-level-low.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/volume-level-off.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/volume-level-off.svg new file mode 100644 index 0000000000000..a4a4d827dd1d5 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/volume-level-off.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/volume-mute.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/volume-mute.svg new file mode 100644 index 0000000000000..02c8c1da05d32 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/volume-mute.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/volume-off.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/volume-off.svg new file mode 100644 index 0000000000000..5d9afb737a9d7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/volume-off.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/vr-headset-1.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/vr-headset-1.svg new file mode 100644 index 0000000000000..99a7bea697187 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/vr-headset-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/vr-headset-2.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/vr-headset-2.svg new file mode 100644 index 0000000000000..88cd45c4ed459 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/vr-headset-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/xbox.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/xbox.svg new file mode 100644 index 0000000000000..47efc6bc543db --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/xbox.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/beer-mug.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/beer-mug.svg new file mode 100644 index 0000000000000..01ecef571622e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/beer-mug.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/beer-pitch.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/beer-pitch.svg new file mode 100644 index 0000000000000..6eda98884b219 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/beer-pitch.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/burger.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/burger.svg new file mode 100644 index 0000000000000..12c6c9d249426 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/burger.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/burrito-fastfood.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/burrito-fastfood.svg new file mode 100644 index 0000000000000..88abc83543a83 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/burrito-fastfood.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/cake-slice.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/cake-slice.svg new file mode 100644 index 0000000000000..ec6132a52004a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/cake-slice.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/candy-cane.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/candy-cane.svg new file mode 100644 index 0000000000000..12510b6fcb6b4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/candy-cane.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/champagne-party-alcohol.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/champagne-party-alcohol.svg new file mode 100644 index 0000000000000..01c22f99550b5 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/champagne-party-alcohol.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/cheese.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/cheese.svg new file mode 100644 index 0000000000000..721c865fb4f6f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/cheese.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/cherries.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/cherries.svg new file mode 100644 index 0000000000000..df3d75d719f71 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/cherries.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/chicken-grilled-stream.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/chicken-grilled-stream.svg new file mode 100644 index 0000000000000..b3410829f2ca6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/chicken-grilled-stream.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/cocktail.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/cocktail.svg new file mode 100644 index 0000000000000..fa4f8a3c2f5ab --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/cocktail.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/coffee-bean.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/coffee-bean.svg new file mode 100644 index 0000000000000..17cd87ef5213c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/coffee-bean.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/coffee-mug.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/coffee-mug.svg new file mode 100644 index 0000000000000..9d798f57617f3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/coffee-mug.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/coffee-takeaway-cup.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/coffee-takeaway-cup.svg new file mode 100644 index 0000000000000..c4db12c0235f9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/coffee-takeaway-cup.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/donut.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/donut.svg new file mode 100644 index 0000000000000..9e43a78ce1a28 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/donut.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/fork-knife.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/fork-knife.svg new file mode 100644 index 0000000000000..c084ce727ba48 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/fork-knife.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/fork-spoon.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/fork-spoon.svg new file mode 100644 index 0000000000000..b1ac770721293 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/fork-spoon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/ice-cream-2.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/ice-cream-2.svg new file mode 100644 index 0000000000000..de00d2d5d7ecd --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/ice-cream-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/ice-cream-3.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/ice-cream-3.svg new file mode 100644 index 0000000000000..8b4d864570142 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/ice-cream-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/lemon-fruit-seasoning.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/lemon-fruit-seasoning.svg new file mode 100644 index 0000000000000..3da07de6799ff --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/lemon-fruit-seasoning.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/microwave.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/microwave.svg new file mode 100644 index 0000000000000..162c26c96d9b7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/microwave.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/milkshake.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/milkshake.svg new file mode 100644 index 0000000000000..9a73d0d4e43b6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/milkshake.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/popcorn.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/popcorn.svg new file mode 100644 index 0000000000000..33cf71d4442d1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/popcorn.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/pork-meat.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/pork-meat.svg new file mode 100644 index 0000000000000..081e550618f11 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/pork-meat.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/refrigerator.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/refrigerator.svg new file mode 100644 index 0000000000000..89f233c48d7c8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/refrigerator.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/serving-dome.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/serving-dome.svg new file mode 100644 index 0000000000000..1bdc48d306fba --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/serving-dome.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/shrimp.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/shrimp.svg new file mode 100644 index 0000000000000..b9a5add6da303 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/shrimp.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/strawberry.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/strawberry.svg new file mode 100644 index 0000000000000..14aa7a9f8d843 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/strawberry.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/tea-cup.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/tea-cup.svg new file mode 100644 index 0000000000000..e678274acc00f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/tea-cup.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/toast.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/toast.svg new file mode 100644 index 0000000000000..5aa9be15ad3d6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/toast.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/water-glass.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/water-glass.svg new file mode 100644 index 0000000000000..8e9f674c8c64c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/water-glass.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/wine.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/wine.svg new file mode 100644 index 0000000000000..1f6be74e618ee --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/wine.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/ambulance.svg b/frontend/appflowy_web_app/public/af_icons/health/ambulance.svg new file mode 100644 index 0000000000000..c0747996ff9de --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/ambulance.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/bacteria-virus-cells-biology.svg b/frontend/appflowy_web_app/public/af_icons/health/bacteria-virus-cells-biology.svg new file mode 100644 index 0000000000000..39c0d6442a6f3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/bacteria-virus-cells-biology.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/bandage.svg b/frontend/appflowy_web_app/public/af_icons/health/bandage.svg new file mode 100644 index 0000000000000..ff4e17b118c51 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/bandage.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/blood-bag-donation.svg b/frontend/appflowy_web_app/public/af_icons/health/blood-bag-donation.svg new file mode 100644 index 0000000000000..6a558d54deb42 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/blood-bag-donation.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/blood-donate-drop.svg b/frontend/appflowy_web_app/public/af_icons/health/blood-donate-drop.svg new file mode 100644 index 0000000000000..6959a292d527f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/blood-donate-drop.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/blood-drop-donation.svg b/frontend/appflowy_web_app/public/af_icons/health/blood-drop-donation.svg new file mode 100644 index 0000000000000..8bd0bfc4324a0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/blood-drop-donation.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/brain-cognitive.svg b/frontend/appflowy_web_app/public/af_icons/health/brain-cognitive.svg new file mode 100644 index 0000000000000..9fdb4125d4715 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/brain-cognitive.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/brain.svg b/frontend/appflowy_web_app/public/af_icons/health/brain.svg new file mode 100644 index 0000000000000..e12682c2effb8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/brain.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/call-center-support-service.svg b/frontend/appflowy_web_app/public/af_icons/health/call-center-support-service.svg new file mode 100644 index 0000000000000..1593b6c790190 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/call-center-support-service.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/checkup-medical-report-clipboard.svg b/frontend/appflowy_web_app/public/af_icons/health/checkup-medical-report-clipboard.svg new file mode 100644 index 0000000000000..2837b7f2e595b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/checkup-medical-report-clipboard.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/ear-hearing.svg b/frontend/appflowy_web_app/public/af_icons/health/ear-hearing.svg new file mode 100644 index 0000000000000..5fa596e560608 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/ear-hearing.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/eye-optic.svg b/frontend/appflowy_web_app/public/af_icons/health/eye-optic.svg new file mode 100644 index 0000000000000..1617190a8e094 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/eye-optic.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/flu-mask.svg b/frontend/appflowy_web_app/public/af_icons/health/flu-mask.svg new file mode 100644 index 0000000000000..365d4cab843e8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/flu-mask.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/health-care-2.svg b/frontend/appflowy_web_app/public/af_icons/health/health-care-2.svg new file mode 100644 index 0000000000000..495a02dc3d312 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/health-care-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/heart-rate-pulse-graph.svg b/frontend/appflowy_web_app/public/af_icons/health/heart-rate-pulse-graph.svg new file mode 100644 index 0000000000000..0351f05eb27d4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/heart-rate-pulse-graph.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/heart-rate-search.svg b/frontend/appflowy_web_app/public/af_icons/health/heart-rate-search.svg new file mode 100644 index 0000000000000..e8a6faa1db7f8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/heart-rate-search.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/hospital-sign-circle.svg b/frontend/appflowy_web_app/public/af_icons/health/hospital-sign-circle.svg new file mode 100644 index 0000000000000..964abce175798 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/hospital-sign-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/hospital-sign-square.svg b/frontend/appflowy_web_app/public/af_icons/health/hospital-sign-square.svg new file mode 100644 index 0000000000000..1648b17479dfc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/hospital-sign-square.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/insurance-hand.svg b/frontend/appflowy_web_app/public/af_icons/health/insurance-hand.svg new file mode 100644 index 0000000000000..70ae0036b96eb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/insurance-hand.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/medical-bag.svg b/frontend/appflowy_web_app/public/af_icons/health/medical-bag.svg new file mode 100644 index 0000000000000..c469e591d9148 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/medical-bag.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/medical-cross-sign-healthcare.svg b/frontend/appflowy_web_app/public/af_icons/health/medical-cross-sign-healthcare.svg new file mode 100644 index 0000000000000..fc35cba77e818 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/medical-cross-sign-healthcare.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/medical-cross-symbol.svg b/frontend/appflowy_web_app/public/af_icons/health/medical-cross-symbol.svg new file mode 100644 index 0000000000000..7906b49bd2626 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/medical-cross-symbol.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/medical-files-report-history.svg b/frontend/appflowy_web_app/public/af_icons/health/medical-files-report-history.svg new file mode 100644 index 0000000000000..b18c22e979c51 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/medical-files-report-history.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/medical-ribbon-1.svg b/frontend/appflowy_web_app/public/af_icons/health/medical-ribbon-1.svg new file mode 100644 index 0000000000000..c53c3ef448b92 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/medical-ribbon-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/medical-search-diagnosis.svg b/frontend/appflowy_web_app/public/af_icons/health/medical-search-diagnosis.svg new file mode 100644 index 0000000000000..f5995068ccff4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/medical-search-diagnosis.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/microscope-observation-sciene.svg b/frontend/appflowy_web_app/public/af_icons/health/microscope-observation-sciene.svg new file mode 100644 index 0000000000000..be4a39d09ec1d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/microscope-observation-sciene.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/nurse-assistant-emergency.svg b/frontend/appflowy_web_app/public/af_icons/health/nurse-assistant-emergency.svg new file mode 100644 index 0000000000000..43e1a0fcbf345 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/nurse-assistant-emergency.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/nurse-hat.svg b/frontend/appflowy_web_app/public/af_icons/health/nurse-hat.svg new file mode 100644 index 0000000000000..e8f3ca9dc3d61 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/nurse-hat.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/online-medical-call-service.svg b/frontend/appflowy_web_app/public/af_icons/health/online-medical-call-service.svg new file mode 100644 index 0000000000000..24190f5ed4aa8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/online-medical-call-service.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/online-medical-service-monitor.svg b/frontend/appflowy_web_app/public/af_icons/health/online-medical-service-monitor.svg new file mode 100644 index 0000000000000..85370f0d5710f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/online-medical-service-monitor.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/online-medical-web-service.svg b/frontend/appflowy_web_app/public/af_icons/health/online-medical-web-service.svg new file mode 100644 index 0000000000000..cf683b8d42cfb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/online-medical-web-service.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/petri-dish-lab-equipment.svg b/frontend/appflowy_web_app/public/af_icons/health/petri-dish-lab-equipment.svg new file mode 100644 index 0000000000000..46409cf4d8c1c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/petri-dish-lab-equipment.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/pharmacy.svg b/frontend/appflowy_web_app/public/af_icons/health/pharmacy.svg new file mode 100644 index 0000000000000..c2f871ae9b7b7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/pharmacy.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/prescription-pills-drugs-healthcare.svg b/frontend/appflowy_web_app/public/af_icons/health/prescription-pills-drugs-healthcare.svg new file mode 100644 index 0000000000000..0b297f59f049d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/prescription-pills-drugs-healthcare.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/sign-cross-square.svg b/frontend/appflowy_web_app/public/af_icons/health/sign-cross-square.svg new file mode 100644 index 0000000000000..a3f893c951bd1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/sign-cross-square.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/sos-help-emergency-sign.svg b/frontend/appflowy_web_app/public/af_icons/health/sos-help-emergency-sign.svg new file mode 100644 index 0000000000000..850b0371363f4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/sos-help-emergency-sign.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/stethoscope.svg b/frontend/appflowy_web_app/public/af_icons/health/stethoscope.svg new file mode 100644 index 0000000000000..f78716a7f86e9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/stethoscope.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/syringe.svg b/frontend/appflowy_web_app/public/af_icons/health/syringe.svg new file mode 100644 index 0000000000000..07fe454cff2d5 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/syringe.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/tablet-capsule.svg b/frontend/appflowy_web_app/public/af_icons/health/tablet-capsule.svg new file mode 100644 index 0000000000000..9553c056c38a3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/tablet-capsule.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/tooth.svg b/frontend/appflowy_web_app/public/af_icons/health/tooth.svg new file mode 100644 index 0000000000000..6817c2b796bc3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/tooth.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/virus-antivirus.svg b/frontend/appflowy_web_app/public/af_icons/health/virus-antivirus.svg new file mode 100644 index 0000000000000..ad972cff8b54d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/virus-antivirus.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/waiting-appointments-calendar.svg b/frontend/appflowy_web_app/public/af_icons/health/waiting-appointments-calendar.svg new file mode 100644 index 0000000000000..59ab62e17f948 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/waiting-appointments-calendar.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/wheelchair.svg b/frontend/appflowy_web_app/public/af_icons/health/wheelchair.svg new file mode 100644 index 0000000000000..a29e32ca48402 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/wheelchair.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/icons.json b/frontend/appflowy_web_app/public/af_icons/icons.json new file mode 100644 index 0000000000000..b76b0d051a97e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/icons.json @@ -0,0 +1 @@ +{ "artificial_intelligence": [ { "id": "artificial_intelligence/ai-chip-spark", "name": "ai-chip-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-cloud-spark", "name": "ai-cloud-spark", "keywords": [], "content": "\n \n \n \n \n \n \n \n \n\n" }, { "id": "artificial_intelligence/ai-edit-spark", "name": "ai-edit-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-email-generator-spark", "name": "ai-email-generator-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-gaming-spark", "name": "ai-gaming-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-generate-landscape-image-spark", "name": "ai-generate-landscape-image-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-generate-music-spark", "name": "ai-generate-music-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-generate-portrait-image-spark", "name": "ai-generate-portrait-image-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-generate-variation-spark", "name": "ai-generate-variation-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-navigation-spark", "name": "ai-navigation-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-network-spark", "name": "ai-network-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-prompt-spark", "name": "ai-prompt-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-redo-spark", "name": "ai-redo-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-science-spark", "name": "ai-science-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-settings-spark", "name": "ai-settings-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-technology-spark", "name": "ai-technology-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-upscale-spark", "name": "ai-upscale-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-vehicle-spark-1", "name": "ai-vehicle-spark-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/artificial-intelligence-spark", "name": "artificial-intelligence-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "computer_devices": [ { "id": "computer_devices/adobe", "name": "adobe", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/alt", "name": "alt", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/amazon", "name": "amazon", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/android", "name": "android", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/app-store", "name": "app-store", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/apple", "name": "apple", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/asterisk-1", "name": "asterisk-1", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/battery-alert-1", "name": "battery-alert-1", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/battery-charging", "name": "battery-charging", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/battery-empty-1", "name": "battery-empty-1", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/battery-empty-2", "name": "battery-empty-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/battery-full-1", "name": "battery-full-1", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/battery-low-1", "name": "battery-low-1", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/battery-medium-1", "name": "battery-medium-1", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/bluetooth-disabled", "name": "bluetooth-disabled", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/bluetooth-searching", "name": "bluetooth-searching", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/bluetooth", "name": "bluetooth", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/browser-wifi", "name": "browser-wifi", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/chrome", "name": "chrome", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/command", "name": "command", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/computer-chip-1", "name": "computer-chip-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/computer-chip-2", "name": "computer-chip-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/computer-pc-desktop", "name": "computer-pc-desktop", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/controller-1", "name": "controller-1", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/controller-wireless", "name": "controller-wireless", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/controller", "name": "controller", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/cursor-click", "name": "cursor-click", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/cyborg-2", "name": "cyborg-2", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/cyborg", "name": "cyborg", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/database-check", "name": "database-check", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/database-lock", "name": "database-lock", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/database-refresh", "name": "database-refresh", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/database-remove", "name": "database-remove", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/database-server-1", "name": "database-server-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/database-server-2", "name": "database-server-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/database-setting", "name": "database-setting", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/database-subtract-2-raid-storage-code-disk-programming-database-array-hard-disc-minus", "name": "database-subtract-2-raid-storage-code-disk-programming-database-array-hard-disc-minus", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/database", "name": "database", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/delete-keyboard", "name": "delete-keyboard", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/desktop-chat", "name": "desktop-chat", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/desktop-check", "name": "desktop-check", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/desktop-code", "name": "desktop-code", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/desktop-delete", "name": "desktop-delete", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/desktop-dollar", "name": "desktop-dollar", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/desktop-emoji", "name": "desktop-emoji", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/desktop-favorite-star", "name": "desktop-favorite-star", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/desktop-game", "name": "desktop-game", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/desktop-help", "name": "desktop-help", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/device-database-encryption-1", "name": "device-database-encryption-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/discord", "name": "discord", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/drone", "name": "drone", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/dropbox", "name": "dropbox", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/eject", "name": "eject", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/electric-cord-1", "name": "electric-cord-1", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/electric-cord-3", "name": "electric-cord-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/facebook-1", "name": "facebook-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/figma", "name": "figma", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/floppy-disk", "name": "floppy-disk", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/gmail", "name": "gmail", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/google-drive", "name": "google-drive", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/google", "name": "google", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/hand-held-tablet-drawing", "name": "hand-held-tablet-drawing", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/hand-held-tablet-writing", "name": "hand-held-tablet-writing", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/hand-held", "name": "hand-held", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/hard-disk", "name": "hard-disk", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/hard-drive-1", "name": "hard-drive-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/instagram", "name": "instagram", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/keyboard-virtual", "name": "keyboard-virtual", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/keyboard-wireless-2", "name": "keyboard-wireless-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/keyboard", "name": "keyboard", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/laptop-charging", "name": "laptop-charging", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/linkedin", "name": "linkedin", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/local-storage-folder", "name": "local-storage-folder", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/meta", "name": "meta", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/mouse-wireless-1", "name": "mouse-wireless-1", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/mouse-wireless", "name": "mouse-wireless", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/mouse", "name": "mouse", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/netflix", "name": "netflix", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/network", "name": "network", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/next", "name": "next", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/paypal", "name": "paypal", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/play-store", "name": "play-store", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/printer", "name": "printer", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/return-2", "name": "return-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/screen-1", "name": "screen-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/screen-2", "name": "screen-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/screen-curve", "name": "screen-curve", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/screensaver-monitor-wallpaper", "name": "screensaver-monitor-wallpaper", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/shift", "name": "shift", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/shredder", "name": "shredder", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/signal-loading", "name": "signal-loading", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/slack", "name": "slack", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/spotify", "name": "spotify", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/telegram", "name": "telegram", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/tiktok", "name": "tiktok", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/tinder", "name": "tinder", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/twitter", "name": "twitter", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/usb-drive", "name": "usb-drive", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/virtual-reality", "name": "virtual-reality", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/voice-mail-off", "name": "voice-mail-off", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/voice-mail", "name": "voice-mail", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/VPN-connection", "name": "VPN-connection", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/watch-1", "name": "watch-1", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/watch-2", "name": "watch-2", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/watch-circle-charging", "name": "watch-circle-charging", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/watch-circle-heartbeat-monitor-1", "name": "watch-circle-heartbeat-monitor-1", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/watch-circle-heartbeat-monitor-2", "name": "watch-circle-heartbeat-monitor-2", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/watch-circle-menu", "name": "watch-circle-menu", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/watch-circle-time", "name": "watch-circle-time", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/webcam-video-circle", "name": "webcam-video-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/webcam-video-off", "name": "webcam-video-off", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/webcam-video", "name": "webcam-video", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/webcam", "name": "webcam", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/whatsapp", "name": "whatsapp", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/wifi-antenna", "name": "wifi-antenna", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/wifi-disabled", "name": "wifi-disabled", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/wifi-horizontal", "name": "wifi-horizontal", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/wifi-router", "name": "wifi-router", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/wifi", "name": "wifi", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/windows", "name": "windows", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "culture": [ { "id": "culture/christian-cross-1", "name": "christian-cross-1", "keywords": [], "content": "\n\n\n" }, { "id": "culture/christian-cross-2", "name": "christian-cross-2", "keywords": [], "content": "\n\n\n" }, { "id": "culture/christianity", "name": "christianity", "keywords": [], "content": "\n\n\n" }, { "id": "culture/dhammajak", "name": "dhammajak", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/hexagram", "name": "hexagram", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/hinduism", "name": "hinduism", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/islam", "name": "islam", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/news-paper", "name": "news-paper", "keywords": [], "content": "\n\n\n" }, { "id": "culture/peace-symbol", "name": "peace-symbol", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/politics-compaign", "name": "politics-compaign", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/politics-speech", "name": "politics-speech", "keywords": [], "content": "\n\n\n" }, { "id": "culture/politics-vote-2", "name": "politics-vote-2", "keywords": [], "content": "\n\n\n" }, { "id": "culture/ticket-1", "name": "ticket-1", "keywords": [], "content": "\n\n\n" }, { "id": "culture/tickets", "name": "tickets", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/yin-yang-symbol", "name": "yin-yang-symbol", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/zodiac-1", "name": "zodiac-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/zodiac-10", "name": "zodiac-10", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/zodiac-11", "name": "zodiac-11", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/zodiac-12", "name": "zodiac-12", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/zodiac-2", "name": "zodiac-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/zodiac-3", "name": "zodiac-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/zodiac-4", "name": "zodiac-4", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/zodiac-5", "name": "zodiac-5", "keywords": [], "content": "\n\n\n" }, { "id": "culture/zodiac-6", "name": "zodiac-6", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/zodiac-7", "name": "zodiac-7", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/zodiac-8", "name": "zodiac-8", "keywords": [], "content": "\n\n\n" }, { "id": "culture/zodiac-9", "name": "zodiac-9", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "entertainment": [ { "id": "entertainment/balloon", "name": "balloon", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/bow", "name": "bow", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/button-fast-forward-1", "name": "button-fast-forward-1", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/button-fast-forward-2", "name": "button-fast-forward-2", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/button-next", "name": "button-next", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/button-pause-2", "name": "button-pause-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/button-play", "name": "button-play", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/button-power-1", "name": "button-power-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/button-previous", "name": "button-previous", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/button-record-3", "name": "button-record-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/button-rewind-1", "name": "button-rewind-1", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/button-rewind-2", "name": "button-rewind-2", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/button-stop", "name": "button-stop", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/camera-video", "name": "camera-video", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/cards", "name": "cards", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/chess-bishop", "name": "chess-bishop", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/chess-king", "name": "chess-king", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/chess-knight", "name": "chess-knight", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/chess-pawn", "name": "chess-pawn", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/cloud-gaming-1", "name": "cloud-gaming-1", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/clubs-symbol", "name": "clubs-symbol", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/diamonds-symbol", "name": "diamonds-symbol", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/dice-1", "name": "dice-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/dice-2", "name": "dice-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/dice-3", "name": "dice-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/dice-4", "name": "dice-4", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/dice-5", "name": "dice-5", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/dice-6", "name": "dice-6", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/dices-entertainment-gaming-dices", "name": "dices-entertainment-gaming-dices", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/earpods", "name": "earpods", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/epic-games-1", "name": "epic-games-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/esports", "name": "esports", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/fireworks-rocket", "name": "fireworks-rocket", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/gameboy", "name": "gameboy", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/gramophone", "name": "gramophone", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/hearts-symbol", "name": "hearts-symbol", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/music-equalizer", "name": "music-equalizer", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/music-note-1", "name": "music-note-1", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/music-note-2", "name": "music-note-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/music-note-off-1", "name": "music-note-off-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/music-note-off-2", "name": "music-note-off-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/nintendo-switch", "name": "nintendo-switch", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/one-vesus-one", "name": "one-vesus-one", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/pacman", "name": "pacman", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/party-popper", "name": "party-popper", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/play-list-4", "name": "play-list-4", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/play-list-5", "name": "play-list-5", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/play-list-8", "name": "play-list-8", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/play-list-9", "name": "play-list-9", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/play-list-folder", "name": "play-list-folder", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/play-station", "name": "play-station", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/radio", "name": "radio", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/recording-tape-bubble-circle", "name": "recording-tape-bubble-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/recording-tape-bubble-square", "name": "recording-tape-bubble-square", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/song-recommendation", "name": "song-recommendation", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/spades-symbol", "name": "spades-symbol", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/speaker-1", "name": "speaker-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/speaker-2", "name": "speaker-2", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/stream", "name": "stream", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/tape-cassette-record", "name": "tape-cassette-record", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/volume-down", "name": "volume-down", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/volume-level-high", "name": "volume-level-high", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/volume-level-low", "name": "volume-level-low", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/volume-level-off", "name": "volume-level-off", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/volume-mute", "name": "volume-mute", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/volume-off", "name": "volume-off", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/vr-headset-1", "name": "vr-headset-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/vr-headset-2", "name": "vr-headset-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/xbox", "name": "xbox", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "food_drink": [ { "id": "food_drink/beer-mug", "name": "beer-mug", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/beer-pitch", "name": "beer-pitch", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/burger", "name": "burger", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/burrito-fastfood", "name": "burrito-fastfood", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/cake-slice", "name": "cake-slice", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/candy-cane", "name": "candy-cane", "keywords": [], "content": "\n\n\n" }, { "id": "food_drink/champagne-party-alcohol", "name": "champagne-party-alcohol", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/cheese", "name": "cheese", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/cherries", "name": "cherries", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/chicken-grilled-stream", "name": "chicken-grilled-stream", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/cocktail", "name": "cocktail", "keywords": [], "content": "\n\n\n" }, { "id": "food_drink/coffee-bean", "name": "coffee-bean", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/coffee-mug", "name": "coffee-mug", "keywords": [], "content": "\n\n\n" }, { "id": "food_drink/coffee-takeaway-cup", "name": "coffee-takeaway-cup", "keywords": [], "content": "\n\n\n" }, { "id": "food_drink/donut", "name": "donut", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/fork-knife", "name": "fork-knife", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/fork-spoon", "name": "fork-spoon", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/ice-cream-2", "name": "ice-cream-2", "keywords": [], "content": "\n\n\n" }, { "id": "food_drink/ice-cream-3", "name": "ice-cream-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/lemon-fruit-seasoning", "name": "lemon-fruit-seasoning", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/microwave", "name": "microwave", "keywords": [], "content": "\n\n\n" }, { "id": "food_drink/milkshake", "name": "milkshake", "keywords": [], "content": "\n\n\n" }, { "id": "food_drink/popcorn", "name": "popcorn", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/pork-meat", "name": "pork-meat", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/refrigerator", "name": "refrigerator", "keywords": [], "content": "\n\n\n" }, { "id": "food_drink/serving-dome", "name": "serving-dome", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/shrimp", "name": "shrimp", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/strawberry", "name": "strawberry", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/tea-cup", "name": "tea-cup", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/toast", "name": "toast", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/water-glass", "name": "water-glass", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/wine", "name": "wine", "keywords": [], "content": "\n\n\n" } ], "health": [ { "id": "health/ambulance", "name": "ambulance", "keywords": [], "content": "\n\n\n" }, { "id": "health/bacteria-virus-cells-biology", "name": "bacteria-virus-cells-biology", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/bandage", "name": "bandage", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/blood-bag-donation", "name": "blood-bag-donation", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/blood-donate-drop", "name": "blood-donate-drop", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/blood-drop-donation", "name": "blood-drop-donation", "keywords": [], "content": "\n\n\n" }, { "id": "health/brain-cognitive", "name": "brain-cognitive", "keywords": [], "content": "\n\n\n" }, { "id": "health/brain", "name": "brain", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/call-center-support-service", "name": "call-center-support-service", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/checkup-medical-report-clipboard", "name": "checkup-medical-report-clipboard", "keywords": [], "content": "\n\n\n" }, { "id": "health/ear-hearing", "name": "ear-hearing", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/eye-optic", "name": "eye-optic", "keywords": [], "content": "\n\n\n" }, { "id": "health/flu-mask", "name": "flu-mask", "keywords": [], "content": "\n\n\n" }, { "id": "health/health-care-2", "name": "health-care-2", "keywords": [], "content": "\n\n\n" }, { "id": "health/heart-rate-pulse-graph", "name": "heart-rate-pulse-graph", "keywords": [], "content": "\n\n\n" }, { "id": "health/heart-rate-search", "name": "heart-rate-search", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/hospital-sign-circle", "name": "hospital-sign-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/hospital-sign-square", "name": "hospital-sign-square", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/insurance-hand", "name": "insurance-hand", "keywords": [], "content": "\n\n\n" }, { "id": "health/medical-bag", "name": "medical-bag", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/medical-cross-sign-healthcare", "name": "medical-cross-sign-healthcare", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/medical-cross-symbol", "name": "medical-cross-symbol", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/medical-files-report-history", "name": "medical-files-report-history", "keywords": [], "content": "\n\n\n" }, { "id": "health/medical-ribbon-1", "name": "medical-ribbon-1", "keywords": [], "content": "\n\n\n" }, { "id": "health/medical-search-diagnosis", "name": "medical-search-diagnosis", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/microscope-observation-sciene", "name": "microscope-observation-sciene", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/nurse-assistant-emergency", "name": "nurse-assistant-emergency", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/nurse-hat", "name": "nurse-hat", "keywords": [], "content": "\n\n\n" }, { "id": "health/online-medical-call-service", "name": "online-medical-call-service", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/online-medical-service-monitor", "name": "online-medical-service-monitor", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/online-medical-web-service", "name": "online-medical-web-service", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/petri-dish-lab-equipment", "name": "petri-dish-lab-equipment", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/pharmacy", "name": "pharmacy", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/prescription-pills-drugs-healthcare", "name": "prescription-pills-drugs-healthcare", "keywords": [], "content": "\n\n\n" }, { "id": "health/sign-cross-square", "name": "sign-cross-square", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/sos-help-emergency-sign", "name": "sos-help-emergency-sign", "keywords": [], "content": "\n\n\n" }, { "id": "health/stethoscope", "name": "stethoscope", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/syringe", "name": "syringe", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/tablet-capsule", "name": "tablet-capsule", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/tooth", "name": "tooth", "keywords": [], "content": "\n\n\n" }, { "id": "health/virus-antivirus", "name": "virus-antivirus", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/waiting-appointments-calendar", "name": "waiting-appointments-calendar", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/wheelchair", "name": "wheelchair", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "images_photography": [ { "id": "images_photography/auto-flash", "name": "auto-flash", "keywords": [], "content": "\n\n\n" }, { "id": "images_photography/camera-1", "name": "camera-1", "keywords": [], "content": "\n\n\n" }, { "id": "images_photography/camera-disabled", "name": "camera-disabled", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "images_photography/camera-loading", "name": "camera-loading", "keywords": [], "content": "\n\n\n" }, { "id": "images_photography/camera-square", "name": "camera-square", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "images_photography/composition-oval", "name": "composition-oval", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "images_photography/composition-vertical", "name": "composition-vertical", "keywords": [], "content": "\n\n\n" }, { "id": "images_photography/compsition-horizontal", "name": "compsition-horizontal", "keywords": [], "content": "\n\n\n" }, { "id": "images_photography/edit-image-photo", "name": "edit-image-photo", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "images_photography/film-roll-1", "name": "film-roll-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "images_photography/film-slate", "name": "film-slate", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "images_photography/flash-1", "name": "flash-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "images_photography/flash-2", "name": "flash-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "images_photography/flash-3", "name": "flash-3", "keywords": [], "content": "\n\n\n" }, { "id": "images_photography/flash-off", "name": "flash-off", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "images_photography/flower", "name": "flower", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "images_photography/focus-points", "name": "focus-points", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "images_photography/landscape-2", "name": "landscape-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "images_photography/landscape-setting", "name": "landscape-setting", "keywords": [], "content": "\n\n\n" }, { "id": "images_photography/laptop-camera", "name": "laptop-camera", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "images_photography/mobile-phone-camera", "name": "mobile-phone-camera", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "images_photography/orientation-landscape", "name": "orientation-landscape", "keywords": [], "content": "\n\n\n" }, { "id": "images_photography/orientation-portrait", "name": "orientation-portrait", "keywords": [], "content": "\n\n\n" }, { "id": "images_photography/polaroid-four", "name": "polaroid-four", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "interface_essential": [ { "id": "interface_essential/add-1", "name": "add-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/add-bell-notification", "name": "add-bell-notification", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/add-circle", "name": "add-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/add-layer-2", "name": "add-layer-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/add-square", "name": "add-square", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/alarm-clock", "name": "alarm-clock", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/align-back-1", "name": "align-back-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/align-center", "name": "align-center", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/align-front-1", "name": "align-front-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/align-left", "name": "align-left", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/align-right", "name": "align-right", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/ampersand", "name": "ampersand", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/archive-box", "name": "archive-box", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-bend-left-down-2", "name": "arrow-bend-left-down-2", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/arrow-bend-right-down-2", "name": "arrow-bend-right-down-2", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/arrow-crossover-down", "name": "arrow-crossover-down", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-crossover-left", "name": "arrow-crossover-left", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-crossover-right", "name": "arrow-crossover-right", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-crossover-up", "name": "arrow-crossover-up", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-cursor-1", "name": "arrow-cursor-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-cursor-2", "name": "arrow-cursor-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-curvy-up-down-1", "name": "arrow-curvy-up-down-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-curvy-up-down-2", "name": "arrow-curvy-up-down-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-down-2", "name": "arrow-down-2", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/arrow-down-dashed-square", "name": "arrow-down-dashed-square", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-expand", "name": "arrow-expand", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-infinite-loop", "name": "arrow-infinite-loop", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/arrow-move", "name": "arrow-move", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-reload-horizontal-1", "name": "arrow-reload-horizontal-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-reload-horizontal-2", "name": "arrow-reload-horizontal-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-reload-vertical-1", "name": "arrow-reload-vertical-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-reload-vertical-2", "name": "arrow-reload-vertical-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-roadmap", "name": "arrow-roadmap", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-round-left", "name": "arrow-round-left", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/arrow-round-right", "name": "arrow-round-right", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/arrow-shrink-diagonal-1", "name": "arrow-shrink-diagonal-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-shrink-diagonal-2", "name": "arrow-shrink-diagonal-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-shrink", "name": "arrow-shrink", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-transfer-diagonal-1", "name": "arrow-transfer-diagonal-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-transfer-diagonal-2", "name": "arrow-transfer-diagonal-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-transfer-diagonal-3", "name": "arrow-transfer-diagonal-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-up-1", "name": "arrow-up-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-up-dashed-square", "name": "arrow-up-dashed-square", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/ascending-number-order", "name": "ascending-number-order", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/attribution", "name": "attribution", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/blank-calendar", "name": "blank-calendar", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/blank-notepad", "name": "blank-notepad", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/block-bell-notification", "name": "block-bell-notification", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/bomb", "name": "bomb", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/bookmark", "name": "bookmark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/braces-circle", "name": "braces-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/brightness-1", "name": "brightness-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/brightness-2", "name": "brightness-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/brightness-3", "name": "brightness-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/broken-link-2", "name": "broken-link-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/bullet-list", "name": "bullet-list", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/calendar-add", "name": "calendar-add", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/calendar-edit", "name": "calendar-edit", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/calendar-jump-to-date", "name": "calendar-jump-to-date", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/calendar-star", "name": "calendar-star", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/celsius", "name": "celsius", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/check-square", "name": "check-square", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/check", "name": "check", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/circle-clock", "name": "circle-clock", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/circle", "name": "circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/clipboard-add", "name": "clipboard-add", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/clipboard-check", "name": "clipboard-check", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/clipboard-remove", "name": "clipboard-remove", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/cloud", "name": "cloud", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/cog", "name": "cog", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/color-palette", "name": "color-palette", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/color-picker", "name": "color-picker", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/color-swatches", "name": "color-swatches", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/cone-shape", "name": "cone-shape", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/convert-PDF-2", "name": "convert-PDF-2", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/copy-paste", "name": "copy-paste", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/creative-commons", "name": "creative-commons", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/crop-selection", "name": "crop-selection", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/crown", "name": "crown", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/customer-support-1", "name": "customer-support-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/cut", "name": "cut", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/dark-dislay-mode", "name": "dark-dislay-mode", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/dashboard-3", "name": "dashboard-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/dashboard-circle", "name": "dashboard-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/delete-1", "name": "delete-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/descending-number-order", "name": "descending-number-order", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/disable-bell-notification", "name": "disable-bell-notification", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/disable-heart", "name": "disable-heart", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/division-circle", "name": "division-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/download-box-1", "name": "download-box-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/download-circle", "name": "download-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/download-computer", "name": "download-computer", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/download-file", "name": "download-file", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/empty-clipboard", "name": "empty-clipboard", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/equal-sign", "name": "equal-sign", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/expand-horizontal-1", "name": "expand-horizontal-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/expand-window-2", "name": "expand-window-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/expand", "name": "expand", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/face-scan-1", "name": "face-scan-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/factorial", "name": "factorial", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/fahrenheit", "name": "fahrenheit", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/fastforward-clock", "name": "fastforward-clock", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/file-add-alternate", "name": "file-add-alternate", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/file-delete-alternate", "name": "file-delete-alternate", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/file-remove-alternate", "name": "file-remove-alternate", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/filter-2", "name": "filter-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/fingerprint-1", "name": "fingerprint-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/fingerprint-2", "name": "fingerprint-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/fist", "name": "fist", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/fit-to-height-square", "name": "fit-to-height-square", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/flip-vertical-arrow-2", "name": "flip-vertical-arrow-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/flip-vertical-circle-1", "name": "flip-vertical-circle-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/flip-vertical-square-2", "name": "flip-vertical-square-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/folder-add", "name": "folder-add", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/folder-check", "name": "folder-check", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/folder-delete", "name": "folder-delete", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/front-camera", "name": "front-camera", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/gif-format", "name": "gif-format", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/give-gift", "name": "give-gift", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/glasses", "name": "glasses", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/half-star-1", "name": "half-star-1", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/hand-cursor", "name": "hand-cursor", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/hand-grab", "name": "hand-grab", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/heading-1-paragraph-styles-heading", "name": "heading-1-paragraph-styles-heading", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/heading-2-paragraph-styles-heading", "name": "heading-2-paragraph-styles-heading", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/heading-3-paragraph-styles-heading", "name": "heading-3-paragraph-styles-heading", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/heart", "name": "heart", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/help-chat-2", "name": "help-chat-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/help-question-1", "name": "help-question-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/hierarchy-10", "name": "hierarchy-10", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/hierarchy-13", "name": "hierarchy-13", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/hierarchy-14", "name": "hierarchy-14", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/hierarchy-2", "name": "hierarchy-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/hierarchy-4", "name": "hierarchy-4", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/hierarchy-7", "name": "hierarchy-7", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/home-3", "name": "home-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/home-4", "name": "home-4", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/horizontal-menu-circle", "name": "horizontal-menu-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/humidity-none", "name": "humidity-none", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/image-blur", "name": "image-blur", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/image-saturation", "name": "image-saturation", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/information-circle", "name": "information-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/input-box", "name": "input-box", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/insert-side", "name": "insert-side", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/insert-top-left", "name": "insert-top-left", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/insert-top-right", "name": "insert-top-right", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/invisible-1", "name": "invisible-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/invisible-2", "name": "invisible-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/jump-object", "name": "jump-object", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/key", "name": "key", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/keyhole-lock-circle", "name": "keyhole-lock-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/lasso-tool", "name": "lasso-tool", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/layers-1", "name": "layers-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/layers-2", "name": "layers-2", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/layout-window-1", "name": "layout-window-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/layout-window-11", "name": "layout-window-11", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/layout-window-2", "name": "layout-window-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/layout-window-8", "name": "layout-window-8", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/lightbulb", "name": "lightbulb", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/like-1", "name": "like-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/link-chain", "name": "link-chain", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/live-video", "name": "live-video", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/lock-rotation", "name": "lock-rotation", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/login-1", "name": "login-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/logout-1", "name": "logout-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/loop-1", "name": "loop-1", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/magic-wand-2", "name": "magic-wand-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/magnifying-glass-circle", "name": "magnifying-glass-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/magnifying-glass", "name": "magnifying-glass", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/manual-book", "name": "manual-book", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/megaphone-2", "name": "megaphone-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/minimize-window-2", "name": "minimize-window-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/moon-cloud", "name": "moon-cloud", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/move-left", "name": "move-left", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/move-right", "name": "move-right", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/multiple-file-2", "name": "multiple-file-2", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/music-folder-song", "name": "music-folder-song", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/new-file", "name": "new-file", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/new-folder", "name": "new-folder", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/new-sticky-note", "name": "new-sticky-note", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/not-equal-sign", "name": "not-equal-sign", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/ok-hand", "name": "ok-hand", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/one-finger-drag-horizontal", "name": "one-finger-drag-horizontal", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/one-finger-drag-vertical", "name": "one-finger-drag-vertical", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/one-finger-hold", "name": "one-finger-hold", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/one-finger-tap", "name": "one-finger-tap", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/open-book", "name": "open-book", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/open-umbrella", "name": "open-umbrella", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/padlock-square-1", "name": "padlock-square-1", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/page-setting", "name": "page-setting", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/paint-bucket", "name": "paint-bucket", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/paint-palette", "name": "paint-palette", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/paintbrush-1", "name": "paintbrush-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/paintbrush-2", "name": "paintbrush-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/paperclip-1", "name": "paperclip-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/paragraph", "name": "paragraph", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/pathfinder-divide", "name": "pathfinder-divide", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/pathfinder-exclude", "name": "pathfinder-exclude", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/pathfinder-intersect", "name": "pathfinder-intersect", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/pathfinder-merge", "name": "pathfinder-merge", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/pathfinder-minus-front-1", "name": "pathfinder-minus-front-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/pathfinder-trim", "name": "pathfinder-trim", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/pathfinder-union", "name": "pathfinder-union", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/peace-hand", "name": "peace-hand", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/pen-3", "name": "pen-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/pen-draw", "name": "pen-draw", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/pen-tool", "name": "pen-tool", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/pencil", "name": "pencil", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/pentagon", "name": "pentagon", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/pi-symbol-circle", "name": "pi-symbol-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/pictures-folder-memories", "name": "pictures-folder-memories", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/podium", "name": "podium", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/polygon", "name": "polygon", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/praying-hand", "name": "praying-hand", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/projector-board", "name": "projector-board", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/pyramid-shape", "name": "pyramid-shape", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/quotation-2", "name": "quotation-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/radioactive-2", "name": "radioactive-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/rain-cloud", "name": "rain-cloud", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/recycle-bin-2", "name": "recycle-bin-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/ringing-bell-notification", "name": "ringing-bell-notification", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/rock-and-roll-hand", "name": "rock-and-roll-hand", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/rotate-angle-45", "name": "rotate-angle-45", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/round-cap", "name": "round-cap", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/satellite-dish", "name": "satellite-dish", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/scanner", "name": "scanner", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/search-visual", "name": "search-visual", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/select-circle-area-1", "name": "select-circle-area-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/share-link", "name": "share-link", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/shield-1", "name": "shield-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/shield-2", "name": "shield-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/shield-check", "name": "shield-check", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/shield-cross", "name": "shield-cross", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/shrink-horizontal-1", "name": "shrink-horizontal-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/shuffle", "name": "shuffle", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/sigma", "name": "sigma", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/skull-1", "name": "skull-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/sleep", "name": "sleep", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/snow-flake", "name": "snow-flake", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/sort-descending", "name": "sort-descending", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/spiral-shape", "name": "spiral-shape", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/split-vertical", "name": "split-vertical", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/spray-paint", "name": "spray-paint", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/square-brackets-circle", "name": "square-brackets-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/square-cap", "name": "square-cap", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/square-clock", "name": "square-clock", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/square-root-x-circle", "name": "square-root-x-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/star-1", "name": "star-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/star-2", "name": "star-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/star-badge", "name": "star-badge", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/straight-cap", "name": "straight-cap", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/subtract-1", "name": "subtract-1", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/subtract-circle", "name": "subtract-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/subtract-square", "name": "subtract-square", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/sun-cloud", "name": "sun-cloud", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/synchronize-disable", "name": "synchronize-disable", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/synchronize-warning", "name": "synchronize-warning", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/table-lamp-1", "name": "table-lamp-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/tag", "name": "tag", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/text-flow-rows", "name": "text-flow-rows", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/text-square", "name": "text-square", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/text-style", "name": "text-style", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/thermometer", "name": "thermometer", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/trending-content", "name": "trending-content", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/trophy", "name": "trophy", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/two-finger-drag-hotizontal", "name": "two-finger-drag-hotizontal", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/two-finger-tap", "name": "two-finger-tap", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/underline-text-1", "name": "underline-text-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/upload-box-1", "name": "upload-box-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/upload-circle", "name": "upload-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/upload-computer", "name": "upload-computer", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/upload-file", "name": "upload-file", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/user-add-plus", "name": "user-add-plus", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/user-check-validate", "name": "user-check-validate", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/user-circle-single", "name": "user-circle-single", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/user-identifier-card", "name": "user-identifier-card", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/user-multiple-circle", "name": "user-multiple-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/user-multiple-group", "name": "user-multiple-group", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/user-profile-focus", "name": "user-profile-focus", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/user-protection-2", "name": "user-protection-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/user-remove-subtract", "name": "user-remove-subtract", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/user-single-neutral-male", "name": "user-single-neutral-male", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/user-sync-online-in-person", "name": "user-sync-online-in-person", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/vertical-slider-square", "name": "vertical-slider-square", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/video-swap-camera", "name": "video-swap-camera", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/visible", "name": "visible", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/voice-scan-2", "name": "voice-scan-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/waning-cresent-moon", "name": "waning-cresent-moon", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/warning-octagon", "name": "warning-octagon", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/warning-triangle", "name": "warning-triangle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "mail": [ { "id": "mail/chat-bubble-oval-notification", "name": "chat-bubble-oval-notification", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/chat-bubble-oval-smiley-1", "name": "chat-bubble-oval-smiley-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/chat-bubble-oval-smiley-2", "name": "chat-bubble-oval-smiley-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/chat-bubble-oval", "name": "chat-bubble-oval", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/chat-bubble-square-block", "name": "chat-bubble-square-block", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/chat-bubble-square-question", "name": "chat-bubble-square-question", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/chat-bubble-square-warning", "name": "chat-bubble-square-warning", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/chat-bubble-square-write", "name": "chat-bubble-square-write", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/chat-bubble-text-square", "name": "chat-bubble-text-square", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/chat-bubble-typing-oval", "name": "chat-bubble-typing-oval", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/chat-two-bubbles-oval", "name": "chat-two-bubbles-oval", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/discussion-converstion-reply", "name": "discussion-converstion-reply", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/happy-face", "name": "happy-face", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/inbox-block", "name": "inbox-block", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/inbox-favorite-heart", "name": "inbox-favorite-heart", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/inbox-favorite", "name": "inbox-favorite", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/inbox-lock", "name": "inbox-lock", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/inbox-tray-1", "name": "inbox-tray-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/inbox-tray-2", "name": "inbox-tray-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/mail-incoming", "name": "mail-incoming", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/mail-search", "name": "mail-search", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/mail-send-email-message", "name": "mail-send-email-message", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/mail-send-envelope", "name": "mail-send-envelope", "keywords": [], "content": "\n\n\n" }, { "id": "mail/mail-send-reply-all", "name": "mail-send-reply-all", "keywords": [], "content": "\n\n\n" }, { "id": "mail/sad-face", "name": "sad-face", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/send-email", "name": "send-email", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/sign-at", "name": "sign-at", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/sign-hashtag", "name": "sign-hashtag", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/smiley-angry", "name": "smiley-angry", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/smiley-cool", "name": "smiley-cool", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/smiley-crying-1", "name": "smiley-crying-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/smiley-cute", "name": "smiley-cute", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/smiley-drool", "name": "smiley-drool", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/smiley-emoji-kiss-nervous", "name": "smiley-emoji-kiss-nervous", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/smiley-emoji-terrified", "name": "smiley-emoji-terrified", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/smiley-grumpy", "name": "smiley-grumpy", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/smiley-happy", "name": "smiley-happy", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/smiley-in-love", "name": "smiley-in-love", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/smiley-kiss", "name": "smiley-kiss", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/smiley-laughing-3", "name": "smiley-laughing-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "map_travel": [ { "id": "map_travel/airplane", "name": "airplane", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/airport-plane-transit", "name": "airport-plane-transit", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/airport-plane", "name": "airport-plane", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/airport-security", "name": "airport-security", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/anchor", "name": "anchor", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/baggage", "name": "baggage", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/beach", "name": "beach", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/bicycle-bike", "name": "bicycle-bike", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/braille-blind", "name": "braille-blind", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/bus", "name": "bus", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/camping-tent", "name": "camping-tent", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/cane", "name": "cane", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/capitol", "name": "capitol", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/car-battery-charging", "name": "car-battery-charging", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/car-taxi-1", "name": "car-taxi-1", "keywords": [], "content": "\n\n\n\n\n" }, { "id": "map_travel/city-hall", "name": "city-hall", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/compass-navigator", "name": "compass-navigator", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/crutch", "name": "crutch", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/dangerous-zone-sign", "name": "dangerous-zone-sign", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/earth-1", "name": "earth-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/earth-airplane", "name": "earth-airplane", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/emergency-exit", "name": "emergency-exit", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/fire-alarm-2", "name": "fire-alarm-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/fire-extinguisher-sign", "name": "fire-extinguisher-sign", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/gas-station-fuel-petroleum", "name": "gas-station-fuel-petroleum", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/hearing-deaf-1", "name": "hearing-deaf-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/hearing-deaf-2", "name": "hearing-deaf-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/high-speed-train-front", "name": "high-speed-train-front", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/hot-spring", "name": "hot-spring", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/hotel-air-conditioner", "name": "hotel-air-conditioner", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/hotel-bed-2", "name": "hotel-bed-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/hotel-laundry", "name": "hotel-laundry", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/hotel-one-star", "name": "hotel-one-star", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/hotel-shower-head", "name": "hotel-shower-head", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/hotel-two-star", "name": "hotel-two-star", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/information-desk-customer", "name": "information-desk-customer", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/information-desk", "name": "information-desk", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/iron", "name": "iron", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/ladder", "name": "ladder", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/lift-disability", "name": "lift-disability", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/lift", "name": "lift", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/location-compass-1", "name": "location-compass-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/location-pin-3", "name": "location-pin-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/location-pin-disabled", "name": "location-pin-disabled", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/location-target-1", "name": "location-target-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/lost-and-found", "name": "lost-and-found", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/man-symbol", "name": "man-symbol", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/map-fold", "name": "map-fold", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/navigation-arrow-off", "name": "navigation-arrow-off", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/navigation-arrow-on", "name": "navigation-arrow-on", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/parking-sign", "name": "parking-sign", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/parliament", "name": "parliament", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/passport", "name": "passport", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/pet-paw", "name": "pet-paw", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/pets-allowed", "name": "pets-allowed", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/pool-ladder", "name": "pool-ladder", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/rock-slide", "name": "rock-slide", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/sail-ship", "name": "sail-ship", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/school-bus-side", "name": "school-bus-side", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/smoke-detector", "name": "smoke-detector", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/smoking-area", "name": "smoking-area", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/snorkle", "name": "snorkle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/steering-wheel", "name": "steering-wheel", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/street-road", "name": "street-road", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/street-sign", "name": "street-sign", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/take-off", "name": "take-off", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/toilet-man", "name": "toilet-man", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/toilet-sign-man-woman-2", "name": "toilet-sign-man-woman-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/toilet-women", "name": "toilet-women", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/traffic-cone", "name": "traffic-cone", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/triangle-flag", "name": "triangle-flag", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/wheelchair-1", "name": "wheelchair-1", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/woman-symbol", "name": "woman-symbol", "keywords": [], "content": "\n\n\n" } ], "money_shopping": [ { "id": "money_shopping/annoncement-megaphone", "name": "annoncement-megaphone", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/backpack", "name": "backpack", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/bag-dollar", "name": "bag-dollar", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/bag-pound", "name": "bag-pound", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/bag-rupee", "name": "bag-rupee", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/bag-suitcase-1", "name": "bag-suitcase-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/bag-suitcase-2", "name": "bag-suitcase-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/bag-yen", "name": "bag-yen", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/bag", "name": "bag", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/ball", "name": "ball", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/bank", "name": "bank", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/beanie", "name": "beanie", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/bill-1", "name": "bill-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/bill-2", "name": "bill-2", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/bill-4", "name": "bill-4", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/bill-cashless", "name": "bill-cashless", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/binance-circle", "name": "binance-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/bitcoin", "name": "bitcoin", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/bow-tie", "name": "bow-tie", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/briefcase-dollar", "name": "briefcase-dollar", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/building-2", "name": "building-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/business-card", "name": "business-card", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/business-handshake", "name": "business-handshake", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/business-idea-money", "name": "business-idea-money", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/business-profession-home-office", "name": "business-profession-home-office", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/business-progress-bar-2", "name": "business-progress-bar-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/business-user-curriculum", "name": "business-user-curriculum", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/calculator-1", "name": "calculator-1", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/calculator-2", "name": "calculator-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/cane", "name": "cane", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/chair", "name": "chair", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/closet", "name": "closet", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/coin-share", "name": "coin-share", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/coins-stack", "name": "coins-stack", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/credit-card-1", "name": "credit-card-1", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/credit-card-2", "name": "credit-card-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/diamond-2", "name": "diamond-2", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/discount-percent-badge", "name": "discount-percent-badge", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/discount-percent-circle", "name": "discount-percent-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/discount-percent-coupon", "name": "discount-percent-coupon", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/discount-percent-cutout", "name": "discount-percent-cutout", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/discount-percent-fire", "name": "discount-percent-fire", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/dollar-coin-1", "name": "dollar-coin-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/dollar-coin", "name": "dollar-coin", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/dressing-table", "name": "dressing-table", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/ethereum-circle", "name": "ethereum-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/ethereum", "name": "ethereum", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/euro", "name": "euro", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/gift-2", "name": "gift-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/gift", "name": "gift", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/gold", "name": "gold", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/graph-arrow-decrease", "name": "graph-arrow-decrease", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/graph-arrow-increase", "name": "graph-arrow-increase", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/graph-bar-decrease", "name": "graph-bar-decrease", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/graph-bar-increase", "name": "graph-bar-increase", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/graph-dot", "name": "graph-dot", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/graph", "name": "graph", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/investment-selection", "name": "investment-selection", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/justice-hammer", "name": "justice-hammer", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/justice-scale-1", "name": "justice-scale-1", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/justice-scale-2", "name": "justice-scale-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/lipstick", "name": "lipstick", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/make-up-brush", "name": "make-up-brush", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/moustache", "name": "moustache", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/mouth-lip", "name": "mouth-lip", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/necklace", "name": "necklace", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/necktie", "name": "necktie", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/payment-10", "name": "payment-10", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/payment-cash-out-3", "name": "payment-cash-out-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/pie-chart", "name": "pie-chart", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/piggy-bank", "name": "piggy-bank", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/polka-dot-circle", "name": "polka-dot-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/production-belt", "name": "production-belt", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/qr-code", "name": "qr-code", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/receipt-add", "name": "receipt-add", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/receipt-check", "name": "receipt-check", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/receipt-subtract", "name": "receipt-subtract", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/receipt", "name": "receipt", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/safe-vault", "name": "safe-vault", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/scanner-3", "name": "scanner-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/scanner-bar-code", "name": "scanner-bar-code", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/shelf", "name": "shelf", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/shopping-bag-hand-bag-2", "name": "shopping-bag-hand-bag-2", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/shopping-basket-1", "name": "shopping-basket-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/shopping-basket-2", "name": "shopping-basket-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/shopping-cart-1", "name": "shopping-cart-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/shopping-cart-2", "name": "shopping-cart-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/shopping-cart-3", "name": "shopping-cart-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/shopping-cart-add", "name": "shopping-cart-add", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/shopping-cart-check", "name": "shopping-cart-check", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/shopping-cart-subtract", "name": "shopping-cart-subtract", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/signage-3", "name": "signage-3", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/signage-4", "name": "signage-4", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/startup", "name": "startup", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/stock", "name": "stock", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/store-1", "name": "store-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/store-2", "name": "store-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/store-computer", "name": "store-computer", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/subscription-cashflow", "name": "subscription-cashflow", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/tag", "name": "tag", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/tall-hat", "name": "tall-hat", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/target-3", "name": "target-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/target", "name": "target", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/wallet-purse", "name": "wallet-purse", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/wallet", "name": "wallet", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/xrp-circle", "name": "xrp-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/yuan-circle", "name": "yuan-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/yuan", "name": "yuan", "keywords": [], "content": "\n\n\n" } ], "nature_ecology": [ { "id": "nature_ecology/affordable-and-clean-energy", "name": "affordable-and-clean-energy", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/alien", "name": "alien", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/bone", "name": "bone", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/cat-1", "name": "cat-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/circle-flask", "name": "circle-flask", "keywords": [], "content": "\n\n\n" }, { "id": "nature_ecology/clean-water-and-sanitation", "name": "clean-water-and-sanitation", "keywords": [], "content": "\n\n\n" }, { "id": "nature_ecology/comet", "name": "comet", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/decent-work-and-economic-growth", "name": "decent-work-and-economic-growth", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/dna", "name": "dna", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/erlenmeyer-flask", "name": "erlenmeyer-flask", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/flower", "name": "flower", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/galaxy-1", "name": "galaxy-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/galaxy-2", "name": "galaxy-2", "keywords": [], "content": "\n\n\n" }, { "id": "nature_ecology/gender-equality", "name": "gender-equality", "keywords": [], "content": "\n\n\n" }, { "id": "nature_ecology/good-health-and-well-being", "name": "good-health-and-well-being", "keywords": [], "content": "\n\n\n" }, { "id": "nature_ecology/industry-innovation-and-infrastructure", "name": "industry-innovation-and-infrastructure", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/leaf", "name": "leaf", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/log", "name": "log", "keywords": [], "content": "\n\n\n" }, { "id": "nature_ecology/no-poverty", "name": "no-poverty", "keywords": [], "content": "\n\n\n" }, { "id": "nature_ecology/octopus", "name": "octopus", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/planet", "name": "planet", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/potted-flower-tulip", "name": "potted-flower-tulip", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/quality-education", "name": "quality-education", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/rainbow", "name": "rainbow", "keywords": [], "content": "\n\n\n" }, { "id": "nature_ecology/recycle-1", "name": "recycle-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/reduced-inequalities", "name": "reduced-inequalities", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/rose", "name": "rose", "keywords": [], "content": "\n\n\n" }, { "id": "nature_ecology/shell", "name": "shell", "keywords": [], "content": "\n\n\n" }, { "id": "nature_ecology/shovel-rake", "name": "shovel-rake", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/sprout", "name": "sprout", "keywords": [], "content": "\n\n\n" }, { "id": "nature_ecology/telescope", "name": "telescope", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/test-tube", "name": "test-tube", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/tidal-wave", "name": "tidal-wave", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/tree-2", "name": "tree-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/tree-3", "name": "tree-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/volcano", "name": "volcano", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/windmill", "name": "windmill", "keywords": [], "content": "\n\n\n" }, { "id": "nature_ecology/zero-hunger", "name": "zero-hunger", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "phone": [ { "id": "phone/airplane-disabled", "name": "airplane-disabled", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "phone/airplane-enabled", "name": "airplane-enabled", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "phone/back-camera-1", "name": "back-camera-1", "keywords": [], "content": "\n\n\n" }, { "id": "phone/call-hang-up", "name": "call-hang-up", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "phone/cellular-network-4g", "name": "cellular-network-4g", "keywords": [], "content": "\n\n\n" }, { "id": "phone/cellular-network-5g", "name": "cellular-network-5g", "keywords": [], "content": "\n\n\n" }, { "id": "phone/cellular-network-lte", "name": "cellular-network-lte", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "phone/contact-phonebook-2", "name": "contact-phonebook-2", "keywords": [], "content": "\n\n\n" }, { "id": "phone/hang-up-1", "name": "hang-up-1", "keywords": [], "content": "\n\n\n" }, { "id": "phone/hang-up-2", "name": "hang-up-2", "keywords": [], "content": "\n\n\n" }, { "id": "phone/incoming-call", "name": "incoming-call", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "phone/missed-call", "name": "missed-call", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "phone/notification-alarm-2", "name": "notification-alarm-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "phone/notification-application-1", "name": "notification-application-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "phone/notification-application-2", "name": "notification-application-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "phone/notification-message-alert", "name": "notification-message-alert", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "phone/outgoing-call", "name": "outgoing-call", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "phone/phone-mobile-phone", "name": "phone-mobile-phone", "keywords": [], "content": "\n\n\n" }, { "id": "phone/phone-qr", "name": "phone-qr", "keywords": [], "content": "\n\n\n" }, { "id": "phone/phone-ringing-1", "name": "phone-ringing-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "phone/phone-ringing-2", "name": "phone-ringing-2", "keywords": [], "content": "\n\n\n" }, { "id": "phone/phone", "name": "phone", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "phone/signal-full", "name": "signal-full", "keywords": [], "content": "\n\n\n" }, { "id": "phone/signal-low", "name": "signal-low", "keywords": [], "content": "\n\n\n" }, { "id": "phone/signal-medium", "name": "signal-medium", "keywords": [], "content": "\n\n\n" }, { "id": "phone/signal-none", "name": "signal-none", "keywords": [], "content": "\n\n\n" } ], "programing": [ { "id": "programing/application-add", "name": "application-add", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/bracket", "name": "bracket", "keywords": [], "content": "\n\n\n" }, { "id": "programing/browser-add", "name": "browser-add", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/browser-block", "name": "browser-block", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/browser-build", "name": "browser-build", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/browser-check", "name": "browser-check", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/browser-delete", "name": "browser-delete", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/browser-hash", "name": "browser-hash", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/browser-lock", "name": "browser-lock", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/browser-multiple-window", "name": "browser-multiple-window", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/browser-remove", "name": "browser-remove", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/browser-website-1", "name": "browser-website-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/bug-antivirus-debugging", "name": "bug-antivirus-debugging", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/bug-antivirus-shield", "name": "bug-antivirus-shield", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/bug-virus-browser", "name": "bug-virus-browser", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/bug-virus-document", "name": "bug-virus-document", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/bug-virus-folder", "name": "bug-virus-folder", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/bug", "name": "bug", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/cloud-add", "name": "cloud-add", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/cloud-block", "name": "cloud-block", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/cloud-check", "name": "cloud-check", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/cloud-data-transfer", "name": "cloud-data-transfer", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/cloud-refresh", "name": "cloud-refresh", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/cloud-share", "name": "cloud-share", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/cloud-warning", "name": "cloud-warning", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/cloud-wifi", "name": "cloud-wifi", "keywords": [], "content": "\n\n\n" }, { "id": "programing/code-analysis", "name": "code-analysis", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/code-monitor-1", "name": "code-monitor-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/code-monitor-2", "name": "code-monitor-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/css-three", "name": "css-three", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/curly-brackets", "name": "curly-brackets", "keywords": [], "content": "\n\n\n" }, { "id": "programing/file-code-1", "name": "file-code-1", "keywords": [], "content": "\n\n\n" }, { "id": "programing/incognito-mode", "name": "incognito-mode", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/insert-cloud-video", "name": "insert-cloud-video", "keywords": [], "content": "\n\n\n" }, { "id": "programing/markdown-circle-programming", "name": "markdown-circle-programming", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/markdown-document-programming", "name": "markdown-document-programming", "keywords": [], "content": "\n\n\n" }, { "id": "programing/module-puzzle-1", "name": "module-puzzle-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/module-puzzle-3", "name": "module-puzzle-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/module-three", "name": "module-three", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/rss-square", "name": "rss-square", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "shipping": [ { "id": "shipping/box-sign", "name": "box-sign", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "shipping/container", "name": "container", "keywords": [], "content": "\n\n\n" }, { "id": "shipping/fragile", "name": "fragile", "keywords": [], "content": "\n\n\n" }, { "id": "shipping/parachute-drop", "name": "parachute-drop", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "shipping/shipment-add", "name": "shipment-add", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "shipping/shipment-check", "name": "shipment-check", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "shipping/shipment-download", "name": "shipment-download", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "shipping/shipment-remove", "name": "shipment-remove", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "shipping/shipment-upload", "name": "shipment-upload", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "shipping/shipping-box-1", "name": "shipping-box-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "shipping/shipping-truck", "name": "shipping-truck", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "shipping/transfer-motorcycle", "name": "transfer-motorcycle", "keywords": [], "content": "\n\n\n" }, { "id": "shipping/transfer-van", "name": "transfer-van", "keywords": [], "content": "\n\n\n" }, { "id": "shipping/warehouse-1", "name": "warehouse-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "work_education": [ { "id": "work_education/book-reading", "name": "book-reading", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "work_education/class-lesson", "name": "class-lesson", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "work_education/collaborations-idea", "name": "collaborations-idea", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "work_education/definition-search-book", "name": "definition-search-book", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "work_education/dictionary-language-book", "name": "dictionary-language-book", "keywords": [], "content": "\n\n\n" }, { "id": "work_education/global-learning", "name": "global-learning", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "work_education/graduation-cap", "name": "graduation-cap", "keywords": [], "content": "\n\n\n" }, { "id": "work_education/group-meeting-call", "name": "group-meeting-call", "keywords": [], "content": "\n\n\n" }, { "id": "work_education/office-building-1", "name": "office-building-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "work_education/office-worker", "name": "office-worker", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "work_education/search-dollar", "name": "search-dollar", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "work_education/strategy-tasks", "name": "strategy-tasks", "keywords": [], "content": "\n\n\n" }, { "id": "work_education/task-list", "name": "task-list", "keywords": [], "content": "\n\n\n" }, { "id": "work_education/workspace-desk", "name": "workspace-desk", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" } ] } \ No newline at end of file diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/auto-flash.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/auto-flash.svg new file mode 100644 index 0000000000000..0c1936fb80f39 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/auto-flash.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/camera-1.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/camera-1.svg new file mode 100644 index 0000000000000..6b6609071c5e8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/camera-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/camera-disabled.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/camera-disabled.svg new file mode 100644 index 0000000000000..4f4c45d181ab2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/camera-disabled.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/camera-loading.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/camera-loading.svg new file mode 100644 index 0000000000000..ad3ec3d08dfce --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/camera-loading.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/camera-square.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/camera-square.svg new file mode 100644 index 0000000000000..f90f048eaf1a2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/camera-square.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/composition-oval.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/composition-oval.svg new file mode 100644 index 0000000000000..1799610d70dee --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/composition-oval.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/composition-vertical.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/composition-vertical.svg new file mode 100644 index 0000000000000..758a66a9a28d7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/composition-vertical.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/compsition-horizontal.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/compsition-horizontal.svg new file mode 100644 index 0000000000000..b4b5ed760db88 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/compsition-horizontal.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/edit-image-photo.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/edit-image-photo.svg new file mode 100644 index 0000000000000..fc9c7e8b3f5df --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/edit-image-photo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/film-roll-1.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/film-roll-1.svg new file mode 100644 index 0000000000000..d657abec5d3c2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/film-roll-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/film-slate.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/film-slate.svg new file mode 100644 index 0000000000000..8fd8f3fed8348 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/film-slate.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/flash-1.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/flash-1.svg new file mode 100644 index 0000000000000..f1814e8186759 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/flash-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/flash-2.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/flash-2.svg new file mode 100644 index 0000000000000..24d2d68e079dd --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/flash-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/flash-3.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/flash-3.svg new file mode 100644 index 0000000000000..e98c8193e9583 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/flash-3.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/flash-off.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/flash-off.svg new file mode 100644 index 0000000000000..4260106b57c22 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/flash-off.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/flower.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/flower.svg new file mode 100644 index 0000000000000..87981fa4a1b2e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/flower.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/focus-points.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/focus-points.svg new file mode 100644 index 0000000000000..149495c4afc60 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/focus-points.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/landscape-2.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/landscape-2.svg new file mode 100644 index 0000000000000..ec970a9893e04 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/landscape-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/landscape-setting.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/landscape-setting.svg new file mode 100644 index 0000000000000..c87b58d38bd7f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/landscape-setting.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/laptop-camera.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/laptop-camera.svg new file mode 100644 index 0000000000000..88d1c7bb8f96f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/laptop-camera.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/mobile-phone-camera.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/mobile-phone-camera.svg new file mode 100644 index 0000000000000..b7b69aa738803 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/mobile-phone-camera.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/orientation-landscape.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/orientation-landscape.svg new file mode 100644 index 0000000000000..c432b7b0462ee --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/orientation-landscape.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/orientation-portrait.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/orientation-portrait.svg new file mode 100644 index 0000000000000..deaf60faf6fab --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/orientation-portrait.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/polaroid-four.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/polaroid-four.svg new file mode 100644 index 0000000000000..6e9121cd504fc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/polaroid-four.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/add-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/add-1.svg new file mode 100644 index 0000000000000..dedf45912b9ab --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/add-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/add-bell-notification.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/add-bell-notification.svg new file mode 100644 index 0000000000000..d8af9e31e5bc0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/add-bell-notification.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/add-circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/add-circle.svg new file mode 100644 index 0000000000000..4e6af27c9a031 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/add-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/add-layer-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/add-layer-2.svg new file mode 100644 index 0000000000000..5027acb248171 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/add-layer-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/add-square.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/add-square.svg new file mode 100644 index 0000000000000..1900a45c11b51 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/add-square.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/alarm-clock.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/alarm-clock.svg new file mode 100644 index 0000000000000..773ca81fe6af5 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/alarm-clock.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/align-back-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/align-back-1.svg new file mode 100644 index 0000000000000..8045f92e3d243 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/align-back-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/align-center.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/align-center.svg new file mode 100644 index 0000000000000..25dd359f6ae37 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/align-center.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/align-front-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/align-front-1.svg new file mode 100644 index 0000000000000..402eb326da2cb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/align-front-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/align-left.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/align-left.svg new file mode 100644 index 0000000000000..e19e815cfbaa6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/align-left.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/align-right.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/align-right.svg new file mode 100644 index 0000000000000..3ff840a8134e2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/align-right.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/ampersand.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/ampersand.svg new file mode 100644 index 0000000000000..11da33fb200cc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/ampersand.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/archive-box.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/archive-box.svg new file mode 100644 index 0000000000000..3816bf9f6c9fa --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/archive-box.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-bend-left-down-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-bend-left-down-2.svg new file mode 100644 index 0000000000000..7df296c6043bc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-bend-left-down-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-bend-right-down-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-bend-right-down-2.svg new file mode 100644 index 0000000000000..4d351c0f8ed0d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-bend-right-down-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-crossover-down.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-crossover-down.svg new file mode 100644 index 0000000000000..c824a1d2aa75f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-crossover-down.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-crossover-left.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-crossover-left.svg new file mode 100644 index 0000000000000..c64e0771b08ea --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-crossover-left.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-crossover-right.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-crossover-right.svg new file mode 100644 index 0000000000000..1e87e2927b0df --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-crossover-right.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-crossover-up.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-crossover-up.svg new file mode 100644 index 0000000000000..8707846460c11 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-crossover-up.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-cursor-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-cursor-1.svg new file mode 100644 index 0000000000000..1d4948c62e241 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-cursor-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-cursor-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-cursor-2.svg new file mode 100644 index 0000000000000..2fddfa485df4c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-cursor-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-curvy-up-down-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-curvy-up-down-1.svg new file mode 100644 index 0000000000000..7df202b6dfb94 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-curvy-up-down-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-curvy-up-down-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-curvy-up-down-2.svg new file mode 100644 index 0000000000000..65762b3f51699 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-curvy-up-down-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-down-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-down-2.svg new file mode 100644 index 0000000000000..1eacf2b68d4a9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-down-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-down-dashed-square.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-down-dashed-square.svg new file mode 100644 index 0000000000000..7e3f1a5a40517 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-down-dashed-square.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-expand.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-expand.svg new file mode 100644 index 0000000000000..6a282421ea3cc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-expand.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-infinite-loop.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-infinite-loop.svg new file mode 100644 index 0000000000000..a586e55081a8a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-infinite-loop.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-move.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-move.svg new file mode 100644 index 0000000000000..b106268e87675 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-move.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-reload-horizontal-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-reload-horizontal-1.svg new file mode 100644 index 0000000000000..0b0a93b6309ae --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-reload-horizontal-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-reload-horizontal-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-reload-horizontal-2.svg new file mode 100644 index 0000000000000..a649467631150 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-reload-horizontal-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-reload-vertical-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-reload-vertical-1.svg new file mode 100644 index 0000000000000..933f27a9e908e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-reload-vertical-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-reload-vertical-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-reload-vertical-2.svg new file mode 100644 index 0000000000000..a307381d2cc61 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-reload-vertical-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-roadmap.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-roadmap.svg new file mode 100644 index 0000000000000..70870883bb3c9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-roadmap.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-round-left.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-round-left.svg new file mode 100644 index 0000000000000..f9520235025ed --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-round-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-round-right.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-round-right.svg new file mode 100644 index 0000000000000..e335b2a94faf6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-round-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-shrink-diagonal-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-shrink-diagonal-1.svg new file mode 100644 index 0000000000000..613ce1cabfdd4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-shrink-diagonal-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-shrink-diagonal-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-shrink-diagonal-2.svg new file mode 100644 index 0000000000000..286c959465209 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-shrink-diagonal-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-shrink.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-shrink.svg new file mode 100644 index 0000000000000..19489b132a12b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-shrink.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-transfer-diagonal-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-transfer-diagonal-1.svg new file mode 100644 index 0000000000000..3e6efceb00c19 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-transfer-diagonal-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-transfer-diagonal-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-transfer-diagonal-2.svg new file mode 100644 index 0000000000000..db9160cbfd9de --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-transfer-diagonal-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-transfer-diagonal-3.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-transfer-diagonal-3.svg new file mode 100644 index 0000000000000..63b8361656a56 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-transfer-diagonal-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-up-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-up-1.svg new file mode 100644 index 0000000000000..6554aeefb4479 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-up-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-up-dashed-square.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-up-dashed-square.svg new file mode 100644 index 0000000000000..e583df64a3e33 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-up-dashed-square.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/ascending-number-order.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/ascending-number-order.svg new file mode 100644 index 0000000000000..8b8fe17bb3bf0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/ascending-number-order.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/attribution.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/attribution.svg new file mode 100644 index 0000000000000..9118a362ede3f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/attribution.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/blank-calendar.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/blank-calendar.svg new file mode 100644 index 0000000000000..cdbe62e663dac --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/blank-calendar.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/blank-notepad.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/blank-notepad.svg new file mode 100644 index 0000000000000..b1f814264b1ae --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/blank-notepad.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/block-bell-notification.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/block-bell-notification.svg new file mode 100644 index 0000000000000..fd9389548aa61 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/block-bell-notification.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/bomb.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/bomb.svg new file mode 100644 index 0000000000000..972746f65e5e7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/bomb.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/bookmark.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/bookmark.svg new file mode 100644 index 0000000000000..808752dc47c3c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/bookmark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/braces-circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/braces-circle.svg new file mode 100644 index 0000000000000..9ce91cffd0c5f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/braces-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/brightness-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/brightness-1.svg new file mode 100644 index 0000000000000..8374202149d83 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/brightness-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/brightness-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/brightness-2.svg new file mode 100644 index 0000000000000..343c13113d5e7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/brightness-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/brightness-3.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/brightness-3.svg new file mode 100644 index 0000000000000..d18adb4fc802b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/brightness-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/broken-link-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/broken-link-2.svg new file mode 100644 index 0000000000000..f2dc320b5053f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/broken-link-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/bullet-list.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/bullet-list.svg new file mode 100644 index 0000000000000..a282a82f70b76 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/bullet-list.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/calendar-add.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/calendar-add.svg new file mode 100644 index 0000000000000..36d0fd2ef4b3c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/calendar-add.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/calendar-edit.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/calendar-edit.svg new file mode 100644 index 0000000000000..2d5296ae1ca1e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/calendar-edit.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/calendar-jump-to-date.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/calendar-jump-to-date.svg new file mode 100644 index 0000000000000..b9b39c8dbbccd --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/calendar-jump-to-date.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/calendar-star.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/calendar-star.svg new file mode 100644 index 0000000000000..18de81b0bfad3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/calendar-star.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/celsius.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/celsius.svg new file mode 100644 index 0000000000000..42c694210dc2e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/celsius.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/check-square.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/check-square.svg new file mode 100644 index 0000000000000..fd7897030357b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/check-square.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/check.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/check.svg new file mode 100644 index 0000000000000..1a0d205a49c4c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/check.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/circle-clock.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/circle-clock.svg new file mode 100644 index 0000000000000..66fea10946506 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/circle-clock.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/circle.svg new file mode 100644 index 0000000000000..fc162183335db --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/clipboard-add.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/clipboard-add.svg new file mode 100644 index 0000000000000..ceea0cddffeff --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/clipboard-add.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/clipboard-check.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/clipboard-check.svg new file mode 100644 index 0000000000000..5ac2b85299331 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/clipboard-check.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/clipboard-remove.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/clipboard-remove.svg new file mode 100644 index 0000000000000..db6feb1ee1fc9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/clipboard-remove.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/cloud.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/cloud.svg new file mode 100644 index 0000000000000..22a7dfa2cf5c4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/cloud.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/cog.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/cog.svg new file mode 100644 index 0000000000000..f951e92137be3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/cog.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/color-palette.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/color-palette.svg new file mode 100644 index 0000000000000..05b74893670f3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/color-palette.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/color-picker.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/color-picker.svg new file mode 100644 index 0000000000000..9fde8baaa2956 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/color-picker.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/color-swatches.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/color-swatches.svg new file mode 100644 index 0000000000000..5071d67161e27 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/color-swatches.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/cone-shape.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/cone-shape.svg new file mode 100644 index 0000000000000..e5623415c6d14 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/cone-shape.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/convert-PDF-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/convert-PDF-2.svg new file mode 100644 index 0000000000000..ed7db405845e4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/convert-PDF-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/copy-paste.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/copy-paste.svg new file mode 100644 index 0000000000000..ce0fd6383c9fe --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/copy-paste.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/creative-commons.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/creative-commons.svg new file mode 100644 index 0000000000000..7a3997e6368bf --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/creative-commons.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/crop-selection.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/crop-selection.svg new file mode 100644 index 0000000000000..4c5166cb656f5 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/crop-selection.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/crown.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/crown.svg new file mode 100644 index 0000000000000..951fb68553eb6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/crown.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/customer-support-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/customer-support-1.svg new file mode 100644 index 0000000000000..5593196fbf2a2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/customer-support-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/cut.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/cut.svg new file mode 100644 index 0000000000000..8d63408f39c5b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/cut.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/dark-dislay-mode.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/dark-dislay-mode.svg new file mode 100644 index 0000000000000..b5fddc9f7dde7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/dark-dislay-mode.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/dashboard-3.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/dashboard-3.svg new file mode 100644 index 0000000000000..54eb799a01c72 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/dashboard-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/dashboard-circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/dashboard-circle.svg new file mode 100644 index 0000000000000..e60ce62cdf6db --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/dashboard-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/delete-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/delete-1.svg new file mode 100644 index 0000000000000..534ae11cbabc2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/delete-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/descending-number-order.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/descending-number-order.svg new file mode 100644 index 0000000000000..9ce81193f3e21 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/descending-number-order.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/disable-bell-notification.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/disable-bell-notification.svg new file mode 100644 index 0000000000000..2e1a02036a9f7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/disable-bell-notification.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/disable-heart.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/disable-heart.svg new file mode 100644 index 0000000000000..d3943473ef938 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/disable-heart.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/division-circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/division-circle.svg new file mode 100644 index 0000000000000..2695bb2aaae16 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/division-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/download-box-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/download-box-1.svg new file mode 100644 index 0000000000000..11bb09cabaf8d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/download-box-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/download-circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/download-circle.svg new file mode 100644 index 0000000000000..bf14c7df8d7c1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/download-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/download-computer.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/download-computer.svg new file mode 100644 index 0000000000000..d7ea9900f4929 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/download-computer.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/download-file.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/download-file.svg new file mode 100644 index 0000000000000..a298a8eec18d9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/download-file.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/empty-clipboard.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/empty-clipboard.svg new file mode 100644 index 0000000000000..5ea444ac50ccb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/empty-clipboard.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/equal-sign.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/equal-sign.svg new file mode 100644 index 0000000000000..94fa93fc415f1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/equal-sign.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/expand-horizontal-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/expand-horizontal-1.svg new file mode 100644 index 0000000000000..108286e4b213f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/expand-horizontal-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/expand-window-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/expand-window-2.svg new file mode 100644 index 0000000000000..f04b40d4613a5 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/expand-window-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/expand.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/expand.svg new file mode 100644 index 0000000000000..adad5b6fc5189 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/expand.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/face-scan-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/face-scan-1.svg new file mode 100644 index 0000000000000..468f7b9d25336 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/face-scan-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/factorial.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/factorial.svg new file mode 100644 index 0000000000000..127c8e23245a2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/factorial.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/fahrenheit.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/fahrenheit.svg new file mode 100644 index 0000000000000..3336086ece153 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/fahrenheit.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/fastforward-clock.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/fastforward-clock.svg new file mode 100644 index 0000000000000..c7d02240eabe5 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/fastforward-clock.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/file-add-alternate.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/file-add-alternate.svg new file mode 100644 index 0000000000000..1df2d54768431 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/file-add-alternate.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/file-delete-alternate.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/file-delete-alternate.svg new file mode 100644 index 0000000000000..1dc099eaa6086 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/file-delete-alternate.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/file-remove-alternate.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/file-remove-alternate.svg new file mode 100644 index 0000000000000..9c019dea7a5a4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/file-remove-alternate.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/filter-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/filter-2.svg new file mode 100644 index 0000000000000..b8f72f9e89065 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/filter-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/fingerprint-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/fingerprint-1.svg new file mode 100644 index 0000000000000..2d481b19168f3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/fingerprint-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/fingerprint-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/fingerprint-2.svg new file mode 100644 index 0000000000000..8216298e20b3f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/fingerprint-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/fist.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/fist.svg new file mode 100644 index 0000000000000..7f9e043096d14 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/fist.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/fit-to-height-square.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/fit-to-height-square.svg new file mode 100644 index 0000000000000..b8976428b2908 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/fit-to-height-square.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/flip-vertical-arrow-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/flip-vertical-arrow-2.svg new file mode 100644 index 0000000000000..758b31b29afaf --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/flip-vertical-arrow-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/flip-vertical-circle-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/flip-vertical-circle-1.svg new file mode 100644 index 0000000000000..1be2ef6ffb469 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/flip-vertical-circle-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/flip-vertical-square-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/flip-vertical-square-2.svg new file mode 100644 index 0000000000000..dfbb30b0c1ec1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/flip-vertical-square-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/folder-add.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/folder-add.svg new file mode 100644 index 0000000000000..d21b28a5847c8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/folder-add.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/folder-check.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/folder-check.svg new file mode 100644 index 0000000000000..e838527d46b54 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/folder-check.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/folder-delete.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/folder-delete.svg new file mode 100644 index 0000000000000..7f39330a0b372 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/folder-delete.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/front-camera.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/front-camera.svg new file mode 100644 index 0000000000000..1373e61f2d136 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/front-camera.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/gif-format.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/gif-format.svg new file mode 100644 index 0000000000000..432e41013029e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/gif-format.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/give-gift.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/give-gift.svg new file mode 100644 index 0000000000000..8040687c5ea32 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/give-gift.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/glasses.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/glasses.svg new file mode 100644 index 0000000000000..d5feb7462d96f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/glasses.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/half-star-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/half-star-1.svg new file mode 100644 index 0000000000000..57e9efaf7c763 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/half-star-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/hand-cursor.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/hand-cursor.svg new file mode 100644 index 0000000000000..2d09bc7926fba --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/hand-cursor.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/hand-grab.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/hand-grab.svg new file mode 100644 index 0000000000000..24eec4e45319c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/hand-grab.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/heading-1-paragraph-styles-heading.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/heading-1-paragraph-styles-heading.svg new file mode 100644 index 0000000000000..7bdad9cff93b2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/heading-1-paragraph-styles-heading.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/heading-2-paragraph-styles-heading.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/heading-2-paragraph-styles-heading.svg new file mode 100644 index 0000000000000..53c948d7b2316 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/heading-2-paragraph-styles-heading.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/heading-3-paragraph-styles-heading.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/heading-3-paragraph-styles-heading.svg new file mode 100644 index 0000000000000..69d6db3d3d62e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/heading-3-paragraph-styles-heading.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/heart.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/heart.svg new file mode 100644 index 0000000000000..e525ec4e3d3ce --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/heart.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/help-chat-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/help-chat-2.svg new file mode 100644 index 0000000000000..ee9b03674388d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/help-chat-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/help-question-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/help-question-1.svg new file mode 100644 index 0000000000000..e709c34077f88 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/help-question-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-10.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-10.svg new file mode 100644 index 0000000000000..a39558ce91125 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-10.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-13.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-13.svg new file mode 100644 index 0000000000000..7a95cd6bb0472 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-13.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-14.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-14.svg new file mode 100644 index 0000000000000..8d7650b3ef2e1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-14.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-2.svg new file mode 100644 index 0000000000000..7308894854391 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-4.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-4.svg new file mode 100644 index 0000000000000..24e31540de7cd --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-4.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-7.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-7.svg new file mode 100644 index 0000000000000..3485bea5e0678 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-7.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/home-3.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/home-3.svg new file mode 100644 index 0000000000000..36d1d77fbb676 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/home-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/home-4.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/home-4.svg new file mode 100644 index 0000000000000..c7bc580449912 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/home-4.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/horizontal-menu-circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/horizontal-menu-circle.svg new file mode 100644 index 0000000000000..a3091c335783e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/horizontal-menu-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/humidity-none.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/humidity-none.svg new file mode 100644 index 0000000000000..c81c318039bee --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/humidity-none.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/image-blur.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/image-blur.svg new file mode 100644 index 0000000000000..132f437695079 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/image-blur.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/image-saturation.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/image-saturation.svg new file mode 100644 index 0000000000000..5bfd1feb04cd3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/image-saturation.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/information-circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/information-circle.svg new file mode 100644 index 0000000000000..8ea9d8a04bb21 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/information-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/input-box.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/input-box.svg new file mode 100644 index 0000000000000..6369712e83e9e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/input-box.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/insert-side.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/insert-side.svg new file mode 100644 index 0000000000000..a8cb471c5b3ac --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/insert-side.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/insert-top-left.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/insert-top-left.svg new file mode 100644 index 0000000000000..248fa83cb8fcb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/insert-top-left.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/insert-top-right.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/insert-top-right.svg new file mode 100644 index 0000000000000..e8729e632b4f6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/insert-top-right.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/invisible-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/invisible-1.svg new file mode 100644 index 0000000000000..2faf921e82af7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/invisible-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/invisible-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/invisible-2.svg new file mode 100644 index 0000000000000..df9c4e5e4273b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/invisible-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/jump-object.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/jump-object.svg new file mode 100644 index 0000000000000..2859e74ef9f07 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/jump-object.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/key.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/key.svg new file mode 100644 index 0000000000000..738976a249a9e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/key.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/keyhole-lock-circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/keyhole-lock-circle.svg new file mode 100644 index 0000000000000..ef1cd3be8e84c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/keyhole-lock-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/lasso-tool.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/lasso-tool.svg new file mode 100644 index 0000000000000..ff0238cdf760f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/lasso-tool.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/layers-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/layers-1.svg new file mode 100644 index 0000000000000..8475e73e3b473 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/layers-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/layers-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/layers-2.svg new file mode 100644 index 0000000000000..80ad0566b4b07 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/layers-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/layout-window-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/layout-window-1.svg new file mode 100644 index 0000000000000..111f8792656bf --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/layout-window-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/layout-window-11.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/layout-window-11.svg new file mode 100644 index 0000000000000..5ecd8b291bb2f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/layout-window-11.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/layout-window-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/layout-window-2.svg new file mode 100644 index 0000000000000..9539f79acafa6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/layout-window-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/layout-window-8.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/layout-window-8.svg new file mode 100644 index 0000000000000..8ddfa4d969c32 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/layout-window-8.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/lightbulb.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/lightbulb.svg new file mode 100644 index 0000000000000..84f1978687948 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/lightbulb.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/like-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/like-1.svg new file mode 100644 index 0000000000000..ab7b5ac62c5c8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/like-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/link-chain.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/link-chain.svg new file mode 100644 index 0000000000000..1e8c9ebf0322f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/link-chain.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/live-video.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/live-video.svg new file mode 100644 index 0000000000000..74eaac7e5d082 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/live-video.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/lock-rotation.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/lock-rotation.svg new file mode 100644 index 0000000000000..641f61bca4caf --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/lock-rotation.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/login-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/login-1.svg new file mode 100644 index 0000000000000..1bed479e06261 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/login-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/logout-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/logout-1.svg new file mode 100644 index 0000000000000..d5aa2c018b56d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/logout-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/loop-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/loop-1.svg new file mode 100644 index 0000000000000..2ec2b2bd1bab5 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/loop-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/magic-wand-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/magic-wand-2.svg new file mode 100644 index 0000000000000..4ddba5343354e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/magic-wand-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/magnifying-glass-circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/magnifying-glass-circle.svg new file mode 100644 index 0000000000000..7c34859cc81ed --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/magnifying-glass-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/magnifying-glass.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/magnifying-glass.svg new file mode 100644 index 0000000000000..d7884201c5a1e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/magnifying-glass.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/manual-book.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/manual-book.svg new file mode 100644 index 0000000000000..2057e661ed39d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/manual-book.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/megaphone-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/megaphone-2.svg new file mode 100644 index 0000000000000..4f3236db97aa0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/megaphone-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/minimize-window-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/minimize-window-2.svg new file mode 100644 index 0000000000000..0c898ad6b36df --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/minimize-window-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/moon-cloud.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/moon-cloud.svg new file mode 100644 index 0000000000000..75a3f4d90ef3d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/moon-cloud.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/move-left.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/move-left.svg new file mode 100644 index 0000000000000..3d1f5c3b1fa39 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/move-left.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/move-right.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/move-right.svg new file mode 100644 index 0000000000000..333693da80fd3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/move-right.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/multiple-file-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/multiple-file-2.svg new file mode 100644 index 0000000000000..a65117c01fbaa --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/multiple-file-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/music-folder-song.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/music-folder-song.svg new file mode 100644 index 0000000000000..13e2814e47570 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/music-folder-song.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/new-file.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/new-file.svg new file mode 100644 index 0000000000000..0618a6f84ebd8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/new-file.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/new-folder.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/new-folder.svg new file mode 100644 index 0000000000000..897ec6811296b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/new-folder.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/new-sticky-note.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/new-sticky-note.svg new file mode 100644 index 0000000000000..2b9c67b187354 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/new-sticky-note.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/not-equal-sign.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/not-equal-sign.svg new file mode 100644 index 0000000000000..f24755f0d16fe --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/not-equal-sign.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/ok-hand.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/ok-hand.svg new file mode 100644 index 0000000000000..7a101d56e142c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/ok-hand.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/one-finger-drag-horizontal.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/one-finger-drag-horizontal.svg new file mode 100644 index 0000000000000..0f20eee768098 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/one-finger-drag-horizontal.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/one-finger-drag-vertical.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/one-finger-drag-vertical.svg new file mode 100644 index 0000000000000..44d28b3a69549 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/one-finger-drag-vertical.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/one-finger-hold.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/one-finger-hold.svg new file mode 100644 index 0000000000000..1945cd18fe82d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/one-finger-hold.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/one-finger-tap.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/one-finger-tap.svg new file mode 100644 index 0000000000000..af6d35a12f7da --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/one-finger-tap.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/open-book.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/open-book.svg new file mode 100644 index 0000000000000..7d0258d9ae7a2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/open-book.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/open-umbrella.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/open-umbrella.svg new file mode 100644 index 0000000000000..694edaee6e5e7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/open-umbrella.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/padlock-square-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/padlock-square-1.svg new file mode 100644 index 0000000000000..c15e161f51c16 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/padlock-square-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/page-setting.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/page-setting.svg new file mode 100644 index 0000000000000..05a50047b704f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/page-setting.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/paint-bucket.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/paint-bucket.svg new file mode 100644 index 0000000000000..bc0bb97da87a8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/paint-bucket.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/paint-palette.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/paint-palette.svg new file mode 100644 index 0000000000000..05a7cd2c17eba --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/paint-palette.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/paintbrush-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/paintbrush-1.svg new file mode 100644 index 0000000000000..ac1d931e84291 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/paintbrush-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/paintbrush-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/paintbrush-2.svg new file mode 100644 index 0000000000000..ac8cbe43b42d6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/paintbrush-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/paperclip-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/paperclip-1.svg new file mode 100644 index 0000000000000..2c4234130012f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/paperclip-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/paragraph.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/paragraph.svg new file mode 100644 index 0000000000000..077a43d46a2ee --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/paragraph.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-divide.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-divide.svg new file mode 100644 index 0000000000000..11617189b9db5 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-divide.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-exclude.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-exclude.svg new file mode 100644 index 0000000000000..6e791326e0dca --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-exclude.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-intersect.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-intersect.svg new file mode 100644 index 0000000000000..84050733ca228 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-intersect.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-merge.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-merge.svg new file mode 100644 index 0000000000000..81e6775419a4b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-merge.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-minus-front-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-minus-front-1.svg new file mode 100644 index 0000000000000..ee9f455c92938 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-minus-front-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-trim.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-trim.svg new file mode 100644 index 0000000000000..6a8d72d908f98 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-trim.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-union.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-union.svg new file mode 100644 index 0000000000000..470996d2bb36e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-union.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/peace-hand.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/peace-hand.svg new file mode 100644 index 0000000000000..6791449f4258d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/peace-hand.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/pen-3.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/pen-3.svg new file mode 100644 index 0000000000000..651b4a383a5b2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/pen-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/pen-draw.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/pen-draw.svg new file mode 100644 index 0000000000000..1923942612201 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/pen-draw.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/pen-tool.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/pen-tool.svg new file mode 100644 index 0000000000000..db0e8c253fd5c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/pen-tool.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/pencil.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/pencil.svg new file mode 100644 index 0000000000000..95251d86d9cb0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/pencil.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/pentagon.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/pentagon.svg new file mode 100644 index 0000000000000..c3a5663ef7c8f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/pentagon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/pi-symbol-circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/pi-symbol-circle.svg new file mode 100644 index 0000000000000..5656f8155a287 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/pi-symbol-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/pictures-folder-memories.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/pictures-folder-memories.svg new file mode 100644 index 0000000000000..f7db57e6e765f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/pictures-folder-memories.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/podium.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/podium.svg new file mode 100644 index 0000000000000..5914889c53ebe --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/podium.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/polygon.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/polygon.svg new file mode 100644 index 0000000000000..93d003ae129d8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/polygon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/praying-hand.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/praying-hand.svg new file mode 100644 index 0000000000000..64e54f8d71f97 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/praying-hand.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/projector-board.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/projector-board.svg new file mode 100644 index 0000000000000..e79950e656091 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/projector-board.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/pyramid-shape.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/pyramid-shape.svg new file mode 100644 index 0000000000000..a8544363c6391 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/pyramid-shape.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/quotation-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/quotation-2.svg new file mode 100644 index 0000000000000..941b957351645 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/quotation-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/radioactive-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/radioactive-2.svg new file mode 100644 index 0000000000000..4bb4a1ad8f9d5 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/radioactive-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/rain-cloud.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/rain-cloud.svg new file mode 100644 index 0000000000000..0ea0fc0369775 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/rain-cloud.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/recycle-bin-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/recycle-bin-2.svg new file mode 100644 index 0000000000000..71e8570525189 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/recycle-bin-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/ringing-bell-notification.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/ringing-bell-notification.svg new file mode 100644 index 0000000000000..fc194e471c4d6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/ringing-bell-notification.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/rock-and-roll-hand.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/rock-and-roll-hand.svg new file mode 100644 index 0000000000000..12b685624f80d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/rock-and-roll-hand.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/rotate-angle-45.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/rotate-angle-45.svg new file mode 100644 index 0000000000000..697a2d232a593 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/rotate-angle-45.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/round-cap.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/round-cap.svg new file mode 100644 index 0000000000000..ca90db1ada1a4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/round-cap.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/satellite-dish.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/satellite-dish.svg new file mode 100644 index 0000000000000..900f43faa28f2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/satellite-dish.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/scanner.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/scanner.svg new file mode 100644 index 0000000000000..a539ba8bcc048 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/scanner.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/search-visual.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/search-visual.svg new file mode 100644 index 0000000000000..01ae1a15513df --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/search-visual.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/select-circle-area-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/select-circle-area-1.svg new file mode 100644 index 0000000000000..aa4caab372256 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/select-circle-area-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/share-link.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/share-link.svg new file mode 100644 index 0000000000000..b7871bc70d6c9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/share-link.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/shield-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/shield-1.svg new file mode 100644 index 0000000000000..55ec4a54989c1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/shield-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/shield-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/shield-2.svg new file mode 100644 index 0000000000000..95eac8b8d1ac6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/shield-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/shield-check.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/shield-check.svg new file mode 100644 index 0000000000000..a50dedecf33d5 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/shield-check.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/shield-cross.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/shield-cross.svg new file mode 100644 index 0000000000000..a9d68aeb874a1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/shield-cross.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/shrink-horizontal-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/shrink-horizontal-1.svg new file mode 100644 index 0000000000000..5211f9f9b0426 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/shrink-horizontal-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/shuffle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/shuffle.svg new file mode 100644 index 0000000000000..794fdec2c43f1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/shuffle.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/sigma.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/sigma.svg new file mode 100644 index 0000000000000..c552e8c07dc68 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/sigma.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/skull-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/skull-1.svg new file mode 100644 index 0000000000000..44937e48ccf30 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/skull-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/sleep.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/sleep.svg new file mode 100644 index 0000000000000..20d55c012a97a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/sleep.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/snow-flake.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/snow-flake.svg new file mode 100644 index 0000000000000..d1bb1f1d450a8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/snow-flake.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/sort-descending.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/sort-descending.svg new file mode 100644 index 0000000000000..912b92b88cae2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/sort-descending.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/spiral-shape.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/spiral-shape.svg new file mode 100644 index 0000000000000..dfd002ef8b565 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/spiral-shape.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/split-vertical.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/split-vertical.svg new file mode 100644 index 0000000000000..ce7c2bda52a56 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/split-vertical.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/spray-paint.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/spray-paint.svg new file mode 100644 index 0000000000000..8e18a39c40e4c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/spray-paint.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/square-brackets-circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/square-brackets-circle.svg new file mode 100644 index 0000000000000..3b75475809f7b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/square-brackets-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/square-cap.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/square-cap.svg new file mode 100644 index 0000000000000..91c82353fc337 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/square-cap.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/square-clock.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/square-clock.svg new file mode 100644 index 0000000000000..cdcaf984faac4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/square-clock.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/square-root-x-circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/square-root-x-circle.svg new file mode 100644 index 0000000000000..3b2dc980b80ae --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/square-root-x-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/star-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/star-1.svg new file mode 100644 index 0000000000000..58a5c887596e6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/star-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/star-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/star-2.svg new file mode 100644 index 0000000000000..660bbbe347c88 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/star-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/star-badge.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/star-badge.svg new file mode 100644 index 0000000000000..78307418c786a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/star-badge.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/straight-cap.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/straight-cap.svg new file mode 100644 index 0000000000000..a1b64c1675970 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/straight-cap.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/subtract-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/subtract-1.svg new file mode 100644 index 0000000000000..32300958c5efb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/subtract-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/subtract-circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/subtract-circle.svg new file mode 100644 index 0000000000000..6bcdece9d1850 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/subtract-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/subtract-square.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/subtract-square.svg new file mode 100644 index 0000000000000..0384f63da5bdf --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/subtract-square.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/sun-cloud.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/sun-cloud.svg new file mode 100644 index 0000000000000..1606b89874e75 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/sun-cloud.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/synchronize-disable.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/synchronize-disable.svg new file mode 100644 index 0000000000000..fe5ae9bc25f1b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/synchronize-disable.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/synchronize-warning.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/synchronize-warning.svg new file mode 100644 index 0000000000000..3f773ad3ff082 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/synchronize-warning.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/table-lamp-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/table-lamp-1.svg new file mode 100644 index 0000000000000..a5859f39fc14d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/table-lamp-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/tag.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/tag.svg new file mode 100644 index 0000000000000..b79a4ff92f3de --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/tag.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/text-flow-rows.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/text-flow-rows.svg new file mode 100644 index 0000000000000..15977a72f7a81 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/text-flow-rows.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/text-square.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/text-square.svg new file mode 100644 index 0000000000000..b297b154de0f0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/text-square.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/text-style.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/text-style.svg new file mode 100644 index 0000000000000..9c5ae09c4444d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/text-style.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/thermometer.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/thermometer.svg new file mode 100644 index 0000000000000..362672a374a6a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/thermometer.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/trending-content.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/trending-content.svg new file mode 100644 index 0000000000000..42e3618f54c84 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/trending-content.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/trophy.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/trophy.svg new file mode 100644 index 0000000000000..d05a23292b3c0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/trophy.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/two-finger-drag-hotizontal.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/two-finger-drag-hotizontal.svg new file mode 100644 index 0000000000000..fc07dc2fb27df --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/two-finger-drag-hotizontal.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/two-finger-tap.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/two-finger-tap.svg new file mode 100644 index 0000000000000..638319a091a24 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/two-finger-tap.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/underline-text-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/underline-text-1.svg new file mode 100644 index 0000000000000..daa443a4d9abd --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/underline-text-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/upload-box-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/upload-box-1.svg new file mode 100644 index 0000000000000..787314f4768c6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/upload-box-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/upload-circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/upload-circle.svg new file mode 100644 index 0000000000000..886fa014f3598 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/upload-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/upload-computer.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/upload-computer.svg new file mode 100644 index 0000000000000..8784850a7eb2b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/upload-computer.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/upload-file.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/upload-file.svg new file mode 100644 index 0000000000000..c0696194eff29 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/upload-file.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/user-add-plus.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-add-plus.svg new file mode 100644 index 0000000000000..a95ceab231e37 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-add-plus.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/user-check-validate.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-check-validate.svg new file mode 100644 index 0000000000000..d9bd0051e6b3e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-check-validate.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/user-circle-single.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-circle-single.svg new file mode 100644 index 0000000000000..a40b12549e1e1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-circle-single.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/user-identifier-card.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-identifier-card.svg new file mode 100644 index 0000000000000..bab5c06ac5fc4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-identifier-card.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/user-multiple-circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-multiple-circle.svg new file mode 100644 index 0000000000000..6740649fa4a28 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-multiple-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/user-multiple-group.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-multiple-group.svg new file mode 100644 index 0000000000000..07c4e2ffd057d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-multiple-group.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/user-profile-focus.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-profile-focus.svg new file mode 100644 index 0000000000000..72f13810928d7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-profile-focus.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/user-protection-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-protection-2.svg new file mode 100644 index 0000000000000..adcd16e31d561 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-protection-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/user-remove-subtract.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-remove-subtract.svg new file mode 100644 index 0000000000000..8b84f56060c50 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-remove-subtract.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/user-single-neutral-male.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-single-neutral-male.svg new file mode 100644 index 0000000000000..7f81c29bfb786 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-single-neutral-male.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/user-sync-online-in-person.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-sync-online-in-person.svg new file mode 100644 index 0000000000000..71b68e2f37cc7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-sync-online-in-person.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/vertical-slider-square.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/vertical-slider-square.svg new file mode 100644 index 0000000000000..0cf86e26e0537 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/vertical-slider-square.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/video-swap-camera.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/video-swap-camera.svg new file mode 100644 index 0000000000000..f7cdad5918fd6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/video-swap-camera.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/visible.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/visible.svg new file mode 100644 index 0000000000000..343ffa0ca98ab --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/visible.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/voice-scan-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/voice-scan-2.svg new file mode 100644 index 0000000000000..8a083f13adee8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/voice-scan-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/waning-cresent-moon.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/waning-cresent-moon.svg new file mode 100644 index 0000000000000..91dde28366f18 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/waning-cresent-moon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/warning-octagon.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/warning-octagon.svg new file mode 100644 index 0000000000000..52cc420522a72 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/warning-octagon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/warning-triangle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/warning-triangle.svg new file mode 100644 index 0000000000000..5f205c4c95a0e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/warning-triangle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-oval-notification.svg b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-oval-notification.svg new file mode 100644 index 0000000000000..320ab16d198c7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-oval-notification.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-oval-smiley-1.svg b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-oval-smiley-1.svg new file mode 100644 index 0000000000000..b79fed59f7e06 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-oval-smiley-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-oval-smiley-2.svg b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-oval-smiley-2.svg new file mode 100644 index 0000000000000..6f20c07292479 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-oval-smiley-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-oval.svg b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-oval.svg new file mode 100644 index 0000000000000..74bfb2b1f1e99 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-oval.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-square-block.svg b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-square-block.svg new file mode 100644 index 0000000000000..81e9132b68639 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-square-block.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-square-question.svg b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-square-question.svg new file mode 100644 index 0000000000000..b09ee92a0958e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-square-question.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-square-warning.svg b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-square-warning.svg new file mode 100644 index 0000000000000..e784e77e10c5a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-square-warning.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-square-write.svg b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-square-write.svg new file mode 100644 index 0000000000000..9dc5174dcab60 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-square-write.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-text-square.svg b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-text-square.svg new file mode 100644 index 0000000000000..60f7c3032b721 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-text-square.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-typing-oval.svg b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-typing-oval.svg new file mode 100644 index 0000000000000..05f9dc722dc5e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-typing-oval.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/chat-two-bubbles-oval.svg b/frontend/appflowy_web_app/public/af_icons/mail/chat-two-bubbles-oval.svg new file mode 100644 index 0000000000000..068e2de39e51e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/chat-two-bubbles-oval.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/discussion-converstion-reply.svg b/frontend/appflowy_web_app/public/af_icons/mail/discussion-converstion-reply.svg new file mode 100644 index 0000000000000..4d63be9a85dd8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/discussion-converstion-reply.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/happy-face.svg b/frontend/appflowy_web_app/public/af_icons/mail/happy-face.svg new file mode 100644 index 0000000000000..1f5f581da68d0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/happy-face.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/inbox-block.svg b/frontend/appflowy_web_app/public/af_icons/mail/inbox-block.svg new file mode 100644 index 0000000000000..251a31897f112 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/inbox-block.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/inbox-favorite-heart.svg b/frontend/appflowy_web_app/public/af_icons/mail/inbox-favorite-heart.svg new file mode 100644 index 0000000000000..68d266fe493f3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/inbox-favorite-heart.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/inbox-favorite.svg b/frontend/appflowy_web_app/public/af_icons/mail/inbox-favorite.svg new file mode 100644 index 0000000000000..9a27890605276 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/inbox-favorite.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/inbox-lock.svg b/frontend/appflowy_web_app/public/af_icons/mail/inbox-lock.svg new file mode 100644 index 0000000000000..721163917df7a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/inbox-lock.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/inbox-tray-1.svg b/frontend/appflowy_web_app/public/af_icons/mail/inbox-tray-1.svg new file mode 100644 index 0000000000000..25cf8d1ace3ab --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/inbox-tray-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/inbox-tray-2.svg b/frontend/appflowy_web_app/public/af_icons/mail/inbox-tray-2.svg new file mode 100644 index 0000000000000..2d2b6afa4c267 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/inbox-tray-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/mail-incoming.svg b/frontend/appflowy_web_app/public/af_icons/mail/mail-incoming.svg new file mode 100644 index 0000000000000..e3cebdbffa5fe --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/mail-incoming.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/mail-search.svg b/frontend/appflowy_web_app/public/af_icons/mail/mail-search.svg new file mode 100644 index 0000000000000..280d7cb36360b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/mail-search.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/mail-send-email-message.svg b/frontend/appflowy_web_app/public/af_icons/mail/mail-send-email-message.svg new file mode 100644 index 0000000000000..5a017646076ff --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/mail-send-email-message.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/mail-send-envelope.svg b/frontend/appflowy_web_app/public/af_icons/mail/mail-send-envelope.svg new file mode 100644 index 0000000000000..ee32a0d2d5efc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/mail-send-envelope.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/mail-send-reply-all.svg b/frontend/appflowy_web_app/public/af_icons/mail/mail-send-reply-all.svg new file mode 100644 index 0000000000000..5a4bbd13ae54a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/mail-send-reply-all.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/sad-face.svg b/frontend/appflowy_web_app/public/af_icons/mail/sad-face.svg new file mode 100644 index 0000000000000..cb07b814ff738 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/sad-face.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/send-email.svg b/frontend/appflowy_web_app/public/af_icons/mail/send-email.svg new file mode 100644 index 0000000000000..0431ab66ebebf --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/send-email.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/sign-at.svg b/frontend/appflowy_web_app/public/af_icons/mail/sign-at.svg new file mode 100644 index 0000000000000..764b2bf31269c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/sign-at.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/sign-hashtag.svg b/frontend/appflowy_web_app/public/af_icons/mail/sign-hashtag.svg new file mode 100644 index 0000000000000..545e6610078a9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/sign-hashtag.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/smiley-angry.svg b/frontend/appflowy_web_app/public/af_icons/mail/smiley-angry.svg new file mode 100644 index 0000000000000..3e9ad9ee33bfa --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/smiley-angry.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/smiley-cool.svg b/frontend/appflowy_web_app/public/af_icons/mail/smiley-cool.svg new file mode 100644 index 0000000000000..71d44d8279201 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/smiley-cool.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/smiley-crying-1.svg b/frontend/appflowy_web_app/public/af_icons/mail/smiley-crying-1.svg new file mode 100644 index 0000000000000..c5cfe2df8d150 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/smiley-crying-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/smiley-cute.svg b/frontend/appflowy_web_app/public/af_icons/mail/smiley-cute.svg new file mode 100644 index 0000000000000..918f29f705bbd --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/smiley-cute.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/smiley-drool.svg b/frontend/appflowy_web_app/public/af_icons/mail/smiley-drool.svg new file mode 100644 index 0000000000000..acc73cee7cba6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/smiley-drool.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/smiley-emoji-kiss-nervous.svg b/frontend/appflowy_web_app/public/af_icons/mail/smiley-emoji-kiss-nervous.svg new file mode 100644 index 0000000000000..2e8ce83c873da --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/smiley-emoji-kiss-nervous.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/smiley-emoji-terrified.svg b/frontend/appflowy_web_app/public/af_icons/mail/smiley-emoji-terrified.svg new file mode 100644 index 0000000000000..2ee952b07cde3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/smiley-emoji-terrified.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/smiley-grumpy.svg b/frontend/appflowy_web_app/public/af_icons/mail/smiley-grumpy.svg new file mode 100644 index 0000000000000..e0f6d4c9396ac --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/smiley-grumpy.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/smiley-happy.svg b/frontend/appflowy_web_app/public/af_icons/mail/smiley-happy.svg new file mode 100644 index 0000000000000..1849e6fc5fc1f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/smiley-happy.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/smiley-in-love.svg b/frontend/appflowy_web_app/public/af_icons/mail/smiley-in-love.svg new file mode 100644 index 0000000000000..cb3f446338090 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/smiley-in-love.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/smiley-kiss.svg b/frontend/appflowy_web_app/public/af_icons/mail/smiley-kiss.svg new file mode 100644 index 0000000000000..f86db7c10cb99 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/smiley-kiss.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/smiley-laughing-3.svg b/frontend/appflowy_web_app/public/af_icons/mail/smiley-laughing-3.svg new file mode 100644 index 0000000000000..df01420baacf5 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/smiley-laughing-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/airplane.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/airplane.svg new file mode 100644 index 0000000000000..85b9018e5bd4c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/airplane.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/airport-plane-transit.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/airport-plane-transit.svg new file mode 100644 index 0000000000000..723a23d913926 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/airport-plane-transit.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/airport-plane.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/airport-plane.svg new file mode 100644 index 0000000000000..6731fd899264c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/airport-plane.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/airport-security.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/airport-security.svg new file mode 100644 index 0000000000000..30d2c370a6219 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/airport-security.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/anchor.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/anchor.svg new file mode 100644 index 0000000000000..8b05191e02f40 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/anchor.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/baggage.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/baggage.svg new file mode 100644 index 0000000000000..674be5b254ac1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/baggage.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/beach.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/beach.svg new file mode 100644 index 0000000000000..8e38eaea77334 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/beach.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/bicycle-bike.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/bicycle-bike.svg new file mode 100644 index 0000000000000..0f01d9cdd150e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/bicycle-bike.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/braille-blind.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/braille-blind.svg new file mode 100644 index 0000000000000..8c8f5310038bd --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/braille-blind.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/bus.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/bus.svg new file mode 100644 index 0000000000000..2bf0c8ab84f29 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/bus.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/camping-tent.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/camping-tent.svg new file mode 100644 index 0000000000000..46d9e7fcc7c85 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/camping-tent.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/cane.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/cane.svg new file mode 100644 index 0000000000000..6778b91182979 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/cane.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/capitol.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/capitol.svg new file mode 100644 index 0000000000000..c9f7106687e3c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/capitol.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/car-battery-charging.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/car-battery-charging.svg new file mode 100644 index 0000000000000..610323ea42609 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/car-battery-charging.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/car-taxi-1.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/car-taxi-1.svg new file mode 100644 index 0000000000000..156ee2113aff0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/car-taxi-1.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/city-hall.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/city-hall.svg new file mode 100644 index 0000000000000..379f9a974a295 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/city-hall.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/compass-navigator.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/compass-navigator.svg new file mode 100644 index 0000000000000..63ead58975390 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/compass-navigator.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/crutch.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/crutch.svg new file mode 100644 index 0000000000000..6f46d47b8793b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/crutch.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/dangerous-zone-sign.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/dangerous-zone-sign.svg new file mode 100644 index 0000000000000..675fbfb386097 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/dangerous-zone-sign.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/earth-1.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/earth-1.svg new file mode 100644 index 0000000000000..b38deea54bd94 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/earth-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/earth-airplane.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/earth-airplane.svg new file mode 100644 index 0000000000000..44103e0c82409 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/earth-airplane.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/emergency-exit.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/emergency-exit.svg new file mode 100644 index 0000000000000..047192a9f196d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/emergency-exit.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/fire-alarm-2.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/fire-alarm-2.svg new file mode 100644 index 0000000000000..17cd4f4304498 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/fire-alarm-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/fire-extinguisher-sign.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/fire-extinguisher-sign.svg new file mode 100644 index 0000000000000..54da68657a9f7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/fire-extinguisher-sign.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/gas-station-fuel-petroleum.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/gas-station-fuel-petroleum.svg new file mode 100644 index 0000000000000..3fb3f024a71fa --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/gas-station-fuel-petroleum.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/hearing-deaf-1.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/hearing-deaf-1.svg new file mode 100644 index 0000000000000..5abe2f60a0454 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/hearing-deaf-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/hearing-deaf-2.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/hearing-deaf-2.svg new file mode 100644 index 0000000000000..7623a86ae79e6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/hearing-deaf-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/high-speed-train-front.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/high-speed-train-front.svg new file mode 100644 index 0000000000000..0334e072ee1d0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/high-speed-train-front.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/hot-spring.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/hot-spring.svg new file mode 100644 index 0000000000000..3f72df09e3004 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/hot-spring.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-air-conditioner.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-air-conditioner.svg new file mode 100644 index 0000000000000..b8006f8ab8e02 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-air-conditioner.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-bed-2.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-bed-2.svg new file mode 100644 index 0000000000000..2af7f57ce18f6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-bed-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-laundry.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-laundry.svg new file mode 100644 index 0000000000000..5a7291b7da412 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-laundry.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-one-star.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-one-star.svg new file mode 100644 index 0000000000000..c7d69e2d42bb7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-one-star.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-shower-head.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-shower-head.svg new file mode 100644 index 0000000000000..5e3cf1de4055d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-shower-head.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-two-star.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-two-star.svg new file mode 100644 index 0000000000000..c1ae54056ccd7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-two-star.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/information-desk-customer.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/information-desk-customer.svg new file mode 100644 index 0000000000000..7add1d0610dfd --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/information-desk-customer.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/information-desk.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/information-desk.svg new file mode 100644 index 0000000000000..d13cba2fc51de --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/information-desk.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/iron.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/iron.svg new file mode 100644 index 0000000000000..641099f09aa1d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/iron.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/ladder.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/ladder.svg new file mode 100644 index 0000000000000..996312944529b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/ladder.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/lift-disability.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/lift-disability.svg new file mode 100644 index 0000000000000..588edf9c83a6f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/lift-disability.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/lift.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/lift.svg new file mode 100644 index 0000000000000..aafccb263e4bb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/lift.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/location-compass-1.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/location-compass-1.svg new file mode 100644 index 0000000000000..00647fbe0f5ed --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/location-compass-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/location-pin-3.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/location-pin-3.svg new file mode 100644 index 0000000000000..bc88a620b8a25 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/location-pin-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/location-pin-disabled.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/location-pin-disabled.svg new file mode 100644 index 0000000000000..2ff5e3cbd30c0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/location-pin-disabled.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/location-target-1.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/location-target-1.svg new file mode 100644 index 0000000000000..9c015e2f4779b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/location-target-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/lost-and-found.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/lost-and-found.svg new file mode 100644 index 0000000000000..047a764906670 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/lost-and-found.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/man-symbol.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/man-symbol.svg new file mode 100644 index 0000000000000..0f2a3dcef70b5 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/man-symbol.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/map-fold.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/map-fold.svg new file mode 100644 index 0000000000000..5f059cb98b653 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/map-fold.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/navigation-arrow-off.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/navigation-arrow-off.svg new file mode 100644 index 0000000000000..d40bbffe6fefe --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/navigation-arrow-off.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/navigation-arrow-on.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/navigation-arrow-on.svg new file mode 100644 index 0000000000000..ca83a1f7b640d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/navigation-arrow-on.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/parking-sign.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/parking-sign.svg new file mode 100644 index 0000000000000..3708cca823543 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/parking-sign.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/parliament.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/parliament.svg new file mode 100644 index 0000000000000..aae1a5f440afb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/parliament.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/passport.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/passport.svg new file mode 100644 index 0000000000000..848f049fbc39e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/passport.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/pet-paw.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/pet-paw.svg new file mode 100644 index 0000000000000..27cf85c3186d6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/pet-paw.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/pets-allowed.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/pets-allowed.svg new file mode 100644 index 0000000000000..48bd43e11a3ef --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/pets-allowed.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/pool-ladder.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/pool-ladder.svg new file mode 100644 index 0000000000000..dabe71440e8fa --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/pool-ladder.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/rock-slide.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/rock-slide.svg new file mode 100644 index 0000000000000..0a6a5a711d624 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/rock-slide.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/sail-ship.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/sail-ship.svg new file mode 100644 index 0000000000000..982767a3c5ba6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/sail-ship.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/school-bus-side.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/school-bus-side.svg new file mode 100644 index 0000000000000..7b5856e7ff2b8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/school-bus-side.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/smoke-detector.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/smoke-detector.svg new file mode 100644 index 0000000000000..89862439b9a8d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/smoke-detector.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/smoking-area.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/smoking-area.svg new file mode 100644 index 0000000000000..4e39d94df2a86 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/smoking-area.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/snorkle.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/snorkle.svg new file mode 100644 index 0000000000000..626e2cf4844c0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/snorkle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/steering-wheel.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/steering-wheel.svg new file mode 100644 index 0000000000000..2b3adeb5bb400 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/steering-wheel.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/street-road.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/street-road.svg new file mode 100644 index 0000000000000..5ff4e3c03cce8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/street-road.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/street-sign.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/street-sign.svg new file mode 100644 index 0000000000000..d5f80c30a8219 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/street-sign.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/take-off.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/take-off.svg new file mode 100644 index 0000000000000..7d5aafc2c3a45 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/take-off.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/toilet-man.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/toilet-man.svg new file mode 100644 index 0000000000000..67df34ada541d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/toilet-man.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/toilet-sign-man-woman-2.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/toilet-sign-man-woman-2.svg new file mode 100644 index 0000000000000..44f3680273946 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/toilet-sign-man-woman-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/toilet-women.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/toilet-women.svg new file mode 100644 index 0000000000000..916f4591c7adb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/toilet-women.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/traffic-cone.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/traffic-cone.svg new file mode 100644 index 0000000000000..1cd16d0c2f577 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/traffic-cone.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/triangle-flag.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/triangle-flag.svg new file mode 100644 index 0000000000000..a6f86c16b3002 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/triangle-flag.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/wheelchair-1.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/wheelchair-1.svg new file mode 100644 index 0000000000000..a0d539594cca7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/wheelchair-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/woman-symbol.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/woman-symbol.svg new file mode 100644 index 0000000000000..a9c4377ec1774 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/woman-symbol.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/annoncement-megaphone.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/annoncement-megaphone.svg new file mode 100644 index 0000000000000..472f766f0e491 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/annoncement-megaphone.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/backpack.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/backpack.svg new file mode 100644 index 0000000000000..d9800f6d76069 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/backpack.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-dollar.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-dollar.svg new file mode 100644 index 0000000000000..176e8d38de66f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-dollar.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-pound.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-pound.svg new file mode 100644 index 0000000000000..a282df85fbc85 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-pound.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-rupee.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-rupee.svg new file mode 100644 index 0000000000000..eb5c02be60139 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-rupee.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-suitcase-1.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-suitcase-1.svg new file mode 100644 index 0000000000000..82a1e7ff3197c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-suitcase-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-suitcase-2.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-suitcase-2.svg new file mode 100644 index 0000000000000..706aab5df3fc1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-suitcase-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-yen.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-yen.svg new file mode 100644 index 0000000000000..2a54180e78aac --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-yen.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/bag.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/bag.svg new file mode 100644 index 0000000000000..d1abe114437a2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/bag.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/ball.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/ball.svg new file mode 100644 index 0000000000000..6f7e278919564 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/ball.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/bank.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/bank.svg new file mode 100644 index 0000000000000..fd6cf58939fee --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/bank.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/beanie.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/beanie.svg new file mode 100644 index 0000000000000..374557ac4fcdd --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/beanie.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/bill-1.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/bill-1.svg new file mode 100644 index 0000000000000..6df0d554df06b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/bill-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/bill-2.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/bill-2.svg new file mode 100644 index 0000000000000..61f55ac5d5084 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/bill-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/bill-4.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/bill-4.svg new file mode 100644 index 0000000000000..2ffbf22977976 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/bill-4.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/bill-cashless.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/bill-cashless.svg new file mode 100644 index 0000000000000..aa77bf8d67704 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/bill-cashless.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/binance-circle.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/binance-circle.svg new file mode 100644 index 0000000000000..6b489a693e815 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/binance-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/bitcoin.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/bitcoin.svg new file mode 100644 index 0000000000000..211aee40c6887 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/bitcoin.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/bow-tie.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/bow-tie.svg new file mode 100644 index 0000000000000..2f2bbdaa5570a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/bow-tie.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/briefcase-dollar.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/briefcase-dollar.svg new file mode 100644 index 0000000000000..14c68c87c4c02 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/briefcase-dollar.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/building-2.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/building-2.svg new file mode 100644 index 0000000000000..56c3585f37ac3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/building-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/business-card.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/business-card.svg new file mode 100644 index 0000000000000..f980629bceb53 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/business-card.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/business-handshake.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/business-handshake.svg new file mode 100644 index 0000000000000..62ce5e55c45a8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/business-handshake.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/business-idea-money.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/business-idea-money.svg new file mode 100644 index 0000000000000..d4eb07175f987 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/business-idea-money.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/business-profession-home-office.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/business-profession-home-office.svg new file mode 100644 index 0000000000000..f634c96f5f38a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/business-profession-home-office.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/business-progress-bar-2.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/business-progress-bar-2.svg new file mode 100644 index 0000000000000..b73b5e9015fec --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/business-progress-bar-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/business-user-curriculum.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/business-user-curriculum.svg new file mode 100644 index 0000000000000..19714b52b13a3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/business-user-curriculum.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/calculator-1.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/calculator-1.svg new file mode 100644 index 0000000000000..d14d7b0050920 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/calculator-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/calculator-2.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/calculator-2.svg new file mode 100644 index 0000000000000..bf48853eaa2d6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/calculator-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/cane.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/cane.svg new file mode 100644 index 0000000000000..4c7c27073cad0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/cane.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/chair.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/chair.svg new file mode 100644 index 0000000000000..a6d33f000f7b3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/chair.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/closet.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/closet.svg new file mode 100644 index 0000000000000..16991a60e9d39 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/closet.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/coin-share.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/coin-share.svg new file mode 100644 index 0000000000000..9aa28f013ff13 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/coin-share.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/coins-stack.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/coins-stack.svg new file mode 100644 index 0000000000000..fceb62950c741 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/coins-stack.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/credit-card-1.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/credit-card-1.svg new file mode 100644 index 0000000000000..e730a703d551f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/credit-card-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/credit-card-2.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/credit-card-2.svg new file mode 100644 index 0000000000000..f6ba16a21315a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/credit-card-2.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/diamond-2.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/diamond-2.svg new file mode 100644 index 0000000000000..e226caaf3813f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/diamond-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/discount-percent-badge.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/discount-percent-badge.svg new file mode 100644 index 0000000000000..5cda678b879b6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/discount-percent-badge.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/discount-percent-circle.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/discount-percent-circle.svg new file mode 100644 index 0000000000000..6fa8b79900033 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/discount-percent-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/discount-percent-coupon.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/discount-percent-coupon.svg new file mode 100644 index 0000000000000..9ff5dac1f453d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/discount-percent-coupon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/discount-percent-cutout.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/discount-percent-cutout.svg new file mode 100644 index 0000000000000..82a7587a6efd2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/discount-percent-cutout.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/discount-percent-fire.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/discount-percent-fire.svg new file mode 100644 index 0000000000000..00a78332c45fb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/discount-percent-fire.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/dollar-coin-1.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/dollar-coin-1.svg new file mode 100644 index 0000000000000..0db0836acacbb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/dollar-coin-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/dollar-coin.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/dollar-coin.svg new file mode 100644 index 0000000000000..b285d8d4b2199 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/dollar-coin.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/dressing-table.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/dressing-table.svg new file mode 100644 index 0000000000000..90e453e2b752a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/dressing-table.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/ethereum-circle.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/ethereum-circle.svg new file mode 100644 index 0000000000000..06db44f70d804 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/ethereum-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/ethereum.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/ethereum.svg new file mode 100644 index 0000000000000..40f205e9d74e1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/ethereum.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/euro.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/euro.svg new file mode 100644 index 0000000000000..73121ce282794 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/euro.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/gift-2.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/gift-2.svg new file mode 100644 index 0000000000000..e64b42428ee88 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/gift-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/gift.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/gift.svg new file mode 100644 index 0000000000000..ba788391024f4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/gift.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/gold.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/gold.svg new file mode 100644 index 0000000000000..33ef845ad24a8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/gold.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/graph-arrow-decrease.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/graph-arrow-decrease.svg new file mode 100644 index 0000000000000..1177b1acfb644 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/graph-arrow-decrease.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/graph-arrow-increase.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/graph-arrow-increase.svg new file mode 100644 index 0000000000000..82415d32c65e9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/graph-arrow-increase.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/graph-bar-decrease.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/graph-bar-decrease.svg new file mode 100644 index 0000000000000..79f523f8d9b9a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/graph-bar-decrease.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/graph-bar-increase.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/graph-bar-increase.svg new file mode 100644 index 0000000000000..801daa256be52 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/graph-bar-increase.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/graph-dot.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/graph-dot.svg new file mode 100644 index 0000000000000..7ca8308d52d9e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/graph-dot.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/graph.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/graph.svg new file mode 100644 index 0000000000000..4c692f6ca0015 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/graph.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/investment-selection.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/investment-selection.svg new file mode 100644 index 0000000000000..478b852ad17bc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/investment-selection.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/justice-hammer.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/justice-hammer.svg new file mode 100644 index 0000000000000..d9bf64217e593 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/justice-hammer.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/justice-scale-1.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/justice-scale-1.svg new file mode 100644 index 0000000000000..6e531dd1b8145 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/justice-scale-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/justice-scale-2.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/justice-scale-2.svg new file mode 100644 index 0000000000000..e90aa2594b6d2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/justice-scale-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/lipstick.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/lipstick.svg new file mode 100644 index 0000000000000..e2ffa968c624f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/lipstick.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/make-up-brush.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/make-up-brush.svg new file mode 100644 index 0000000000000..d5e28774ae060 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/make-up-brush.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/moustache.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/moustache.svg new file mode 100644 index 0000000000000..049e343f185b1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/moustache.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/mouth-lip.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/mouth-lip.svg new file mode 100644 index 0000000000000..22cb09ed17bc0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/mouth-lip.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/necklace.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/necklace.svg new file mode 100644 index 0000000000000..7e615d5fc1d9c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/necklace.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/necktie.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/necktie.svg new file mode 100644 index 0000000000000..dd502d68eccc1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/necktie.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/payment-10.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/payment-10.svg new file mode 100644 index 0000000000000..42935055e54ec --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/payment-10.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/payment-cash-out-3.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/payment-cash-out-3.svg new file mode 100644 index 0000000000000..8f451cb9f827e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/payment-cash-out-3.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/pie-chart.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/pie-chart.svg new file mode 100644 index 0000000000000..2966a8ef8fcdd --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/pie-chart.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/piggy-bank.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/piggy-bank.svg new file mode 100644 index 0000000000000..ef796838b2665 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/piggy-bank.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/polka-dot-circle.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/polka-dot-circle.svg new file mode 100644 index 0000000000000..f50f1edc7673b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/polka-dot-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/production-belt.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/production-belt.svg new file mode 100644 index 0000000000000..b56f95f19f838 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/production-belt.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/qr-code.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/qr-code.svg new file mode 100644 index 0000000000000..c93d63f0601f0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/qr-code.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/receipt-add.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/receipt-add.svg new file mode 100644 index 0000000000000..50cc1f57da36f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/receipt-add.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/receipt-check.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/receipt-check.svg new file mode 100644 index 0000000000000..99762d138f719 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/receipt-check.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/receipt-subtract.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/receipt-subtract.svg new file mode 100644 index 0000000000000..9a18ed6d061a1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/receipt-subtract.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/receipt.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/receipt.svg new file mode 100644 index 0000000000000..da4dab634027a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/receipt.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/safe-vault.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/safe-vault.svg new file mode 100644 index 0000000000000..9c3d9d7230b5c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/safe-vault.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/scanner-3.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/scanner-3.svg new file mode 100644 index 0000000000000..f7db012503432 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/scanner-3.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/scanner-bar-code.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/scanner-bar-code.svg new file mode 100644 index 0000000000000..b60571a669758 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/scanner-bar-code.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/shelf.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/shelf.svg new file mode 100644 index 0000000000000..3ea11815bfb91 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/shelf.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-bag-hand-bag-2.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-bag-hand-bag-2.svg new file mode 100644 index 0000000000000..ae8ef2d315c62 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-bag-hand-bag-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-basket-1.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-basket-1.svg new file mode 100644 index 0000000000000..b33b5304fb3ca --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-basket-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-basket-2.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-basket-2.svg new file mode 100644 index 0000000000000..f2610aab4c24e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-basket-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-1.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-1.svg new file mode 100644 index 0000000000000..74529f1ae54f9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-2.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-2.svg new file mode 100644 index 0000000000000..eecaeadf06e30 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-3.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-3.svg new file mode 100644 index 0000000000000..681b1653e9ac3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-add.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-add.svg new file mode 100644 index 0000000000000..50d05a70b71a8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-add.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-check.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-check.svg new file mode 100644 index 0000000000000..6d7b9fb635dbb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-check.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-subtract.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-subtract.svg new file mode 100644 index 0000000000000..41c96d3ec9edb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-subtract.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/signage-3.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/signage-3.svg new file mode 100644 index 0000000000000..92c321b4b57be --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/signage-3.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/signage-4.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/signage-4.svg new file mode 100644 index 0000000000000..42b4f8c6a9f89 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/signage-4.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/startup.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/startup.svg new file mode 100644 index 0000000000000..77ed266e676d2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/startup.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/stock.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/stock.svg new file mode 100644 index 0000000000000..d350449bcf0d6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/stock.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/store-1.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/store-1.svg new file mode 100644 index 0000000000000..53751414d59b4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/store-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/store-2.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/store-2.svg new file mode 100644 index 0000000000000..7e2312aa57c4b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/store-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/store-computer.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/store-computer.svg new file mode 100644 index 0000000000000..4ea808bf31438 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/store-computer.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/subscription-cashflow.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/subscription-cashflow.svg new file mode 100644 index 0000000000000..c7f45631aca95 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/subscription-cashflow.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/tag.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/tag.svg new file mode 100644 index 0000000000000..3aae6ba5c3ca0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/tag.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/tall-hat.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/tall-hat.svg new file mode 100644 index 0000000000000..1f5650785c4b6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/tall-hat.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/target-3.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/target-3.svg new file mode 100644 index 0000000000000..35591b9238df6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/target-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/target.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/target.svg new file mode 100644 index 0000000000000..632d7c4c3c928 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/target.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/wallet-purse.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/wallet-purse.svg new file mode 100644 index 0000000000000..6df7533528f00 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/wallet-purse.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/wallet.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/wallet.svg new file mode 100644 index 0000000000000..39042c9f04bf2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/wallet.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/xrp-circle.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/xrp-circle.svg new file mode 100644 index 0000000000000..4250e7055521c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/xrp-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/yuan-circle.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/yuan-circle.svg new file mode 100644 index 0000000000000..138056c639332 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/yuan-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/yuan.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/yuan.svg new file mode 100644 index 0000000000000..65976287feeca --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/yuan.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/affordable-and-clean-energy.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/affordable-and-clean-energy.svg new file mode 100644 index 0000000000000..70421c5d33893 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/affordable-and-clean-energy.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/alien.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/alien.svg new file mode 100644 index 0000000000000..5cfaf813bf575 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/alien.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/bone.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/bone.svg new file mode 100644 index 0000000000000..669f8e97557c9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/bone.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/cat-1.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/cat-1.svg new file mode 100644 index 0000000000000..d23a378b47f57 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/cat-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/circle-flask.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/circle-flask.svg new file mode 100644 index 0000000000000..fc75c75d0d237 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/circle-flask.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/clean-water-and-sanitation.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/clean-water-and-sanitation.svg new file mode 100644 index 0000000000000..c7b4738cee6a4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/clean-water-and-sanitation.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/comet.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/comet.svg new file mode 100644 index 0000000000000..3d0012a1fc1f7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/comet.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/decent-work-and-economic-growth.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/decent-work-and-economic-growth.svg new file mode 100644 index 0000000000000..3aa52629d704d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/decent-work-and-economic-growth.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/dna.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/dna.svg new file mode 100644 index 0000000000000..dda09e350c632 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/dna.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/erlenmeyer-flask.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/erlenmeyer-flask.svg new file mode 100644 index 0000000000000..14e078c4769df --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/erlenmeyer-flask.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/flower.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/flower.svg new file mode 100644 index 0000000000000..675f917956064 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/flower.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/galaxy-1.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/galaxy-1.svg new file mode 100644 index 0000000000000..59d4638f8c359 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/galaxy-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/galaxy-2.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/galaxy-2.svg new file mode 100644 index 0000000000000..d8948d5f353d5 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/galaxy-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/gender-equality.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/gender-equality.svg new file mode 100644 index 0000000000000..3f0a9cdecc76b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/gender-equality.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/good-health-and-well-being.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/good-health-and-well-being.svg new file mode 100644 index 0000000000000..c92374cff59e8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/good-health-and-well-being.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/industry-innovation-and-infrastructure.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/industry-innovation-and-infrastructure.svg new file mode 100644 index 0000000000000..7a58eecbcaf29 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/industry-innovation-and-infrastructure.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/leaf.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/leaf.svg new file mode 100644 index 0000000000000..2a53ca6fd5c5f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/leaf.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/log.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/log.svg new file mode 100644 index 0000000000000..1a3deaa880ad2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/log.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/no-poverty.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/no-poverty.svg new file mode 100644 index 0000000000000..f12c0b41114dc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/no-poverty.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/octopus.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/octopus.svg new file mode 100644 index 0000000000000..38855cbe547ab --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/octopus.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/planet.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/planet.svg new file mode 100644 index 0000000000000..9ee346f58413b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/planet.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/potted-flower-tulip.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/potted-flower-tulip.svg new file mode 100644 index 0000000000000..2be0dc0cb5740 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/potted-flower-tulip.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/quality-education.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/quality-education.svg new file mode 100644 index 0000000000000..c6e0335e9b24e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/quality-education.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/rainbow.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/rainbow.svg new file mode 100644 index 0000000000000..8c99192cee098 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/rainbow.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/recycle-1.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/recycle-1.svg new file mode 100644 index 0000000000000..30ebcf8fb2b33 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/recycle-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/reduced-inequalities.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/reduced-inequalities.svg new file mode 100644 index 0000000000000..cc3de7a365748 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/reduced-inequalities.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/rose.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/rose.svg new file mode 100644 index 0000000000000..3459894b5b4a8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/rose.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/shell.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/shell.svg new file mode 100644 index 0000000000000..bde5159a0a133 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/shell.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/shovel-rake.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/shovel-rake.svg new file mode 100644 index 0000000000000..832d2a4f1d415 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/shovel-rake.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/sprout.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/sprout.svg new file mode 100644 index 0000000000000..3b8ea11ba38c0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/sprout.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/telescope.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/telescope.svg new file mode 100644 index 0000000000000..9a29c5483dc26 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/telescope.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/test-tube.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/test-tube.svg new file mode 100644 index 0000000000000..01d12af810b09 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/test-tube.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/tidal-wave.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/tidal-wave.svg new file mode 100644 index 0000000000000..56c80ec200a05 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/tidal-wave.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/tree-2.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/tree-2.svg new file mode 100644 index 0000000000000..da32de6697bdf --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/tree-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/tree-3.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/tree-3.svg new file mode 100644 index 0000000000000..db8bfe987bae6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/tree-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/volcano.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/volcano.svg new file mode 100644 index 0000000000000..989f5e68b92bc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/volcano.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/windmill.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/windmill.svg new file mode 100644 index 0000000000000..edfb75988366d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/windmill.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/zero-hunger.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/zero-hunger.svg new file mode 100644 index 0000000000000..7cd7b52a34c60 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/zero-hunger.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/airplane-disabled.svg b/frontend/appflowy_web_app/public/af_icons/phone/airplane-disabled.svg new file mode 100644 index 0000000000000..dd6bf8d91aac6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/airplane-disabled.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/airplane-enabled.svg b/frontend/appflowy_web_app/public/af_icons/phone/airplane-enabled.svg new file mode 100644 index 0000000000000..af74878ae43e6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/airplane-enabled.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/back-camera-1.svg b/frontend/appflowy_web_app/public/af_icons/phone/back-camera-1.svg new file mode 100644 index 0000000000000..9c38dd55e23ad --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/back-camera-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/call-hang-up.svg b/frontend/appflowy_web_app/public/af_icons/phone/call-hang-up.svg new file mode 100644 index 0000000000000..41d0886a0e573 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/call-hang-up.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/cellular-network-4g.svg b/frontend/appflowy_web_app/public/af_icons/phone/cellular-network-4g.svg new file mode 100644 index 0000000000000..559284654ef6a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/cellular-network-4g.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/cellular-network-5g.svg b/frontend/appflowy_web_app/public/af_icons/phone/cellular-network-5g.svg new file mode 100644 index 0000000000000..a4b9a8626f0f0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/cellular-network-5g.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/cellular-network-lte.svg b/frontend/appflowy_web_app/public/af_icons/phone/cellular-network-lte.svg new file mode 100644 index 0000000000000..373d8e86405de --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/cellular-network-lte.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/contact-phonebook-2.svg b/frontend/appflowy_web_app/public/af_icons/phone/contact-phonebook-2.svg new file mode 100644 index 0000000000000..8968279a298c9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/contact-phonebook-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/hang-up-1.svg b/frontend/appflowy_web_app/public/af_icons/phone/hang-up-1.svg new file mode 100644 index 0000000000000..fb4c7970281fd --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/hang-up-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/hang-up-2.svg b/frontend/appflowy_web_app/public/af_icons/phone/hang-up-2.svg new file mode 100644 index 0000000000000..dbd92cb2a3f7c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/hang-up-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/incoming-call.svg b/frontend/appflowy_web_app/public/af_icons/phone/incoming-call.svg new file mode 100644 index 0000000000000..dfb4e8a02eefb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/incoming-call.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/missed-call.svg b/frontend/appflowy_web_app/public/af_icons/phone/missed-call.svg new file mode 100644 index 0000000000000..9efc8a2eb9924 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/missed-call.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/notification-alarm-2.svg b/frontend/appflowy_web_app/public/af_icons/phone/notification-alarm-2.svg new file mode 100644 index 0000000000000..7eec376825fd6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/notification-alarm-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/notification-application-1.svg b/frontend/appflowy_web_app/public/af_icons/phone/notification-application-1.svg new file mode 100644 index 0000000000000..4908995f02161 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/notification-application-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/notification-application-2.svg b/frontend/appflowy_web_app/public/af_icons/phone/notification-application-2.svg new file mode 100644 index 0000000000000..32db7b5be0d0b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/notification-application-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/notification-message-alert.svg b/frontend/appflowy_web_app/public/af_icons/phone/notification-message-alert.svg new file mode 100644 index 0000000000000..7d1123fbee3d3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/notification-message-alert.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/outgoing-call.svg b/frontend/appflowy_web_app/public/af_icons/phone/outgoing-call.svg new file mode 100644 index 0000000000000..70ea90ca9eb5b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/outgoing-call.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/phone-mobile-phone.svg b/frontend/appflowy_web_app/public/af_icons/phone/phone-mobile-phone.svg new file mode 100644 index 0000000000000..299a57b66cd00 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/phone-mobile-phone.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/phone-qr.svg b/frontend/appflowy_web_app/public/af_icons/phone/phone-qr.svg new file mode 100644 index 0000000000000..f572d984d6a8c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/phone-qr.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/phone-ringing-1.svg b/frontend/appflowy_web_app/public/af_icons/phone/phone-ringing-1.svg new file mode 100644 index 0000000000000..1b13856b30825 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/phone-ringing-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/phone-ringing-2.svg b/frontend/appflowy_web_app/public/af_icons/phone/phone-ringing-2.svg new file mode 100644 index 0000000000000..889998fa684c1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/phone-ringing-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/phone.svg b/frontend/appflowy_web_app/public/af_icons/phone/phone.svg new file mode 100644 index 0000000000000..fc6ab0ad9c467 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/phone.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/signal-full.svg b/frontend/appflowy_web_app/public/af_icons/phone/signal-full.svg new file mode 100644 index 0000000000000..58823e23ea4e7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/signal-full.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/signal-low.svg b/frontend/appflowy_web_app/public/af_icons/phone/signal-low.svg new file mode 100644 index 0000000000000..e96c4af8a2762 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/signal-low.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/signal-medium.svg b/frontend/appflowy_web_app/public/af_icons/phone/signal-medium.svg new file mode 100644 index 0000000000000..1af4305dd0e5c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/signal-medium.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/signal-none.svg b/frontend/appflowy_web_app/public/af_icons/phone/signal-none.svg new file mode 100644 index 0000000000000..a4eb9031e261e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/signal-none.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/application-add.svg b/frontend/appflowy_web_app/public/af_icons/programing/application-add.svg new file mode 100644 index 0000000000000..3206d90bff512 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/application-add.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/bracket.svg b/frontend/appflowy_web_app/public/af_icons/programing/bracket.svg new file mode 100644 index 0000000000000..19ec5e423ddc8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/bracket.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/browser-add.svg b/frontend/appflowy_web_app/public/af_icons/programing/browser-add.svg new file mode 100644 index 0000000000000..f202217042302 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/browser-add.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/browser-block.svg b/frontend/appflowy_web_app/public/af_icons/programing/browser-block.svg new file mode 100644 index 0000000000000..6eb1b7be24836 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/browser-block.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/browser-build.svg b/frontend/appflowy_web_app/public/af_icons/programing/browser-build.svg new file mode 100644 index 0000000000000..2fb2366c85ff0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/browser-build.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/browser-check.svg b/frontend/appflowy_web_app/public/af_icons/programing/browser-check.svg new file mode 100644 index 0000000000000..198d2aad19f1c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/browser-check.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/browser-delete.svg b/frontend/appflowy_web_app/public/af_icons/programing/browser-delete.svg new file mode 100644 index 0000000000000..f6dc39ae72f2b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/browser-delete.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/browser-hash.svg b/frontend/appflowy_web_app/public/af_icons/programing/browser-hash.svg new file mode 100644 index 0000000000000..92664d39a7f35 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/browser-hash.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/browser-lock.svg b/frontend/appflowy_web_app/public/af_icons/programing/browser-lock.svg new file mode 100644 index 0000000000000..fec83df52fdd4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/browser-lock.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/browser-multiple-window.svg b/frontend/appflowy_web_app/public/af_icons/programing/browser-multiple-window.svg new file mode 100644 index 0000000000000..7c8b3043cd7d1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/browser-multiple-window.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/browser-remove.svg b/frontend/appflowy_web_app/public/af_icons/programing/browser-remove.svg new file mode 100644 index 0000000000000..eb5a5b1741465 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/browser-remove.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/browser-website-1.svg b/frontend/appflowy_web_app/public/af_icons/programing/browser-website-1.svg new file mode 100644 index 0000000000000..a49315daca6bf --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/browser-website-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/bug-antivirus-debugging.svg b/frontend/appflowy_web_app/public/af_icons/programing/bug-antivirus-debugging.svg new file mode 100644 index 0000000000000..be5811df0fcb6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/bug-antivirus-debugging.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/bug-antivirus-shield.svg b/frontend/appflowy_web_app/public/af_icons/programing/bug-antivirus-shield.svg new file mode 100644 index 0000000000000..84af3bb9cce43 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/bug-antivirus-shield.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/bug-virus-browser.svg b/frontend/appflowy_web_app/public/af_icons/programing/bug-virus-browser.svg new file mode 100644 index 0000000000000..4f42f5432633f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/bug-virus-browser.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/bug-virus-document.svg b/frontend/appflowy_web_app/public/af_icons/programing/bug-virus-document.svg new file mode 100644 index 0000000000000..a7c24e2ce6b0b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/bug-virus-document.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/bug-virus-folder.svg b/frontend/appflowy_web_app/public/af_icons/programing/bug-virus-folder.svg new file mode 100644 index 0000000000000..eeea1baf39c18 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/bug-virus-folder.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/bug.svg b/frontend/appflowy_web_app/public/af_icons/programing/bug.svg new file mode 100644 index 0000000000000..a2d2795e45a58 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/bug.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/cloud-add.svg b/frontend/appflowy_web_app/public/af_icons/programing/cloud-add.svg new file mode 100644 index 0000000000000..00a46324d0bc3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/cloud-add.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/cloud-block.svg b/frontend/appflowy_web_app/public/af_icons/programing/cloud-block.svg new file mode 100644 index 0000000000000..b321169629fb9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/cloud-block.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/cloud-check.svg b/frontend/appflowy_web_app/public/af_icons/programing/cloud-check.svg new file mode 100644 index 0000000000000..a56b3fbbffc1b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/cloud-check.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/cloud-data-transfer.svg b/frontend/appflowy_web_app/public/af_icons/programing/cloud-data-transfer.svg new file mode 100644 index 0000000000000..74d319d7f1534 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/cloud-data-transfer.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/cloud-refresh.svg b/frontend/appflowy_web_app/public/af_icons/programing/cloud-refresh.svg new file mode 100644 index 0000000000000..9096ac8796881 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/cloud-refresh.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/cloud-share.svg b/frontend/appflowy_web_app/public/af_icons/programing/cloud-share.svg new file mode 100644 index 0000000000000..e69919e88c926 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/cloud-share.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/cloud-warning.svg b/frontend/appflowy_web_app/public/af_icons/programing/cloud-warning.svg new file mode 100644 index 0000000000000..ebd5917d01535 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/cloud-warning.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/cloud-wifi.svg b/frontend/appflowy_web_app/public/af_icons/programing/cloud-wifi.svg new file mode 100644 index 0000000000000..51072ad76f7ae --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/cloud-wifi.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/code-analysis.svg b/frontend/appflowy_web_app/public/af_icons/programing/code-analysis.svg new file mode 100644 index 0000000000000..ef9dd46dbd51b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/code-analysis.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/code-monitor-1.svg b/frontend/appflowy_web_app/public/af_icons/programing/code-monitor-1.svg new file mode 100644 index 0000000000000..34c51a7ab24a8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/code-monitor-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/code-monitor-2.svg b/frontend/appflowy_web_app/public/af_icons/programing/code-monitor-2.svg new file mode 100644 index 0000000000000..5719d9699f341 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/code-monitor-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/css-three.svg b/frontend/appflowy_web_app/public/af_icons/programing/css-three.svg new file mode 100644 index 0000000000000..f20ce41833862 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/css-three.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/curly-brackets.svg b/frontend/appflowy_web_app/public/af_icons/programing/curly-brackets.svg new file mode 100644 index 0000000000000..6b6eb7d645d51 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/curly-brackets.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/file-code-1.svg b/frontend/appflowy_web_app/public/af_icons/programing/file-code-1.svg new file mode 100644 index 0000000000000..7e12e3847b7e7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/file-code-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/incognito-mode.svg b/frontend/appflowy_web_app/public/af_icons/programing/incognito-mode.svg new file mode 100644 index 0000000000000..84197023a4ae8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/incognito-mode.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/insert-cloud-video.svg b/frontend/appflowy_web_app/public/af_icons/programing/insert-cloud-video.svg new file mode 100644 index 0000000000000..4f883df73fe4a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/insert-cloud-video.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/markdown-circle-programming.svg b/frontend/appflowy_web_app/public/af_icons/programing/markdown-circle-programming.svg new file mode 100644 index 0000000000000..7c03684a41946 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/markdown-circle-programming.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/markdown-document-programming.svg b/frontend/appflowy_web_app/public/af_icons/programing/markdown-document-programming.svg new file mode 100644 index 0000000000000..514d68e7f81b2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/markdown-document-programming.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/module-puzzle-1.svg b/frontend/appflowy_web_app/public/af_icons/programing/module-puzzle-1.svg new file mode 100644 index 0000000000000..c285965ff6ada --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/module-puzzle-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/module-puzzle-3.svg b/frontend/appflowy_web_app/public/af_icons/programing/module-puzzle-3.svg new file mode 100644 index 0000000000000..60c754e08db36 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/module-puzzle-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/module-three.svg b/frontend/appflowy_web_app/public/af_icons/programing/module-three.svg new file mode 100644 index 0000000000000..d517266362978 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/module-three.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/rss-square.svg b/frontend/appflowy_web_app/public/af_icons/programing/rss-square.svg new file mode 100644 index 0000000000000..d84eeaabd14ba --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/rss-square.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/shipping/box-sign.svg b/frontend/appflowy_web_app/public/af_icons/shipping/box-sign.svg new file mode 100644 index 0000000000000..4edefc3583e5d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/shipping/box-sign.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/shipping/container.svg b/frontend/appflowy_web_app/public/af_icons/shipping/container.svg new file mode 100644 index 0000000000000..6b85af7c6c5d7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/shipping/container.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/shipping/fragile.svg b/frontend/appflowy_web_app/public/af_icons/shipping/fragile.svg new file mode 100644 index 0000000000000..0c694045289bd --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/shipping/fragile.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/shipping/parachute-drop.svg b/frontend/appflowy_web_app/public/af_icons/shipping/parachute-drop.svg new file mode 100644 index 0000000000000..b4a08a3417474 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/shipping/parachute-drop.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/shipping/shipment-add.svg b/frontend/appflowy_web_app/public/af_icons/shipping/shipment-add.svg new file mode 100644 index 0000000000000..96060a0e5c265 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/shipping/shipment-add.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/shipping/shipment-check.svg b/frontend/appflowy_web_app/public/af_icons/shipping/shipment-check.svg new file mode 100644 index 0000000000000..80046da591480 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/shipping/shipment-check.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/shipping/shipment-download.svg b/frontend/appflowy_web_app/public/af_icons/shipping/shipment-download.svg new file mode 100644 index 0000000000000..aff16bb56ab25 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/shipping/shipment-download.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/shipping/shipment-remove.svg b/frontend/appflowy_web_app/public/af_icons/shipping/shipment-remove.svg new file mode 100644 index 0000000000000..cb2759d0c7dcb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/shipping/shipment-remove.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/shipping/shipment-upload.svg b/frontend/appflowy_web_app/public/af_icons/shipping/shipment-upload.svg new file mode 100644 index 0000000000000..aa76150c54d60 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/shipping/shipment-upload.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/shipping/shipping-box-1.svg b/frontend/appflowy_web_app/public/af_icons/shipping/shipping-box-1.svg new file mode 100644 index 0000000000000..42a5129d1e5ec --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/shipping/shipping-box-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/shipping/shipping-truck.svg b/frontend/appflowy_web_app/public/af_icons/shipping/shipping-truck.svg new file mode 100644 index 0000000000000..6835708103c7f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/shipping/shipping-truck.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/shipping/transfer-motorcycle.svg b/frontend/appflowy_web_app/public/af_icons/shipping/transfer-motorcycle.svg new file mode 100644 index 0000000000000..2ab10a3db403b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/shipping/transfer-motorcycle.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/shipping/transfer-van.svg b/frontend/appflowy_web_app/public/af_icons/shipping/transfer-van.svg new file mode 100644 index 0000000000000..8c4a0cebc2b0e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/shipping/transfer-van.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/shipping/warehouse-1.svg b/frontend/appflowy_web_app/public/af_icons/shipping/warehouse-1.svg new file mode 100644 index 0000000000000..57e18260f0987 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/shipping/warehouse-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/work_education/book-reading.svg b/frontend/appflowy_web_app/public/af_icons/work_education/book-reading.svg new file mode 100644 index 0000000000000..c792c95b11223 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/work_education/book-reading.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/work_education/class-lesson.svg b/frontend/appflowy_web_app/public/af_icons/work_education/class-lesson.svg new file mode 100644 index 0000000000000..ea2bef46a0315 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/work_education/class-lesson.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/work_education/collaborations-idea.svg b/frontend/appflowy_web_app/public/af_icons/work_education/collaborations-idea.svg new file mode 100644 index 0000000000000..633d1811a6c16 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/work_education/collaborations-idea.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/work_education/definition-search-book.svg b/frontend/appflowy_web_app/public/af_icons/work_education/definition-search-book.svg new file mode 100644 index 0000000000000..14039daefbedd --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/work_education/definition-search-book.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/work_education/dictionary-language-book.svg b/frontend/appflowy_web_app/public/af_icons/work_education/dictionary-language-book.svg new file mode 100644 index 0000000000000..60591372bbcff --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/work_education/dictionary-language-book.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/work_education/global-learning.svg b/frontend/appflowy_web_app/public/af_icons/work_education/global-learning.svg new file mode 100644 index 0000000000000..bbcea6ce493a9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/work_education/global-learning.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/work_education/graduation-cap.svg b/frontend/appflowy_web_app/public/af_icons/work_education/graduation-cap.svg new file mode 100644 index 0000000000000..10dcd1e8342ec --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/work_education/graduation-cap.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/work_education/group-meeting-call.svg b/frontend/appflowy_web_app/public/af_icons/work_education/group-meeting-call.svg new file mode 100644 index 0000000000000..786fad824d5a7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/work_education/group-meeting-call.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/work_education/office-building-1.svg b/frontend/appflowy_web_app/public/af_icons/work_education/office-building-1.svg new file mode 100644 index 0000000000000..cba001bb4ebca --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/work_education/office-building-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/work_education/office-worker.svg b/frontend/appflowy_web_app/public/af_icons/work_education/office-worker.svg new file mode 100644 index 0000000000000..42e2938530fbc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/work_education/office-worker.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/work_education/search-dollar.svg b/frontend/appflowy_web_app/public/af_icons/work_education/search-dollar.svg new file mode 100644 index 0000000000000..acb98adab2f30 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/work_education/search-dollar.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/work_education/strategy-tasks.svg b/frontend/appflowy_web_app/public/af_icons/work_education/strategy-tasks.svg new file mode 100644 index 0000000000000..3938e55804ee4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/work_education/strategy-tasks.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/work_education/task-list.svg b/frontend/appflowy_web_app/public/af_icons/work_education/task-list.svg new file mode 100644 index 0000000000000..24f3080a8b322 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/work_education/task-list.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/work_education/workspace-desk.svg b/frontend/appflowy_web_app/public/af_icons/work_education/workspace-desk.svg new file mode 100644 index 0000000000000..9c2e287d7bb4c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/work_education/workspace-desk.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/appflowy.svg b/frontend/appflowy_web_app/public/appflowy.svg index a3e0c6491d20d..e8ad42279473b 100644 --- a/frontend/appflowy_web_app/public/appflowy.svg +++ b/frontend/appflowy_web_app/public/appflowy.svg @@ -1,12 +1,9 @@ - - - - - - - - - - - + + + + + + + + diff --git a/frontend/appflowy_web_app/scripts/generate_af_icons.cjs b/frontend/appflowy_web_app/scripts/generate_af_icons.cjs new file mode 100644 index 0000000000000..9763beba56eda --- /dev/null +++ b/frontend/appflowy_web_app/scripts/generate_af_icons.cjs @@ -0,0 +1,64 @@ +const fs = require('fs'); +const path = require('path'); + +const getIconsDir = () => path.resolve(__dirname, '../public/af_icons'); + +const readSvgFile = (filePath) => { + return fs.readFileSync(filePath, 'utf8'); +}; + +const renameSvgFile = (filePath, newName) => { + const newPath = path.join(path.dirname(filePath), newName); + fs.renameSync(filePath, newPath); +}; + +const processSvgFiles = (dirPath) => { + const categories = {}; + + const traverseDir = (currentPath) => { + const items = fs.readdirSync(currentPath); + + items.forEach((item) => { + const itemPath = path.join(currentPath, item); + const stat = fs.statSync(itemPath); + + if (stat.isDirectory()) { + traverseDir(itemPath); + } else if (stat.isFile() && path.extname(item) === '.svg') { + const category = path.basename(currentPath); + const [namePart, ...keywordParts] = path.basename(item, '.svg').split('--'); + const name = namePart; + const keywords = keywordParts.length > 0 ? keywordParts[0].split('-') : []; + const svgContent = readSvgFile(itemPath); + renameSvgFile(itemPath, `${name}.svg`); + if (!categories[category]) { + categories[category] = []; + } + + categories[category].push({ + id: `${category}/${name}`, + name, + keywords, + content: svgContent, + }); + } + }); + }; + + traverseDir(dirPath); + return categories; +}; + +const outputJson = (data, outputFilePath) => { + fs.writeFileSync(outputFilePath, JSON.stringify(data, null, 2)); +}; + +const main = () => { + const iconsDirPath = getIconsDir(); + const categories = processSvgFiles(iconsDirPath); + const outputFilePath = path.join(iconsDirPath, 'icons.json'); + outputJson(categories, outputFilePath); + console.log(`JSON data has been written to ${outputFilePath}`); +}; + +main(); diff --git a/frontend/appflowy_web_app/src-tauri/Cargo.lock b/frontend/appflowy_web_app/src-tauri/Cargo.lock index 54a5eb6fc0221..7fdcdb07a7cd3 100644 --- a/frontend/appflowy_web_app/src-tauri/Cargo.lock +++ b/frontend/appflowy_web_app/src-tauri/Cargo.lock @@ -163,7 +163,7 @@ checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "bincode", @@ -183,11 +183,12 @@ dependencies = [ [[package]] name = "appflowy-ai-client" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "bytes", "futures", + "pin-project", "serde", "serde_json", "serde_repr", @@ -197,7 +198,7 @@ dependencies = [ [[package]] name = "appflowy-local-ai" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=c4ab1db44e96348f9b0770dd8ecc990f68ac415d#c4ab1db44e96348f9b0770dd8ecc990f68ac415d" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=6f064efe232268f8d396edbb4b84d57fbb640f13#6f064efe232268f8d396edbb4b84d57fbb640f13" dependencies = [ "anyhow", "appflowy-plugin", @@ -216,7 +217,7 @@ dependencies = [ [[package]] name = "appflowy-plugin" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=c4ab1db44e96348f9b0770dd8ecc990f68ac415d#c4ab1db44e96348f9b0770dd8ecc990f68ac415d" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=6f064efe232268f8d396edbb4b84d57fbb640f13#6f064efe232268f8d396edbb4b84d57fbb640f13" dependencies = [ "anyhow", "cfg-if", @@ -239,7 +240,7 @@ version = "0.0.0" dependencies = [ "bytes", "dotenv", - "flowy-chat", + "flowy-ai", "flowy-config", "flowy-core", "flowy-date", @@ -300,6 +301,17 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" +[[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-stream" version = "0.3.5" @@ -324,9 +336,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.79" +version = "0.1.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a507401cad91ec6a857ed5513a2073c82a9b9048762b885bb98655b306964681" +checksum = "6e0c28dcc82d7c8ead5cb13beb15405b57b8546e93215673ff8ca0349a028107" dependencies = [ "proc-macro2", "quote", @@ -425,17 +437,16 @@ 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.5.0", "cexpr", "clang-sys", + "itertools 0.12.1", "lazy_static", "lazycell", - "peeking_take_while", - "prettyplease", "proc-macro2", "quote", "regex", @@ -800,11 +811,12 @@ dependencies = [ [[package]] name = "client-api" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "again", "anyhow", "app-error", + "arc-swap", "async-trait", "bincode", "brotli", @@ -850,19 +862,20 @@ dependencies = [ [[package]] name = "client-api-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" 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=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "futures-channel", "futures-util", @@ -945,15 +958,16 @@ dependencies = [ [[package]] name = "collab" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=20f7814#20f7814beb265ea76e85ea7a9d392b9fe18a2a63" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b987647c190b337d0dca2718c93ff918e94b3730#b987647c190b337d0dca2718c93ff918e94b3730" dependencies = [ "anyhow", + "arc-swap", "async-trait", "bincode", "bytes", "chrono", "js-sys", - "parking_lot 0.12.1", + "lazy_static", "serde", "serde_json", "serde_repr", @@ -969,20 +983,19 @@ dependencies = [ [[package]] name = "collab-database" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=20f7814#20f7814beb265ea76e85ea7a9d392b9fe18a2a63" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b987647c190b337d0dca2718c93ff918e94b3730#b987647c190b337d0dca2718c93ff918e94b3730" dependencies = [ "anyhow", "async-trait", "chrono", "collab", "collab-entity", - "collab-plugins", - "dashmap", + "dashmap 5.5.3", + "futures", "getrandom 0.2.12", "js-sys", "lazy_static", "nanoid", - "parking_lot 0.12.1", "rayon", "serde", "serde_json", @@ -999,14 +1012,14 @@ dependencies = [ [[package]] name = "collab-document" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=20f7814#20f7814beb265ea76e85ea7a9d392b9fe18a2a63" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b987647c190b337d0dca2718c93ff918e94b3730#b987647c190b337d0dca2718c93ff918e94b3730" dependencies = [ "anyhow", + "arc-swap", "collab", "collab-entity", "getrandom 0.2.12", "nanoid", - "parking_lot 0.12.1", "serde", "serde_json", "thiserror", @@ -1019,29 +1032,34 @@ dependencies = [ [[package]] name = "collab-entity" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=20f7814#20f7814beb265ea76e85ea7a9d392b9fe18a2a63" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b987647c190b337d0dca2718c93ff918e94b3730#b987647c190b337d0dca2718c93ff918e94b3730" dependencies = [ "anyhow", "bytes", "collab", "getrandom 0.2.12", + "prost", + "prost-build", + "protoc-bin-vendored", "serde", "serde_json", "serde_repr", "uuid", + "walkdir", ] [[package]] name = "collab-folder" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=20f7814#20f7814beb265ea76e85ea7a9d392b9fe18a2a63" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b987647c190b337d0dca2718c93ff918e94b3730#b987647c190b337d0dca2718c93ff918e94b3730" dependencies = [ "anyhow", + "arc-swap", "chrono", "collab", "collab-entity", + "dashmap 5.5.3", "getrandom 0.2.12", - "parking_lot 0.12.1", "serde", "serde_json", "serde_repr", @@ -1056,13 +1074,17 @@ name = "collab-integrate" version = "0.1.0" dependencies = [ "anyhow", + "arc-swap", "async-trait", "collab", + "collab-database", + "collab-document", "collab-entity", + "collab-folder", "collab-plugins", + "collab-user", "futures", "lib-infra", - "parking_lot 0.12.1", "serde", "serde_json", "tokio", @@ -1072,7 +1094,7 @@ dependencies = [ [[package]] name = "collab-plugins" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=20f7814#20f7814beb265ea76e85ea7a9d392b9fe18a2a63" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b987647c190b337d0dca2718c93ff918e94b3730#b987647c190b337d0dca2718c93ff918e94b3730" dependencies = [ "anyhow", "async-stream", @@ -1088,7 +1110,6 @@ dependencies = [ "indexed_db_futures", "js-sys", "lazy_static", - "parking_lot 0.12.1", "rand 0.8.5", "rocksdb", "serde", @@ -1111,7 +1132,7 @@ dependencies = [ [[package]] name = "collab-rt-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "bincode", @@ -1136,7 +1157,7 @@ dependencies = [ [[package]] name = "collab-rt-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "async-trait", @@ -1153,13 +1174,12 @@ dependencies = [ [[package]] name = "collab-user" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=20f7814#20f7814beb265ea76e85ea7a9d392b9fe18a2a63" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=b987647c190b337d0dca2718c93ff918e94b3730#b987647c190b337d0dca2718c93ff918e94b3730" dependencies = [ "anyhow", "collab", "collab-entity", "getrandom 0.2.12", - "parking_lot 0.12.1", "serde", "serde_json", "tokio", @@ -1183,6 +1203,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console" version = "0.14.1" @@ -1509,6 +1538,20 @@ dependencies = [ "parking_lot_core 0.9.9", ] +[[package]] +name = "dashmap" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804c8821570c3f8b70230c2ba75ffa5c0f9a4189b9a432b6656c536712acae28" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.3", + "lock_api", + "once_cell", + "parking_lot_core 0.9.9", +] + [[package]] name = "data-encoding" version = "2.5.0" @@ -1518,7 +1561,7 @@ checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "app-error", @@ -1871,6 +1914,27 @@ version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0474425d51df81997e2f90a21591180b38eccf27292d755f3e30750225c175b" +[[package]] +name = "event-listener" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f214dc438f977e6d4e3500aaa277f5ad94ca83fbbd9b1a15713ce2344ccc5a1" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "faccess" version = "0.2.4" @@ -1977,38 +2041,32 @@ dependencies = [ ] [[package]] -name = "flowy-ast" -version = "0.1.0" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "flowy-chat" +name = "flowy-ai" version = "0.1.0" dependencies = [ "allo-isolate", "anyhow", "appflowy-local-ai", "appflowy-plugin", + "arc-swap", "base64 0.21.7", "bytes", - "dashmap", - "flowy-chat-pub", + "dashmap 6.0.1", + "flowy-ai-pub", "flowy-codegen", "flowy-derive", "flowy-error", "flowy-notification", "flowy-sqlite", + "flowy-storage-pub", "futures", "futures-util", "lib-dispatch", "lib-infra", "log", "md5", - "parking_lot 0.12.1", + "notify", + "pin-project", "protobuf", "reqwest", "serde", @@ -2026,7 +2084,7 @@ dependencies = [ ] [[package]] -name = "flowy-chat-pub" +name = "flowy-ai-pub" version = "0.1.0" dependencies = [ "bytes", @@ -2034,6 +2092,16 @@ dependencies = [ "flowy-error", "futures", "lib-infra", + "serde_json", +] + +[[package]] +name = "flowy-ast" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", ] [[package]] @@ -2079,6 +2147,8 @@ name = "flowy-core" version = "0.1.0" dependencies = [ "anyhow", + "appflowy-local-ai", + "arc-swap", "base64 0.21.7", "bytes", "client-api", @@ -2086,9 +2156,10 @@ dependencies = [ "collab-entity", "collab-integrate", "collab-plugins", + "dashmap 6.0.1", "diesel", - "flowy-chat", - "flowy-chat-pub", + "flowy-ai", + "flowy-ai-pub", "flowy-config", "flowy-database-pub", "flowy-database2", @@ -2112,7 +2183,6 @@ dependencies = [ "lib-dispatch", "lib-infra", "lib-log", - "parking_lot 0.12.1", "semver", "serde", "serde_json", @@ -2142,6 +2212,7 @@ name = "flowy-database2" version = "0.1.0" dependencies = [ "anyhow", + "arc-swap", "async-stream", "async-trait", "bytes", @@ -2153,7 +2224,7 @@ dependencies = [ "collab-integrate", "collab-plugins", "csv", - "dashmap", + "dashmap 6.0.1", "fancy-regex 0.11.0", "flowy-codegen", "flowy-database-pub", @@ -2166,7 +2237,6 @@ dependencies = [ "lib-dispatch", "lib-infra", "nanoid", - "parking_lot 0.12.1", "protobuf", "rayon", "rust_decimal", @@ -2177,6 +2247,7 @@ dependencies = [ "strum", "strum_macros 0.25.3", "tokio", + "tokio-util", "tracing", "url", "validator", @@ -2203,7 +2274,7 @@ dependencies = [ name = "flowy-derive" version = "0.1.0" dependencies = [ - "dashmap", + "dashmap 6.0.1", "flowy-ast", "flowy-codegen", "lazy_static", @@ -2225,7 +2296,7 @@ dependencies = [ "collab-entity", "collab-integrate", "collab-plugins", - "dashmap", + "dashmap 6.0.1", "flowy-codegen", "flowy-derive", "flowy-document-pub", @@ -2238,7 +2309,6 @@ dependencies = [ "lib-dispatch", "lib-infra", "nanoid", - "parking_lot 0.12.1", "protobuf", "scraper 0.18.1", "serde", @@ -2309,6 +2379,7 @@ dependencies = [ name = "flowy-folder" version = "0.1.0" dependencies = [ + "arc-swap", "async-trait", "bytes", "chrono", @@ -2330,7 +2401,6 @@ dependencies = [ "lib-dispatch", "lib-infra", "nanoid", - "parking_lot 0.12.1", "protobuf", "regex", "serde", @@ -2362,7 +2432,7 @@ name = "flowy-notification" version = "0.1.0" dependencies = [ "bytes", - "dashmap", + "dashmap 6.0.1", "flowy-codegen", "flowy-derive", "lazy_static", @@ -2425,15 +2495,18 @@ name = "flowy-server" version = "0.1.0" dependencies = [ "anyhow", + "arc-swap", "bytes", "chrono", "client-api", "collab", + "collab-database", "collab-document", "collab-entity", "collab-folder", "collab-plugins", - "flowy-chat-pub", + "dashmap 6.0.1", + "flowy-ai-pub", "flowy-database-pub", "flowy-document-pub", "flowy-encrypt", @@ -2452,7 +2525,6 @@ dependencies = [ "lib-dispatch", "lib-infra", "mime_guess", - "parking_lot 0.12.1", "postgrest", "rand 0.8.5", "reqwest", @@ -2488,7 +2560,6 @@ dependencies = [ "diesel_derives", "diesel_migrations", "libsqlite3-sys", - "parking_lot 0.12.1", "r2d2", "scheduled-thread-pool", "serde", @@ -2501,22 +2572,27 @@ dependencies = [ name = "flowy-storage" version = "0.1.0" dependencies = [ + "allo-isolate", "anyhow", "async-trait", "bytes", "chrono", + "dashmap 6.0.1", "flowy-codegen", "flowy-derive", "flowy-error", "flowy-notification", "flowy-sqlite", "flowy-storage-pub", + "futures-util", "fxhash", + "lib-dispatch", "lib-infra", "mime_guess", "protobuf", "serde", "serde_json", + "strum_macros 0.25.3", "tokio", "tracing", "url", @@ -2537,6 +2613,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "tracing", ] [[package]] @@ -2544,6 +2621,7 @@ name = "flowy-user" version = "0.1.0" dependencies = [ "anyhow", + "arc-swap", "base64 0.21.7", "bytes", "chrono", @@ -2556,6 +2634,7 @@ dependencies = [ "collab-integrate", "collab-plugins", "collab-user", + "dashmap 6.0.1", "diesel", "diesel_derives", "fancy-regex 0.11.0", @@ -2572,7 +2651,6 @@ dependencies = [ "lib-dispatch", "lib-infra", "once_cell", - "parking_lot 0.12.1", "protobuf", "semver", "serde", @@ -2677,6 +2755,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "funty" version = "2.0.0" @@ -3101,7 +3188,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "futures-util", @@ -3118,7 +3205,7 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "app-error", @@ -3555,7 +3642,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "bytes", @@ -3566,6 +3653,26 @@ dependencies = [ "tracing", ] +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.3" @@ -3740,6 +3847,26 @@ dependencies = [ "simple_asn1", ] +[[package]] +name = "kqueue" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7447f1ca1b7b563588a205fe93dea8df60fd981423a768bc1c0ded35ed147d0c" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "kuchikiki" version = "0.8.2" @@ -3785,7 +3912,6 @@ dependencies = [ "futures-util", "getrandom 0.2.12", "nanoid", - "parking_lot 0.12.1", "pin-project", "protobuf", "serde", @@ -3812,6 +3938,7 @@ dependencies = [ "chrono", "futures", "futures-core", + "futures-util", "md5", "pin-project", "tempfile", @@ -3873,9 +4000,9 @@ dependencies = [ [[package]] name = "librocksdb-sys" -version = "0.11.0+8.1.1" +version = "0.16.0+8.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3386f101bcb4bd252d8e9d2fb41ec3b0862a15a62b478c355b2982efa469e3e" +checksum = "ce3d60bc059831dc1c83903fb45c103f75db65c5a7bf22272764d9cc683e348c" dependencies = [ "bindgen", "bzip2-sys", @@ -4174,6 +4301,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ "libc", + "log", "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.48.0", ] @@ -4279,6 +4407,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.5.0", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "walkdir", + "windows-sys 0.48.0", +] + [[package]] name = "ntapi" version = "0.4.1" @@ -4576,6 +4723,12 @@ dependencies = [ "system-deps 6.2.2", ] +[[package]] +name = "parking" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae" + [[package]] name = "parking_lot" version = "0.11.2" @@ -4672,12 +4825,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" @@ -5615,9 +5762,9 @@ dependencies = [ [[package]] name = "rocksdb" -version = "0.21.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb6f170a4041d50a0ce04b0d2e14916d6ca863ea2e422689a5b694395d299ffe" +checksum = "6bd13e55d6d7b8cd0ea569161127567cd587676c99f4472f779a0279aa60a7a7" dependencies = [ "libc", "librocksdb-sys", @@ -6091,7 +6238,7 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=886376e8bd95ef633aa4b3d7a8377f1003764748#886376e8bd95ef633aa4b3d7a8377f1003764748" dependencies = [ "anyhow", "app-error", @@ -6109,6 +6256,7 @@ dependencies = [ "serde_json", "serde_repr", "thiserror", + "tracing", "uuid", ] @@ -8563,12 +8711,14 @@ dependencies = [ [[package]] name = "yrs" -version = "0.19.2" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8ca5126331b9a5ef5bb10f3f1c3d01b05f298d348c66f8fb15497d83ee73176" +checksum = "a8fc56b25e3aaf4b81a73f2a9a68ceae1e02d9005552e24058cfb9f96db73f33" dependencies = [ "arc-swap", - "atomic_refcell", + "async-lock", + "async-trait", + "dashmap 6.0.1", "fastrand", "serde", "serde_json", diff --git a/frontend/appflowy_web_app/src-tauri/Cargo.toml b/frontend/appflowy_web_app/src-tauri/Cargo.toml index 9591ac709f9e9..f34e2e3a7004d 100644 --- a/frontend/appflowy_web_app/src-tauri/Cargo.toml +++ b/frontend/appflowy_web_app/src-tauri/Cargo.toml @@ -20,7 +20,12 @@ bytes = "1.5.0" serde = "1.0" serde_json = "1.0.108" protobuf = { version = "2.28.0" } -diesel = { version = "2.1.0", features = ["sqlite", "chrono", "r2d2", "serde_json"] } +diesel = { version = "2.1.0", features = [ + "sqlite", + "chrono", + "r2d2", + "serde_json", +] } uuid = { version = "1.5.0", features = ["serde", "v4"] } serde_repr = "0.1" parking_lot = "0.12" @@ -52,7 +57,7 @@ collab-user = { version = "0.2" } # Run the script: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "c2a839ba8bf9ead44679eb08f3a9680467b767ca" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "886376e8bd95ef633aa4b3d7a8377f1003764748" } [dependencies] serde_json.workspace = true @@ -69,9 +74,7 @@ tracing.workspace = true lib-dispatch = { path = "../../rust-lib/lib-dispatch", features = [ "use_serde", ] } -flowy-core = { path = "../../rust-lib/flowy-core", features = [ - "ts", -] } +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"] } @@ -89,9 +92,7 @@ flowy-document = { path = "../../rust-lib/flowy-document", features = [ flowy-notification = { path = "../../rust-lib/flowy-notification", features = [ "tauri_ts", ] } -flowy-chat = { path = "../../rust-lib/flowy-chat", features = [ - "tauri_ts", -] } +flowy-ai = { path = "../../rust-lib/flowy-ai", features = ["tauri_ts"] } uuid = "1.5.0" tauri-plugin-deep-link = "0.1.2" @@ -116,18 +117,17 @@ custom-protocol = ["tauri/custom-protocol"] # 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 = "20f7814" } -collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "20f7814" } -collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "20f7814" } -collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "20f7814" } -collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "20f7814" } -collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "20f7814" } -collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "20f7814" } +collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b987647c190b337d0dca2718c93ff918e94b3730" } +collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b987647c190b337d0dca2718c93ff918e94b3730" } +collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b987647c190b337d0dca2718c93ff918e94b3730" } +collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b987647c190b337d0dca2718c93ff918e94b3730" } +collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b987647c190b337d0dca2718c93ff918e94b3730" } +collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b987647c190b337d0dca2718c93ff918e94b3730" } +collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b987647c190b337d0dca2718c93ff918e94b3730" } # Working directory: frontend # To update the commit ID, run: # scripts/tool/update_local_ai_rev.sh new_rev_id # ⚠️⚠️⚠️️ -appflowy-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "c4ab1db44e96348f9b0770dd8ecc990f68ac415d" } -appflowy-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "c4ab1db44e96348f9b0770dd8ecc990f68ac415d" } - +appflowy-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "6f064efe232268f8d396edbb4b84d57fbb640f13" } +appflowy-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "6f064efe232268f8d396edbb4b84d57fbb640f13" } diff --git a/frontend/appflowy_web_app/src-tauri/src/init.rs b/frontend/appflowy_web_app/src-tauri/src/init.rs index 42c857abdf33b..7af31af362b3d 100644 --- a/frontend/appflowy_web_app/src-tauri/src/init.rs +++ b/frontend/appflowy_web_app/src-tauri/src/init.rs @@ -1,36 +1,42 @@ +use dotenv::dotenv; use flowy_core::config::AppFlowyCoreConfig; use flowy_core::{AppFlowyCore, DEFAULT_NAME}; use lib_dispatch::runtime::AFPluginRuntime; -use std::sync::Arc; - -use dotenv::dotenv; +use std::rc::Rc; +use std::sync::{Arc, Mutex}; pub fn read_env() { dotenv().ok(); let env = if cfg!(debug_assertions) { - include_str!("../env.development") + include_str!("../env.development") } else { - include_str!("../env.production") + 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); - } + 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 { +pub fn init_appflowy_core() -> MutexAppFlowyCore { let config_json = include_str!("../tauri.conf.json"); let config: tauri_utils::config::Config = serde_json::from_str(config_json).unwrap(); - let app_version = config.package.version.clone().map(|v| v.to_string()).unwrap_or_else(|| "0.0.0".to_string()); - let app_version = semver::Version::parse(&app_version).unwrap_or_else(|_| semver::Version::new(0, 5, 8)); + let app_version = config + .package + .version + .clone() + .map(|v| v.to_string()) + .unwrap_or_else(|| "0.5.8".to_string()); + let app_version = + semver::Version::parse(&app_version).unwrap_or_else(|_| semver::Version::new(0, 5, 8)); let mut data_path = tauri::api::path::app_local_data_dir(&config).unwrap(); if cfg!(debug_assertions) { data_path.push("data_dev"); @@ -50,12 +56,24 @@ pub fn init_flowy_core() -> AppFlowyCore { custom_application_path, application_path, device_id, - "web".to_string(), + "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 }) + runtime.block_on(async move { + MutexAppFlowyCore::new(AppFlowyCore::new(config, cloned_runtime, None).await) + }) +} + +pub struct MutexAppFlowyCore(pub Arc>); + +impl MutexAppFlowyCore { + pub(crate) fn new(appflowy_core: AppFlowyCore) -> Self { + Self(Arc::new(Mutex::new(appflowy_core))) + } } +unsafe impl Sync for MutexAppFlowyCore {} +unsafe impl Send for MutexAppFlowyCore {} diff --git a/frontend/appflowy_web_app/src-tauri/src/main.rs b/frontend/appflowy_web_app/src-tauri/src/main.rs index 6a69de07fd322..781ce55098126 100644 --- a/frontend/appflowy_web_app/src-tauri/src/main.rs +++ b/frontend/appflowy_web_app/src-tauri/src/main.rs @@ -21,7 +21,7 @@ extern crate dotenv; fn main() { tauri_plugin_deep_link::prepare(DEEP_LINK_SCHEME); - let flowy_core = init_flowy_core(); + let flowy_core = init_appflowy_core(); tauri::Builder::default() .invoke_handler(tauri::generate_handler![invoke_request]) .manage(flowy_core) diff --git a/frontend/appflowy_web_app/src-tauri/src/request.rs b/frontend/appflowy_web_app/src-tauri/src/request.rs index 029e71c18cd9c..ff69a438c9c19 100644 --- a/frontend/appflowy_web_app/src-tauri/src/request.rs +++ b/frontend/appflowy_web_app/src-tauri/src/request.rs @@ -1,4 +1,4 @@ -use flowy_core::AppFlowyCore; +use crate::init::MutexAppFlowyCore; use lib_dispatch::prelude::{ AFPluginDispatcher, AFPluginEventResponse, AFPluginRequest, StatusCode, }; @@ -38,8 +38,8 @@ pub async fn invoke_request( 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; + let state: State = app_handler.state(); + let dispatcher = state.0.lock().unwrap().dispatcher(); + let response = AFPluginDispatcher::sync_send(dispatcher, request); response.into() } diff --git a/frontend/appflowy_web_app/src/application/collab.type.ts b/frontend/appflowy_web_app/src/application/collab.type.ts index 0040c49ffcdad..34a6c1094e98b 100644 --- a/frontend/appflowy_web_app/src/application/collab.type.ts +++ b/frontend/appflowy_web_app/src/application/collab.type.ts @@ -34,6 +34,8 @@ export enum BlockType { TableBlock = 'table', TableCell = 'table/cell', LinkPreview = 'link_preview', + FileBlock = 'file', + GalleryBlock = 'multi_image', } export enum InlineBlockType { @@ -85,6 +87,18 @@ export interface LinkPreviewBlockData extends BlockData { url?: string; } +export enum FieldURLType { + Upload = 2, + Link = 1, +} + +export interface FileBlockData extends BlockData { + name: string; + uploaded_at: number; + url: string; + url_type: FieldURLType; +} + export enum ImageType { Local = 0, Internal = 1, @@ -99,6 +113,19 @@ export interface ImageBlockData extends BlockData { height?: number; } +export enum GalleryLayout { + Carousel = 0, + Grid = 1, +} + +export interface GalleryBlockData extends BlockData { + images: { + type: ImageType, + url: string, + }[]; + layout: GalleryLayout; +} + export interface OutlineBlockData extends BlockData { depth?: number; } @@ -271,151 +298,151 @@ export enum YjsDatabaseKey { export interface YDoc extends Y.Doc { // eslint-disable-next-line @typescript-eslint/no-explicit-any - getMap(key: YjsEditorKey.data_section): YSharedRoot | any; + getMap (key: YjsEditorKey.data_section): YSharedRoot | any; } export interface YDatabaseRow extends Y.Map { - get(key: YjsDatabaseKey.id): RowId; + get (key: YjsDatabaseKey.id): RowId; - get(key: YjsDatabaseKey.height): string; + get (key: YjsDatabaseKey.height): string; - get(key: YjsDatabaseKey.visibility): boolean; + get (key: YjsDatabaseKey.visibility): boolean; - get(key: YjsDatabaseKey.created_at): CreatedAt; + get (key: YjsDatabaseKey.created_at): CreatedAt; - get(key: YjsDatabaseKey.last_modified): LastModified; + get (key: YjsDatabaseKey.last_modified): LastModified; - get(key: YjsDatabaseKey.cells): YDatabaseCells; + get (key: YjsDatabaseKey.cells): YDatabaseCells; } export interface YDatabaseCells extends Y.Map { - get(key: FieldId): YDatabaseCell; + get (key: FieldId): YDatabaseCell; } export type EndTimestamp = string; export type ReminderId = string; export interface YDatabaseCell extends Y.Map { - get(key: YjsDatabaseKey.created_at): CreatedAt; + get (key: YjsDatabaseKey.created_at): CreatedAt; - get(key: YjsDatabaseKey.last_modified): LastModified; + get (key: YjsDatabaseKey.last_modified): LastModified; - get(key: YjsDatabaseKey.field_type): string; + get (key: YjsDatabaseKey.field_type): string; - get(key: YjsDatabaseKey.data): object | string | boolean | number; + get (key: YjsDatabaseKey.data): object | string | boolean | number; - get(key: YjsDatabaseKey.end_timestamp): EndTimestamp; + get (key: YjsDatabaseKey.end_timestamp): EndTimestamp; - get(key: YjsDatabaseKey.include_time): boolean; + get (key: YjsDatabaseKey.include_time): boolean; // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsDatabaseKey.is_range): boolean; + get (key: YjsDatabaseKey.is_range): boolean; - get(key: YjsDatabaseKey.reminder_id): ReminderId; + get (key: YjsDatabaseKey.reminder_id): ReminderId; } export interface YSharedRoot extends Y.Map { - get(key: YjsEditorKey.document): YDocument; + get (key: YjsEditorKey.document): YDocument; - get(key: YjsEditorKey.folder): YFolder; + get (key: YjsEditorKey.folder): YFolder; - get(key: YjsEditorKey.database): YDatabase; + get (key: YjsEditorKey.database): YDatabase; - get(key: YjsEditorKey.database_row): YDatabaseRow; + get (key: YjsEditorKey.database_row): YDatabaseRow; } export interface YFolder extends Y.Map { - get(key: YjsFolderKey.views): YViews; + get (key: YjsFolderKey.views): YViews; // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsFolderKey.meta): YFolderMeta; + get (key: YjsFolderKey.meta): YFolderMeta; // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsFolderKey.relation): YFolderRelation; + get (key: YjsFolderKey.relation): YFolderRelation; // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsFolderKey.section): YFolderSection; + get (key: YjsFolderKey.section): YFolderSection; } export interface YViews extends Y.Map { - get(key: ViewId): YView; + get (key: ViewId): YView; } export interface YView extends Y.Map { - get(key: YjsFolderKey.id): ViewId; + get (key: YjsFolderKey.id): ViewId; - get(key: YjsFolderKey.bid): string; + get (key: YjsFolderKey.bid): string; // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsFolderKey.name): string; + get (key: YjsFolderKey.name): string; // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsFolderKey.icon | YjsFolderKey.extra): string; + get (key: YjsFolderKey.icon | YjsFolderKey.extra): string; // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsFolderKey.layout): string; + get (key: YjsFolderKey.layout): string; } export interface YFolderRelation extends Y.Map { - get(key: ViewId): Y.Array; + get (key: ViewId): Y.Array; } export interface YFolderMeta extends Y.Map { - get(key: YjsFolderKey.current_view | YjsFolderKey.current_workspace): string; + 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; + get (key: YjsFolderKey.favorite | YjsFolderKey.private | YjsFolderKey.recent | YjsFolderKey.trash): YFolderSectionItem; } export interface YFolderSectionItem extends Y.Map { - get(key: string): Y.Array; + get (key: string): Y.Array; } export interface YDocument extends Y.Map { - get(key: YjsEditorKey.blocks | YjsEditorKey.page_id | YjsEditorKey.meta): YBlocks | YMeta | string; + get (key: YjsEditorKey.blocks | YjsEditorKey.page_id | YjsEditorKey.meta): YBlocks | YMeta | string; } export interface YBlocks extends Y.Map { - get(key: BlockId): YBlock; + get (key: BlockId): YBlock; } export interface YBlock extends Y.Map { - get(key: YjsEditorKey.block_id | YjsEditorKey.block_parent): BlockId; + get (key: YjsEditorKey.block_id | YjsEditorKey.block_parent): BlockId; - get(key: YjsEditorKey.block_type): BlockType; + get (key: YjsEditorKey.block_type): BlockType; - get(key: YjsEditorKey.block_data): string; + get (key: YjsEditorKey.block_data): string; - get(key: YjsEditorKey.block_children): ChildrenId; + get (key: YjsEditorKey.block_children): ChildrenId; - get(key: YjsEditorKey.block_external_id): ExternalId; + get (key: YjsEditorKey.block_external_id): ExternalId; } export interface YMeta extends Y.Map { - get(key: YjsEditorKey.children_map | YjsEditorKey.text_map): YChildrenMap | YTextMap; + get (key: YjsEditorKey.children_map | YjsEditorKey.text_map): YChildrenMap | YTextMap; } export interface YChildrenMap extends Y.Map { - get(key: ChildrenId): Y.Array; + get (key: ChildrenId): Y.Array; } export interface YTextMap extends Y.Map { - get(key: ExternalId): Y.Text; + get (key: ExternalId): Y.Text; } export interface YDatabase extends Y.Map { - get(key: YjsDatabaseKey.views): YDatabaseViews; + get (key: YjsDatabaseKey.views): YDatabaseViews; - get(key: YjsDatabaseKey.metas): YDatabaseMetas; + get (key: YjsDatabaseKey.metas): YDatabaseMetas; - get(key: YjsDatabaseKey.fields): YDatabaseFields; + get (key: YjsDatabaseKey.fields): YDatabaseFields; - get(key: YjsDatabaseKey.id): string; + get (key: YjsDatabaseKey.id): string; } export interface YDatabaseViews extends Y.Map { - get(key: ViewId): YDatabaseView; + get (key: ViewId): YDatabaseView; } export type DatabaseId = string; @@ -431,32 +458,32 @@ export enum DatabaseViewLayout { } export interface YDatabaseView extends Y.Map { - get(key: YjsDatabaseKey.database_id): DatabaseId; + get (key: YjsDatabaseKey.database_id): DatabaseId; - get(key: YjsDatabaseKey.name): string; + get (key: YjsDatabaseKey.name): string; - get(key: YjsDatabaseKey.created_at): CreatedAt; + get (key: YjsDatabaseKey.created_at): CreatedAt; - get(key: YjsDatabaseKey.modified_at): ModifiedAt; + get (key: YjsDatabaseKey.modified_at): ModifiedAt; // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsDatabaseKey.layout): string; + get (key: YjsDatabaseKey.layout): string; - get(key: YjsDatabaseKey.layout_settings): YDatabaseLayoutSettings; + get (key: YjsDatabaseKey.layout_settings): YDatabaseLayoutSettings; - get(key: YjsDatabaseKey.filters): YDatabaseFilters; + get (key: YjsDatabaseKey.filters): YDatabaseFilters; - get(key: YjsDatabaseKey.groups): YDatabaseGroups; + get (key: YjsDatabaseKey.groups): YDatabaseGroups; - get(key: YjsDatabaseKey.sorts): YDatabaseSorts; + get (key: YjsDatabaseKey.sorts): YDatabaseSorts; - get(key: YjsDatabaseKey.field_settings): YDatabaseFieldSettings; + get (key: YjsDatabaseKey.field_settings): YDatabaseFieldSettings; - get(key: YjsDatabaseKey.field_orders): YDatabaseFieldOrders; + get (key: YjsDatabaseKey.field_orders): YDatabaseFieldOrders; - get(key: YjsDatabaseKey.row_orders): YDatabaseRowOrders; + get (key: YjsDatabaseKey.row_orders): YDatabaseRowOrders; - get(key: YjsDatabaseKey.calculations): YDatabaseCalculations; + get (key: YjsDatabaseKey.calculations): YDatabaseCalculations; } export type YDatabaseFieldOrders = Y.Array; // [ { id: FieldId } ] @@ -477,128 +504,128 @@ export type GroupId = string; export interface YDatabaseLayoutSettings extends Y.Map { // DatabaseViewLayout.Board - get(key: '1'): YDatabaseBoardLayoutSetting; + get (key: '1'): YDatabaseBoardLayoutSetting; // DatabaseViewLayout.Calendar - get(key: '2'): YDatabaseCalendarLayoutSetting; + get (key: '2'): YDatabaseCalendarLayoutSetting; } export interface YDatabaseBoardLayoutSetting extends Y.Map { - get(key: YjsDatabaseKey.hide_ungrouped_column | YjsDatabaseKey.collapse_hidden_groups): boolean; + get (key: YjsDatabaseKey.hide_ungrouped_column | YjsDatabaseKey.collapse_hidden_groups): boolean; } export interface YDatabaseCalendarLayoutSetting extends Y.Map { - get(key: YjsDatabaseKey.first_day_of_week | YjsDatabaseKey.field_id | YjsDatabaseKey.layout_ty): string; + get (key: YjsDatabaseKey.first_day_of_week | YjsDatabaseKey.field_id | YjsDatabaseKey.layout_ty): string; - get(key: YjsDatabaseKey.show_week_numbers | YjsDatabaseKey.show_weekends): boolean; + get (key: YjsDatabaseKey.show_week_numbers | YjsDatabaseKey.show_weekends): boolean; } export interface YDatabaseGroup extends Y.Map { - get(key: YjsDatabaseKey.id): GroupId; + get (key: YjsDatabaseKey.id): GroupId; - get(key: YjsDatabaseKey.field_id): FieldId; + get (key: YjsDatabaseKey.field_id): FieldId; // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsDatabaseKey.content): string; + get (key: YjsDatabaseKey.content): string; - get(key: YjsDatabaseKey.groups): YDatabaseGroupColumns; + get (key: YjsDatabaseKey.groups): YDatabaseGroupColumns; } export type YDatabaseGroupColumns = Y.Array; export interface YDatabaseGroupColumn extends Y.Map { - get(key: YjsDatabaseKey.id): string; + get (key: YjsDatabaseKey.id): string; - get(key: YjsDatabaseKey.visible): boolean; + get (key: YjsDatabaseKey.visible): boolean; } export interface YDatabaseRowOrder extends Y.Map { - get(key: YjsDatabaseKey.id): SortId; + get (key: YjsDatabaseKey.id): SortId; - get(key: YjsDatabaseKey.height): number; + get (key: YjsDatabaseKey.height): number; } export interface YDatabaseSort extends Y.Map { - get(key: YjsDatabaseKey.id): SortId; + get (key: YjsDatabaseKey.id): SortId; - get(key: YjsDatabaseKey.field_id): FieldId; + get (key: YjsDatabaseKey.field_id): FieldId; - get(key: YjsDatabaseKey.condition): string; + get (key: YjsDatabaseKey.condition): string; } export type FilterId = string; export interface YDatabaseFilter extends Y.Map { - get(key: YjsDatabaseKey.id): FilterId; + get (key: YjsDatabaseKey.id): FilterId; - get(key: YjsDatabaseKey.field_id): FieldId; + get (key: YjsDatabaseKey.field_id): FieldId; - get(key: YjsDatabaseKey.type | YjsDatabaseKey.condition | YjsDatabaseKey.content | YjsDatabaseKey.filter_type): string; + get (key: YjsDatabaseKey.type | YjsDatabaseKey.condition | YjsDatabaseKey.content | YjsDatabaseKey.filter_type): string; } export interface YDatabaseCalculation extends Y.Map { - get(key: YjsDatabaseKey.field_id): FieldId; + get (key: YjsDatabaseKey.field_id): FieldId; - get(key: YjsDatabaseKey.id | YjsDatabaseKey.type | YjsDatabaseKey.calculation_value): string; + get (key: YjsDatabaseKey.id | YjsDatabaseKey.type | YjsDatabaseKey.calculation_value): string; } export interface YDatabaseFieldSettings extends Y.Map { - get(key: FieldId): YDatabaseFieldSetting; + get (key: FieldId): YDatabaseFieldSetting; } export interface YDatabaseFieldSetting extends Y.Map { - get(key: YjsDatabaseKey.visibility): string; + get (key: YjsDatabaseKey.visibility): string; - get(key: YjsDatabaseKey.wrap): boolean; + get (key: YjsDatabaseKey.wrap): boolean; // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsDatabaseKey.width): string; + get (key: YjsDatabaseKey.width): string; } export interface YDatabaseMetas extends Y.Map { - get(key: YjsDatabaseKey.iid): string; + get (key: YjsDatabaseKey.iid): string; } export interface YDatabaseFields extends Y.Map { - get(key: FieldId): YDatabaseField; + get (key: FieldId): YDatabaseField; } export interface YDatabaseField extends Y.Map { - get(key: YjsDatabaseKey.name): string; + get (key: YjsDatabaseKey.name): string; - get(key: YjsDatabaseKey.id): FieldId; + get (key: YjsDatabaseKey.id): FieldId; // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsDatabaseKey.type): string; + get (key: YjsDatabaseKey.type): string; - get(key: YjsDatabaseKey.type_option): YDatabaseFieldTypeOption; + get (key: YjsDatabaseKey.type_option): YDatabaseFieldTypeOption; - get(key: YjsDatabaseKey.is_primary): boolean; + get (key: YjsDatabaseKey.is_primary): boolean; - get(key: YjsDatabaseKey.last_modified): LastModified; + get (key: YjsDatabaseKey.last_modified): LastModified; } export interface YDatabaseFieldTypeOption extends Y.Map { // key is the field type - get(key: string): YMapFieldTypeOption; + get (key: string): YMapFieldTypeOption; } export interface YMapFieldTypeOption extends Y.Map { - get(key: YjsDatabaseKey.content): string; + get (key: YjsDatabaseKey.content): string; // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsDatabaseKey.data): string; + get (key: YjsDatabaseKey.data): string; // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsDatabaseKey.time_format): string; + get (key: YjsDatabaseKey.time_format): string; // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsDatabaseKey.date_format): string; + get (key: YjsDatabaseKey.date_format): string; - get(key: YjsDatabaseKey.database_id): DatabaseId; + get (key: YjsDatabaseKey.database_id): DatabaseId; // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsDatabaseKey.format): string; + get (key: YjsDatabaseKey.format): string; } export enum CollabType { diff --git a/frontend/appflowy_web_app/src/application/comment.type.ts b/frontend/appflowy_web_app/src/application/comment.type.ts new file mode 100644 index 0000000000000..b4c81e40a1902 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/comment.type.ts @@ -0,0 +1,22 @@ +export interface CommentUser { + uuid: string; + name: string; + avatarUrl: string | null; +} + +export interface GlobalComment { + commentId: string; + user: CommentUser | null; + content: string; + createdAt: string; + lastUpdatedAt: string; + replyCommentId: string | null; + isDeleted: boolean; + canDeleted: boolean; +} + +export interface Reaction { + reactionType: string; + reactUsers: CommentUser[]; + commentId: string; +} diff --git a/frontend/appflowy_web_app/src/application/publish/context.tsx b/frontend/appflowy_web_app/src/application/publish/context.tsx index 6463f00b25f38..f75dda3b975f3 100644 --- a/frontend/appflowy_web_app/src/application/publish/context.tsx +++ b/frontend/appflowy_web_app/src/application/publish/context.tsx @@ -1,7 +1,10 @@ import { GetViewRowsMap, LoadView, LoadViewMeta } from '@/application/collab.type'; import { db } from '@/application/db'; import { ViewMeta } from '@/application/db/tables/view_metas'; -import { AFConfigContext } from '@/components/app/AppConfig'; +import { View } from '@/application/types'; +import { useService } from '@/components/app/app.hooks'; +import { notify } from '@/components/_shared/notify'; +import { findView } from '@/components/publish/header/utils'; import { useLiveQuery } from 'dexie-react-hooks'; import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -9,12 +12,13 @@ import { useNavigate } from 'react-router-dom'; export interface PublishContextType { namespace: string; publishName: string; + isTemplateThumb?: boolean; viewMeta?: ViewMeta; toView: (viewId: string) => Promise; loadViewMeta: LoadViewMeta; getViewRowsMap?: GetViewRowsMap; - loadView: LoadView; + outline?: View; } export const PublishContext = createContext(null); @@ -23,16 +27,16 @@ export const PublishProvider = ({ children, namespace, publishName, + isTemplateThumb, }: { children: React.ReactNode; namespace: string; publishName: string; + isTemplateThumb?: boolean; }) => { - const viewMeta = useLiveQuery(async () => { - const name = `${namespace}_${publishName}`; - return db.view_metas.get(name); - }, [namespace, publishName]); + const [outline, setOutline] = useState(); + const [subscribers, setSubscribers] = useState void>>(new Map()); useEffect(() => { @@ -40,6 +44,20 @@ export const PublishProvider = ({ setSubscribers(new Map()); }; }, []); + + const viewMeta = useLiveQuery(async () => { + const name = `${namespace}_${publishName}`; + + const view = await db.view_metas.get(name); + + if (!view) return; + + return { + ...view, + name: findView(outline?.children || [], view.view_id)?.name || view.name, + }; + }, [namespace, publishName, outline]); + useEffect(() => { db.view_metas.hook('creating', (primaryKey, obj) => { const subscriber = subscribers.get(primaryKey); @@ -69,7 +87,8 @@ export const PublishProvider = ({ const prevViewMeta = useRef(viewMeta); - const service = useContext(AFConfigContext)?.service; + const service = useService(); + const navigate = useNavigate(); const toView = useCallback( async (viewId: string) => { @@ -77,19 +96,38 @@ export const PublishProvider = ({ const res = await service?.getPublishInfo(viewId); if (!res) { - throw new Error('Not found'); + throw new Error('View has not been published yet'); } - const { namespace, publishName } = res; + const { namespace: viewNamespace, publishName } = res; - navigate(`/${namespace}/${publishName}`); + prevViewMeta.current = undefined; + navigate(`/${viewNamespace}/${publishName}`, { + replace: true, + }); + return; } catch (e) { return Promise.reject(e); } }, - [navigate, service] + [navigate, service], ); + const loadOutline = useCallback(async () => { + if (!service || !namespace) return; + try { + const res = await service?.getPublishOutline(namespace); + + if (!res) { + throw new Error('Publish outline not found'); + } + + setOutline(res); + } catch (e) { + notify.error('Publish outline not found'); + } + }, [namespace, service]); + const loadViewMeta = useCallback( async (viewId: string, callback?: (meta: ViewMeta) => void) => { try { @@ -124,7 +162,7 @@ export const PublishProvider = ({ return Promise.reject(e); } }, - [service] + [service], ); const getViewRowsMap = useCallback( @@ -148,7 +186,7 @@ export const PublishProvider = ({ return Promise.reject(e); } }, - [service] + [service], ); const loadView = useCallback( @@ -173,7 +211,7 @@ export const PublishProvider = ({ return Promise.reject(e); } }, - [service] + [service], ); useEffect(() => { @@ -185,6 +223,10 @@ export const PublishProvider = ({ prevViewMeta.current = viewMeta; }, [viewMeta]); + useEffect(() => { + void loadOutline(); + }, [loadOutline]); + return ( {children} @@ -202,6 +246,6 @@ export const PublishProvider = ({ ); }; -export function usePublishContext() { +export function usePublishContext () { return useContext(PublishContext); } diff --git a/frontend/appflowy_web_app/src/application/services/js-services/__tests__/fetch.test.ts b/frontend/appflowy_web_app/src/application/services/js-services/__tests__/fetch.test.ts index 575efef1599e4..b80434a93d9d7 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/__tests__/fetch.test.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/__tests__/fetch.test.ts @@ -1,8 +1,8 @@ import { expect } from '@jest/globals'; import { fetchPublishView, fetchPublishViewMeta, fetchViewInfo } from '../fetch'; -import { APIService } from '@/application/services/js-services/wasm'; +import { APIService } from '@/application/services/js-services/http'; -jest.mock('@/application/services/js-services/wasm', () => { +jest.mock('@/application/services/js-services/http', () => { return { APIService: { getPublishView: jest.fn(), diff --git a/frontend/appflowy_web_app/src/application/services/js-services/__tests__/index.test.ts b/frontend/appflowy_web_app/src/application/services/js-services/__tests__/index.test.ts index be4341be4b506..9bab9c935216f 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/__tests__/index.test.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/__tests__/index.test.ts @@ -5,7 +5,7 @@ import { fetchViewInfo } from '@/application/services/js-services/fetch'; import { expect, jest } from '@jest/globals'; import { getPublishView, getPublishViewMeta } from '@/application/services/js-services/cache'; -jest.mock('@/application/services/js-services/wasm/client_api', () => { +jest.mock('@/application/services/js-services/http/http_api', () => { return { initAPIService: jest.fn(), }; diff --git a/frontend/appflowy_web_app/src/application/services/js-services/cache/index.ts b/frontend/appflowy_web_app/src/application/services/js-services/cache/index.ts index 712e903c6b0ae..2c8c39f57ade9 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/cache/index.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/cache/index.ts @@ -15,7 +15,7 @@ import { Fetcher, StrategyType } from '@/application/services/js-services/cache/ // import { IndexeddbPersistence } from 'y-indexeddb'; import * as Y from 'yjs'; -export function collabTypeToDBType(type: CollabType) { +export function collabTypeToDBType (type: CollabType) { switch (type) { case CollabType.Folder: return 'folder'; @@ -44,7 +44,7 @@ const collabSharedRootKeyMap = { [CollabType.Empty]: YjsEditorKey.empty, }; -export function hasCollabCache(doc: YDoc) { +export function hasCollabCache (doc: YDoc) { const data = doc.getMap(YjsEditorKey.data_section) as YSharedRoot; return Object.values(collabSharedRootKeyMap).some((key) => { @@ -52,7 +52,7 @@ export function hasCollabCache(doc: YDoc) { }); } -export async function hasViewMetaCache(name: string) { +export async function hasViewMetaCache (name: string) { const data = await db.view_metas.get(name); return !!data; @@ -64,7 +64,7 @@ export async function getPublishViewMeta< child_views: PublishViewInfo[]; ancestor_views: PublishViewInfo[]; } ->( +> ( fetcher: Fetcher, { namespace, @@ -73,7 +73,7 @@ export async function getPublishViewMeta< namespace: string; publishName: string; }, - strategy: StrategyType = StrategyType.CACHE_AND_NETWORK + strategy: StrategyType = StrategyType.CACHE_AND_NETWORK, ) { const name = `${namespace}_${publishName}`; const exist = await hasViewMetaCache(name); @@ -114,7 +114,7 @@ export async function getPublishViewMeta< export async function getPublishView< T extends { - data: number[]; + data: Uint8Array; rows?: Record; visibleViewIds?: ViewId[]; relations?: Record; @@ -124,7 +124,7 @@ export async function getPublishView< ancestor_views: PublishViewInfo[]; }; } ->( +> ( fetcher: Fetcher, { namespace, @@ -133,7 +133,7 @@ export async function getPublishView< namespace: string; publishName: string; }, - strategy: StrategyType = StrategyType.CACHE_AND_NETWORK + strategy: StrategyType = StrategyType.CACHE_AND_NETWORK, ) { const name = `${namespace}_${publishName}`; const doc = await openCollabDB(name); @@ -197,7 +197,7 @@ export async function revalidatePublishViewMeta< child_views: PublishViewInfo[]; ancestor_views: PublishViewInfo[]; } ->(name: string, fetcher: Fetcher) { +> (name: string, fetcher: Fetcher) { const { view, child_views, ancestor_views } = await fetcher(); const dbView = await db.view_metas.get(name); @@ -211,7 +211,7 @@ export async function revalidatePublishViewMeta< visible_view_ids: dbView?.visible_view_ids ?? [], database_relations: dbView?.database_relations ?? {}, }, - name + name, ); return db.view_metas.get(name); @@ -219,13 +219,13 @@ export async function revalidatePublishViewMeta< export async function revalidatePublishView< T extends { - data: number[]; + data: Uint8Array; rows?: Record; visibleViewIds?: ViewId[]; relations?: Record; meta: PublishViewMetaData; } ->(name: string, fetcher: Fetcher, collab: YDoc, rowMapDoc: Y.Doc) { +> (name: string, fetcher: Fetcher, collab: YDoc, rowMapDoc: Y.Doc) { const { data, meta, rows, visibleViewIds = [], relations = {} } = await fetcher(); await db.view_metas.put( @@ -237,7 +237,7 @@ export async function revalidatePublishView< visible_view_ids: visibleViewIds, database_relations: relations, }, - name + name, ); if (rows) { @@ -258,20 +258,16 @@ export async function revalidatePublishView< // rowMapDoc.getMap().set(subdoc.guid, subdoc); // }); } - - console.log('rows', rows); } - const state = new Uint8Array(data); - - applyYDoc(collab, state); + applyYDoc(collab, data); } -export async function deleteViewMeta(name: string) { +export async function deleteViewMeta (name: string) { await db.view_metas.delete(name); } -export async function deleteView(name: string) { +export async function deleteView (name: string) { console.log('deleteView', name); await deleteViewMeta(name); await closeCollabDB(name); diff --git a/frontend/appflowy_web_app/src/application/services/js-services/fetch.ts b/frontend/appflowy_web_app/src/application/services/js-services/fetch.ts index 3f9ca9873b437..4725195ead086 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/fetch.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/fetch.ts @@ -1,4 +1,4 @@ -import { APIService } from '@/application/services/js-services/wasm'; +import { APIService } from '@/application/services/js-services/http'; const pendingRequests = new Map(); diff --git a/frontend/appflowy_web_app/src/application/services/js-services/http/gotrue.ts b/frontend/appflowy_web_app/src/application/services/js-services/http/gotrue.ts new file mode 100644 index 0000000000000..4668af7d906d8 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/services/js-services/http/gotrue.ts @@ -0,0 +1,103 @@ +import { refreshToken as refreshSessionToken } from '@/application/session/token'; +import axios, { AxiosInstance } from 'axios'; + +let axiosInstance: AxiosInstance | null = null; + +export function initGrantService (baseURL: string) { + if (axiosInstance) { + return; + } + + axiosInstance = axios.create({ + baseURL, + }); + + axiosInstance.interceptors.request.use((config) => { + Object.assign(config.headers, { + 'Content-Type': 'application/json', + }); + + return config; + }); +} + +export async function refreshToken (refresh_token: string) { + const response = await axiosInstance?.post<{ + access_token: string; + expires_at: number; + refresh_token: string; + }>('/token?grant_type=refresh_token', { + refresh_token, + }); + + const newToken = response?.data; + + if (newToken) { + refreshSessionToken(JSON.stringify(newToken)); + } + + return newToken; +} + +export async function signInWithMagicLink (email: string, authUrl: string) { + const res = await axiosInstance?.post( + '/magiclink', + { + code_challenge: '', + code_challenge_method: '', + data: {}, + email, + }, + { + headers: { + Redirect_to: authUrl, + }, + }, + ); + + return res?.data; +} + +export async function settings () { + const res = await axiosInstance?.get('/settings'); + + return res?.data; +} + +export function signInGoogle (authUrl: string) { + const provider = 'google'; + const redirectTo = encodeURIComponent(authUrl); + const accessType = 'offline'; + const prompt = 'consent'; + const baseURL = axiosInstance?.defaults.baseURL; + const url = `${baseURL}/authorize?provider=${provider}&redirect_to=${redirectTo}&access_type=${accessType}&prompt=${prompt}`; + + window.open(url, '_current'); +} + +export function signInApple (authUrl: string) { + const provider = 'apple'; + const redirectTo = encodeURIComponent(authUrl); + const baseURL = axiosInstance?.defaults.baseURL; + const url = `${baseURL}/authorize?provider=${provider}&redirect_to=${redirectTo}`; + + window.open(url, '_current'); +} + +export function signInGithub (authUrl: string) { + const provider = 'github'; + const redirectTo = encodeURIComponent(authUrl); + const baseURL = axiosInstance?.defaults.baseURL; + const url = `${baseURL}/authorize?provider=${provider}&redirect_to=${redirectTo}`; + + window.open(url, '_current'); +} + +export function signInDiscord (authUrl: string) { + const provider = 'discord'; + const redirectTo = encodeURIComponent(authUrl); + const baseURL = axiosInstance?.defaults.baseURL; + const url = `${baseURL}/authorize?provider=${provider}&redirect_to=${redirectTo}`; + + window.open(url, '_current'); +} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/http/http_api.ts b/frontend/appflowy_web_app/src/application/services/js-services/http/http_api.ts new file mode 100644 index 0000000000000..cfeda5e915b72 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/services/js-services/http/http_api.ts @@ -0,0 +1,756 @@ +import { DatabaseId, RowId, ViewId, ViewLayout } from '@/application/collab.type'; +import { GlobalComment, Reaction } from '@/application/comment.type'; +import { initGrantService, refreshToken } from '@/application/services/js-services/http/gotrue'; +import { blobToBytes } from '@/application/services/js-services/http/utils'; +import { AFCloudConfig } from '@/application/services/services.type'; +import { getTokenParsed, invalidToken } from '@/application/session/token'; +import { + Template, + TemplateCategory, + TemplateCategoryFormValues, + TemplateCreator, TemplateCreatorFormValues, TemplateSummary, + UploadTemplatePayload, +} from '@/application/template.type'; +import { FolderView, User, View, Workspace } from '@/application/types'; +import axios, { AxiosInstance } from 'axios'; +import dayjs from 'dayjs'; + +export * from './gotrue'; + +let axiosInstance: AxiosInstance | null = null; + +export function initAPIService (config: AFCloudConfig) { + if (axiosInstance) { + return; + } + + axiosInstance = axios.create({ + baseURL: config.baseURL, + headers: { + 'Content-Type': 'application/json', + }, + }); + + initGrantService(config.gotrueURL); + + axiosInstance.interceptors.request.use( + async (config) => { + const token = getTokenParsed(); + + if (!token) { + return config; + } + + const isExpired = dayjs().isAfter(dayjs.unix(token.expires_at)); + + let access_token = token.access_token; + const refresh_token = token.refresh_token; + + if (isExpired) { + const newToken = await refreshToken(refresh_token); + + access_token = newToken?.access_token || ''; + } + + if (access_token) { + Object.assign(config.headers, { + Authorization: `Bearer ${access_token}`, + }); + } + + return config; + }, + (error) => { + return Promise.reject(error); + }, + ); + + axiosInstance.interceptors.response.use(async (response) => { + const status = response.status; + + if (status === 401) { + const token = getTokenParsed(); + + if (!token) { + invalidToken(); + return response; + } + + const refresh_token = token.refresh_token; + + try { + await refreshToken(refresh_token); + } catch (e) { + invalidToken(); + } + } + + return response; + }); +} + +export async function signInWithUrl (url: string) { + const hash = new URL(url).hash; + + if (!hash) { + return Promise.reject('No hash found'); + } + + const params = new URLSearchParams(hash.slice(1)); + const refresh_token = params.get('refresh_token'); + + if (!refresh_token) { + return Promise.reject('No access_token found'); + } + + await refreshToken(refresh_token); +} + +export async function verifyToken (accessToken: string) { + const url = `/api/user/verify/${accessToken}`; + const response = await axiosInstance?.get<{ + code: number; + data?: { + is_new: boolean; + }; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data; + } + + return Promise.reject(data); +} + +export async function getCurrentUser (): Promise { + const url = '/api/user/profile'; + const response = await axiosInstance?.get<{ + code: number; + data?: { + uid: number; + uuid: string; + email: string; + name: string; + metadata: { + icon_url: string; + }; + encryption_sign: null; + latest_workspace_id: string; + updated_at: number; + }; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + const { uid, uuid, email, name, metadata } = data.data; + + return { + uid: String(uid), + uuid, + email, + name, + avatar: metadata.icon_url, + }; + } + + return Promise.reject(data); +} + +export async function getPublishViewMeta (namespace: string, publishName: string) { + const url = `/api/workspace/published/${namespace}/${publishName}`; + const response = await axiosInstance?.get(url); + + return response?.data; +} + +export async function getPublishViewBlob (namespace: string, publishName: string) { + const url = `/api/workspace/published/${namespace}/${publishName}/blob`; + const response = await axiosInstance?.get(url, { + responseType: 'blob', + }); + + return blobToBytes(response?.data); +} + +export async function getPublishView (publishNamespace: string, publishName: string) { + const meta = await getPublishViewMeta(publishNamespace, publishName); + const blob = await getPublishViewBlob(publishNamespace, publishName); + + if (meta.view.layout === ViewLayout.Document) { + return { + data: blob, + meta, + }; + } + + try { + const decoder = new TextDecoder('utf-8'); + + const jsonStr = decoder.decode(blob); + + const res = JSON.parse(jsonStr) as { + database_collab: Uint8Array; + database_row_collabs: Record; + database_row_document_collabs: Record; + visible_database_view_ids: ViewId[]; + database_relations: Record; + }; + + return { + data: new Uint8Array(res.database_collab), + rows: res.database_row_collabs, + visibleViewIds: res.visible_database_view_ids, + relations: res.database_relations, + meta, + }; + } catch (e) { + return Promise.reject(e); + } +} + +export async function getPublishInfoWithViewId (viewId: string) { + const url = `/api/workspace/published-info/${viewId}`; + const response = await axiosInstance?.get<{ + code: number; + data?: { + namespace: string; + publish_name: string; + }; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data; + } + + return Promise.reject(data); +} + +export async function getPublishOutline (publishNamespace: string) { + const url = `/api/workspace/published-outline/${publishNamespace}`; + const response = await axiosInstance?.get<{ + code: number; + data?: View; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data; + } + + return Promise.reject(data); +} + +export async function getPublishViewComments (viewId: string): Promise { + const url = `/api/workspace/published-info/${viewId}/comment`; + const response = await axiosInstance?.get<{ + code: number; + data?: { + comments: { + comment_id: string; + user: { + uuid: string; + name: string; + avatar_url: string | null; + }; + content: string; + created_at: string; + last_updated_at: string; + reply_comment_id: string | null; + is_deleted: boolean; + can_be_deleted: boolean; + }[]; + }; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + const { comments } = data.data; + + return comments.map((comment) => { + return { + commentId: comment.comment_id, + user: { + uuid: comment.user?.uuid || '', + name: comment.user?.name || '', + avatarUrl: comment.user?.avatar_url || null, + }, + content: comment.content, + createdAt: comment.created_at, + lastUpdatedAt: comment.last_updated_at, + replyCommentId: comment.reply_comment_id, + isDeleted: comment.is_deleted, + canDeleted: comment.can_be_deleted, + }; + }); + } + + return Promise.reject(data); +} + +export async function getReactions (viewId: string, commentId?: string): Promise> { + let url = `/api/workspace/published-info/${viewId}/reaction`; + + if (commentId) { + url += `?comment_id=${commentId}`; + } + + const response = await axiosInstance?.get<{ + code: number; + data?: { + reactions: { + reaction_type: string; + react_users: { + uuid: string; + name: string; + avatar_url: string | null; + }[]; + comment_id: string; + }[]; + }; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + const { reactions } = data.data; + const reactionsMap: Record = {}; + + for (const reaction of reactions) { + if (!reactionsMap[reaction.comment_id]) { + reactionsMap[reaction.comment_id] = []; + } + + reactionsMap[reaction.comment_id].push({ + reactionType: reaction.reaction_type, + commentId: reaction.comment_id, + reactUsers: reaction.react_users.map((user) => ({ + uuid: user.uuid, + name: user.name, + avatarUrl: user.avatar_url, + })), + }); + } + + return reactionsMap; + } + + return Promise.reject(data); +} + +export async function createGlobalCommentOnPublishView (viewId: string, content: string, replyCommentId?: string) { + const url = `/api/workspace/published-info/${viewId}/comment`; + const response = await axiosInstance?.post<{ code: number; message: string }>(url, { + content, + reply_comment_id: replyCommentId, + }); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function deleteGlobalCommentOnPublishView (viewId: string, commentId: string) { + const url = `/api/workspace/published-info/${viewId}/comment`; + const response = await axiosInstance?.delete<{ code: number; message: string }>(url, { + data: { + comment_id: commentId, + }, + }); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function addReaction (viewId: string, commentId: string, reactionType: string) { + const url = `/api/workspace/published-info/${viewId}/reaction`; + const response = await axiosInstance?.post<{ code: number; message: string }>(url, { + comment_id: commentId, + reaction_type: reactionType, + }); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function removeReaction (viewId: string, commentId: string, reactionType: string) { + const url = `/api/workspace/published-info/${viewId}/reaction`; + const response = await axiosInstance?.delete<{ code: number; message: string }>(url, { + data: { + comment_id: commentId, + reaction_type: reactionType, + }, + }); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function getWorkspaces (): Promise { + const query = new URLSearchParams({ + include_member_count: 'true', + }); + + const url = `/api/workspace?${query.toString()}`; + const response = await axiosInstance?.get<{ + code: number; + data?: { + workspace_id: string; + workspace_name: string; + member_count: number; + icon: string; + }[]; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data.map((workspace) => { + return { + id: workspace.workspace_id, + name: workspace.workspace_name, + memberCount: workspace.member_count, + icon: workspace.icon, + }; + }); + } + + return Promise.reject(data); +} + +export interface WorkspaceFolder { + view_id: string; + icon: string | null; + name: string; + is_space: boolean; + is_private: boolean; + extra: { + is_space: boolean; + space_created_at: number; + space_icon: string; + space_icon_color: string; + space_permission: number; + }; + + children: WorkspaceFolder[]; +} + +function iterateFolder (folder: WorkspaceFolder): FolderView { + return { + id: folder.view_id, + name: folder.name, + icon: folder.icon, + isSpace: folder.is_space, + extra: folder.extra ? JSON.stringify(folder.extra) : null, + isPrivate: folder.is_private, + children: folder.children.map((child: WorkspaceFolder) => { + return iterateFolder(child); + }), + }; +} + +export async function getWorkspaceFolder (workspaceId: string): Promise { + const url = `/api/workspace/${workspaceId}/folder`; + const response = await axiosInstance?.get<{ + code: number; + data?: WorkspaceFolder; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return iterateFolder(data.data); + } + + return Promise.reject(data); +} + +export interface DuplicatePublishViewPayload { + published_collab_type: 0 | 1 | 2 | 3 | 4 | 5 | 6; + published_view_id: string; + dest_view_id: string; +} + +export async function duplicatePublishView (workspaceId: string, payload: DuplicatePublishViewPayload) { + const url = `/api/workspace/${workspaceId}/published-duplicate`; + + const res = await axiosInstance?.post<{ + code: number; + message: string; + }>(url, payload); + + if (res?.data.code === 0) { + return; + } + + return Promise.reject(res?.data.message); +} + +export async function createTemplate (template: UploadTemplatePayload) { + const url = '/api/template-center/template'; + const response = await axiosInstance?.post<{ + code: number; + message: string; + }>(url, template); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function updateTemplate (viewId: string, template: UploadTemplatePayload) { + const url = `/api/template-center/template/${viewId}`; + const response = await axiosInstance?.put<{ + code: number; + message: string; + }>(url, template); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function getTemplates ({ + categoryId, + nameContains, +}: { + categoryId?: string; + nameContains?: string; +}) { + const url = `/api/template-center/template`; + + const response = await axiosInstance?.get<{ + code: number; + data?: { + templates: TemplateSummary[]; + }; + message: string; + }>(url, { + params: { + category_id: categoryId, + name_contains: nameContains, + }, + }); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data.templates; + } + + return Promise.reject(data); +} + +export async function getTemplateById (viewId: string) { + const url = `/api/template-center/template/${viewId}`; + const response = await axiosInstance?.get<{ + code: number; + data?: Template; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data; + } + + return Promise.reject(data); +} + +export async function deleteTemplate (viewId: string) { + const url = `/api/template-center/template/${viewId}`; + const response = await axiosInstance?.delete<{ + code: number; + message: string; + }>(url); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function getTemplateCategories () { + const url = '/api/template-center/category'; + const response = await axiosInstance?.get<{ + code: number; + data?: { + categories: TemplateCategory[] + + }; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data.categories; + } + + return Promise.reject(data); +} + +export async function addTemplateCategory (category: TemplateCategoryFormValues) { + const url = '/api/template-center/category'; + const response = await axiosInstance?.post<{ + code: number; + message: string; + }>(url, category); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function updateTemplateCategory (id: string, category: TemplateCategoryFormValues) { + const url = `/api/template-center/category/${id}`; + const response = await axiosInstance?.put<{ + code: number; + message: string; + }>(url, category); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function deleteTemplateCategory (categoryId: string) { + const url = `/api/template-center/category/${categoryId}`; + const response = await axiosInstance?.delete<{ + code: number; + message: string; + }>(url); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function getTemplateCreators () { + const url = '/api/template-center/creator'; + const response = await axiosInstance?.get<{ + code: number; + data?: { + creators: TemplateCreator[]; + }; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data.creators; + } + + return Promise.reject(data); +} + +export async function createTemplateCreator (creator: TemplateCreatorFormValues) { + const url = '/api/template-center/creator'; + const response = await axiosInstance?.post<{ + code: number; + message: string; + }>(url, creator); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function updateTemplateCreator (creatorId: string, creator: TemplateCreatorFormValues) { + const url = `/api/template-center/creator/${creatorId}`; + const response = await axiosInstance?.put<{ + code: number; + message: string; + }>(url, creator); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function deleteTemplateCreator (creatorId: string) { + const url = `/api/template-center/creator/${creatorId}`; + const response = await axiosInstance?.delete<{ + code: number; + message: string; + }>(url); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function uploadFileToCDN (file: File) { + const url = '/api/template-center/avatar'; + const formData = new FormData(); + + console.log(file); + formData.append('avatar', file); + + const response = await axiosInstance?.request<{ + code: number; + data?: { + file_id: string; + }; + message: string; + }>({ + method: 'PUT', + url, + data: formData, + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return axiosInstance?.defaults.baseURL + '/api/template-center/avatar/' + data.data.file_id; + } + + return Promise.reject(data); +} \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/application/services/js-services/http/index.ts b/frontend/appflowy_web_app/src/application/services/js-services/http/index.ts new file mode 100644 index 0000000000000..e170c830a47be --- /dev/null +++ b/frontend/appflowy_web_app/src/application/services/js-services/http/index.ts @@ -0,0 +1 @@ +export * as APIService from './http_api'; diff --git a/frontend/appflowy_web_app/src/application/services/js-services/http/utils.ts b/frontend/appflowy_web_app/src/application/services/js-services/http/utils.ts new file mode 100644 index 0000000000000..aa197a7516105 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/services/js-services/http/utils.ts @@ -0,0 +1,17 @@ +export function blobToBytes (blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onloadend = () => { + if (!(reader.result instanceof ArrayBuffer)) { + reject(new Error('Failed to convert blob to bytes')); + return; + } + + resolve(new Uint8Array(reader.result)); + }; + + reader.onerror = reject; + reader.readAsArrayBuffer(blob); + }); +} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/index.ts b/frontend/appflowy_web_app/src/application/services/js-services/index.ts index 7ac7cbc25675b..268c5170add4a 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/index.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/index.ts @@ -1,4 +1,5 @@ import { YDoc } from '@/application/collab.type'; +import { GlobalComment, Reaction } from '@/application/comment.type'; import { deleteView, getPublishView, @@ -7,19 +8,19 @@ import { } from '@/application/services/js-services/cache'; import { StrategyType } from '@/application/services/js-services/cache/types'; import { fetchPublishView, fetchPublishViewMeta, fetchViewInfo } from '@/application/services/js-services/fetch'; -import { - initAPIService, - signInGoogle, - signInWithMagicLink, - signInGithub, - signInDiscord, - signInWithUrl, -} from '@/application/services/js-services/wasm/client_api'; +import { APIService } from '@/application/services/js-services/http'; + import { AFService, AFServiceConfig } from '@/application/services/services.type'; import { emit, EventType } from '@/application/session'; import { afterAuth, AUTH_CALLBACK_URL, withSignIn } from '@/application/session/sign_in'; +import { + TemplateCategoryFormValues, + TemplateCreatorFormValues, + UploadTemplatePayload, +} from '@/application/template.type'; import { nanoid } from 'nanoid'; import * as Y from 'yjs'; +import { DuplicatePublishView } from '@/application/types'; export class AFClientService implements AFService { private deviceId: string = nanoid(8); @@ -40,19 +41,15 @@ export class AFClientService implements AFService { private cacheDatabaseRowFolder: Map> = new Map(); - constructor(config: AFServiceConfig) { - initAPIService({ - ...config.cloudConfig, - deviceId: this.deviceId, - clientId: this.clientId, - }); + constructor (config: AFServiceConfig) { + APIService.initAPIService(config.cloudConfig); } - getClientId() { + getClientId () { return this.clientId; } - async getPublishViewMeta(namespace: string, publishName: string) { + async getPublishViewMeta (namespace: string, publishName: string) { const name = `${namespace}_${publishName}`; const isLoaded = this.publishViewLoaded.has(name); @@ -64,7 +61,7 @@ export class AFClientService implements AFService { namespace, publishName, }, - isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK + isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK, ); if (!viewMeta) { @@ -74,7 +71,7 @@ export class AFClientService implements AFService { return viewMeta; } - async getPublishView(namespace: string, publishName: string) { + async getPublishView (namespace: string, publishName: string) { const name = `${namespace}_${publishName}`; const isLoaded = this.publishViewLoaded.has(name); @@ -99,7 +96,7 @@ export class AFClientService implements AFService { namespace, publishName, }, - isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK + isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK, ); if (!isLoaded) { @@ -111,7 +108,7 @@ export class AFClientService implements AFService { return doc; } - async getPublishDatabaseViewRows(namespace: string, publishName: string) { + async getPublishDatabaseViewRows (namespace: string, publishName: string) { const name = `${namespace}_${publishName}`; if (!this.publishViewLoaded.has(name) || !this.cacheDatabaseRowDocMap.has(name)) { @@ -141,7 +138,7 @@ export class AFClientService implements AFService { }; } - async getPublishInfo(viewId: string) { + async getPublishInfo (viewId: string) { if (this.publishViewInfo.has(viewId)) { return this.publishViewInfo.get(viewId) as { namespace: string; @@ -167,10 +164,14 @@ export class AFClientService implements AFService { return data; } - async loginAuth(url: string) { + async getPublishOutline (namespace: string) { + return APIService.getPublishOutline(namespace); + } + + async loginAuth (url: string) { try { console.log('loginAuth', url); - await signInWithUrl(url); + await APIService.signInWithUrl(url); emit(EventType.SESSION_VALID); afterAuth(); return; @@ -181,22 +182,137 @@ export class AFClientService implements AFService { } @withSignIn() - async signInMagicLink({ email }: { email: string; redirectTo: string }) { - return await signInWithMagicLink(email, AUTH_CALLBACK_URL); + async signInMagicLink ({ email }: { email: string; redirectTo: string }) { + return await APIService.signInWithMagicLink(email, AUTH_CALLBACK_URL); + } + + @withSignIn() + async signInGoogle (_: { redirectTo: string }) { + return APIService.signInGoogle(AUTH_CALLBACK_URL); } @withSignIn() - async signInGoogle(_: { redirectTo: string }) { - return await signInGoogle(AUTH_CALLBACK_URL); + async signInApple (_: { redirectTo: string }) { + return APIService.signInApple(AUTH_CALLBACK_URL); } @withSignIn() - async signInGithub(_: { redirectTo: string }) { - return await signInGithub(AUTH_CALLBACK_URL); + async signInGithub (_: { redirectTo: string }) { + return APIService.signInGithub(AUTH_CALLBACK_URL); } @withSignIn() - async signInDiscord(_: { redirectTo: string }) { - return await signInDiscord(AUTH_CALLBACK_URL); + async signInDiscord (_: { redirectTo: string }) { + return APIService.signInDiscord(AUTH_CALLBACK_URL); + } + + async getWorkspaces () { + const data = APIService.getWorkspaces(); + + return data; + } + + async getWorkspaceFolder (workspaceId: string) { + const data = await APIService.getWorkspaceFolder(workspaceId); + + return data; } + + async getCurrentUser () { + const data = await APIService.getCurrentUser(); + + return data; + } + + async duplicatePublishView (params: DuplicatePublishView) { + return APIService.duplicatePublishView(params.workspaceId, { + dest_view_id: params.spaceViewId, + published_view_id: params.viewId, + published_collab_type: params.collabType, + }); + } + + createCommentOnPublishView (viewId: string, content: string, replyCommentId: string | undefined): Promise { + return APIService.createGlobalCommentOnPublishView(viewId, content, replyCommentId); + } + + deleteCommentOnPublishView (viewId: string, commentId: string): Promise { + return APIService.deleteGlobalCommentOnPublishView(viewId, commentId); + } + + getPublishViewGlobalComments (viewId: string): Promise { + return APIService.getPublishViewComments(viewId); + } + + getPublishViewReactions (viewId: string, commentId?: string): Promise> { + return APIService.getReactions(viewId, commentId); + } + + addPublishViewReaction (viewId: string, commentId: string, reactionType: string): Promise { + return APIService.addReaction(viewId, commentId, reactionType); + } + + removePublishViewReaction (viewId: string, commentId: string, reactionType: string): Promise { + return APIService.removeReaction(viewId, commentId, reactionType); + } + + async getTemplateCategories () { + return APIService.getTemplateCategories(); + } + + async getTemplateCreators () { + return APIService.getTemplateCreators(); + } + + async createTemplate (template: UploadTemplatePayload) { + return APIService.createTemplate(template); + } + + async updateTemplate (id: string, template: UploadTemplatePayload) { + return APIService.updateTemplate(id, template); + } + + async getTemplateById (id: string) { + return APIService.getTemplateById(id); + } + + async getTemplates (params: { + categoryId?: string; + nameContains?: string; + }) { + return APIService.getTemplates(params); + } + + async deleteTemplate (id: string) { + return APIService.deleteTemplate(id); + } + + async addTemplateCategory (category: TemplateCategoryFormValues) { + return APIService.addTemplateCategory(category); + } + + async updateTemplateCategory (categoryId: string, category: TemplateCategoryFormValues) { + return APIService.updateTemplateCategory(categoryId, category); + } + + async deleteTemplateCategory (categoryId: string) { + return APIService.deleteTemplateCategory(categoryId); + } + + async updateTemplateCreator (creatorId: string, creator: TemplateCreatorFormValues) { + return APIService.updateTemplateCreator(creatorId, creator); + } + + async createTemplateCreator (creator: TemplateCreatorFormValues) { + return APIService.createTemplateCreator(creator); + } + + async deleteTemplateCreator (creatorId: string) { + return APIService.deleteTemplateCreator(creatorId); + } + + async uploadFileToCDN (file: File) { + return APIService.uploadFileToCDN(file); + } + } 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 852559c3aac7b..0000000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/wasm/client_api.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { getToken, invalidToken, isTokenValid, refreshToken } from '@/application/session/token'; -import { ClientAPI } from '@appflowyinc/client-api-wasm'; -import { AFCloudConfig } from '@/application/services/services.type'; -import { DatabaseId, PublishViewMetaData, RowId, ViewId, ViewLayout } from '@/application/collab.type'; - -let client: ClientAPI; - -export function initAPIService( - config: AFCloudConfig & { - deviceId: string; - clientId: string; - } -) { - if (client) { - return; - } - - window.refresh_token = refreshToken; - - 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, - }, - }); - - if (isTokenValid()) { - client.restore_token(getToken() || ''); - } - - client.subscribe(); -} - -export async function getPublishView(publishNamespace: string, publishName: string) { - const data = await client.get_publish_view(publishNamespace, publishName); - - const meta = JSON.parse(data.meta.data) as PublishViewMetaData; - - if (meta.view.layout === ViewLayout.Document) { - return { - data: data.data, - meta, - }; - } - - try { - const decoder = new TextDecoder('utf-8'); - - const jsonStr = decoder.decode(new Uint8Array(data.data)); - - const res = JSON.parse(jsonStr) as { - database_collab: number[]; - database_row_collabs: Record; - database_row_document_collabs: Record; - visible_database_view_ids: ViewId[]; - database_relations: Record; - }; - - return { - data: res.database_collab, - rows: res.database_row_collabs, - visibleViewIds: res.visible_database_view_ids, - relations: res.database_relations, - meta, - }; - } catch (e) { - return Promise.reject(e); - } -} - -export async function getPublishInfoWithViewId(viewId: string) { - return client.get_publish_info(viewId); -} - -export async function getPublishViewMeta(publishNamespace: string, publishName: string) { - const data = await client.get_publish_view_meta(publishNamespace, publishName); - const metadata = JSON.parse(data.data) as PublishViewMetaData; - - return metadata; -} - -export async function signInWithUrl(url: string) { - return client.sign_in_with_url(url); -} - -export async function signInWithMagicLink(email: string, redirectTo: string) { - return client.sign_in_with_magic_link(email, redirectTo); -} - -export async function signInGoogle(redirectTo: string) { - return signInProvider('google', redirectTo); -} - -export async function signInProvider(provider: string, redirectTo: string) { - try { - const { url } = await client.generate_oauth_url_with_provider(provider, redirectTo); - - window.open(url, '_current'); - } catch (e) { - return Promise.reject(e); - } -} - -export async function signInGithub(redirectTo: string) { - return signInProvider('github', redirectTo); -} - -export async function signInDiscord(redirectTo: string) { - return signInProvider('discord', redirectTo); -} 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 b4f0b4f4ccb1a..0000000000000 --- 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 index 919cbf5306db7..435153e2c598a 100644 --- a/frontend/appflowy_web_app/src/application/services/services.type.ts +++ b/frontend/appflowy_web_app/src/application/services/services.type.ts @@ -1,6 +1,15 @@ import { YDoc } from '@/application/collab.type'; +import { GlobalComment, Reaction } from '@/application/comment.type'; import { ViewMeta } from '@/application/db/tables/view_metas'; +import { + Template, + TemplateCategory, + TemplateCategoryFormValues, + TemplateCreator, TemplateCreatorFormValues, TemplateSummary, + UploadTemplatePayload, +} from '@/application/template.type'; import * as Y from 'yjs'; +import { DuplicatePublishView, FolderView, User, View, Workspace } from '@/application/types'; export type AFService = PublishService; @@ -22,15 +31,48 @@ export interface PublishService { getPublishDatabaseViewRows: ( namespace: string, publishName: string, - rowIds?: string[] + rowIds?: string[], ) => Promise<{ rows: Y.Map; destroy: () => void; }>; + getPublishOutline (namespace: string): Promise; + + getPublishViewGlobalComments: (viewId: string) => Promise; + createCommentOnPublishView: (viewId: string, content: string, replyCommentId?: string) => Promise; + deleteCommentOnPublishView: (viewId: string, commentId: string) => Promise; + getPublishViewReactions: (viewId: string, commentId?: string) => Promise>; + addPublishViewReaction: (viewId: string, commentId: string, reactionType: string) => Promise; + removePublishViewReaction: (viewId: string, commentId: string, reactionType: string) => Promise; + loginAuth: (url: string) => Promise; signInMagicLink: (params: { email: string; redirectTo: string }) => Promise; signInGoogle: (params: { redirectTo: string }) => Promise; signInGithub: (params: { redirectTo: string }) => Promise; signInDiscord: (params: { redirectTo: string }) => Promise; + signInApple: (params: { redirectTo: string }) => Promise; + + getWorkspaces: () => Promise; + getWorkspaceFolder: (workspaceId: string) => Promise; + getCurrentUser: () => Promise; + duplicatePublishView: (params: DuplicatePublishView) => Promise; + + getTemplateCategories: () => Promise; + addTemplateCategory: (category: TemplateCategoryFormValues) => Promise; + deleteTemplateCategory: (categoryId: string) => Promise; + getTemplateCreators: () => Promise; + createTemplateCreator: (creator: TemplateCreatorFormValues) => Promise; + deleteTemplateCreator: (creatorId: string) => Promise; + getTemplateById: (id: string) => Promise
+ {divider && } + +
{ + window.open('https://appflowy.io', '_blank'); + }} + style={{ + width, + }} + className={ + 'flex w-full cursor-pointer gap-2 items-center justify-center py-4 text-sm text-text-title opacity-50' + } + > + Powered by + +
+
+ ); +} + +export default AppFlowyPower; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPicker.hooks.ts b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPicker.hooks.ts new file mode 100644 index 0000000000000..0dbc727a95bdd --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPicker.hooks.ts @@ -0,0 +1,177 @@ +import { notify } from '@/components/_shared/notify'; +import { loadEmojiData } from '@/utils/emoji'; +import { EmojiMartData } from '@emoji-mart/data'; +import { PopoverProps } from '@mui/material/Popover'; +import { PopoverOrigin } from '@mui/material/Popover/Popover'; +import chunk from 'lodash-es/chunk'; +import React, { useCallback, useEffect, useState } from 'react'; + +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 [loading, setLoading] = useState(false); + const [isEmpty, setIsEmpty] = useState(false); + const [emojiCategories, setEmojiCategories] = useState([]); + const [skin, setSkin] = useState(() => { + return Number(localStorage.getItem('emoji-mart.skin')) || 0; + }); + + const onSkinChange = useCallback((val: number) => { + setSkin(val); + localStorage.setItem('emoji-mart.skin', String(val)); + }, []); + + const searchEmojiData = useCallback( + async (searchVal?: string) => { + setLoading(true); + setIsEmpty(false); + try { + const emojiData = await loadEmojiData(); + 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); + + if (filteredCategories.length === 0) { + setIsEmpty(true); + } + + setEmojiCategories(filteredCategories); + } catch (_e) { + notify.error('Failed to load emoji data'); + setIsEmpty(true); + } + + setLoading(false); + }, + [skin], + ); + + useEffect(() => { + void (async () => { + await searchEmojiData(); + })(); + }, [searchEmojiData]); + + useEffect(() => { + void searchEmojiData(searchValue); + }, [searchEmojiData, searchValue]); + + const onSelect = useCallback( + async (native: string) => { + onEmojiSelect(native); + }, + [onEmojiSelect], + ); + + return { + emojiCategories, + setSearchValue, + searchValue, + onSelect, + onSkinChange, + skin, + loading, + isEmpty, + }; +} + +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: { + name: string; + keywords?: string[]; + }, + 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[]; + category?: string; + }[] = []; + + emojiCategories.forEach((category) => { + rows.push({ + id: category.id, + type: 'category', + }); + chunk(category.emojis, rowSize).forEach((chunk, index) => { + rows.push({ + category: category.id, + type: 'emojis', + emojis: chunk, + id: `${category.id}-${index}`, + }); + }); + }); + return rows; +} \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPicker.tsx b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPicker.tsx new file mode 100644 index 0000000000000..952a3a7b85461 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPicker.tsx @@ -0,0 +1,48 @@ +import CircularProgress from '@mui/material/CircularProgress'; +import React from 'react'; + +import { useLoadEmojiData } from './EmojiPicker.hooks'; +import EmojiPickerHeader from './EmojiPickerHeader'; +import EmojiPickerCategories from './EmojiPickerCategories'; +import emptyImageSrc from '@/assets/images/empty.png'; + +interface Props { + onEmojiSelect: (emoji: string) => void; + onEscape?: () => void; + defaultEmoji?: string; + hideRemove?: boolean; +} + +export function EmojiPicker ({ defaultEmoji, onEscape, ...props }: Props) { + const { skin, onSkinChange, emojiCategories, setSearchValue, searchValue, onSelect, loading, isEmpty } = + useLoadEmojiData(props); + + return ( +
+ + {loading ? ( +
+ +
+ ) : isEmpty ? ( + {'No + ) : ( + + )} +
+ ); +} + +export default EmojiPicker; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPickerCategories.tsx b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPickerCategories.tsx new file mode 100644 index 0000000000000..9b07222967f34 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPickerCategories.tsx @@ -0,0 +1,371 @@ +import { EMOJI_SIZE, PER_ROW_EMOJI_COUNT } from '@/components/_shared/emoji-picker/const'; +import { AFScroller } from '@/components/_shared/scroller'; +import { getDistanceEdge, inView } from '@/utils/position'; +import { Tooltip } from '@mui/material'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { FixedSizeList } from 'react-window'; +import { EmojiCategory, getRowsWithCategories } from './EmojiPicker.hooks'; + +function EmojiPickerCategories({ + emojiCategories, + onEmojiSelect, + onEscape, + defaultEmoji, +}: { + emojiCategories: EmojiCategory[]; + onEmojiSelect: (emoji: string) => void; + onEscape?: () => void; + defaultEmoji?: string; +}) { + const scrollRef = 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]; + const tagName = getCategoryName(item.id); + const isFlags = item.category === 'flags'; + + return ( +
+ {item.type === 'category' ? ( +
{tagName}
+ ) : null} +
+ {item.emojis?.map((emoji, columnIndex) => { + const isSelected = selectCell.row === index && selectCell.column === columnIndex; + + const isDefaultEmoji = defaultEmoji === emoji.native; + const classList = [ + 'flex cursor-pointer items-center justify-center rounded text-[20px] hover:bg-fill-list-hover', + ]; + + if (isSelected) { + classList.push('bg-fill-list-hover'); + } else { + classList.push('hover:bg-transparent'); + } + + if (isDefaultEmoji) { + classList.push('bg-fill-list-active'); + } + + if (isFlags) { + classList.push('icon'); + } + + 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={classList.join(' ')} + > + {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_web_app/src/components/_shared/emoji-picker/EmojiPickerHeader.tsx b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPickerHeader.tsx new file mode 100644 index 0000000000000..97d76cf7b55b4 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPickerHeader.tsx @@ -0,0 +1,156 @@ +import { useSelectSkinPopoverProps } from './EmojiPicker.hooks'; +import React, { useCallback } from 'react'; +import { Button, OutlinedInput } from '@mui/material'; + +import Tooltip from '@mui/material/Tooltip'; +import { randomEmoji } from '@/utils/emoji'; +import { ReactComponent as ShuffleIcon } from '@/assets/shuffle.svg'; +import Popover from '@mui/material/Popover'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as DeleteOutlineRounded } from '@/assets/trash.svg'; +import { ReactComponent as SearchOutlined } from '@/assets/search.svg'; + +const skinTones = [ + { + value: 0, + icon: '👋', + }, + { + value: 1, + icon: '👋🏻', + }, + { + value: 2, + icon: '👋🏼', + }, + { + value: 3, + icon: '👋🏽', + }, + { + value: 4, + icon: '👋🏾', + }, + { + value: 5, + icon: '👋🏿', + }, +]; + +interface Props { + onEmojiSelect: (emoji: string) => void; + skin: number; + onSkinSelect: (skin: number) => void; + searchValue: string; + onSearchChange: (value: string) => void; + hideRemove?: boolean; +} + +function EmojiPickerHeader({ hideRemove, onEmojiSelect, onSkinSelect, searchValue, onSearchChange, skin }: Props) { + const { onOpen, ...popoverProps } = useSelectSkinPopoverProps(); + const { t } = useTranslation(); + + const renderButton = useCallback( + ({ + onClick, + tooltip, + children, + }: { + onClick: (e: React.MouseEvent) => void; + tooltip: string; + children: React.ReactNode; + }) => { + return ( + + + + ); + }, + [] + ); + + return ( +
+
+ } + value={searchValue} + onChange={(e) => { + onSearchChange(e.target.value); + }} + autoFocus={true} + fullWidth={true} + size={'small'} + autoCorrect={'off'} + autoComplete={'off'} + spellCheck={false} + inputProps={{ + className: 'px-2 py-1.5 text-base', + }} + className={'search-emoji-input'} + placeholder={t('search.label')} + /> +
+ {renderButton({ + onClick: async () => { + const emoji = await randomEmoji(); + + onEmojiSelect(emoji); + }, + tooltip: t('emoji.random'), + children: , + })} + + {renderButton({ + onClick: onOpen, + tooltip: t('emoji.selectSkinTone'), + children: {skinTones[skin].icon}, + })} + + {hideRemove + ? null + : renderButton({ + onClick: () => { + onEmojiSelect(''); + }, + tooltip: t('emoji.remove'), + children: , + })} +
+
+ +
+ {skinTones.map((skinTone) => ( +
+ +
+ ))} +
+
+
+ ); +} + +export default EmojiPickerHeader; diff --git a/frontend/appflowy_web_app/src/components/_shared/emoji-picker/const.ts b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/const.ts new file mode 100644 index 0000000000000..151c93fa20e53 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/const.ts @@ -0,0 +1,3 @@ +export const EMOJI_SIZE = 38; +export const PER_ROW_EMOJI_COUNT = 9; +export const MAX_FREQUENTLY_ROW_COUNT = 2; diff --git a/frontend/appflowy_web_app/src/components/_shared/emoji-picker/index.ts b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/index.ts new file mode 100644 index 0000000000000..035bfce0b610f --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/index.ts @@ -0,0 +1,5 @@ +// import { lazy } from 'react'; +// +// export const EmojiPicker = lazy(() => import('./EmojiPicker')); + +export * from './EmojiPicker'; diff --git a/frontend/appflowy_web_app/src/components/_shared/file-dropzone/FileDropzone.tsx b/frontend/appflowy_web_app/src/components/_shared/file-dropzone/FileDropzone.tsx new file mode 100644 index 0000000000000..9e58f276d7b3f --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/file-dropzone/FileDropzone.tsx @@ -0,0 +1,100 @@ +import React, { useState, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ReactComponent as Inbox, +} from '@/assets/inbox.svg'; + +interface FileDropzoneProps { + onChange?: (files: File[]) => void; + accept?: string; + multiple?: boolean; +} + +function FileDropzone ({ + onChange, + accept, + multiple, +}: FileDropzoneProps) { + const { t } = useTranslation(); + const [dragging, setDragging] = useState(false); + const fileInputRef = useRef(null); + + const handleFiles = (files: FileList) => { + const fileArray = Array.from(files); + + if (onChange) { + if (!multiple && fileArray.length > 1) { + onChange(fileArray.slice(0, 1)); + } else { + onChange(fileArray); + + } + } + }; + + const handleDrop = (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setDragging(false); + + if (event.dataTransfer.files && event.dataTransfer.files.length > 0) { + handleFiles(event.dataTransfer.files); + event.dataTransfer.clearData(); + } + }; + + const handleDragOver = (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setDragging(true); + }; + + const handleDragLeave = (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setDragging(false); + }; + + const handleClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = (event: React.ChangeEvent) => { + if (event.target.files) { + handleFiles(event.target.files); + event.target.value = ''; + } + }; + + return ( +
+
+ +
+ {t('fileDropzone.dropFile')} +
+
+ +
+ ); + +} + +export default FileDropzone; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/_shared/gallery-preview/GalleryPreview.tsx b/frontend/appflowy_web_app/src/components/_shared/gallery-preview/GalleryPreview.tsx new file mode 100644 index 0000000000000..eb4a0c9af40aa --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/gallery-preview/GalleryPreview.tsx @@ -0,0 +1,184 @@ +import { notify } from '@/components/_shared/notify'; +import { copyTextToClipboard } from '@/utils/copy'; +import { IconButton, Portal, Tooltip } from '@mui/material'; +import React, { memo, useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'; +import { ReactComponent as RightIcon } from '@/assets/arrow_right.svg'; +import { ReactComponent as ReloadIcon } from '@/assets/reload.svg'; +import { ReactComponent as AddIcon } from '@/assets/add.svg'; +import { ReactComponent as MinusIcon } from '@/assets/minus.svg'; +import { ReactComponent as LinkIcon } from '@/assets/link.svg'; +import { ReactComponent as DownloadIcon } from '@/assets/download.svg'; +import { ReactComponent as CloseIcon } from '@/assets/close.svg'; + +export interface GalleryImage { + src: string; + thumb: string; + responsive: string; +} + +export interface GalleryPreviewProps { + images: GalleryImage[]; + open: boolean; + onClose: () => void; + previewIndex: number; +} + +const buttonClassName = 'p-1 hover:bg-transparent text-white hover:text-content-blue-400 p-0'; + +function GalleryPreview ({ + images, + open, + onClose, + previewIndex, +}: GalleryPreviewProps) { + const { t } = useTranslation(); + const [index, setIndex] = useState(previewIndex); + const handleToPrev = useCallback(() => { + setIndex((prev) => prev === 0 ? images.length - 1 : prev - 1); + }, [images.length]); + + const handleToNext = useCallback(() => { + setIndex((prev) => prev === images.length - 1 ? 0 : prev + 1); + }, [images.length]); + + const handleCopy = useCallback(async () => { + const image = images[index]; + + if (!image) { + return; + } + + await copyTextToClipboard(image.src); + notify.success(t('publish.copy.imageBlock')); + }, [images, index, t]); + + const handleDownload = useCallback(() => { + const image = images[index]; + + if (!image) { + return; + } + + window.open(image.src, '_blank'); + }, [images, index]); + + const handleKeydown = useCallback((e: KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + switch (true) { + case e.key === 'ArrowLeft': + case e.key === 'ArrowUp': + handleToPrev(); + break; + case e.key === 'ArrowRight': + case e.key === 'ArrowDown': + handleToNext(); + break; + case e.key === 'Escape': + onClose(); + break; + } + }, [handleToNext, handleToPrev, onClose]); + + useEffect(() => { + (document.activeElement as HTMLElement)?.blur(); + window.addEventListener('keydown', handleKeydown); + + return () => { + window.removeEventListener('keydown', handleKeydown); + }; + }, [handleKeydown]); + + if (!open) { + return null; + } + + return ( + +
+ + + {({ zoomIn, zoomOut, resetTransform }) => ( + +
e.stopPropagation()} + > + {images.length > 1 && +
+ + + + + + {index + 1}/{images.length} + + + + + +
} +
+ + zoomIn()} className={buttonClassName}> + + + + {/**/} + + zoomOut()} className={buttonClassName}> + + + + + resetTransform()} className={buttonClassName}> + + + +
+
+ + + + + + + + + + + +
+ + + + + +
+ e.stopPropagation(), + }} wrapperStyle={{ width: '100%', height: '100%' }} + > + {images[index].src} + +
+ )} +
+
+
+ ); +} + +export default memo(GalleryPreview); \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/_shared/gallery-preview/index.ts b/frontend/appflowy_web_app/src/components/_shared/gallery-preview/index.ts new file mode 100644 index 0000000000000..ca0290c05e8de --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/gallery-preview/index.ts @@ -0,0 +1,3 @@ +import { lazy } from 'react'; + +export const GalleryPreview = lazy(() => import('./GalleryPreview')); \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/_shared/gallery-preview/preview.scss b/frontend/appflowy_web_app/src/components/_shared/gallery-preview/preview.scss new file mode 100644 index 0000000000000..61d4aaa8037a0 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/gallery-preview/preview.scss @@ -0,0 +1,13 @@ +.gallery-preview { + .lg-outer .lg-thumb-item { + @apply rounded-[8px]; + img { + @apply rounded-[6px]; + } + } + + .lg-outer .lg-thumb-item.active, .lg-outer .lg-thumb-item:hover { + border: 2px solid var(--content-blue-400); + padding: 2px; + } +} \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/_shared/helmet/ViewHelmet.tsx b/frontend/appflowy_web_app/src/components/_shared/helmet/ViewHelmet.tsx new file mode 100644 index 0000000000000..bdf36f2627f5f --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/helmet/ViewHelmet.tsx @@ -0,0 +1,59 @@ +import { ViewIcon, ViewIconType } from '@/application/types'; +import React, { useEffect } from 'react'; +import { Helmet } from 'react-helmet'; + +function ViewHelmet ({ + name, + icon, +}: { + name?: string; + icon?: ViewIcon +}) { + + useEffect(() => { + const setFavicon = async () => { + try { + let url = '/appflowy.svg'; + + if (icon && icon.ty === ViewIconType.Emoji && icon.value) { + const emojiCode = icon?.value?.codePointAt(0)?.toString(16); // Convert emoji to hex code + const baseUrl = 'https://raw.githubusercontent.com/googlefonts/noto-emoji/main/svg/emoji_u'; + + const response = await fetch(`${baseUrl}${emojiCode}.svg`); + const svgText = await response.text(); + const blob = new Blob([svgText], { type: 'image/svg+xml' }); + + url = URL.createObjectURL(blob); + } + + const link = document.querySelector('link[rel*=\'icon\']') as HTMLLinkElement || document.createElement('link'); + + link.type = 'image/svg+xml'; + link.rel = 'icon'; + link.href = url; + document.getElementsByTagName('head')[0].appendChild(link); + } catch (error) { + console.error('Error setting favicon:', error); + } + }; + + void setFavicon(); + + return () => { + const link = document.querySelector('link[rel*=\'icon\']'); + + if (link) { + document.getElementsByTagName('head')[0].removeChild(link); + } + }; + }, [icon]); + + if (!name) return null; + return ( + + {name} | AppFlowy + + ); +} + +export default ViewHelmet; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/_shared/image-render/ImageRender.tsx b/frontend/appflowy_web_app/src/components/_shared/image-render/ImageRender.tsx new file mode 100644 index 0000000000000..4cd2aafa6f243 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/image-render/ImageRender.tsx @@ -0,0 +1,50 @@ +import { Skeleton } from '@mui/material'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as ErrorOutline } from '@/assets/error.svg'; + +interface ImageRenderProps extends React.HTMLAttributes { + src: string; + alt?: string; +} + +export function ImageRender({ src, ...props }: ImageRenderProps) { + const { t } = useTranslation(); + const [loading, setLoading] = useState(true); + const [hasError, setHasError] = useState(false); + + return ( + <> + {hasError ? ( +
+ +
{t('editor.imageLoadFailed')}
+
+ ) : loading ? ( + + ) : null} + { + props.onLoad?.(e); + setLoading(false); + setHasError(false); + }} + onError={(e) => { + props.onError?.(e); + setHasError(true); + setLoading(false); + }} + /> + + ); +} + +export default ImageRender; diff --git a/frontend/appflowy_web_app/src/components/_shared/katex-math/index.ts b/frontend/appflowy_web_app/src/components/_shared/katex-math/index.ts new file mode 100644 index 0000000000000..b9833620d9116 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/katex-math/index.ts @@ -0,0 +1,3 @@ +import { lazy } from 'react'; + +export const KatexMath = lazy(() => import('./KatexMath')); diff --git a/frontend/appflowy_web_app/src/components/_shared/modal/NormalModal.tsx b/frontend/appflowy_web_app/src/components/_shared/modal/NormalModal.tsx new file mode 100644 index 0000000000000..8b95d6eb89a52 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/modal/NormalModal.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, ButtonProps, CircularProgress, Dialog, DialogProps, IconButton } from '@mui/material'; +import { ReactComponent as CloseIcon } from '@/assets/close.svg'; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +export interface NormalModalProps extends DialogProps { + okText?: string; + cancelText?: string; + onOk?: () => void; + onCancel?: () => void; + danger?: boolean; + onClose?: () => void; + title: string | React.ReactNode; + okButtonProps?: ButtonProps; + cancelButtonProps?: ButtonProps; + okLoading?: boolean; +} + +export function NormalModal ({ + okText, + title, + cancelText, + onOk, + onCancel, + danger, + onClose, + children, + okButtonProps, + cancelButtonProps, + okLoading, + ...dialogProps +}: NormalModalProps) { + const { t } = useTranslation(); + const modalOkText = okText || t('button.ok'); + const modalCancelText = cancelText || t('button.cancel'); + + return ( + { + if (e.key === 'Escape') { + onClose?.(); + } + }} + {...dialogProps} + > +
+
+
{title}
+
+ + + +
+
+ +
{children}
+
+ + +
+
+
+ ); +} + +export default NormalModal; diff --git a/frontend/appflowy_web_app/src/components/_shared/modal/index.ts b/frontend/appflowy_web_app/src/components/_shared/modal/index.ts new file mode 100644 index 0000000000000..d6ea76cb2dd92 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/modal/index.ts @@ -0,0 +1 @@ +export * from './NormalModal'; diff --git a/frontend/appflowy_web_app/src/components/_shared/notify/InfoSnackbar.tsx b/frontend/appflowy_web_app/src/components/_shared/notify/InfoSnackbar.tsx new file mode 100644 index 0000000000000..bc43e60c45aba --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/notify/InfoSnackbar.tsx @@ -0,0 +1,122 @@ +import { notify } from '@/components/_shared/notify/index'; +import React, { forwardRef } from 'react'; +import { Button, IconButton, Paper } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as CloseIcon } from '@/assets/close.svg'; +import { CustomContentProps, SnackbarContent } from 'notistack'; +import { ReactComponent as CheckCircle } from '@/assets/check_circle.svg'; +import { ReactComponent as ErrorOutline } from '@/assets/error_outline.svg'; +import { ReactComponent as WarningAmber } from '@/assets/warning_amber.svg'; + +export interface InfoProps { + onOk?: () => void; + okText?: string; + title?: string; + message?: JSX.Element | string; + onClose?: () => void; + autoHideDuration?: number | null; + type?: 'success' | 'info' | 'warning' | 'error'; + showActions?: boolean; +} + +export type InfoSnackbarProps = InfoProps & CustomContentProps; + +const InfoSnackbar = forwardRef( + ({ showActions = true, type = 'info', onOk, okText, title, message, onClose }, ref) => { + const { t } = useTranslation(); + + const handleClose = () => { + onClose?.(); + notify.clear(); + }; + + return ( + + +
+
+ {getIcon(type)} +
{title}
+
+
+ + + +
+
+ +
{message}
+ {showActions && ( +
+ +
+ )} +
+
+ ); + } +); + +export default InfoSnackbar; + +function getIcon(type: 'success' | 'info' | 'warning' | 'error') { + switch (type) { + case 'success': + return ; + case 'info': + return ''; + case 'warning': + return ; + case 'error': + return ; + } +} + +function getButtonBgColor(type: 'success' | 'info' | 'warning' | 'error') { + switch (type) { + case 'success': + return 'bg-[var(--function-success)]'; + case 'info': + return ''; + case 'warning': + return 'bg-[var(--function-warning)]'; + case 'error': + return 'bg-[var(--function-error)]'; + } +} + +function getButtonHoverBgColor(type: 'success' | 'info' | 'warning' | 'error') { + switch (type) { + case 'success': + return 'hover:bg-[var(--function-success-hover)]'; + case 'info': + return ''; + case 'warning': + return 'hover:bg-[var(--function-warning-hover)]'; + case 'error': + return 'hover:bg-[var(--function-error-hover)]'; + } +} + +function getBorderColor(type: 'success' | 'info' | 'warning' | 'error') { + switch (type) { + case 'success': + return 'border-[var(--function-success)]'; + case 'info': + return 'border-transparent'; + case 'warning': + return 'border-[var(--function-warning)]'; + case 'error': + return 'border-[var(--function-error)]'; + } +} diff --git a/frontend/appflowy_web_app/src/components/_shared/notify/index.ts b/frontend/appflowy_web_app/src/components/_shared/notify/index.ts index 9b6e888f2e075..127d89dbacf9c 100644 --- a/frontend/appflowy_web_app/src/components/_shared/notify/index.ts +++ b/frontend/appflowy_web_app/src/components/_shared/notify/index.ts @@ -1,3 +1,8 @@ +import { InfoProps } from '@/components/_shared/notify/InfoSnackbar'; +import { lazy } from 'react'; + +export const InfoSnackbar = lazy(() => import('./InfoSnackbar')); + export const notify = { success: (message: string) => { window.toast.success(message); @@ -11,10 +16,19 @@ export const notify = { warning: (message: string) => { window.toast.warning(message); }, - info: (message: string) => { - window.toast.info(message); + info: (props: InfoProps) => { + window.toast.info({ + ...props, + variant: 'info', + anchorOrigin: { + vertical: 'bottom', + horizontal: 'center', + }, + }); }, clear: () => { window.toast.clear(); }, }; + +export * from './InfoSnackbar'; diff --git a/frontend/appflowy_web_app/src/components/_shared/popover/Popover.tsx b/frontend/appflowy_web_app/src/components/_shared/popover/Popover.tsx index f91ac8284e82d..90819ed57b13e 100644 --- a/frontend/appflowy_web_app/src/components/_shared/popover/Popover.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/popover/Popover.tsx @@ -17,3 +17,5 @@ export function Popover({ children, ...props }: PopoverComponentProps) { ); } + +export default Popover; diff --git a/frontend/appflowy_web_app/src/components/_shared/popover/RichTooltip.tsx b/frontend/appflowy_web_app/src/components/_shared/popover/RichTooltip.tsx index 437b08eaf5081..e67b8a388a607 100644 --- a/frontend/appflowy_web_app/src/components/_shared/popover/RichTooltip.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/popover/RichTooltip.tsx @@ -7,9 +7,13 @@ interface Props { open: boolean; onClose: () => void; placement?: PopperPlacementType; + PaperProps?: { + className?: string; + }; + } -export const RichTooltip = ({ placement = 'top', open, onClose, content, children }: Props) => { +export const RichTooltip = ({ placement = 'top', open, onClose, content, children, PaperProps }: Props) => { const [childNode, setChildNode] = React.useState(null); const [, setTransitioning] = React.useState(false); @@ -26,7 +30,7 @@ export const RichTooltip = ({ placement = 'top', open, onClose, content, childre anchorEl={childNode} placement={placement} transition - style={{ zIndex: 1200 }} + style={{ zIndex: 1500 }} modifiers={[ { name: 'flip', @@ -48,7 +52,8 @@ export const RichTooltip = ({ placement = 'top', open, onClose, content, childre > - + {content} diff --git a/frontend/appflowy_web_app/src/components/_shared/skeleton/BreadcrumbSkeleton.tsx b/frontend/appflowy_web_app/src/components/_shared/skeleton/BreadcrumbSkeleton.tsx new file mode 100644 index 0000000000000..fa61d8e7de73a --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/skeleton/BreadcrumbSkeleton.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { Skeleton, Box } from '@mui/material'; +import { ReactComponent as RightIcon } from '@/assets/arrow_right.svg'; + +export const BreadcrumbsSkeleton = () => { + return ( + + + + + + + + + + + ); +}; + +export default BreadcrumbsSkeleton; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/_shared/skeleton/DocumentSkeleton.tsx b/frontend/appflowy_web_app/src/components/_shared/skeleton/DocumentSkeleton.tsx new file mode 100644 index 0000000000000..3f0951c6de286 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/skeleton/DocumentSkeleton.tsx @@ -0,0 +1,17 @@ +import Skeleton from '@mui/material/Skeleton'; +import React from 'react'; + +function DocumentSkeleton () { + return ( +
+ +
+ ); +} + +export default DocumentSkeleton; diff --git a/frontend/appflowy_web_app/src/components/_shared/skeleton/OutlineSkeleton.tsx b/frontend/appflowy_web_app/src/components/_shared/skeleton/OutlineSkeleton.tsx new file mode 100644 index 0000000000000..dcf0f19e8bd59 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/skeleton/OutlineSkeleton.tsx @@ -0,0 +1,47 @@ +import { Box, Skeleton } from '@mui/material'; +import './skeleton.scss'; + +export const DirectoryStructure = () => { + return ( + +
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/_shared/skeleton/skeleton.scss b/frontend/appflowy_web_app/src/components/_shared/skeleton/skeleton.scss new file mode 100644 index 0000000000000..37e86ff1db9b2 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/skeleton/skeleton.scss @@ -0,0 +1,14 @@ +.directory-item { + display: flex; + align-items: center; + margin-bottom: 8px; + +} + +.directory-item .MuiSkeleton-root { + margin-right: 8px; +} + +.nested { + margin-left: 24px; +} \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/database/components/tabs/ViewTabs.tsx b/frontend/appflowy_web_app/src/components/_shared/tabs/ViewTabs.tsx similarity index 98% rename from frontend/appflowy_web_app/src/components/database/components/tabs/ViewTabs.tsx rename to frontend/appflowy_web_app/src/components/_shared/tabs/ViewTabs.tsx index a9c58e42c7ab8..64faab7949913 100644 --- a/frontend/appflowy_web_app/src/components/database/components/tabs/ViewTabs.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/tabs/ViewTabs.tsx @@ -12,7 +12,7 @@ export const ViewTabs = styled((props: TabsProps) => )({ export const ViewTab = styled((props: TabProps) => )({ padding: '0 12px', minHeight: '24px', - fontSize: '12px', + fontSize: '14px', minWidth: 'unset', margin: '4px 0', borderRadius: 0, diff --git a/frontend/appflowy_web_app/src/components/_shared/tag/Tag.tsx b/frontend/appflowy_web_app/src/components/_shared/tag/Tag.tsx index 6e379e645886c..d48b394d8b823 100644 --- a/frontend/appflowy_web_app/src/components/_shared/tag/Tag.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/tag/Tag.tsx @@ -1,20 +1,23 @@ import { FC, useMemo } from 'react'; +import { ReactComponent as CircleIcon } from '@/assets/bulleted_list_icon_1.svg'; export interface TagProps { color?: string; label?: string; size?: 'small' | 'medium'; + badge?: string; } -export const Tag: FC = ({ color, size = 'small', label }) => { +export const Tag: FC = ({ color, size = 'small', label, badge }) => { const className = useMemo(() => { - const classList = ['rounded-md', 'font-medium', 'leading-[18px]']; + const classList = ['rounded-[8px]', 'font-medium', 'leading-[1.5em]', 'flex items-center gap-1 max-w-full']; if (color) classList.push(`text-text-title`); - if (size === 'small') classList.push('px-2', 'py-[2px]'); + if (size === 'small') classList.push('px-2', 'py-1'); if (size === 'medium') classList.push('px-3', 'py-1'); + if (badge) classList.push('pr-4'); return classList.join(' '); - }, [color, size]); + }, [color, size, badge]); return (
= ({ color, size = 'small', label }) => { }} className={className} > - {label} + {badge && + + } +
{label}
+
); }; diff --git a/frontend/appflowy_web_app/src/components/app/App.tsx b/frontend/appflowy_web_app/src/components/app/App.tsx index 6ead293033624..3ad6d46fc649e 100644 --- a/frontend/appflowy_web_app/src/components/app/App.tsx +++ b/frontend/appflowy_web_app/src/components/app/App.tsx @@ -2,6 +2,7 @@ import { AUTH_CALLBACK_PATH } from '@/application/session/sign_in'; import NotFound from '@/components/error/NotFound'; import LoginAuth from '@/components/login/LoginAuth'; import AfterPaymentPage from '@/pages/AfterPaymentPage'; +import AsTemplatePage from '@/pages/AsTemplatePage'; import LoginPage from '@/pages/LoginPage'; import PublishPage from '@/pages/PublishPage'; import { BrowserRouter, Route, Routes } from 'react-router-dom'; @@ -16,6 +17,7 @@ const AppMain = withAppWrapper(() => { } /> } /> } /> + } /> } /> ); diff --git a/frontend/appflowy_web_app/src/components/app/AppConfig.tsx b/frontend/appflowy_web_app/src/components/app/AppConfig.tsx index b4ef0e60d49d6..d7895c0fe44a3 100644 --- a/frontend/appflowy_web_app/src/components/app/AppConfig.tsx +++ b/frontend/appflowy_web_app/src/components/app/AppConfig.tsx @@ -1,36 +1,29 @@ import { clearData } from '@/application/db'; +import { getService } from '@/application/services'; +import { AFService, AFServiceConfig } from '@/application/services/services.type'; import { EventType, on } from '@/application/session'; import { isTokenValid } from '@/application/session/token'; +import { User } from '@/application/types'; +import { InfoSnackbarProps } from '@/components/_shared/notify'; +import { AFConfigContext, defaultConfig } from '@/components/app/app.hooks'; import { useAppLanguage } from '@/components/app/useAppLanguage'; +import { LoginModal } from '@/components/login'; +import { createHotkey, HOT_KEY_NAME } from '@/utils/hotkeys'; import { useSnackbar } from 'notistack'; -import React, { createContext, useEffect, useState } from 'react'; -import { AFService, AFServiceConfig } from '@/application/services/services.type'; -import { getService } from '@/application/services'; +import React, { useCallback, useEffect, useState } from 'react'; -const baseURL = import.meta.env.AF_BASE_URL || 'https://test.appflowy.cloud'; -const gotrueURL = import.meta.env.AF_GOTRUE_URL || 'https://test.appflowy.cloud/gotrue'; -const wsURL = import.meta.env.AF_WS_URL || 'wss://test.appflowy.cloud/ws/v1'; - -const defaultConfig: AFServiceConfig = { - cloudConfig: { - baseURL, - gotrueURL, - wsURL, - }, -}; - -export const AFConfigContext = createContext< - | { - service: AFService | undefined; - isAuthenticated: boolean; - } - | undefined ->(undefined); - -function AppConfig({ children }: { children: React.ReactNode }) { +function AppConfig ({ children }: { children: React.ReactNode }) { const [appConfig] = useState(defaultConfig); const [service, setService] = useState(); const [isAuthenticated, setIsAuthenticated] = React.useState(isTokenValid()); + const [currentUser, setCurrentUser] = React.useState(); + const [loginOpen, setLoginOpen] = React.useState(false); + const [loginCompletedRedirectTo, setLoginCompletedRedirectTo] = React.useState(''); + + const openLoginModal = useCallback((redirectTo?: string) => { + setLoginOpen(true); + setLoginCompletedRedirectTo(redirectTo || ''); + }, []); useEffect(() => { return on(EventType.SESSION_VALID, () => { @@ -38,6 +31,24 @@ function AppConfig({ children }: { children: React.ReactNode }) { }); }, []); + useEffect(() => { + if (!isAuthenticated) { + setCurrentUser(undefined); + return; + } + + void (async () => { + if (!service) return; + try { + const user = await service.getCurrentUser(); + + setCurrentUser(user); + } catch (e) { + console.error(e); + } + })(); + }, [isAuthenticated, service]); + useEffect(() => { const handleStorageChange = (event: StorageEvent) => { if (event.key === 'token') setIsAuthenticated(isTokenValid()); @@ -70,7 +81,6 @@ function AppConfig({ children }: { children: React.ReactNode }) { enqueueSnackbar(message, { variant: 'success' }); }, error: (message: string) => { - console.log('error', message); enqueueSnackbar(message, { variant: 'error' }); }, warning: (message: string) => { @@ -79,8 +89,9 @@ function AppConfig({ children }: { children: React.ReactNode }) { default: (message: string) => { enqueueSnackbar(message, { variant: 'default' }); }, - info: (message: string) => { - enqueueSnackbar(message, { variant: 'info' }); + + info: (props: InfoSnackbarProps) => { + enqueueSnackbar(props.message, props); }, clear: () => { @@ -91,13 +102,18 @@ function AppConfig({ children }: { children: React.ReactNode }) { useEffect(() => { const handleClearData = (e: KeyboardEvent) => { - if (e.key.toLowerCase() === 'r' && (e.ctrlKey || e.metaKey) && e.shiftKey) { - e.stopPropagation(); - e.preventDefault(); - void clearData().then(() => { - window.location.reload(); - }); + switch (true) { + case createHotkey(HOT_KEY_NAME.CLEAR_CACHE)(e): + e.stopPropagation(); + e.preventDefault(); + void clearData().then(() => { + window.location.reload(); + }); + break; + default: + break; } + }; window.addEventListener('keydown', handleClearData); @@ -111,9 +127,20 @@ function AppConfig({ children }: { children: React.ReactNode }) { value={{ service, isAuthenticated, + currentUser, + openLoginModal, }} > {children} + {loginOpen && ( + { + setLoginOpen(false); + }} + /> + )} ); } diff --git a/frontend/appflowy_web_app/src/components/app/AppTheme.tsx b/frontend/appflowy_web_app/src/components/app/AppTheme.tsx index 329617b8ce078..4f2163cb6f9ba 100644 --- a/frontend/appflowy_web_app/src/components/app/AppTheme.tsx +++ b/frontend/appflowy_web_app/src/components/app/AppTheme.tsx @@ -7,7 +7,7 @@ import '@/i18n/config'; import 'src/styles/tailwind.css'; import 'src/styles/template.css'; -function AppTheme({ children }: { children: React.ReactNode }) { +function AppTheme ({ children }: { children: React.ReactNode; }) { const { isDark, setIsDark } = useAppThemeMode(); const theme = useMemo( @@ -15,7 +15,7 @@ function AppTheme({ children }: { children: React.ReactNode }) { createTheme({ typography: { fontFamily: ['inherit'].join(','), - fontSize: 12, + fontSize: 14, button: { textTransform: 'none', }, @@ -41,20 +41,47 @@ function AppTheme({ children }: { children: React.ReactNode }) { }, borderRadius: '4px', padding: '2px', + '&.MuiIconButton-colorInherit': { + color: 'var(--icon-primary)', + }, }, }, }, + MuiButton: { styleOverrides: { + text: { + borderRadius: '8px', + '&:hover': { + backgroundColor: 'var(--fill-list-hover)', + }, + }, contained: { color: 'var(--content-on-fill)', boxShadow: 'none', - '&:hover': { - backgroundColor: 'var(--content-blue-600)', + '&.MuiButton-containedPrimary': { + '&:hover': { + backgroundColor: 'var(--content-blue-600)', + }, + }, + + borderRadius: '8px', + '&.Mui-disabled': { + backgroundColor: 'var(--content-blue-400)', + opacity: 0.3, + color: 'var(--content-on-fill)', + }, + }, + outlined: { + '&.MuiButton-outlinedInherit': { + borderColor: 'var(--line-divider)', }, + borderRadius: '8px', }, + }, }, + MuiButtonBase: { styleOverrides: { root: { @@ -66,22 +93,32 @@ function AppTheme({ children }: { children: React.ReactNode }) { backgroundColor: 'var(--fill-list-hover)', }, }, + '&.MuiMenuItem-root': { + borderRadius: '8px', + }, borderRadius: '4px', padding: '2px', boxShadow: 'none !important', }, }, + }, MuiPaper: { styleOverrides: { root: { backgroundImage: 'none', boxShadow: 'var(--shadow)', + borderRadius: '10px', }, }, }, MuiDialog: { + styleOverrides: { + paper: { + borderRadius: '12px', + }, + }, defaultProps: { sx: { '& .MuiBackdrop-root': { @@ -112,6 +149,7 @@ function AppTheme({ children }: { children: React.ReactNode }) { color: 'var(--text-caption)', WebkitTextFillColor: 'var(--text-caption) !important', }, + borderRadius: '8px', }, }, styleOverrides: { @@ -162,7 +200,7 @@ function AppTheme({ children }: { children: React.ReactNode }) { }, }, }), - [isDark] + [isDark], ); return ( diff --git a/frontend/appflowy_web_app/src/components/app/app.hooks.ts b/frontend/appflowy_web_app/src/components/app/app.hooks.ts new file mode 100644 index 0000000000000..9653b82d002b2 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/app/app.hooks.ts @@ -0,0 +1,45 @@ +import { AFService, AFServiceConfig } from '@/application/services/services.type'; +import { User } from '@/application/types'; +import { createContext, useContext } from 'react'; + +const baseURL = import.meta.env.AF_BASE_URL || 'https://test.appflowy.cloud'; +const gotrueURL = import.meta.env.AF_GOTRUE_URL || 'https://test.appflowy.cloud/gotrue'; +const wsURL = import.meta.env.AF_WS_URL || 'wss://test.appflowy.cloud/ws/v1'; + +export const defaultConfig: AFServiceConfig = { + cloudConfig: { + baseURL, + gotrueURL, + wsURL, + }, +}; + +export const AFConfigContext = createContext< + | { + service: AFService | undefined; + isAuthenticated: boolean; + currentUser?: User; + openLoginModal: (redirectTo?: string) => void; +} + | undefined +>(undefined); + +export function useCurrentUser () { + const context = useContext(AFConfigContext); + + if (!context) { + throw new Error('useCurrentUser must be used within a AFConfigContext'); + } + + return context.currentUser; +} + +export function useService () { + const context = useContext(AFConfigContext); + + if (!context) { + throw new Error('useService must be used within a AFConfigContext'); + } + + return context.service; +} \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/app/useAppThemeMode.ts b/frontend/appflowy_web_app/src/components/app/useAppThemeMode.ts index cdd5eed890ca5..62b4af8c853d5 100644 --- a/frontend/appflowy_web_app/src/components/app/useAppThemeMode.ts +++ b/frontend/appflowy_web_app/src/components/app/useAppThemeMode.ts @@ -2,21 +2,32 @@ import { useEffect, useState, createContext } from 'react'; export const ThemeModeContext = createContext< | { - isDark: boolean; - setDark: (isDark: boolean) => void; - } + isDark: boolean; + setDark: (isDark: boolean) => void; +} | undefined >(undefined); -export function useAppThemeMode() { +export function useAppThemeMode () { + const fixedTheme = window.location.search.includes('theme') ? new URLSearchParams(window.location.search).get('theme') : null; const [isDark, setIsDark] = useState(() => { + if (fixedTheme === 'light') { + return false; + } + + if (fixedTheme === 'dark') { + return true; + } + const darkMode = localStorage.getItem('dark-mode'); return darkMode === 'true'; }); useEffect(() => { - function detectColorScheme() { + if (fixedTheme) return; + + function detectColorScheme () { const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); setIsDark(darkModeMediaQuery.matches); @@ -30,7 +41,7 @@ export function useAppThemeMode() { return () => { window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', detectColorScheme); }; - }, []); + }, [fixedTheme]); useEffect(() => { document.documentElement.setAttribute('data-dark-mode', isDark ? 'true' : 'false'); diff --git a/frontend/appflowy_web_app/src/components/app/withAppWrapper.tsx b/frontend/appflowy_web_app/src/components/app/withAppWrapper.tsx index 0fddafd2a518d..8aa93ee5eb0b4 100644 --- a/frontend/appflowy_web_app/src/components/app/withAppWrapper.tsx +++ b/frontend/appflowy_web_app/src/components/app/withAppWrapper.tsx @@ -5,31 +5,21 @@ import AppConfig from '@/components/app/AppConfig'; import { Suspense } from 'react'; import { SnackbarProvider } from 'notistack'; import { styled } from '@mui/material'; +import { InfoSnackbar } from '../_shared/notify'; const StyledSnackbarProvider = styled(SnackbarProvider)` - &.notistack-MuiContent-default { - background-color: var(--fill-toolbar); - } + &.notistack-MuiContent-default { + background-color: var(--fill-toolbar); + } - &.notistack-MuiContent-info { - background-color: var(--function-info); - } + &.notistack-MuiContent-info { + background-color: var(--function-info); + } - &.notistack-MuiContent-success { - background-color: var(--function-success); - } - - &.notistack-MuiContent-error { - background-color: var(--function-error); - } - - &.notistack-MuiContent-warning { - background-color: var(--function-warning); - } `; -export default function withAppWrapper(Component: React.FC): React.FC { - return function AppWrapper(): JSX.Element { +export default function withAppWrapper (Component: React.FC): React.FC { + return function AppWrapper (): JSX.Element { return ( @@ -39,6 +29,9 @@ export default function withAppWrapper(Component: React.FC): React.FC { horizontal: 'center', }} preventDuplicate + Components={{ + info: InfoSnackbar, + }} > diff --git a/frontend/appflowy_web_app/src/components/as-template/AsTemplate.tsx b/frontend/appflowy_web_app/src/components/as-template/AsTemplate.tsx new file mode 100644 index 0000000000000..fcb7ed7a0e22e --- /dev/null +++ b/frontend/appflowy_web_app/src/components/as-template/AsTemplate.tsx @@ -0,0 +1,191 @@ +import { UploadTemplatePayload } from '@/application/template.type'; +import { notify } from '@/components/_shared/notify'; +import { AFScroller } from '@/components/_shared/scroller'; +import { useService } from '@/components/app/app.hooks'; +import AsTemplateForm, { AsTemplateFormValue } from '@/components/as-template/AsTemplateForm'; +import Categories from '@/components/as-template/category/Categories'; +import Creator from '@/components/as-template/creator/Creator'; +import DeleteTemplate from '@/components/as-template/DeleteTemplate'; +import { useLoadTemplate } from '@/components/as-template/hooks'; +import { Button, CircularProgress, InputLabel, Paper, Switch } from '@mui/material'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as CloseIcon } from '@/assets/close.svg'; +import { ReactComponent as DeleteIcon } from '@/assets/trash.svg'; +import './template.scss'; +import { slugify } from '@/components/as-template/utils'; +import { ReactComponent as WebsiteIcon } from '@/assets/website.svg'; + +function AsTemplate ({ + viewName, + viewUrl, + viewId, +}: { + viewName: string; + viewUrl: string; + viewId: string; +}) { + const [selectedCategoryIds, setSelectedCategoryIds] = useState([]); + const [selectedCreatorId, setSelectedCreatorId] = useState(undefined); + const { t } = useTranslation(); + const [isNewTemplate, setIsNewTemplate] = React.useState(false); + const [isFeatured, setIsFeatured] = React.useState(false); + const [deleteModalOpen, setDeleteModalOpen] = React.useState(false); + const service = useService(); + const { + template, + loadTemplate, + loading, + } = useLoadTemplate(viewId); + + const handleBack = useCallback(() => { + window.location.href = `${decodeURIComponent(viewUrl)}`; + }, [viewUrl]); + const handleSubmit = useCallback(async (data: AsTemplateFormValue) => { + if (!service || !selectedCreatorId || selectedCategoryIds.length === 0) return; + const formData: UploadTemplatePayload = { + ...data, + view_id: viewId, + category_ids: selectedCategoryIds, + creator_id: selectedCreatorId, + is_new_template: isNewTemplate, + is_featured: isFeatured, + view_url: viewUrl, + }; + + try { + if (template) { + await service?.updateTemplate(template.view_id, formData); + } else { + await service?.createTemplate(formData); + + } + + await loadTemplate(); + handleBack(); + } catch (error) { + // eslint-disable-next-line + // @ts-ignore + notify.error(error.toString()); + } + + }, [service, selectedCreatorId, selectedCategoryIds, isNewTemplate, isFeatured, viewId, viewUrl, template, loadTemplate, handleBack]); + const submitRef = React.useRef(null); + + useEffect(() => { + void loadTemplate(); + }, [loadTemplate]); + + useEffect(() => { + if (!template) return; + setSelectedCategoryIds(template.categories.map((category) => category.id)); + setSelectedCreatorId(template.creator.id); + setIsNewTemplate(template.is_new_template); + setIsFeatured(template.is_featured); + }, [template]); + + const defaultValue = useMemo(() => { + if (!template) return { + name: viewName, + description: '', + about: '', + related_view_ids: [], + }; + + return { + name: template.name, + description: template.description, + about: template.about, + related_view_ids: template.related_templates?.map((related) => related.view_id) || [], + }; + }, [template, viewName]); + + return ( +
+
+ + {template && } +
+ {template && } + + +
+ +
+
+ + + {loading ? + : + + } + + +
+ + +
+ {t('template.isNewTemplate')} + setIsNewTemplate(!isNewTemplate)} + /> +
+
+ {t('template.featured')} + setIsFeatured(!isFeatured)} + /> +
+
+
+ {deleteModalOpen && + setDeleteModalOpen(false)} + />} +
+ + ); +} + +export default AsTemplate; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/as-template/AsTemplateButton.tsx b/frontend/appflowy_web_app/src/components/as-template/AsTemplateButton.tsx new file mode 100644 index 0000000000000..e0ec762ddd6e7 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/as-template/AsTemplateButton.tsx @@ -0,0 +1,40 @@ +import { useCurrentUser } from '@/components/app/app.hooks'; +import { useViewMeta } from '@/components/publish/useViewMeta'; +import { Button, Divider } from '@mui/material'; +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { ReactComponent as TemplateIcon } from '@/assets/template.svg'; + +function AsTemplateButton () { + const { t } = useTranslation(); + const viewMeta = useViewMeta(); + const navigate = useNavigate(); + const handleClick = useCallback(() => { + const url = encodeURIComponent(window.location.href.replace(window.location.search, '')); + + navigate(`/as-template?viewUrl=${url}&viewName=${viewMeta?.name || ''}&viewId=${viewMeta?.viewId || ''}`); + }, [navigate, viewMeta]); + + const currentUser = useCurrentUser(); + + if (!currentUser) return null; + + const isAppFlowyUser = currentUser.email?.endsWith('@appflowy.io'); + + if (!isAppFlowyUser) return null; + return ( + <> + + + + ); +} + +export default AsTemplateButton; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/as-template/AsTemplateForm.tsx b/frontend/appflowy_web_app/src/components/as-template/AsTemplateForm.tsx new file mode 100644 index 0000000000000..9adb9c6c2f136 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/as-template/AsTemplateForm.tsx @@ -0,0 +1,123 @@ +import { TemplateSummary } from '@/application/template.type'; +import RelatedTemplates from '@/components/as-template/related-template/RelatedTemplates'; +import { + InputLabel, + TextField, +} from '@mui/material'; +import React, { forwardRef, useMemo } from 'react'; +import { useForm, Controller } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +export interface AsTemplateFormValue { + name: string; + description: string; + about: string; + related_view_ids: string[]; +} + +function AsTemplateForm ({ viewUrl, defaultValues, onSubmit, defaultRelatedTemplates }: { + viewUrl: string; + defaultValues: AsTemplateFormValue; + onSubmit: (data: AsTemplateFormValue) => void; + defaultRelatedTemplates?: TemplateSummary[]; +}, ref: React.ForwardedRef) { + const { control, handleSubmit } = useForm({ + defaultValues, + }); + + const { t } = useTranslation(); + + const iframeUrl = useMemo(() => { + const url = new URL(viewUrl); + + url.searchParams.delete('v'); + url.searchParams.set('theme', 'light'); + url.searchParams.set('template', 'true'); + return url.toString(); + }, [viewUrl]); + + return ( +
+ + ( + + )} + name="name" + /> + + + ( + + + )} + name="description" + /> + + + ( + + )} + name="about" + /> + +
+ {t('template.preview')} +