Compare commits

..
15 Commits
2236 changed files with 24250 additions and 60716 deletions

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
name: Deploy Web (Test)
on:
push:
branches:
- build/test
env:
NODE_VERSION: "18.16.0"
PNPM_VERSION: "8.5.0"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
env:
SSH_PRIVATE_KEY: ${{ secrets.WEB_TEST_SSH_PRIVATE_KEY }}
REMOTE_HOST: ${{ secrets.WEB_TEST_REMOTE_HOST }}
REMOTE_USER: ${{ secrets.WEB_TEST_REMOTE_USER }}
SSL_CERTIFICATE: ${{ secrets.WEB_TEST_SSL_CERTIFICATE }}
SSL_CERTIFICATE_KEY: ${{ secrets.WEB_TEST_SSL_CERTIFICATE_KEY }}
ENV_FILE: test.env
steps:
- uses: actions/checkout@v4
- name: setup node
uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VERSION }}
- name: setup pnpm
uses: pnpm/action-setup@v2
with:
version: ${{ env.PNPM_VERSION }}
- name: Node_modules cache
uses: actions/cache@v2
with:
path: frontend/appflowy_web_app/node_modules
key: node-modules-${{ runner.os }}
- name: install frontend dependencies
working-directory: frontend/appflowy_web_app
run: |
pnpm install
- name: copy env file
working-directory: frontend/appflowy_web_app
run: |
cp ${{ env.ENV_FILE }} .env
- name: test and lint
working-directory: frontend/appflowy_web_app
run: |
pnpm run lint
- name: build
working-directory: frontend/appflowy_web_app
run: |
pnpm run build
- name: generate SSL certificate
run: |
echo "${{ env.SSL_CERTIFICATE }}" > nginx-signed.crt
echo "${{ env.SSL_CERTIFICATE_KEY }}" > nginx-signed.key
- name: Deploy to EC2
uses: easingthemes/ssh-deploy@main
with:
SSH_PRIVATE_KEY: ${{ env.SSH_PRIVATE_KEY }}
ARGS: "-rlgoDzvc -i"
SOURCE: "frontend/appflowy_web_app/dist frontend/appflowy_web_app/server.cjs frontend/appflowy_web_app/start.sh frontend/appflowy_web_app/Dockerfile frontend/appflowy_web_app/nginx.conf frontend/appflowy_web_app/.env nginx-signed.crt nginx-signed.key"
REMOTE_HOST: ${{ env.REMOTE_HOST }}
REMOTE_USER: ${{ env.REMOTE_USER }}
EXCLUDE: "frontend/appflowy_web_app/dist/, frontend/appflowy_web_app/node_modules/"
SCRIPT_AFTER: |
docker build -t appflowy-web-app .
docker rm -f appflowy-web-app || true
docker run -d -p 80:80 -p 443:443 --env-file .env --name appflowy-web-app appflowy-web-app
+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
+83
View File
@@ -0,0 +1,83 @@
name: WEB-CI
on:
workflow_dispatch:
inputs:
build:
description: 'Build the web app'
required: true
default: 'true'
env:
CARGO_TERM_COLOR: always
NODE_VERSION: "18.16.0"
PNPM_VERSION: "8.5.0"
RUST_TOOLCHAIN: "1.77.2"
CARGO_MAKE_VERSION: "0.36.6"
jobs:
web-build:
if: github.event.pull_request.draft != true
strategy:
fail-fast: false
matrix:
platform: [ ubuntu-latest ]
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: setup node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Cache Rust Dependencies
uses: Swatinem/rust-cache@v2
with:
key: rust-dependencies-${{ runner.os }}
workspaces: |
frontend/rust-lib
frontend/appflowy_web/appflowy_wasm
# TODO: Can combine caching deps and node_modules in one
# See Glob patterns: https://github.com/actions/toolkit/tree/main/packages/glob
- name: Cache Node.js dependencies
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}
- name: Cache node_modules
uses: actions/cache@v4
with:
path: frontend/appflowy_web/node_modules
key: node-modules-${{ runner.os }}
- name: Install Rust toolchain
id: rust_toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
override: true
profile: minimal
- name: Install wasm-pack
run: cargo install wasm-pack
- uses: taiki-e/install-action@v2
with:
tool: cargo-make@${{ env.CARGO_MAKE_VERSION }}
- name: install dependencies
if: matrix.platform == 'ubuntu-latest'
working-directory: frontend
run: |
sudo apt-get update
npm install -g pnpm@${{ env.PNPM_VERSION }}
cargo make install_web_protobuf
- name: Build
working-directory: frontend/appflowy_web
run: |
pnpm install
pnpm run build_release_wasm
-52
View File
@@ -1,56 +1,4 @@
# Release Notes
## Version 0.6.7 - 13/08/2024
### New Features
- Redesigned the icon picker design on Desktop.
- Redesigned the notification page on Mobile.
### Bug Fixes
- Enhance the toolbar tooltip functionality on Desktop.
- Enhance the slash menu user experience on Desktop.
- Fixed the issue where list style overrides occurred during text pasting.
- Fixed the issue where linking multiple databases in the same document could cause random loss of focus.
## Version 0.6.6 - 30/07/2024
### New Features
- Upgrade your workspace to a premium plan to unlock more features and storage.
- Image galleries and drag-and-drop image support in documents.
### Bug Fixes
- Fix minor UI issues on Desktop and Mobile.
## Version 0.6.5 - 24/07/2024
### New Features
- Publish a Database to the Web
## Version 0.6.4 - 16/07/2024
### New Features
- Enhanced the message style on the AI chat page.
- Added the ability to choose cursor color and selection color from a palette in settings page.
### Bug Fixes
- Optimized the performance for loading recent pages.
- Fixed an issue where the cursor would jump randomly when typing in the document title on mobile.
## Version 0.6.3 - 08/07/2024
### New Features
- Publish a Document to the Web
## Version 0.6.2 - 01/07/2024
### New Features
- Added support for duplicating spaces.
- Added support for moving pages across spaces.
- Undo markdown formatting with `Ctrl + Z` or `Cmd + Z`.
- Improved shortcuts settings UI.
### Bug Fixes
- Fixed unable to zoom in with `Ctrl` and `+` or `Cmd` and `+` on some keyboards.
- Fixed unable to paste nested lists in existing lists.
## Version 0.6.1 - 22/06/2024
### New Features
- Introduced the "Space" feature to help you organize your pages more efficiently.
### Bug Fixes
- Resolved shortcut conflicts on the board page.
- Resolved an issue where underscores could cause the editor to freeze.
## Version 0.6.0 - 19/06/2024
### New Features
- Introduced the "Space" feature to help you organize your pages more efficiently.
+2 -1
View File
@@ -13,6 +13,7 @@
"type": "dart",
"env": {
"RUST_LOG": "debug",
"RUST_BACKTRACE": "1"
},
// uncomment the following line to testing performance.
// "flutterMode": "profile",
@@ -137,4 +138,4 @@
"cwd": "${workspaceRoot}/appflowy_tauri/"
},
]
}
}
+2 -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.7"
APPFLOWY_VERSION = "0.6.1"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"
@@ -50,6 +50,7 @@ APP_ENVIRONMENT = "local"
FLUTTER_FLOWY_SDK_PATH = "appflowy_flutter/packages/appflowy_backend"
TAURI_BACKEND_SERVICE_PATH = "appflowy_tauri/src/services/backend"
WEB_BACKEND_SERVICE_PATH = "appflowy_web/src/services/backend"
WEB_LIB_PATH = "appflowy_web/wasm-libs/af-wasm"
TAURI_APP_BACKEND_SERVICE_PATH = "appflowy_web_app/src/application/services/tauri-services/backend"
# Test default config
TEST_CRATE_TYPE = "cdylib"
@@ -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
@@ -1 +0,0 @@
{ "artificial_intelligence": [ { "name": "ai-chip-spark", "keywords": [ "chip", "processor", "artificial", "intelligence", "ai" ], "content": "<svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<g clip-path=\"url(#clip0_1068_189544)\">\n<path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M8.71468 0.0400391C9.1289 0.0400391 9.46468 0.375825 9.46468 0.790039V1.97754H10.4999C11.3283 1.97754 11.9999 2.64911 11.9999 3.47754V4.51367H13.2186C13.6328 4.51367 13.9686 4.84946 13.9686 5.26367C13.9686 5.67789 13.6328 6.01367 13.2186 6.01367H11.9999V7.94324H13.2186C13.6328 7.94324 13.9686 8.27902 13.9686 8.69324C13.9686 9.10745 13.6328 9.44324 13.2186 9.44324H11.9999V10.4775C11.9999 11.306 11.3283 11.9775 10.4999 11.9775H9.46468V13.2099C9.46468 13.6241 9.1289 13.9599 8.71468 13.9599C8.30047 13.9599 7.96468 13.6241 7.96468 13.2099V11.9775H6.03524V13.2099C6.03524 13.6241 5.69945 13.9599 5.28524 13.9599C4.87103 13.9599 4.53524 13.6241 4.53524 13.2099V11.9775H3.49984C2.67141 11.9775 1.99984 11.306 1.99984 10.4775V9.44324H0.783203C0.368989 9.44324 0.0332031 9.10745 0.0332031 8.69324C0.0332031 8.27902 0.368989 7.94324 0.783203 7.94324H1.99984V6.01367H0.783203C0.368989 6.01367 0.0332031 5.67789 0.0332031 5.26367C0.0332031 4.84946 0.368989 4.51367 0.783203 4.51367H1.99984V3.47754C1.99984 2.64911 2.67141 1.97754 3.49984 1.97754H4.53524V0.790039C4.53524 0.375825 4.87103 0.0400391 5.28524 0.0400391C5.69945 0.0400391 6.03524 0.375825 6.03524 0.790039V1.97754H7.96468V0.790039C7.96468 0.375825 8.30047 0.0400391 8.71468 0.0400391ZM6.24357 4.5682C6.41951 3.76446 7.56392 3.75946 7.74688 4.56163L7.75499 4.59732C7.76038 4.62113 7.76543 4.64342 7.7708 4.66628C7.96264 5.48335 8.62389 6.10768 9.45164 6.25169C10.2908 6.39768 10.2908 7.60232 9.45164 7.74831C8.61948 7.89308 7.95559 8.52332 7.76776 9.34682L7.74688 9.43837C7.56392 10.2405 6.41951 10.2355 6.24357 9.4318L6.22638 9.35325C6.04547 8.52684 5.38257 7.89201 4.54911 7.74701C3.71142 7.60128 3.71141 6.39872 4.54911 6.25299C5.37967 6.10849 6.04086 5.47757 6.22447 4.65539L6.23707 4.598L6.24357 4.5682Z\" fill=\"black\"/>\n</g>\n<defs>\n<clipPath id=\"clip0_1068_189544\">\n<rect width=\"14\" height=\"14\" fill=\"white\"/>\n</clipPath>\n</defs>\n</svg>\n" }, { "name": "ai-cloud-spark", "keywords": [ "cloud", "internet", "server", "network", "artificial", "intelligence", "ai" ], "content": "<svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n <g clip-path=\"url(#clip0_1068_189529)\">\n <path fill-rule=\"evenodd\" clip-rule=\"evenodd\"\n d=\"M7.88381 0.165291C6.89196 0.0450664 5.88886 0.268505 5.04181 0.798345C4.25349 1.29145 3.64467 2.02219 3.30135 2.88247C2.87262 2.92977 2.45508 3.05187 2.06781 3.24362C1.63324 3.45878 1.24531 3.75744 0.92616 4.12253C0.281611 4.85986 -0.0436335 5.82304 0.0219743 6.80017C0.087582 7.77731 0.538668 8.68836 1.276 9.33291C1.52925 9.55429 1.80913 9.738 2.10708 9.88114C2.23853 9.09496 2.79956 8.38069 3.79016 8.20836C4.35848 8.10949 4.81104 7.67811 4.93736 7.11585L4.96023 7.01146C5.43591 4.83845 8.53 4.82492 9.02466 6.9937L9.03475 7.03814C9.04166 7.06865 9.047 7.09221 9.05244 7.11538C9.18449 7.6778 9.63973 8.10763 10.2096 8.20676C11.2609 8.38966 11.8287 9.18251 11.9131 10.0243C12.2792 9.884 12.6197 9.67707 12.9162 9.41068C13.5042 8.88231 13.878 8.15668 13.9669 7.37119C14.0558 6.58569 13.8536 5.79486 13.3985 5.14845C12.9811 4.55545 12.3775 4.12175 11.6866 3.91417C11.5613 2.99088 11.1436 2.12994 10.4923 1.45901C9.79633 0.742138 8.87567 0.285516 7.88381 0.165291ZM6.18132 7.27876C6.37146 6.41016 7.60823 6.40476 7.80595 7.27166L7.81577 7.3149C7.8225 7.34462 7.82879 7.37239 7.83553 7.4011C8.08204 8.451 8.93171 9.25323 9.99531 9.43826C10.9004 9.59573 10.9004 10.8951 9.99531 11.0525C8.92604 11.2386 8.07299 12.0484 7.83164 13.1065L7.80595 13.2191C7.60823 14.086 6.37146 14.0806 6.18132 13.212L6.16017 13.1154C5.92765 12.0532 5.07564 11.2373 4.0044 11.0509C3.10108 10.8938 3.10107 9.59701 4.0044 9.43986C5.07191 9.25414 5.92172 8.44323 6.15771 7.38648L6.17343 7.31494L6.18132 7.27876Z\"\n fill=\"black\"/>\n </g>\n <defs>\n <clipPath id=\"clip0_1068_189529\">\n <rect width=\"14\" height=\"14\" fill=\"white\"/>\n </clipPath>\n </defs>\n</svg>\n" }, { "name": "ai-edit-spark", "keywords": [ "change", "edit", "modify", "pencil", "write", "writing", "artificial", "intelligence", "ai" ], "content": "<svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<g clip-path=\"url(#clip0_1068_189535)\">\n<path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M2.85637 0.654372C3.04651 -0.214226 4.28328 -0.219632 4.481 0.647271L4.49082 0.690505C4.49756 0.720231 4.50384 0.747995 4.51058 0.776709C4.75709 1.82661 5.60676 2.62884 6.67036 2.81387C7.57548 2.97134 7.57548 4.27068 6.67036 4.42815C5.60109 4.61417 4.74804 5.42398 4.50669 6.48214L4.481 6.59475C4.28328 7.46166 3.04651Line truncated
@@ -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();
});
@@ -46,6 +46,7 @@ void main() {
await tester.ime.insertText(inputContent);
expect(find.text(inputContent, findRichText: true), findsOneWidget);
// TODO(nathan): remove the await
// 6 seconds for data sync
await tester.waitForSeconds(6);
@@ -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);
});
}
@@ -1,10 +1,10 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -25,7 +25,6 @@ void main() {
const lines = 3;
final text = List.generate(lines, (index) => 'line $index').join('\n');
AppFlowyClipboard.mockSetData(AppFlowyClipboardData(text: text));
ClipboardService.mockSetData(ClipboardServiceData(plainText: text));
await insertCodeBlockInDocument(tester);
@@ -52,9 +51,7 @@ Future<void> insertCodeBlockInDocument(WidgetTester tester) async {
// open the actions menu and insert the codeBlock
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_code.tr(),
offset: 150,
LocaleKeys.document_selectionMenu_codeBlock.tr(),
);
// wait for the codeBlock to be inserted
await tester.pumpAndSettle();
}
@@ -116,91 +116,6 @@ void main() {
]);
});
});
testWidgets('paste bulleted list in numbered list', (tester) async {
const inAppJson =
'{"document":{"type":"page","children":[{"type":"bulleted_list","children":[{"type":"bulleted_list","data":{"delta":[{"insert":"World"}]}}],"data":{"delta":[{"insert":"Hello"}]}}]}}';
await tester.pasteContent(
inAppJson: inAppJson,
beforeTest: (editorState) async {
final transaction = editorState.transaction;
// Insert two numbered list nodes
// 1. Parent One
// 2.
transaction.insertNodes(
[0],
[
Node(
type: NumberedListBlockKeys.type,
attributes: {
'delta': [
{"insert": "One"},
],
},
),
Node(
type: NumberedListBlockKeys.type,
attributes: {'delta': []},
),
],
);
// Set the selection to the second numbered list node (which has empty delta)
transaction.afterSelection = Selection.collapsed(Position(path: [1]));
await editorState.apply(transaction);
await tester.pumpAndSettle();
},
(editorState) {
final secondNode = editorState.getNodeAtPath([1]);
expect(secondNode?.delta?.toPlainText(), 'Hello');
expect(secondNode?.children.length, 1);
final childNode = secondNode?.children.first;
expect(childNode?.delta?.toPlainText(), 'World');
expect(childNode?.type, BulletedListBlockKeys.type);
},
);
});
});
testWidgets('paste text on part of bullet list', (tester) async {
const plainText = 'test';
await tester.pasteContent(
plainText: plainText,
beforeTest: (editorState) async {
final transaction = editorState.transaction;
transaction.insertNodes(
[0],
[
Node(
type: BulletedListBlockKeys.type,
attributes: {
'delta': [
{"insert": "bullet list"},
],
},
),
],
);
// Set the selection to the second numbered list node (which has empty delta)
transaction.afterSelection = Selection(
start: Position(path: [0], offset: 7),
end: Position(path: [0], offset: 11),
);
await editorState.apply(transaction);
await tester.pumpAndSettle();
},
(editorState) {
final node = editorState.getNodeAtPath([0]);
expect(node?.delta?.toPlainText(), 'bullet test');
expect(node?.type, BulletedListBlockKeys.type);
},
);
});
testWidgets('paste image(png) from memory', (tester) async {
@@ -283,6 +198,10 @@ void main() {
expect(editorState.document.root.children.length, 2);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ImageBlockKeys.type);
expect(
node.attributes[ImageBlockKeys.url],
'https://user-images.githubusercontent.com/9403740/262918875-603f4adb-58dd-49b5-8201-341d354935fd.png',
);
},
);
},
@@ -291,33 +210,42 @@ void main() {
testWidgets('paste the html content contains section', (tester) async {
const html =
'''<meta charset='utf-8'><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgb(0, 160, 113);"><strong style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important;">AppFlowy</strong></span></section><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgb(0, 160, 113);"><strong style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important;">Hello World</strong></span></section>''';
await tester.pasteContent(html: html, (editorState) {
expect(editorState.document.root.children.length, 2);
final node1 = editorState.getNodeAtPath([0])!;
final node2 = editorState.getNodeAtPath([1])!;
expect(node1.type, ParagraphBlockKeys.type);
expect(node2.type, ParagraphBlockKeys.type);
});
await tester.pasteContent(
html: html,
(editorState) {
expect(editorState.document.root.children.length, 2);
final node1 = editorState.getNodeAtPath([0])!;
final node2 = editorState.getNodeAtPath([1])!;
expect(node1.type, ParagraphBlockKeys.type);
expect(node2.type, ParagraphBlockKeys.type);
},
);
});
testWidgets('paste the html from google translation', (tester) async {
const html =
'''<meta charset='utf-8'><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgb(0, 160, 113);"><strong style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important;"><font style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; vertical-align: inherit;"><font style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; vertical-align: inherit;">new force</font></font></strong></span></section><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgb(0, 160, 113);"><strong style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important;"><font style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; vertical-align: inherit;"><font style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; vertical-align: inherit;">Assessment focus: potential motivations, empathy</font></font></strong></span></section><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><br style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important;"></section><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><font style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-Line truncated
await tester.pasteContent(html: html, (editorState) {
expect(editorState.document.root.children.length, 8);
});
await tester.pasteContent(
html: html,
(editorState) {
expect(editorState.document.root.children.length, 8);
},
);
});
testWidgets(
'auto convert url to link preview block',
(tester) async {
(widgetTester) async {
const url = 'https://appflowy.io';
await tester.pasteContent(plainText: url, (editorState) {
expect(editorState.document.root.children.length, 2);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, LinkPreviewBlockKeys.type);
expect(node.attributes[LinkPreviewBlockKeys.url], url);
});
await widgetTester.pasteContent(
plainText: url,
(editorState) {
expect(editorState.document.root.children.length, 2);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, LinkPreviewBlockKeys.type);
expect(node.attributes[LinkPreviewBlockKeys.url], url);
},
);
},
);
}
@@ -328,7 +256,6 @@ extension on WidgetTester {
Future<void> Function(EditorState editorState)? beforeTest,
String? plainText,
String? html,
String? inAppJson,
(String, Uint8List?)? image,
}) async {
await initializeAppFlowy();
@@ -344,7 +271,6 @@ extension on WidgetTester {
ClipboardServiceData(
plainText: plainText,
html: html,
inAppJson: inAppJson,
image: image,
),
);
@@ -12,13 +12,10 @@ import 'document_more_actions_test.dart' as document_more_actions_test;
import 'document_text_direction_test.dart' as document_text_direction_test;
import 'document_with_cover_image_test.dart' as document_with_cover_image_test;
import 'document_with_database_test.dart' as document_with_database_test;
import 'document_with_file_test.dart' as document_with_file_test;
import 'document_with_image_block_test.dart' as document_with_image_block_test;
import 'document_with_inline_math_equation_test.dart'
as document_with_inline_math_equation_test;
import 'document_with_inline_page_test.dart' as document_with_inline_page_test;
import 'document_with_multi_image_block_test.dart'
as document_with_multi_image_block_test;
import 'document_with_outline_block_test.dart' as document_with_outline_block;
import 'document_with_toggle_list_test.dart' as document_with_toggle_list_test;
import 'edit_document_test.dart' as document_edit_test;
@@ -41,8 +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();
document_with_file_test.main();
}
@@ -1,3 +1,4 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/board/presentation/board_page.dart';
import 'package:appflowy/plugins/database/calendar/presentation/calendar_page.dart';
import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart';
@@ -6,6 +7,7 @@ import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.d
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -20,7 +22,7 @@ void main() {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await insertLinkedDatabase(tester, ViewLayoutPB.Grid);
await insertReferenceDatabase(tester, ViewLayoutPB.Grid);
// validate the referenced grid is inserted
expect(
@@ -48,7 +50,7 @@ void main() {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await insertLinkedDatabase(tester, ViewLayoutPB.Board);
await insertReferenceDatabase(tester, ViewLayoutPB.Board);
// validate the referenced board is inserted
expect(
@@ -64,7 +66,7 @@ void main() {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await insertLinkedDatabase(tester, ViewLayoutPB.Calendar);
await insertReferenceDatabase(tester, ViewLayoutPB.Calendar);
// validate the referenced grid is inserted
expect(
@@ -127,7 +129,7 @@ void main() {
}
/// Insert a referenced database of [layout] into the document
Future<void> insertLinkedDatabase(
Future<void> insertReferenceDatabase(
WidgetTester tester,
ViewLayoutPB layout,
) async {
@@ -148,7 +150,7 @@ Future<void> insertLinkedDatabase(
// insert a referenced view
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
layout.slashMenuLinkedName,
layout.referencedMenuName,
);
final linkToPageMenu = find.byType(InlineActionsHandler);
@@ -174,8 +176,16 @@ Future<void> createInlineDatabase(
await tester.editor.tapLineOfEditorAt(0);
// insert a referenced view
await tester.editor.showSlashMenu();
final name = switch (layout) {
ViewLayoutPB.Grid => LocaleKeys.document_slashMenu_grid_createANewGrid.tr(),
ViewLayoutPB.Board =>
LocaleKeys.document_slashMenu_board_createANewBoard.tr(),
ViewLayoutPB.Calendar =>
LocaleKeys.document_slashMenu_calendar_createANewCalendar.tr(),
_ => '',
};
await tester.editor.tapSlashMenuItemWithName(
layout.slashMenuName,
name,
);
await tester.pumpAndSettle();
@@ -1,169 +0,0 @@
import 'dart:io';
import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../../shared/mock/mock_file_picker.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
TestWidgetsFlutterBinding.ensureInitialized();
group('file block in document', () {
testWidgets('insert a file from local file + rename file', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a new document
await tester.createNewPageWithNameUnderParent(name: 'Insert file test');
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_file.tr(),
);
expect(find.byType(FileBlockComponent), findsOneWidget);
await tester.tap(find.byType(FileBlockComponent));
await tester.pumpAndSettle(const Duration(seconds: 1));
expect(find.byType(FileUploadMenu), findsOneWidget);
final image = await rootBundle.load('assets/test/images/sample.jpeg');
final tempDirectory = await getTemporaryDirectory();
final filePath = p.join(tempDirectory.path, 'sample.jpeg');
final file = File(filePath)..writeAsBytesSync(image.buffer.asUint8List());
mockPickFilePaths(paths: [filePath]);
await getIt<KeyValueStorage>().set(KVKeys.kCloudType, '0');
await tester.tap(
find.text(LocaleKeys.document_plugins_file_fileUploadHint.tr()),
);
await tester.pumpAndSettle();
expect(find.byType(FileUploadMenu), findsNothing);
expect(find.byType(FileBlockComponent), findsOneWidget);
final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
expect(node.type, FileBlockKeys.type);
expect(node.attributes[FileBlockKeys.url], isNotEmpty);
expect(
node.attributes[FileBlockKeys.urlType],
FileUrlType.local.toIntValue(),
);
// Check the name of the file is correctly extracted
expect(node.attributes[FileBlockKeys.name], 'sample.jpeg');
expect(find.text('sample.jpeg'), findsOneWidget);
const newName = "Renamed file";
// Hover on the widget to see the three dots to open FileBlockMenu
await tester.hoverOnWidget(
find.byType(FileBlockComponent),
onHover: () async {
await tester.tap(find.byType(FileMenuTrigger));
await tester.pumpAndSettle();
await tester.tap(
find.text(LocaleKeys.document_plugins_file_renameFile_title.tr()),
);
},
);
await tester.pumpAndSettle();
expect(find.byType(FlowyTextField), findsOneWidget);
await tester.enterText(find.byType(FlowyTextField), newName);
await tester.pump();
await tester.tap(find.text(LocaleKeys.button_save.tr()));
await tester.pumpAndSettle();
final updatedNode =
tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
expect(updatedNode.attributes[FileBlockKeys.name], newName);
expect(find.text(newName), findsOneWidget);
// remove the temp file
file.deleteSync();
});
testWidgets('insert a file from network', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a new document
await tester.createNewPageWithNameUnderParent(name: 'Insert file test');
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_file.tr(),
);
expect(find.byType(FileBlockComponent), findsOneWidget);
await tester.tap(find.byType(FileBlockComponent));
await tester.pumpAndSettle(const Duration(seconds: 1));
expect(find.byType(FileUploadMenu), findsOneWidget);
// Navigate to integrate link tab
await tester.tapButtonWithName(
LocaleKeys.document_plugins_file_networkTab.tr(),
);
await tester.pumpAndSettle();
const url =
'https://images.unsplash.com/photo-1469474968028-56623f02e42e?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&dl=david-marcu-78A265wPiO4-unsplash.jpg&w=640';
await tester.enterText(
find.descendant(
of: find.byType(FileUploadMenu),
matching: find.byType(FlowyTextField),
),
url,
);
await tester.tapButton(
find.descendant(
of: find.byType(FileUploadMenu),
matching: find.text(
LocaleKeys.document_plugins_file_networkAction.tr(),
findRichText: true,
),
),
);
await tester.pumpAndSettle();
expect(find.byType(FileUploadMenu), findsNothing);
expect(find.byType(FileBlockComponent), findsOneWidget);
final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
expect(node.type, FileBlockKeys.type);
expect(node.attributes[FileBlockKeys.url], isNotEmpty);
expect(
node.attributes[FileBlockKeys.urlType],
FileUrlType.network.toIntValue(),
);
// Check the name is correctly extracted from the url
expect(
node.attributes[FileBlockKeys.name],
'photo-1469474968028-56623f02e42e',
);
expect(find.text('photo-1469474968028-56623f02e42e'), findsOneWidget);
});
});
}
@@ -3,12 +3,12 @@ import 'dart:io';
import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/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;
@@ -36,15 +36,13 @@ 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
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_image.tr(),
);
await tester.editor.tapSlashMenuItemWithName('Image');
expect(find.byType(CustomImageBlockComponent), findsOneWidget);
expect(find.byType(ImagePlaceholder), findsOneWidget);
expect(
@@ -86,15 +84,13 @@ 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
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_image.tr(),
);
await tester.editor.tapSlashMenuItemWithName('Image');
expect(find.byType(CustomImageBlockComponent), findsOneWidget);
expect(find.byType(ImagePlaceholder), findsOneWidget);
expect(
@@ -141,15 +137,13 @@ 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
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_image.tr(),
);
await tester.editor.tapSlashMenuItemWithName('Image');
expect(find.byType(CustomImageBlockComponent), findsOneWidget);
expect(find.byType(ImagePlaceholder), findsOneWidget);
expect(
@@ -167,69 +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(
LocaleKeys.document_slashMenu_name_image.tr(),
);
expect(find.byType(CustomImageBlockComponent), findsOneWidget);
expect(find.byType(ImagePlaceholder), findsOneWidget);
expect(
find.descendant(
of: find.byType(ImagePlaceholder),
matching: find.byType(AppFlowyPopover),
),
findsOneWidget,
);
expect(find.byType(UploadImageMenu), findsOneWidget);
final firstImage =
await rootBundle.load('assets/test/images/sample.jpeg');
final secondImage =
await rootBundle.load('assets/test/images/sample.gif');
final tempDirectory = await getTemporaryDirectory();
final firstImagePath = p.join(tempDirectory.path, 'sample.jpeg');
final firstFile = File(firstImagePath)
..writeAsBytesSync(firstImage.buffer.asUint8List());
final secondImagePath = p.join(tempDirectory.path, 'sample.gif');
final secondFile = File(secondImagePath)
..writeAsBytesSync(secondImage.buffer.asUint8List());
mockPickFilePaths(paths: [firstImagePath, secondImagePath]);
await getIt<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()]);
});
});
}
@@ -33,7 +33,7 @@ void main() {
);
// tap the inline math equation button
final inlineMathEquationButton = find.findFlowyTooltip(
final inlineMathEquationButton = find.byTooltip(
LocaleKeys.document_plugins_createInlineMathEquation.tr(),
);
await tester.tapButton(inlineMathEquationButton);
@@ -78,7 +78,7 @@ void main() {
);
// tap the inline math equation button
var inlineMathEquationButton = find.findFlowyTooltip(
var inlineMathEquationButton = find.byTooltip(
LocaleKeys.document_plugins_createInlineMathEquation.tr(),
);
await tester.tapButton(inlineMathEquationButton);
@@ -93,11 +93,11 @@ void main() {
);
// expect to the see the inline math equation button is highlighted
inlineMathEquationButton = find.descendant(
of: find.findFlowyTooltip(
LocaleKeys.document_plugins_createInlineMathEquation.tr(),
),
matching: find.byType(SVGIconItemWidget),
inlineMathEquationButton = find.byWidgetPredicate(
(widget) =>
widget is SVGIconItemWidget &&
widget.tooltip ==
LocaleKeys.document_plugins_createInlineMathEquation.tr(),
);
expect(
tester.widget<SVGIconItemWidget>(inlineMathEquationButton).isHighlight,
@@ -1,293 +0,0 @@
import 'dart:io';
import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/image_render.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/shared/appflowy_network_image.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart';
import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../../shared/mock/mock_file_picker.dart';
import '../../shared/util.dart';
import '../board/board_hide_groups_test.dart';
void main() {
setUp(() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
TestWidgetsFlutterBinding.ensureInitialized();
});
group('multi image block in document', () {
testWidgets('insert images from local and use interactive viewer',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a new document
await tester.createNewPageWithNameUnderParent(
name: 'multi image block test',
);
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_photoGallery.tr(),
offset: 100,
);
expect(find.byType(MultiImageBlockComponent), findsOneWidget);
expect(find.byType(MultiImagePlaceholder), findsOneWidget);
await tester.tap(find.byType(MultiImagePlaceholder));
await tester.pumpAndSettle();
expect(find.byType(UploadImageMenu), findsOneWidget);
final firstImage =
await rootBundle.load('assets/test/images/sample.jpeg');
final secondImage =
await rootBundle.load('assets/test/images/sample.gif');
final tempDirectory = await getTemporaryDirectory();
final firstImagePath = p.join(tempDirectory.path, 'sample.jpeg');
final firstFile = File(firstImagePath)
..writeAsBytesSync(firstImage.buffer.asUint8List());
final secondImagePath = p.join(tempDirectory.path, 'sample.gif');
final secondFile = File(secondImagePath)
..writeAsBytesSync(secondImage.buffer.asUint8List());
mockPickFilePaths(paths: [firstImagePath, secondImagePath]);
await getIt<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(
LocaleKeys.document_slashMenu_name_photoGallery.tr(),
offset: 100,
);
expect(find.byType(MultiImageBlockComponent), findsOneWidget);
expect(find.byType(MultiImagePlaceholder), findsOneWidget);
await tester.tap(find.byType(MultiImagePlaceholder));
await tester.pumpAndSettle();
expect(find.byType(UploadImageMenu), findsOneWidget);
await tester.tapButtonWithName(
LocaleKeys.document_imageBlock_embedLink_label.tr(),
);
const url =
'https://images.unsplash.com/photo-1469474968028-56623f02e42e?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&dl=david-marcu-78A265wPiO4-unsplash.jpg&w=640';
await tester.enterText(
find.descendant(
of: find.byType(EmbedImageUrlWidget),
matching: find.byType(TextField),
),
url,
);
await tester.pumpAndSettle();
await tester.tapButton(
find.descendant(
of: find.byType(EmbedImageUrlWidget),
matching: find.text(
LocaleKeys.document_imageBlock_embedLink_label.tr(),
findRichText: true,
),
),
);
await tester.pumpAndSettle();
expect(find.byType(ImageBrowserLayout), findsOneWidget);
final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
expect(node.type, MultiImageBlockKeys.type);
final data = MultiImageData.fromJson(
node.attributes[MultiImageBlockKeys.images],
);
expect(data.images.length, 1);
final imageFinder = find
.byWidgetPredicate(
(w) => w is FlowyNetworkImage && w.url == url,
)
.first;
// Insert two images from network
for (int i = 0; i < 2; i++) {
// Hover on the image to show the image toolbar
await tester.hoverOnWidget(
imageFinder,
onHover: () async {
// Click on the add
final addFinder = find.descendant(
of: find.byType(MultiImageMenu),
matching: find.byFlowySvg(FlowySvgs.add_s),
);
expect(addFinder, findsOneWidget);
await tester.tap(addFinder);
await tester.pumpAndSettle();
await tester.tapButtonWithName(
LocaleKeys.document_imageBlock_embedLink_label.tr(),
);
await tester.enterText(
find.descendant(
of: find.byType(EmbedImageUrlWidget),
matching: find.byType(TextField),
),
url,
);
await tester.pumpAndSettle();
await tester.tapButton(
find.descendant(
of: find.byType(EmbedImageUrlWidget),
matching: find.text(
LocaleKeys.document_imageBlock_embedLink_label.tr(),
findRichText: true,
),
),
);
await tester.pumpAndSettle();
},
);
}
await tester.pumpAndSettle();
// There should be 4 images visible now, where 2 are thumbnails
expect(find.byType(ThumbnailItem), findsNWidgets(3));
// And all three use ImageRender
expect(find.byType(ImageRender), findsNWidgets(4));
// Hover on and delete the first thumbnail image
await tester.hoverOnWidget(find.byType(ThumbnailItem).first);
final deleteFinder = find
.descendant(
of: find.byType(ThumbnailItem),
matching: find.byFlowySvg(FlowySvgs.delete_s),
)
.first;
expect(deleteFinder, findsOneWidget);
await tester.tap(deleteFinder);
await tester.pumpAndSettle();
expect(find.byType(ImageRender), findsNWidgets(3));
// Delete one from interactive viewer
await tester.tap(imageFinder);
await tester.pump(kDoubleTapMinTime);
await tester.tap(imageFinder);
await tester.pumpAndSettle();
final ivFinder = find.byType(InteractiveImageViewer);
expect(ivFinder, findsOneWidget);
await tester.tap(
find.descendant(
of: find.byType(InteractiveImageToolbar),
matching: find.byFlowySvg(FlowySvgs.delete_s),
),
);
await tester.pumpAndSettle();
expect(find.byType(InteractiveImageViewer), findsNothing);
// There should be 1 image and the thumbnail for said image still visible
expect(find.byType(ImageRender), findsNWidgets(2));
});
});
}
@@ -171,8 +171,7 @@ Future<void> insertOutlineInDocument(WidgetTester tester) async {
// open the actions menu and insert the outline block
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_outline.tr(),
offset: 100,
LocaleKeys.document_selectionMenu_outline.tr(),
);
await tester.pumpAndSettle();
}
@@ -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,
);
});
@@ -0,0 +1,113 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/openai_client.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/render/toolbar/toolbar_widget.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/mock/mock_openai_repository.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
const service = TestWorkspaceService(TestWorkspace.aiWorkSpace);
group('integration tests for open-ai smart menu', () {
setUpAll(() async => service.setUpAll());
setUp(() async => service.setUp());
testWidgets('testing selection on open-ai smart menu replace',
(tester) async {
final appFlowyEditor = await setUpOpenAITesting(tester);
final editorState = appFlowyEditor.editorState;
editorState.service.selectionService.updateSelection(
Selection(
start: Position(path: [1], offset: 4),
end: Position(path: [1], offset: 10),
),
);
await tester.pumpAndSettle(const Duration(milliseconds: 500));
await tester.pumpAndSettle();
expect(find.byType(ToolbarWidget), findsAtLeastNWidgets(1));
await tester.tap(find.byTooltip('AI Assistants'));
await tester.pumpAndSettle(const Duration(milliseconds: 500));
await tester.tap(find.text('Summarize'));
await tester.pumpAndSettle();
await tester
.tap(find.byType(FlowyRichTextButton, skipOffstage: false).first);
await tester.pumpAndSettle();
expect(
editorState.service.selectionService.currentSelection.value,
Selection(
start: Position(path: [1], offset: 4),
end: Position(path: [1], offset: 84),
),
);
});
testWidgets('testing selection on open-ai smart menu insert',
(tester) async {
final appFlowyEditor = await setUpOpenAITesting(tester);
final editorState = appFlowyEditor.editorState;
editorState.service.selectionService.updateSelection(
Selection(
start: Position(path: [1]),
end: Position(path: [1], offset: 5),
),
);
await tester.pumpAndSettle(const Duration(milliseconds: 500));
await tester.pumpAndSettle();
expect(find.byType(ToolbarWidget), findsAtLeastNWidgets(1));
await tester.tap(find.byTooltip('AI Assistants'));
await tester.pumpAndSettle(const Duration(milliseconds: 500));
await tester.tap(find.text('Summarize'));
await tester.pumpAndSettle();
await tester
.tap(find.byType(FlowyRichTextButton, skipOffstage: false).at(1));
await tester.pumpAndSettle();
expect(
editorState.service.selectionService.currentSelection.value,
Selection(
start: Position(path: [2]),
end: Position(path: [3]),
),
);
});
});
}
Future<AppFlowyEditor> setUpOpenAITesting(WidgetTester tester) async {
await tester.initializeAppFlowy();
await mockOpenAIRepository();
await simulateKeyDownEvent(LogicalKeyboardKey.controlLeft);
await simulateKeyDownEvent(LogicalKeyboardKey.backslash);
await tester.pumpAndSettle();
final Finder editor = find.byType(AppFlowyEditor);
await tester.tap(editor);
await tester.pumpAndSettle();
return tester.state(editor).widget as AppFlowyEditor;
}
Future<void> mockOpenAIRepository() async {
await getIt.unregister<OpenAIRepository>();
getIt.registerFactoryAsync<OpenAIRepository>(
() => Future.value(
MockOpenAIRepository(),
),
);
return;
}
@@ -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,6 +1,9 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/env/cloud_env_test.dart';
import 'package:appflowy/startup/entry_point.dart';
@@ -13,8 +16,6 @@ import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widget
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
@@ -230,16 +231,6 @@ extension AppFlowyFinderTestBase on CommonFinders {
(widget) => widget is FlowyText && widget.text == text,
);
}
Finder findFlowyTooltip(String richMessage, {bool skipOffstage = true}) {
return byWidgetPredicate(
(widget) =>
widget is FlowyTooltip &&
widget.richMessage != null &&
widget.richMessage!.toPlainText().contains(richMessage),
skipOffstage: skipOffstage,
);
}
}
Future<void> useTestSupabaseCloud() 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);
}
@@ -662,34 +663,4 @@ extension ViewLayoutPBTest on ViewLayoutPB {
throw UnsupportedError('Unsupported layout: $this');
}
}
String get slashMenuName {
switch (this) {
case ViewLayoutPB.Grid:
return LocaleKeys.document_slashMenu_name_grid.tr();
case ViewLayoutPB.Board:
return LocaleKeys.document_slashMenu_name_kanban.tr();
case ViewLayoutPB.Document:
return LocaleKeys.document_slashMenu_name_doc.tr();
case ViewLayoutPB.Calendar:
return LocaleKeys.document_slashMenu_name_calendar.tr();
default:
throw UnsupportedError('Unsupported layout: $this');
}
}
String get slashMenuLinkedName {
switch (this) {
case ViewLayoutPB.Grid:
return LocaleKeys.document_slashMenu_name_linkedGrid.tr();
case ViewLayoutPB.Board:
return LocaleKeys.document_slashMenu_name_linkedKanban.tr();
case ViewLayoutPB.Document:
return LocaleKeys.document_slashMenu_name_linkedDoc.tr();
case ViewLayoutPB.Calendar:
return LocaleKeys.document_slashMenu_name_linkedCalendar.tr();
default:
throw UnsupportedError('Unsupported layout: $this');
}
}
}
@@ -3,15 +3,15 @@ import 'dart:ui';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
import 'package:appflowy/plugins/base/emoji/emoji_skin_tone.dart';
import 'package:appflowy/plugins/base/icon/icon_picker.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_add_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/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/shared/icon_emoji_picker/emoji_skin_tone.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@@ -84,7 +84,7 @@ class EditorOperations {
final Finder button = !isInPicker
? find.text(LocaleKeys.document_plugins_cover_removeIcon.tr())
: find.descendant(
of: find.byType(FlowyIconEmojiPicker),
of: find.byType(FlowyIconPicker),
matching: find.text(LocaleKeys.button_remove.tr()),
);
await tester.tapButton(button);
@@ -170,26 +170,8 @@ class EditorOperations {
/// Tap the slash menu item with [name]
///
/// Must call [showSlashMenu] first.
Future<void> tapSlashMenuItemWithName(
String name, {
double offset = 200,
}) async {
final slashMenu = find
.ancestor(
of: find.byType(SelectionMenuItemWidget),
matching: find.byWidgetPredicate(
(widget) => widget is Scrollable,
),
)
.first;
Future<void> tapSlashMenuItemWithName(String name) async {
final slashMenuItem = find.text(name, findRichText: true);
await tester.scrollUntilVisible(
slashMenuItem,
offset,
scrollable: slashMenu,
duration: const Duration(milliseconds: 250),
);
assert(slashMenuItem.hasFound);
await tester.tapButton(slashMenuItem);
}
@@ -44,7 +44,7 @@ class MockOpenAIRepository extends HttpOpenAIRepository {
required Future<void> Function() onStart,
required Future<void> Function(TextCompletionResponse response) onProcess,
required Future<void> Function() onEnd,
required void Function(AIError error) onError,
required void Function(OpenAIError error) onError,
String? suffix,
int maxTokens = 2048,
double temperature = 0.3,
@@ -12,23 +12,17 @@ import 'package:flowy_infra_ui/style_widget/text_field.dart';
import 'package:flutter_test/flutter_test.dart';
import '../desktop/board/board_hide_groups_test.dart';
import 'base.dart';
import 'common_operations.dart';
extension AppFlowySettings on WidgetTester {
/// Open settings page
Future<void> openSettings() async {
final settingsDialog = find.byType(SettingsDialog);
// tap empty area to close the settings page
while (settingsDialog.evaluate().isNotEmpty) {
await tapAt(Offset.zero);
await pumpAndSettle();
}
final settingsButton = find.byType(UserSettingButton);
expect(settingsButton, findsOneWidget);
await tapButton(settingsButton);
final settingsDialog = find.byType(SettingsDialog);
expect(settingsDialog, findsOneWidget);
return;
}
@@ -1,5 +1,5 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/plugins/base/icon/icon_picker.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart';
@@ -58,7 +58,7 @@ extension AppFlowyWorkspace on WidgetTester {
);
expect(iconButton, findsOneWidget);
await tapButton(iconButton);
final iconPicker = find.byType(FlowyIconEmojiPicker);
final iconPicker = find.byType(FlowyIconPicker);
expect(iconPicker, findsOneWidget);
await tapButton(find.findTextInFlowyText(icon));
}
+1 -7
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: e9a18c7be5413da53898f660530c56f35edfba9c
fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265
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
@@ -18,13 +18,6 @@ class KVKeys {
/// {'dx': 10.0, 'dy': 10.0}
static const String windowPosition = 'windowPosition';
/// The key for saving the window status
///
/// The value is a json string with the following format:
/// { 'windowMaximized': true }
///
static const String windowMaximized = 'windowMaximized';
static const String kDocumentAppearanceFontSize =
'kDocumentAppearanceFontSize';
static const String kDocumentAppearanceFontFamily =
@@ -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;
}
@@ -1,217 +0,0 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart';
import 'package:appflowy/plugins/document/application/document_service.dart';
import 'package:appflowy/user/application/reminder/reminder_extension.dart';
import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart';
import 'package:appflowy/workspace/application/settings/date_time/time_format_ext.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:bloc/bloc.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:time/time.dart';
part 'notification_reminder_bloc.freezed.dart';
class NotificationReminderBloc
extends Bloc<NotificationReminderEvent, NotificationReminderState> {
NotificationReminderBloc() : super(NotificationReminderState.initial()) {
on<NotificationReminderEvent>((event, emit) async {
await event.when(
initial: (reminder, dateFormat, timeFormat) async {
this.reminder = reminder;
this.dateFormat = dateFormat;
this.timeFormat = timeFormat;
add(const NotificationReminderEvent.reset());
},
reset: () async {
final createdAt = await _getCreatedAt(
reminder,
dateFormat,
timeFormat,
);
final view = await _getView(reminder);
if (view == null) {
emit(
NotificationReminderState(
createdAt: createdAt,
pageTitle: '',
reminderContent: '',
status: NotificationReminderStatus.error,
),
);
}
final layout = view!.layout;
if (layout.isDocumentView) {
final node = await _getContent(reminder);
if (node != null) {
emit(
NotificationReminderState(
createdAt: createdAt,
pageTitle: view.name,
view: view,
reminderContent: node.delta?.toPlainText() ?? '',
nodes: [node],
status: NotificationReminderStatus.loaded,
),
);
}
} else if (layout.isDatabaseView) {
emit(
NotificationReminderState(
createdAt: createdAt,
pageTitle: view.name,
view: view,
reminderContent: reminder.message,
status: NotificationReminderStatus.loaded,
),
);
}
},
);
});
}
late final ReminderPB reminder;
late final UserDateFormatPB dateFormat;
late final UserTimeFormatPB timeFormat;
Future<String> _getCreatedAt(
ReminderPB reminder,
UserDateFormatPB dateFormat,
UserTimeFormatPB timeFormat,
) async {
final rCreatedAt = reminder.createdAt;
final createdAt = rCreatedAt != null
? _formatTimestamp(
rCreatedAt,
timeFormat: timeFormat,
dateFormate: dateFormat,
)
: '';
return createdAt;
}
Future<ViewPB?> _getView(ReminderPB reminder) async {
return ViewBackendService.getView(reminder.objectId)
.fold((s) => s, (_) => null);
}
Future<Node?> _getContent(ReminderPB reminder) async {
final blockId = reminder.meta[ReminderMetaKeys.blockId];
if (blockId == null) {
return null;
}
final document = await DocumentService()
.openDocument(
documentId: reminder.objectId,
)
.fold((s) => s.toDocument(), (_) => null);
if (document == null) {
return null;
}
final node = _searchById(document.root, blockId);
if (node == null) {
return null;
}
return node;
}
Node? _searchById(Node current, String id) {
if (current.id == id) {
return current;
}
if (current.children.isNotEmpty) {
for (final child in current.children) {
final node = _searchById(child, id);
if (node != null) {
return node;
}
}
}
return null;
}
String _formatTimestamp(
int timestamp, {
required UserDateFormatPB dateFormate,
required UserTimeFormatPB timeFormat,
}) {
final now = DateTime.now();
final dateTime = DateTime.fromMillisecondsSinceEpoch(timestamp);
final difference = now.difference(dateTime);
final String date;
if (difference.inMinutes < 1) {
date = LocaleKeys.sideBar_justNow.tr();
} else if (difference.inHours < 1 && dateTime.isToday) {
// Less than 1 hour
date = LocaleKeys.sideBar_minutesAgo
.tr(namedArgs: {'count': difference.inMinutes.toString()});
} else if (difference.inHours >= 1 && dateTime.isToday) {
// in same day
date = timeFormat.formatTime(dateTime);
} else {
date = dateFormate.formatDate(dateTime, false);
}
return date;
}
}
@freezed
class NotificationReminderEvent with _$NotificationReminderEvent {
const factory NotificationReminderEvent.initial(
ReminderPB reminder,
UserDateFormatPB dateFormat,
UserTimeFormatPB timeFormat,
) = _Initial;
const factory NotificationReminderEvent.reset() = _Reset;
}
enum NotificationReminderStatus {
initial,
loading,
loaded,
error,
}
@freezed
class NotificationReminderState with _$NotificationReminderState {
const NotificationReminderState._();
const factory NotificationReminderState({
required String createdAt,
required String pageTitle,
required String reminderContent,
@Default(NotificationReminderStatus.initial)
NotificationReminderStatus status,
@Default([]) List<Node> nodes,
ViewPB? view,
}) = _NotificationReminderState;
factory NotificationReminderState.initial() =>
const NotificationReminderState(
createdAt: '',
pageTitle: '',
reminderContent: '',
);
}
@@ -23,9 +23,6 @@ class DocumentPageStyleBloc
await event.when(
initial: () async {
try {
if (view.id.isEmpty) {
return;
}
final layoutObject =
await ViewBackendService.getView(view.id).fold(
(s) => jsonDecode(s.extra),
@@ -149,7 +146,7 @@ class DocumentPageStyleBloc
) {
double padding = switch (fontLayout) {
PageStyleFontLayout.small => 1.0,
PageStyleFontLayout.normal => 1.0,
PageStyleFontLayout.normal => 2.0,
PageStyleFontLayout.large => 4.0,
};
switch (lineHeightLayout) {
@@ -165,16 +162,6 @@ class DocumentPageStyleBloc
return max(0, padding);
}
double calculateIconScale(
PageStyleFontLayout fontLayout,
) {
return switch (fontLayout) {
PageStyleFontLayout.small => 0.8,
PageStyleFontLayout.normal => 1.0,
PageStyleFontLayout.large => 1.2,
};
}
PageStyleFontLayout _getSelectedFontLayout(Map layoutObject) {
final fontLayout = layoutObject[ViewExtKeys.fontLayoutKey] ??
PageStyleFontLayout.normal.toString();
@@ -1,54 +0,0 @@
import 'package:appflowy/shared/feedback_gesture_detector.dart';
import 'package:flutter/material.dart';
class AnimatedGestureDetector extends StatefulWidget {
const AnimatedGestureDetector({
super.key,
this.scaleFactor = 0.98,
this.feedback = true,
this.duration = const Duration(milliseconds: 100),
this.alignment = Alignment.center,
this.behavior = HitTestBehavior.opaque,
this.onTapUp,
required this.child,
});
final Widget child;
final double scaleFactor;
final Duration duration;
final Alignment alignment;
final bool feedback;
final HitTestBehavior behavior;
final VoidCallback? onTapUp;
@override
State<AnimatedGestureDetector> createState() =>
_AnimatedGestureDetectorState();
}
class _AnimatedGestureDetectorState extends State<AnimatedGestureDetector> {
double scale = 1.0;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: widget.behavior,
onTapUp: (details) {
setState(() => scale = 1.0);
HapticFeedbackType.light.call();
widget.onTapUp?.call();
},
onTapDown: (details) {
setState(() => scale = widget.scaleFactor);
},
child: AnimatedScale(
scale: scale,
alignment: widget.alignment,
duration: widget.duration,
child: widget.child,
),
);
}
}
@@ -46,18 +46,10 @@ class _MobileViewPageState extends State<MobileViewPage> {
// control the app bar opacity when in immersive mode
final ValueNotifier<double> _appBarOpacity = ValueNotifier(1.0);
@override
void initState() {
super.initState();
getIt<ReminderBloc>().add(const ReminderEvent.started());
}
@override
void dispose() {
_appBarOpacity.dispose();
_scrollNotificationObserver = null;
super.dispose();
}
@@ -86,7 +78,8 @@ class _MobileViewPageState extends State<MobileViewPage> {
ViewBloc(view: view)..add(const ViewEvent.initial()),
),
BlocProvider.value(
value: getIt<ReminderBloc>(),
value: getIt<ReminderBloc>()
..add(const ReminderEvent.started()),
),
if (view.layout.isDocumentView)
BlocProvider(
@@ -132,7 +125,7 @@ class _MobileViewPageState extends State<MobileViewPage> {
return child;
},
)
: SafeArea(child: child);
: child;
return Scaffold(
extendBodyBehindAppBar: isDocument,
appBar: appBar,
@@ -102,7 +102,6 @@ class _TypeOptionMenuItem<T> extends StatelessWidget {
value.text,
fontSize: 14.0,
maxLines: 2,
lineHeight: 1.0,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
@@ -24,10 +24,9 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget {
height: 52.0,
leftIcon: const FlowySvg(
FlowySvgs.icon_document_s,
size: Size.square(20),
size: Size.square(18),
),
showTopBorder: false,
showBottomBorder: false,
onTap: () => onAction(ViewLayoutPB.Document),
),
FlowyOptionTile.text(
@@ -35,10 +34,9 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget {
height: 52.0,
leftIcon: const FlowySvg(
FlowySvgs.icon_grid_s,
size: Size.square(20),
size: Size.square(18),
),
showTopBorder: false,
showBottomBorder: false,
onTap: () => onAction(ViewLayoutPB.Grid),
),
FlowyOptionTile.text(
@@ -46,10 +44,9 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget {
height: 52.0,
leftIcon: const FlowySvg(
FlowySvgs.icon_board_s,
size: Size.square(20),
size: Size.square(18),
),
showTopBorder: false,
showBottomBorder: false,
onTap: () => onAction(ViewLayoutPB.Board),
),
FlowyOptionTile.text(
@@ -57,10 +54,9 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget {
height: 52.0,
leftIcon: const FlowySvg(
FlowySvgs.icon_calendar_s,
size: Size.square(20),
size: Size.square(18),
),
showTopBorder: false,
showBottomBorder: false,
onTap: () => onAction(ViewLayoutPB.Calendar),
),
FlowyOptionTile.text(
@@ -68,10 +64,9 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget {
height: 52.0,
leftIcon: const FlowySvg(
FlowySvgs.chat_ai_page_s,
size: Size.square(20),
size: Size.square(18),
),
showTopBorder: false,
showBottomBorder: false,
onTap: () => onAction(ViewLayoutPB.Chat),
),
],
@@ -1,3 +1,4 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart';
@@ -5,7 +6,6 @@ import 'package:appflowy/startup/tasks/app_widget.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@@ -109,6 +109,16 @@ class _MobileViewItemBottomSheetState extends State<MobileViewItemBottomSheet> {
await _showConfirmDialog(
onDelete: () {
recentViewsBloc.add(RecentViewsEvent.removeRecentViews([viewId]));
fToast.showToast(
child: const _RemoveToast(),
positionedToastBuilder: (context, child) {
return Positioned.fill(
top: 450,
child: child,
);
},
);
},
);
}
@@ -126,14 +136,38 @@ class _MobileViewItemBottomSheetState extends State<MobileViewItemBottomSheet> {
),
onRightButtonPressed: (context) {
onDelete();
Navigator.pop(context);
showToastNotification(
context,
message: LocaleKeys.sideBar_removeSuccess.tr(),
);
},
);
}
}
class _RemoveToast extends StatelessWidget {
const _RemoveToast();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 13.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12.0),
color: const Color(0xE5171717),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const FlowySvg(
FlowySvgs.success_s,
blendMode: null,
),
const HSpace(8.0),
FlowyText.regular(
LocaleKeys.sideBar_removeSuccess.tr(),
fontSize: 16.0,
color: Colors.white,
),
],
),
);
}
}
@@ -7,7 +7,6 @@ import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@@ -41,32 +40,18 @@ enum MobilePaneActionType {
backgroundColor: const Color(0xFFFA217F),
svg: FlowySvgs.favorite_section_remove_from_favorite_s,
size: 24.0,
onPressed: (context) {
showToastNotification(
context,
message: LocaleKeys.button_unfavoriteSuccessfully.tr(),
);
context
.read<FavoriteBloc>()
.add(FavoriteEvent.toggle(context.read<ViewBloc>().view));
},
onPressed: (context) => context
.read<FavoriteBloc>()
.add(FavoriteEvent.toggle(context.read<ViewBloc>().view)),
);
case MobilePaneActionType.addToFavorites:
return MobileSlideActionButton(
backgroundColor: const Color(0xFF00C8FF),
svg: FlowySvgs.favorite_s,
size: 24.0,
onPressed: (context) {
showToastNotification(
context,
message: LocaleKeys.button_favoriteSuccessfully.tr(),
);
context
.read<FavoriteBloc>()
.add(FavoriteEvent.toggle(context.read<ViewBloc>().view));
},
onPressed: (context) => context
.read<FavoriteBloc>()
.add(FavoriteEvent.toggle(context.read<ViewBloc>().view)),
);
case MobilePaneActionType.add:
return MobileSlideActionButton(
@@ -84,7 +69,6 @@ enum MobilePaneActionType {
showDragHandle: true,
showCloseButton: true,
useRootNavigator: true,
showDivider: false,
backgroundColor: Theme.of(context).colorScheme.surface,
builder: (sheetContext) {
return AddNewPageWidgetBottomSheet(
@@ -161,6 +145,8 @@ enum MobilePaneActionType {
? MobileViewItemBottomSheetBodyAction.removeFromFavorites
: MobileViewItemBottomSheetBodyAction.addToFavorites,
MobileViewItemBottomSheetBodyAction.divider,
if (view.layout != ViewLayoutPB.Chat)
MobileViewItemBottomSheetBodyAction.duplicate,
MobileViewItemBottomSheetBodyAction.divider,
MobileViewItemBottomSheetBodyAction.removeFromRecent,
];
@@ -170,6 +156,7 @@ enum MobilePaneActionType {
? MobileViewItemBottomSheetBodyAction.removeFromFavorites
: MobileViewItemBottomSheetBodyAction.addToFavorites,
MobileViewItemBottomSheetBodyAction.divider,
MobileViewItemBottomSheetBodyAction.duplicate,
];
}
}
@@ -194,13 +181,12 @@ ActionPane buildEndActionPane(
bool needSpace = true,
MobilePageCardType? cardType,
FolderSpaceType? spaceType,
required double spaceRatio,
}) {
return ActionPane(
motion: const ScrollMotion(),
extentRatio: actions.length / spaceRatio,
extentRatio: actions.length / 5,
children: [
if (needSpace) const HSpace(60),
if (needSpace) const HSpace(20),
...actions.map(
(action) => action.actionButton(
context,
@@ -70,7 +70,6 @@ Future<T?> showMobileBottomSheet<T>(
backgroundColor ??= Theme.of(context).brightness == Brightness.light
? const Color(0xFFF7F8FB)
: const Color(0xFF23262B);
barrierColor ??= Colors.black.withOpacity(0.3);
return showModalBottomSheet<T>(
context: context,
@@ -227,14 +226,10 @@ class BottomSheetHeader extends StatelessWidget {
),
),
Align(
child: Container(
constraints: const BoxConstraints(maxWidth: 250),
child: FlowyText(
title,
fontSize: 17.0,
fontWeight: FontWeight.w500,
overflow: TextOverflow.ellipsis,
),
child: FlowyText(
title,
fontSize: 16.0,
fontWeight: FontWeight.w500,
),
),
if (showDoneButton)
@@ -133,7 +133,7 @@ enum DatabaseViewSettings {
filter => FlowySvgs.filter_s,
sort => FlowySvgs.sort_ascending_s,
board => FlowySvgs.board_s,
calendar => FlowySvgs.calendar_s,
calendar => FlowySvgs.date_s,
duplicate => FlowySvgs.copy_s,
delete => FlowySvgs.delete_s,
};
@@ -176,7 +176,6 @@ class DatabaseViewSettingTile extends StatelessWidget {
return Row(
children: [
FlowyText(
lineHeight: 1.0,
databaseLayoutFromViewLayout(view.layout).layoutName,
color: Theme.of(context).hintColor,
),
@@ -100,6 +100,8 @@ class _FavoriteViews extends StatelessWidget {
child: ListView.separated(
key: const PageStorageKey('favorite_views_page_storage_key'),
padding: EdgeInsets.only(
left: HomeSpaceViewSizes.mHorizontalPadding,
right: HomeSpaceViewSizes.mHorizontalPadding,
bottom: HomeSpaceViewSizes.mVerticalPadding +
MediaQuery.of(context).padding.bottom,
),
@@ -72,7 +72,6 @@ class MobileFavoriteFolder extends StatelessWidget {
MobilePaneActionType.more,
],
spaceType: FolderSpaceType.favorite,
spaceRatio: 5,
),
),
),
@@ -29,6 +29,8 @@ class _MobileHomeSpaceState extends State<MobileHomeSpace>
child: SingleChildScrollView(
child: Padding(
padding: EdgeInsets.only(
left: HomeSpaceViewSizes.mHorizontalPadding,
right: HomeSpaceViewSizes.mHorizontalPadding,
top: HomeSpaceViewSizes.mVerticalPadding,
bottom: HomeSpaceViewSizes.mVerticalPadding +
MediaQuery.of(context).padding.bottom,
@@ -1,13 +1,14 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/mobile_router.dart';
import 'package:appflowy/mobile/presentation/home/home.dart';
import 'package:appflowy/mobile/presentation/home/section_folder/mobile_home_section_folder.dart';
import 'package:appflowy/mobile/presentation/home/space/mobile_space.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@@ -31,56 +32,73 @@ class MobileFolders extends StatelessWidget {
@override
Widget build(BuildContext context) {
final workspaceId =
context.read<UserWorkspaceBloc>().state.currentWorkspace?.workspaceId ??
'';
return BlocListener<UserWorkspaceBloc, UserWorkspaceState>(
listener: (context, state) {
context.read<SidebarSectionsBloc>().add(
SidebarSectionsEvent.initial(
user,
state.currentWorkspace?.workspaceId ?? workspaceId,
),
);
context.read<SpaceBloc>().add(
SpaceEvent.reset(
user,
state.currentWorkspace?.workspaceId ?? workspaceId,
),
);
},
child: const _MobileFolder(),
);
}
}
class _MobileFolder extends StatefulWidget {
const _MobileFolder();
@override
State<_MobileFolder> createState() => _MobileFolderState();
}
class _MobileFolderState extends State<_MobileFolder> {
@override
Widget build(BuildContext context) {
return BlocBuilder<SidebarSectionsBloc, SidebarSectionsState>(
builder: (context, state) {
return SlidableAutoCloseBehavior(
child: Column(
children: [
..._buildSpaceOrSection(context, state),
const VSpace(4.0),
const Padding(
padding: EdgeInsets.symmetric(
horizontal: HomeSpaceViewSizes.mHorizontalPadding,
return MultiBlocProvider(
providers: [
BlocProvider(
create: (_) => SidebarSectionsBloc()
..add(SidebarSectionsEvent.initial(user, workspaceId)),
),
BlocProvider(
create: (_) => FavoriteBloc()..add(const FavoriteEvent.initial()),
),
BlocProvider(
create: (_) => SpaceBloc()
..add(SpaceEvent.initial(user, workspaceId, openFirstPage: false)),
),
],
child: BlocListener<UserWorkspaceBloc, UserWorkspaceState>(
listener: (context, state) {
context.read<SidebarSectionsBloc>().add(
SidebarSectionsEvent.initial(
user,
state.currentWorkspace?.workspaceId ?? workspaceId,
),
child: _TrashButton(),
),
],
);
context.read<SpaceBloc>().add(
SpaceEvent.reset(
user,
state.currentWorkspace?.workspaceId ?? workspaceId,
),
);
},
child: MultiBlocListener(
listeners: [
BlocListener<SpaceBloc, SpaceState>(
listenWhen: (p, c) =>
p.lastCreatedPage?.id != c.lastCreatedPage?.id,
listener: (context, state) {
final lastCreatedPage = state.lastCreatedPage;
if (lastCreatedPage != null) {
context.pushView(lastCreatedPage);
}
},
),
BlocListener<SidebarSectionsBloc, SidebarSectionsState>(
listenWhen: (p, c) =>
p.lastCreatedRootView?.id != c.lastCreatedRootView?.id,
listener: (context, state) {
final lastCreatedPage = state.lastCreatedRootView;
if (lastCreatedPage != null) {
context.pushView(lastCreatedPage);
}
},
),
],
child: BlocBuilder<SidebarSectionsBloc, SidebarSectionsState>(
builder: (context, state) {
return SlidableAutoCloseBehavior(
child: Column(
children: [
..._buildSpaceOrSection(context, state),
const VSpace(4.0),
const _TrashButton(),
],
),
);
},
),
);
},
),
),
);
}
@@ -1,5 +1,7 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:appflowy/mobile/presentation/home/mobile_home_page_header.dart';
import 'package:appflowy/mobile/presentation/home/tab/mobile_space_tab.dart';
import 'package:appflowy/mobile/presentation/home/tab/space_order_bloc.dart';
@@ -7,9 +9,7 @@ import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart';
import 'package:appflowy/workspace/application/recent/cached_recent_service.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/presentation/home/errors/workspace_failed_screen.dart';
import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
@@ -18,8 +18,6 @@ import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
@@ -76,9 +74,6 @@ class MobileHomeScreen extends StatelessWidget {
}
}
final PropertyValueNotifier<UserWorkspacePB?> mCurrentWorkspace =
PropertyValueNotifier<UserWorkspacePB?>(null);
class MobileHomePage extends StatefulWidget {
const MobileHomePage({
super.key,
@@ -99,13 +94,11 @@ class _MobileHomePageState extends State<MobileHomePage> {
super.initState();
getIt<MenuSharedState>().addLatestViewListener(_onLatestViewChange);
getIt<ReminderBloc>().add(const ReminderEvent.started());
}
@override
void dispose() {
getIt<MenuSharedState>().removeLatestViewListener(_onLatestViewChange);
super.dispose();
}
@@ -129,17 +122,12 @@ class _MobileHomePageState extends State<MobileHomePage> {
buildWhen: (previous, current) =>
previous.currentWorkspace?.workspaceId !=
current.currentWorkspace?.workspaceId,
listener: (context, state) {
getIt<CachedRecentService>().reset();
mCurrentWorkspace.value = state.currentWorkspace;
},
listener: (context, state) => getIt<CachedRecentService>().reset(),
builder: (context, state) {
if (state.currentWorkspace == null) {
return const SizedBox.shrink();
}
final workspaceId = state.currentWorkspace!.workspaceId;
return Column(
children: [
// Header
@@ -155,36 +143,9 @@ class _MobileHomePageState extends State<MobileHomePage> {
),
Expanded(
child: MultiBlocProvider(
providers: [
BlocProvider(
create: (_) => SpaceOrderBloc()
..add(const SpaceOrderEvent.initial()),
),
BlocProvider(
create: (_) => SidebarSectionsBloc()
..add(
SidebarSectionsEvent.initial(
widget.userProfile,
workspaceId,
),
),
),
BlocProvider(
create: (_) =>
FavoriteBloc()..add(const FavoriteEvent.initial()),
),
BlocProvider(
create: (_) => SpaceBloc()
..add(
SpaceEvent.initial(
widget.userProfile,
workspaceId,
openFirstPage: false,
),
),
),
],
child: BlocProvider(
create: (context) =>
SpaceOrderBloc()..add(const SpaceOrderEvent.initial()),
child: MobileSpaceTab(
userProfile: widget.userProfile,
),
@@ -1,11 +1,10 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/gesture.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/home/mobile_home_setting_page.dart';
import 'package:appflowy/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart';
import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/plugins/base/icon/icon_picker.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/built_in_svgs.dart';
import 'package:appflowy/workspace/application/user/settings_user_bloc.dart';
@@ -51,10 +50,9 @@ class MobileHomePageHeader extends StatelessWidget {
),
child: const Padding(
padding: EdgeInsets.all(8.0),
child: FlowySvg(FlowySvgs.m_notification_settings_s),
child: FlowySvg(FlowySvgs.m_setting_m),
),
),
const HSpace(8.0),
],
),
);
@@ -115,9 +113,8 @@ class _MobileWorkspace extends StatelessWidget {
if (currentWorkspace == null) {
return const SizedBox.shrink();
}
return AnimatedGestureDetector(
alignment: Alignment.centerLeft,
onTapUp: () {
return GestureDetector(
onTap: () {
context.read<UserWorkspaceBloc>().add(
const UserWorkspaceEvent.fetchWorkspaces(),
);
@@ -133,7 +130,6 @@ class _MobileWorkspace extends StatelessWidget {
fontSize: 16.0,
enableEdit: false,
alignment: Alignment.centerLeft,
figmaLineHeight: 16.0,
onSelected: (result) => context.read<UserWorkspaceBloc>().add(
UserWorkspaceEvent.updateWorkspaceIcon(
currentWorkspace.workspaceId,
@@ -147,7 +143,7 @@ class _MobileWorkspace extends StatelessWidget {
: const HSpace(8),
FlowyText.semibold(
currentWorkspace.name,
fontSize: 20.0,
fontSize: 16.0,
overflow: TextOverflow.ellipsis,
),
],
@@ -166,10 +162,9 @@ class _MobileWorkspace extends StatelessWidget {
showHeader: true,
showDragHandle: true,
showCloseButton: true,
useRootNavigator: true,
title: LocaleKeys.workspace_menuTitle.tr(),
backgroundColor: Theme.of(context).colorScheme.surface,
builder: (sheetContext) {
builder: (_) {
return BlocProvider.value(
value: context.read<UserWorkspaceBloc>(),
child: BlocBuilder<UserWorkspaceBloc, UserWorkspaceState>(
@@ -184,7 +179,7 @@ class _MobileWorkspace extends StatelessWidget {
currentWorkspace: currentWorkspace,
workspaces: workspaces,
onWorkspaceSelected: (workspace) {
Navigator.of(sheetContext).pop();
context.pop();
if (workspace == currentWorkspace) {
return;
@@ -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;
}
@@ -72,6 +72,8 @@ class _RecentViews extends StatelessWidget {
child: ListView.separated(
key: const PageStorageKey('recent_views_page_storage_key'),
padding: EdgeInsets.only(
left: HomeSpaceViewSizes.mHorizontalPadding,
right: HomeSpaceViewSizes.mHorizontalPadding,
bottom: HomeSpaceViewSizes.mVerticalPadding +
MediaQuery.of(context).padding.bottom,
),
@@ -1,6 +1,6 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/mobile_router.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/default_mobile_action_pane.dart';
import 'package:appflowy/mobile/presentation/home/section_folder/mobile_home_section_folder_header.dart';
import 'package:appflowy/mobile/presentation/page_item/mobile_view_item.dart';
import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart';
@@ -43,17 +43,47 @@ class MobileSectionFolder extends StatelessWidget {
onPressed: () => context
.read<FolderBloc>()
.add(const FolderEvent.expandOrUnExpand()),
onAdded: () => _createNewPage(context),
onAdded: () {
context.read<SidebarSectionsBloc>().add(
SidebarSectionsEvent.createRootViewInSection(
name: LocaleKeys.menuAppHeader_defaultNewPageName
.tr(),
index: 0,
viewSection: spaceType.toViewSectionPB,
),
);
context.read<FolderBloc>().add(
const FolderEvent.expandOrUnExpand(isExpanded: true),
);
},
),
),
if (state.isExpanded)
Padding(
padding: const EdgeInsets.only(
left: HomeSpaceViewSizes.leftPadding,
),
child: _Pages(
views: views,
...views.map(
(view) => MobileViewItem(
key: ValueKey(
'${FolderSpaceType.private.name} ${view.id}',
),
spaceType: spaceType,
isFirstChild: view.id == views.first.id,
view: view,
level: 0,
leftPadding: HomeSpaceViewSizes.leftPadding,
isFeedback: false,
onSelected: context.pushView,
endActionPane: (context) {
final view = context.read<ViewBloc>().state.view;
return buildEndActionPane(
context,
[
MobilePaneActionType.more,
if (view.layout == ViewLayoutPB.Document)
MobilePaneActionType.add,
],
spaceType: spaceType,
needSpace: false,
);
},
),
),
],
@@ -62,63 +92,4 @@ class MobileSectionFolder extends StatelessWidget {
),
);
}
void _createNewPage(BuildContext context) {
context.read<SidebarSectionsBloc>().add(
SidebarSectionsEvent.createRootViewInSection(
name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
index: 0,
viewSection: spaceType.toViewSectionPB,
),
);
context.read<FolderBloc>().add(
const FolderEvent.expandOrUnExpand(isExpanded: true),
);
}
}
class _Pages extends StatelessWidget {
const _Pages({
required this.views,
required this.spaceType,
});
final List<ViewPB> views;
final FolderSpaceType spaceType;
@override
Widget build(BuildContext context) {
return Column(
children: views
.map(
(view) => MobileViewItem(
key: ValueKey(
'${FolderSpaceType.private.name} ${view.id}',
),
spaceType: spaceType,
isFirstChild: view.id == views.first.id,
view: view,
level: 0,
leftPadding: HomeSpaceViewSizes.leftPadding,
isFeedback: false,
onSelected: context.pushView,
endActionPane: (context) {
final view = context.read<ViewBloc>().state.view;
return buildEndActionPane(
context,
[
MobilePaneActionType.more,
if (view.layout == ViewLayoutPB.Document)
MobilePaneActionType.add,
],
spaceType: spaceType,
needSpace: false,
spaceRatio: 5,
);
},
),
)
.toList(),
);
}
}
@@ -32,7 +32,6 @@ class _MobileSectionFolderHeaderState extends State<MobileSectionFolderHeader> {
Widget build(BuildContext context) {
return Row(
children: [
const HSpace(HomeSpaceViewSizes.mHorizontalPadding),
Expanded(
child: FlowyButton(
text: FlowyText.medium(
@@ -58,19 +57,15 @@ class _MobileSectionFolderHeaderState extends State<MobileSectionFolderHeader> {
},
),
),
GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: widget.onAdded,
child: Container(
// expand the touch area
margin: const EdgeInsets.symmetric(
horizontal: HomeSpaceViewSizes.mHorizontalPadding,
vertical: 8.0,
),
child: const FlowySvg(
FlowySvgs.m_space_add_s,
),
FlowyIconButton(
key: mobileCreateNewPageButtonKey,
hoverColor: Theme.of(context).colorScheme.secondaryContainer,
height: HomeSpaceViewSizes.mViewButtonDimension,
width: HomeSpaceViewSizes.mViewButtonDimension,
icon: const FlowySvg(
FlowySvgs.m_space_add_s,
),
onPressed: widget.onAdded,
),
],
);
@@ -5,7 +5,6 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/mobile_router.dart';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
import 'package:appflowy/mobile/application/recent/recent_view_bloc.dart';
import 'package:appflowy/mobile/presentation/base/gesture.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/shared/appflowy_network_image.dart';
@@ -16,7 +15,6 @@ import 'package:appflowy/workspace/application/settings/appearance/appearance_cu
import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart';
import 'package:appflowy/workspace/application/settings/date_time/time_format_ext.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
@@ -78,14 +76,13 @@ class MobileViewPage extends StatelessWidget {
: MobilePaneActionType.addToFavorites,
],
cardType: type,
spaceRatio: 4,
),
child: AnimatedGestureDetector(
onTapUp: () => context.pushView(view),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapUp: (_) => context.pushView(view),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const HSpace(HomeSpaceViewSizes.mHorizontalPadding),
Expanded(child: _buildDescription(context, state)),
const HSpace(20.0),
SizedBox(
@@ -93,7 +90,6 @@ class MobileViewPage extends StatelessWidget {
height: 60,
child: _buildCover(context, state),
),
const HSpace(HomeSpaceViewSizes.mHorizontalPadding),
],
),
),
@@ -215,7 +211,7 @@ class MobileViewPage extends StatelessWidget {
Widget _buildLastViewed(BuildContext context) {
final textColor = Theme.of(context).isLightMode
? const Color(0x7F171717)
? const Color(0xFF171717)
: Colors.white.withOpacity(0.45);
if (timestamp == null) {
return const SizedBox.shrink();
@@ -4,6 +4,7 @@ import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/home/space/mobile_space_header.dart';
import 'package:appflowy/mobile/presentation/home/space/mobile_space_menu.dart';
import 'package:appflowy/mobile/presentation/page_item/mobile_view_item.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
@@ -14,9 +15,26 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class MobileSpace extends StatelessWidget {
class MobileSpace extends StatefulWidget {
const MobileSpace({super.key});
@override
State<MobileSpace> createState() => _MobileSpaceState();
}
class _MobileSpaceState extends State<MobileSpace> {
@override
void initState() {
super.initState();
createNewPageNotifier.addListener(_createNewPage);
}
@override
void dispose() {
createNewPageNotifier.removeListener(_createNewPage);
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<SpaceBloc, SpaceState>(
@@ -32,17 +50,22 @@ class MobileSpace extends StatelessWidget {
MobileSpaceHeader(
isExpanded: state.isExpanded,
space: currentSpace,
onAdded: () => _showCreatePageMenu(context, currentSpace),
onAdded: () {
context.read<SpaceBloc>().add(
SpaceEvent.createPage(
name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
index: 0,
),
);
context.read<SpaceBloc>().add(
SpaceEvent.expand(currentSpace, true),
);
},
onPressed: () => _showSpaceMenu(context),
),
Padding(
padding: const EdgeInsets.only(
left: HomeSpaceViewSizes.mHorizontalPadding,
),
child: _Pages(
key: ValueKey(currentSpace.id),
space: currentSpace,
),
_Pages(
key: ValueKey(currentSpace.id),
space: currentSpace,
),
],
);
@@ -58,7 +81,6 @@ class MobileSpace extends StatelessWidget {
showDragHandle: true,
showCloseButton: true,
showDoneButton: true,
useRootNavigator: true,
title: LocaleKeys.space_title.tr(),
backgroundColor: Theme.of(context).colorScheme.surface,
builder: (_) {
@@ -73,36 +95,12 @@ class MobileSpace extends StatelessWidget {
);
}
void _showCreatePageMenu(BuildContext context, ViewPB space) {
final title = space.name;
showMobileBottomSheet(
context,
showHeader: true,
title: title,
showDragHandle: true,
showCloseButton: true,
useRootNavigator: true,
showDivider: false,
backgroundColor: Theme.of(context).colorScheme.surface,
builder: (sheetContext) {
return AddNewPageWidgetBottomSheet(
view: space,
onAction: (layout) {
Navigator.of(sheetContext).pop();
context.read<SpaceBloc>().add(
SpaceEvent.createPage(
name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
layout: layout,
index: 0,
),
);
context.read<SpaceBloc>().add(
SpaceEvent.expand(space, true),
);
},
void _createNewPage() {
context.read<SpaceBloc>().add(
SpaceEvent.createPage(
name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
),
);
},
);
}
}
@@ -140,16 +138,15 @@ class _Pages extends StatelessWidget {
onSelected: context.pushView,
endActionPane: (context) {
final view = context.read<ViewBloc>().state.view;
final actions = [
MobilePaneActionType.more,
if (view.layout == ViewLayoutPB.Document)
MobilePaneActionType.add,
];
return buildEndActionPane(
context,
actions,
[
MobilePaneActionType.more,
if (view.layout == ViewLayoutPB.Document)
MobilePaneActionType.add,
],
spaceType: spaceType,
spaceRatio: actions.length == 1 ? 3 : 4,
needSpace: false,
);
},
),
@@ -1,5 +1,4 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@@ -31,12 +30,9 @@ class MobileSpaceHeader extends StatelessWidget {
height: 48,
child: Row(
children: [
const HSpace(HomeSpaceViewSizes.mHorizontalPadding),
SpaceIcon(
dimension: 24,
space: space,
svgSize: 14,
textDimension: 18.0,
cornerRadius: 6.0,
),
const HSpace(8),
@@ -53,15 +49,8 @@ class MobileSpaceHeader extends StatelessWidget {
GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: onAdded,
child: Container(
// expand the touch area
margin: const EdgeInsets.symmetric(
horizontal: HomeSpaceViewSizes.mHorizontalPadding,
vertical: 8.0,
),
child: const FlowySvg(
FlowySvgs.m_space_add_s,
),
child: const FlowySvg(
FlowySvgs.m_space_add_s,
),
),
],
@@ -59,7 +59,6 @@ class _SidebarSpaceMenuItem extends StatelessWidget {
children: [
FlowyText.medium(
space.name,
fontSize: 16.0,
),
const HSpace(6.0),
if (space.spacePermission == SpacePermission.private)
@@ -69,19 +68,16 @@ class _SidebarSpaceMenuItem extends StatelessWidget {
),
],
),
margin: const EdgeInsets.symmetric(horizontal: 12.0),
iconPadding: 10,
leftIcon: SpaceIcon(
dimension: 24,
space: space,
svgSize: 14,
textDimension: 18.0,
cornerRadius: 6.0,
),
leftIconSize: const Size.square(24),
leftIconSize: const Size.square(20),
rightIcon: isSelected
? const FlowySvg(
FlowySvgs.m_blue_check_s,
FlowySvgs.workspace_selected_s,
blendMode: null,
)
: null,
@@ -21,14 +21,12 @@ class MobileSpaceTabBar extends StatelessWidget {
Widget build(BuildContext context) {
final baseStyle = Theme.of(context).textTheme.bodyMedium;
final labelStyle = baseStyle?.copyWith(
fontWeight: FontWeight.w500,
fontWeight: FontWeight.w600,
fontSize: 16.0,
height: 22.0 / 16.0,
);
final unselectedLabelStyle = baseStyle?.copyWith(
fontWeight: FontWeight.w400,
fontSize: 15.0,
height: 22.0 / 15.0,
);
return Container(
@@ -1,27 +1,16 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/mobile_router.dart';
import 'package:appflowy/mobile/presentation/home/favorite_folder/favorite_space.dart';
import 'package:appflowy/mobile/presentation/home/home_space/home_space.dart';
import 'package:appflowy/mobile/presentation/home/recent_folder/recent_space.dart';
import 'package:appflowy/mobile/presentation/home/tab/_tab_bar.dart';
import 'package:appflowy/mobile/presentation/home/tab/space_order_bloc.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
class MobileSpaceTab extends StatefulWidget {
const MobileSpaceTab({
super.key,
required this.userProfile,
});
const MobileSpaceTab({super.key, required this.userProfile});
final UserProfilePB userProfile;
@@ -33,19 +22,10 @@ class _MobileSpaceTabState extends State<MobileSpaceTab>
with SingleTickerProviderStateMixin {
TabController? tabController;
@override
void initState() {
super.initState();
mobileCreateNewPageNotifier.addListener(_createNewPage);
}
@override
void dispose() {
tabController?.removeListener(_onTabChange);
tabController?.dispose();
mobileCreateNewPageNotifier.removeListener(_createNewPage);
super.dispose();
}
@@ -53,60 +33,36 @@ class _MobileSpaceTabState extends State<MobileSpaceTab>
Widget build(BuildContext context) {
return Provider.value(
value: widget.userProfile,
child: MultiBlocListener(
listeners: [
BlocListener<SpaceBloc, SpaceState>(
listenWhen: (p, c) =>
p.lastCreatedPage?.id != c.lastCreatedPage?.id,
listener: (context, state) {
final lastCreatedPage = state.lastCreatedPage;
if (lastCreatedPage != null) {
context.pushView(lastCreatedPage);
}
},
),
BlocListener<SidebarSectionsBloc, SidebarSectionsState>(
listenWhen: (p, c) =>
p.lastCreatedRootView?.id != c.lastCreatedRootView?.id,
listener: (context, state) {
final lastCreatedPage = state.lastCreatedRootView;
if (lastCreatedPage != null) {
context.pushView(lastCreatedPage);
}
},
),
],
child: BlocBuilder<SpaceOrderBloc, SpaceOrderState>(
builder: (context, state) {
if (state.isLoading) {
return const SizedBox.shrink();
}
child: BlocBuilder<SpaceOrderBloc, SpaceOrderState>(
builder: (context, state) {
if (state.isLoading) {
return const SizedBox.shrink();
}
_initTabController(state);
_initTabController(state);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MobileSpaceTabBar(
tabController: tabController!,
tabs: state.tabsOrder,
onReorder: (from, to) {
context.read<SpaceOrderBloc>().add(
SpaceOrderEvent.reorder(from, to),
);
},
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MobileSpaceTabBar(
tabController: tabController!,
tabs: state.tabsOrder,
onReorder: (from, to) {
context.read<SpaceOrderBloc>().add(
SpaceOrderEvent.reorder(from, to),
);
},
),
const HSpace(12.0),
Expanded(
child: TabBarView(
controller: tabController,
children: _buildTabs(state),
),
const HSpace(12.0),
Expanded(
child: TabBarView(
controller: tabController,
children: _buildTabs(state),
),
),
],
);
},
),
),
],
);
},
),
);
}
@@ -148,27 +104,4 @@ class _MobileSpaceTabState extends State<MobileSpaceTab>
}
}).toList();
}
// quick create new page when clicking the add button in navigation bar
void _createNewPage() {
if (context.read<SpaceBloc>().state.spaces.isNotEmpty) {
context.read<SpaceBloc>().add(
SpaceEvent.createPage(
name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
layout: ViewLayoutPB.Document,
),
);
} else {
context.read<SidebarSectionsBloc>().add(
SidebarSectionsEvent.createRootViewInSection(
name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
index: 0,
viewSection: FolderSpaceType.public.toViewSectionPB,
),
);
context.read<FolderBloc>().add(
const FolderEvent.expandOrUnExpand(isExpanded: true),
);
}
}
}
@@ -138,21 +138,17 @@ class _WorkspaceMenuItem extends StatelessWidget {
height: 60,
showTopBorder: showTopBorder,
showBottomBorder: false,
leftIcon: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: WorkspaceIcon(
enableEdit: false,
iconSize: 26,
fontSize: 16.0,
figmaLineHeight: 16.0,
workspace: workspace,
onSelected: (result) => context.read<UserWorkspaceBloc>().add(
UserWorkspaceEvent.updateWorkspaceIcon(
workspace.workspaceId,
result.emoji,
),
leftIcon: WorkspaceIcon(
enableEdit: false,
iconSize: 26,
fontSize: 16.0,
workspace: workspace,
onSelected: (result) => context.read<UserWorkspaceBloc>().add(
UserWorkspaceEvent.updateWorkspaceIcon(
workspace.workspaceId,
result.emoji,
),
),
),
),
trailing: workspace.workspaceId == currentWorkspace.workspaceId
? const FlowySvg(
@@ -1,31 +1,14 @@
import 'dart:ui';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart';
import 'package:appflowy/mobile/presentation/widgets/navigation_bar_button.dart';
import 'package:appflowy/shared/red_dot.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
enum BottomNavigationBarActionType {
home,
notificationMultiSelect,
}
final PropertyValueNotifier<ViewLayoutPB?> mobileCreateNewPageNotifier =
final PropertyValueNotifier<ViewLayoutPB?> createNewPageNotifier =
PropertyValueNotifier(null);
final ValueNotifier<BottomNavigationBarActionType> bottomNavigationBarType =
ValueNotifier(BottomNavigationBarActionType.home);
const _homeLabel = 'home';
const _addLabel = 'add';
@@ -42,16 +25,16 @@ final _items = <BottomNavigationBarItem>[
),
const BottomNavigationBarItem(
label: _notificationLabel,
icon: _NotificationNavigationBarItemIcon(),
activeIcon: _NotificationNavigationBarItemIcon(
isActive: true,
icon: FlowySvg(FlowySvgs.m_home_notification_m),
activeIcon: FlowySvg(
FlowySvgs.m_home_notification_m,
),
),
];
/// Builds the "shell" for the app by building a Scaffold with a
/// BottomNavigationBar, where [child] is placed in the body of the Scaffold.
class MobileBottomNavigationBar extends StatefulWidget {
class MobileBottomNavigationBar extends StatelessWidget {
/// Constructs an [MobileBottomNavigationBar].
const MobileBottomNavigationBar({
required this.navigationShell,
@@ -61,148 +44,41 @@ class MobileBottomNavigationBar extends StatefulWidget {
/// The navigation shell and container for the branch Navigators.
final StatefulNavigationShell navigationShell;
@override
State<MobileBottomNavigationBar> createState() =>
_MobileBottomNavigationBarState();
}
class _MobileBottomNavigationBarState extends State<MobileBottomNavigationBar> {
Widget? _bottomNavigationBar;
@override
void initState() {
super.initState();
bottomNavigationBarType.addListener(_animate);
}
@override
void dispose() {
bottomNavigationBarType.removeListener(_animate);
super.dispose();
}
@override
Widget build(BuildContext context) {
_bottomNavigationBar = switch (bottomNavigationBarType.value) {
BottomNavigationBarActionType.home =>
_buildHomePageNavigationBar(context),
BottomNavigationBarActionType.notificationMultiSelect =>
_buildNotificationNavigationBar(context),
};
final isLightMode = Theme.of(context).isLightMode;
final backgroundColor = isLightMode
? Colors.white.withOpacity(0.95)
: const Color(0xFF23262B).withOpacity(0.95);
return Scaffold(
body: widget.navigationShell,
body: navigationShell,
extendBody: true,
bottomNavigationBar: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
switchInCurve: Curves.easeInOut,
switchOutCurve: Curves.easeInOut,
transitionBuilder: _transitionBuilder,
child: _bottomNavigationBar,
),
);
}
Widget _buildHomePageNavigationBar(BuildContext context) {
return _HomePageNavigationBar(
navigationShell: widget.navigationShell,
);
}
Widget _buildNotificationNavigationBar(BuildContext context) {
return const _NotificationNavigationBar();
}
// widget A going down, widget B going up
Widget _transitionBuilder(
Widget child,
Animation<double> animation,
) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(animation),
child: child,
);
}
void _animate() {
setState(() {});
}
}
class _NotificationNavigationBarItemIcon extends StatelessWidget {
const _NotificationNavigationBarItemIcon({
this.isActive = false,
});
final bool isActive;
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: getIt<ReminderBloc>(),
child: BlocBuilder<ReminderBloc, ReminderState>(
builder: (context, state) {
final hasUnreads = state.reminders.any(
(reminder) => !reminder.isRead,
);
return Stack(
children: [
isActive
? const FlowySvg(
FlowySvgs.m_home_active_notification_m,
blendMode: null,
)
: const FlowySvg(
FlowySvgs.m_home_notification_m,
),
if (hasUnreads)
const Positioned(
top: 2,
right: 4,
child: NotificationRedDot(),
),
],
);
},
),
);
}
}
class _HomePageNavigationBar extends StatelessWidget {
const _HomePageNavigationBar({
required this.navigationShell,
});
final StatefulNavigationShell navigationShell;
@override
Widget build(BuildContext context) {
return ClipRRect(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: 3,
sigmaY: 3,
),
child: DecoratedBox(
decoration: BoxDecoration(
border: context.border,
color: context.backgroundColor,
bottomNavigationBar: ClipRRect(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: 3,
sigmaY: 3,
),
child: BottomNavigationBar(
showSelectedLabels: false,
showUnselectedLabels: false,
enableFeedback: false,
type: BottomNavigationBarType.fixed,
elevation: 0,
items: _items,
backgroundColor: Colors.transparent,
currentIndex: navigationShell.currentIndex,
onTap: (int bottomBarIndex) => _onTap(context, bottomBarIndex),
child: DecoratedBox(
decoration: BoxDecoration(
border: isLightMode
? Border(
top: BorderSide(color: Theme.of(context).dividerColor),
)
: null,
color: backgroundColor,
),
child: BottomNavigationBar(
showSelectedLabels: false,
showUnselectedLabels: false,
enableFeedback: false,
type: BottomNavigationBarType.fixed,
elevation: 0,
items: _items,
backgroundColor: Colors.transparent,
currentIndex: navigationShell.currentIndex,
onTap: (int bottomBarIndex) => _onTap(context, bottomBarIndex),
),
),
),
),
@@ -212,13 +88,10 @@ class _HomePageNavigationBar extends StatelessWidget {
/// Navigate to the current location of the branch at the provided index when
/// tapping an item in the BottomNavigationBar.
void _onTap(BuildContext context, int bottomBarIndex) {
final label = _items[bottomBarIndex].label;
if (label == _addLabel) {
if (_items[bottomBarIndex].label == _addLabel) {
// show an add dialog
mobileCreateNewPageNotifier.value = ViewLayoutPB.Document;
createNewPageNotifier.value = ViewLayoutPB.Document;
return;
} else if (label == _notificationLabel) {
getIt<ReminderBloc>().add(const ReminderEvent.refresh());
}
// When navigating to a new branch, it's recommended to use the goBranch
// method, as doing so makes sure the last navigation state of the
@@ -233,112 +106,3 @@ class _HomePageNavigationBar extends StatelessWidget {
);
}
}
class _NotificationNavigationBar extends StatelessWidget {
const _NotificationNavigationBar();
@override
Widget build(BuildContext context) {
return Container(
// todo: use real height here.
height: 90,
decoration: BoxDecoration(
border: context.border,
color: context.backgroundColor,
),
padding: const EdgeInsets.only(bottom: 20),
child: ValueListenableBuilder(
valueListenable: mSelectedNotificationIds,
builder: (context, value, child) {
if (value.isEmpty) {
// not editable
return IgnorePointer(
child: Opacity(
opacity: 0.3,
child: child,
),
);
}
return child!;
},
child: Row(
children: [
const HSpace(20),
Expanded(
child: NavigationBarButton(
icon: FlowySvgs.m_notification_action_mark_as_read_s,
text: LocaleKeys.settings_notifications_action_markAsRead.tr(),
onTap: () => _onMarkAsRead(context),
),
),
const HSpace(16),
Expanded(
child: NavigationBarButton(
icon: FlowySvgs.m_notification_action_archive_s,
text: LocaleKeys.settings_notifications_action_archive.tr(),
onTap: () => _onArchive(context),
),
),
const HSpace(20),
],
),
),
);
}
void _onMarkAsRead(BuildContext context) {
if (mSelectedNotificationIds.value.isEmpty) {
return;
}
showToastNotification(
context,
message: LocaleKeys
.settings_notifications_markAsReadNotifications_allSuccess
.tr(),
);
getIt<ReminderBloc>()
.add(ReminderEvent.markAsRead(mSelectedNotificationIds.value));
mSelectedNotificationIds.value = [];
}
void _onArchive(BuildContext context) {
if (mSelectedNotificationIds.value.isEmpty) {
return;
}
showToastNotification(
context,
message: LocaleKeys.settings_notifications_archiveNotifications_allSuccess
.tr(),
);
getIt<ReminderBloc>()
.add(ReminderEvent.archive(mSelectedNotificationIds.value));
mSelectedNotificationIds.value = [];
}
}
extension on BuildContext {
Color get backgroundColor {
return Theme.of(this).isLightMode
? Colors.white.withOpacity(0.95)
: const Color(0xFF23262B).withOpacity(0.95);
}
Color get borderColor {
return Theme.of(this).isLightMode
? const Color(0x141F2329)
: const Color(0xFF23262B).withOpacity(0.5);
}
Border? get border {
return Theme.of(this).isLightMode
? Border(top: BorderSide(color: borderColor))
: null;
}
}
@@ -1,59 +0,0 @@
import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart';
import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class MobileNotificationsMultiSelectScreen extends StatelessWidget {
const MobileNotificationsMultiSelectScreen({super.key});
static const routeName = '/notifications_multi_select';
@override
Widget build(BuildContext context) {
return BlocProvider<ReminderBloc>.value(
value: getIt<ReminderBloc>(),
child: const MobileNotificationMultiSelect(),
);
}
}
class MobileNotificationMultiSelect extends StatefulWidget {
const MobileNotificationMultiSelect({
super.key,
});
@override
State<MobileNotificationMultiSelect> createState() =>
_MobileNotificationMultiSelectState();
}
class _MobileNotificationMultiSelectState
extends State<MobileNotificationMultiSelect> {
@override
void dispose() {
mSelectedNotificationIds.value.clear();
super.dispose();
}
@override
Widget build(BuildContext context) {
return const Scaffold(
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MobileNotificationMultiSelectPageHeader(),
VSpace(12.0),
Expanded(
child: MultiSelectNotificationTab(),
),
],
),
),
);
}
}
@@ -1,129 +0,0 @@
import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_multiple_select_page.dart';
import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
final PropertyValueNotifier<List<String>> mSelectedNotificationIds =
PropertyValueNotifier([]);
class MobileNotificationsScreenV2 extends StatefulWidget {
const MobileNotificationsScreenV2({super.key});
static const routeName = '/notifications';
@override
State<MobileNotificationsScreenV2> createState() =>
_MobileNotificationsScreenV2State();
}
class _MobileNotificationsScreenV2State
extends State<MobileNotificationsScreenV2>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
mCurrentWorkspace.addListener(_onRefresh);
}
@override
void dispose() {
mCurrentWorkspace.removeListener(_onRefresh);
super.dispose();
}
@override
Widget build(BuildContext context) {
super.build(context);
return BlocProvider<ReminderBloc>.value(
value: getIt<ReminderBloc>(),
child: ValueListenableBuilder(
valueListenable: bottomNavigationBarType,
builder: (_, value, __) {
switch (value) {
case BottomNavigationBarActionType.home:
return const MobileNotificationsTab();
case BottomNavigationBarActionType.notificationMultiSelect:
return const MobileNotificationMultiSelect();
}
},
),
);
}
void _onRefresh() {
getIt<ReminderBloc>().add(const ReminderEvent.refresh());
}
}
class MobileNotificationsTab extends StatefulWidget {
const MobileNotificationsTab({
super.key,
});
@override
State<MobileNotificationsTab> createState() => _MobileNotificationsTabState();
}
class _MobileNotificationsTabState extends State<MobileNotificationsTab>
with SingleTickerProviderStateMixin {
late TabController tabController;
final tabs = [
MobileNotificationTabType.inbox,
MobileNotificationTabType.unread,
MobileNotificationTabType.archive,
];
@override
void initState() {
super.initState();
tabController = TabController(
length: 3,
vsync: this,
);
}
@override
void dispose() {
tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const MobileNotificationPageHeader(),
MobileNotificationTabBar(
tabController: tabController,
tabs: tabs,
),
const VSpace(12.0),
Expanded(
child: TabBarView(
controller: tabController,
children: tabs.map((e) => NotificationTab(tabType: e)).toList(),
),
),
],
),
),
);
}
}
@@ -1,11 +0,0 @@
import 'package:appflowy/util/theme_extension.dart';
import 'package:flutter/material.dart';
extension NotificationItemColors on BuildContext {
Color get notificationItemTextColor {
if (Theme.of(this).isLightMode) {
return const Color(0xFF171717);
}
return const Color(0xFFffffff).withOpacity(0.8);
}
}
@@ -1,58 +0,0 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
class EmptyNotification extends StatelessWidget {
const EmptyNotification({
super.key,
required this.type,
});
final MobileNotificationTabType type;
@override
Widget build(BuildContext context) {
final title = switch (type) {
MobileNotificationTabType.inbox =>
LocaleKeys.settings_notifications_emptyInbox_title.tr(),
MobileNotificationTabType.archive =>
LocaleKeys.settings_notifications_emptyArchived_title.tr(),
MobileNotificationTabType.unread =>
LocaleKeys.settings_notifications_emptyUnread_title.tr(),
};
final desc = switch (type) {
MobileNotificationTabType.inbox =>
LocaleKeys.settings_notifications_emptyInbox_description.tr(),
MobileNotificationTabType.archive =>
LocaleKeys.settings_notifications_emptyArchived_description.tr(),
MobileNotificationTabType.unread =>
LocaleKeys.settings_notifications_emptyUnread_description.tr(),
};
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const FlowySvg(FlowySvgs.m_empty_notification_xl),
const VSpace(12.0),
FlowyText(
title,
fontSize: 16.0,
figmaLineHeight: 24.0,
fontWeight: FontWeight.w500,
),
const VSpace(4.0),
Opacity(
opacity: 0.45,
child: FlowyText(
desc,
fontSize: 15.0,
figmaLineHeight: 22.0,
fontWeight: FontWeight.w400,
),
),
],
);
}
}
@@ -1,96 +0,0 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart';
import 'package:appflowy/mobile/presentation/notifications/widgets/settings_popup_menu.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
class MobileNotificationPageHeader extends StatelessWidget {
const MobileNotificationPageHeader({
super.key,
});
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: const BoxConstraints(minHeight: 56),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const HSpace(16.0),
FlowyText(
LocaleKeys.settings_notifications_titles_notifications.tr(),
fontSize: 20,
fontWeight: FontWeight.w600,
),
const Spacer(),
const NotificationSettingsPopupMenu(),
const HSpace(16.0),
],
),
);
}
}
class MobileNotificationMultiSelectPageHeader extends StatelessWidget {
const MobileNotificationMultiSelectPageHeader({
super.key,
});
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: const BoxConstraints(minHeight: 56),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildCancelButton(
isOpaque: false,
padding: const EdgeInsets.symmetric(horizontal: 16),
onTap: () => bottomNavigationBarType.value =
BottomNavigationBarActionType.home,
),
ValueListenableBuilder(
valueListenable: mSelectedNotificationIds,
builder: (_, value, __) {
return FlowyText(
// todo: i18n
'${value.length} Selected',
fontSize: 17.0,
figmaLineHeight: 24.0,
fontWeight: FontWeight.w500,
);
},
),
// this button is used to align the text to the center
_buildCancelButton(
isOpaque: true,
padding: const EdgeInsets.symmetric(horizontal: 16),
),
],
),
);
}
//
Widget _buildCancelButton({
required bool isOpaque,
required EdgeInsets padding,
VoidCallback? onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Padding(
padding: padding,
child: FlowyText(
LocaleKeys.button_cancel.tr(),
fontSize: 17.0,
figmaLineHeight: 24.0,
fontWeight: FontWeight.w400,
color: isOpaque ? Colors.transparent : null,
),
),
);
}
}
@@ -1,115 +0,0 @@
import 'package:appflowy/mobile/application/notification/notification_reminder_bloc.dart';
import 'package:appflowy/mobile/presentation/base/gesture.dart';
import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart';
import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class MultiSelectNotificationItem extends StatelessWidget {
const MultiSelectNotificationItem({
super.key,
required this.reminder,
});
final ReminderPB reminder;
@override
Widget build(BuildContext context) {
final settings = context.read<AppearanceSettingsCubit>().state;
final dateFormate = settings.dateFormat;
final timeFormate = settings.timeFormat;
return BlocProvider<NotificationReminderBloc>(
create: (context) => NotificationReminderBloc()
..add(
NotificationReminderEvent.initial(
reminder,
dateFormate,
timeFormate,
),
),
child: BlocBuilder<NotificationReminderBloc, NotificationReminderState>(
builder: (context, state) {
if (state.status == NotificationReminderStatus.loading ||
state.status == NotificationReminderStatus.initial) {
return const SizedBox.shrink();
}
if (state.status == NotificationReminderStatus.error) {
// error handle.
return const SizedBox.shrink();
}
final child = ValueListenableBuilder(
valueListenable: mSelectedNotificationIds,
builder: (_, selectedIds, child) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 12),
decoration: selectedIds.contains(reminder.id)
? ShapeDecoration(
color: const Color(0x1900BCF0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
)
: null,
child: child,
);
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: _InnerNotificationItem(
reminder: reminder,
),
),
);
return AnimatedGestureDetector(
scaleFactor: 0.99,
onTapUp: () {
if (mSelectedNotificationIds.value.contains(reminder.id)) {
mSelectedNotificationIds.value = mSelectedNotificationIds.value
..remove(reminder.id);
} else {
mSelectedNotificationIds.value = mSelectedNotificationIds.value
..add(reminder.id);
}
},
child: child,
);
},
),
);
}
}
class _InnerNotificationItem extends StatelessWidget {
const _InnerNotificationItem({
required this.reminder,
});
final ReminderPB reminder;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const HSpace(10.0),
NotificationCheckIcon(
isSelected: mSelectedNotificationIds.value.contains(reminder.id),
),
const HSpace(3.0),
!reminder.isRead ? const UnreadRedDot() : const HSpace(6.0),
const HSpace(3.0),
NotificationIcon(reminder: reminder),
const HSpace(12.0),
Expanded(
child: NotificationContent(reminder: reminder),
),
],
);
}
}
@@ -1,164 +0,0 @@
import 'package:appflowy/mobile/application/mobile_router.dart';
import 'package:appflowy/mobile/application/notification/notification_reminder_bloc.dart';
import 'package:appflowy/mobile/presentation/base/gesture.dart';
import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
class NotificationItem extends StatelessWidget {
const NotificationItem({
super.key,
required this.tabType,
required this.reminder,
});
final MobileNotificationTabType tabType;
final ReminderPB reminder;
@override
Widget build(BuildContext context) {
final settings = context.read<AppearanceSettingsCubit>().state;
final dateFormate = settings.dateFormat;
final timeFormate = settings.timeFormat;
return BlocProvider<NotificationReminderBloc>(
create: (context) => NotificationReminderBloc()
..add(
NotificationReminderEvent.initial(
reminder,
dateFormate,
timeFormate,
),
),
child: BlocBuilder<NotificationReminderBloc, NotificationReminderState>(
builder: (context, state) {
if (state.status == NotificationReminderStatus.loading ||
state.status == NotificationReminderStatus.initial) {
return const SizedBox.shrink();
}
if (state.status == NotificationReminderStatus.error) {
// error handle.
return const SizedBox.shrink();
}
final child = Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: _SlidableNotificationItem(
tabType: tabType,
reminder: reminder,
child: _InnerNotificationItem(
tabType: tabType,
reminder: reminder,
),
),
);
return AnimatedGestureDetector(
scaleFactor: 0.99,
child: child,
onTapUp: () async {
final view = state.view;
if (view == null) {
return;
}
await context.pushView(view);
if (!reminder.isRead && context.mounted) {
context.read<ReminderBloc>().add(
ReminderEvent.markAsRead([reminder.id]),
);
}
},
);
},
),
);
}
}
class _InnerNotificationItem extends StatelessWidget {
const _InnerNotificationItem({
required this.reminder,
required this.tabType,
});
final MobileNotificationTabType tabType;
final ReminderPB reminder;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const HSpace(8.0),
!reminder.isRead ? const UnreadRedDot() : const HSpace(6.0),
const HSpace(4.0),
NotificationIcon(reminder: reminder),
const HSpace(12.0),
Expanded(
child: NotificationContent(reminder: reminder),
),
],
);
}
}
class _SlidableNotificationItem extends StatelessWidget {
const _SlidableNotificationItem({
required this.tabType,
required this.reminder,
required this.child,
});
final MobileNotificationTabType tabType;
final ReminderPB reminder;
final Widget child;
@override
Widget build(BuildContext context) {
final List<NotificationPaneActionType> actions = switch (tabType) {
MobileNotificationTabType.inbox => [
NotificationPaneActionType.more,
if (!reminder.isRead) NotificationPaneActionType.markAsRead,
],
MobileNotificationTabType.unread => [
NotificationPaneActionType.more,
NotificationPaneActionType.markAsRead,
],
MobileNotificationTabType.archive => [
if (kDebugMode) NotificationPaneActionType.unArchive,
],
};
if (actions.isEmpty) {
return child;
}
final children = actions
.map(
(action) => action.actionButton(
context,
tabType: tabType,
),
)
.toList();
final extentRatio = actions.length == 1 ? 1 / 5 : 1 / 3;
return Slidable(
endActionPane: ActionPane(
motion: const ScrollMotion(),
extentRatio: extentRatio,
children: children,
),
child: child,
);
}
}
@@ -1,167 +0,0 @@
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/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
enum _NotificationSettingsPopupMenuItem {
settings,
markAllAsRead,
archiveAll,
// only visible in debug mode
unarchiveAll;
}
class NotificationSettingsPopupMenu extends StatelessWidget {
const NotificationSettingsPopupMenu({super.key});
@override
Widget build(BuildContext context) {
return PopupMenuButton<_NotificationSettingsPopupMenuItem>(
offset: const Offset(0, 36),
padding: EdgeInsets.zero,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(
Radius.circular(12.0),
),
),
// todo: replace it with shadows
shadowColor: const Color(0x68000000),
elevation: 10,
child: const Padding(
padding: EdgeInsets.all(8.0),
child: FlowySvg(
FlowySvgs.m_settings_more_s,
),
),
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<_NotificationSettingsPopupMenuItem>>[
_buildItem(
value: _NotificationSettingsPopupMenuItem.settings,
svg: FlowySvgs.m_notification_settings_s,
text: LocaleKeys.settings_notifications_settings_settings.tr(),
),
const PopupMenuDivider(height: 0.5),
_buildItem(
value: _NotificationSettingsPopupMenuItem.markAllAsRead,
svg: FlowySvgs.m_notification_mark_as_read_s,
text: LocaleKeys.settings_notifications_settings_markAllAsRead.tr(),
),
const PopupMenuDivider(height: 0.5),
_buildItem(
value: _NotificationSettingsPopupMenuItem.archiveAll,
svg: FlowySvgs.m_notification_archived_s,
text: LocaleKeys.settings_notifications_settings_archiveAll.tr(),
),
// only visible in debug mode
if (kDebugMode) ...[
const PopupMenuDivider(height: 0.5),
_buildItem(
value: _NotificationSettingsPopupMenuItem.unarchiveAll,
svg: FlowySvgs.m_notification_archived_s,
text: 'Unarchive all (Debug Mode)',
),
],
],
onSelected: (_NotificationSettingsPopupMenuItem value) {
switch (value) {
case _NotificationSettingsPopupMenuItem.markAllAsRead:
_onMarkAllAsRead(context);
break;
case _NotificationSettingsPopupMenuItem.archiveAll:
_onArchiveAll(context);
break;
case _NotificationSettingsPopupMenuItem.settings:
context.push(MobileHomeSettingPage.routeName);
break;
case _NotificationSettingsPopupMenuItem.unarchiveAll:
_onUnarchiveAll(context);
break;
}
},
);
}
PopupMenuItem<T> _buildItem<T>({
required T value,
required FlowySvgData svg,
required String text,
}) {
return PopupMenuItem<T>(
value: value,
padding: EdgeInsets.zero,
child: _PopupButton(
svg: svg,
text: text,
),
);
}
void _onMarkAllAsRead(BuildContext context) {
showToastNotification(
context,
message: LocaleKeys
.settings_notifications_markAsReadNotifications_allSuccess
.tr(),
);
context.read<ReminderBloc>().add(const ReminderEvent.markAllRead());
}
void _onArchiveAll(BuildContext context) {
showToastNotification(
context,
message: LocaleKeys.settings_notifications_archiveNotifications_allSuccess
.tr(),
);
context.read<ReminderBloc>().add(const ReminderEvent.archiveAll());
}
void _onUnarchiveAll(BuildContext context) {
if (!kDebugMode) {
return;
}
showToastNotification(
context,
message: 'Unarchive all success (Debug Mode)',
);
context.read<ReminderBloc>().add(const ReminderEvent.unarchiveAll());
}
}
class _PopupButton extends StatelessWidget {
const _PopupButton({
required this.svg,
required this.text,
});
final FlowySvgData svg;
final String text;
@override
Widget build(BuildContext context) {
return Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
FlowySvg(svg),
const HSpace(12),
FlowyText.regular(
text,
fontSize: 16,
),
],
),
);
}
}
@@ -1,286 +0,0 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/notification/notification_reminder_bloc.dart';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
import 'package:appflowy/mobile/presentation/notifications/widgets/color.dart';
import 'package:appflowy/plugins/document/presentation/editor_configuration.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/user/application/reminder/reminder_extension.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
const _kNotificationIconHeight = 36.0;
class NotificationIcon extends StatelessWidget {
const NotificationIcon({
super.key,
required this.reminder,
});
final ReminderPB reminder;
@override
Widget build(BuildContext context) {
return const FlowySvg(
FlowySvgs.m_notification_reminder_s,
size: Size.square(_kNotificationIconHeight),
blendMode: null,
);
}
}
class NotificationCheckIcon extends StatelessWidget {
const NotificationCheckIcon({super.key, required this.isSelected});
final bool isSelected;
@override
Widget build(BuildContext context) {
return SizedBox(
height: _kNotificationIconHeight,
child: Center(
child: FlowySvg(
isSelected
? FlowySvgs.m_notification_multi_select_s
: FlowySvgs.m_notification_multi_unselect_s,
blendMode: isSelected ? null : BlendMode.srcIn,
),
),
);
}
}
class UnreadRedDot extends StatelessWidget {
const UnreadRedDot({super.key});
@override
Widget build(BuildContext context) {
return const SizedBox(
height: _kNotificationIconHeight,
child: Center(
child: SizedBox.square(
dimension: 6.0,
child: DecoratedBox(
decoration: ShapeDecoration(
color: Color(0xFFFF6331),
shape: OvalBorder(),
),
),
),
),
);
}
}
class NotificationContent extends StatefulWidget {
const NotificationContent({
super.key,
required this.reminder,
});
final ReminderPB reminder;
@override
State<NotificationContent> createState() => _NotificationContentState();
}
class _NotificationContentState extends State<NotificationContent> {
@override
void didUpdateWidget(covariant NotificationContent oldWidget) {
super.didUpdateWidget(oldWidget);
context.read<NotificationReminderBloc>().add(
const NotificationReminderEvent.reset(),
);
}
@override
Widget build(BuildContext context) {
return BlocBuilder<NotificationReminderBloc, NotificationReminderState>(
builder: (context, state) {
final view = state.view;
if (view == null) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// title
_buildHeader(),
// time & page name
_buildTimeAndPageName(
context,
state.createdAt,
state.pageTitle,
),
// content
Padding(
padding: const EdgeInsets.only(right: 16.0),
child: _buildContent(view, nodes: state.nodes),
),
],
);
},
);
}
Widget _buildContent(ViewPB view, {List<Node>? nodes}) {
if (view.layout.isDocumentView && nodes != null) {
return IntrinsicHeight(
child: BlocProvider(
create: (context) => DocumentPageStyleBloc(view: view),
child: NotificationDocumentContent(
reminder: widget.reminder,
nodes: nodes,
),
),
);
} else if (view.layout.isDatabaseView) {
final opacity = widget.reminder.type == ReminderType.past ? 0.3 : 1.0;
return Opacity(
opacity: opacity,
child: FlowyText(
widget.reminder.message,
fontSize: 14,
figmaLineHeight: 22,
color: context.notificationItemTextColor,
),
);
}
return const SizedBox.shrink();
}
Widget _buildHeader() {
return FlowyText.semibold(
LocaleKeys.settings_notifications_titles_reminder.tr(),
fontSize: 14,
figmaLineHeight: 20,
);
}
Widget _buildTimeAndPageName(
BuildContext context,
String createdAt,
String pageTitle,
) {
return Opacity(
opacity: 0.5,
child: Row(
children: [
// the legacy reminder doesn't contain the timestamp, so we don't show it
if (createdAt.isNotEmpty) ...[
FlowyText.regular(
createdAt,
fontSize: 12,
figmaLineHeight: 18,
color: context.notificationItemTextColor,
),
const NotificationEllipse(),
],
FlowyText.regular(
pageTitle,
fontSize: 12,
figmaLineHeight: 18,
color: context.notificationItemTextColor,
),
],
),
);
}
}
class NotificationEllipse extends StatelessWidget {
const NotificationEllipse({super.key});
@override
Widget build(BuildContext context) {
return Container(
width: 2.50,
height: 2.50,
margin: const EdgeInsets.symmetric(horizontal: 5.0),
decoration: ShapeDecoration(
color: context.notificationItemTextColor,
shape: const OvalBorder(),
),
);
}
}
class NotificationDocumentContent extends StatelessWidget {
const NotificationDocumentContent({
super.key,
required this.reminder,
required this.nodes,
});
final ReminderPB reminder;
final List<Node> nodes;
@override
Widget build(BuildContext context) {
final editorState = EditorState(
document: Document(
root: pageNode(children: nodes),
),
);
final styleCustomizer = EditorStyleCustomizer(
context: context,
padding: EdgeInsets.zero,
);
final editorStyle = styleCustomizer.style().copyWith(
// hide the cursor
cursorColor: Colors.transparent,
cursorWidth: 0,
textStyleConfiguration: TextStyleConfiguration(
lineHeight: 22 / 14,
applyHeightToFirstAscent: true,
applyHeightToLastDescent: true,
text: TextStyle(
fontSize: 14,
color: context.notificationItemTextColor,
height: 22 / 14,
fontWeight: FontWeight.w400,
leadingDistribution: TextLeadingDistribution.even,
),
),
);
final blockBuilders = getEditorBuilderMap(
context: context,
editorState: editorState,
styleCustomizer: styleCustomizer,
// the editor is not editable in the chat
editable: false,
customHeadingPadding: EdgeInsets.zero,
);
return IgnorePointer(
child: Opacity(
opacity: reminder.type == ReminderType.past ? 0.3 : 1,
child: AppFlowyEditor(
editorState: editorState,
editorStyle: editorStyle,
disableSelectionService: true,
disableKeyboardService: true,
disableScrollService: true,
editable: false,
shrinkWrap: true,
blockComponentBuilders: blockBuilders,
),
),
);
}
}
@@ -1,212 +0,0 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/notification/notification_reminder_bloc.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart';
import 'package:appflowy/mobile/presentation/page_item/mobile_slide_action_button.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy/user/application/reminder/reminder_extension.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
enum NotificationPaneActionType {
more,
markAsRead,
// only used in the debug mode.
unArchive;
MobileSlideActionButton actionButton(
BuildContext context, {
required MobileNotificationTabType tabType,
}) {
switch (this) {
case NotificationPaneActionType.markAsRead:
return MobileSlideActionButton(
backgroundColor: const Color(0xFF00C8FF),
svg: FlowySvgs.m_notification_action_mark_as_read_s,
size: 24.0,
onPressed: (context) {
showToastNotification(
context,
message: LocaleKeys
.settings_notifications_markAsReadNotifications_success
.tr(),
);
context.read<ReminderBloc>().add(
ReminderEvent.update(
ReminderUpdate(
id: context.read<NotificationReminderBloc>().reminder.id,
isRead: true,
),
),
);
},
);
// this action is only used in the debug mode.
case NotificationPaneActionType.unArchive:
return MobileSlideActionButton(
backgroundColor: const Color(0xFF00C8FF),
svg: FlowySvgs.m_notification_action_mark_as_read_s,
size: 24.0,
onPressed: (context) {
showToastNotification(
context,
message: 'Unarchive notification success',
);
context.read<ReminderBloc>().add(
ReminderEvent.update(
ReminderUpdate(
id: context.read<NotificationReminderBloc>().reminder.id,
isArchived: false,
),
),
);
},
);
case NotificationPaneActionType.more:
return MobileSlideActionButton(
backgroundColor: const Color(0xE5515563),
svg: FlowySvgs.three_dots_s,
size: 24.0,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(10),
bottomLeft: Radius.circular(10),
),
onPressed: (context) {
final reminderBloc = context.read<ReminderBloc>();
final notificationReminderBloc =
context.read<NotificationReminderBloc>();
showMobileBottomSheet(
context,
showDragHandle: true,
showDivider: false,
useRootNavigator: true,
backgroundColor: Theme.of(context).colorScheme.surface,
builder: (_) {
return MultiBlocProvider(
providers: [
BlocProvider.value(value: reminderBloc),
BlocProvider.value(value: notificationReminderBloc),
],
child: _NotificationMoreActions(
onClickMultipleChoice: () {
Future.delayed(const Duration(milliseconds: 250), () {
bottomNavigationBarType.value =
BottomNavigationBarActionType
.notificationMultiSelect;
});
},
),
);
},
);
},
);
}
}
}
class _NotificationMoreActions extends StatelessWidget {
const _NotificationMoreActions({
required this.onClickMultipleChoice,
});
final VoidCallback onClickMultipleChoice;
@override
Widget build(BuildContext context) {
final reminder = context.read<NotificationReminderBloc>().reminder;
return Column(
children: [
if (!reminder.isRead)
FlowyOptionTile.text(
height: 52.0,
text: LocaleKeys.settings_notifications_action_markAsRead.tr(),
leftIcon: const FlowySvg(
FlowySvgs.m_notification_action_mark_as_read_s,
size: Size.square(20),
),
showTopBorder: false,
showBottomBorder: false,
onTap: () => _onMarkAsRead(context),
),
FlowyOptionTile.text(
height: 52.0,
text: LocaleKeys.settings_notifications_action_multipleChoice.tr(),
leftIcon: const FlowySvg(
FlowySvgs.m_notification_action_multiple_choice_s,
size: Size.square(20),
),
showTopBorder: false,
showBottomBorder: false,
onTap: () => _onMultipleChoice(context),
),
if (!reminder.isArchived)
FlowyOptionTile.text(
height: 52.0,
text: LocaleKeys.settings_notifications_action_archive.tr(),
leftIcon: const FlowySvg(
FlowySvgs.m_notification_action_archive_s,
size: Size.square(20),
),
showTopBorder: false,
showBottomBorder: false,
onTap: () => _onArchive(context),
),
],
);
}
void _onMarkAsRead(BuildContext context) {
Navigator.of(context).pop();
showToastNotification(
context,
message: LocaleKeys.settings_notifications_markAsReadNotifications_success
.tr(),
);
context.read<ReminderBloc>().add(
ReminderEvent.update(
ReminderUpdate(
id: context.read<NotificationReminderBloc>().reminder.id,
isRead: true,
),
),
);
}
void _onMultipleChoice(BuildContext context) {
Navigator.of(context).pop();
onClickMultipleChoice();
}
void _onArchive(BuildContext context) {
showToastNotification(
context,
message: LocaleKeys.settings_notifications_archiveNotifications_success
.tr()
.tr(),
);
context.read<ReminderBloc>().add(
ReminderEvent.update(
ReminderUpdate(
id: context.read<NotificationReminderBloc>().reminder.id,
isRead: true,
isArchived: true,
),
),
);
Navigator.of(context).pop();
}
}
@@ -1,137 +0,0 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart';
import 'package:appflowy/shared/list_extension.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy/user/application/reminder/reminder_extension.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/appflowy_backend.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class NotificationTab extends StatefulWidget {
const NotificationTab({
super.key,
required this.tabType,
});
final MobileNotificationTabType tabType;
@override
State<NotificationTab> createState() => _NotificationTabState();
}
class _NotificationTabState extends State<NotificationTab>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return BlocBuilder<ReminderBloc, ReminderState>(
builder: (context, state) {
final reminders = _filterReminders(state.reminders);
if (reminders.isEmpty) {
// add refresh indicator to the empty notification.
return EmptyNotification(
type: widget.tabType,
);
}
final child = ListView.separated(
itemCount: reminders.length,
separatorBuilder: (context, index) => const VSpace(8.0),
itemBuilder: (context, index) {
final reminder = reminders[index];
return NotificationItem(
key: ValueKey('${widget.tabType}_${reminder.id}'),
tabType: widget.tabType,
reminder: reminder,
);
},
);
return RefreshIndicator.adaptive(
onRefresh: () async => _onRefresh(context),
child: child,
);
},
);
}
Future<void> _onRefresh(BuildContext context) async {
context.read<ReminderBloc>().add(const ReminderEvent.refresh());
// at least 0.5 seconds to dismiss the refresh indicator.
// otherwise, it will be dismissed immediately.
await context.read<ReminderBloc>().stream.firstOrNull;
await Future.delayed(const Duration(milliseconds: 500));
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.settings_notifications_refreshSuccess.tr(),
);
}
}
List<ReminderPB> _filterReminders(List<ReminderPB> reminders) {
switch (widget.tabType) {
case MobileNotificationTabType.inbox:
return reminders.reversed
.where((reminder) => !reminder.isArchived)
.toList()
.unique((reminder) => reminder.id);
case MobileNotificationTabType.archive:
return reminders.reversed
.where((reminder) => reminder.isArchived)
.toList()
.unique((reminder) => reminder.id);
case MobileNotificationTabType.unread:
return reminders.reversed
.where((reminder) => !reminder.isRead)
.toList()
.unique((reminder) => reminder.id);
}
}
}
class MultiSelectNotificationTab extends StatelessWidget {
const MultiSelectNotificationTab({
super.key,
});
@override
Widget build(BuildContext context) {
return BlocBuilder<ReminderBloc, ReminderState>(
builder: (context, state) {
// find the reminders that are not archived or read.
final reminders = state.reminders.reversed
.where((reminder) => !reminder.isArchived || !reminder.isRead)
.toList();
if (reminders.isEmpty) {
// add refresh indicator to the empty notification.
return const SizedBox.shrink();
}
return ListView.separated(
itemCount: reminders.length,
separatorBuilder: (context, index) => const VSpace(8.0),
itemBuilder: (context, index) {
final reminder = reminders[index];
return MultiSelectNotificationItem(
key: ValueKey(reminder.id),
reminder: reminder,
);
},
);
},
);
}
}
@@ -1,87 +0,0 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/home/tab/_round_underline_tab_indicator.dart';
import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:reorderable_tabbar/reorderable_tabbar.dart';
enum MobileNotificationTabType {
inbox,
unread,
archive;
String get tr {
switch (this) {
case MobileNotificationTabType.inbox:
return LocaleKeys.settings_notifications_tabs_inbox.tr();
case MobileNotificationTabType.unread:
return LocaleKeys.settings_notifications_tabs_unread.tr();
case MobileNotificationTabType.archive:
return LocaleKeys.settings_notifications_tabs_archived.tr();
}
}
List<NotificationPaneActionType> get actions {
switch (this) {
case MobileNotificationTabType.inbox:
return [
NotificationPaneActionType.more,
NotificationPaneActionType.markAsRead,
];
case MobileNotificationTabType.unread:
case MobileNotificationTabType.archive:
return [];
}
}
}
class MobileNotificationTabBar extends StatelessWidget {
const MobileNotificationTabBar({
super.key,
this.height = 38.0,
required this.tabController,
required this.tabs,
});
final double height;
final List<MobileNotificationTabType> tabs;
final TabController tabController;
@override
Widget build(BuildContext context) {
final baseStyle = Theme.of(context).textTheme.bodyMedium;
final labelStyle = baseStyle?.copyWith(
fontWeight: FontWeight.w500,
fontSize: 16.0,
height: 22.0 / 16.0,
);
final unselectedLabelStyle = baseStyle?.copyWith(
fontWeight: FontWeight.w400,
fontSize: 15.0,
height: 22.0 / 15.0,
);
return Container(
height: height,
padding: const EdgeInsets.only(left: 8.0),
child: ReorderableTabBar(
controller: tabController,
tabs: tabs.map((e) => Tab(text: e.tr)).toList(),
indicatorSize: TabBarIndicatorSize.label,
isScrollable: true,
labelStyle: labelStyle,
labelColor: baseStyle?.color,
labelPadding: const EdgeInsets.symmetric(horizontal: 12.0),
unselectedLabelStyle: unselectedLabelStyle,
overlayColor: WidgetStateProperty.all(Colors.transparent),
indicator: const RoundUnderlineTabIndicator(
width: 28.0,
borderSide: BorderSide(
color: Color(0xFF00C8FF),
width: 3,
),
),
),
);
}
}
@@ -1,9 +0,0 @@
export 'empty.dart';
export 'header.dart';
export 'multi_select_notification_item.dart';
export 'notification_item.dart';
export 'settings_popup_menu.dart';
export 'shared.dart';
export 'slide_actions.dart';
export 'tab.dart';
export 'tab_bar.dart';
@@ -260,7 +260,6 @@ class _SingleMobileInnerViewItemState extends State<SingleMobileInnerViewItem> {
child: FlowyText.regular(
widget.view.name,
fontSize: 16.0,
figmaLineHeight: 20.0,
overflow: TextOverflow.ellipsis,
),
),
@@ -298,17 +297,12 @@ class _SingleMobileInnerViewItemState extends State<SingleMobileInnerViewItem> {
? FlowyText.emoji(
widget.view.icon.value,
fontSize: 18.0,
figmaLineHeight: 20.0,
optimizeEmojiAlign: true,
)
: Opacity(
opacity: 0.7,
child: widget.view.defaultIcon(size: const Size.square(18)),
child: widget.view.defaultIcon(),
);
return SizedBox(
width: 18.0,
child: icon,
);
return SizedBox(width: 18.0, child: icon);
}
// > button or · button
@@ -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(
@@ -29,7 +29,6 @@ class FontSetting extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
FlowyText(
lineHeight: 1.0,
name,
color: theme.colorScheme.onSurface,
),
@@ -38,7 +38,6 @@ class _LanguageSettingGroupState extends State<LanguageSettingGroup> {
mainAxisSize: MainAxisSize.min,
children: [
FlowyText(
lineHeight: 1.0,
languageFromLocale(locale),
color: theme.colorScheme.onSurface,
),
@@ -1,47 +0,0 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
class NavigationBarButton extends StatelessWidget {
const NavigationBarButton({
super.key,
required this.text,
required this.icon,
required this.onTap,
this.enable = true,
});
final String text;
final FlowySvgData icon;
final VoidCallback onTap;
final bool enable;
@override
Widget build(BuildContext context) {
return Opacity(
opacity: enable ? 1.0 : 0.3,
child: Container(
height: 40,
decoration: ShapeDecoration(
shape: RoundedRectangleBorder(
side: const BorderSide(color: Color(0x3F1F2329)),
borderRadius: BorderRadius.circular(10),
),
),
child: FlowyButton(
useIntrinsicWidth: true,
expandText: false,
iconPadding: 8,
leftIcon: FlowySvg(icon),
onTap: enable ? onTap : null,
text: FlowyText(
text,
fontSize: 15.0,
figmaLineHeight: 18.0,
fontWeight: FontWeight.w400,
),
),
),
);
}
}
@@ -1,53 +1,33 @@
import 'dart:async';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'chat_message_service.dart';
part 'chat_ai_message_bloc.freezed.dart';
class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
ChatAIMessageBloc({
dynamic message,
String? refSourceJsonString,
required this.chatId,
required this.questionId,
}) : super(
ChatAIMessageState.initial(
message,
messageReferenceSource(refSourceJsonString),
),
) {
}) : 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());
}
},
onMetadata: (sources) {
if (!isClosed) {
add(ChatAIMessageEvent.receiveSources(sources));
}
},
);
_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), () {
@@ -62,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) {
@@ -80,7 +55,8 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
}
emit(
state.copyWith(
messageState: const MessageState.loading(),
retryState: const LoadingState.loading(),
error: null,
),
);
@@ -88,7 +64,7 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
chatId: chatId,
messageId: questionId,
);
AIEventGetAnswerForQuestion(payload).send().then((result) {
ChatEventGetAnswerForQuestion(payload).send().then((result) {
if (!isClosed) {
result.fold(
(answer) {
@@ -106,21 +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(),
),
);
},
receiveSources: (List<ChatMessageRefSource> sources) {
emit(
state.copyWith(
sources: sources,
error: null,
retryState: const LoadingState.finish(),
),
);
},
@@ -129,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;
}
@@ -136,42 +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;
const factory ChatAIMessageEvent.receiveSources(
List<ChatMessageRefSource> sources,
) = _ReceiveMetadata;
}
@freezed
class ChatAIMessageState with _$ChatAIMessageState {
const factory ChatAIMessageState({
AnswerStream? stream,
String? error,
required String text,
required MessageState messageState,
required List<ChatMessageRefSource> sources,
required LoadingState retryState,
}) = _ChatAIMessageState;
factory ChatAIMessageState.initial(
dynamic text,
List<ChatMessageRefSource> sources,
) {
factory ChatAIMessageState.initial(dynamic text) {
return ChatAIMessageState(
text: text is String ? text : "",
stream: text is AnswerStream ? text : null,
messageState: const MessageState.ready(),
sources: sources,
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;
}
@@ -1,10 +1,11 @@
import 'dart:async';
import 'dart:collection';
import 'dart:ffi';
import 'dart:isolate';
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.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-folder/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
@@ -16,12 +17,12 @@ import 'package:flutter_chat_types/flutter_chat_types.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:nanoid/nanoid.dart';
import 'chat_entity.dart';
import 'chat_message_listener.dart';
import 'chat_message_service.dart';
part 'chat_bloc.freezed.dart';
const sendMessageErrorKey = "sendMessageError";
class ChatBloc extends Bloc<ChatEvent, ChatState> {
ChatBloc({
required ViewPB view,
@@ -39,8 +40,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
final String chatId;
/// The last streaming message id
String answerStreamMessageId = '';
String questionStreamMessageId = '';
String lastStreamMessageId = '';
/// Using a temporary map to associate the real message ID with the last streaming message ID.
///
@@ -69,7 +69,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
chatId: state.view.id,
limit: Int64(10),
);
AIEventLoadNextMessage(payload).send().then(
ChatEventLoadNextMessage(payload).send().then(
(result) {
result.fold((list) {
if (!isClosed) {
@@ -83,23 +83,16 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
},
);
},
// Loading messages
startLoadingPrevMessage: () async {
Int64? beforeMessageId;
final oldestMessage = _getOlderstMessage();
if (oldestMessage != null) {
try {
beforeMessageId = Int64.parseInt(oldestMessage.id);
} catch (e) {
Log.error(
"Failed to parse message id: $e, messaeg_id: ${oldestMessage.id}",
);
}
beforeMessageId = Int64.parseInt(oldestMessage.id);
}
_loadPrevMessage(beforeMessageId);
emit(
state.copyWith(
loadingPreviousStatus: const ChatLoadingState.loading(),
loadingPreviousStatus: const LoadingState.loading(),
),
);
},
@@ -115,7 +108,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
emit(
state.copyWith(
messages: uniqueMessages,
loadingPreviousStatus: const ChatLoadingState.finish(),
loadingPreviousStatus: const LoadingState.finish(),
hasMorePrevMessage: hasMore,
),
);
@@ -126,54 +119,28 @@ 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,
initialLoadingStatus: const ChatLoadingState.finish(),
initialLoadingStatus: const LoadingState.finish(),
),
);
},
// streaming message
finishAnswerStreaming: () {
streaming: (Message message) {
final allMessages = _perminentMessages();
allMessages.insert(0, message);
emit(
state.copyWith(
streamingState: const StreamingState.done(),
acceptRelatedQuestion: true,
canSendMessage:
state.sendingState == const SendMessageState.done(),
messages: allMessages,
streamingStatus: const LoadingState.loading(),
),
);
},
didUpdateAnswerStream: (AnswerStream stream) {
emit(state.copyWith(answerStream: stream));
},
stopStream: () async {
if (state.answerStream == null) {
return;
}
final payload = StopStreamPB(chatId: chatId);
await AIEventStopStream(payload).send();
final allMessages = _perminentMessages();
if (state.streamingState != const StreamingState.done()) {
// If the streaming is not started, remove the message from the list
if (!state.answerStream!.hasStarted) {
allMessages.removeWhere(
(element) => element.id == answerStreamMessageId,
);
answerStreamMessageId = "";
}
// when stop stream, we will set the answer stream to null. Which means the streaming
// is finished or canceled.
emit(
state.copyWith(
messages: allMessages,
answerStream: null,
streamingState: const StreamingState.done(),
),
);
}
didFinishStreaming: () {
emit(
state.copyWith(streamingStatus: const LoadingState.finish()),
);
},
receveMessage: (Message message) {
final allMessages = _perminentMessages();
@@ -186,52 +153,23 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
),
);
},
startAnswerStreaming: (Message message) {
final allMessages = _perminentMessages();
allMessages.insert(0, message);
emit(
state.copyWith(
messages: allMessages,
streamingState: const StreamingState.streaming(),
canSendMessage: false,
),
);
},
sendMessage: (String message, Map<String, dynamic>? metadata) async {
unawaited(_startStreamingMessage(message, metadata, emit));
sendMessage: (String message) {
_startStreamingMessage(message, emit);
final allMessages = _perminentMessages();
emit(
state.copyWith(
lastSentMessage: null,
messages: allMessages,
relatedQuestions: [],
acceptRelatedQuestion: false,
sendingState: const SendMessageState.sending(),
canSendMessage: false,
),
);
},
finishSending: (ChatMessagePB message) {
emit(
state.copyWith(
lastSentMessage: message,
sendingState: const SendMessageState.done(),
canSendMessage:
state.streamingState == const StreamingState.done(),
),
);
},
// related question
didReceiveRelatedQuestion: (List<RelatedQuestionPB> questions) {
if (questions.isEmpty) {
return;
}
final allMessages = _perminentMessages();
final message = CustomMessage(
metadata: OnetimeShotType.relatedQuestion.toMap(),
author: const User(id: systemUserId),
id: systemUserId,
author: const User(id: "system"),
id: 'system',
);
allMessages.insert(0, message);
emit(
@@ -248,6 +186,44 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
),
);
},
didSentUserMessage: (ChatMessagePB message) {
emit(
state.copyWith(
lastSentMessage: message,
),
);
},
didUpdateAnswerStream: (AnswerStream stream) {
emit(state.copyWith(answerStream: stream));
},
stopStream: () async {
if (state.answerStream == null) {
return;
}
final payload = StopStreamPB(chatId: chatId);
await ChatEventStopStream(payload).send();
final allMessages = _perminentMessages();
if (state.streamingStatus != const LoadingState.finish()) {
// If the streaming is not started, remove the message from the list
if (!state.answerStream!.hasStarted) {
allMessages.removeWhere(
(element) => element.id == lastStreamMessageId,
);
lastStreamMessageId = "";
}
// when stop stream, we will set the answer stream to null. Which means the streaming
// is finished or canceled.
emit(
state.copyWith(
messages: allMessages,
answerStream: null,
streamingStatus: const LoadingState.finish(),
),
);
}
},
);
},
);
@@ -258,17 +234,10 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
chatMessageCallback: (pb) {
if (!isClosed) {
// 3 mean message response from AI
if (pb.authorType == 3 && answerStreamMessageId.isNotEmpty) {
if (pb.authorType == 3 && lastStreamMessageId.isNotEmpty) {
temporaryMessageIDMap[pb.messageId.toString()] =
answerStreamMessageId;
answerStreamMessageId = "";
}
// 1 mean message response from User
if (pb.authorType == 1 && questionStreamMessageId.isNotEmpty) {
temporaryMessageIDMap[pb.messageId.toString()] =
questionStreamMessageId;
questionStreamMessageId = "";
lastStreamMessageId;
lastStreamMessageId = "";
}
final message = _createTextMessage(pb);
@@ -278,7 +247,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
chatErrorMessageCallback: (err) {
if (!isClosed) {
Log.error("chat error: ${err.errorMessage}");
add(const ChatEvent.finishAnswerStreaming());
add(const ChatEvent.didFinishStreaming());
}
},
latestMessageCallback: (list) {
@@ -295,7 +264,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
},
finishStreamingCallback: () {
if (!isClosed) {
add(const ChatEvent.finishAnswerStreaming());
add(const ChatEvent.didFinishStreaming());
// The answer strema will bet set to null after the streaming is finished or canceled.
// so if the answer stream is null, we will not get related question.
if (state.lastSentMessage != null && state.answerStream != null) {
@@ -304,13 +273,11 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
messageId: state.lastSentMessage!.messageId,
);
// When user message was sent to the server, we start gettting related question
AIEventGetRelatedQuestion(payload).send().then((result) {
ChatEventGetRelatedQuestion(payload).send().then((result) {
if (!isClosed) {
result.fold(
(list) {
if (state.acceptRelatedQuestion) {
add(ChatEvent.didReceiveRelatedQuestion(list.items));
}
add(ChatEvent.didReceiveRelatedQuestion(list.items));
},
(err) {
Log.error("Failed to get related question: $err");
@@ -355,12 +322,11 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
limit: Int64(10),
beforeMessageId: beforeMessageId,
);
AIEventLoadPrevMessage(payload).send();
ChatEventLoadPrevMessage(payload).send();
}
Future<void> _startStreamingMessage(
String message,
Map<String, dynamic>? metadata,
Emitter<ChatState> emit,
) async {
if (state.answerStream != null) {
@@ -368,37 +334,29 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
}
final answerStream = AnswerStream();
final questionStream = QuestionStream();
add(ChatEvent.didUpdateAnswerStream(answerStream));
final payload = StreamChatPayloadPB(
chatId: state.view.id,
message: message,
messageType: ChatMessageTypePB.User,
questionStreamPort: Int64(questionStream.nativePort),
answerStreamPort: Int64(answerStream.nativePort),
metadata: await metadataPBFromMetadata(metadata),
textStreamPort: Int64(answerStream.nativePort),
);
final questionStreamMessage = _createQuestionStreamMessage(
questionStream,
metadata,
);
add(ChatEvent.receveMessage(questionStreamMessage));
// Stream message to the server
final result = await AIEventStreamMessage(payload).send();
final result = await ChatEventStreamMessage(payload).send();
result.fold(
(ChatMessagePB question) {
if (!isClosed) {
add(ChatEvent.finishSending(question));
add(ChatEvent.didSentUserMessage(question));
// final message = _createTextMessage(question);
// add(ChatEvent.receveMessage(message));
final questionMessageId = question.messageId;
final message = _createTextMessage(question);
add(ChatEvent.receveMessage(message));
final streamAnswer =
_createAnswerStreamMessage(answerStream, question.messageId);
add(ChatEvent.startAnswerStreaming(streamAnswer));
_createStreamMessage(answerStream, questionMessageId);
add(ChatEvent.streaming(streamAnswer));
}
},
(err) {
@@ -411,8 +369,8 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
final error = CustomMessage(
metadata: metadata,
author: const User(id: systemUserId),
id: systemUserId,
author: const User(id: "system"),
id: 'system',
);
add(ChatEvent.receveMessage(error));
@@ -421,18 +379,15 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
);
}
Message _createAnswerStreamMessage(
AnswerStream stream,
Int64 questionMessageId,
) {
final streamMessageId = (questionMessageId + 1).toString();
answerStreamMessageId = streamMessageId;
Message _createStreamMessage(AnswerStream stream, Int64 questionMessageId) {
final streamMessageId = nanoid();
lastStreamMessageId = streamMessageId;
return TextMessage(
author: User(id: "streamId:${nanoid()}"),
author: User(id: nanoid()),
metadata: {
"$AnswerStream": stream,
messageQuestionIdKey: questionMessageId,
"question": questionMessageId,
"chatId": chatId,
},
id: streamMessageId,
@@ -441,32 +396,6 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
);
}
Message _createQuestionStreamMessage(
QuestionStream stream,
Map<String, dynamic>? sentMetadata,
) {
final now = DateTime.now();
final timestamp = now.millisecondsSinceEpoch;
questionStreamMessageId = timestamp.toString();
final Map<String, dynamic> metadata = {};
// if (sentMetadata != null) {
// metadata[messageMetadataJsonStringKey] = sentMetadata;
// }
metadata["$QuestionStream"] = stream;
metadata["chatId"] = chatId;
metadata[messageChatFileListKey] =
chatFilesFromMessageMetadata(sentMetadata);
return TextMessage(
author: User(id: state.userProfile.id.toString()),
metadata: metadata,
id: questionStreamMessageId,
createdAt: DateTime.now().millisecondsSinceEpoch,
text: '',
);
}
Message _createTextMessage(ChatMessagePB message) {
String messageId = message.messageId.toString();
@@ -480,9 +409,6 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
id: messageId,
text: message.content,
createdAt: message.createdAt.toInt() * 1000,
metadata: {
messageRefSourceJsonStringKey: message.metadata,
},
);
}
}
@@ -490,22 +416,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
@freezed
class ChatEvent with _$ChatEvent {
const factory ChatEvent.initialLoad() = _InitialLoadMessage;
// send message
const factory ChatEvent.sendMessage({
required String message,
Map<String, dynamic>? metadata,
}) = _SendMessage;
const factory ChatEvent.finishSending(ChatMessagePB message) =
_FinishSendMessage;
// receive message
const factory ChatEvent.startAnswerStreaming(Message message) =
_StartAnswerStreaming;
const factory ChatEvent.receveMessage(Message message) = _ReceiveMessage;
const factory ChatEvent.finishAnswerStreaming() = _FinishAnswerStreaming;
// loading messages
const factory ChatEvent.sendMessage(String message) = _SendMessage;
const factory ChatEvent.startLoadingPrevMessage() = _StartLoadPrevMessage;
const factory ChatEvent.didLoadPreviousMessages(
List<Message> messages,
@@ -513,13 +424,16 @@ class ChatEvent with _$ChatEvent {
) = _DidLoadPreviousMessages;
const factory ChatEvent.didLoadLatestMessages(List<Message> messages) =
_DidLoadMessages;
const factory ChatEvent.streaming(Message message) = _StreamingMessage;
const factory ChatEvent.receveMessage(Message message) = _ReceiveMessage;
// related questions
const factory ChatEvent.didFinishStreaming() = _FinishStreamingMessage;
const factory ChatEvent.didReceiveRelatedQuestion(
List<RelatedQuestionPB> questions,
) = _DidReceiveRelatedQueston;
const factory ChatEvent.clearReleatedQuestion() = _ClearRelatedQuestion;
const factory ChatEvent.didSentUserMessage(ChatMessagePB message) =
_DidSendUserMessage;
const factory ChatEvent.didUpdateAnswerStream(
AnswerStream stream,
) = _DidUpdateAnswerStream;
@@ -534,23 +448,20 @@ class ChatState with _$ChatState {
required UserProfilePB userProfile,
// When opening the chat, the initial loading status will be set as loading.
//After the initial loading is done, the status will be set as finished.
required ChatLoadingState initialLoadingStatus,
required LoadingState initialLoadingStatus,
// When loading previous messages, the status will be set as loading.
// After the loading is done, the status will be set as finished.
required ChatLoadingState loadingPreviousStatus,
required LoadingState loadingPreviousStatus,
// When sending a user message, the status will be set as loading.
// After the message is sent, the status will be set as finished.
required StreamingState streamingState,
required SendMessageState sendingState,
required LoadingState streamingStatus,
// Indicate whether there are more previous messages to load.
required bool hasMorePrevMessage,
// The related questions that are received after the user message is sent.
required List<RelatedQuestionPB> relatedQuestions,
@Default(false) bool acceptRelatedQuestion,
// The last user message that is sent to the server.
ChatMessagePB? lastSentMessage,
AnswerStream? answerStream,
@Default(true) bool canSendMessage,
}) = _ChatState;
factory ChatState.initial(ViewPB view, UserProfilePB userProfile) =>
@@ -558,17 +469,97 @@ class ChatState with _$ChatState {
view: view,
messages: [],
userProfile: userProfile,
initialLoadingStatus: const ChatLoadingState.finish(),
loadingPreviousStatus: const ChatLoadingState.finish(),
streamingState: const StreamingState.done(),
sendingState: const SendMessageState.done(),
initialLoadingStatus: const LoadingState.finish(),
loadingPreviousStatus: const LoadingState.finish(),
streamingStatus: const LoadingState.finish(),
hasMorePrevMessage: true,
relatedQuestions: [],
);
}
bool isOtherUserMessage(Message message) {
return message.author.id != aiResponseUserId &&
message.author.id != systemUserId &&
!message.author.id.startsWith("streamId:");
@freezed
class LoadingState with _$LoadingState {
const factory LoadingState.loading() = _Loading;
const factory LoadingState.finish() = _Finish;
}
enum OnetimeShotType {
unknown,
relatedQuestion,
invalidSendMesssage,
}
const onetimeShotType = "OnetimeShotType";
extension OnetimeMessageTypeExtension on OnetimeShotType {
static OnetimeShotType fromString(String value) {
switch (value) {
case 'OnetimeShotType.relatedQuestion':
return OnetimeShotType.relatedQuestion;
case 'OnetimeShotType.invalidSendMesssage':
return OnetimeShotType.invalidSendMesssage;
default:
Log.error('Unknown OnetimeShotType: $value');
return OnetimeShotType.unknown;
}
}
Map<String, dynamic> toMap() {
return {
onetimeShotType: toString(),
};
}
}
OnetimeShotType? onetimeMessageTypeFromMeta(Map<String, dynamic>? metadata) {
if (metadata == null) {
return null;
}
for (final entry in metadata.entries) {
if (entry.key == onetimeShotType) {
return OnetimeMessageTypeExtension.fromString(entry.value as String);
}
}
return null;
}
typedef AnswerStreamElement = String;
class AnswerStream {
AnswerStream() {
_port.handler = _controller.add;
_subscription = _controller.stream.listen(
(event) {
if (event.startsWith("data:")) {
_hasStarted = true;
} else if (event.startsWith("error:")) {
_error = event.substring(5);
}
},
);
}
final RawReceivePort _port = RawReceivePort();
final StreamController<AnswerStreamElement> _controller =
StreamController.broadcast();
late StreamSubscription<AnswerStreamElement> _subscription;
bool _hasStarted = false;
String? _error;
int get nativePort => _port.sendPort.nativePort;
bool get hasStarted => _hasStarted;
String? get error => _error;
Future<void> dispose() async {
await _controller.close();
await _subscription.cancel();
_port.close();
}
StreamSubscription<AnswerStreamElement> listen(
void Function(AnswerStreamElement event)? onData,
) {
return _controller.stream.listen(onData);
}
}
@@ -1,182 +0,0 @@
import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:path/path.dart' as path;
part 'chat_entity.g.dart';
part 'chat_entity.freezed.dart';
const sendMessageErrorKey = "sendMessageError";
const systemUserId = "system";
const aiResponseUserId = "0";
/// `messageRefSourceJsonStringKey` is the key used for metadata that contains the reference source of a message.
/// Each message may include this information.
/// - When used in a sent message, it indicates that the message includes an attachment.
/// - When used in a received message, it indicates the AI reference sources used to answer a question.
const messageRefSourceJsonStringKey = "ref_source_json_string";
const messageChatFileListKey = "chat_files";
const messageQuestionIdKey = "question_id";
@JsonSerializable()
class ChatMessageRefSource {
ChatMessageRefSource({
required this.id,
required this.name,
required this.source,
});
factory ChatMessageRefSource.fromJson(Map<String, dynamic> json) =>
_$ChatMessageRefSourceFromJson(json);
final String id;
final String name;
final String source;
Map<String, dynamic> toJson() => _$ChatMessageRefSourceToJson(this);
}
@freezed
class StreamingState with _$StreamingState {
const factory StreamingState.streaming() = _Streaming;
const factory StreamingState.done({FlowyError? error}) = _StreamDone;
}
@freezed
class SendMessageState with _$SendMessageState {
const factory SendMessageState.sending() = _Sending;
const factory SendMessageState.done({FlowyError? error}) = _SendDone;
}
class ChatFile extends Equatable {
const ChatFile({
required this.filePath,
required this.fileName,
required this.fileType,
});
static ChatFile? fromFilePath(String filePath) {
final file = File(filePath);
if (!file.existsSync()) {
return null;
}
final fileName = path.basename(filePath);
final extension = path.extension(filePath).toLowerCase();
ChatMessageMetaTypePB fileType;
switch (extension) {
case '.pdf':
fileType = ChatMessageMetaTypePB.PDF;
break;
case '.txt':
fileType = ChatMessageMetaTypePB.Txt;
break;
case '.md':
fileType = ChatMessageMetaTypePB.Markdown;
break;
default:
fileType = ChatMessageMetaTypePB.UnknownMetaType;
}
return ChatFile(
filePath: filePath,
fileName: fileName,
fileType: fileType,
);
}
final String filePath;
final String fileName;
final ChatMessageMetaTypePB fileType;
@override
List<Object?> get props => [filePath];
}
extension ChatFileTypeExtension on ChatMessageMetaTypePB {
Widget get icon {
switch (this) {
case ChatMessageMetaTypePB.PDF:
return const FlowySvg(
FlowySvgs.file_pdf_s,
color: Color(0xff00BCF0),
);
case ChatMessageMetaTypePB.Txt:
return const FlowySvg(
FlowySvgs.file_txt_s,
color: Color(0xff00BCF0),
);
case ChatMessageMetaTypePB.Markdown:
return const FlowySvg(
FlowySvgs.file_md_s,
color: Color(0xff00BCF0),
);
default:
return const FlowySvg(FlowySvgs.file_unknown_s);
}
}
}
typedef ChatInputFileMetadata = Map<String, ChatFile>;
@freezed
class ChatLoadingState with _$ChatLoadingState {
const factory ChatLoadingState.loading() = _Loading;
const factory ChatLoadingState.finish({FlowyError? error}) = _Finish;
}
extension ChatLoadingStateExtension on ChatLoadingState {
bool get isLoading => this is _Loading;
bool get isFinish => this is _Finish;
}
enum OnetimeShotType {
unknown,
sendingMessage,
relatedQuestion,
invalidSendMesssage,
}
const onetimeShotType = "OnetimeShotType";
extension OnetimeMessageTypeExtension on OnetimeShotType {
static OnetimeShotType fromString(String value) {
switch (value) {
case 'OnetimeShotType.sendingMessage':
return OnetimeShotType.sendingMessage;
case 'OnetimeShotType.relatedQuestion':
return OnetimeShotType.relatedQuestion;
case 'OnetimeShotType.invalidSendMesssage':
return OnetimeShotType.invalidSendMesssage;
default:
Log.error('Unknown OnetimeShotType: $value');
return OnetimeShotType.unknown;
}
}
Map<String, dynamic> toMap() {
return {
onetimeShotType: toString(),
};
}
}
OnetimeShotType? onetimeMessageTypeFromMeta(Map<String, dynamic>? metadata) {
if (metadata == null) {
return null;
}
for (final entry in metadata.entries) {
if (entry.key == onetimeShotType) {
return OnetimeMessageTypeExtension.fromString(entry.value as String);
}
}
return null;
}
@@ -1,154 +0,0 @@
import 'dart:async';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'chat_input_bloc.dart';
part 'chat_file_bloc.freezed.dart';
class ChatFileBloc extends Bloc<ChatFileEvent, ChatFileState> {
ChatFileBloc()
: 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 AIEventGetLocalAIChatState().send();
result.fold(
(chatState) {
if (!isClosed) {
add(
ChatFileEvent.updateChatState(chatState),
);
}
},
(err) {
Log.error(err.toString());
},
);
},
newFile: (String filePath, String fileName) async {
final files = List<ChatFile>.from(state.uploadFiles);
final newFile = ChatFile.fromFilePath(filePath);
if (newFile != null) {
files.add(newFile);
emit(
state.copyWith(
uploadFiles: files,
),
);
}
},
updateChatState: (LocalAIChatPB chatState) {
// Only user enable chat with file and the plugin is already running
final supportChatWithFile = chatState.fileEnabled &&
chatState.pluginState.state == RunningStatePB.Running;
emit(
state.copyWith(
supportChatWithFile: supportChatWithFile,
chatState: chatState,
),
);
},
updatePluginState: (LocalAIPluginStatePB chatState) {
final fileEnabled = state.chatState?.fileEnabled ?? false;
final supportChatWithFile =
fileEnabled && chatState.state == RunningStatePB.Running;
final aiType = chatState.state == RunningStatePB.Running
? const AIType.localAI()
: const AIType.appflowyAI();
emit(
state.copyWith(
supportChatWithFile: supportChatWithFile,
aiType: aiType,
),
);
},
deleteFile: (file) {
final files = List<ChatFile>.from(state.uploadFiles);
files.remove(file);
emit(
state.copyWith(
uploadFiles: files,
),
);
},
clear: () {
emit(
state.copyWith(
uploadFiles: [],
),
);
},
);
},
);
}
ChatInputFileMetadata consumeMetaData() {
final metadata = state.uploadFiles.fold(
<String, ChatFile>{},
(map, file) => map..putIfAbsent(file.filePath, () => file),
);
if (metadata.isNotEmpty) {
add(const ChatFileEvent.clear());
}
return metadata;
}
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.deleteFile(ChatFile file) = _DeleteFile;
const factory ChatFileEvent.clear() = _ClearFile;
const factory ChatFileEvent.updateChatState(LocalAIChatPB chatState) =
_UpdateChatState;
const factory ChatFileEvent.updatePluginState(
LocalAIPluginStatePB chatState,
) = _UpdatePluginState;
}
@freezed
class ChatFileState with _$ChatFileState {
const factory ChatFileState({
@Default(false) bool supportChatWithFile,
LocalAIChatPB? chatState,
@Default([]) List<ChatFile> uploadFiles,
@Default(AIType.appflowyAI()) AIType aiType,
}) = _ChatFileState;
}
@@ -1,217 +0,0 @@
import 'dart:async';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'chat_input_action_control.dart';
part 'chat_input_action_bloc.freezed.dart';
class ChatInputActionBloc
extends Bloc<ChatInputActionEvent, ChatInputActionState> {
ChatInputActionBloc({required this.chatId})
: super(const ChatInputActionState()) {
on<ChatInputActionEvent>(_handleEvent);
}
final String chatId;
Future<void> _handleEvent(
ChatInputActionEvent event,
Emitter<ChatInputActionState> emit,
) async {
await event.when(
started: () async {
unawaited(
ViewBackendService.getAllViews().then(
(result) {
final views = result
.toNullable()
?.items
.where(
(v) =>
v.layout.isDocumentView &&
!v.isSpace &&
v.parentViewId.isNotEmpty,
)
.toList() ??
[];
if (!isClosed) {
add(ChatInputActionEvent.refreshViews(views));
}
},
),
);
},
refreshViews: (List<ViewPB> views) {
final List<ViewActionPage> pages = _filterPages(
views,
state.selectedPages,
state.filter,
);
emit(
state.copyWith(
views: views,
pages: pages,
indicator: const ChatActionMenuIndicator.ready(),
),
);
},
filter: (String filter) {
Log.debug("Filter chat input pages: $filter");
final List<ViewActionPage> pages = _filterPages(
state.views,
state.selectedPages,
filter,
);
emit(state.copyWith(pages: pages, filter: filter));
},
handleKeyEvent: (PhysicalKeyboardKey physicalKey) {
emit(
state.copyWith(
keyboardKey: ChatInputKeyboardEvent(physicalKey: physicalKey),
),
);
},
addPage: (ChatInputMention page) {
if (!state.selectedPages.any((p) => p.pageId == page.pageId)) {
final List<ViewActionPage> pages = _filterPages(
state.views,
state.selectedPages,
state.filter,
);
emit(
state.copyWith(
pages: pages,
selectedPages: [...state.selectedPages, page],
),
);
}
},
removePage: (String text) {
final List<ChatInputMention> selectedPages =
List.from(state.selectedPages);
selectedPages.retainWhere((t) => !text.contains(t.title));
final List<ViewActionPage> allPages = _filterPages(
state.views,
state.selectedPages,
state.filter,
);
emit(
state.copyWith(
selectedPages: selectedPages,
pages: allPages,
),
);
},
clear: () {
emit(
state.copyWith(
selectedPages: [],
filter: "",
),
);
},
);
}
}
List<ViewActionPage> _filterPages(
List<ViewPB> views,
List<ChatInputMention> selectedPages,
String filter,
) {
final pages = views
.map(
(v) => ViewActionPage(view: v),
)
.toList();
pages.retainWhere((page) {
return !selectedPages.contains(page);
});
if (filter.isEmpty) {
return pages;
}
return pages
.where(
(v) => v.title.toLowerCase().contains(filter.toLowerCase()),
)
.toList();
}
class ViewActionPage extends ChatInputMention {
ViewActionPage({required this.view});
final ViewPB view;
@override
String get pageId => view.id;
@override
String get title => view.name;
@override
List<Object?> get props => [pageId];
@override
dynamic get page => view;
@override
Widget get icon => view.defaultIcon();
}
@freezed
class ChatInputActionEvent with _$ChatInputActionEvent {
const factory ChatInputActionEvent.started() = _Started;
const factory ChatInputActionEvent.refreshViews(List<ViewPB> views) =
_RefreshViews;
const factory ChatInputActionEvent.filter(String filter) = _Filter;
const factory ChatInputActionEvent.handleKeyEvent(
PhysicalKeyboardKey keyboardKey,
) = _HandleKeyEvent;
const factory ChatInputActionEvent.addPage(ChatInputMention page) = _AddPage;
const factory ChatInputActionEvent.removePage(String text) = _RemovePage;
const factory ChatInputActionEvent.clear() = _Clear;
}
@freezed
class ChatInputActionState with _$ChatInputActionState {
const factory ChatInputActionState({
@Default([]) List<ViewPB> views,
@Default([]) List<ChatInputMention> pages,
@Default([]) List<ChatInputMention> selectedPages,
@Default("") String filter,
ChatInputKeyboardEvent? keyboardKey,
@Default(ChatActionMenuIndicator.loading())
ChatActionMenuIndicator indicator,
}) = _ChatInputActionState;
}
class ChatInputKeyboardEvent extends Equatable {
ChatInputKeyboardEvent({required this.physicalKey});
final PhysicalKeyboardKey physicalKey;
final int timestamp = DateTime.now().millisecondsSinceEpoch;
@override
List<Object?> get props => [timestamp];
}
@freezed
class ChatActionMenuIndicator with _$ChatActionMenuIndicator {
const factory ChatActionMenuIndicator.ready() = _Ready;
const factory ChatActionMenuIndicator.loading() = _Loading;
}
@@ -1,172 +0,0 @@
import 'package:appflowy/plugins/ai_chat/application/chat_input_action_bloc.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_input_action_menu.dart';
import 'package:appflowy_backend/log.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
abstract class ChatInputMention extends Equatable {
String get title;
String get pageId;
dynamic get page;
Widget get icon;
}
/// Key: the key is the pageId
typedef ChatInputMentionMetadata = Map<String, ChatInputMention>;
class ChatInputActionControl extends ChatActionHandler {
ChatInputActionControl({
required this.textController,
required this.textFieldFocusNode,
required this.chatId,
}) : _commandBloc = ChatInputActionBloc(chatId: chatId);
final TextEditingController textController;
final ChatInputActionBloc _commandBloc;
final FocusNode textFieldFocusNode;
final String chatId;
// Private attributes
String _atText = "";
String _prevText = "";
String _showMenuText = "";
// Getter
List<String> get tags =>
_commandBloc.state.selectedPages.map((e) => e.title).toList();
ChatInputMentionMetadata consumeMetaData() {
final metadata = _commandBloc.state.selectedPages.fold(
<String, ChatInputMention>{},
(map, page) => map..putIfAbsent(page.pageId, () => page),
);
if (metadata.isNotEmpty) {
_commandBloc.add(const ChatInputActionEvent.clear());
}
return metadata;
}
void handleKeyEvent(KeyEvent event) {
// ignore: deprecated_member_use
if (event is KeyDownEvent || event is RawKeyDownEvent) {
_commandBloc.add(ChatInputActionEvent.handleKeyEvent(event.physicalKey));
}
}
bool canHandleKeyEvent(KeyEvent event) {
return _showMenuText.isNotEmpty &&
<PhysicalKeyboardKey>{
PhysicalKeyboardKey.arrowDown,
PhysicalKeyboardKey.arrowUp,
PhysicalKeyboardKey.enter,
PhysicalKeyboardKey.escape,
}.contains(event.physicalKey);
}
void dispose() {
_commandBloc.close();
}
@override
void onSelected(ChatInputMention page) {
_commandBloc.add(ChatInputActionEvent.addPage(page));
textController.text = "$_showMenuText${page.title}";
onExit();
}
@override
void onExit() {
_atText = "";
_showMenuText = "";
_prevText = "";
_commandBloc.add(const ChatInputActionEvent.filter(""));
}
@override
void onEnter() {
_commandBloc.add(const ChatInputActionEvent.started());
_showMenuText = textController.text;
}
@override
double actionMenuOffsetX() {
final TextPosition textPosition = textController.selection.extent;
if (textFieldFocusNode.context == null) {
return 0;
}
final RenderBox renderBox =
textFieldFocusNode.context?.findRenderObject() as RenderBox;
final TextPainter textPainter = TextPainter(
text: TextSpan(text: textController.text),
textDirection: TextDirection.ltr,
);
textPainter.layout(
minWidth: renderBox.size.width,
maxWidth: renderBox.size.width,
);
final Offset caretOffset =
textPainter.getOffsetForCaret(textPosition, Rect.zero);
final List<TextBox> boxes = textPainter.getBoxesForSelection(
TextSelection(
baseOffset: textPosition.offset,
extentOffset: textPosition.offset,
),
);
if (boxes.isNotEmpty) {
return boxes.last.right;
}
return caretOffset.dx;
}
bool onTextChanged(String text) {
final String inputText = text;
if (_prevText.length > inputText.length) {
final deleteStartIndex = textController.selection.baseOffset;
final deleteEndIndex =
_prevText.length - inputText.length + deleteStartIndex;
final deletedText = _prevText.substring(deleteStartIndex, deleteEndIndex);
_commandBloc.add(ChatInputActionEvent.removePage(deletedText));
}
// If the action menu is shown, filter the views
if (_showMenuText.isNotEmpty) {
if (text.length >= _showMenuText.length) {
final filterText = inputText.substring(_showMenuText.length);
_commandBloc.add(ChatInputActionEvent.filter(filterText));
}
// If the text change from "xxx @"" to "xxx", which means user delete the @, we should hide the action menu
if (_atText.isNotEmpty && !inputText.contains(_atText)) {
_commandBloc.add(
const ChatInputActionEvent.handleKeyEvent(PhysicalKeyboardKey.escape),
);
}
} else {
final isTypingNewAt =
text.endsWith("@") && _prevText.length < text.length;
if (isTypingNewAt) {
_atText = text;
_prevText = text;
return true;
}
}
_prevText = text;
return false;
}
@override
void onFilter(String filter) {
Log.info("filter: $filter");
}
@override
ChatInputActionBloc get commandBloc => _commandBloc;
}
@@ -1,87 +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-ai/entities.pb.dart';
import 'package:bloc/bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_input_bloc.freezed.dart';
class ChatInputStateBloc
extends Bloc<ChatInputStateEvent, ChatInputStateState> {
ChatInputStateBloc()
: listener = LocalLLMListener(),
super(const ChatInputStateState(aiType: _AppFlowyAI())) {
listener.start(
stateCallback: (pluginState) {
if (!isClosed) {
add(ChatInputStateEvent.updatePluginState(pluginState));
}
},
);
on<ChatInputStateEvent>(_handleEvent);
}
final LocalLLMListener listener;
@override
Future<void> close() async {
await listener.stop();
return super.close();
}
Future<void> _handleEvent(
ChatInputStateEvent event,
Emitter<ChatInputStateState> emit,
) async {
await event.when(
started: () async {
final result = await AIEventGetLocalAIPluginState().send();
result.fold(
(pluginState) {
if (!isClosed) {
add(
ChatInputStateEvent.updatePluginState(pluginState),
);
}
},
(err) {
Log.error(err.toString());
},
);
},
updatePluginState: (pluginState) {
if (pluginState.state == RunningStatePB.Running) {
emit(const ChatInputStateState(aiType: _LocalAI()));
} else {
emit(const ChatInputStateState(aiType: _AppFlowyAI()));
}
},
);
}
}
@freezed
class ChatInputStateEvent with _$ChatInputStateEvent {
const factory ChatInputStateEvent.started() = _Started;
const factory ChatInputStateEvent.updatePluginState(
LocalAIPluginStatePB pluginState,
) = _UpdatePluginState;
}
@freezed
class ChatInputStateState with _$ChatInputStateState {
const factory ChatInputStateState({required AIType aiType}) = _ChatInputState;
}
@freezed
class AIType with _$AIType {
const factory AIType.appflowyAI() = _AppFlowyAI;
const factory AIType.localAI() = _LocalAI;
}
extension AITypeX on AIType {
bool isLocalAI() => this is _LocalAI;
}
@@ -1,49 +0,0 @@
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_input_file_bloc.freezed.dart';
class ChatInputFileBloc extends Bloc<ChatInputFileEvent, ChatInputFileState> {
ChatInputFileBloc({
// ignore: avoid_unused_constructor_parameters
required String chatId,
required this.file,
}) : super(const ChatInputFileState()) {
on<ChatInputFileEvent>(
(event, emit) async {
await event.when(
initial: () async {},
updateUploadState: (UploadFileIndicator indicator) {
emit(state.copyWith(uploadFileIndicator: indicator));
},
);
},
);
}
final ChatFile file;
}
@freezed
class ChatInputFileEvent with _$ChatInputFileEvent {
const factory ChatInputFileEvent.initial() = Initial;
const factory ChatInputFileEvent.updateUploadState(
UploadFileIndicator indicator,
) = _UpdateUploadState;
}
@freezed
class ChatInputFileState with _$ChatInputFileState {
const factory ChatInputFileState({
UploadFileIndicator? uploadFileIndicator,
}) = _ChatInputFileState;
}
@freezed
class UploadFileIndicator with _$UploadFileIndicator {
const factory UploadFileIndicator.finish() = _Finish;
const factory UploadFileIndicator.uploading() = _Uploading;
const factory UploadFileIndicator.error(String error) = _Error;
}
@@ -1,80 +0,0 @@
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:equatable/equatable.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_member_bloc.freezed.dart';
class ChatMemberBloc extends Bloc<ChatMemberEvent, ChatMemberState> {
ChatMemberBloc() : super(const ChatMemberState()) {
on<ChatMemberEvent>(
(event, emit) async {
event.when(
initial: () {},
receiveMemberInfo: (String id, WorkspaceMemberPB memberInfo) {
final members = Map<String, ChatMember>.from(state.members);
members[id] = ChatMember(info: memberInfo);
emit(state.copyWith(members: members));
},
getMemberInfo: (String userId) {
if (state.members.containsKey(userId)) {
// Member info already exists. Debouncing refresh member info from backend would be better.
return;
}
final payload = WorkspaceMemberIdPB(
uid: Int64.parseInt(userId),
);
UserEventGetMemberInfo(payload).send().then((result) {
if (!isClosed) {
result.fold((member) {
add(
ChatMemberEvent.receiveMemberInfo(
userId,
member,
),
);
}, (err) {
Log.error("Error getting member info: $err");
});
}
});
},
);
},
);
}
}
@freezed
class ChatMemberEvent with _$ChatMemberEvent {
const factory ChatMemberEvent.initial() = Initial;
const factory ChatMemberEvent.getMemberInfo(
String userId,
) = _GetMemberInfo;
const factory ChatMemberEvent.receiveMemberInfo(
String id,
WorkspaceMemberPB memberInfo,
) = _ReceiveMemberInfo;
}
@freezed
class ChatMemberState with _$ChatMemberState {
const factory ChatMemberState({
@Default({}) Map<String, ChatMember> members,
}) = _ChatMemberState;
}
class ChatMember extends Equatable {
ChatMember({
required this.info,
});
final DateTime _date = DateTime.now();
final WorkspaceMemberPB info;
@override
List<Object?> get props => [_date, info];
}
@@ -1,8 +1,8 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/notification.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-chat/notification.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart';
import 'package:appflowy_backend/rust_stream.dart';
@@ -1,160 +0,0 @@
import 'dart:convert';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_action_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:nanoid/nanoid.dart';
/// Indicate file source from appflowy document
const appflowySoruce = "appflowy";
List<ChatFile> fileListFromMessageMetadata(
Map<String, dynamic>? map,
) {
final List<ChatFile> metadata = [];
if (map != null) {
for (final entry in map.entries) {
if (entry.value is ChatFile) {
metadata.add(entry.value);
}
}
}
return metadata;
}
List<ChatFile> chatFilesFromMetadataString(String? s) {
if (s == null || s.isEmpty || s == "null") {
return [];
}
final metadataJson = jsonDecode(s);
if (metadataJson is Map<String, dynamic>) {
final file = chatFileFromMap(metadataJson);
if (file != null) {
return [file];
} else {
return [];
}
} else if (metadataJson is List) {
return metadataJson
.map((e) => e as Map<String, dynamic>)
.map(chatFileFromMap)
.where((file) => file != null)
.cast<ChatFile>()
.toList();
} else {
Log.error("Invalid metadata: $metadataJson");
return [];
}
}
ChatFile? chatFileFromMap(Map<String, dynamic>? map) {
if (map == null) return null;
final filePath = map['source'] as String?;
final fileName = map['name'] as String?;
if (filePath == null || fileName == null) {
return null;
}
return ChatFile.fromFilePath(filePath);
}
List<ChatMessageRefSource> messageReferenceSource(String? s) {
if (s == null || s.isEmpty || s == "null") {
return [];
}
final List<ChatMessageRefSource> metadata = [];
try {
final metadataJson = jsonDecode(s);
if (metadataJson == null) {
Log.warn("metadata is null");
return [];
}
// [{"id":null,"name":"The Five Dysfunctions of a Team.pdf","source":"/Users/weidongfu/Desktop/The Five Dysfunctions of a Team.pdf"}]
if (metadataJson is Map<String, dynamic>) {
if (metadataJson.isNotEmpty) {
metadata.add(ChatMessageRefSource.fromJson(metadataJson));
}
} else if (metadataJson is List) {
metadata.addAll(
metadataJson.map(
(e) => ChatMessageRefSource.fromJson(e as Map<String, dynamic>),
),
);
} else {
Log.error("Invalid metadata: $metadataJson");
}
} catch (e) {
Log.error("Failed to parse metadata: $e");
}
return metadata;
}
Future<List<ChatMessageMetaPB>> metadataPBFromMetadata(
Map<String, dynamic>? map,
) async {
final List<ChatMessageMetaPB> metadata = [];
if (map != null) {
for (final entry in map.entries) {
if (entry.value is ViewActionPage) {
if (entry.value.page is ViewPB) {
final view = entry.value.page as ViewPB;
if (view.layout.isDocumentView) {
final payload = OpenDocumentPayloadPB(documentId: view.id);
final result = await DocumentEventGetDocumentText(payload).send();
result.fold((pb) {
metadata.add(
ChatMessageMetaPB(
id: view.id,
name: view.name,
data: pb.text,
dataType: ChatMessageMetaTypePB.Txt,
source: appflowySoruce,
),
);
}, (err) {
Log.error('Failed to get document text: $err');
});
}
}
} else if (entry.value is ChatFile) {
metadata.add(
ChatMessageMetaPB(
id: nanoid(8),
name: entry.value.fileName,
data: entry.value.filePath,
dataType: entry.value.fileType,
source: entry.value.filePath,
),
);
}
}
}
return metadata;
}
List<ChatFile> chatFilesFromMessageMetadata(
Map<String, dynamic>? map,
) {
final List<ChatFile> metadata = [];
if (map != null) {
for (final entry in map.entries) {
if (entry.value is ChatFile) {
metadata.add(entry.value);
}
}
}
return metadata;
}
@@ -1,193 +0,0 @@
import 'dart:async';
import 'dart:ffi';
import 'dart:isolate';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_message_service.dart';
class AnswerStream {
AnswerStream() {
_port.handler = _controller.add;
_subscription = _controller.stream.listen(
(event) {
if (event.startsWith("data:")) {
_hasStarted = true;
final newText = event.substring(5);
_text += newText;
if (_onData != null) {
_onData!(_text);
}
} else if (event.startsWith("error:")) {
_error = event.substring(5);
if (_onError != null) {
_onError!(_error!);
}
} else if (event.startsWith("metadata:")) {
if (_onMetadata != null) {
final s = event.substring(9);
_onMetadata!(messageReferenceSource(s));
}
} else if (event == "AI_RESPONSE_LIMIT") {
if (_onAIResponseLimit != null) {
_onAIResponseLimit!();
}
}
},
onDone: () {
if (_onEnd != null) {
_onEnd!();
}
},
onError: (error) {
if (_onError != null) {
_onError!(error.toString());
}
},
);
}
final RawReceivePort _port = RawReceivePort();
final StreamController<String> _controller = StreamController.broadcast();
late StreamSubscription<String> _subscription;
bool _hasStarted = false;
String? _error;
String _text = "";
// Callbacks
void Function(String text)? _onData;
void Function()? _onStart;
void Function()? _onEnd;
void Function(String error)? _onError;
void Function()? _onAIResponseLimit;
void Function(List<ChatMessageRefSource> metadata)? _onMetadata;
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();
await _subscription.cancel();
_port.close();
}
void listen({
void Function(String text)? onData,
void Function()? onStart,
void Function()? onEnd,
void Function(String error)? onError,
void Function()? onAIResponseLimit,
void Function(List<ChatMessageRefSource> metadata)? onMetadata,
}) {
_onData = onData;
_onStart = onStart;
_onEnd = onEnd;
_onError = onError;
_onAIResponseLimit = onAIResponseLimit;
_onMetadata = onMetadata;
if (_onStart != null) {
_onStart!();
}
}
}
class QuestionStream {
QuestionStream() {
_port.handler = _controller.add;
_subscription = _controller.stream.listen(
(event) {
if (event.startsWith("data:")) {
_hasStarted = true;
final newText = event.substring(5);
_text += newText;
if (_onData != null) {
_onData!(_text);
}
} else if (event.startsWith("message_id:")) {
final messageId = event.substring(11);
_onMessageId?.call(messageId);
} else if (event.startsWith("start_index_file:")) {
final indexName = event.substring(17);
_onFileIndexStart?.call(indexName);
} else if (event.startsWith("end_index_file:")) {
final indexName = event.substring(10);
_onFileIndexEnd?.call(indexName);
} else if (event.startsWith("index_file_error:")) {
final indexName = event.substring(16);
_onFileIndexError?.call(indexName);
} else if (event.startsWith("index_start:")) {
_onIndexStart?.call();
} else if (event.startsWith("index_end:")) {
_onIndexEnd?.call();
} else if (event.startsWith("done:")) {
_onDone?.call();
} else if (event.startsWith("error:")) {
_error = event.substring(5);
if (_onError != null) {
_onError!(_error!);
}
}
},
onError: (error) {
if (_onError != null) {
_onError!(error.toString());
}
},
);
}
final RawReceivePort _port = RawReceivePort();
final StreamController<String> _controller = StreamController.broadcast();
late StreamSubscription<String> _subscription;
bool _hasStarted = false;
String? _error;
String _text = "";
// Callbacks
void Function(String text)? _onData;
void Function(String error)? _onError;
void Function(String messageId)? _onMessageId;
void Function(String indexName)? _onFileIndexStart;
void Function(String indexName)? _onFileIndexEnd;
void Function(String indexName)? _onFileIndexError;
void Function()? _onIndexStart;
void Function()? _onIndexEnd;
void Function()? _onDone;
int get nativePort => _port.sendPort.nativePort;
bool get hasStarted => _hasStarted;
String? get error => _error;
String get text => _text;
Future<void> dispose() async {
await _controller.close();
await _subscription.cancel();
_port.close();
}
void listen({
void Function(String text)? onData,
void Function(String error)? onError,
void Function(String messageId)? onMessageId,
void Function(String indexName)? onFileIndexStart,
void Function(String indexName)? onFileIndexEnd,
void Function(String indexName)? onFileIndexFail,
void Function()? onIndexStart,
void Function()? onIndexEnd,
void Function()? onDone,
}) {
_onData = onData;
_onError = onError;
_onMessageId = onMessageId;
_onFileIndexStart = onFileIndexStart;
_onFileIndexEnd = onFileIndexEnd;
_onFileIndexError = onFileIndexFail;
_onIndexStart = onIndexStart;
_onIndexEnd = onIndexEnd;
_onDone = onDone;
}
}
@@ -2,7 +2,7 @@ import 'dart:async';
import 'dart:typed_data';
import 'package:appflowy/core/notification/notification_helper.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/notification.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-chat/notification.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart';
import 'package:appflowy_backend/rust_stream.dart';
Loaded 100 of 2236 files, more files were not shown because too many files have changed in this diff. Show more