Compare commits

..
Author SHA1 Message Date
qinluhe 9b4b8a7f24 chore: modifed server.js to server.ts 2024-07-04 12:59:33 +08:00
qinluhe a7397b74a0 chore: modified server.cjs to server.js 2024-07-04 12:37:42 +08:00
qinluhe 6e4cc3a174 chore: modified Dockerfile 2024-07-04 12:23:10 +08:00
qinluhe e9ce211c16 chore: update deploy.sh 2024-07-04 12:05:10 +08:00
qinluhe 59bbc43fc4 chore: move the files and modified the en 2024-07-03 22:54:39 +08:00
qinluhe 1534a02953 fix: title longer 2024-07-03 17:26:01 +08:00
Zack 55f06cb7a9 chore: make bash script more portable (#5679) 2024-07-03 15:26:28 +08:00
qinluhe e21ad18453 fix: text color 2024-07-03 15:21:50 +08:00
qinluhe 1b69f7841c fix: save the dark mode in local storage 2024-07-03 14:56:43 +08:00
qinluhe b2d6876347 fix: the dark mode color 2024-07-03 14:52:18 +08:00
qinluhe d5c08a3c80 fix: modified bullted icon style 2024-07-03 13:45:29 +08:00
Lucas.Xu f96218820d feat: disable publish in non-apppflowy-cloud user mode 2024-07-03 13:43:59 +08:00
Lucas.Xu bec7227cff chore: use beta.appflowy.com 2024-07-03 12:00:47 +08:00
Lucas.Xu 4ece2ff959 feat: show unpublish error 2024-07-03 11:05:15 +08:00
Lucas.Xu 59b36f67c8 feat: improve confirm deletion dialog 2024-07-03 10:47:59 +08:00
Lucas.Xu 38f49d617f Merge branch 'feat/support-get-encoded-collab-event' of https://github.com/AppFlowy-IO/AppFlowy into feat/support-get-encoded-collab-event 2024-07-03 10:21:20 +08:00
Lucas.Xu 5be41553be feat: unpublish the page auto when moving it to another space 2024-07-03 10:21:17 +08:00
Kilu 2a74fbcfe6 fix: to 404
fix: to 404

fix: to 404

fix: the error to 404
2024-07-02 23:10:30 +08:00
Lucas.Xu 81f0100e78 feat: add copy toast in publish tab 2024-07-02 22:15:12 +08:00
Lucas.Xu cb2a7c9f98 feat: show a confirm deletion dialog if the deleted page contains published page 2024-07-02 21:40:52 +08:00
Lucas.Xu 2da3744700 chore: update copy 2024-07-02 17:10:55 +08:00
Lucas.Xu 0daabd2f3c feat: use default publish name 2024-07-02 17:09:19 +08:00
Lucas.Xu be4d60ca0d Merge branch 'main' into feat/support-get-encoded-collab-event 2024-07-02 16:42:00 +08:00
qinluhe e852d25ad8 chore: add a params 2024-07-02 15:39:45 +08:00
Kilu 9c68983ff3 fix: support copy button 2024-07-02 12:11:47 +08:00
Kilu df51cfda09 fix: support copy button 2024-07-02 10:55:09 +08:00
Kilu 0a40e67e4f fix: support copy button 2024-07-02 10:46:26 +08:00
Kilu 2bf83df2b3 fix: og image 2024-07-01 12:53:25 +08:00
Kilu 03a068eb0b fix: deploy 2024-06-30 15:52:13 +08:00
Kilu 4348355868 fix: deploy 2024-06-30 15:47:30 +08:00
Kilu 15de98d61c fix: deploy 2024-06-30 15:46:22 +08:00
Kilu 80255f98de fix: deploy 2024-06-30 15:45:01 +08:00
Kilu 9595011f4e fix: deploy 2024-06-30 15:36:52 +08:00
Kilu 357988c248 fix: deploy 2024-06-30 15:34:38 +08:00
Kilu 655b0735bb fix: deploy 2024-06-30 15:31:16 +08:00
Kilu 28e3025c83 fix: rerelease 2024-06-30 01:06:54 +08:00
Kilu 460b9968e6 fix: bugs 2024-06-30 00:30:06 +08:00
Kilu 4e43ee1904 fix: bugs 2024-06-29 23:43:57 +08:00
Kilu 4ae9aac50e fix: bugs 2024-06-29 23:23:02 +08:00
Kilu b9b7ce60ce fix: bugs 2024-06-29 22:54:03 +08:00
Kilu 5396f07ad2 fix: bugs 2024-06-29 22:18:47 +08:00
Kilu a5193477bb fix: bugs 2024-06-29 20:50:27 +08:00
Kilu b59bda9210 fix: bugs 2024-06-29 20:28:19 +08:00
Kilu 3aabe7182d fix: bugs 2024-06-29 20:16:49 +08:00
Kilu 2b602f10cb fix: bugs 2024-06-29 20:13:48 +08:00
Kilu de3a1dfd15 fix: bugs 2024-06-29 20:08:47 +08:00
Kilu deae07b83e fix: bugs 2024-06-29 20:05:50 +08:00
Kilu a5a8e8f125 fix: bugs 2024-06-29 20:02:56 +08:00
Kilu e5fd455e93 fix: bugs 2024-06-29 17:23:18 +08:00
Kilu d4c5180761 fix: bugs 2024-06-29 16:29:54 +08:00
Kilu 7b133e60fc fix: bugs 2024-06-29 16:23:12 +08:00
Kilu 215910cd24 fix: bugs 2024-06-29 16:18:48 +08:00
Kilu e211fdce13 fix: deploy 2024-06-29 16:18:48 +08:00
Kilu b1afc455e9 fix: deploy 2024-06-29 16:18:48 +08:00
Kilu df7e70e9f6 fix: deploy 2024-06-29 16:18:48 +08:00
Kilu 6e8b0aa467 fix: deploy 2024-06-29 16:18:48 +08:00
Kilu 8bd1df539f fix: deploy 2024-06-29 16:18:48 +08:00
Kilu 1c0992ed58 fix: deploy 2024-06-29 16:18:47 +08:00
Kilu f7174727d4 fix: deploy 2024-06-29 16:18:47 +08:00
Kilu 91368a04b5 fix: deploy 2024-06-29 16:18:47 +08:00
Kilu 967a647342 fix: deploy 2024-06-29 16:18:47 +08:00
Kilu 9ab1ed8ccb fix: deploy 2024-06-29 16:18:47 +08:00
Kilu 30e24294cb fix: deploy 2024-06-29 16:18:47 +08:00
Kilu 7d55369dd8 fix: deploy 2024-06-29 16:18:47 +08:00
Kilu 37864a07ce fix: some style 2024-06-29 16:18:47 +08:00
Lucas.Xu 6cb81d7b7c Merge branch 'feat/support-get-encoded-collab-event' of https://github.com/AppFlowy-IO/AppFlowy into feat/support-get-encoded-collab-event 2024-06-28 14:03:26 +08:00
Lucas.Xu 14d62db688 Merge branch 'main' into feat/support-get-encoded-collab-event 2024-06-28 14:03:20 +08:00
Kilu 70e6a5c545 fix: some style 2024-06-28 14:01:41 +08:00
Lucas.Xu 724f1ce362 fix: rust ci 2024-06-28 13:35:03 +08:00
Lucas.Xu a379e805bc Merge branch 'feat/support-get-encoded-collab-event' of https://github.com/AppFlowy-IO/AppFlowy into feat/support-get-encoded-collab-event 2024-06-28 13:33:43 +08:00
Lucas.Xu 6033e128f0 chore: refactor share menu colors 2024-06-28 13:33:39 +08:00
Kilu f83bf109fc fix: some style 2024-06-28 13:17:54 +08:00
Lucas.Xu eab1519c3e // Merge branch 'feat/support-get-encoded-collab-event' of https://github.com/AppFlowy-IO/AppFlowy into feat/support-get-encoded-collab-event 2024-06-28 13:17:16 +08:00
Lucas.Xu 7dd4a52c61 feat: refacotor export-as 2024-06-28 13:17:13 +08:00
Kilu 07f22a9913 fix: some style 2024-06-28 12:30:22 +08:00
Kilu c398c0cb19 fix: lint 2024-06-28 12:04:06 +08:00
Kilu c07158935d fix: some style 2024-06-28 11:53:47 +08:00
Lucas.Xu 2eeac030a5 Merge branch 'feat/support-get-encoded-collab-event' of https://github.com/AppFlowy-IO/AppFlowy into feat/support-get-encoded-collab-event 2024-06-28 11:51:10 +08:00
Lucas.Xu 95c4da5e6f feat: refacotor share menu UI 2024-06-28 11:51:07 +08:00
Kilu 40f7e8ed5a fix: some style 2024-06-28 11:18:53 +08:00
Kilu 349705768a fix: some style 2024-06-28 10:54:25 +08:00
Lucas.Xu 701b55dc45 chore: change color 2024-06-28 10:34:52 +08:00
Lucas.Xu b4fe7fa03d Merge branch 'feat/support-get-encoded-collab-event' of https://github.com/AppFlowy-IO/AppFlowy into feat/support-get-encoded-collab-event 2024-06-28 10:24:08 +08:00
Lucas.Xu 2f9f2c1ba7 fix: the name is too long to publish 2024-06-28 10:24:03 +08:00
Kilu 15dfd194a1 fix: some style 2024-06-27 20:12:31 +08:00
Kilu 8c0d451630 fix: some style 2024-06-27 20:11:13 +08:00
Kilu 516cf1c9e5 fix: some style 2024-06-27 20:07:47 +08:00
Kilu 2cbcf2968c fix: some style 2024-06-27 19:57:29 +08:00
Kilu 163a80a8ee fix: some style 2024-06-27 19:54:13 +08:00
Kilu 127d0319ad fix: some style 2024-06-27 19:46:35 +08:00
Lucas.Xu e51b0f90de Merge branch 'main' into feat/support-get-encoded-collab-event 2024-06-27 17:57:56 +08:00
Lucas.Xu b67a576b19 Merge branch 'feat/support-get-encoded-collab-event' of https://github.com/AppFlowy-IO/AppFlowy into feat/support-get-encoded-collab-event 2024-06-27 17:57:36 +08:00
Lucas.Xu 39f2789e13 chore: refactor publish bloc 2024-06-27 17:57:33 +08:00
qinluhe c9b1591dba fix: update codes 2024-06-27 14:59:57 +08:00
qinluhe 0ffc4cb4eb fix: update codes 2024-06-27 14:26:36 +08:00
qinluhe 3b76ee9078 fix: update codes 2024-06-27 14:03:53 +08:00
qinluhe e27dddef61 fix: update codes 2024-06-27 14:00:37 +08:00
qinluhe 3f05ac8f62 fix: update codes 2024-06-27 13:49:09 +08:00
Kilu a5cb427a8a fix: some style 2024-06-26 21:23:40 +08:00
Kilu dc910e46b2 fix: some style 2024-06-26 20:26:19 +08:00
Kilu 303e2b30ac fix: some style 2024-06-26 19:47:24 +08:00
Kilu 492b9fddb5 fix: some style 2024-06-26 19:08:10 +08:00
Kilu 4cef37d9a7 fix: some style 2024-06-26 19:04:17 +08:00
Kilu 990c149c75 fix: some style 2024-06-26 17:57:33 +08:00
Kilu b64514c7e4 fix: some style 2024-06-26 17:47:34 +08:00
Kilu 971c0e1ea9 fix: some style 2024-06-26 17:47:25 +08:00
Kilu 2557d90c0d fix: some style 2024-06-26 17:05:10 +08:00
Kilu 431e03e3c1 fix: modified web server 2024-06-26 17:05:10 +08:00
Kilu 1c90d935be fix: lint error 2024-06-26 17:05:09 +08:00
Kilu 1309a94439 feat: support publish interfaces 2024-06-26 17:05:07 +08:00
Lucas.Xu 02199e3a73 feat: fetch the publish info to show the publish status 2024-06-26 17:03:25 +08:00
Lucas.Xu 9119e4a926 feat: integrate unpublish api 2024-06-26 17:03:25 +08:00
Lucas.Xu 14ba35c728 feat: integrate publish api 2024-06-26 17:03:25 +08:00
qinluhe e88423c83e feat: support publish view and unpublish views 2024-06-26 17:03:22 +08:00
Lucas.Xu 95194930f9 feat: call the publish event 2024-06-26 17:01:10 +08:00
Lucas.Xu 360f3df99e chore: refacotor share bloc 2024-06-26 17:01:10 +08:00
Lucas.Xu 3edcc08543 feat: publish page to the web 2024-06-26 17:01:10 +08:00
qinluhe 1c8913eb79 feat: support publish view and unpublish views 2024-06-26 17:00:52 +08:00
qinluhe 4151c48180 feat: support a event for getting encoded collab of document 2024-06-26 16:59:23 +08:00
572 changed files with 9139 additions and 23935 deletions

No files matched your search

+102
View File
@@ -0,0 +1,102 @@
name: Deploy
on:
workflow_dispatch:
inputs:
environment:
description: 'Environment'
required: true
default: 'test'
port:
description: 'Docker Port'
required: true
default: '30012'
api_base_url:
description: 'API Base URL'
required: true
default: 'https://test.appflowy.cloud'
api_gotrue_url:
description: 'GoTrue API URL'
required: true
default: 'https://test.appflowy.cloud/gotrue'
ws_url:
description: 'Websocket URL'
required: true
default: 'wss://test.appflowy.cloud/ws/v1'
env:
NODE_VERSION: "18.16.0"
PNPM_VERSION: "8.5.0"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: setup node
uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VERSION }}
- name: setup pnpm
uses: pnpm/action-setup@v2
with:
version: ${{ env.PNPM_VERSION }}
- name: Node_modules cache
uses: actions/cache@v2
with:
path: frontend/appflowy_web_app/node_modules
key: node-modules-${{ runner.os }}
- name: install frontend dependencies
working-directory: frontend/appflowy_web_app
run: |
pnpm install
- name: generate env file
working-directory: frontend/appflowy_web_app
run: |
echo "AF_BASE_URL=${{ github.event.inputs.api_base_url }}" >> .env
echo "AF_GOTRUE_URL=${{ github.event.inputs.api_gotrue_url }}" >> .env
echo "AF_WS_URL=${{ github.event.inputs.ws_url }}" >> .env
- name: test and lint
working-directory: frontend/appflowy_web_app
run: |
pnpm run lint
- name: build
working-directory: frontend/appflowy_web_app
run: |
pnpm run build
- name: Archive build output
working-directory: frontend/appflowy_web_app
run: |
tar -czf build-output.tar.gz dist deploy .env
- name: Deploy to EC2 (Test)
if: github.event.inputs.environment == 'test'
uses: easingthemes/ssh-deploy@main
with:
SSH_PRIVATE_KEY: ${{ secrets.WEB_TEST_SSH_PRIVATE_KEY }}
ARGS: "-rlgoDzvc -i"
SOURCE: frontend/appflowy_web_app/build-output.tar.gz frontend/appflowy_web_app/deploy/deploy.sh
TARGET: /home/${{ secrets.WEB_TEST_REMOTE_USER }}/appflowy-web-app
REMOTE_HOST: ${{ secrets.WEB_TEST_REMOTE_HOST }}
REMOTE_USER: ${{ secrets.WEB_TEST_REMOTE_USER }}
SCRIPT_AFTER: |
cd appflowy-web-app
chmod +x deploy.sh
sh deploy.sh ${{ github.event.inputs.port }}
- name: Deploy to EC2 (Prod)
if: github.event.inputs.environment == 'prod'
uses: easingthemes/ssh-deploy@main
with:
SSH_PRIVATE_KEY: ${{ secrets.WEB_PROD_SSH_PRIVATE_KEY }}
ARGS: "-rlgoDzvc -i"
TARGET: /home/${{ secrets.WEB_PROD_REMOTE_USER }}/appflowy-web-app
SOURCE: frontend/appflowy_web_app/build-output.tar.gz frontend/appflowy_web_app/deploy/deploy.sh
REMOTE_HOST: ${{ secrets.WEB_PROD_REMOTE_HOST }}
REMOTE_USER: ${{ secrets.WEB_PROD_REMOTE_USER }}
SCRIPT_AFTER: |
cd appflowy-web-app
chmod +x deploy.sh
sh deploy.sh ${{ github.event.inputs.port }}
+1 -1
View File
@@ -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 }}
+4 -7
View File
@@ -248,13 +248,10 @@ jobs:
env:
BACKEND_VERSION: 0.3.24-amd64
run: |
if [ "$(docker ps --filter name=appflowy-cloud -q)" == "" ]; then
docker compose pull
docker compose up -d
sleep 10
else
echo "Docker container 'appflowy-cloud' is already running."
fi
docker compose down -v --remove-orphans
docker compose pull
docker compose up -d
sleep 10
- name: Checkout source code
uses: actions/checkout@v4
+11 -25
View File
@@ -28,35 +28,20 @@ concurrency:
cancel-in-progress: true
jobs:
build-self-hosted:
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: self-hosted
steps:
- name: Checkout source code
uses: actions/checkout@v2
- name: Build AppFlowy
working-directory: frontend
run: |
cargo make --profile development-ios-arm64-sim appflowy-core-dev-ios
cargo make --profile development-ios-arm64-sim code_generation
- uses: futureware-tech/simulator-action@v3
id: simulator-action
with:
model: 'iPhone 15'
shutdown_after_job: false
build-macos:
if: github.event.pull_request.head.repo.full_name != github.repository
runs-on: macos-latest
build:
if: github.event.pull_request.draft != true
strategy:
fail-fast: true
matrix:
os: [ macos-14 ]
runs-on: ${{ matrix.os }}
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 }}
@@ -64,7 +49,8 @@ jobs:
override: true
profile: minimal
- name: Install Flutter
- name: Install flutter
id: flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
@@ -73,7 +59,7 @@ jobs:
- uses: Swatinem/rust-cache@v2
with:
prefix-key: macos-latest
prefix-key: ${{ matrix.os }}
workspaces: |
frontend/rust-lib
+2 -20
View File
@@ -135,7 +135,7 @@ jobs:
fail-fast: false
matrix:
job:
- { target: x86_64-apple-darwin, os: macos-12, extra-build-args: "" }
- { target: x86_64-apple-darwin, os: macos-11, extra-build-args: "" }
steps:
- name: Checkout source code
uses: actions/checkout@v4
@@ -233,7 +233,7 @@ jobs:
job:
- {
targets: "aarch64-apple-darwin,x86_64-apple-darwin",
os: macos-latest,
os: macos-11,
extra-build-args: "",
}
steps:
@@ -479,24 +479,6 @@ 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:
+23 -57
View File
@@ -8,7 +8,6 @@ on:
- "release/*"
paths:
- "frontend/rust-lib/**"
- ".github/workflows/rust_ci.yaml"
pull_request:
branches:
@@ -23,49 +22,17 @@ env:
RUST_TOOLCHAIN: "1.77.2"
jobs:
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: 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
test-on-ubuntu:
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
@@ -78,16 +45,23 @@ 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: ${{ runner.os }}
cache-on-failure: true
prefix-key: "ubuntu-latest"
workspaces: |
frontend/rust-lib
@@ -100,20 +74,18 @@ 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
working-directory: AppFlowy-Cloud
env:
BACKEND_VERSION: 0.3.24-amd64
run: |
if [ "$(docker ps --filter name=appflowy-cloud -q)" == "" ]; then
docker compose pull
docker compose up -d
sleep 10
else
echo "Docker container 'appflowy-cloud' is already running."
fi
docker pull appflowyinc/appflowy_cloud:latest
docker compose up -d
- name: Run rust-lib tests
working-directory: frontend/rust-lib
@@ -134,12 +106,6 @@ 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
+28 -39
View File
@@ -1,5 +1,4 @@
name: Tauri-CI
on:
pull_request:
paths:
@@ -12,47 +11,28 @@ 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-self-hosted:
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: self-hosted
tauri-build:
if: github.event.pull_request.draft != true
strategy:
fail-fast: false
matrix:
platform: [ ubuntu-20.04 ]
runs-on: ${{ matrix.platform }}
env:
CI: true
steps:
- uses: actions/checkout@v4
- name: 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
steps:
- uses: actions/checkout@v4
- name: Maximize build space
- name: Maximize build space (ubuntu only)
if: matrix.platform == 'ubuntu-20.04'
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
@@ -81,27 +61,36 @@ 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
- name: install dependencies (windows only)
if: matrix.platform == 'windows-latest'
working-directory: frontend
run: |
cargo install --force duckscript_cli
vcpkg integrate install
- name: install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-20.04'
working-directory: frontend
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
- uses: taiki-e/install-action@v2
with:
tool: cargo-make@${{ env.CARGO_MAKE_VERSION }}
- name: install tauri deps tools
- name: install cargo-make
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
-16
View File
@@ -1,20 +1,4 @@
# Release Notes
## Version 0.6.5 - 24/07/2024
### New Features
- Publish a Database to the Web
## Version 0.6.4 - 16/07/2024
### New Features
- Enhanced the message style on the AI chat page.
- Added the ability to choose cursor color and selection color from a palette in settings page.
### Bug Fixes
- Optimized the performance for loading recent pages.
- Fixed an issue where the cursor would jump randomly when typing in the document title on mobile.
## Version 0.6.3 - 08/07/2024
### New Features
- Publish a Document to the Web
## Version 0.6.2 - 01/07/2024
### New Features
- Added support for duplicating spaces.
+6 -5
View File
@@ -13,6 +13,7 @@
"type": "dart",
"env": {
"RUST_LOG": "debug",
"RUST_BACKTRACE": "1"
},
// uncomment the following line to testing performance.
// "flutterMode": "profile",
@@ -115,11 +116,11 @@
{
"name": "AF-desktop: Debug Rust",
"type": "lldb",
"request": "attach",
"pid": "${command:pickMyProcess}"
// "request": "attach",
// "pid": "${command:pickMyProcess}"
// To launch the application directly, use the following configuration:
// "request": "launch",
// "program": "[YOUR_APPLICATION_PATH]",
"request": "launch",
"program": "/Users/lucas.xu/Desktop/development/AppFlowy/frontend/appflowy_flutter/build/macos/Build/Products/Debug/AppFlowy.app",
},
{
// https://tauri.app/v1/guides/debugging/vs-code
@@ -137,4 +138,4 @@
"cwd": "${workspaceRoot}/appflowy_tauri/"
},
]
}
}
+1 -1
View File
@@ -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.6"
APPFLOWY_VERSION = "0.6.2"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"
@@ -53,7 +53,7 @@ android {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "io.appflowy.appflowy"
minSdkVersion 24
targetSdkVersion 34
targetSdkVersion 33
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
multiDexEnabled true
@@ -42,7 +42,7 @@ void main() {
await tester.tapAnonymousSignInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
// rename the name of the anon user
// reanme the name of the anon user
await tester.openSettings();
await tester.openSettingsPage(SettingsPage.account);
await tester.pumpAndSettle();
@@ -47,11 +47,11 @@ void main() {
);
await tester.tapButton(find.byType(SignInOutButton));
tester.expectToSeeText(LocaleKeys.button_ok.tr());
await tester.tapButtonWithName(LocaleKeys.button_ok.tr());
tester.expectToSeeText(LocaleKeys.button_confirm.tr());
await tester.tapButtonWithName(LocaleKeys.button_confirm.tr());
// Go to the sign in page again
await tester.pumpAndSettle(const Duration(seconds: 5));
await tester.pumpAndSettle(const Duration(seconds: 1));
tester.expectToSeeGoogleLoginButton();
});
@@ -50,28 +50,26 @@ void main() {
await tester.tapEscButton();
// wait 2 seconds for the sync to finish
await tester.pumpAndSettle(const Duration(seconds: 6));
});
testWidgets('get user icon and name from server', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
email: email,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
await tester.pumpAndSettle();
await tester.openSettings();
await tester.openSettingsPage(SettingsPage.account);
// Verify name
final profileSetting =
tester.widget(find.byType(UserProfileSetting)) as UserProfileSetting;
expect(profileSetting.name, name);
await tester.pumpAndSettle(const Duration(seconds: 2));
});
});
testWidgets('get user icon and name from server', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
email: email,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
await tester.pumpAndSettle();
await tester.openSettings();
await tester.openSettingsPage(SettingsPage.account);
// Verify name
final profileSetting =
tester.widget(find.byType(UserProfileSetting)) as UserProfileSetting;
expect(profileSetting.name, name);
});
}
@@ -16,8 +16,6 @@ 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;
@@ -40,7 +38,6 @@ 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();
}
@@ -1,22 +1,21 @@
import 'dart:io';
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/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/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/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/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/image/upload_image_menu.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.dart'
hide UploadImageMenu, ResizableImage;
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
@@ -37,7 +36,7 @@ void main() {
// create a new document
await tester.createNewPageWithNameUnderParent(
name: LocaleKeys.document_plugins_image_addAnImageDesktop.tr(),
name: LocaleKeys.document_plugins_image_addAnImage.tr(),
);
// tap the first line of the document
@@ -85,7 +84,7 @@ void main() {
// create a new document
await tester.createNewPageWithNameUnderParent(
name: LocaleKeys.document_plugins_image_addAnImageDesktop.tr(),
name: LocaleKeys.document_plugins_image_addAnImage.tr(),
);
// tap the first line of the document
@@ -138,7 +137,7 @@ void main() {
// create a new document
await tester.createNewPageWithNameUnderParent(
name: LocaleKeys.document_plugins_image_addAnImageDesktop.tr(),
name: LocaleKeys.document_plugins_image_addAnImage.tr(),
);
// tap the first line of the document
@@ -162,67 +161,5 @@ 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('Image');
expect(find.byType(CustomImageBlockComponent), findsOneWidget);
expect(find.byType(ImagePlaceholder), findsOneWidget);
expect(
find.descendant(
of: find.byType(ImagePlaceholder),
matching: find.byType(AppFlowyPopover),
),
findsOneWidget,
);
expect(find.byType(UploadImageMenu), findsOneWidget);
final 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<KeyValueStorage>().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()]);
});
});
}
@@ -1,287 +0,0 @@
import 'dart:io';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/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/multi_image_layouts.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_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('Photo gallery');
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<KeyValueStorage>().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('Photo gallery');
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));
});
});
}
@@ -8,10 +8,9 @@ void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Empty', () {
testWidgets('empty test', (tester) async {
testWidgets('toggle theme mode', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.wait(1000);
});
});
}
@@ -82,11 +82,11 @@ void main() {
HeadingBlockKeys.type,
);
expect(
importedPageEditorState.getNodeAtPath([1])!.type,
importedPageEditorState.getNodeAtPath([2])!.type,
HeadingBlockKeys.type,
);
expect(
importedPageEditorState.getNodeAtPath([2])!.type,
importedPageEditorState.getNodeAtPath([4])!.type,
TableBlockKeys.type,
);
});
@@ -1,6 +1,6 @@
import 'dart:io';
import 'package:appflowy/plugins/shared/share/share_button.dart';
import 'package:appflowy/plugins/document/presentation/share/share_button.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
@@ -51,13 +51,13 @@ void main() {
},
);
final shareButton = find.byType(ShareButton);
final shareButtonState = tester.widget(shareButton) as ShareButton;
final shareButton = find.byType(ShareActionList);
final shareButtonState =
tester.state(shareButton) as ShareActionListState;
final path = await mockSaveFilePath(
p.join(
context.applicationDataDirectory,
'${shareButtonState.view.name}.md',
'${shareButtonState.name}.md',
),
);
@@ -1,3 +1,5 @@
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';
@@ -5,16 +7,13 @@ import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflow
import 'package:appflowy/workspace/presentation/settings/widgets/setting_supabase_cloud.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'util.dart';
extension AppFlowyAuthTest on WidgetTester {
Future<void> tapGoogleLoginInButton() async {
await tapButton(
find.byKey(const Key('signInWithGoogleButton')),
);
await tapButton(find.byKey(const Key('signInWithGoogleButton')));
}
/// Requires being on the SettingsPage.account of the SettingsDialog
@@ -28,8 +27,8 @@ extension AppFlowyAuthTest on WidgetTester {
await tapButton(find.byType(SignInOutButton));
expectToSeeText(LocaleKeys.button_ok.tr());
await tapButtonWithName(LocaleKeys.button_ok.tr());
expectToSeeText(LocaleKeys.button_confirm.tr());
await tapButtonWithName(LocaleKeys.button_confirm.tr());
}
Future<void> tapSignInAsGuest() async {
@@ -1,12 +1,17 @@
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';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart';
import 'package:appflowy/plugins/shared/share/share_button.dart';
import 'package:appflowy/plugins/document/presentation/share/share_button.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/presentation/screens/screens.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';
@@ -258,7 +259,7 @@ extension CommonOperations on WidgetTester {
/// Tap the share button above the document page.
Future<void> tapShareButton() async {
final shareButton = find.byWidgetPredicate(
(widget) => widget is ShareButton,
(widget) => widget is DocumentShareButton,
);
await tapButton(shareButton);
}
@@ -1,9 +1,6 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
import 'package:appflowy/plugins/base/emoji/emoji_skin_tone.dart';
@@ -13,10 +10,12 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/bl
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/upload_image_menu/widgets/embed_image_url_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/embed_image_url_widget.dart';
import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_emoji_mart/flutter_emoji_mart.dart';
import 'package:flutter_test/flutter_test.dart';
+2 -8
View File
@@ -65,8 +65,6 @@ 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)
@@ -103,7 +101,6 @@ 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`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite (from `.symlinks/plugins/sqflite/darwin`)
@@ -152,8 +149,6 @@ 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"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
@@ -175,7 +170,7 @@ SPEC CHECKSUMS:
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
flowy_infra_ui: 0455e1fa8c51885aa1437848e361e99419f34ebc
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265
fluttertoast: fafc4fa4d01a6a9e4f772ecd190ffa525e9e2d9c
image_gallery_saver: cb43cc43141711190510e92c460eb1655cd343cb
image_picker_ios: 99dfe1854b4fa34d0364e74a78448a0151025425
integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4
@@ -184,7 +179,6 @@ SPEC CHECKSUMS:
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
printing: 233e1b73bd1f4a05615548e9b5a324c98588640b
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84
share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5
@@ -197,4 +191,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca
COCOAPODS: 1.15.2
COCOAPODS: 1.11.3
@@ -1,14 +1,16 @@
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/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class CocoaWindowChannel {
@@ -102,17 +104,19 @@ class MoveWindowDetectorState extends State<MoveWindowDetector> {
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: context.tooltipTextStyle(),
style: Theme.of(context).textTheme.bodyMedium!.copyWith(color: color),
),
TextSpan(
text: Platform.isMacOS ? '⌘+.' : 'Ctrl+\\',
style: context
.tooltipTextStyle()
?.copyWith(color: Theme.of(context).hintColor),
style: Theme.of(context)
.textTheme
.bodyMedium!
.copyWith(color: Theme.of(context).hintColor),
),
],
);
-7
View File
@@ -29,11 +29,4 @@ abstract class Env {
defaultValue: '',
)
static const String afCloudUrl = _Env.afCloudUrl;
@EnviedField(
obfuscate: false,
varName: 'INTERNAL_BUILD',
defaultValue: '',
)
static const String internalBuild = _Env.internalBuild;
}
@@ -125,7 +125,7 @@ class _MobileViewPageState extends State<MobileViewPage> {
return child;
},
)
: SafeArea(child: child);
: child;
return Scaffold(
extendBodyBehindAppBar: isDocument,
appBar: appBar,
@@ -38,7 +38,8 @@ class _MobileRecentFolderState extends State<MobileRecentFolder> {
builder: (context, state) {
final ids = <String>{};
List<ViewPB> recentViews = state.views.map((e) => e.item).toList();
List<ViewPB> recentViews =
state.views.reversed.map((e) => e.item).toList();
recentViews.retainWhere((element) => ids.add(element.id));
// only keep the first 20 items.
@@ -49,7 +49,7 @@ class _MobileRecentSpaceState extends State<MobileRecentSpace>
List<SectionViewPB> _filterRecentViews(List<SectionViewPB> recentViews) {
final ids = <String>{};
final filteredRecentViews = recentViews.toList();
final filteredRecentViews = recentViews.reversed.toList();
filteredRecentViews.retainWhere((e) => ids.add(e.item.id));
return filteredRecentViews;
}
@@ -25,14 +25,14 @@ class AboutSettingGroup extends StatelessWidget {
trailing: const Icon(
Icons.chevron_right,
),
onTap: () => afLaunchUrlString('https://appflowy.io/privacy'),
onTap: () => afLaunchUrlString('https://appflowy.io/privacy/app'),
),
MobileSettingItem(
name: LocaleKeys.settings_mobile_termsAndConditions.tr(),
trailing: const Icon(
Icons.chevron_right,
),
onTap: () => afLaunchUrlString('https://appflowy.io/terms'),
onTap: () => afLaunchUrlString('https://appflowy.io/terms/app'),
),
if (kDebugMode)
MobileSettingItem(
@@ -17,23 +17,17 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
required this.questionId,
}) : super(ChatAIMessageState.initial(message)) {
if (state.stream != null) {
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());
}
},
);
_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)));
}
});
if (state.stream!.error != null) {
Future.delayed(const Duration(milliseconds: 300), () {
@@ -48,16 +42,11 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
(event, emit) async {
await event.when(
initial: () async {},
updateText: (newText) {
emit(
state.copyWith(
text: newText,
messageState: const MessageState.ready(),
),
);
newText: (newText) {
emit(state.copyWith(text: state.text + newText, error: null));
},
receiveError: (error) {
emit(state.copyWith(messageState: MessageState.onError(error)));
emit(state.copyWith(error: error));
},
retry: () {
if (questionId is! Int64) {
@@ -66,7 +55,8 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
}
emit(
state.copyWith(
messageState: const MessageState.loading(),
retryState: const LoadingState.loading(),
error: null,
),
);
@@ -92,14 +82,8 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
emit(
state.copyWith(
text: text,
messageState: const MessageState.ready(),
),
);
},
onAIResponseLimit: () {
emit(
state.copyWith(
messageState: const MessageState.onAIResponseLimit(),
error: null,
retryState: const LoadingState.finish(),
),
);
},
@@ -108,6 +92,13 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
);
}
@override
Future<void> close() {
_subscription?.cancel();
return super.close();
}
StreamSubscription<AnswerStreamElement>? _subscription;
final String chatId;
final Int64? questionId;
}
@@ -115,34 +106,26 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
@freezed
class ChatAIMessageEvent with _$ChatAIMessageEvent {
const factory ChatAIMessageEvent.initial() = Initial;
const factory ChatAIMessageEvent.updateText(String text) = _UpdateText;
const factory ChatAIMessageEvent.newText(String text) = _NewText;
const factory ChatAIMessageEvent.receiveError(String error) = _ReceiveError;
const factory ChatAIMessageEvent.retry() = _Retry;
const factory ChatAIMessageEvent.retryResult(String text) = _RetryResult;
const factory ChatAIMessageEvent.onAIResponseLimit() = _OnAIResponseLimit;
}
@freezed
class ChatAIMessageState with _$ChatAIMessageState {
const factory ChatAIMessageState({
AnswerStream? stream,
String? error,
required String text,
required MessageState messageState,
required LoadingState retryState,
}) = _ChatAIMessageState;
factory ChatAIMessageState.initial(dynamic text) {
return ChatAIMessageState(
text: text is String ? text : "",
stream: text is AnswerStream ? text : null,
messageState: const MessageState.ready(),
retryState: const LoadingState.finish(),
);
}
}
@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;
}
@@ -7,7 +7,6 @@ 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-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';
@@ -120,6 +119,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
final uniqueMessages = {...allMessages, ...messages}.toList()
..sort((a, b) => b.id.compareTo(a.id));
uniqueMessages.insertAll(0, onetimeMessages);
emit(
state.copyWith(
messages: uniqueMessages,
@@ -380,8 +380,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
}
Message _createStreamMessage(AnswerStream stream, Int64 questionMessageId) {
final streamMessageId = (questionMessageId + 1).toString();
final streamMessageId = nanoid();
lastStreamMessageId = streamMessageId;
return TextMessage(
@@ -481,7 +480,7 @@ class ChatState with _$ChatState {
@freezed
class LoadingState with _$LoadingState {
const factory LoadingState.loading() = _Loading;
const factory LoadingState.finish({FlowyError? error}) = _Finish;
const factory LoadingState.finish() = _Finish;
}
enum OnetimeShotType {
@@ -525,6 +524,8 @@ OnetimeShotType? onetimeMessageTypeFromMeta(Map<String, dynamic>? metadata) {
return null;
}
typedef AnswerStreamElement = String;
class AnswerStream {
AnswerStream() {
_port.handler = _controller.add;
@@ -532,53 +533,23 @@ class AnswerStream {
(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 == "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<String> _controller = StreamController.broadcast();
late StreamSubscription<String> _subscription;
final StreamController<AnswerStreamElement> _controller =
StreamController.broadcast();
late StreamSubscription<AnswerStreamElement> _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;
int get nativePort => _port.sendPort.nativePort;
bool get hasStarted => _hasStarted;
String? get error => _error;
String get text => _text;
Future<void> dispose() async {
await _controller.close();
@@ -586,21 +557,9 @@ class AnswerStream {
_port.close();
}
void listen({
void Function(String text)? onData,
void Function()? onStart,
void Function()? onEnd,
void Function(String error)? onError,
void Function()? onAIResponseLimit,
}) {
_onData = onData;
_onStart = onStart;
_onEnd = onEnd;
_onError = onError;
_onAIResponseLimit = onAIResponseLimit;
if (_onStart != null) {
_onStart!();
}
StreamSubscription<AnswerStreamElement> listen(
void Function(AnswerStreamElement event)? onData,
) {
return _controller.stream.listen(onData);
}
}
@@ -1,139 +0,0 @@
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:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_file_bloc.freezed.dart';
class ChatFileBloc extends Bloc<ChatFileEvent, ChatFileState> {
ChatFileBloc({
required String chatId,
}) : listener = LocalLLMListener(),
super(const ChatFileState()) {
listener.start(
stateCallback: (pluginState) {
if (!isClosed) {
add(ChatFileEvent.updatePluginState(pluginState));
}
},
chatStateCallback: (chatState) {
if (!isClosed) {
add(ChatFileEvent.updateChatState(chatState));
}
},
);
on<ChatFileEvent>(
(event, emit) async {
await event.when(
initial: () async {
final result = await ChatEventGetLocalAIChatState().send();
result.fold(
(chatState) {
if (!isClosed) {
add(
ChatFileEvent.updateChatState(chatState),
);
}
},
(err) {
Log.error(err.toString());
},
);
},
newFile: (String filePath, String fileName) async {
emit(
state.copyWith(
indexFileIndicator: IndexFileIndicator.indexing(fileName),
),
);
final payload = ChatFilePB(filePath: filePath, chatId: chatId);
unawaited(
ChatEventChatWithFile(payload).send().then((result) {
if (!isClosed) {
result.fold((_) {
add(
ChatFileEvent.updateIndexFile(
IndexFileIndicator.finish(fileName),
),
);
}, (err) {
add(
ChatFileEvent.updateIndexFile(
IndexFileIndicator.error(err.msg),
),
);
});
}
}),
);
},
updateChatState: (LocalAIChatPB chatState) {
// Only user enable chat with file and the plugin is already running
final supportChatWithFile = chatState.fileEnabled &&
chatState.pluginState.state == RunningStatePB.Running;
emit(
state.copyWith(
supportChatWithFile: supportChatWithFile,
chatState: chatState,
),
);
},
updateIndexFile: (IndexFileIndicator indicator) {
emit(
state.copyWith(indexFileIndicator: indicator),
);
},
updatePluginState: (LocalAIPluginStatePB chatState) {
final fileEnabled = state.chatState?.fileEnabled ?? false;
final supportChatWithFile =
fileEnabled && chatState.state == RunningStatePB.Running;
emit(state.copyWith(supportChatWithFile: supportChatWithFile));
},
);
},
);
}
final LocalLLMListener listener;
@override
Future<void> close() async {
await listener.stop();
return super.close();
}
}
@freezed
class ChatFileEvent with _$ChatFileEvent {
const factory ChatFileEvent.initial() = Initial;
const factory ChatFileEvent.newFile(String filePath, String fileName) =
_NewFile;
const factory ChatFileEvent.updateChatState(LocalAIChatPB chatState) =
_UpdateChatState;
const factory ChatFileEvent.updatePluginState(
LocalAIPluginStatePB chatState,
) = _UpdatePluginState;
const factory ChatFileEvent.updateIndexFile(IndexFileIndicator indicator) =
_UpdateIndexFile;
}
@freezed
class ChatFileState with _$ChatFileState {
const factory ChatFileState({
@Default(false) bool supportChatWithFile,
IndexFileIndicator? indexFileIndicator,
LocalAIChatPB? chatState,
}) = _ChatFileState;
}
@freezed
class IndexFileIndicator with _$IndexFileIndicator {
const factory IndexFileIndicator.finish(String fileName) = _Finish;
const factory IndexFileIndicator.indexing(String fileName) = _Indexing;
const factory IndexFileIndicator.error(String error) = _Error;
}
@@ -1,73 +0,0 @@
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:bloc/bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_input_bloc.freezed.dart';
class ChatInputBloc extends Bloc<ChatInputEvent, ChatInputState> {
ChatInputBloc()
: listener = LocalLLMListener(),
super(const ChatInputState(aiType: _AppFlowyAI())) {
listener.start(
stateCallback: (pluginState) {
if (!isClosed) {
add(ChatInputEvent.updateState(pluginState));
}
},
);
on<ChatInputEvent>(_handleEvent);
}
final LocalLLMListener listener;
@override
Future<void> close() async {
await listener.stop();
return super.close();
}
Future<void> _handleEvent(
ChatInputEvent event,
Emitter<ChatInputState> emit,
) async {
await event.when(
started: () async {
final result = await ChatEventGetLocalAIPluginState().send();
result.fold(
(pluginState) {
if (!isClosed) {
add(ChatInputEvent.updateState(pluginState));
}
},
(err) => Log.error(err.toString()),
);
},
updateState: (LocalAIPluginStatePB aiPluginState) {
emit(const ChatInputState(aiType: _AppFlowyAI()));
},
);
}
}
@freezed
class ChatInputEvent with _$ChatInputEvent {
const factory ChatInputEvent.started() = _Started;
const factory ChatInputEvent.updateState(LocalAIPluginStatePB aiPluginState) =
_UpdatePluginState;
}
@freezed
class ChatInputState with _$ChatInputState {
const factory ChatInputState({required AIType aiType}) = _ChatInputState;
}
@freezed
class AIType with _$AIType {
const factory AIType.appflowyAI() = _AppFlowyAI;
const factory AIType.localAI() = _LocalAI;
}
@@ -1,3 +1,5 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/ai_chat/chat_page.dart';
import 'package:appflowy/plugins/util.dart';
@@ -7,7 +9,6 @@ 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/protobuf/flowy-folder/view.pb.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class AIChatPluginBuilder extends PluginBuilder {
@@ -95,7 +96,6 @@ class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder
Widget buildWidget({
required PluginContext context,
required bool shrinkWrap,
Map<String, dynamic>? data,
}) {
notifier.isDeleted.addListener(() {
final deletedView = notifier.isDeleted.value;
@@ -1,8 +1,3 @@
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/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -55,7 +50,7 @@ class AIChatUILayout {
}
}
class AIChatPage extends StatelessWidget {
class AIChatPage extends StatefulWidget {
const AIChatPage({
super.key,
required this.view,
@@ -68,112 +63,10 @@ class AIChatPage extends StatelessWidget {
final UserProfilePB userProfile;
@override
Widget build(BuildContext context) {
if (userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) {
return MultiBlocProvider(
providers: [
BlocProvider(
create: (_) => ChatFileBloc(chatId: view.id.toString())
..add(const ChatFileEvent.initial()),
),
BlocProvider(
create: (_) => ChatBloc(
view: view,
userProfile: userProfile,
)..add(const ChatEvent.initialLoad()),
),
BlocProvider(create: (_) => ChatInputBloc()),
],
child: BlocListener<ChatFileBloc, ChatFileState>(
listenWhen: (previous, current) =>
previous.indexFileIndicator != current.indexFileIndicator,
listener: (context, state) {
_handleIndexIndicator(state.indexFileIndicator, context);
},
child: BlocBuilder<ChatFileBloc, ChatFileState>(
builder: (context, state) {
return DropTarget(
onDragDone: (DropDoneDetails detail) async {
if (state.supportChatWithFile) {
await showConfirmDialog(
context: context,
style: ConfirmPopupStyle.cancelAndOk,
title: LocaleKeys.chat_chatWithFilePrompt.tr(),
confirmLabel: LocaleKeys.button_confirm.tr(),
onConfirm: () {
for (final file in detail.files) {
context
.read<ChatFileBloc>()
.add(ChatFileEvent.newFile(file.path, file.name));
}
},
description: '',
);
}
},
child: _ChatContentPage(
view: view,
userProfile: userProfile,
),
);
},
),
),
);
}
return Center(
child: FlowyText(
LocaleKeys.chat_unsupportedCloudPrompt.tr(),
fontSize: 20,
),
);
}
void _handleIndexIndicator(
IndexFileIndicator? indicator,
BuildContext context,
) {
if (indicator != null) {
indicator.when(
finish: (fileName) {
showSnackBarMessage(
context,
LocaleKeys.chat_indexFileSuccess.tr(args: [fileName]),
);
},
indexing: (fileName) {
showSnackBarMessage(
context,
LocaleKeys.chat_indexingFile.tr(args: [fileName]),
duration: const Duration(seconds: 2),
);
},
error: (err) {
showSnackBarMessage(
context,
err,
);
},
);
}
}
State<AIChatPage> createState() => _AIChatPageState();
}
class _ChatContentPage extends StatefulWidget {
const _ChatContentPage({
required this.view,
required this.userProfile,
});
final UserProfilePB userProfile;
final ViewPB view;
@override
State<_ChatContentPage> createState() => _ChatContentPageState();
}
class _ChatContentPageState extends State<_ChatContentPage> {
class _AIChatPageState extends State<AIChatPage> {
late types.User _user;
@override
@@ -203,61 +96,67 @@ class _ChatContentPageState extends State<_ChatContentPage> {
Flexible(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 784),
child: BlocBuilder<ChatBloc, ChatState>(
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
.read<ChatBloc>()
.add(const ChatEvent.startLoadingPrevMessage());
}
},
emptyState: BlocBuilder<ChatBloc, ChatState>(
builder: (_, state) =>
state.initialLoadingStatus == const LoadingState.finish()
? Padding(
padding: AIChatUILayout.welcomePagePadding,
child: ChatWelcomePage(
onSelectedQuestion: (question) => blocContext
.read<ChatBloc>()
.add(ChatEvent.sendMessage(question)),
),
)
: const Center(
child: CircularProgressIndicator.adaptive(),
child: BlocProvider(
create: (_) => ChatBloc(
view: widget.view,
userProfile: widget.userProfile,
)..add(const ChatEvent.initialLoad()),
child: BlocBuilder<ChatBloc, ChatState>(
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
.read<ChatBloc>()
.add(const ChatEvent.startLoadingPrevMessage());
}
},
emptyState: BlocBuilder<ChatBloc, ChatState>(
builder: (_, state) => state.initialLoadingStatus ==
const LoadingState.finish()
? Padding(
padding: AIChatUILayout.welcomePagePadding,
child: ChatWelcomePage(
onSelectedQuestion: (question) => blocContext
.read<ChatBloc>()
.add(ChatEvent.sendMessage(question)),
),
),
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,
);
}
)
: 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);
},
return _buildAIBubble(message, blocContext, state, child);
},
),
),
),
),
@@ -389,40 +288,31 @@ class _ChatContentPageState extends State<_ChatContentPage> {
return ClipRect(
child: Padding(
padding: AIChatUILayout.safeAreaInsets(context),
child: BlocBuilder<ChatInputBloc, ChatInputState>(
builder: (context, state) {
return state.aiType.when(
appflowyAI: () => Column(
children: [
BlocSelector<ChatBloc, ChatState, LoadingState>(
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<ChatBloc>()
.add(const ChatEvent.stopStream());
},
);
},
),
const VSpace(6),
Opacity(
opacity: 0.6,
child: FlowyText(
LocaleKeys.chat_aiMistakePrompt.tr(),
fontSize: 12,
),
),
],
child: Column(
children: [
BlocSelector<ChatBloc, ChatState, LoadingState>(
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<ChatBloc>().add(const ChatEvent.stopStream());
},
);
},
),
const VSpace(6),
Opacity(
opacity: 0.6,
child: FlowyText(
LocaleKeys.chat_aiMistakePrompt.tr(),
fontSize: 12,
),
localAI: () => const SizedBox.shrink(),
);
},
),
],
),
),
);
@@ -1,16 +1,10 @@
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/presentation/chat_avatar.dart';
import 'package:appflowy/plugins/ai_chat/presentation/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';
@@ -182,20 +176,9 @@ class CopyButton extends StatelessWidget {
size: const Size.square(14),
color: Theme.of(context).colorScheme.primary,
),
onPressed: () async {
final document = customMarkdownToDocument(textMessage.text);
await getIt<ClipboardService>().setData(
ClipboardServiceData(
plainText: textMessage.text,
inAppJson: jsonEncode(document.toJson()),
),
);
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.grid_url_copiedNotification.tr(),
);
}
onPressed: () {
Clipboard.setData(ClipboardData(text: textMessage.text));
showMessageToast(LocaleKeys.grid_row_copyProperty.tr());
},
),
);
@@ -49,9 +49,7 @@ class _ChatInputState extends State<ChatInput> {
return KeyEventResult.ignored;
}
if (event is KeyDownEvent) {
if (!widget.isStreaming) {
_handleSendPressed();
}
_handleSendPressed();
}
return KeyEventResult.handled;
} else {
@@ -67,7 +65,8 @@ class _ChatInputState extends State<ChatInput> {
void initState() {
super.initState();
_textController = InputTextFieldController();
_textController =
widget.options.textEditingController ?? InputTextFieldController();
_handleSendButtonVisibilityModeChange();
}
@@ -79,12 +78,18 @@ class _ChatInputState extends State<ChatInput> {
}
void _handleSendPressed() {
final trimmedText = _textController.text.trim();
if (trimmedText != '') {
final partialText = types.PartialText(text: trimmedText);
widget.onSendPressed(partialText);
if (widget.isStreaming) {
widget.onStopStreaming();
} else {
final trimmedText = _textController.text.trim();
if (trimmedText != '') {
final partialText = types.PartialText(text: trimmedText);
widget.onSendPressed(partialText);
_textController.clear();
if (widget.options.inputClearMode == InputClearMode.always) {
_textController.clear();
}
}
}
}
@@ -103,6 +108,7 @@ class _ChatInputState extends State<ChatInput> {
const inputPadding = EdgeInsets.all(6);
return Focus(
autofocus: !widget.options.autofocus,
child: Padding(
padding: inputPadding,
child: Material(
@@ -133,6 +139,7 @@ class _ChatInputState extends State<ChatInput> {
padding: textPadding,
child: TextField(
controller: _textController,
readOnly: widget.isStreaming,
focusNode: _inputFocusNode,
decoration: InputDecoration(
border: InputBorder.none,
@@ -144,11 +151,16 @@ class _ChatInputState extends State<ChatInput> {
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
),
keyboardType: TextInputType.multiline,
enabled: widget.options.enabled,
autocorrect: widget.options.autocorrect,
autofocus: widget.options.autofocus,
enableSuggestions: widget.options.enableSuggestions,
keyboardType: widget.options.keyboardType,
textCapitalization: TextCapitalization.sentences,
maxLines: 10,
minLines: 1,
onChanged: (_) {},
onChanged: widget.options.onTextChanged,
onTap: widget.options.onTextFieldTap,
),
);
}
@@ -164,10 +176,7 @@ class _ChatInputState extends State<ChatInput> {
padding: buttonPadding,
child: AccessoryButton(
onSendPressed: () {
if (!widget.isStreaming) {
widget.onStopStreaming();
_handleSendPressed();
}
_handleSendPressed();
},
onStopStreaming: () {
widget.onStopStreaming();
@@ -199,6 +208,53 @@ class _ChatInputState extends State<ChatInput> {
);
}
@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;
@@ -6,6 +6,7 @@ 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,
@@ -96,7 +97,6 @@ class _RelatedQuestionItemState extends State<RelatedQuestionItem> {
style: TextStyle(
color: _isHovered ? Theme.of(context).colorScheme.primary : null,
fontSize: 14,
height: 1.5,
),
),
onTap: () {
@@ -1,9 +1,10 @@
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/style_widget/hover.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'chat_input.dart';
@@ -1,375 +0,0 @@
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_editor/appflowy_editor.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flutter/material.dart';
import 'package:markdown_widget/markdown_widget.dart';
import 'selectable_highlight.dart';
enum AIMarkdownType {
appflowyEditor,
markdownWidget,
}
// Wrap the appflowy_editor or markdown_widget as a chat text message widget
class AIMarkdownText extends StatelessWidget {
const AIMarkdownText({
super.key,
required this.markdown,
this.type = AIMarkdownType.appflowyEditor,
});
final String markdown;
final AIMarkdownType type;
@override
Widget build(BuildContext context) {
switch (type) {
case AIMarkdownType.appflowyEditor:
return _AppFlowyEditorMarkdown(markdown: markdown);
case AIMarkdownType.markdownWidget:
return _ThirdPartyMarkdown(markdown: markdown);
}
}
}
class _AppFlowyEditorMarkdown extends StatefulWidget {
const _AppFlowyEditorMarkdown({
required this.markdown,
});
// the text should be the markdown format
final String markdown;
@override
State<_AppFlowyEditorMarkdown> createState() =>
_AppFlowyEditorMarkdownState();
}
class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
late EditorState editorState;
late EditorScrollController scrollController;
@override
void initState() {
super.initState();
editorState = _parseMarkdown(widget.markdown);
scrollController = EditorScrollController(
editorState: editorState,
shrinkWrap: true,
);
}
@override
void didUpdateWidget(covariant _AppFlowyEditorMarkdown oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.markdown != widget.markdown) {
editorState.dispose();
editorState = _parseMarkdown(widget.markdown);
scrollController.dispose();
scrollController = EditorScrollController(
editorState: editorState,
shrinkWrap: true,
);
}
}
@override
void dispose() {
scrollController.dispose();
editorState.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// don't lazy load the styleCustomizer and blockBuilders,
// it needs the context to get the theme.
final styleCustomizer = 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,
styleCustomizer: styleCustomizer,
// the editor is not editable in the chat
editable: false,
);
return IntrinsicHeight(
child: AppFlowyEditor(
shrinkWrap: true,
// the editor is not editable in the chat
editable: false,
editorStyle: editorStyle,
editorScrollController: scrollController,
blockComponentBuilders: blockBuilders,
commandShortcutEvents: [customCopyCommand],
editorState: editorState,
),
);
}
EditorState _parseMarkdown(String markdown) {
final document = customMarkdownToDocument(markdown);
final editorState = EditorState(document: document);
return editorState;
}
}
class _ThirdPartyMarkdown extends StatelessWidget {
const _ThirdPartyMarkdown({
required this.markdown,
});
final String markdown;
@override
Widget build(BuildContext context) {
return MarkdownWidget(
data: markdown,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
config: configFromContext(context),
);
}
MarkdownConfig configFromContext(BuildContext context) {
return MarkdownConfig(
configs: [
HrConfig(color: AFThemeExtension.of(context).textColor),
_ChatH1Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 24,
fontWeight: FontWeight.bold,
height: 1.5,
),
dividerColor: AFThemeExtension.of(context).lightGreyHover,
),
_ChatH2Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 20,
fontWeight: FontWeight.bold,
height: 1.5,
),
dividerColor: AFThemeExtension.of(context).lightGreyHover,
),
_ChatH3Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 18,
fontWeight: FontWeight.bold,
height: 1.5,
),
dividerColor: AFThemeExtension.of(context).lightGreyHover,
),
H4Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 16,
fontWeight: FontWeight.bold,
height: 1.5,
),
),
H5Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 14,
fontWeight: FontWeight.bold,
height: 1.5,
),
),
H6Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 12,
fontWeight: FontWeight.bold,
height: 1.5,
),
),
PreConfig(
builder: (code, language) {
return ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 800,
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(6.0)),
child: SelectableHighlightView(
code,
language: language,
theme: getHighlightTheme(context),
padding: const EdgeInsets.all(14),
textStyle: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 14,
fontWeight: FontWeight.bold,
height: 1.5,
),
),
),
);
},
),
PConfig(
textStyle: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
),
),
CodeConfig(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
),
),
BlockquoteConfig(
sideColor: AFThemeExtension.of(context).lightGreyHover,
textColor: AFThemeExtension.of(context).textColor,
),
],
);
}
Map<String, TextStyle> getHighlightTheme(BuildContext context) {
return {
'root': TextStyle(
color: const Color(0xffabb2bf),
backgroundColor:
Theme.of(context).isLightMode ? Colors.white : Colors.black38,
),
'comment': const TextStyle(
color: Color(0xff5c6370),
fontStyle: FontStyle.italic,
),
'quote': const TextStyle(
color: Color(0xff5c6370),
fontStyle: FontStyle.italic,
),
'doctag': const TextStyle(color: Color(0xffc678dd)),
'keyword': const TextStyle(color: Color(0xffc678dd)),
'formula': const TextStyle(color: Color(0xffc678dd)),
'section': const TextStyle(color: Color(0xffe06c75)),
'name': const TextStyle(color: Color(0xffe06c75)),
'selector-tag': const TextStyle(color: Color(0xffe06c75)),
'deletion': const TextStyle(color: Color(0xffe06c75)),
'subst': const TextStyle(color: Color(0xffe06c75)),
'literal': const TextStyle(color: Color(0xff56b6c2)),
'string': const TextStyle(color: Color(0xff98c379)),
'regexp': const TextStyle(color: Color(0xff98c379)),
'addition': const TextStyle(color: Color(0xff98c379)),
'attribute': const TextStyle(color: Color(0xff98c379)),
'meta-string': const TextStyle(color: Color(0xff98c379)),
'built_in': const TextStyle(color: Color(0xffe6c07b)),
'attr': const TextStyle(color: Color(0xffd19a66)),
'variable': const TextStyle(color: Color(0xffd19a66)),
'template-variable': const TextStyle(color: Color(0xffd19a66)),
'type': const TextStyle(color: Color(0xffd19a66)),
'selector-class': const TextStyle(color: Color(0xffd19a66)),
'selector-attr': const TextStyle(color: Color(0xffd19a66)),
'selector-pseudo': const TextStyle(color: Color(0xffd19a66)),
'number': const TextStyle(color: Color(0xffd19a66)),
'symbol': const TextStyle(color: Color(0xff61aeee)),
'bullet': const TextStyle(color: Color(0xff61aeee)),
'link': const TextStyle(color: Color(0xff61aeee)),
'meta': const TextStyle(color: Color(0xff61aeee)),
'selector-id': const TextStyle(color: Color(0xff61aeee)),
'title': const TextStyle(color: Color(0xff61aeee)),
'emphasis': const TextStyle(fontStyle: FontStyle.italic),
'strong': const TextStyle(fontWeight: FontWeight.bold),
};
}
}
class _ChatH1Config extends HeadingConfig {
const _ChatH1Config({
this.style = const TextStyle(
fontSize: 32,
height: 40 / 32,
fontWeight: FontWeight.bold,
),
required this.dividerColor,
});
@override
final TextStyle style;
final Color dividerColor;
@override
String get tag => MarkdownTag.h1.name;
@override
HeadingDivider? get divider => HeadingDivider(
space: 10,
color: dividerColor,
height: 10,
);
}
///config class for h2
class _ChatH2Config extends HeadingConfig {
const _ChatH2Config({
this.style = const TextStyle(
fontSize: 24,
height: 30 / 24,
fontWeight: FontWeight.bold,
),
required this.dividerColor,
});
@override
final TextStyle style;
final Color dividerColor;
@override
String get tag => MarkdownTag.h2.name;
@override
HeadingDivider? get divider => HeadingDivider(
space: 10,
color: dividerColor,
height: 10,
);
}
class _ChatH3Config extends HeadingConfig {
const _ChatH3Config({
this.style = const TextStyle(
fontSize: 24,
height: 30 / 24,
fontWeight: FontWeight.bold,
),
required this.dividerColor,
});
@override
final TextStyle style;
final Color dividerColor;
@override
String get tag => MarkdownTag.h3.name;
@override
HeadingDivider? get divider => HeadingDivider(
space: 10,
color: dividerColor,
height: 10,
);
}
@@ -1,15 +1,20 @@
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/presentation/chat_loading.dart';
import 'package:appflowy/plugins/ai_chat/presentation/message/ai_markdown_text.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart';
import 'package:markdown_widget/markdown_widget.dart';
import 'selectable_highlight.dart';
class ChatAITextMessageWidget extends StatelessWidget {
const ChatAITextMessageWidget({
@@ -37,38 +42,265 @@ class ChatAITextMessageWidget extends StatelessWidget {
)..add(const ChatAIMessageEvent.initial()),
child: BlocBuilder<ChatAIMessageBloc, ChatAIMessageState>(
builder: (context, state) {
return state.messageState.when(
onError: (err) {
return StreamingError(
onRetryPressed: () {
context.read<ChatAIMessageBloc>().add(
const ChatAIMessageEvent.retry(),
);
},
);
},
onAIResponseLimit: () {
return FlowyText(
LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(),
maxLines: 10,
lineHeight: 1.5,
);
},
ready: () {
if (state.text.isEmpty) {
return const ChatAILoading();
} else {
return AIMarkdownText(markdown: state.text);
}
},
loading: () {
return const ChatAILoading();
},
);
if (state.error != null) {
return StreamingError(
onRetryPressed: () {
context.read<ChatAIMessageBloc>().add(
const ChatAIMessageEvent.retry(),
);
},
);
}
if (state.retryState == const LoadingState.loading()) {
return const ChatAILoading();
}
if (state.text.isEmpty) {
return const ChatAILoading();
} else {
return _textWidgetBuilder(user, context, state.text);
}
},
),
);
}
Widget _textWidgetBuilder(
User user,
BuildContext context,
String text,
) {
return MarkdownWidget(
data: text,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
config: configFromContext(context),
);
}
MarkdownConfig configFromContext(BuildContext context) {
return MarkdownConfig(
configs: [
HrConfig(color: AFThemeExtension.of(context).textColor),
ChatH1Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 24,
fontWeight: FontWeight.bold,
height: 1.5,
),
dividerColor: AFThemeExtension.of(context).lightGreyHover,
),
ChatH2Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 20,
fontWeight: FontWeight.bold,
height: 1.5,
),
dividerColor: AFThemeExtension.of(context).lightGreyHover,
),
ChatH3Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 18,
fontWeight: FontWeight.bold,
height: 1.5,
),
dividerColor: AFThemeExtension.of(context).lightGreyHover,
),
H4Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 16,
fontWeight: FontWeight.bold,
height: 1.5,
),
),
H5Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 14,
fontWeight: FontWeight.bold,
height: 1.5,
),
),
H6Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 12,
fontWeight: FontWeight.bold,
height: 1.5,
),
),
PreConfig(
builder: (code, language) {
return ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 800,
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(6.0)),
child: SelectableHighlightView(
code,
language: language,
theme: getHightlineTheme(context),
padding: const EdgeInsets.all(14),
textStyle: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 14,
fontWeight: FontWeight.bold,
height: 1.5,
),
),
),
);
},
),
PConfig(
textStyle: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
),
),
CodeConfig(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
),
),
BlockquoteConfig(
sideColor: AFThemeExtension.of(context).lightGreyHover,
textColor: AFThemeExtension.of(context).textColor,
),
],
);
}
}
Map<String, TextStyle> getHightlineTheme(BuildContext context) {
return {
'root': TextStyle(
color: const Color(0xffabb2bf),
backgroundColor:
Theme.of(context).isLightMode ? Colors.white : Colors.black38,
),
'comment':
const TextStyle(color: Color(0xff5c6370), fontStyle: FontStyle.italic),
'quote':
const TextStyle(color: Color(0xff5c6370), fontStyle: FontStyle.italic),
'doctag': const TextStyle(color: Color(0xffc678dd)),
'keyword': const TextStyle(color: Color(0xffc678dd)),
'formula': const TextStyle(color: Color(0xffc678dd)),
'section': const TextStyle(color: Color(0xffe06c75)),
'name': const TextStyle(color: Color(0xffe06c75)),
'selector-tag': const TextStyle(color: Color(0xffe06c75)),
'deletion': const TextStyle(color: Color(0xffe06c75)),
'subst': const TextStyle(color: Color(0xffe06c75)),
'literal': const TextStyle(color: Color(0xff56b6c2)),
'string': const TextStyle(color: Color(0xff98c379)),
'regexp': const TextStyle(color: Color(0xff98c379)),
'addition': const TextStyle(color: Color(0xff98c379)),
'attribute': const TextStyle(color: Color(0xff98c379)),
'meta-string': const TextStyle(color: Color(0xff98c379)),
'built_in': const TextStyle(color: Color(0xffe6c07b)),
'attr': const TextStyle(color: Color(0xffd19a66)),
'variable': const TextStyle(color: Color(0xffd19a66)),
'template-variable': const TextStyle(color: Color(0xffd19a66)),
'type': const TextStyle(color: Color(0xffd19a66)),
'selector-class': const TextStyle(color: Color(0xffd19a66)),
'selector-attr': const TextStyle(color: Color(0xffd19a66)),
'selector-pseudo': const TextStyle(color: Color(0xffd19a66)),
'number': const TextStyle(color: Color(0xffd19a66)),
'symbol': const TextStyle(color: Color(0xff61aeee)),
'bullet': const TextStyle(color: Color(0xff61aeee)),
'link': const TextStyle(color: Color(0xff61aeee)),
'meta': const TextStyle(color: Color(0xff61aeee)),
'selector-id': const TextStyle(color: Color(0xff61aeee)),
'title': const TextStyle(color: Color(0xff61aeee)),
'emphasis': const TextStyle(fontStyle: FontStyle.italic),
'strong': const TextStyle(fontWeight: FontWeight.bold),
};
}
class ChatH1Config extends HeadingConfig {
const ChatH1Config({
this.style = const TextStyle(
fontSize: 32,
height: 40 / 32,
fontWeight: FontWeight.bold,
),
required this.dividerColor,
});
@override
final TextStyle style;
final Color dividerColor;
@override
String get tag => MarkdownTag.h1.name;
@override
HeadingDivider? get divider => HeadingDivider(
space: 10,
color: dividerColor,
height: 10,
);
}
///config class for h2
class ChatH2Config extends HeadingConfig {
const ChatH2Config({
this.style = const TextStyle(
fontSize: 24,
height: 30 / 24,
fontWeight: FontWeight.bold,
),
required this.dividerColor,
});
@override
final TextStyle style;
final Color dividerColor;
@override
String get tag => MarkdownTag.h2.name;
@override
HeadingDivider? get divider => HeadingDivider(
space: 10,
color: dividerColor,
height: 10,
);
}
class ChatH3Config extends HeadingConfig {
const ChatH3Config({
this.style = const TextStyle(
fontSize: 24,
height: 30 / 24,
fontWeight: FontWeight.bold,
),
required this.dividerColor,
});
@override
final TextStyle style;
final Color dividerColor;
@override
String get tag => MarkdownTag.h3.name;
@override
HeadingDivider? get divider => HeadingDivider(
space: 10,
color: dividerColor,
height: 10,
);
}
class StreamingError extends StatelessWidget {
@@ -52,6 +52,7 @@ class TextMessageText extends StatelessWidget {
text,
fontSize: 16,
fontWeight: FontWeight.w500,
lineHeight: 1.5,
maxLines: null,
selectable: true,
color: AFThemeExtension.of(context).textColor,
@@ -54,7 +54,6 @@ class BlankPagePluginWidgetBuilder extends PluginWidgetBuilder
Widget buildWidget({
required PluginContext context,
required bool shrinkWrap,
Map<String, dynamic>? data,
}) =>
const BlankPage();
@@ -1,5 +1,7 @@
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:appflowy/plugins/database/application/row/row_cache.dart';
import 'package:appflowy/plugins/database/application/setting/setting_listener.dart';
import 'package:appflowy/plugins/database/domain/database_view_service.dart';
@@ -17,9 +19,9 @@ import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import '../setting/setting_service.dart';
import 'field_info.dart';
class _GridFieldNotifier extends ChangeNotifier {
@@ -74,6 +76,7 @@ typedef OnReceiveFields = void Function(List<FieldInfo>);
typedef OnReceiveFilters = void Function(List<FilterInfo>);
typedef OnReceiveSorts = void Function(List<SortInfo>);
class FieldController {
FieldController({required this.viewId})
: _fieldListener = FieldsListener(viewId: viewId),
@@ -443,13 +446,9 @@ class FieldController {
/// Listen for field setting changes in the backend.
void _listenOnFieldSettingsChanged() {
FieldInfo? updateFieldSettings(FieldSettingsPB updatedFieldSettings) {
FieldInfo updateFieldSettings(FieldSettingsPB updatedFieldSettings) {
final List<FieldInfo> newFields = fieldInfos;
var updatedField = newFields.firstOrNull;
if (updatedField == null) {
return null;
}
FieldInfo updatedField = newFields[0];
final index = newFields
.indexWhere((field) => field.id == updatedFieldSettings.fieldId);
@@ -471,10 +470,6 @@ class FieldController {
result.fold(
(fieldSettings) {
final updatedFieldInfo = updateFieldSettings(fieldSettings);
if (updatedFieldInfo == null) {
return;
}
for (final listener in _updatedFieldCallbacks.values) {
listener([updatedFieldInfo]);
}
@@ -52,7 +52,7 @@ class DatabaseTabBarBloc
_createLinkedView(layout.layoutType, name ?? layout.layoutName);
},
deleteView: (String viewId) async {
final result = await ViewBackendService.deleteView(viewId: viewId);
final result = await ViewBackendService.delete(viewId: viewId);
result.fold(
(l) {},
(r) => Log.error(r),
@@ -1,3 +1,6 @@
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/row/row_service.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/calculations_row.dart';
@@ -6,7 +9,6 @@ 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/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';
@@ -14,7 +16,6 @@ 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';
@@ -23,6 +24,7 @@ import '../../application/row/row_controller.dart';
import '../../tab_bar/tab_bar_view.dart';
import '../../widgets/row/row_detail.dart';
import '../application/grid_bloc.dart';
import 'grid_scroll.dart';
import 'layout/layout.dart';
import 'layout/sizes.dart';
@@ -502,10 +504,7 @@ class _PositionedCalculationsRowState
left: 0,
right: 0,
child: Container(
margin: EdgeInsets.only(
left:
context.read<DatabasePluginWidgetBuilderSize>().horizontalPadding,
),
margin: EdgeInsets.only(left: GridSize.horizontalHeaderPadding + 40),
padding: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: Theme.of(context).canvasColor,
@@ -1,3 +1,5 @@
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';
@@ -14,7 +16,6 @@ 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 {
@@ -140,14 +141,11 @@ class _CalculateCellState extends State<CalculateCell> {
TextSpan(
text: widget.calculation!.calculationType.shortLabel
.toUpperCase(),
style: context.tooltipTextStyle(),
),
const TextSpan(text: ' '),
TextSpan(
text: calculateValue,
style: context
.tooltipTextStyle()
?.copyWith(fontWeight: FontWeight.w500),
style: const TextStyle(fontWeight: FontWeight.w500),
),
],
),
@@ -4,7 +4,6 @@ import 'package:appflowy/plugins/database/application/field/field_controller.dar
import 'package:appflowy/plugins/database/domain/field_service.dart';
import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart';
import 'package:appflowy/plugins/database/grid/application/grid_header_bloc.dart';
import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:easy_localization/easy_localization.dart';
@@ -140,9 +139,7 @@ class _GridHeaderState extends State<_GridHeader> {
}
Widget _cellLeading() {
return SizedBox(
width: context.read<DatabasePluginWidgetBuilderSize>().horizontalPadding,
);
return SizedBox(width: GridSize.horizontalHeaderPadding + 40);
}
}
@@ -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';
@@ -5,19 +7,18 @@ import 'package:appflowy/plugins/database/application/field/field_controller.dar
import 'package:appflowy/plugins/database/application/row/row_controller.dart';
import 'package:appflowy/plugins/database/application/row/row_service.dart';
import 'package:appflowy/plugins/database/grid/application/row/row_bloc.dart';
import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart';
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart';
import 'package: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 {
@@ -111,9 +112,7 @@ class _RowLeadingState extends State<_RowLeading> {
child: Consumer<RegionStateNotifier>(
builder: (context, state, _) {
return SizedBox(
width: context
.read<DatabasePluginWidgetBuilderSize>()
.horizontalPadding,
width: GridSize.horizontalHeaderPadding + 40,
child: state.onEnter ? _activeWidget() : null,
);
},
@@ -1,7 +1,7 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart';
import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
@@ -17,17 +17,14 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'tab_bar_add_button.dart';
class TabBarHeader extends StatelessWidget {
const TabBarHeader({
super.key,
});
const TabBarHeader({super.key});
@override
Widget build(BuildContext context) {
return Container(
height: 30,
padding: EdgeInsets.symmetric(
horizontal:
context.read<DatabasePluginWidgetBuilderSize>().horizontalPadding,
horizontal: GridSize.horizontalHeaderPadding + 40,
),
child: Stack(
children: [
@@ -1,7 +1,6 @@
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/shared/share/share_button.dart';
import 'package:appflowy/plugins/database/widgets/share_button.dart';
import 'package:appflowy/plugins/util.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
@@ -17,7 +16,6 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import 'desktop/tab_bar_header.dart';
import 'mobile/mobile_tab_bar_header.dart';
@@ -221,16 +219,6 @@ class DatabaseTabBarViewPlugin extends Plugin {
}
}
const kDatabasePluginWidgetBuilderHorizontalPadding = 'horizontal_padding';
class DatabasePluginWidgetBuilderSize {
const DatabasePluginWidgetBuilderSize({
required this.horizontalPadding,
});
final double horizontalPadding;
}
class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {
DatabasePluginWidgetBuilder({
required this.bloc,
@@ -256,7 +244,6 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {
Widget buildWidget({
required PluginContext context,
required bool shrinkWrap,
Map<String, dynamic>? data,
}) {
notifier.isDeleted.addListener(() {
final deletedView = notifier.isDeleted.value;
@@ -265,20 +252,11 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {
}
});
final horizontalPadding =
data?[kDatabasePluginWidgetBuilderHorizontalPadding] as double? ??
GridSize.horizontalHeaderPadding + 40;
return Provider(
create: (context) => DatabasePluginWidgetBuilderSize(
horizontalPadding: horizontalPadding,
),
child: DatabaseTabBarView(
key: ValueKey(notifier.view.id),
view: notifier.view,
shrinkWrap: shrinkWrap,
initialRowId: initialRowId,
),
return DatabaseTabBarView(
key: ValueKey(notifier.view.id),
view: notifier.view,
shrinkWrap: shrinkWrap,
initialRowId: initialRowId,
);
}
@@ -292,7 +270,7 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {
value: bloc,
child: Row(
children: [
ShareButton(key: ValueKey(view.id), view: view),
DatabaseShareButton(key: ValueKey(view.id), view: view),
const HSpace(10),
ViewFavoriteButton(view: view),
const HSpace(4),
@@ -1,6 +1,6 @@
import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -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/database/application/cell/bloc/summary_cell_bloc.dart';
@@ -10,18 +7,19 @@ 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 {
@@ -151,22 +149,7 @@ class SummaryCellAccessory extends StatelessWidget {
rowId: rowId,
fieldId: fieldId,
),
child: BlocConsumer<SummaryRowBloc, SummaryRowState>(
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);
}
}
},
child: BlocBuilder<SummaryRowBloc, SummaryRowState>(
builder: (context, state) {
return const Row(
children: [SummaryButton(), HSpace(6), CopyButton()],
@@ -186,13 +169,13 @@ class SummaryButton extends StatelessWidget {
Widget build(BuildContext context) {
return BlocBuilder<SummaryRowBloc, SummaryRowState>(
builder: (context, state) {
return state.loadingState.when(
loading: () {
return state.loadingState.map(
loading: (_) {
return const Center(
child: CircularProgressIndicator.adaptive(),
);
},
finish: () {
finish: (_) {
return FlowyTooltip(
message: LocaleKeys.tooltip_aiGenerate.tr(),
child: Container(
@@ -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/database/application/cell/bloc/translate_cell_bloc.dart';
@@ -10,18 +7,19 @@ 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 {
@@ -152,22 +150,7 @@ class TranslateCellAccessory extends StatelessWidget {
rowId: rowId,
fieldId: fieldId,
),
child: BlocConsumer<TranslateRowBloc, TranslateRowState>(
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);
}
}
},
child: BlocBuilder<TranslateRowBloc, TranslateRowState>(
builder: (context, state) {
return const Row(
children: [TranslateButton(), HSpace(6), CopyButton()],
@@ -1,7 +1,7 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flutter/material.dart';
import 'package:percent_indicator/percent_indicator.dart';
import '../../application/cell/bloc/checklist_cell_bloc.dart';
class ChecklistProgressBar extends StatefulWidget {
@@ -68,7 +68,7 @@ class _ChecklistProgressBarState extends State<ChecklistProgressBar> {
),
),
SizedBox(
width: 45,
width: PlatformExtension.isDesktop ? 36 : 45,
child: Align(
alignment: AlignmentDirectional.centerEnd,
child: Text(
@@ -1,9 +1,9 @@
import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_listener.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flutter/material.dart';
class DatabaseViewWidget extends StatefulWidget {
const DatabaseViewWidget({
@@ -55,9 +55,6 @@ class _DatabaseViewWidgetState extends State<DatabaseViewWidget> {
builder: (_, __, ___) => viewPlugin.widgetBuilder.buildWidget(
shrinkWrap: widget.shrinkWrap,
context: PluginContext(),
data: {
kDatabasePluginWidgetBuilderHorizontalPadding: 40.0,
},
),
);
}
@@ -104,7 +104,6 @@ class DatabaseDocumentPluginWidgetBuilder extends PluginWidgetBuilder
Widget buildWidget({
required PluginContext context,
required bool shrinkWrap,
Map<String, dynamic>? data,
}) {
return BlocBuilder<DocumentAppearanceCubit, DocumentAppearance>(
builder: (_, state) => DatabaseDocumentPage(
@@ -18,7 +18,6 @@ import 'package:appflowy/util/color_to_hex_string.dart';
import 'package:appflowy/util/debounce.dart';
import 'package:appflowy/util/throttle.dart';
import 'package:appflowy/workspace/application/view/view_listener.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
@@ -38,8 +37,6 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'document_bloc.freezed.dart';
bool enableDocumentInternalLog = false;
class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
DocumentBloc({
required this.documentId,
@@ -215,10 +212,6 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
}
Future<EditorState?> _initAppFlowyEditorState(DocumentDataPB data) async {
if (enableDocumentInternalLog) {
Log.info('document data: ${data.toProto3Json()}');
}
final document = data.toDocument();
if (document == null) {
assert(false, 'document is null');
@@ -180,8 +180,6 @@ extension NodeToBlock on Node {
String? parentId,
String? childrenId,
Attributes? attributes,
String? externalId,
String? externalType,
}) {
assert(id.isNotEmpty);
final block = BlockPB.create()
@@ -194,12 +192,6 @@ extension NodeToBlock on Node {
if (parentId != null && parentId.isNotEmpty) {
block.parentId = parentId;
}
if (externalId != null && externalId.isNotEmpty) {
block.externalId = externalId;
}
if (externalType != null && externalType.isNotEmpty) {
block.externalType = externalType;
}
return block;
}
@@ -136,7 +136,7 @@ class DocumentService {
}) async {
final workspace = await FolderEventReadCurrentWorkspace().send();
return workspace.fold((l) async {
final payload = DownloadFilePB(
final payload = UploadedFilePB(
url: url,
);
final result = await DocumentEventDownloadFile(payload).send();
@@ -2,10 +2,10 @@ import 'dart:io';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/workspace/application/export/document_exporter.dart';
import 'package:appflowy/workspace/application/settings/share/export_service.dart';
import 'package:appflowy/workspace/application/view/view_listener.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
@@ -13,31 +13,53 @@ import 'package:appflowy_result/appflowy_result.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'share_bloc.freezed.dart';
part 'document_share_bloc.freezed.dart';
const _url = 'https://appflowy.com';
// todo: replace with beta
const _url = 'https://beta.appflowy.com';
class ShareBloc extends Bloc<ShareEvent, ShareState> {
ShareBloc({
class DocumentShareBloc extends Bloc<DocumentShareEvent, DocumentShareState> {
DocumentShareBloc({
required this.view,
}) : super(ShareState.initial()) {
on<ShareEvent>((event, emit) async {
}) : super(DocumentShareState.initial()) {
on<DocumentShareEvent>((event, emit) async {
await event.when(
initial: () async {
viewListener = ViewListener(viewId: view.id)
..start(
onViewUpdated: (value) {
add(ShareEvent.updateViewName(value.name));
},
onViewMoveToTrash: (p0) {
add(const ShareEvent.setPublishStatus(false));
add(DocumentShareEvent.updateViewName(value.name));
},
);
add(const ShareEvent.updatePublishStatus());
final publishInfo = await ViewBackendService.getPublishInfo(view);
final enablePublish =
await UserBackendService.getCurrentUserProfile().fold(
(v) => v.authenticator == AuthenticatorPB.AppFlowyCloud,
(p) => false,
);
publishInfo.fold((s) {
emit(
state.copyWith(
isPublished: true,
url: '$_url/${s.namespace}/${s.publishName}',
viewName: view.name,
enablePublish: enablePublish,
),
);
}, (f) {
emit(
state.copyWith(
isPublished: false,
url: '',
viewName: view.name,
enablePublish: enablePublish,
),
);
});
},
share: (type, path) async {
if (ShareType.unimplemented.contains(type)) {
if (DocumentShareType.unimplemented.contains(type)) {
Log.error('DocumentShareType $type is not implemented');
return;
}
@@ -53,7 +75,7 @@ class ShareBloc extends Bloc<ShareEvent, ShareState> {
),
);
},
publish: (nameSpace, publishName, selectedViewIds) async {
publish: (nameSpace, publishName) async {
// set space name
try {
final result =
@@ -62,7 +84,6 @@ class ShareBloc extends Bloc<ShareEvent, ShareState> {
await ViewBackendService.publish(
view,
name: publishName,
selectedViewIds: selectedViewIds,
).getOrThrow();
emit(
@@ -73,8 +94,6 @@ class ShareBloc extends Bloc<ShareEvent, ShareState> {
url: '$_url/${result.namespace}/$publishName',
),
);
Log.info('publish success: ${result.namespace}/$publishName');
} catch (e) {
Log.error('publish error: $e');
@@ -116,41 +135,6 @@ class ShareBloc extends Bloc<ShareEvent, ShareState> {
updateViewName: (viewName) async {
emit(state.copyWith(viewName: viewName));
},
setPublishStatus: (isPublished) {
emit(
state.copyWith(
isPublished: isPublished,
url: isPublished ? state.url : '',
),
);
},
updatePublishStatus: () async {
final publishInfo = await ViewBackendService.getPublishInfo(view);
final enablePublish =
await UserBackendService.getCurrentUserProfile().fold(
(v) => v.authenticator == AuthenticatorPB.AppFlowyCloud,
(p) => false,
);
publishInfo.fold((s) {
emit(
state.copyWith(
isPublished: true,
url: '$_url/${s.namespace}/${s.publishName}',
viewName: view.name,
enablePublish: enablePublish,
),
);
}, (f) {
emit(
state.copyWith(
isPublished: false,
url: '',
viewName: view.name,
enablePublish: enablePublish,
),
);
});
},
);
});
}
@@ -158,7 +142,7 @@ class ShareBloc extends Bloc<ShareEvent, ShareState> {
final ViewPB view;
late final ViewListener viewListener;
late final documentExporter = DocumentExporter(view);
late final exporter = DocumentExporter(view);
@override
Future<void> close() async {
@@ -166,31 +150,19 @@ class ShareBloc extends Bloc<ShareEvent, ShareState> {
return super.close();
}
Future<FlowyResult<ShareType, FlowyError>> _export(
ShareType type,
Future<FlowyResult<ExportDataPB, FlowyError>> _export(
DocumentShareType type,
String? path,
) async {
final FlowyResult<String, FlowyError> result;
if (type == ShareType.csv) {
final exportResult = await BackendExportService.exportDatabaseAsCSV(
view.id,
);
result = exportResult.fold(
(s) => FlowyResult.success(s.data),
(f) => FlowyResult.failure(f),
);
} else {
result = await documentExporter.export(type.documentExportType);
}
final result = await exporter.export(type.exportType);
return result.fold(
(s) {
if (path != null) {
switch (type) {
case ShareType.markdown:
case ShareType.html:
case ShareType.csv:
File(path).writeAsStringSync(s);
return FlowyResult.success(type);
case DocumentShareType.markdown:
return FlowySuccess(_saveMarkdownToPath(s, path));
case DocumentShareType.html:
return FlowySuccess(_saveHTMLToPath(s, path));
default:
break;
}
@@ -200,69 +172,74 @@ class ShareBloc extends Bloc<ShareEvent, ShareState> {
(f) => FlowyResult.failure(f),
);
}
ExportDataPB _saveMarkdownToPath(String markdown, String path) {
File(path).writeAsStringSync(markdown);
return ExportDataPB()
..data = markdown
..exportType = ExportType.Markdown;
}
ExportDataPB _saveHTMLToPath(String html, String path) {
File(path).writeAsStringSync(html);
return ExportDataPB()
..data = html
..exportType = ExportType.HTML;
}
}
enum ShareType {
// available in document
enum DocumentShareType {
markdown,
html,
text,
link,
link;
// only available in database
csv;
static List<DocumentShareType> get unimplemented => [text, link];
static List<ShareType> get unimplemented => [link];
DocumentExportType get documentExportType {
DocumentExportType get exportType {
switch (this) {
case ShareType.markdown:
case DocumentShareType.markdown:
return DocumentExportType.markdown;
case ShareType.html:
case DocumentShareType.html:
return DocumentExportType.html;
case ShareType.text:
case DocumentShareType.text:
return DocumentExportType.text;
case ShareType.csv:
throw UnsupportedError('DocumentShareType.csv is not supported');
case ShareType.link:
case DocumentShareType.link:
throw UnsupportedError('DocumentShareType.link is not supported');
}
}
}
@freezed
class ShareEvent with _$ShareEvent {
const factory ShareEvent.initial() = _Initial;
const factory ShareEvent.share(
ShareType type,
class DocumentShareEvent with _$DocumentShareEvent {
const factory DocumentShareEvent.initial() = _Initial;
const factory DocumentShareEvent.share(
DocumentShareType type,
String? path,
) = _Share;
const factory ShareEvent.publish(
const factory DocumentShareEvent.publish(
String nameSpace,
String pageId,
List<String> selectedViewIds,
) = _Publish;
const factory ShareEvent.unPublish() = _UnPublish;
const factory ShareEvent.updateViewName(String name) = _UpdateViewName;
const factory ShareEvent.updatePublishStatus() = _UpdatePublishStatus;
const factory ShareEvent.setPublishStatus(bool isPublished) =
_SetPublishStatus;
const factory DocumentShareEvent.unPublish() = _UnPublish;
const factory DocumentShareEvent.updateViewName(String name) =
_UpdateViewName;
}
@freezed
class ShareState with _$ShareState {
const factory ShareState({
class DocumentShareState with _$DocumentShareState {
const factory DocumentShareState({
required bool isPublished,
required bool isLoading,
required String url,
required String viewName,
required bool enablePublish,
FlowyResult<ShareType, FlowyError>? exportResult,
FlowyResult<ExportDataPB, FlowyError>? exportResult,
FlowyResult<void, FlowyError>? publishResult,
FlowyResult<void, FlowyError>? unpublishResult,
}) = _ShareState;
}) = _DocumentShareState;
factory ShareState.initial() => const ShareState(
factory DocumentShareState.initial() => const DocumentShareState(
isLoading: false,
isPublished: false,
enablePublish: true,
@@ -1,7 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart';
import 'package:appflowy/plugins/document/application/document_service.dart';
import 'package:appflowy_backend/log.dart';
@@ -22,8 +21,6 @@ import 'package:appflowy_editor/appflowy_editor.dart'
import 'package:collection/collection.dart';
import 'package:nanoid/nanoid.dart';
const _kExternalTextType = 'text';
/// Uses to adjust the data structure between the editor and the backend.
///
/// The editor uses a tree structure to represent the document, while the backend uses a flat structure.
@@ -37,9 +34,11 @@ class TransactionAdapter {
final DocumentService documentService;
final String documentId;
final bool _enableDebug = false;
Future<void> apply(Transaction transaction, EditorState editorState) async {
final stopwatch = Stopwatch()..start();
if (enableDocumentInternalLog) {
if (_enableDebug) {
Log.debug('transaction => ${transaction.toJson()}');
}
final actions = transaction.operations
@@ -61,10 +60,8 @@ class TransactionAdapter {
textId: payload.textId,
delta: payload.delta,
);
if (enableDocumentInternalLog) {
Log.debug(
'[editor_transaction_adapter] create external text: ${payload.delta}',
);
if (_enableDebug) {
Log.debug('create external text: ${payload.delta}');
}
} else if (type == TextDeltaType.update) {
await documentService.updateExternalText(
@@ -72,10 +69,8 @@ class TransactionAdapter {
textId: payload.textId,
delta: payload.delta,
);
if (enableDocumentInternalLog) {
Log.debug(
'[editor_transaction_adapter] update external text: ${payload.delta}',
);
if (_enableDebug) {
Log.debug('update external text: ${payload.delta}');
}
}
}
@@ -87,9 +82,9 @@ class TransactionAdapter {
);
final elapsed = stopwatch.elapsedMilliseconds;
stopwatch.stop();
if (enableDocumentInternalLog) {
if (_enableDebug) {
Log.debug(
'[editor_transaction_adapter] apply transaction cost: total $elapsed ms, converter action $actionCostTime ms, apply action ${elapsed - actionCostTime} ms',
'apply transaction cost: total $elapsed ms, converter action $actionCostTime ms, apply action ${elapsed - actionCostTime} ms',
);
}
}
@@ -141,9 +136,8 @@ extension on InsertOperation {
// create the external text if the node contains the delta in its data.
final delta = node.delta;
TextDeltaPayloadPB? textDeltaPayloadPB;
String? textId;
if (delta != null) {
textId = nanoid(6);
final textId = nanoid(6);
textDeltaPayloadPB = TextDeltaPayloadPB(
documentId: documentId,
@@ -154,17 +148,13 @@ extension on InsertOperation {
// sync the text id to the node
node.externalValues = ExternalValues(
externalId: textId,
externalType: _kExternalTextType,
externalType: 'text',
);
}
// remove the delta from the data when the incremental update is stable.
final payload = BlockActionPayloadPB()
..block = node.toBlock(
childrenId: nanoid(6),
externalId: textId,
externalType: textId != null ? _kExternalTextType : null,
)
..block = node.toBlock(childrenId: nanoid(6))
..parentId = parentId
..prevId = prevId;
@@ -258,7 +248,7 @@ extension on UpdateOperation {
node.externalValues = ExternalValues(
externalId: textId,
externalType: _kExternalTextType,
externalType: 'text',
);
actions.add(
@@ -1,3 +1,3 @@
export '../../shared/share/share_bloc.dart';
export 'document_bloc.dart';
export 'document_service.dart';
export 'document_share_bloc.dart';
@@ -5,7 +5,7 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart';
import 'package:appflowy/plugins/document/document_page.dart';
import 'package:appflowy/plugins/document/presentation/document_collaborators.dart';
import 'package:appflowy/plugins/shared/share/share_button.dart';
import 'package:appflowy/plugins/document/presentation/share/share_button.dart';
import 'package:appflowy/plugins/util.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
@@ -109,7 +109,6 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
Widget buildWidget({
required PluginContext context,
required bool shrinkWrap,
Map<String, dynamic>? data,
}) {
notifier.isDeleted.addListener(() {
final deletedView = notifier.isDeleted.value;
@@ -155,7 +154,7 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
const HSpace(16),
]
: [const HSpace(8)],
ShareButton(
DocumentShareButton(
key: ValueKey('share_button_${view.id}'),
view: view,
),
@@ -6,10 +6,7 @@ import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/banner.dart';
import 'package:appflowy/plugins/document/presentation/editor_notification.dart';
import 'package:appflowy/plugins/document/presentation/editor_page.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/startup/startup.dart';
@@ -19,17 +16,10 @@ 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:desktop_drop/desktop_drop.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/error_page.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
const _excludeFromDropTarget = [
ImageBlockKeys.type,
CustomImageBlockKeys.type,
MultiImageBlockKeys.type,
];
class DocumentPage extends StatefulWidget {
const DocumentPage({
super.key,
@@ -135,62 +125,15 @@ class _DocumentPageState extends State<DocumentPage>
},
);
} else {
child = DropTarget(
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 ([
ImageBlockKeys.type,
CustomImageBlockKeys.type,
MultiImageBlockKeys.type,
].contains(data.cursorNode?.type)) {
return;
}
final isLocalMode = context.read<DocumentBloc>().isLocalMode;
await editorState!.dropImages(
data.dropTarget!,
details.files,
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,
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,
);
}
@@ -1,20 +1,17 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/code_block/code_block_copy_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/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/image/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:easy_localization/easy_localization.dart' hide TextDirection;
import 'package:flowy_infra/theme_extension.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
Map<String, BlockComponentBuilder> getEditorBuilderMap({
@@ -110,31 +107,11 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
ImageBlockKeys.type: CustomImageBlockComponentBuilder(
configuration: configuration,
showMenu: true,
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<int> indexNotifier,
VoidCallback onImageDeleted,
) =>
menuBuilder: (Node node, CustomImageBlockComponentState state) =>
Positioned(
top: 10,
right: 10,
child: MultiImageMenu(
node: node,
state: state,
indexNotifier: indexNotifier,
isLocalMode: context.read<DocumentBloc>().isLocalMode,
onImageDeleted: onImageDeleted,
),
child: ImageMenu(node: node, state: state),
),
),
TableBlockKeys.type: TableBlockComponentBuilder(
@@ -1,8 +1,5 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_configuration.dart';
@@ -31,6 +28,8 @@ import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
final codeBlockLocalization = CodeBlockLocalizations(
@@ -353,10 +352,6 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
},
child: VSpace(PlatformExtension.isDesktopOrWeb ? 200 : 400),
),
dropTargetStyle: AppFlowyDropTargetStyle(
color: Theme.of(context).colorScheme.primary.withOpacity(0.8),
margin: const EdgeInsets.only(left: 44),
),
),
);
@@ -420,7 +415,6 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
emojiMenuItem,
autoGeneratorMenuItem,
dateMenuItem,
multiImageMenuItem,
];
}
@@ -91,8 +91,10 @@ class _AlignmentButtonsState extends State<_AlignmentButtons> {
margin: const EdgeInsets.all(4),
direction: PopoverDirection.bottomWithCenterAligned,
offset: const Offset(0, 10),
decorationColor: Theme.of(context).colorScheme.onTertiary,
borderRadius: const BorderRadius.all(Radius.circular(4)),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.onTertiary,
borderRadius: const BorderRadius.all(Radius.circular(4)),
),
popupBuilder: (_) {
keepEditorFocusNotifier.increase();
return _AlignButtons(onAlignChanged: widget.onAlignChanged);
@@ -13,7 +13,9 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
class BuiltInPageWidget extends StatefulWidget {
@@ -125,6 +127,7 @@ class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
icon: const FlowySvg(
FlowySvgs.information_s,
),
iconColorOnHover: Theme.of(context).colorScheme.onSecondary,
),
// setting
const Space(7, 0),
@@ -138,6 +141,7 @@ class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
width: 24,
height: 24,
iconPadding: const EdgeInsets.all(3),
iconColorOnHover: Theme.of(context).colorScheme.onSecondary,
icon: const FlowySvg(
FlowySvgs.settings_s,
),
@@ -1,17 +1,16 @@
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: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/workspace/presentation/home/toast.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/material.dart';
CodeBlockCopyBuilder codeBlockCopyBuilder =
(_, node) => _CopyButton(node: node);
@@ -29,26 +28,16 @@ class _CopyButton extends StatelessWidget {
message: LocaleKeys.document_codeBlock_copyTooltip.tr(),
child: FlowyIconButton(
onPressed: () async {
final delta = node.delta;
if (delta == null) {
return;
}
final document = Document.blank()
..insert([0], [node.copyWith()])
..toJson();
await getIt<ClipboardService>().setData(
ClipboardServiceData(
plainText: delta.toPlainText(),
inAppJson: jsonEncode(document.toJson()),
plainText: node.delta?.toPlainText(),
),
);
if (context.mounted) {
showToastNotification(
showSnackBarMessage(
context,
message: LocaleKeys.document_codeBlock_codeCopiedSnackbar.tr(),
LocaleKeys.document_codeBlock_codeCopiedSnackbar.tr(),
);
}
},
@@ -1,3 +1,5 @@
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart';
@@ -8,7 +10,6 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_p
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/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:string_validator/string_validator.dart';
@@ -53,7 +54,6 @@ 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)) {
return;
@@ -3,16 +3,12 @@ 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/common_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;
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;
@@ -25,42 +21,6 @@ extension PasteFromImage on EditorState {
'gif',
];
Future<void> dropImages(
Node dropNode,
List<XFile> files,
String documentId,
bool isLocalMode,
) async {
final imageFiles = files.where(
(file) =>
file.mimeType?.startsWith('image/') ??
false || imgExtensionRegex.hasMatch(file.name),
);
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<bool> pasteImage(
String format,
Uint8List imageBytes,
@@ -75,9 +75,7 @@ class _DocumentImmersiveCoverState extends State<DocumentImmersiveCover> {
child: BlocConsumer<DocumentImmersiveCoverBloc,
DocumentImmersiveCoverState>(
listener: (context, state) {
if (textEditingController.text.isEmpty) {
textEditingController.text = state.name;
}
textEditingController.text = state.name;
},
builder: (_, state) {
final iconAndTitle = _buildIconAndTitle(context, state);
@@ -161,16 +159,8 @@ class _DocumentImmersiveCoverState extends State<DocumentImmersiveCover> {
state.cover.isNone || state.cover.isPresets ? null : Colors.white,
overflow: TextOverflow.ellipsis,
),
onChanged: (name) => Debounce.debounce(
'rename',
const Duration(milliseconds: 300),
() => _rename(name),
),
onSubmitted: (name) => Debounce.debounce(
'rename',
const Duration(milliseconds: 300),
() => _rename(name),
),
onChanged: _rename,
onSubmitted: _rename,
);
}
@@ -1,7 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
@@ -11,9 +9,9 @@ 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/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/migration/editor_migration.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/shared/appflowy_network_image.dart';
@@ -25,6 +23,7 @@ import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/rounded_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:string_validator/string_validator.dart';
@@ -483,12 +482,9 @@ class DocumentCoverState extends State<DocumentCover> {
UploadImageType.url,
UploadImageType.unsplash,
],
onSelectedLocalImages: (paths) async {
onSelectedLocalImage: (path) async {
context.pop();
widget.onChangeCover(
CoverType.file,
paths.first,
);
widget.onChangeCover(CoverType.file, path);
},
onSelectedAIImage: (_) {
throw UnimplementedError();
@@ -612,9 +608,9 @@ class DocumentCoverState extends State<DocumentCover> {
UploadImageType.url,
UploadImageType.unsplash,
],
onSelectedLocalImages: (paths) {
onSelectedLocalImage: (path) {
popoverController.close();
onCoverChanged(CoverType.file, paths.first);
onCoverChanged(CoverType.file, path);
},
onSelectedAIImage: (_) {
throw UnimplementedError();
@@ -1,64 +0,0 @@
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<String, dynamic> 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<String, dynamic> 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));
}
}
}
@@ -4,28 +4,53 @@ 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/flowy_option_tile.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/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';
import '../common.dart';
const kImagePlaceholderKey = 'imagePlaceholderKey';
enum CustomImageType {
local,
internal, // the images saved in self-host cloud
external; // the images linked from network, like unsplash, https://xxx/yyy/zzz.jpg
static CustomImageType fromIntValue(int value) {
switch (value) {
case 0:
return CustomImageType.local;
case 1:
return CustomImageType.internal;
case 2:
return CustomImageType.external;
default:
throw UnimplementedError();
}
}
int toIntValue() {
switch (this) {
case CustomImageType.local:
return 0;
case CustomImageType.internal:
return 1;
case CustomImageType.external:
return 2;
}
}
}
class CustomImageBlockKeys {
const CustomImageBlockKeys._();
@@ -59,25 +84,6 @@ 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,
@@ -176,7 +182,7 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
);
} else if (imageType != CustomImageType.internal &&
!_checkIfURLIsValid(src)) {
child = const UnsupportedImageWidget();
child = const UnSupportImageWidget();
} else {
child = ResizableImage(
src: src,
@@ -185,22 +191,11 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
editable: editorState.editable,
alignment: alignment,
type: imageType,
onDoubleTap: () => showDialog(
context: context,
builder: (_) => InteractiveImageViewer(
userProfile: context.read<DocumentBloc>().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);
},
);
@@ -212,11 +207,21 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
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) {
@@ -241,7 +246,7 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
opaque: false,
child: ValueListenableBuilder<bool>(
valueListenable: showActionsNotifier,
builder: (_, value, child) {
builder: (context, value, child) {
final url = node.attributes[CustomImageBlockKeys.url];
return Stack(
children: [
@@ -254,7 +259,10 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
child: child!,
),
if (value && url.isNotEmpty == true)
widget.menuBuilder!(widget.node, this),
widget.menuBuilder!(
widget.node,
this,
),
],
);
},
@@ -0,0 +1,40 @@
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<ImagePickerPage> createState() => _ImagePickerPageState();
}
class _ImagePickerPageState extends State<ImagePickerPage> {
@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: (_) {},
),
),
);
}
}
@@ -1,27 +1,26 @@
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/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/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;
@@ -31,7 +30,7 @@ class ImageMenu extends StatefulWidget {
}
class _ImageMenuState extends State<ImageMenu> {
late final String? url = widget.node.attributes[CustomImageBlockKeys.url];
late final String? url = widget.node.attributes[ImageBlockKeys.url];
@override
Widget build(BuildContext context) {
@@ -51,12 +50,6 @@ class _ImageMenuState extends State<ImageMenu> {
),
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
@@ -68,7 +61,10 @@ class _ImageMenuState extends State<ImageMenu> {
),
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(),
@@ -99,34 +95,13 @@ class _ImageMenuState extends State<ImageMenu> {
transaction.afterSelection = null;
await editorState.apply(transaction);
}
void openFullScreen() {
showDialog(
context: context,
builder: (_) => InteractiveImageViewer(
userProfile: context.read<DocumentBloc>().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;
@@ -135,28 +110,30 @@ 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[CustomImageBlockKeys.align] ?? centerAlignmentKey;
widget.node.attributes[ImageBlockKeys.align] ?? centerAlignmentKey;
final popoverController = PopoverController();
late final EditorState editorState;
@override
void initState() {
super.initState();
editorState = context.read<EditorState>();
}
@override
void dispose() {
allowMenuClose();
super.dispose();
}
@@ -176,7 +153,9 @@ class _ImageAlignButtonState extends State<_ImageAlignButton> {
),
popupBuilder: (_) {
preventMenuClose();
return _AlignButtons(onAlignChanged: onAlignChanged);
return _AlignButtons(
onAlignChanged: onAlignChanged,
);
},
),
);
@@ -186,7 +165,9 @@ class _ImageAlignButtonState extends State<_ImageAlignButton> {
popoverController.close();
final transaction = editorState.transaction;
transaction.updateNode(widget.node, {CustomImageBlockKeys.align: align});
transaction.updateNode(widget.node, {
ImageBlockKeys.align: align,
});
editorState.apply(transaction);
allowMenuClose();
@@ -202,7 +183,7 @@ class _ImageAlignButtonState extends State<_ImageAlignButton> {
void allowMenuClose() {
widget.state.alwaysShowMenu = false;
editorState.service.selectionService.unregisterGestureInterceptor(
_interceptorKey,
interceptorKey,
);
}
@@ -220,7 +201,9 @@ class _ImageAlignButtonState extends State<_ImageAlignButton> {
}
class _AlignButtons extends StatelessWidget {
const _AlignButtons({required this.onAlignChanged});
const _AlignButtons({
required this.onAlignChanged,
});
final Function(String align) onAlignChanged;
@@ -263,7 +246,10 @@ 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,
),
);
}
}
@@ -1,40 +1,13 @@
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 Scaffold(
appBar: AppBar(
titleSpacing: 0,
title: FlowyText.semibold(
LocaleKeys.titleBar_pageIcon.tr(),
fontSize: 14.0,
),
leading: const AppBarBackButton(),
),
body: SafeArea(
child: UploadImageMenu(
onSubmitted: (_) {},
onUpload: (_) {},
),
),
);
return const ImagePickerPage();
}
}
@@ -1,29 +1,24 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart';
import 'package:appflowy/shared/patterns/common_patterns.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu.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: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:http/http.dart';
@@ -31,7 +26,10 @@ 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;
@@ -47,20 +45,12 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
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(
@@ -95,23 +85,18 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
clickHandler: PopoverClickHandler.gestureDetector,
popupBuilder: (context) {
return UploadImageMenu(
allowMultipleImages: true,
limitMaximumImageSize: !_isLocalMode(),
supportTypes: const [
UploadImageType.local,
UploadImageType.url,
UploadImageType.unsplash,
// UploadImageType.openAI,
UploadImageType.stabilityAI,
],
onSelectedLocalImages: (paths) {
onSelectedLocalImage: (path) {
controller.close();
WidgetsBinding.instance.addPostFrameCallback((_) async {
final List<String> items = List.from(
paths.where((url) => url != null && url.isNotEmpty),
);
if (items.isNotEmpty) {
await insertMultipleLocalImages(items);
}
WidgetsBinding.instance.addPostFrameCallback((timeStamp) async {
await insertLocalImage(path);
});
},
onSelectedAIImage: (url) {
@@ -128,27 +113,7 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
},
);
},
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,
),
child: child,
);
} else {
return MobileBlockActionButtons(
@@ -168,11 +133,8 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
List<Widget> _buildTrailing(BuildContext context) {
if (errorMessage != null) {
return [
Flexible(
child: FlowyText(
'${LocaleKeys.document_plugins_image_imageUploadFailed.tr()}: ${errorMessage!}',
maxLines: 3,
),
FlowyText(
'${LocaleKeys.document_plugins_image_imageUploadFailed.tr()}: ${errorMessage!}',
),
];
} else if (showLoading) {
@@ -185,14 +147,8 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
];
} else {
return [
Flexible(
child: FlowyText(
PlatformExtension.isDesktop
? isDraggingFiles
? LocaleKeys.document_plugins_image_dropImageToInsert.tr()
: LocaleKeys.document_plugins_image_addAnImageDesktop.tr()
: LocaleKeys.document_plugins_image_addAnImageMobile.tr(),
),
FlowyText(
LocaleKeys.document_plugins_image_addAnImage.tr(),
),
];
}
@@ -223,14 +179,9 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
UploadImageType.url,
UploadImageType.unsplash,
],
onSelectedLocalImages: (paths) async {
onSelectedLocalImage: (path) async {
context.pop();
final List<String> items = List.from(
paths.where((url) => url != null && url.isNotEmpty),
);
await insertMultipleLocalImages(items);
await insertLocalImage(path);
},
onSelectedAIImage: (url) async {
context.pop();
@@ -247,102 +198,77 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
}
}
Future<void> insertMultipleLocalImages(List<String> urls) async {
Future<void> insertLocalImage(String? url) async {
controller.close();
setState(() {
showLoading = true;
errorMessage = null;
if (url == null || url.isEmpty) {
return;
}
final transaction = editorState.transaction;
String? path;
String? errorMessage;
CustomImageType imageType = CustomImageType.local;
// if the user is using local authenticator, we need to save the image to local storage
if (_isLocalMode()) {
// don't limit the image size for local mode.
path = await saveImageToLocalStorage(url);
} else {
final documentId = context.read<DocumentBloc>().documentId;
if (documentId.isEmpty) {
return;
}
// 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;
}
if (mounted && path == null) {
showSnackBarMessage(
context,
errorMessage == null
? LocaleKeys.document_imageBlock_error_invalidImage.tr()
: ': $errorMessage',
);
setState(() {
this.errorMessage = errorMessage;
});
return;
}
transaction.updateNode(widget.node, {
CustomImageBlockKeys.url: path,
CustomImageBlockKeys.imageType: imageType.toIntValue(),
});
bool hasError = false;
if (_isLocalMode()) {
final first = urls.removeAt(0);
final firstPath = await saveImageToLocalStorage(first);
final transaction = editorState.transaction;
transaction.updateNode(widget.node, {
CustomImageBlockKeys.url: firstPath,
CustomImageBlockKeys.imageType: CustomImageType.local.toIntValue(),
});
if (urls.isNotEmpty) {
// Create new nodes for the rest of the images:
final paths = await Future.wait(urls.map(saveImageToLocalStorage));
paths.removeWhere((url) => url == null || url.isEmpty);
transaction.insertNodes(
widget.node.path.next,
paths.map((url) => customImageNode(url: url!)).toList(),
);
}
await editorState.apply(transaction);
} else {
final transaction = editorState.transaction;
bool isFirst = true;
for (final url in urls) {
// Upload to cloud
final (path, error) = await saveImageToCloudStorage(
url,
context.read<DocumentBloc>().documentId,
);
if (error != null) {
hasError = true;
if (isFirst) {
setState(() => errorMessage = error);
}
continue;
}
if (path != null) {
if (isFirst) {
isFirst = false;
transaction.updateNode(widget.node, {
CustomImageBlockKeys.url: path,
CustomImageBlockKeys.imageType:
CustomImageType.internal.toIntValue(),
});
} else {
transaction.insertNode(
widget.node.path.next,
customImageNode(
url: path,
type: CustomImageType.internal,
),
);
}
}
}
await editorState.apply(transaction);
}
setState(() => showLoading = false);
if (hasError && mounted) {
showSnapBar(
context,
LocaleKeys.document_imageBlock_error_multipleImagesFailed.tr(),
);
}
await editorState.apply(transaction);
}
Future<void> insertAIImage(String url) async {
if (url.isEmpty || !isURL(url)) {
// show error
return showSnackBarMessage(
showSnackBarMessage(
context,
LocaleKeys.document_imageBlock_error_invalidImage.tr(),
);
return;
}
final path = await getIt<ApplicationDataStorage>().getPath();
final imagePath = p.join(path, 'images');
final imagePath = p.join(
path,
'images',
);
try {
// create the directory if not exists
final directory = Directory(imagePath);
@@ -357,7 +283,7 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
final response = await get(uri);
await File(copyToPath).writeAsBytes(response.bodyBytes);
await insertMultipleLocalImages([copyToPath]);
await insertLocalImage(copyToPath);
await File(copyToPath).delete();
} catch (e) {
Log.error('cannot save image file', e);
@@ -367,16 +293,16 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
Future<void> insertNetworkImage(String url) async {
if (url.isEmpty || !isURL(url)) {
// show error
return showSnackBarMessage(
showSnackBarMessage(
context,
LocaleKeys.document_imageBlock_error_invalidImage.tr(),
);
return;
}
final transaction = editorState.transaction;
transaction.updateNode(widget.node, {
CustomImageBlockKeys.url: url,
CustomImageBlockKeys.imageType: CustomImageType.external.toIntValue(),
ImageBlockKeys.url: url,
});
await editorState.apply(transaction);
}
@@ -1,54 +1,27 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/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: (_, isSelected, style) => SelectionMenuIconWidget(
icon: (editorState, isSelected, style) => SelectionMenuIconWidget(
name: 'image',
isSelected: isSelected,
style: style,
),
keywords: ['image', 'picture', 'img', 'photo'],
handler: (editorState, _, __) async {
handler: (editorState, menuService, context) async {
// use the key to retrieve the state of the image block to show the popover automatically
final imagePlaceholderKey = GlobalKey<ImagePlaceholderState>();
await editorState.insertEmptyImageBlock(imagePlaceholderKey);
WidgetsBinding.instance.addPostFrameCallback((_) {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
imagePlaceholderKey.currentState?.controller.show();
});
},
);
final multiImageMenuItem = SelectionMenuItem(
getName: () => LocaleKeys.document_plugins_photoGallery_name.tr(),
icon: (_, isSelected, style) => SelectionMenuIconWidget(
icon: Icons.photo_library_outlined,
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<ImagePlaceholderState>();
await editorState.insertEmptyMultiImageBlock(imagePlaceholderKey);
WidgetsBinding.instance.addPostFrameCallback(
(_) => imagePlaceholderKey.currentState?.controller.show(),
);
},
);
extension InsertImage on EditorState {
Future<void> insertEmptyImageBlock(GlobalKey key) async {
final selection = this.selection;
@@ -60,49 +33,31 @@ 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.selectionExtraInfo = {};
return apply(transaction);
}
Future<void> 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.afterSelection = Selection.collapsed(
Position(
path: node.path.next,
),
);
transaction.selectionExtraInfo = {};
return apply(transaction);
@@ -1,20 +1,14 @@
import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
import 'package:appflowy/shared/custom_image_cache_manager.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/file_extension.dart';
import 'package:appflowy/workspace/application/settings/application_data_storage.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy_backend/dispatch/error.dart';
import 'package:appflowy_backend/log.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:path/path.dart' as p;
Future<String?> saveImageToLocalStorage(String localImagePath) async {
@@ -69,58 +63,6 @@ Future<(String? path, String? errorMessage)> saveImageToCloudStorage(
);
return (s.url, null);
},
(err) {
if (err.isStorageLimitExceeded) {
return (null, LocaleKeys.sideBar_storageLimitDialogTitle.tr());
} else {
return (null, err.msg);
}
},
(e) => (null, e.msg),
);
}
Future<List<ImageBlockData>> extractAndUploadImages(
BuildContext context,
List<String?> urls,
bool isLocalMode,
) async {
final List<ImageBlockData> 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<DocumentBloc>().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;
}
@@ -1,9 +1,8 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/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),
@@ -11,7 +10,7 @@ final imageMobileToolbarItem = MobileToolbarItem.action(
final imagePlaceholderKey = GlobalKey<ImagePlaceholderState>();
await editorState.insertEmptyImageBlock(imagePlaceholderKey);
WidgetsBinding.instance.addPostFrameCallback((_) {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
imagePlaceholderKey.currentState?.showUploadImageMenu();
});
},
@@ -1,331 +0,0 @@
import 'package:flutter/material.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/multi_image_placeholder.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_layouts.dart';
import 'package:appflowy_editor/appflowy_editor.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<int> 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<MultiImageBlockComponent> createState() =>
MultiImageBlockComponentState();
}
class MultiImageBlockComponentState extends State<MultiImageBlockComponent>
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<EditorState>(context, listen: false);
final showActionsNotifier = ValueNotifier<bool>(false);
ValueNotifier<int> indexNotifier = ValueNotifier(0);
bool alwaysShowMenu = false;
@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 = ImageBrowserLayout(
node: node,
images: data.images,
editorState: editorState,
indexNotifier: indexNotifier,
isLocalMode: context.read<DocumentBloc>().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<bool>(
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<Rect> 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<dynamic> json) {
final images = json
.map((e) => ImageBlockData.fromJson(e as Map<String, dynamic>))
.toList();
return MultiImageData(images: images);
}
MultiImageData({required this.images});
final List<ImageBlockData> images;
List<dynamic> toJson() => images.map((e) => e.toJson()).toList();
}
enum MultiImageLayout {
browser,
masonry,
grid;
int toIntValue() {
switch (this) {
case MultiImageLayout.browser:
return 0;
case MultiImageLayout.masonry:
return 1;
case MultiImageLayout.grid:
return 2;
}
}
static MultiImageLayout fromIntValue(int value) {
switch (value) {
case 0:
return MultiImageLayout.browser;
case 1:
return MultiImageLayout.masonry;
case 2:
return MultiImageLayout.grid;
default:
throw UnimplementedError();
}
}
}
@@ -1,332 +0,0 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/string_extension.dart';
import 'package:appflowy/workspace/application/settings/application_data_storage.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart';
import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu, Log;
import 'package:appflowy_popover/appflowy_popover.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: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<int> indexNotifier;
final bool isLocalMode;
final VoidCallback onImageDeleted;
@override
State<MultiImageMenu> createState() => _MultiImageMenuState();
}
class _MultiImageMenuState extends State<MultiImageMenu> {
final gestureInterceptor = SelectionGestureInterceptor(
key: _interceptorKey,
canTap: (details) => false,
);
final PopoverController controller = PopoverController();
late List<ImageBlockData> images;
late final EditorState editorState;
@override
void initState() {
super.initState();
editorState = context.read<EditorState>();
images = MultiImageData.fromJson(
widget.node.attributes[MultiImageBlockKeys.images] ?? {},
).images;
}
@override
void dispose() {
allowMenuClose();
controller.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);
return Container(
height: 32,
decoration: BoxDecoration(
color: theme.cardColor,
boxShadow: [
BoxShadow(
blurRadius: 5,
spreadRadius: 1,
color: Colors.black.withOpacity(0.1),
),
],
borderRadius: BorderRadius.circular(4.0),
),
child: Row(
children: [
const HSpace(4),
MenuBlockButton(
tooltip: LocaleKeys.document_imageBlock_openFullScreen.tr(),
iconData: FlowySvgs.full_view_s,
onTap: openFullScreen,
),
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,
UploadImageType.stabilityAI,
],
onSelectedLocalImages: insertLocalImages,
onSelectedAIImage: insertAIImage,
onSelectedNetworkImage: insertNetworkImage,
);
},
child: MenuBlockButton(
tooltip:
LocaleKeys.document_plugins_photoGallery_addImageTooltip.tr(),
iconData: FlowySvgs.add_s,
onTap: () {},
),
),
// disable the copy link button if the image is hosted on appflowy cloud
// because the url needs the verification token to be accessible
if (!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.button_delete.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<void> deleteImage() async {
final node = widget.node;
final editorState = context.read<EditorState>();
final transaction = editorState.transaction;
transaction.deleteNode(node);
transaction.afterSelection = null;
await editorState.apply(transaction);
}
void openFullScreen() {
showDialog(
context: context,
builder: (_) => InteractiveImageViewer(
userProfile: context.read<DocumentBloc>().state.userProfilePB,
imageProvider: AFBlockImageProvider(
images: images,
initialIndex: widget.indexNotifier.value,
onDeleteImage: (index) async {
final transaction = editorState.transaction;
final newImages = List<ImageBlockData>.from(images);
newImages.removeAt(index);
images = newImages;
widget.onImageDeleted();
final imagesJson =
newImages.map((image) => image.toJson()).toList();
transaction.updateNode(widget.node, {
MultiImageBlockKeys.images: imagesJson,
// Default to Browser layout
MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(),
});
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<void> insertLocalImages(List<String?> 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;
}
newImages.addAll(images);
final imagesJson = newImages.map((image) => image.toJson()).toList();
transaction.updateNode(widget.node, {
MultiImageBlockKeys.images: imagesJson,
// Default to Browser layout
MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(),
});
await editorState.apply(transaction);
setState(() => images = newImages);
});
}
Future<void> 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<ApplicationDataStorage>().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<void> 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,
// Default to Browser layout
MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(),
});
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),
);
}
}
@@ -1,294 +0,0 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart';
import 'package:appflowy/plugins/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/common_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<MultiImagePlaceholder> createState() => MultiImagePlaceholderState();
}
class MultiImagePlaceholderState extends State<MultiImagePlaceholder> {
final controller = PopoverController();
final documentService = DocumentService();
late final editorState = context.read<EditorState>();
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: [
const Icon(Icons.photo_library_outlined, size: 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(),
),
],
),
),
),
);
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,
UploadImageType.stabilityAI,
],
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<void> insertLocalImages(List<String?> 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,
// Default to Browser layout
MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(),
});
await editorState.apply(transaction);
}
Future<void> insertAIImage(String url) async {
if (url.isEmpty || !isURL(url)) {
// show error
return showSnackBarMessage(
context,
LocaleKeys.document_imageBlock_error_invalidImage.tr(),
);
}
final path = await getIt<ApplicationDataStorage>().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<void> 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(),
// Default to Browser layout
MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(),
});
await editorState.apply(transaction);
}
bool _isLocalMode() {
return context.read<DocumentBloc>().isLocalMode;
}
}
@@ -1,451 +0,0 @@
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/multi_image_block_component.dart';
import 'package:appflowy/shared/appflowy_network_image.dart';
import 'package:appflowy/shared/patterns/common_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:appflowy_editor/appflowy_editor.dart' hide ResizableImage;
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';
const _thumbnailItemSize = 100.0;
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<ImageBlockData> images;
final ValueNotifier<int> indexNotifier;
final bool isLocalMode;
}
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<ImageBrowserLayout> createState() => _ImageBrowserLayoutState();
}
class _ImageBrowserLayoutState extends State<ImageBrowserLayout> {
UserProfilePB? _userProfile;
bool isDraggingFiles = false;
@override
void initState() {
super.initState();
_userProfile = context.read<DocumentBloc>().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 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.import_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<void> insertLocalImages(List<String?> urls) async {
if (urls.isEmpty || urls.every((path) => path?.isEmpty ?? true)) {
return;
}
final isLocalMode = context.read<DocumentBloc>().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: MultiImageLayout.browser.toIntValue(),
});
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<ImageBlockData> images;
final int index;
final int selectedIndex;
final VoidCallback onDeleted;
final UserProfilePB? userProfile;
@override
State<ThumbnailItem> createState() => _ThumbnailItemState();
}
class _ThumbnailItemState extends State<ThumbnailItem> {
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,
),
),
),
),
),
),
),
],
),
),
);
}
}
@visibleForTesting
class ImageRender extends StatelessWidget {
const ImageRender({
super.key,
required this.image,
this.userProfile,
this.fit = BoxFit.cover,
});
final ImageBlockData image;
final UserProfilePB? userProfile;
final BoxFit fit;
@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: const BoxDecoration(borderRadius: Corners.s6Border),
child: child,
);
}
}
@@ -1,7 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
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';
@@ -9,6 +7,7 @@ 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({
@@ -1,15 +1,14 @@
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/common.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.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';
@@ -24,7 +23,6 @@ class ResizableImage extends StatefulWidget {
required this.width,
required this.src,
this.height,
this.onDoubleTap,
});
final String src;
@@ -33,7 +31,6 @@ class ResizableImage extends StatefulWidget {
final double? height;
final Alignment alignment;
final bool editable;
final VoidCallback? onDoubleTap;
final void Function(double width) onResize;
@@ -44,23 +41,26 @@ class ResizableImage extends StatefulWidget {
const _kImageBlockComponentMinWidth = 30.0;
class _ResizableImageState extends State<ResizableImage> {
final documentService = DocumentService();
late double imageWidth;
double initialOffset = 0;
double moveDistance = 0;
Widget? _cacheImage;
late double imageWidth;
Widget? _cacheImage;
@visibleForTesting
bool onFocus = false;
final documentService = DocumentService();
UserProfilePB? _userProfilePB;
@override
void initState() {
super.initState();
imageWidth = widget.width;
_userProfilePB = context.read<DocumentBloc>().state.userProfilePB;
}
@@ -72,12 +72,13 @@ class _ResizableImageState extends State<ResizableImage> {
width: max(_kImageBlockComponentMinWidth, imageWidth - moveDistance),
height: widget.height,
child: MouseRegion(
onEnter: (_) => setState(() => onFocus = true),
onExit: (_) => setState(() => onFocus = false),
child: GestureDetector(
onDoubleTap: widget.onDoubleTap,
child: _buildResizableImage(context),
),
onEnter: (event) => setState(() {
onFocus = true;
}),
onExit: (event) => setState(() {
onFocus = false;
}),
child: _buildResizableImage(context),
),
),
);
@@ -96,11 +97,12 @@ class _ResizableImageState extends State<ResizableImage> {
url: widget.src,
width: imageWidth - moveDistance,
userProfilePB: _userProfilePB,
progressIndicatorBuilder: (context, _, __) => _buildLoading(context),
errorWidgetBuilder: (_, __, error) => _ImageLoadFailedWidget(
errorWidgetBuilder: (context, url, error) => _ImageLoadFailedWidget(
width: imageWidth,
error: error,
),
progressIndicatorBuilder: (context, url, progress) =>
_buildLoading(context),
);
child = _cacheImage!;
@@ -119,7 +121,11 @@ class _ResizableImageState extends State<ResizableImage> {
left: 5,
bottom: 0,
width: 5,
onUpdate: (distance) => setState(() => moveDistance = distance),
onUpdate: (distance) {
setState(() {
moveDistance = distance;
});
},
),
_buildEdgeGesture(
context,
@@ -127,7 +133,11 @@ class _ResizableImageState extends State<ResizableImage> {
right: 5,
bottom: 0,
width: 5,
onUpdate: (distance) => setState(() => moveDistance = -distance),
onUpdate: (distance) {
setState(() {
moveDistance = -distance;
});
},
),
],
],
@@ -144,7 +154,9 @@ class _ResizableImageState extends State<ResizableImage> {
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),
],
),
@@ -172,7 +184,7 @@ class _ResizableImageState extends State<ResizableImage> {
},
onHorizontalDragUpdate: (details) {
if (onUpdate != null) {
double offset = details.globalPosition.dx - initialOffset;
var offset = details.globalPosition.dx - initialOffset;
if (widget.alignment == Alignment.center) {
offset *= 2.0;
}
@@ -210,7 +222,10 @@ class _ResizableImageState extends State<ResizableImage> {
}
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;
@@ -225,7 +240,9 @@ 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,
@@ -234,7 +251,9 @@ 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(
@@ -1,7 +1,6 @@
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_';
@@ -49,6 +48,7 @@ class _UnsplashImageWidgetState extends State<UnsplashImageWidget> {
@override
void initState() {
super.initState();
randomPhotos = unsplash.photos
.random(count: 18, orientation: PhotoOrientation.landscape)
.goAndGet();
@@ -57,6 +57,7 @@ class _UnsplashImageWidgetState extends State<UnsplashImageWidget> {
@override
void dispose() {
unsplash.close();
super.dispose();
}
@@ -131,16 +132,18 @@ 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,
@@ -152,11 +155,15 @@ 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(),
);
@@ -212,7 +219,10 @@ class _UnsplashImage extends StatelessWidget {
),
),
const HSpace(2.0),
FlowyText('by ${photo.name}', fontSize: 10.0),
FlowyText(
'by ${photo.name}',
fontSize: 10.0,
),
],
);
}
@@ -223,12 +233,14 @@ class _UnsplashImage extends StatelessWidget {
child: Stack(
children: [
LayoutBuilder(
builder: (_, constraints) => Image.network(
photo.urls.thumb.toString(),
fit: BoxFit.cover,
width: constraints.maxWidth,
height: constraints.maxHeight,
),
builder: (context, constraints) {
return Image.network(
photo.urls.thumb.toString(),
fit: BoxFit.cover,
width: constraints.maxWidth,
height: constraints.maxHeight,
);
},
),
Positioned(
bottom: 9,
@@ -249,9 +261,13 @@ extension on Photo {
String get name {
if (user.username.isNotEmpty) {
return user.username;
} else if (user.name.isNotEmpty) {
}
if (user.name.isNotEmpty) {
return user.name;
} else if (user.email?.isNotEmpty == true) {
}
if (user.email?.isNotEmpty == true) {
return user.email!;
}
@@ -1,13 +1,14 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package: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 UnsupportedImageWidget extends StatelessWidget {
const UnsupportedImageWidget({super.key});
class UnSupportImageWidget extends StatelessWidget {
const UnSupportImageWidget({
super.key,
});
@override
Widget build(BuildContext context) {
@@ -17,7 +18,9 @@ class UnsupportedImageWidget 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(
@@ -28,7 +31,9 @@ class UnsupportedImageWidget extends StatelessWidget {
size: Size.square(24),
),
const HSpace(10),
FlowyText(LocaleKeys.document_imageBlock_unableToLoadImage.tr()),
FlowyText(
LocaleKeys.document_imageBlock_unableToLoadImage.tr(),
),
],
),
),
@@ -1,5 +1,3 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/shared/permission/permission_checker.dart';
import 'package:appflowy/startup/startup.dart';
@@ -9,19 +7,18 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/file_picker/file_picker_service.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
class UploadImageFileWidget extends StatelessWidget {
const UploadImageFileWidget({
super.key,
required this.onPickFiles,
required this.onPickFile,
this.allowedExtensions = const ['jpg', 'png', 'jpeg'],
this.allowMultipleImages = false,
});
final void Function(List<String?>) onPickFiles;
final void Function(String? path) onPickFile;
final List<String> allowedExtensions;
final bool allowMultipleImages;
@override
Widget build(BuildContext context) {
@@ -38,7 +35,9 @@ class UploadImageFileWidget extends StatelessWidget {
);
if (PlatformExtension.isDesktopOrWeb) {
return FlowyHover(child: child);
return FlowyHover(
child: child,
);
}
return child;
@@ -51,9 +50,8 @@ class UploadImageFileWidget extends StatelessWidget {
dialogTitle: '',
type: FileType.custom,
allowedExtensions: allowedExtensions,
allowMultiple: allowMultipleImages,
);
onPickFiles(result?.files.map((f) => f.path).toList() ?? const []);
onPickFile(result?.files.firstOrNull?.path);
} else {
final photoPermission =
await PermissionChecker.checkPhotoPermission(context);
@@ -62,8 +60,8 @@ class UploadImageFileWidget extends StatelessWidget {
return;
}
// on mobile, the users can pick a image file from camera or image library
final result = await ImagePicker().pickMultiImage();
onPickFiles(result.map((f) => f.path).toList());
final result = await ImagePicker().pickImage(source: ImageSource.gallery);
onPickFile(result?.path);
}
}
}
@@ -1,10 +1,9 @@
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_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/image/upload_image_file_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide ColorOption;
@@ -12,8 +11,7 @@ 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 'widgets/embed_image_url_widget.dart';
import 'package:flutter/material.dart';
enum UploadImageType {
local,
@@ -44,22 +42,20 @@ enum UploadImageType {
class UploadImageMenu extends StatefulWidget {
const UploadImageMenu({
super.key,
required this.onSelectedLocalImages,
required this.onSelectedLocalImage,
required this.onSelectedAIImage,
required this.onSelectedNetworkImage,
this.onSelectedColor,
this.supportTypes = UploadImageType.values,
this.limitMaximumImageSize = false,
this.allowMultipleImages = false,
});
final void Function(List<String?>) onSelectedLocalImages;
final void Function(String? path) onSelectedLocalImage;
final void Function(String url) onSelectedAIImage;
final void Function(String url) onSelectedNetworkImage;
final void Function(String color)? onSelectedColor;
final List<UploadImageType> supportTypes;
final bool limitMaximumImageSize;
final bool allowMultipleImages;
@override
State<UploadImageMenu> createState() => _UploadImageMenuState();
@@ -137,7 +133,9 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
},
).toList(),
),
const Divider(height: 2),
const Divider(
height: 2,
),
_buildTab(),
],
),
@@ -157,8 +155,7 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
child: Column(
children: [
UploadImageFileWidget(
allowMultipleImages: widget.allowMultipleImages,
onPickFiles: widget.onSelectedLocalImages,
onPickFile: widget.onSelectedLocalImage,
),
if (widget.limitMaximumImageSize) ...[
const VSpace(6.0),
@@ -188,13 +185,30 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
),
),
);
// 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: (url) => widget.onSelectedLocalImages([url]),
onSelectImage: widget.onSelectedLocalImage,
),
),
)
@@ -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/block_menu/block_menu_button.dart';
@@ -10,10 +7,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,
@@ -75,7 +72,7 @@ class _LinkPreviewMenuState extends State<LinkPreviewMenu> {
}
void copyImageLink() {
final url = widget.node.attributes[CustomImageBlockKeys.url];
final url = widget.node.attributes[ImageBlockKeys.url];
if (url != null) {
Clipboard.setData(ClipboardData(text: url));
showSnackBarMessage(
@@ -7,8 +7,6 @@ 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';
@@ -198,19 +196,6 @@ 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<MultiImagePlaceholderState>();
await editorState.insertEmptyMultiImageBlock(imagePlaceholderKey);
});
},
),
// date
TypeOptionMenuItemValue(
@@ -1,6 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_close_keyboard_or_menu_button.dart';
@@ -10,7 +9,6 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_too
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:collection/collection.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -286,14 +284,6 @@ class _MobileToolbarState extends State<_MobileToolbar>
if (canUpdateCachedKeyboardHeight) {
cachedKeyboardHeight.value = height;
if (defaultTargetPlatform == TargetPlatform.android) {
// cache the keyboard height with the view padding in Android
if (cachedKeyboardHeight.value != 0) {
cachedKeyboardHeight.value +=
MediaQuery.of(context).viewPadding.bottom;
}
}
}
if (height == 0) {
@@ -395,19 +385,13 @@ class _MobileToolbarState extends State<_MobileToolbar>
return ValueListenableBuilder(
valueListenable: showMenuNotifier,
builder: (_, showingMenu, __) {
var keyboardHeight = height;
if (defaultTargetPlatform == TargetPlatform.android) {
if (!showingMenu) {
// take the max value of the keyboard height and the view padding
// to make sure the toolbar is above the keyboard
keyboardHeight = max(
keyboardHeight,
MediaQuery.of(context).viewInsets.bottom,
);
}
var paddingHeight = height;
if (Platform.isAndroid) {
paddingHeight =
height + MediaQuery.of(context).viewPadding.bottom;
}
return SizedBox(
height: keyboardHeight,
height: paddingHeight,
child: (showingMenu && selectedMenuIndex != null)
? widget.toolbarItems[selectedMenuIndex!].menuBuilder?.call(
context,
@@ -1,26 +1,14 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'error.freezed.dart';
part 'error.g.dart';
@freezed
class AIError with _$AIError {
const factory AIError({
String? code,
required String message,
@Default(AIErrorCode.other) AIErrorCode code,
}) = _AIError;
factory AIError.fromJson(Map<String, Object?> json) =>
_$AIErrorFromJson(json);
}
enum AIErrorCode {
@JsonValue('AIResponseLimitExceeded')
aiResponseLimitExceeded,
@JsonValue('Other')
other,
}
extension AIErrorExtension on AIError {
bool get isLimitExceeded => code == AIErrorCode.aiResponseLimitExceeded;
}
Loaded 100 of 572 files, more files were not shown because too many files have changed in this diff. Show more