Compare commits
81
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca195887e0 | ||
|
|
f9a5458b94 | ||
|
|
244d072a65 | ||
|
|
d9bc97e012 | ||
|
|
e7491e5182 | ||
|
|
550b8835c6 | ||
|
|
1851721d9a | ||
|
|
603d65a790 | ||
|
|
0cba3f9e3f | ||
|
|
81960a7f05 | ||
|
|
62c4a8c541 | ||
|
|
510d8357ee | ||
|
|
4c6f6f14f3 | ||
|
|
27af57289f | ||
|
|
17aa8c9036 | ||
|
|
c910bda534 | ||
|
|
8d01b96281 | ||
|
|
55f12d5358 | ||
|
|
018c146d72 | ||
|
|
068ac0e992 | ||
|
|
b036129efb | ||
|
|
b3c8eb151a | ||
|
|
e86d584ea7 | ||
|
|
e0226e54a5 | ||
|
|
bde1457524 | ||
|
|
2ad2a79bd0 | ||
|
|
f013bb9d6e | ||
|
|
1b4a723500 | ||
|
|
b5d2af3371 | ||
|
|
4205a34f04 | ||
|
|
e86a9d697c | ||
|
|
f82dabcc75 | ||
|
|
09717d92c5 | ||
|
|
a0d8711d5c | ||
|
|
c24b68481d | ||
|
|
df7fe9750d | ||
|
|
9c22bb4fed | ||
|
|
3803cf2506 | ||
|
|
51a11fbebd | ||
|
|
cc7476c75b | ||
|
|
8a7cedd5b4 | ||
|
|
7e528cf260 | ||
|
|
8a39ff0580 | ||
|
|
710fbbdc08 | ||
|
|
6c52896f99 | ||
|
|
f526df98c7 | ||
|
|
521d74e082 | ||
|
|
225683562b | ||
|
|
9138787f86 | ||
|
|
6ffb9e4d0f | ||
|
|
bced9327b1 | ||
|
|
8120656198 | ||
|
|
941b7cf04c | ||
|
|
1952ef0853 | ||
|
|
d9f2d14e99 | ||
|
|
555d08e8ce | ||
|
|
76009613fe | ||
|
|
651938a322 | ||
|
|
b86011aa94 | ||
|
|
cf240a392b | ||
|
|
28530722bc | ||
|
|
3b304747f2 | ||
|
|
cd3be696dc | ||
|
|
57933736ea | ||
|
|
a46550c250 | ||
|
|
eed3f489c5 | ||
|
|
d268f8c715 | ||
|
|
3cd26cca35 | ||
|
|
6ad303583b | ||
|
|
e527a1843e | ||
|
|
3f8eb70ff3 | ||
|
|
bd7976d005 | ||
|
|
4e1532af3e | ||
|
|
97999aee44 | ||
|
|
6785104c3a | ||
|
|
193c8242e2 | ||
|
|
59eda5e038 | ||
|
|
3c8de8a52c | ||
|
|
d38a5f38e3 | ||
|
|
fe4f85a597 | ||
|
|
0a1af4e61f |
No files matched your search
@@ -1,126 +1,197 @@
|
||||
# name: Android CI
|
||||
name: Android CI
|
||||
|
||||
# on:
|
||||
# push:
|
||||
# branches:
|
||||
# - "main"
|
||||
# paths:
|
||||
# - ".github/workflows/mobile_ci.yaml"
|
||||
# - "frontend/**"
|
||||
# - "!frontend/appflowy_tauri/**"
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
paths:
|
||||
- ".github/workflows/mobile_ci.yaml"
|
||||
- "frontend/**"
|
||||
- "!frontend/appflowy_tauri/**"
|
||||
|
||||
# pull_request:
|
||||
# branches:
|
||||
# - "main"
|
||||
# paths:
|
||||
# - ".github/workflows/mobile_ci.yaml"
|
||||
# - "frontend/**"
|
||||
# - "!frontend/appflowy_tauri/**"
|
||||
pull_request:
|
||||
branches:
|
||||
- "main"
|
||||
paths:
|
||||
- ".github/workflows/mobile_ci.yaml"
|
||||
- "frontend/**"
|
||||
- "!frontend/appflowy_tauri/**"
|
||||
|
||||
# env:
|
||||
# CARGO_TERM_COLOR: always
|
||||
# FLUTTER_VERSION: "3.22.0"
|
||||
# RUST_TOOLCHAIN: "1.77.2"
|
||||
# CARGO_MAKE_VERSION: "0.36.6"
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
FLUTTER_VERSION: "3.22.3"
|
||||
RUST_TOOLCHAIN: "1.80.1"
|
||||
CARGO_MAKE_VERSION: "0.37.18"
|
||||
CLOUD_VERSION: 0.6.54-amd64
|
||||
|
||||
# concurrency:
|
||||
# group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
# cancel-in-progress: true
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# jobs:
|
||||
# build:
|
||||
# if: github.event.pull_request.draft != true
|
||||
# strategy:
|
||||
# fail-fast: true
|
||||
# matrix:
|
||||
# os: [macos-14]
|
||||
# runs-on: ${{ matrix.os }}
|
||||
jobs:
|
||||
build:
|
||||
if: github.event.pull_request.draft != true
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
# steps:
|
||||
# - name: Check storage space
|
||||
# run: df -h
|
||||
steps:
|
||||
- name: Check storage space
|
||||
run:
|
||||
df -h
|
||||
|
||||
# # the following step is required to avoid running out of space
|
||||
# - name: Maximize build space
|
||||
# if: matrix.os == 'ubuntu-latest'
|
||||
# run: |
|
||||
# sudo rm -rf /usr/share/dotnet
|
||||
# sudo rm -rf /opt/ghc
|
||||
# sudo rm -rf "/usr/local/share/boost"
|
||||
# sudo rm -rf "$AGENT_TOOLSDIRECTORY"
|
||||
# sudo docker image prune --all --force
|
||||
# sudo rm -rf /opt/hostedtoolcache/codeQL
|
||||
# sudo rm -rf ${GITHUB_WORKSPACE}/.git
|
||||
# sudo rm -rf $ANDROID_HOME/ndk
|
||||
# the following step is required to avoid running out of space
|
||||
- name: Maximize build space
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
sudo rm -rf /opt/ghc
|
||||
sudo rm -rf "/usr/local/share/boost"
|
||||
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
|
||||
sudo docker image prune --all --force
|
||||
sudo rm -rf /opt/hostedtoolcache/codeQL
|
||||
sudo rm -rf ${GITHUB_WORKSPACE}/.git
|
||||
|
||||
# - name: Check storage space
|
||||
# run: df -h
|
||||
- name: Check storage space
|
||||
run: df -h
|
||||
|
||||
# - name: Checkout source code
|
||||
# uses: actions/checkout@v4
|
||||
- name: Checkout appflowy cloud code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: AppFlowy-IO/AppFlowy-Cloud
|
||||
path: AppFlowy-Cloud
|
||||
|
||||
# - uses: actions/setup-java@v4
|
||||
# with:
|
||||
# distribution: temurin
|
||||
# java-version: 11
|
||||
- name: Prepare appflowy cloud env
|
||||
working-directory: AppFlowy-Cloud
|
||||
run: |
|
||||
# log level
|
||||
cp deploy.env .env
|
||||
sed -i 's|RUST_LOG=.*|RUST_LOG=trace|' .env
|
||||
sed -i 's/GOTRUE_EXTERNAL_GOOGLE_ENABLED=.*/GOTRUE_EXTERNAL_GOOGLE_ENABLED=true/' .env
|
||||
sed -i 's|GOTRUE_MAILER_AUTOCONFIRM=.*|GOTRUE_MAILER_AUTOCONFIRM=true|' .env
|
||||
sed -i 's|API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://localhost|' .env
|
||||
|
||||
# - name: Install Rust toolchain
|
||||
# id: rust_toolchain
|
||||
# uses: actions-rs/toolchain@v1
|
||||
# with:
|
||||
# toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||||
# override: true
|
||||
# profile: minimal
|
||||
- name: Run Docker-Compose
|
||||
working-directory: AppFlowy-Cloud
|
||||
env:
|
||||
APPFLOWY_CLOUD_VERSION: ${{ env.CLOUD_VERSION }}
|
||||
APPFLOWY_HISTORY_VERSION: ${{ env.CLOUD_VERSION }}
|
||||
APPFLOWY_WORKER_VERSION: ${{ env.CLOUD_VERSION }}
|
||||
run: |
|
||||
container_id=$(docker ps --filter name=appflowy-cloud-appflowy_cloud-1 -q)
|
||||
if [ -z "$container_id" ]; then
|
||||
echo "AppFlowy-Cloud container is not running. Pulling and starting the container..."
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
echo "Waiting for the container to be ready..."
|
||||
sleep 10
|
||||
else
|
||||
running_image=$(docker inspect --format='{{index .Config.Image}}' "$container_id")
|
||||
if [ "$running_image" != "appflowy-cloud:$APPFLOWY_CLOUD_VERSION" ]; then
|
||||
echo "AppFlowy-Cloud is running with an incorrect version. Restarting with the correct version..."
|
||||
# Remove all containers if any exist
|
||||
if [ "$(docker ps -aq)" ]; then
|
||||
docker rm -f $(docker ps -aq)
|
||||
else
|
||||
echo "No containers to remove."
|
||||
fi
|
||||
|
||||
# - name: Install flutter
|
||||
# id: flutter
|
||||
# uses: subosito/flutter-action@v2
|
||||
# with:
|
||||
# channel: "stable"
|
||||
# flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
# Remove all volumes if any exist
|
||||
if [ "$(docker volume ls -q)" ]; then
|
||||
docker volume rm $(docker volume ls -q)
|
||||
else
|
||||
echo "No volumes to remove."
|
||||
fi
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
echo "Waiting for the container to be ready..."
|
||||
sleep 10
|
||||
docker ps -a
|
||||
docker compose logs
|
||||
else
|
||||
echo "AppFlowy-Cloud is running with the correct version."
|
||||
fi
|
||||
fi
|
||||
|
||||
# - uses: gradle/gradle-build-action@v3
|
||||
# with:
|
||||
# gradle-version: 7.4.2
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# - uses: davidB/rust-cargo-make@v1
|
||||
# with:
|
||||
# version: "0.36.6"
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 11
|
||||
|
||||
# - name: Install prerequisites
|
||||
# working-directory: frontend
|
||||
# run: |
|
||||
# rustup target install aarch64-linux-android
|
||||
# rustup target install x86_64-linux-android
|
||||
# cargo install --force duckscript_cli
|
||||
# cargo install cargo-ndk
|
||||
# if [ "$RUNNER_OS" == "Linux" ]; then
|
||||
# sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub
|
||||
# sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list
|
||||
# sudo apt-get update
|
||||
# sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev
|
||||
# sudo apt-get install keybinder-3.0 libnotify-dev
|
||||
# sudo apt-get install gcc-multilib
|
||||
# elif [ "$RUNNER_OS" == "Windows" ]; then
|
||||
# vcpkg integrate install
|
||||
# elif [ "$RUNNER_OS" == "macOS" ]; then
|
||||
# echo 'do nothing'
|
||||
# fi
|
||||
# cargo make appflowy-flutter-deps-tools
|
||||
# shell: bash
|
||||
- name: Install Rust toolchain
|
||||
id: rust_toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||||
override: true
|
||||
profile: minimal
|
||||
|
||||
# - name: Build AppFlowy
|
||||
# working-directory: frontend
|
||||
# run: |
|
||||
# cargo make --profile development-android appflowy-android-dev-ci
|
||||
- name: Install flutter
|
||||
id: flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
|
||||
- uses: gradle/gradle-build-action@v3
|
||||
with:
|
||||
gradle-version: 8.10
|
||||
|
||||
# - name: Run integration tests
|
||||
# # https://github.com/ReactiveCircus/android-emulator-runner
|
||||
# uses: reactivecircus/android-emulator-runner@v2
|
||||
# with:
|
||||
# api-level: 32
|
||||
# arch: arm64-v8a
|
||||
# disk-size: 2048M
|
||||
# working-directory: frontend/appflowy_flutter
|
||||
# script: flutter test integration_test/runner.dart
|
||||
- uses: davidB/rust-cargo-make@v1
|
||||
with:
|
||||
version: ${{ env.CARGO_MAKE_VERSION }}
|
||||
|
||||
- name: Install prerequisites
|
||||
working-directory: frontend
|
||||
run: |
|
||||
rustup target install aarch64-linux-android
|
||||
rustup target install x86_64-linux-android
|
||||
rustup target add armv7-linux-androideabi
|
||||
cargo install --force duckscript_cli
|
||||
cargo install cargo-ndk
|
||||
if [ "$RUNNER_OS" == "Linux" ]; then
|
||||
sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub
|
||||
sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev
|
||||
sudo apt-get install keybinder-3.0 libnotify-dev
|
||||
sudo apt-get install gcc-multilib
|
||||
elif [ "$RUNNER_OS" == "Windows" ]; then
|
||||
vcpkg integrate install
|
||||
elif [ "$RUNNER_OS" == "macOS" ]; then
|
||||
echo 'do nothing'
|
||||
fi
|
||||
cargo make appflowy-flutter-deps-tools
|
||||
shell: bash
|
||||
|
||||
- name: Build AppFlowy
|
||||
working-directory: frontend
|
||||
run: |
|
||||
cargo make --profile development-android appflowy-core-dev-android
|
||||
cargo make --profile development-android code_generation
|
||||
cd rust-lib
|
||||
cargo clean
|
||||
|
||||
- name: Enable KVM group perms
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Run integration tests
|
||||
# https://github.com/ReactiveCircus/android-emulator-runner
|
||||
uses: reactivecircus/android-emulator-runner@v2
|
||||
with:
|
||||
api-level: 33
|
||||
arch: x86_64
|
||||
disk-size: 2048M
|
||||
working-directory: frontend/appflowy_flutter
|
||||
disable-animations: true
|
||||
force-avd-creation: false
|
||||
target: google_apis
|
||||
script: flutter test integration_test/mobile/cloud/cloud_runner.dart
|
||||
@@ -346,7 +346,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
test_number: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
test_number: [1, 2, 3, 4, 5, 6, 7, 8, 9]
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: "x86_64-unknown-linux-gnu"
|
||||
|
||||
@@ -20,9 +20,8 @@ on:
|
||||
- "!frontend/appflowy_web_app/**"
|
||||
|
||||
env:
|
||||
FLUTTER_VERSION: "3.22.0"
|
||||
FLUTTER_VERSION: "3.22.3"
|
||||
RUST_TOOLCHAIN: "1.80.1"
|
||||
CLOUD_VERSION: 0.6.51
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -97,21 +96,26 @@ jobs:
|
||||
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
|
||||
- uses: futureware-tech/simulator-action@v3
|
||||
id: simulator-action
|
||||
with:
|
||||
model: "iPhone 15"
|
||||
shutdown_after_job: false
|
||||
|
||||
# - name: Run AppFlowy on simulator
|
||||
# working-directory: frontend/appflowy_flutter
|
||||
# run: |
|
||||
# flutter run -d ${{ steps.simulator-action.outputs.udid }} &
|
||||
# pid=$!
|
||||
# sleep 500
|
||||
# kill $pid
|
||||
# continue-on-error: true
|
||||
- name: Run AppFlowy on simulator
|
||||
working-directory: frontend/appflowy_flutter
|
||||
run: |
|
||||
flutter run -d ${{ steps.simulator-action.outputs.udid }} &
|
||||
pid=$!
|
||||
sleep 500
|
||||
kill $pid
|
||||
continue-on-error: true
|
||||
|
||||
# - name: Run integration tests
|
||||
# working-directory: frontend/appflowy_flutter
|
||||
# run: flutter test integration_test/runner.dart -d ${{ steps.simulator-action.outputs.udid }}
|
||||
# Integration tests
|
||||
- name: Run integration tests
|
||||
working-directory: frontend/appflowy_flutter
|
||||
# The integration tests are flaky and sometimes fail with "Connection timed out":
|
||||
# Don't block the CI. If the tests fail, the CI will still pass.
|
||||
# Instead, we're using Code Magic to re-run the tests to check if they pass.
|
||||
continue-on-error: true
|
||||
run: flutter test integration_test/runner.dart -d ${{ steps.simulator-action.outputs.udid }}
|
||||
@@ -0,0 +1,83 @@
|
||||
name: Mobile-CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: "Branch to build"
|
||||
required: true
|
||||
default: "main"
|
||||
workflow_id:
|
||||
description: "Codemagic workflow ID"
|
||||
required: true
|
||||
default: "ios-workflow"
|
||||
type: choice
|
||||
options:
|
||||
- ios-workflow
|
||||
- android-workflow
|
||||
|
||||
env:
|
||||
CODEMAGIC_API_TOKEN: ${{ secrets.CODEMAGIC_API_TOKEN }}
|
||||
APP_ID: "6731d2f427e7c816080c3674"
|
||||
|
||||
jobs:
|
||||
trigger-mobile-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger Codemagic Build
|
||||
id: trigger_build
|
||||
run: |
|
||||
RESPONSE=$(curl -X POST \
|
||||
--header "Content-Type: application/json" \
|
||||
--header "x-auth-token: $CODEMAGIC_API_TOKEN" \
|
||||
--data '{
|
||||
"appId": "${{ env.APP_ID }}",
|
||||
"workflowId": "${{ github.event.inputs.workflow_id }}",
|
||||
"branch": "${{ github.event.inputs.branch }}"
|
||||
}' \
|
||||
https://api.codemagic.io/builds)
|
||||
|
||||
BUILD_ID=$(echo $RESPONSE | jq -r '.buildId')
|
||||
echo "build_id=$BUILD_ID" >> $GITHUB_OUTPUT
|
||||
echo "build_id=$BUILD_ID"
|
||||
|
||||
- name: Wait for build and check status
|
||||
id: check_status
|
||||
run: |
|
||||
while true; do
|
||||
curl -X GET \
|
||||
--header "Content-Type: application/json" \
|
||||
--header "x-auth-token: $CODEMAGIC_API_TOKEN" \
|
||||
https://api.codemagic.io/builds/${{ steps.trigger_build.outputs.build_id }} > /tmp/response.json
|
||||
|
||||
RESPONSE_WITHOUT_COMMAND=$(cat /tmp/response.json | jq 'walk(if type == "object" and has("subactions") then .subactions |= map(del(.command)) else . end)')
|
||||
STATUS=$(echo $RESPONSE_WITHOUT_COMMAND | jq -r '.build.status')
|
||||
|
||||
if [ "$STATUS" = "finished" ]; then
|
||||
SUCCESS=$(echo $RESPONSE_WITHOUT_COMMAND | jq -r '.success')
|
||||
BUILD_URL=$(echo $RESPONSE_WITHOUT_COMMAND | jq -r '.buildUrl')
|
||||
echo "status=$STATUS" >> $GITHUB_OUTPUT
|
||||
echo "success=$SUCCESS" >> $GITHUB_OUTPUT
|
||||
echo "build_url=$BUILD_URL" >> $GITHUB_OUTPUT
|
||||
break
|
||||
elif [ "$STATUS" = "failed" ]; then
|
||||
echo "status=failed" >> $GITHUB_OUTPUT
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 60
|
||||
done
|
||||
|
||||
- name: Slack Notification
|
||||
uses: 8398a7/action-slack@v3
|
||||
if: always()
|
||||
with:
|
||||
status: ${{ steps.check_status.outputs.success == 'true' && 'success' || 'failure' }}
|
||||
fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
|
||||
text: |
|
||||
Mobile CI Build Result
|
||||
Branch: ${{ github.event.inputs.branch }}
|
||||
Workflow: ${{ github.event.inputs.workflow_id }}
|
||||
Build URL: ${{ steps.check_status.outputs.build_url }}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.RELEASE_SLACK_WEBHOOK }}
|
||||
@@ -15,12 +15,10 @@ on:
|
||||
- "main"
|
||||
- "develop"
|
||||
- "release/*"
|
||||
paths:
|
||||
- "frontend/rust-lib/**"
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CLOUD_VERSION: 0.6.51-amd64
|
||||
CLOUD_VERSION: 0.7.6-amd64
|
||||
RUST_TOOLCHAIN: "1.77.2"
|
||||
|
||||
jobs:
|
||||
@@ -43,6 +41,7 @@ jobs:
|
||||
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
|
||||
sed -i '' 's|APPFLOWY_AI_OPENAI_API_KEY=.*|APPFLOWY_AI_OPENAI_API_KEY=${{ secrets.CI_OPENAI_API_KEY }}|' .env
|
||||
|
||||
- name: Ensure AppFlowy-Cloud is Running with Correct Version
|
||||
working-directory: AppFlowy-Cloud
|
||||
@@ -137,7 +136,7 @@ jobs:
|
||||
run: |
|
||||
cp deploy.env .env
|
||||
sed -i 's|RUST_LOG=.*|RUST_LOG=trace|' .env
|
||||
sed -i 's|GOTRUE_MAILER_AUTOCONFIRM=.*|GOTRUE_MAILER_AUTOCONFIRM=true|' .env
|
||||
sed -i 's|GOTRUE_MAILER_AUTOCONFIRM=.*|GOTRUE_MAILER_AUTOCONFIRM=true|' .env
|
||||
sed -i 's|API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://localhost|' .env
|
||||
|
||||
- name: Ensure AppFlowy-Cloud is Running with Correct Version
|
||||
@@ -160,7 +159,7 @@ jobs:
|
||||
else
|
||||
echo "No volumes to remove."
|
||||
fi
|
||||
|
||||
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
echo "Waiting for the container to be ready..."
|
||||
|
||||
@@ -1,4 +1,38 @@
|
||||
# Release Notes
|
||||
## Version 0.7.5 - 25/11/2024
|
||||
### Bug Fixes
|
||||
- Improved chat response parsing
|
||||
- Fixed toggle list icon direction for RTL mode
|
||||
- Fixed cross blocks formatting not reflecting in float toolbar
|
||||
- Fixed unable to click inside the toggle list to create a new paragraph
|
||||
- Fixed open file error 50 on macOS
|
||||
- Fixed upload file exceed limit error
|
||||
|
||||
## Version 0.7.4 - 19/11/2024
|
||||
### New Features
|
||||
- Support uploading WebP and BMP images
|
||||
- Support managing workspaces on mobile
|
||||
- Support adding toggle headings on mobile
|
||||
- Improve the AI chat page UI
|
||||
### Bug Fixes
|
||||
- Optimized the workspace menu loading performance
|
||||
- Optimized tab switching performance
|
||||
- Fixed searching issues in Document page
|
||||
|
||||
## Version 0.7.3 - 07/11/2024
|
||||
### New Features
|
||||
- Enable custom URLs for published pages
|
||||
- Support toggling headings
|
||||
- Create a subpage by typing in the document
|
||||
- Turn selected blocks into a subpage
|
||||
- Add a manual date picker for the Date property
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed an issue where the workspace owner was unable to delete spaces created by others
|
||||
- Fixed cursor height inconsistencies with text height
|
||||
- Fixed editing issues in Kanban cards
|
||||
- Fixed an issue preventing images or files from being dropped into empty paragraphs
|
||||
|
||||
## Version 0.7.2 - 22/10/2024
|
||||
### New Features
|
||||
- Copy link to block
|
||||
|
||||
@@ -42,10 +42,12 @@ AppFlowy is the AI workspace where you achieve more without losing control of yo
|
||||
## User Installation
|
||||
|
||||
- [Download AppFlowy Desktop (macOS, Windows, and Linux)](https://github.com/AppFlowy-IO/AppFlowy/releases)
|
||||
- Other channels: [FlatHub](https://flathub.org/apps/io.appflowy.AppFlowy), [Snapcraft](https://snapcraft.io/appflowy), [Sourceforge](https://sourceforge.net/projects/appflowy/)
|
||||
- Other
|
||||
channels: [FlatHub](https://flathub.org/apps/io.appflowy.AppFlowy), [Snapcraft](https://snapcraft.io/appflowy), [Sourceforge](https://sourceforge.net/projects/appflowy/)
|
||||
- Available on
|
||||
- [App Store](https://apps.apple.com/app/appflowy/id6457261352): iPhone
|
||||
- [Play Store](https://play.google.com/store/apps/details?id=io.appflowy.appflowy): Android 10 or above; ARMv7 is not supported
|
||||
- [App Store](https://apps.apple.com/app/appflowy/id6457261352): iPhone
|
||||
- [Play Store](https://play.google.com/store/apps/details?id=io.appflowy.appflowy): Android 10 or above; ARMv7 is
|
||||
not supported
|
||||
- [Self-hosting AppFlowy](https://docs.appflowy.io/docs/guides/appflowy/self-hosting-appflowy)
|
||||
- [Source](https://docs.appflowy.io/docs/documentation/appflowy/from-source)
|
||||
|
||||
@@ -61,15 +63,18 @@ AppFlowy is the AI workspace where you achieve more without losing control of yo
|
||||
|
||||
## Getting Started with development
|
||||
|
||||
Please view the [documentation](https://docs.appflowy.io/docs/documentation/appflowy/from-source) for OS specific development instructions
|
||||
Please view the [documentation](https://docs.appflowy.io/docs/documentation/appflowy/from-source) for OS specific
|
||||
development instructions
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [AppFlowy Roadmap ReadMe](https://docs.appflowy.io/docs/appflowy/roadmap)
|
||||
- [AppFlowy Public Roadmap](https://github.com/orgs/AppFlowy-IO/projects/5/views/12)
|
||||
|
||||
If you'd like to propose a feature, submit a feature request [here](https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&template=feature_request.yaml&title=%5BFR%5D+) <br/>
|
||||
If you'd like to report a bug, submit a bug report [here](https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&template=bug_report.yaml&title=%5BBug%5D+)
|
||||
If you'd like to propose a feature, submit a feature
|
||||
request [here](https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&template=feature_request.yaml&title=%5BFR%5D+) <br/>
|
||||
If you'd like to report a bug, submit a bug
|
||||
report [here](https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&template=bug_report.yaml&title=%5BBug%5D+)
|
||||
|
||||
## **Releases**
|
||||
|
||||
@@ -77,16 +82,24 @@ Please see the [changelog](https://www.appflowy.io/whatsnew) for more details ab
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions make the open-source community a fantastic place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. Please look at [Contributing to AppFlowy](https://docs.appflowy.io/docs/documentation/software-contributions/contributing-to-appflowy) for details.
|
||||
Contributions make the open-source community a fantastic place to learn, inspire, and create. Any contributions you make
|
||||
are **greatly appreciated**. Please look
|
||||
at [Contributing to AppFlowy](https://docs.appflowy.io/docs/documentation/software-contributions/contributing-to-appflowy)
|
||||
for details.
|
||||
|
||||
If your Pull Request is accepted as it fixes a bug, adds functionality, or makes AppFlowy's codebase significantly easier to use or understand, **Congratulations!** If your administrative and managerial work behind the scenes sustains the community, **Congratulations!** You are now an official contributor to AppFlowy. Get in touch with us ([link](https://tally.so/r/mKP5z3)) to receive the very special Contributor T-shirt!
|
||||
If your Pull Request is accepted as it fixes a bug, adds functionality, or makes AppFlowy's codebase significantly
|
||||
easier to use or understand, **Congratulations!** If your administrative and managerial work behind the scenes sustains
|
||||
the community, **Congratulations!** You are now an official contributor to AppFlowy. Get in touch with
|
||||
us ([link](https://tally.so/r/mKP5z3)) to receive the very special Contributor T-shirt!
|
||||
Proudly wear your T-shirt and show it to us by tagging [@appflowy](https://twitter.com/appflowy) on Twitter.
|
||||
|
||||
## Translations 🌎🗺
|
||||
|
||||
[](https://inlang.com/editor/github.com/AppFlowy-IO/AppFlowy?ref=badge)
|
||||
|
||||
To add translations, you can manually edit the JSON translation files in `/frontend/resources/translations`, use the [inlang online editor](https://inlang.com/editor/github.com/AppFlowy-IO/AppFlowy), or run `npx inlang machine translate` to add missing translations.
|
||||
To add translations, you can manually edit the JSON translation files in `/frontend/resources/translations`, use
|
||||
the [inlang online editor](https://inlang.com/editor/github.com/AppFlowy-IO/AppFlowy), or
|
||||
run `npx inlang machine translate` to add missing translations.
|
||||
|
||||
## Join the community to build AppFlowy together
|
||||
|
||||
@@ -96,16 +109,30 @@ To add translations, you can manually edit the JSON translation files in `/front
|
||||
|
||||
## Why Are We Building This?
|
||||
|
||||
Notion has been our favourite project and knowledge management tool in recent years because of its aesthetic appeal and functionality. Our team uses it daily, and we are on its paid plan. However, as we all know, Notion has its limitations. These include weak data security and poor compatibility with mobile devices. Likewise, alternative collaborative workplace management tools also have their constraints.
|
||||
Notion has been our favourite project and knowledge management tool in recent years because of its aesthetic appeal and
|
||||
functionality. Our team uses it daily, and we are on its paid plan. However, as we all know, Notion has its limitations.
|
||||
These include weak data security and poor compatibility with mobile devices. Likewise, alternative collaborative
|
||||
workplace management tools also have their constraints.
|
||||
|
||||
The limitations we encountered using these tools and our past work experience with collaborative productivity tools have led to our firm belief that there is a glass ceiling on what's possible for these tools in the future. This emanates from the fact that these tools will probably struggle to scale horizontally at some point and be forced to prioritize a proportion of customers whose needs differ from the rest. While decision-makers want a workplace OS, it is impossible to come up with a one-size fits all solution in such a fragmented market.
|
||||
The limitations we encountered using these tools and our past work experience with collaborative productivity tools have
|
||||
led to our firm belief that there is a glass ceiling on what's possible for these tools in the future. This emanates
|
||||
from the fact that these tools will probably struggle to scale horizontally at some point and be forced to prioritize a
|
||||
proportion of customers whose needs differ from the rest. While decision-makers want a workplace OS, it is impossible to
|
||||
come up with a one-size fits all solution in such a fragmented market.
|
||||
|
||||
When a customer's evolving core needs are not satisfied, they either switch to another or build one from the ground up, in-house. Consequently, they either go under another ceiling or buy an expensive ticket to learn a hard lesson. This is a requirement for many resources and expertise, building a reliable and easy-to-use collaborative tool, not to mention the speed and native experience. The same may apply to individual users as well.
|
||||
When a customer's evolving core needs are not satisfied, they either switch to another or build one from the ground up,
|
||||
in-house. Consequently, they either go under another ceiling or buy an expensive ticket to learn a hard lesson. This is
|
||||
a requirement for many resources and expertise, building a reliable and easy-to-use collaborative tool, not to mention
|
||||
the speed and native experience. The same may apply to individual users as well.
|
||||
|
||||
All these restrictions necessitate our mission - to make it possible for anyone to create apps that suit their needs well.
|
||||
All these restrictions necessitate our mission - to make it possible for anyone to create apps that suit their needs
|
||||
well.
|
||||
|
||||
- To individuals, we would like to offer Notion's functionality, data security, and cross-platform native experience.
|
||||
- To enterprises and hackers, AppFlowy is dedicated to offering building blocks and collaboration infra services to enable you to make apps on your own. Moreover, you have 100% control of your data. You can design and modify AppFlowy your way, with a single codebase written in Flutter and Rust supporting multiple platforms armed with long-term maintainability.
|
||||
- To enterprises and hackers, AppFlowy is dedicated to offering building blocks and collaboration infra services to
|
||||
enable you to make apps on your own. Moreover, you have 100% control of your data. You can design and modify AppFlowy
|
||||
your way, with a single codebase written in Flutter and Rust supporting multiple platforms armed with long-term
|
||||
maintainability.
|
||||
|
||||
We decided to achieve this mission by upholding the three most fundamental values:
|
||||
|
||||
@@ -113,16 +140,20 @@ We decided to achieve this mission by upholding the three most fundamental value
|
||||
- Reliable native experience
|
||||
- Community-driven extensibility
|
||||
|
||||
We do not claim to outperform Notion in terms of functionality and design, at least for now. Besides, our priority doesn't lie in more functionality at the moment. Instead, we would like to cultivate a community to democratize the knowledge and wheels of making complex workplace management tools while enabling people and businesses to create beautiful things on their own by equipping them with a versatile toolbox of building blocks.
|
||||
We do not claim to outperform Notion in terms of functionality and design, at least for now. Besides, our priority
|
||||
doesn't lie in more functionality at the moment. Instead, we would like to cultivate a community to democratize the
|
||||
knowledge and wheels of making complex workplace management tools while enabling people and businesses to create
|
||||
beautiful things on their own by equipping them with a versatile toolbox of building blocks.
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the AGPLv3 License. See [`LICENSE.md`](https://github.com/AppFlowy-IO/AppFlowy/blob/main/LICENSE) for more information.
|
||||
Distributed under the AGPLv3 License. See [`LICENSE.md`](https://github.com/AppFlowy-IO/AppFlowy/blob/main/LICENSE) for
|
||||
more information.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Special thanks to these amazing projects which help power AppFlowy.IO:
|
||||
|
||||
- [flutter-quill](https://github.com/singerdmx/flutter-quill)
|
||||
- [cargo-make](https://github.com/sagiegurari/cargo-make)
|
||||
- [contrib.rocks](https://contrib.rocks)
|
||||
- [flutter_chat_ui](https://pub.dev/packages/flutter_chat_ui)
|
||||
@@ -0,0 +1,47 @@
|
||||
workflows:
|
||||
ios-workflow:
|
||||
name: iOS Workflow
|
||||
instance_type: mac_mini_m2
|
||||
max_build_duration: 30
|
||||
environment:
|
||||
flutter: 3.22.3
|
||||
xcode: latest
|
||||
cocoapods: default
|
||||
|
||||
scripts:
|
||||
- name: Build Flutter
|
||||
script: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
source "$HOME/.cargo/env"
|
||||
rustc --version
|
||||
cargo --version
|
||||
|
||||
cd frontend
|
||||
|
||||
rustup target install aarch64-apple-ios-sim
|
||||
cargo install --force cargo-make
|
||||
cargo install --force duckscript_cli
|
||||
cargo install --force cargo-lipo
|
||||
|
||||
cargo make appflowy-flutter-deps-tools
|
||||
cargo make --profile development-ios-arm64-sim appflowy-core-dev-ios
|
||||
cargo make --profile development-ios-arm64-sim code_generation
|
||||
|
||||
- name: iOS integration tests
|
||||
script: |
|
||||
cd frontend/appflowy_flutter
|
||||
flutter emulators --launch apple_ios_simulator
|
||||
flutter -d iPhone test integration_test/runner.dart
|
||||
|
||||
artifacts:
|
||||
- build/ios/ipa/*.ipa
|
||||
- /tmp/xcodebuild_logs/*.log
|
||||
- flutter_drive.log
|
||||
|
||||
publishing:
|
||||
email:
|
||||
recipients:
|
||||
- lucas.xu@appflowy.io
|
||||
notify:
|
||||
success: true
|
||||
failure: true
|
||||
@@ -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.7.3"
|
||||
APPFLOWY_VERSION = "0.7.6"
|
||||
FLUTTER_DESKTOP_FEATURES = "dart"
|
||||
PRODUCT_NAME = "AppFlowy"
|
||||
MACOSX_DEPLOYMENT_TARGET = "11.0"
|
||||
|
||||
@@ -65,4 +65,5 @@
|
||||
-->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
</manifest>
|
||||
-23
@@ -1,33 +1,10 @@
|
||||
// ignore_for_file: unused_import
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/workspace/application/settings/prelude.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/account/account.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/uuid.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text_field.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../../../shared/dir.dart';
|
||||
import '../../../shared/mock/mock_file_picker.dart';
|
||||
import '../../../shared/util.dart';
|
||||
import '../../board/board_hide_groups_test.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import '../../../shared/constants.dart';
|
||||
import '../../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('AI Writer:', () {
|
||||
testWidgets('the ai writer transaction should only apply in memory',
|
||||
(tester) async {
|
||||
await tester.initializeAppFlowy(
|
||||
cloudType: AuthenticatorType.appflowyCloudSelfHost,
|
||||
);
|
||||
await tester.tapGoogleLoginInButton();
|
||||
await tester.expectToSeeHomePageWithGetStartedPage();
|
||||
|
||||
const pageName = 'Document';
|
||||
await tester.createNewPageInSpace(
|
||||
spaceName: Constants.generalSpaceName,
|
||||
layout: ViewLayoutPB.Document,
|
||||
pageName: pageName,
|
||||
);
|
||||
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.editor.showSlashMenu();
|
||||
await tester.editor.tapSlashMenuItemWithName(
|
||||
LocaleKeys.document_slashMenu_name_aiWriter.tr(),
|
||||
);
|
||||
expect(find.byType(AIWriterBlockComponent), findsOneWidget);
|
||||
|
||||
// switch to another page
|
||||
await tester.openPage(Constants.gettingStartedPageName);
|
||||
// switch back to the page
|
||||
await tester.openPage(pageName);
|
||||
|
||||
// expect the ai writer block is not in the document
|
||||
expect(find.byType(AIWriterBlockComponent), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
+79
-16
@@ -1,33 +1,17 @@
|
||||
// ignore_for_file: unused_import
|
||||
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
||||
import 'package:appflowy/plugins/shared/share/constants.dart';
|
||||
import 'package:appflowy/plugins/shared/share/publish_tab.dart';
|
||||
import 'package:appflowy/plugins/shared/share/share_menu.dart';
|
||||
import 'package:appflowy/shared/feature_flags.dart';
|
||||
import 'package:appflowy/shared/patterns/common_patterns.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/workspace/application/settings/prelude.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/uuid.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../../../shared/constants.dart';
|
||||
import '../../../shared/mock/mock_file_picker.dart';
|
||||
import '../../../shared/util.dart';
|
||||
import '../../../shared/workspace.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -122,9 +106,21 @@ void main() {
|
||||
await tester.pumpUntilFound(errorToast2);
|
||||
await tester.pumpUntilNotFound(errorToast2);
|
||||
|
||||
// rename with empty name
|
||||
await tester.tap(inputField);
|
||||
await tester.enterText(inputField, '');
|
||||
await tester.tapButton(find.text(LocaleKeys.button_save.tr()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// expect to see the toast with error message
|
||||
final errorToast3 = find.text(
|
||||
LocaleKeys.settings_sites_error_publishNameCannotBeEmpty.tr(),
|
||||
);
|
||||
await tester.pumpUntilFound(errorToast3);
|
||||
await tester.pumpUntilNotFound(errorToast3);
|
||||
|
||||
// input the new path name
|
||||
await tester.tap(inputField);
|
||||
await tester.enterText(inputField, 'new-path-name');
|
||||
// click save button
|
||||
await tester.tapButton(find.text(LocaleKeys.button_save.tr()));
|
||||
@@ -153,5 +149,72 @@ void main() {
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('re-publish the document', (tester) async {
|
||||
await tester.initializeAppFlowy(
|
||||
cloudType: AuthenticatorType.appflowyCloudSelfHost,
|
||||
);
|
||||
await tester.tapGoogleLoginInButton();
|
||||
await tester.expectToSeeHomePageWithGetStartedPage();
|
||||
|
||||
const pageName = 'Document';
|
||||
|
||||
await tester.createNewPageInSpace(
|
||||
spaceName: Constants.generalSpaceName,
|
||||
layout: ViewLayoutPB.Document,
|
||||
pageName: pageName,
|
||||
);
|
||||
|
||||
// open the publish menu
|
||||
await tester.openPublishMenu();
|
||||
|
||||
// publish the document
|
||||
final publishButton = find.byType(PublishButton);
|
||||
await tester.tapButton(publishButton);
|
||||
|
||||
// rename the path name
|
||||
final inputField = find.descendant(
|
||||
of: find.byType(ShareMenu),
|
||||
matching: find.byType(TextField),
|
||||
);
|
||||
|
||||
// input the new path name
|
||||
const newName = 'new-path-name';
|
||||
await tester.enterText(inputField, newName);
|
||||
// click save button
|
||||
await tester.tapButton(find.text(LocaleKeys.button_save.tr()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// expect to see the toast with success message
|
||||
final successToast = find.text(
|
||||
LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(),
|
||||
);
|
||||
await tester.pumpUntilNotFound(successToast);
|
||||
|
||||
// unpublish the document
|
||||
final unpublishButton = find.byType(UnPublishButton);
|
||||
await tester.tapButton(unpublishButton);
|
||||
|
||||
final unpublishSuccessToast = find.text(
|
||||
LocaleKeys.publish_unpublishSuccessfully.tr(),
|
||||
);
|
||||
await tester.pumpUntilNotFound(unpublishSuccessToast);
|
||||
|
||||
// re-publish the document
|
||||
await tester.tapButton(publishButton);
|
||||
|
||||
// expect to see the toast with success message
|
||||
final rePublishSuccessToast = find.text(
|
||||
LocaleKeys.publish_publishSuccessfully.tr(),
|
||||
);
|
||||
await tester.pumpUntilNotFound(rePublishSuccessToast);
|
||||
|
||||
// check the clipboard has the link
|
||||
final content = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
expect(
|
||||
content?.text?.contains(newName),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
+2
@@ -1,5 +1,6 @@
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import 'document_ai_writer_test.dart' as document_ai_writer_test;
|
||||
import 'document_copy_link_to_block_test.dart'
|
||||
as document_copy_link_to_block_test;
|
||||
import 'document_option_actions_test.dart' as document_option_actions_test;
|
||||
@@ -11,4 +12,5 @@ void main() {
|
||||
document_option_actions_test.main();
|
||||
document_copy_link_to_block_test.main();
|
||||
document_publish_test.main();
|
||||
document_ai_writer_test.main();
|
||||
}
|
||||
-22
@@ -1,35 +1,13 @@
|
||||
// ignore_for_file: unused_import
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart';
|
||||
import 'package:appflowy/shared/feature_flags.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/workspace/application/settings/prelude.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/uuid.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import '../../../shared/constants.dart';
|
||||
import '../../../shared/database_test_op.dart';
|
||||
import '../../../shared/dir.dart';
|
||||
import '../../../shared/emoji.dart';
|
||||
import '../../../shared/mock/mock_file_picker.dart';
|
||||
import '../../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
|
||||
-9
@@ -1,21 +1,12 @@
|
||||
// ignore_for_file: unused_import
|
||||
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/workspace/application/settings/prelude.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/account/account.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/uuid.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../../../shared/mock/mock_file_picker.dart';
|
||||
import '../../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
|
||||
-15
@@ -1,24 +1,9 @@
|
||||
// ignore_for_file: unused_import
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/workspace/application/settings/prelude.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/uuid.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../../../shared/dir.dart';
|
||||
import '../../../shared/mock/mock_file_picker.dart';
|
||||
import '../../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
|
||||
-22
@@ -1,33 +1,11 @@
|
||||
// ignore_for_file: unused_import
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/workspace/application/settings/prelude.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/account/account_user_profile.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/uuid.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text_field.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../../../shared/database_test_op.dart';
|
||||
import '../../../shared/dir.dart';
|
||||
import '../../../shared/emoji.dart';
|
||||
import '../../../shared/mock/mock_file_picker.dart';
|
||||
import '../../../shared/util.dart';
|
||||
import '../../board/board_hide_groups_test.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
+4
-9
@@ -1,21 +1,12 @@
|
||||
// ignore_for_file: unused_import
|
||||
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/shared/feature_flags.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/workspace/application/settings/prelude.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/uuid.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../../../shared/mock/mock_file_picker.dart';
|
||||
import '../../../shared/util.dart';
|
||||
import '../../../shared/workspace.dart';
|
||||
|
||||
@@ -49,6 +40,10 @@ void main() {
|
||||
await tester.changeWorkspaceIcon(icon);
|
||||
await tester.changeWorkspaceName(name);
|
||||
|
||||
await tester.pumpUntilNotFound(
|
||||
find.text(LocaleKeys.workspace_renameSuccess.tr()),
|
||||
);
|
||||
|
||||
workspaceIcon = tester.widget<WorkspaceIcon>(
|
||||
find.byType(WorkspaceIcon),
|
||||
);
|
||||
|
||||
+75
-20
@@ -1,38 +1,19 @@
|
||||
// ignore_for_file: unused_import
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart';
|
||||
import 'package:appflowy/shared/feature_flags.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/workspace/application/settings/prelude.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/uuid.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../../../shared/database_test_op.dart';
|
||||
import '../../../shared/dir.dart';
|
||||
import '../../../shared/emoji.dart';
|
||||
import '../../../shared/mock/mock_file_picker.dart';
|
||||
import '../../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('collaborative workspace: ', () {
|
||||
group('collaborative workspace:', () {
|
||||
// combine the create and delete workspace test to reduce the time
|
||||
testWidgets('create a new workspace, open it and then delete it',
|
||||
(tester) async {
|
||||
@@ -93,5 +74,79 @@ void main() {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('check the member count immediately after creating a workspace',
|
||||
(tester) async {
|
||||
// only run the test when the feature flag is on
|
||||
if (!FeatureFlag.collaborativeWorkspace.isOn) {
|
||||
return;
|
||||
}
|
||||
|
||||
await tester.initializeAppFlowy(
|
||||
cloudType: AuthenticatorType.appflowyCloudSelfHost,
|
||||
);
|
||||
await tester.tapGoogleLoginInButton();
|
||||
await tester.expectToSeeHomePageWithGetStartedPage();
|
||||
|
||||
const name = 'AppFlowy.IO';
|
||||
// the workspace will be opened after created
|
||||
await tester.createCollaborativeWorkspace(name);
|
||||
|
||||
final loading = find.byType(Loading);
|
||||
await tester.pumpUntilNotFound(loading);
|
||||
|
||||
await tester.openCollaborativeWorkspaceMenu();
|
||||
|
||||
// expect to see the member count
|
||||
final memberCount = find.text('1 member');
|
||||
expect(memberCount, findsNWidgets(2));
|
||||
});
|
||||
|
||||
testWidgets('only display one menu item in the workspace menu',
|
||||
(tester) async {
|
||||
// only run the test when the feature flag is on
|
||||
if (!FeatureFlag.collaborativeWorkspace.isOn) {
|
||||
return;
|
||||
}
|
||||
|
||||
await tester.initializeAppFlowy(
|
||||
cloudType: AuthenticatorType.appflowyCloudSelfHost,
|
||||
);
|
||||
await tester.tapGoogleLoginInButton();
|
||||
await tester.expectToSeeHomePageWithGetStartedPage();
|
||||
|
||||
const name = 'AppFlowy.IO';
|
||||
// the workspace will be opened after created
|
||||
await tester.createCollaborativeWorkspace(name);
|
||||
|
||||
final loading = find.byType(Loading);
|
||||
await tester.pumpUntilNotFound(loading);
|
||||
|
||||
await tester.openCollaborativeWorkspaceMenu();
|
||||
|
||||
// hover on the workspace and click the more button
|
||||
final workspaceItem = find.byWidgetPredicate(
|
||||
(w) => w is WorkspaceMenuItem && w.workspace.name == name,
|
||||
);
|
||||
await tester.hoverOnWidget(
|
||||
workspaceItem,
|
||||
onHover: () async {
|
||||
final moreButton = find.byWidgetPredicate(
|
||||
(w) => w is WorkspaceMoreActionList && w.workspace.name == name,
|
||||
);
|
||||
expect(moreButton, findsOneWidget);
|
||||
await tester.tapButton(moreButton);
|
||||
|
||||
// click it again
|
||||
await tester.tapButton(moreButton);
|
||||
|
||||
// nothing should happen
|
||||
expect(
|
||||
find.text(LocaleKeys.button_rename.tr()),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
-12
@@ -1,29 +1,17 @@
|
||||
// ignore_for_file: unused_import
|
||||
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
||||
import 'package:appflowy/plugins/shared/share/constants.dart';
|
||||
import 'package:appflowy/plugins/shared/share/share_menu.dart';
|
||||
import 'package:appflowy/shared/feature_flags.dart';
|
||||
import 'package:appflowy/shared/patterns/common_patterns.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/workspace/application/settings/prelude.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/uuid.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../../../shared/constants.dart';
|
||||
import '../../../shared/mock/mock_file_picker.dart';
|
||||
import '../../../shared/util.dart';
|
||||
import '../../../shared/workspace.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/tabs/flowy_tab.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/tabs/tabs_manager.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import '../../../shared/constants.dart';
|
||||
import '../../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('Tabs', () {
|
||||
testWidgets('close other tabs before opening a new workspace',
|
||||
(tester) async {
|
||||
await tester.initializeAppFlowy(
|
||||
cloudType: AuthenticatorType.appflowyCloudSelfHost,
|
||||
);
|
||||
await tester.tapGoogleLoginInButton();
|
||||
await tester.expectToSeeHomePageWithGetStartedPage();
|
||||
|
||||
const name = 'AppFlowy.IO';
|
||||
// the workspace will be opened after created
|
||||
await tester.createCollaborativeWorkspace(name);
|
||||
|
||||
final loading = find.byType(Loading);
|
||||
await tester.pumpUntilNotFound(loading);
|
||||
|
||||
// create new tabs in the workspace
|
||||
expect(find.byType(FlowyTab), findsNothing);
|
||||
|
||||
const documentOneName = 'document one';
|
||||
const documentTwoName = 'document two';
|
||||
await tester.createNewPageInSpace(
|
||||
spaceName: Constants.generalSpaceName,
|
||||
layout: ViewLayoutPB.Document,
|
||||
pageName: documentOneName,
|
||||
);
|
||||
await tester.createNewPageInSpace(
|
||||
spaceName: Constants.generalSpaceName,
|
||||
layout: ViewLayoutPB.Document,
|
||||
pageName: documentTwoName,
|
||||
);
|
||||
|
||||
/// Open second menu item in a new tab
|
||||
await tester.openAppInNewTab(documentOneName, ViewLayoutPB.Document);
|
||||
|
||||
/// Open third menu item in a new tab
|
||||
await tester.openAppInNewTab(documentTwoName, ViewLayoutPB.Document);
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(TabsManager),
|
||||
matching: find.byType(FlowyTab),
|
||||
),
|
||||
findsNWidgets(2),
|
||||
);
|
||||
|
||||
// switch to the another workspace
|
||||
final Finder items = find.byType(WorkspaceMenuItem);
|
||||
await tester.openCollaborativeWorkspaceMenu();
|
||||
await tester.pumpUntilFound(items);
|
||||
expect(items, findsNWidgets(2));
|
||||
|
||||
// open the first workspace
|
||||
await tester.tap(items.first);
|
||||
await tester.pumpUntilNotFound(loading);
|
||||
|
||||
expect(find.byType(FlowyTab), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
-24
@@ -1,35 +1,11 @@
|
||||
// ignore_for_file: unused_import
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart';
|
||||
import 'package:appflowy/shared/feature_flags.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/workspace/application/settings/prelude.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/uuid.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import '../../../shared/constants.dart';
|
||||
import '../../../shared/database_test_op.dart';
|
||||
import '../../../shared/dir.dart';
|
||||
import '../../../shared/emoji.dart';
|
||||
import '../../../shared/mock/mock_file_picker.dart';
|
||||
import '../../../shared/util.dart';
|
||||
import '../../../shared/workspace.dart';
|
||||
|
||||
|
||||
-23
@@ -1,46 +1,23 @@
|
||||
// ignore_for_file: unused_import
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
|
||||
import 'package:appflowy/plugins/shared/share/publish_tab.dart';
|
||||
import 'package:appflowy/shared/feature_flags.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/workspace/application/settings/prelude.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/settings_workspace_view.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/sites/domain/domain_item.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/sites/domain/domain_more_action.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/sites/domain/domain_settings_dialog.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/sites/domain/home_page_menu.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/sites/published_page/published_view_item.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/sites/published_page/published_view_more_action.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/sites/published_page/published_view_settings_dialog.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/shared/setting_list_tile.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/uuid.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../../../shared/constants.dart';
|
||||
import '../../../shared/database_test_op.dart';
|
||||
import '../../../shared/dir.dart';
|
||||
import '../../../shared/emoji.dart';
|
||||
import '../../../shared/mock/mock_file_picker.dart';
|
||||
import '../../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
|
||||
+2
@@ -3,6 +3,7 @@ import 'package:integration_test/integration_test.dart';
|
||||
import 'change_name_and_icon_test.dart' as change_name_and_icon_test;
|
||||
import 'collaborative_workspace_test.dart' as collaborative_workspace_test;
|
||||
import 'share_menu_test.dart' as share_menu_test;
|
||||
import 'tabs_test.dart' as tabs_test;
|
||||
import 'workspace_icon_test.dart' as workspace_icon_test;
|
||||
import 'workspace_settings_test.dart' as workspace_settings_test;
|
||||
|
||||
@@ -14,4 +15,5 @@ void main() {
|
||||
collaborative_workspace_test.main();
|
||||
change_name_and_icon_test.main();
|
||||
workspace_icon_test.main();
|
||||
tabs_test.main();
|
||||
}
|
||||
-57
@@ -4,12 +4,10 @@ import 'package:appflowy/core/config/kv.dart';
|
||||
import 'package:appflowy/core/config/kv_keys.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/card/card.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/cell_editor/media_cell_editor.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/row/row_banner.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/shared/af_image.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -26,61 +24,6 @@ void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('database row cover', () {
|
||||
testWidgets('add image to media field and check if cover is set (grid)',
|
||||
(tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
|
||||
|
||||
// Invoke the field editor
|
||||
await tester.tapGridFieldWithName('Type');
|
||||
await tester.tapEditFieldButton();
|
||||
|
||||
// Change to media type
|
||||
await tester.tapSwitchFieldTypeButton();
|
||||
await tester.selectFieldType(FieldType.Media);
|
||||
await tester.dismissFieldEditor();
|
||||
|
||||
// Prepare file for upload from local
|
||||
final image = await rootBundle.load('assets/test/images/sample.jpeg');
|
||||
final tempDirectory = await getTemporaryDirectory();
|
||||
|
||||
final imagePath = p.join(tempDirectory.path, 'sample.jpeg');
|
||||
final file = File(imagePath)
|
||||
..writeAsBytesSync(image.buffer.asUint8List());
|
||||
|
||||
mockPickFilePaths(paths: [imagePath]);
|
||||
await getIt<KeyValueStorage>().set(KVKeys.kCloudType, '0');
|
||||
|
||||
// Open media cell editor
|
||||
await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.Media);
|
||||
await tester.findMediaCellEditor(findsOneWidget);
|
||||
|
||||
// Click on add file button in the Media Cell Editor
|
||||
await tester.tap(find.text(LocaleKeys.grid_media_addFileOrImage.tr()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap on the upload interaction
|
||||
await tester.tapFileUploadHint();
|
||||
|
||||
// Expect one file
|
||||
expect(find.byType(RenderMedia), findsOneWidget);
|
||||
|
||||
// Close cell editor
|
||||
await tester.dismissCellEditor();
|
||||
|
||||
// Open first row in row detail view
|
||||
await tester.openFirstRowDetailPage();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Expect a cover to be shown
|
||||
expect(find.byType(RowCover), findsOneWidget);
|
||||
|
||||
// Remove the temp file
|
||||
await Future.wait([file.delete()]);
|
||||
});
|
||||
|
||||
testWidgets('add and remove cover from Row Detail Card', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
+23
-5
@@ -1,17 +1,17 @@
|
||||
import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_cell_editor.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_cell_editor.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/row/row_detail.dart';
|
||||
import 'package:appflowy/plugins/document/document_page.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
|
||||
import 'package:appflowy/util/field_type_extension.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
@@ -76,6 +76,24 @@ void main() {
|
||||
// The number of emoji should be two. One in the row displayed in the grid
|
||||
// one in the row detail page.
|
||||
expect(emojiText, findsNWidgets(2));
|
||||
|
||||
// insert a sub page in database
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.editor.showSlashMenu();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.editor.tapSlashMenuItemWithName(
|
||||
LocaleKeys.document_slashMenu_subPage_name.tr(),
|
||||
offset: 100,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// the row detail page should be closed
|
||||
final rowDetailPage = find.byType(RowDetailPage);
|
||||
await tester.pumpUntilNotFound(rowDetailPage);
|
||||
|
||||
// expect to see a document page
|
||||
final documentPage = find.byType(DocumentPage);
|
||||
expect(documentPage, findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('remove emoji', (tester) async {
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import '../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('Block option interaction tests', () {
|
||||
testWidgets('has correct block selection on tap option button',
|
||||
(tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
// We edit the document by entering some characters, to ensure the document has focus
|
||||
await tester.editor.updateSelection(
|
||||
Selection.collapsed(Position(path: [2])),
|
||||
);
|
||||
|
||||
// Insert character 'a' three times - easy to identify
|
||||
await tester.ime.insertText('aaa');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final editorState = tester.editor.getCurrentEditorState();
|
||||
final node = editorState.getNodeAtPath([2]);
|
||||
expect(node?.delta?.toPlainText(), startsWith('aaa'));
|
||||
|
||||
final multiSelection = Selection(
|
||||
start: Position(path: [2], offset: 3),
|
||||
end: Position(path: [4], offset: 40),
|
||||
);
|
||||
|
||||
// Select multiple items
|
||||
await tester.editor.updateSelection(multiSelection);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Press the block option menu
|
||||
await tester.editor.hoverAndClickOptionMenuButton([2]);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Expect the selection to be Block type and not have changed
|
||||
expect(editorState.selectionType, SelectionType.block);
|
||||
expect(editorState.selection, multiSelection);
|
||||
});
|
||||
});
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import '../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
String generateRandomString(int len) {
|
||||
final r = Random();
|
||||
return String.fromCharCodes(
|
||||
List.generate(len, (index) => r.nextInt(33) + 89),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'document find menu test',
|
||||
(tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
// create a new document
|
||||
await tester.createNewPageWithNameUnderParent();
|
||||
|
||||
// tap editor to get focus
|
||||
await tester.tapButton(find.byType(AppFlowyEditor));
|
||||
|
||||
// set clipboard data
|
||||
final data = [
|
||||
"123456\n",
|
||||
...List.generate(100, (_) => "${generateRandomString(50)}\n"),
|
||||
"1234567\n",
|
||||
...List.generate(100, (_) => "${generateRandomString(50)}\n"),
|
||||
"12345678\n",
|
||||
...List.generate(100, (_) => "${generateRandomString(50)}\n"),
|
||||
].join();
|
||||
await getIt<ClipboardService>().setData(
|
||||
ClipboardServiceData(
|
||||
plainText: data,
|
||||
),
|
||||
);
|
||||
|
||||
// paste
|
||||
await tester.simulateKeyEvent(
|
||||
LogicalKeyboardKey.keyV,
|
||||
isControlPressed:
|
||||
UniversalPlatform.isLinux || UniversalPlatform.isWindows,
|
||||
isMetaPressed: UniversalPlatform.isMacOS,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// go back to beginning of document
|
||||
// FIXME: Cannot run Ctrl+F unless selection is on screen
|
||||
await tester.editor
|
||||
.updateSelection(Selection.collapsed(Position(path: [0])));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(FindAndReplaceMenuWidget), findsNothing);
|
||||
|
||||
// press cmd/ctrl+F to display the find menu
|
||||
await tester.simulateKeyEvent(
|
||||
LogicalKeyboardKey.keyF,
|
||||
isControlPressed:
|
||||
UniversalPlatform.isLinux || UniversalPlatform.isWindows,
|
||||
isMetaPressed: UniversalPlatform.isMacOS,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget);
|
||||
|
||||
final textField = find.descendant(
|
||||
of: find.byType(FindAndReplaceMenuWidget),
|
||||
matching: find.byType(TextField),
|
||||
);
|
||||
|
||||
await tester.enterText(
|
||||
textField,
|
||||
"123456",
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(AppFlowyEditor),
|
||||
matching: find.text("123456", findRichText: true),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(AppFlowyEditor),
|
||||
matching: find.text("1234567", findRichText: true),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.showKeyboard(textField);
|
||||
await tester.idle();
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(AppFlowyEditor),
|
||||
matching: find.text("12345678", findRichText: true),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
// tap next button, go back to beginning of document
|
||||
await tester.tapButton(
|
||||
find.descendant(
|
||||
of: find.byType(FindMenu),
|
||||
matching: find.byFlowySvg(FlowySvgs.arrow_down_s),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(AppFlowyEditor),
|
||||
matching: find.text("123456", findRichText: true),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
+3
-4
@@ -1,14 +1,13 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
|
||||
+9
-3
@@ -1,13 +1,18 @@
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import 'document_block_option_test.dart' as document_block_option_test;
|
||||
import 'document_find_menu_test.dart' as document_find_menu_test;
|
||||
import 'document_inline_page_reference_test.dart'
|
||||
as document_inline_page_reference_test;
|
||||
import 'document_more_actions_test.dart' as document_more_actions_test;
|
||||
import 'document_shortcuts_test.dart' as document_shortcuts_test;
|
||||
import 'document_toolbar_test.dart' as document_toolbar_test;
|
||||
import 'document_with_file_test.dart' as document_with_file_test;
|
||||
import 'document_with_image_block_test.dart' as document_with_image_block_test;
|
||||
import 'document_with_multi_image_block_test.dart'
|
||||
as document_with_multi_image_block_test;
|
||||
import 'document_with_simple_table_test.dart'
|
||||
as document_with_simple_table_test;
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -19,7 +24,8 @@ void main() {
|
||||
document_more_actions_test.main();
|
||||
document_with_file_test.main();
|
||||
document_shortcuts_test.main();
|
||||
|
||||
// Disable subPage test temporarily, enable it in version 0.7.2
|
||||
// document_sub_page_test.main();
|
||||
document_block_option_test.main();
|
||||
document_find_menu_test.main();
|
||||
document_toolbar_test.main();
|
||||
document_with_simple_table_test.main();
|
||||
}
|
||||
@@ -347,5 +347,27 @@ void main() {
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('paste text in title, check if the text is updated',
|
||||
(tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
await tester.createNewPageWithNameUnderParent();
|
||||
|
||||
await Clipboard.setData(const ClipboardData(text: _testDocumentName));
|
||||
|
||||
final title = tester.editor.findDocumentTitle('');
|
||||
await tester.tapButton(title);
|
||||
await tester.simulateKeyEvent(
|
||||
LogicalKeyboardKey.keyV,
|
||||
isMetaPressed: UniversalPlatform.isMacOS,
|
||||
isControlPressed: !UniversalPlatform.isMacOS,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final newTitle = tester.editor.findDocumentTitle(_testDocumentName);
|
||||
expect(newTitle, findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import '../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('document toolbar:', () {
|
||||
testWidgets('font family', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
await tester.createNewPageWithNameUnderParent();
|
||||
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
const text = 'font family';
|
||||
await tester.ime.insertText(text);
|
||||
await tester.editor.updateSelection(
|
||||
Selection.single(
|
||||
path: [0],
|
||||
startOffset: 0,
|
||||
endOffset: text.length,
|
||||
),
|
||||
);
|
||||
|
||||
// tap the font family button
|
||||
final fontFamilyButton = find.byKey(kFontFamilyToolbarItemKey);
|
||||
await tester.tapButton(fontFamilyButton);
|
||||
|
||||
// expect to see the font family dropdown immediately
|
||||
expect(find.byType(FontFamilyDropDown), findsOneWidget);
|
||||
|
||||
// click the font family 'Abel'
|
||||
const abel = 'Abel';
|
||||
await tester.tapButton(find.text(abel));
|
||||
|
||||
// check the text is updated to 'Abel'
|
||||
final editorState = tester.editor.getCurrentEditorState();
|
||||
expect(
|
||||
editorState.getDeltaAttributeValueInSelection(
|
||||
AppFlowyRichTextKeys.fontFamily,
|
||||
),
|
||||
abel,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
+64
-53
@@ -24,11 +24,70 @@ import 'package:run_with_network_images/run_with_network_images.dart';
|
||||
import '../../shared/mock/mock_file_picker.dart';
|
||||
import '../../shared/util.dart';
|
||||
|
||||
const _testImageUrls = [
|
||||
'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',
|
||||
'https://www.easygifanimator.net/images/samples/eglite.gif',
|
||||
'https://people.math.sc.edu/Burkardt/data/bmp/snail.bmp',
|
||||
'https://file-examples.com/storage/fe9566cb7d67345489a5a97/2017/10/file_example_JPG_100kB.jpg',
|
||||
];
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('image block in document', () {
|
||||
Future<void> testEmbedImage(WidgetTester tester, String url) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
// create a new document
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: LocaleKeys.document_plugins_image_addAnImageDesktop.tr(),
|
||||
);
|
||||
|
||||
// tap the first line of the document
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.editor.showSlashMenu();
|
||||
await tester.editor.tapSlashMenuItemWithName(
|
||||
LocaleKeys.document_slashMenu_name_image.tr(),
|
||||
);
|
||||
expect(find.byType(CustomImageBlockComponent), findsOneWidget);
|
||||
expect(find.byType(ImagePlaceholder), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(ImagePlaceholder),
|
||||
matching: find.byType(AppFlowyPopover),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byType(UploadImageMenu), findsOneWidget);
|
||||
|
||||
await tester.tapButtonWithName(
|
||||
LocaleKeys.document_imageBlock_embedLink_label.tr(),
|
||||
);
|
||||
await tester.enterText(
|
||||
find.descendant(
|
||||
of: find.byType(EmbedImageUrlWidget),
|
||||
matching: find.byType(TextField),
|
||||
),
|
||||
url,
|
||||
);
|
||||
await tester.tapButton(
|
||||
find.descendant(
|
||||
of: find.byType(EmbedImageUrlWidget),
|
||||
matching: find.text(
|
||||
LocaleKeys.document_imageBlock_embedLink_label.tr(),
|
||||
findRichText: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(ResizableImage), findsOneWidget);
|
||||
final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
|
||||
expect(node.type, ImageBlockKeys.type);
|
||||
expect(node.attributes[ImageBlockKeys.url], url);
|
||||
}
|
||||
|
||||
testWidgets('insert an image from local file', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
@@ -79,59 +138,11 @@ void main() {
|
||||
file.deleteSync();
|
||||
});
|
||||
|
||||
testWidgets('insert an image from network', (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);
|
||||
|
||||
await tester.tapButtonWithName(
|
||||
LocaleKeys.document_imageBlock_embedLink_label.tr(),
|
||||
);
|
||||
const url =
|
||||
'https://images.unsplash.com/photo-1469474968028-56623f02e42e?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&dl=david-marcu-78A265wPiO4-unsplash.jpg&w=640';
|
||||
await tester.enterText(
|
||||
find.descendant(
|
||||
of: find.byType(EmbedImageUrlWidget),
|
||||
matching: find.byType(TextField),
|
||||
),
|
||||
url,
|
||||
);
|
||||
await tester.tapButton(
|
||||
find.descendant(
|
||||
of: find.byType(EmbedImageUrlWidget),
|
||||
matching: find.text(
|
||||
LocaleKeys.document_imageBlock_embedLink_label.tr(),
|
||||
findRichText: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(ResizableImage), findsOneWidget);
|
||||
final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
|
||||
expect(node.type, ImageBlockKeys.type);
|
||||
expect(node.attributes[ImageBlockKeys.url], url);
|
||||
});
|
||||
for (final url in _testImageUrls) {
|
||||
testWidgets('insert an image from network: $url', (tester) async {
|
||||
await testEmbedImage(tester, url);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('insert an image from unsplash', (tester) async {
|
||||
await runWithNetworkImages(() async {
|
||||
|
||||
+2
-2
@@ -48,7 +48,7 @@ void main() {
|
||||
await tester.editor.showSlashMenu();
|
||||
await tester.editor.tapSlashMenuItemWithName(
|
||||
LocaleKeys.document_slashMenu_name_photoGallery.tr(),
|
||||
offset: 100,
|
||||
offset: 80,
|
||||
);
|
||||
expect(find.byType(MultiImageBlockComponent), findsOneWidget);
|
||||
expect(find.byType(MultiImagePlaceholder), findsOneWidget);
|
||||
@@ -146,7 +146,7 @@ void main() {
|
||||
await tester.editor.showSlashMenu();
|
||||
await tester.editor.tapSlashMenuItemWithName(
|
||||
LocaleKeys.document_slashMenu_name_photoGallery.tr(),
|
||||
offset: 100,
|
||||
offset: 80,
|
||||
);
|
||||
expect(find.byType(MultiImageBlockComponent), findsOneWidget);
|
||||
expect(find.byType(MultiImagePlaceholder), findsOneWidget);
|
||||
|
||||
+27
-10
@@ -1,6 +1,7 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
@@ -43,6 +44,9 @@ void main() {
|
||||
* # Heading 1
|
||||
* ## Heading 2
|
||||
* ### Heading 3
|
||||
* > # Heading 1
|
||||
* > ## Heading 2
|
||||
* > ### Heading 3
|
||||
*/
|
||||
|
||||
await tester.editor.tapLineOfEditorAt(3);
|
||||
@@ -53,7 +57,7 @@ void main() {
|
||||
of: find.byType(OutlineBlockWidget),
|
||||
matching: find.text(heading1),
|
||||
),
|
||||
findsOneWidget,
|
||||
findsNWidgets(2),
|
||||
);
|
||||
|
||||
// Heading 2 is prefixed with a bullet
|
||||
@@ -62,7 +66,7 @@ void main() {
|
||||
of: find.byType(OutlineBlockWidget),
|
||||
matching: find.text(heading2),
|
||||
),
|
||||
findsOneWidget,
|
||||
findsNWidgets(2),
|
||||
);
|
||||
|
||||
// Heading 3 is prefixed with a dash
|
||||
@@ -71,7 +75,7 @@ void main() {
|
||||
of: find.byType(OutlineBlockWidget),
|
||||
matching: find.text(heading3),
|
||||
),
|
||||
findsOneWidget,
|
||||
findsNWidgets(2),
|
||||
);
|
||||
|
||||
// update the Heading 1 to Heading 1Hello world
|
||||
@@ -99,13 +103,16 @@ void main() {
|
||||
* # Heading 1
|
||||
* ## Heading 2
|
||||
* ### Heading 3
|
||||
* > # Heading 1
|
||||
* > ## Heading 2
|
||||
* > ### Heading 3
|
||||
*/
|
||||
|
||||
await tester.editor.tapLineOfEditorAt(3);
|
||||
await tester.editor.tapLineOfEditorAt(7);
|
||||
await insertOutlineInDocument(tester);
|
||||
|
||||
// expect to find only the `heading1` widget under the [OutlineBlockWidget]
|
||||
await hoverAndClickDepthOptionAction(tester, [3], 1);
|
||||
await hoverAndClickDepthOptionAction(tester, [6], 1);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(OutlineBlockWidget),
|
||||
@@ -123,7 +130,7 @@ void main() {
|
||||
//////
|
||||
|
||||
/// expect to find only the 'heading1' and 'heading2' under the [OutlineBlockWidget]
|
||||
await hoverAndClickDepthOptionAction(tester, [3], 2);
|
||||
await hoverAndClickDepthOptionAction(tester, [6], 2);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(OutlineBlockWidget),
|
||||
@@ -134,13 +141,13 @@ void main() {
|
||||
//////
|
||||
|
||||
// expect to find all the headings under the [OutlineBlockWidget]
|
||||
await hoverAndClickDepthOptionAction(tester, [3], 3);
|
||||
await hoverAndClickDepthOptionAction(tester, [6], 3);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(OutlineBlockWidget),
|
||||
matching: find.text(heading1),
|
||||
),
|
||||
findsOneWidget,
|
||||
findsNWidgets(2),
|
||||
);
|
||||
|
||||
expect(
|
||||
@@ -148,7 +155,7 @@ void main() {
|
||||
of: find.byType(OutlineBlockWidget),
|
||||
matching: find.text(heading2),
|
||||
),
|
||||
findsOneWidget,
|
||||
findsNWidgets(2),
|
||||
);
|
||||
|
||||
expect(
|
||||
@@ -156,7 +163,7 @@ void main() {
|
||||
of: find.byType(OutlineBlockWidget),
|
||||
matching: find.text(heading3),
|
||||
),
|
||||
findsOneWidget,
|
||||
findsNWidgets(2),
|
||||
);
|
||||
//////
|
||||
});
|
||||
@@ -186,7 +193,17 @@ Future<void> hoverAndClickDepthOptionAction(
|
||||
|
||||
Future<void> insertHeadingComponent(WidgetTester tester) async {
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
|
||||
// # heading 1-3
|
||||
await tester.ime.insertText('# $heading1\n');
|
||||
await tester.ime.insertText('## $heading2\n');
|
||||
await tester.ime.insertText('### $heading3\n');
|
||||
|
||||
// > # toggle heading 1-3
|
||||
await tester.ime.insertText('> # $heading1\n');
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.backspace);
|
||||
await tester.ime.insertText('> ## $heading2\n');
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.backspace);
|
||||
await tester.ime.insertText('> ### $heading3\n');
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.backspace);
|
||||
}
|
||||
+370
@@ -0,0 +1,370 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/_shared_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import '../../shared/util.dart';
|
||||
|
||||
const String heading1 = "Heading 1";
|
||||
const String heading2 = "Heading 2";
|
||||
const String heading3 = "Heading 3";
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('simple table block test:', () {
|
||||
testWidgets('insert a simple table block', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: 'simple_table_test',
|
||||
);
|
||||
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.insertTableInDocument();
|
||||
|
||||
// validate the table is inserted
|
||||
expect(find.byType(SimpleTableBlockWidget), findsOneWidget);
|
||||
|
||||
final editorState = tester.editor.getCurrentEditorState();
|
||||
expect(
|
||||
editorState.selection,
|
||||
// table -> row -> cell -> paragraph
|
||||
Selection.collapsed(Position(path: [0, 0, 0, 0])),
|
||||
);
|
||||
|
||||
final firstCell = find.byType(SimpleTableCellBlockWidget).first;
|
||||
expect(
|
||||
tester
|
||||
.state<SimpleTableCellBlockWidgetState>(firstCell)
|
||||
.isEditingCellNotifier
|
||||
.value,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('select all in table cell', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: 'simple_table_test',
|
||||
);
|
||||
|
||||
const cell1Content = 'Cell 1';
|
||||
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.ime.insertText('New Table');
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.editor.tapLineOfEditorAt(1);
|
||||
await tester.insertTableInDocument();
|
||||
await tester.ime.insertText(cell1Content);
|
||||
await tester.pumpAndSettle();
|
||||
// Select all in the cell
|
||||
await tester.simulateKeyEvent(
|
||||
LogicalKeyboardKey.keyA,
|
||||
isControlPressed: !UniversalPlatform.isMacOS,
|
||||
isMetaPressed: UniversalPlatform.isMacOS,
|
||||
);
|
||||
|
||||
expect(
|
||||
tester.editor.getCurrentEditorState().selection,
|
||||
Selection(
|
||||
start: Position(path: [1, 0, 0, 0]),
|
||||
end: Position(path: [1, 0, 0, 0], offset: cell1Content.length),
|
||||
),
|
||||
);
|
||||
|
||||
// Press select all again, the selection should be the entire document
|
||||
await tester.simulateKeyEvent(
|
||||
LogicalKeyboardKey.keyA,
|
||||
isControlPressed: !UniversalPlatform.isMacOS,
|
||||
isMetaPressed: UniversalPlatform.isMacOS,
|
||||
);
|
||||
|
||||
expect(
|
||||
tester.editor.getCurrentEditorState().selection,
|
||||
Selection(
|
||||
start: Position(path: [0]),
|
||||
end: Position(path: [1, 1, 1, 0]),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('''
|
||||
1. hover on the table
|
||||
1.1 click the add row button
|
||||
1.2 click the add column button
|
||||
1.3 click the add row and column button
|
||||
2. validate the table is updated
|
||||
3. delete the last column
|
||||
4. delete the last row
|
||||
5. validate the table is updated
|
||||
''', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: 'simple_table_test',
|
||||
);
|
||||
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.insertTableInDocument();
|
||||
|
||||
// hover on the table
|
||||
final tableBlock = find.byType(SimpleTableBlockWidget).first;
|
||||
await tester.hoverOnWidget(
|
||||
tableBlock,
|
||||
onHover: () async {
|
||||
// click the add row button
|
||||
final addRowButton = find.byType(SimpleTableAddRowButton).first;
|
||||
await tester.tap(addRowButton);
|
||||
|
||||
// click the add column button
|
||||
final addColumnButton = find.byType(SimpleTableAddColumnButton).first;
|
||||
await tester.tap(addColumnButton);
|
||||
|
||||
// click the add row and column button
|
||||
final addRowAndColumnButton =
|
||||
find.byType(SimpleTableAddColumnAndRowButton).first;
|
||||
await tester.tap(addRowAndColumnButton);
|
||||
},
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final tableNode =
|
||||
tester.editor.getCurrentEditorState().document.nodeAtPath([0])!;
|
||||
expect(tableNode.columnLength, 4);
|
||||
expect(tableNode.rowLength, 4);
|
||||
|
||||
// delete the last row
|
||||
await tester.clickMoreActionItemInTableMenu(
|
||||
type: SimpleTableMoreActionType.row,
|
||||
index: tableNode.rowLength - 1,
|
||||
action: SimpleTableMoreAction.delete,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tableNode.rowLength, 3);
|
||||
expect(tableNode.columnLength, 4);
|
||||
|
||||
// delete the last column
|
||||
await tester.clickMoreActionItemInTableMenu(
|
||||
type: SimpleTableMoreActionType.column,
|
||||
index: tableNode.columnLength - 1,
|
||||
action: SimpleTableMoreAction.delete,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tableNode.columnLength, 3);
|
||||
expect(tableNode.rowLength, 3);
|
||||
});
|
||||
|
||||
testWidgets('enable header column and header row', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: 'simple_table_test',
|
||||
);
|
||||
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.insertTableInDocument();
|
||||
|
||||
// enable the header row
|
||||
await tester.clickMoreActionItemInTableMenu(
|
||||
type: SimpleTableMoreActionType.row,
|
||||
index: 0,
|
||||
action: SimpleTableMoreAction.enableHeaderRow,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
// enable the header column
|
||||
await tester.clickMoreActionItemInTableMenu(
|
||||
type: SimpleTableMoreActionType.column,
|
||||
index: 0,
|
||||
action: SimpleTableMoreAction.enableHeaderColumn,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final tableNode =
|
||||
tester.editor.getCurrentEditorState().document.nodeAtPath([0])!;
|
||||
|
||||
expect(tableNode.isHeaderColumnEnabled, isTrue);
|
||||
expect(tableNode.isHeaderRowEnabled, isTrue);
|
||||
|
||||
// disable the header row
|
||||
await tester.clickMoreActionItemInTableMenu(
|
||||
type: SimpleTableMoreActionType.row,
|
||||
index: 0,
|
||||
action: SimpleTableMoreAction.enableHeaderRow,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tableNode.isHeaderColumnEnabled, isTrue);
|
||||
expect(tableNode.isHeaderRowEnabled, isFalse);
|
||||
|
||||
// disable the header column
|
||||
await tester.clickMoreActionItemInTableMenu(
|
||||
type: SimpleTableMoreActionType.column,
|
||||
index: 0,
|
||||
action: SimpleTableMoreAction.enableHeaderColumn,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tableNode.isHeaderColumnEnabled, isFalse);
|
||||
expect(tableNode.isHeaderRowEnabled, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('duplicate a column / row', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: 'simple_table_test',
|
||||
);
|
||||
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.insertTableInDocument();
|
||||
|
||||
// duplicate the row
|
||||
await tester.clickMoreActionItemInTableMenu(
|
||||
type: SimpleTableMoreActionType.row,
|
||||
index: 0,
|
||||
action: SimpleTableMoreAction.duplicate,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// duplicate the column
|
||||
await tester.clickMoreActionItemInTableMenu(
|
||||
type: SimpleTableMoreActionType.column,
|
||||
index: 0,
|
||||
action: SimpleTableMoreAction.duplicate,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final tableNode =
|
||||
tester.editor.getCurrentEditorState().document.nodeAtPath([0])!;
|
||||
expect(tableNode.columnLength, 3);
|
||||
expect(tableNode.rowLength, 3);
|
||||
});
|
||||
|
||||
testWidgets('insert left / insert right', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: 'simple_table_test',
|
||||
);
|
||||
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.insertTableInDocument();
|
||||
|
||||
// insert left
|
||||
await tester.clickMoreActionItemInTableMenu(
|
||||
type: SimpleTableMoreActionType.column,
|
||||
index: 0,
|
||||
action: SimpleTableMoreAction.insertLeft,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// insert right
|
||||
await tester.clickMoreActionItemInTableMenu(
|
||||
type: SimpleTableMoreActionType.column,
|
||||
index: 0,
|
||||
action: SimpleTableMoreAction.insertRight,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final tableNode =
|
||||
tester.editor.getCurrentEditorState().document.nodeAtPath([0])!;
|
||||
expect(tableNode.columnLength, 4);
|
||||
expect(tableNode.rowLength, 2);
|
||||
});
|
||||
|
||||
testWidgets('insert above / insert below', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: 'simple_table_test',
|
||||
);
|
||||
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.insertTableInDocument();
|
||||
|
||||
// insert above
|
||||
await tester.clickMoreActionItemInTableMenu(
|
||||
type: SimpleTableMoreActionType.row,
|
||||
index: 0,
|
||||
action: SimpleTableMoreAction.insertAbove,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// insert below
|
||||
await tester.clickMoreActionItemInTableMenu(
|
||||
type: SimpleTableMoreActionType.row,
|
||||
index: 0,
|
||||
action: SimpleTableMoreAction.insertBelow,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final tableNode =
|
||||
tester.editor.getCurrentEditorState().document.nodeAtPath([0])!;
|
||||
expect(tableNode.rowLength, 4);
|
||||
expect(tableNode.columnLength, 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
extension on WidgetTester {
|
||||
/// Insert a table in the document
|
||||
Future<void> insertTableInDocument() async {
|
||||
// open the actions menu and insert the outline block
|
||||
await editor.showSlashMenu();
|
||||
await editor.tapSlashMenuItemWithName(
|
||||
LocaleKeys.document_slashMenu_name_table.tr(),
|
||||
);
|
||||
await pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> clickMoreActionItemInTableMenu({
|
||||
required SimpleTableMoreActionType type,
|
||||
required int index,
|
||||
required SimpleTableMoreAction action,
|
||||
}) async {
|
||||
if (type == SimpleTableMoreActionType.row) {
|
||||
final row = find.byWidgetPredicate((w) {
|
||||
return w is SimpleTableRowBlockWidget && w.node.rowIndex == index;
|
||||
});
|
||||
await hoverOnWidget(
|
||||
row,
|
||||
onHover: () async {
|
||||
final moreActionButton = find.byWidgetPredicate((w) {
|
||||
return w is SimpleTableMoreActionMenu &&
|
||||
w.type == SimpleTableMoreActionType.row &&
|
||||
w.index == index;
|
||||
});
|
||||
await tapButton(moreActionButton);
|
||||
await tapButton(find.text(action.name));
|
||||
},
|
||||
);
|
||||
await pumpAndSettle();
|
||||
} else if (type == SimpleTableMoreActionType.column) {
|
||||
final column = find.byWidgetPredicate((w) {
|
||||
return w is SimpleTableCellBlockWidget && w.node.columnIndex == index;
|
||||
}).first;
|
||||
await hoverOnWidget(
|
||||
column,
|
||||
onHover: () async {
|
||||
final moreActionButton = find.byWidgetPredicate((w) {
|
||||
return w is SimpleTableMoreActionMenu &&
|
||||
w.type == SimpleTableMoreActionType.column &&
|
||||
w.index == index;
|
||||
});
|
||||
await tapButton(moreActionButton);
|
||||
await tapButton(find.text(action.name));
|
||||
},
|
||||
);
|
||||
await pumpAndSettle();
|
||||
}
|
||||
|
||||
await tapAt(Offset.zero);
|
||||
}
|
||||
}
|
||||
+21
@@ -1,7 +1,9 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -263,5 +265,24 @@ void main() {
|
||||
expect(node.attributes[ToggleListBlockKeys.level], 3);
|
||||
expect(node.delta!.toPlainText(), 'Hello');
|
||||
});
|
||||
|
||||
testWidgets('click the toggle list to create a new paragraph',
|
||||
(tester) async {
|
||||
await prepareToggleHeadingBlock(tester, '> # Hello');
|
||||
final emptyHintText = find.text(
|
||||
LocaleKeys.document_plugins_emptyToggleHeading.tr(
|
||||
args: ['1'],
|
||||
),
|
||||
);
|
||||
expect(emptyHintText, findsOneWidget);
|
||||
|
||||
await tester.tapButton(emptyHintText);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// check the new paragraph is created
|
||||
final editorState = tester.editor.getCurrentEditorState();
|
||||
final node = editorState.getNodeAtPath([0, 0])!;
|
||||
expect(node.type, ParagraphBlockKeys.type);
|
||||
});
|
||||
});
|
||||
}
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -87,7 +88,7 @@ void main() {
|
||||
);
|
||||
expect(
|
||||
importedPageEditorState.getNodeAtPath([2])!.type,
|
||||
TableBlockKeys.type,
|
||||
SimpleTableBlockKeys.type,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/tabs/flowy_tab.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/tabs/tabs_manager.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import '../../shared/base.dart';
|
||||
import '../../shared/common_operations.dart';
|
||||
import '../../shared/expectation.dart';
|
||||
import '../../shared/keyboard.dart';
|
||||
import '../../shared/util.dart';
|
||||
|
||||
const _documentName = 'First Doc';
|
||||
const _documentTwoName = 'Second Doc';
|
||||
@@ -20,17 +22,12 @@ void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('Tabs', () {
|
||||
testWidgets('Open AppFlowy and open/navigate/close tabs', (tester) async {
|
||||
testWidgets('open/navigate/close tabs', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(TabsManager),
|
||||
matching: find.byType(TabBar),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
// No tabs rendered yet
|
||||
expect(find.byType(FlowyTab), findsNothing);
|
||||
|
||||
await tester.createNewPageWithNameUnderParent(name: _documentName);
|
||||
|
||||
@@ -44,7 +41,7 @@ void main() {
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(TabBar),
|
||||
of: find.byType(TabsManager),
|
||||
matching: find.byType(FlowyTab),
|
||||
),
|
||||
findsNWidgets(3),
|
||||
@@ -71,11 +68,267 @@ void main() {
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(TabBar),
|
||||
of: find.byType(TabsManager),
|
||||
matching: find.byType(FlowyTab),
|
||||
),
|
||||
findsNWidgets(2),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('right click show tab menu, close others', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(TabsManager),
|
||||
matching: find.byType(TabBar),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
await tester.createNewPageWithNameUnderParent(name: _documentName);
|
||||
await tester.createNewPageWithNameUnderParent(name: _documentTwoName);
|
||||
|
||||
/// Open second menu item in a new tab
|
||||
await tester.openAppInNewTab(gettingStarted, ViewLayoutPB.Document);
|
||||
|
||||
/// Open third menu item in a new tab
|
||||
await tester.openAppInNewTab(_documentName, ViewLayoutPB.Document);
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(TabsManager),
|
||||
matching: find.byType(FlowyTab),
|
||||
),
|
||||
findsNWidgets(3),
|
||||
);
|
||||
|
||||
/// Right click on second tab
|
||||
await tester.tap(
|
||||
buttons: kSecondaryButton,
|
||||
find.descendant(
|
||||
of: find.byType(FlowyTab),
|
||||
matching: find.text(gettingStarted),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(TabMenu), findsOneWidget);
|
||||
|
||||
final firstTabFinder = find.descendant(
|
||||
of: find.byType(FlowyTab),
|
||||
matching: find.text(_documentTwoName),
|
||||
);
|
||||
final secondTabFinder = find.descendant(
|
||||
of: find.byType(FlowyTab),
|
||||
matching: find.text(gettingStarted),
|
||||
);
|
||||
final thirdTabFinder = find.descendant(
|
||||
of: find.byType(FlowyTab),
|
||||
matching: find.text(_documentName),
|
||||
);
|
||||
|
||||
expect(firstTabFinder, findsOneWidget);
|
||||
expect(secondTabFinder, findsOneWidget);
|
||||
expect(thirdTabFinder, findsOneWidget);
|
||||
|
||||
// Close other tabs than the second item
|
||||
await tester.tap(find.text(LocaleKeys.tabMenu_closeOthers.tr()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// We expect to not find any tabs
|
||||
expect(firstTabFinder, findsNothing);
|
||||
expect(secondTabFinder, findsNothing);
|
||||
expect(thirdTabFinder, findsNothing);
|
||||
|
||||
// Expect second tab to be current page (current page has breadcrumb, cover title,
|
||||
// and in this case view name in sidebar)
|
||||
expect(find.text(gettingStarted), findsNWidgets(3));
|
||||
});
|
||||
|
||||
testWidgets('cannot close pinned tabs', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(TabsManager),
|
||||
matching: find.byType(TabBar),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
await tester.createNewPageWithNameUnderParent(name: _documentName);
|
||||
await tester.createNewPageWithNameUnderParent(name: _documentTwoName);
|
||||
|
||||
// Open second menu item in a new tab
|
||||
await tester.openAppInNewTab(gettingStarted, ViewLayoutPB.Document);
|
||||
|
||||
// Open third menu item in a new tab
|
||||
await tester.openAppInNewTab(_documentName, ViewLayoutPB.Document);
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(TabsManager),
|
||||
matching: find.byType(FlowyTab),
|
||||
),
|
||||
findsNWidgets(3),
|
||||
);
|
||||
|
||||
const firstTab = _documentTwoName;
|
||||
const secondTab = gettingStarted;
|
||||
const thirdTab = _documentName;
|
||||
|
||||
expect(tester.isTabAtIndex(firstTab, 0), isTrue);
|
||||
expect(tester.isTabAtIndex(secondTab, 1), isTrue);
|
||||
expect(tester.isTabAtIndex(thirdTab, 2), isTrue);
|
||||
|
||||
expect(tester.isTabPinned(gettingStarted), isFalse);
|
||||
|
||||
// Right click on second tab
|
||||
await tester.openTabMenu(gettingStarted);
|
||||
expect(find.byType(TabMenu), findsOneWidget);
|
||||
|
||||
// Pin second tab
|
||||
await tester.tap(find.text(LocaleKeys.tabMenu_pinTab.tr()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.isTabPinned(gettingStarted), isTrue);
|
||||
|
||||
/// Right click on first unpinned tab (second tab)
|
||||
await tester.openTabMenu(_documentTwoName);
|
||||
|
||||
// Close others
|
||||
await tester.tap(find.text(LocaleKeys.tabMenu_closeOthers.tr()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// We expect to find 2 tabs, the first pinned tab and the second tab
|
||||
expect(find.byType(FlowyTab), findsNWidgets(2));
|
||||
expect(tester.isTabAtIndex(gettingStarted, 0), isTrue);
|
||||
expect(tester.isTabAtIndex(_documentTwoName, 1), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('pin/unpin tabs proper order', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(TabsManager),
|
||||
matching: find.byType(TabBar),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
await tester.createNewPageWithNameUnderParent(name: _documentName);
|
||||
await tester.createNewPageWithNameUnderParent(name: _documentTwoName);
|
||||
|
||||
// Open second menu item in a new tab
|
||||
await tester.openAppInNewTab(gettingStarted, ViewLayoutPB.Document);
|
||||
|
||||
// Open third menu item in a new tab
|
||||
await tester.openAppInNewTab(_documentName, ViewLayoutPB.Document);
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(TabsManager),
|
||||
matching: find.byType(FlowyTab),
|
||||
),
|
||||
findsNWidgets(3),
|
||||
);
|
||||
|
||||
const firstTabName = _documentTwoName;
|
||||
const secondTabName = gettingStarted;
|
||||
const thirdTabName = _documentName;
|
||||
|
||||
// Expect correct order
|
||||
expect(tester.isTabAtIndex(firstTabName, 0), isTrue);
|
||||
expect(tester.isTabAtIndex(secondTabName, 1), isTrue);
|
||||
expect(tester.isTabAtIndex(thirdTabName, 2), isTrue);
|
||||
|
||||
// Pin second tab
|
||||
await tester.openTabMenu(secondTabName);
|
||||
await tester.tap(find.text(LocaleKeys.tabMenu_pinTab.tr()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.isTabPinned(secondTabName), isTrue);
|
||||
|
||||
// Expect correct order
|
||||
expect(tester.isTabAtIndex(secondTabName, 0), isTrue);
|
||||
expect(tester.isTabAtIndex(firstTabName, 1), isTrue);
|
||||
expect(tester.isTabAtIndex(thirdTabName, 2), isTrue);
|
||||
|
||||
// Pin new second tab (first tab)
|
||||
await tester.openTabMenu(firstTabName);
|
||||
await tester.tap(find.text(LocaleKeys.tabMenu_pinTab.tr()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.isTabPinned(firstTabName), isTrue);
|
||||
expect(tester.isTabPinned(secondTabName), isTrue);
|
||||
expect(tester.isTabPinned(thirdTabName), isFalse);
|
||||
|
||||
expect(tester.isTabAtIndex(secondTabName, 0), isTrue);
|
||||
expect(tester.isTabAtIndex(firstTabName, 1), isTrue);
|
||||
expect(tester.isTabAtIndex(thirdTabName, 2), isTrue);
|
||||
|
||||
// Unpin second tab
|
||||
await tester.openTabMenu(secondTabName);
|
||||
await tester.tap(find.text(LocaleKeys.tabMenu_unpinTab.tr()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.isTabPinned(firstTabName), isTrue);
|
||||
expect(tester.isTabPinned(secondTabName), isFalse);
|
||||
expect(tester.isTabPinned(thirdTabName), isFalse);
|
||||
|
||||
expect(tester.isTabAtIndex(firstTabName, 0), isTrue);
|
||||
expect(tester.isTabAtIndex(secondTabName, 1), isTrue);
|
||||
expect(tester.isTabAtIndex(thirdTabName, 2), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
extension _TabsTester on WidgetTester {
|
||||
bool isTabPinned(String tabName) {
|
||||
final tabFinder = find.ancestor(
|
||||
of: find.byWidgetPredicate(
|
||||
(w) => w is ViewTabBarItem && w.view.name == tabName,
|
||||
),
|
||||
matching: find.byType(FlowyTab),
|
||||
);
|
||||
|
||||
final FlowyTab tabWidget = widget(tabFinder);
|
||||
return tabWidget.pageManager.isPinned;
|
||||
}
|
||||
|
||||
bool isTabAtIndex(String tabName, int index) {
|
||||
final tabFinder = find.ancestor(
|
||||
of: find.byWidgetPredicate(
|
||||
(w) => w is ViewTabBarItem && w.view.name == tabName,
|
||||
),
|
||||
matching: find.byType(FlowyTab),
|
||||
);
|
||||
|
||||
final pluginId = (widget(tabFinder) as FlowyTab).pageManager.plugin.id;
|
||||
|
||||
final pluginIds = find
|
||||
.byType(FlowyTab)
|
||||
.evaluate()
|
||||
.map((e) => (e.widget as FlowyTab).pageManager.plugin.id);
|
||||
|
||||
return pluginIds.elementAt(index) == pluginId;
|
||||
}
|
||||
|
||||
Future<void> openTabMenu(String tabName) async {
|
||||
await tap(
|
||||
buttons: kSecondaryButton,
|
||||
find.ancestor(
|
||||
of: find.byWidgetPredicate(
|
||||
(w) => w is ViewTabBarItem && w.view.name == tabName,
|
||||
),
|
||||
matching: find.byType(FlowyTab),
|
||||
),
|
||||
);
|
||||
await pumpAndSettle();
|
||||
}
|
||||
}
|
||||
-2
@@ -4,7 +4,6 @@ import 'emoji_shortcut_test.dart' as emoji_shortcut_test;
|
||||
import 'hotkeys_test.dart' as hotkeys_test;
|
||||
import 'import_files_test.dart' as import_files_test;
|
||||
import 'share_markdown_test.dart' as share_markdown_test;
|
||||
import 'tabs_test.dart' as tabs_test;
|
||||
import 'zoom_in_out_test.dart' as zoom_in_out_test;
|
||||
|
||||
void main() {
|
||||
@@ -17,7 +16,6 @@ void main() {
|
||||
emoji_shortcut_test.main();
|
||||
share_markdown_test.main();
|
||||
import_files_test.main();
|
||||
tabs_test.main();
|
||||
zoom_in_out_test.main();
|
||||
// DON'T add more tests here.
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import 'desktop/uncategorized/tabs_test.dart' as tabs_test;
|
||||
import 'desktop/first_test/first_test.dart' as first_test;
|
||||
|
||||
Future<void> main() async {
|
||||
await runIntegration9OnDesktop();
|
||||
}
|
||||
|
||||
Future<void> runIntegration9OnDesktop() async {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
first_test.main();
|
||||
|
||||
tabs_test.main();
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'document/publish_test.dart' as publish_test;
|
||||
import 'document/share_link_test.dart' as share_link_test;
|
||||
import 'space/space_test.dart' as space_test;
|
||||
import 'workspace/workspace_operations_test.dart' as workspace_operations_test;
|
||||
|
||||
Future<void> main() async {
|
||||
workspace_operations_test.main();
|
||||
share_link_test.main();
|
||||
publish_test.main();
|
||||
space_test.main();
|
||||
}
|
||||
+51
-30
@@ -1,47 +1,23 @@
|
||||
// ignore_for_file: unused_import
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/home.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_layout.dart';
|
||||
import 'package:appflowy/shared/patterns/common_patterns.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
|
||||
import 'package:appflowy/workspace/application/settings/prelude.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/workspaces/create_workspace_menu.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/uuid.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../../../shared/constants.dart';
|
||||
import '../../../shared/dir.dart';
|
||||
import '../../../shared/mock/mock_file_picker.dart';
|
||||
import '../../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('publish:', () {
|
||||
testWidgets('publish document', (tester) async {
|
||||
testWidgets('''
|
||||
1. publish document
|
||||
2. update path name
|
||||
3. unpublish document
|
||||
''', (tester) async {
|
||||
await tester.initializeAppFlowy(
|
||||
cloudType: AuthenticatorType.appflowyCloudSelfHost,
|
||||
);
|
||||
@@ -75,6 +51,51 @@ void main() {
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
// update the path name
|
||||
await tester.editor.clickMoreActionItemOnMobile(
|
||||
LocaleKeys.shareAction_updatePathName.tr(),
|
||||
);
|
||||
|
||||
const pathName1 = '???????????????';
|
||||
const pathName2 = 'AppFlowy';
|
||||
|
||||
final textField = find.descendant(
|
||||
of: find.byType(EditWorkspaceNameBottomSheet),
|
||||
matching: find.byType(TextFormField),
|
||||
);
|
||||
await tester.enterText(textField, pathName1);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// wait 50ms to ensure the error message is shown
|
||||
await tester.wait(50);
|
||||
|
||||
// click the confirm button
|
||||
final confirmButton = find.text(LocaleKeys.button_confirm.tr());
|
||||
await tester.tapButton(confirmButton);
|
||||
|
||||
// expect to see the update path name failed toast
|
||||
final updatePathFailedText = find.text(
|
||||
LocaleKeys.settings_sites_error_publishNameContainsInvalidCharacters
|
||||
.tr(),
|
||||
);
|
||||
expect(updatePathFailedText, findsOneWidget);
|
||||
|
||||
// input the valid path name
|
||||
await tester.enterText(textField, pathName2);
|
||||
await tester.pumpAndSettle();
|
||||
// click the confirm button
|
||||
await tester.tapButton(confirmButton);
|
||||
|
||||
// wait 50ms to ensure the error message is shown
|
||||
await tester.wait(50);
|
||||
|
||||
// expect to see the update path name success toast
|
||||
final updatePathSuccessText = find.findTextInFlowyText(
|
||||
LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(),
|
||||
);
|
||||
expect(updatePathSuccessText, findsOneWidget);
|
||||
await tester.pumpUntilNotFound(updatePathSuccessText);
|
||||
|
||||
// unpublish the document
|
||||
await tester.editor.clickMoreActionItemOnMobile(
|
||||
LocaleKeys.shareAction_unPublish.tr(),
|
||||
|
||||
@@ -1,39 +1,12 @@
|
||||
// ignore_for_file: unused_import
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/home.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_layout.dart';
|
||||
import 'package:appflowy/shared/patterns/common_patterns.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
|
||||
import 'package:appflowy/workspace/application/settings/prelude.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/uuid.dart';
|
||||
import 'package:flutter/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 '../../../shared/constants.dart';
|
||||
import '../../../shared/dir.dart';
|
||||
import '../../../shared/mock/mock_file_picker.dart';
|
||||
import '../../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/space/manage_space_widget.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/space/mobile_space_header.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/space/mobile_space_menu.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/space/space_menu_bottom_sheet.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/space/widgets.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/workspaces/create_workspace_menu.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import '../../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('space operations:', () {
|
||||
Future<void> openSpaceMenu(WidgetTester tester) async {
|
||||
final spaceHeader = find.byType(MobileSpaceHeader);
|
||||
await tester.tapButton(spaceHeader);
|
||||
await tester.pumpUntilFound(find.byType(MobileSpaceMenu));
|
||||
}
|
||||
|
||||
Future<void> openSpaceMenuMoreOptions(
|
||||
WidgetTester tester,
|
||||
ViewPB space,
|
||||
) async {
|
||||
final spaceMenuItemTrailing = find.byWidgetPredicate(
|
||||
(w) => w is SpaceMenuItemTrailing && w.space.id == space.id,
|
||||
);
|
||||
final moreOptions = find.descendant(
|
||||
of: spaceMenuItemTrailing,
|
||||
matching: find.byWidgetPredicate(
|
||||
(w) =>
|
||||
w is FlowySvg &&
|
||||
w.svg.path == FlowySvgs.workspace_three_dots_s.path,
|
||||
),
|
||||
);
|
||||
await tester.tapButton(moreOptions);
|
||||
await tester.pumpUntilFound(find.byType(SpaceMenuMoreOptions));
|
||||
}
|
||||
|
||||
// combine the tests together to reduce the CI time
|
||||
testWidgets('''
|
||||
1. create a new space
|
||||
2. update the space name
|
||||
3. update the space permission
|
||||
4. update the space icon
|
||||
5. delete the space
|
||||
''', (tester) async {
|
||||
await tester.initializeAppFlowy(
|
||||
cloudType: AuthenticatorType.appflowyCloudSelfHost,
|
||||
);
|
||||
await tester.tapGoogleLoginInButton();
|
||||
await tester.expectToSeeHomePageWithGetStartedPage();
|
||||
|
||||
// 1. create a new space
|
||||
// click the space menu
|
||||
await openSpaceMenu(tester);
|
||||
|
||||
// click the create a new space button
|
||||
final createNewSpaceButton = find.text(
|
||||
LocaleKeys.space_createNewSpace.tr(),
|
||||
);
|
||||
await tester.pumpUntilFound(createNewSpaceButton);
|
||||
await tester.tapButton(createNewSpaceButton);
|
||||
|
||||
// input the new space name
|
||||
final inputField = find.descendant(
|
||||
of: find.byType(ManageSpaceWidget),
|
||||
matching: find.byType(TextField),
|
||||
);
|
||||
const newSpaceName = 'AppFlowy';
|
||||
await tester.enterText(inputField, newSpaceName);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// change the space permission to private
|
||||
final permissionOption = find.byType(ManageSpacePermissionOption);
|
||||
await tester.tapButton(permissionOption);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final privateOption = find.text(LocaleKeys.space_privatePermission.tr());
|
||||
await tester.tapButton(privateOption);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// change the space icon color
|
||||
final color = builtInSpaceColors[1];
|
||||
final iconOption = find.descendant(
|
||||
of: find.byType(ManageSpaceIconOption),
|
||||
matching: find.byWidgetPredicate(
|
||||
(w) => w is SpaceColorItem && w.color == color,
|
||||
),
|
||||
);
|
||||
await tester.tapButton(iconOption);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// change the space icon
|
||||
final icon = kIconGroups![0].icons[1];
|
||||
final iconItem = find.descendant(
|
||||
of: find.byType(ManageSpaceIconOption),
|
||||
matching: find.byWidgetPredicate(
|
||||
(w) => w is SpaceIconItem && w.icon == icon,
|
||||
),
|
||||
);
|
||||
await tester.tapButton(iconItem);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// click the done button
|
||||
final doneButton = find.descendant(
|
||||
of: find.byWidgetPredicate(
|
||||
(w) =>
|
||||
w is BottomSheetHeader &&
|
||||
w.title == LocaleKeys.space_createSpace.tr(),
|
||||
),
|
||||
matching: find.text(LocaleKeys.button_done.tr()),
|
||||
);
|
||||
await tester.tapButton(doneButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// wait 100ms for the space to be created
|
||||
await tester.wait(100);
|
||||
|
||||
// verify the space is created
|
||||
await openSpaceMenu(tester);
|
||||
final spaceItems = find.byType(MobileSpaceMenuItem);
|
||||
// expect to see 3 space items, 2 are built-in, 1 is the new space
|
||||
expect(spaceItems, findsNWidgets(3));
|
||||
// convert the space item to a widget
|
||||
final spaceWidget =
|
||||
tester.widgetList<MobileSpaceMenuItem>(spaceItems).last;
|
||||
final space = spaceWidget.space;
|
||||
expect(space.name, newSpaceName);
|
||||
expect(space.spacePermission, SpacePermission.private);
|
||||
expect(space.spaceIcon, icon.iconPath);
|
||||
expect(space.spaceIconColor, color);
|
||||
|
||||
// open the SpaceMenuMoreOptions menu
|
||||
await openSpaceMenuMoreOptions(tester, space);
|
||||
|
||||
// 2. rename the space name
|
||||
final renameOption = find.text(LocaleKeys.button_rename.tr());
|
||||
await tester.tapButton(renameOption);
|
||||
await tester.pumpUntilFound(find.byType(EditWorkspaceNameBottomSheet));
|
||||
|
||||
// input the new space name
|
||||
final renameInputField = find.descendant(
|
||||
of: find.byType(EditWorkspaceNameBottomSheet),
|
||||
matching: find.byType(TextField),
|
||||
);
|
||||
const renameSpaceName = 'HelloWorld';
|
||||
await tester.enterText(renameInputField, renameSpaceName);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tapButton(find.text(LocaleKeys.button_confirm.tr()));
|
||||
|
||||
// click the done button
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final renameSuccess = find.text(
|
||||
LocaleKeys.space_success_renameSpace.tr(),
|
||||
);
|
||||
await tester.pumpUntilNotFound(renameSuccess);
|
||||
|
||||
// check the space name is updated
|
||||
await openSpaceMenu(tester);
|
||||
final renameSpaceItem = find.descendant(
|
||||
of: find.byType(MobileSpaceMenuItem),
|
||||
matching: find.text(renameSpaceName),
|
||||
);
|
||||
expect(renameSpaceItem, findsOneWidget);
|
||||
|
||||
// 3. manage the space
|
||||
await openSpaceMenuMoreOptions(tester, space);
|
||||
|
||||
final manageOption = find.text(LocaleKeys.space_manage.tr());
|
||||
await tester.tapButton(manageOption);
|
||||
await tester.pumpUntilFound(find.byType(ManageSpaceWidget));
|
||||
|
||||
// 3.1 rename the space
|
||||
final textField = find.descendant(
|
||||
of: find.byType(ManageSpaceWidget),
|
||||
matching: find.byType(TextField),
|
||||
);
|
||||
await tester.enterText(textField, 'AppFlowy');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 3.2 change the permission
|
||||
final permissionOption2 = find.byType(ManageSpacePermissionOption);
|
||||
await tester.tapButton(permissionOption2);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final publicOption = find.text(LocaleKeys.space_publicPermission.tr());
|
||||
await tester.tapButton(publicOption);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 3.3 change the icon
|
||||
// change the space icon color
|
||||
final color2 = builtInSpaceColors[2];
|
||||
final iconOption2 = find.descendant(
|
||||
of: find.byType(ManageSpaceIconOption),
|
||||
matching: find.byWidgetPredicate(
|
||||
(w) => w is SpaceColorItem && w.color == color2,
|
||||
),
|
||||
);
|
||||
await tester.tapButton(iconOption2);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// change the space icon
|
||||
final icon2 = kIconGroups![0].icons[2];
|
||||
final iconItem2 = find.descendant(
|
||||
of: find.byType(ManageSpaceIconOption),
|
||||
matching: find.byWidgetPredicate(
|
||||
(w) => w is SpaceIconItem && w.icon == icon2,
|
||||
),
|
||||
);
|
||||
await tester.tapButton(iconItem2);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// click the done button
|
||||
final doneButton2 = find.descendant(
|
||||
of: find.byWidgetPredicate(
|
||||
(w) =>
|
||||
w is BottomSheetHeader &&
|
||||
w.title == LocaleKeys.space_manageSpace.tr(),
|
||||
),
|
||||
matching: find.text(LocaleKeys.button_done.tr()),
|
||||
);
|
||||
await tester.tapButton(doneButton2);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// check the space is updated
|
||||
final spaceItems2 = find.byType(MobileSpaceMenuItem);
|
||||
final spaceWidget2 =
|
||||
tester.widgetList<MobileSpaceMenuItem>(spaceItems2).last;
|
||||
final space2 = spaceWidget2.space;
|
||||
expect(space2.name, 'AppFlowy');
|
||||
expect(space2.spacePermission, SpacePermission.publicToAll);
|
||||
expect(space2.spaceIcon, icon2.iconPath);
|
||||
expect(space2.spaceIconColor, color2);
|
||||
final manageSuccess = find.text(
|
||||
LocaleKeys.space_success_updateSpace.tr(),
|
||||
);
|
||||
await tester.pumpUntilNotFound(manageSuccess);
|
||||
|
||||
// 4. duplicate the space
|
||||
await openSpaceMenuMoreOptions(tester, space);
|
||||
final duplicateOption = find.text(LocaleKeys.space_duplicate.tr());
|
||||
await tester.tapButton(duplicateOption);
|
||||
final duplicateSuccess = find.text(
|
||||
LocaleKeys.space_success_duplicateSpace.tr(),
|
||||
);
|
||||
await tester.pumpUntilNotFound(duplicateSuccess);
|
||||
|
||||
// check the space is duplicated
|
||||
await openSpaceMenu(tester);
|
||||
final spaceItems3 = find.byType(MobileSpaceMenuItem);
|
||||
expect(spaceItems3, findsNWidgets(4));
|
||||
|
||||
// 5. delete the space
|
||||
await openSpaceMenuMoreOptions(tester, space);
|
||||
final deleteOption = find.text(LocaleKeys.button_delete.tr());
|
||||
await tester.tapButton(deleteOption);
|
||||
final confirmDeleteButton = find.descendant(
|
||||
of: find.byType(CupertinoDialogAction),
|
||||
matching: find.text(LocaleKeys.button_delete.tr()),
|
||||
);
|
||||
await tester.tapButton(confirmDeleteButton);
|
||||
final deleteSuccess = find.text(
|
||||
LocaleKeys.space_success_deleteSpace.tr(),
|
||||
);
|
||||
await tester.pumpUntilNotFound(deleteSuccess);
|
||||
|
||||
// check the space is deleted
|
||||
final spaceItems4 = find.byType(MobileSpaceMenuItem);
|
||||
expect(spaceItems4, findsNWidgets(3));
|
||||
});
|
||||
});
|
||||
}
|
||||
-26
@@ -1,37 +1,11 @@
|
||||
// ignore_for_file: unused_import
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/home.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_layout.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
|
||||
import 'package:appflowy/workspace/application/settings/prelude.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/uuid.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../../../shared/constants.dart';
|
||||
import '../../../shared/dir.dart';
|
||||
import '../../../shared/mock/mock_file_picker.dart';
|
||||
import '../../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import 'page_style_test.dart' as page_style_test;
|
||||
import 'plus_menu_test.dart' as plus_menu_test;
|
||||
import 'title_test.dart' as title_test;
|
||||
|
||||
void main() {
|
||||
@@ -9,4 +10,5 @@ void main() {
|
||||
// Document integration tests
|
||||
title_test.main();
|
||||
page_style_test.main();
|
||||
plus_menu_test.main();
|
||||
}
|
||||
@@ -1,42 +1,19 @@
|
||||
// ignore_for_file: unused_import
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/home.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_layout.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
|
||||
import 'package:appflowy/workspace/application/settings/prelude.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/uuid.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../../shared/dir.dart';
|
||||
import '../../shared/mock/mock_file_picker.dart';
|
||||
import '../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('document page style', () {
|
||||
group('document page style:', () {
|
||||
double getCurrentEditorFontSize() {
|
||||
final editorPage = find
|
||||
.byType(AppFlowyEditorPage)
|
||||
@@ -57,11 +34,9 @@ void main() {
|
||||
.single
|
||||
.widget as AppFlowyEditorPage;
|
||||
return editorPage.styleCustomizer
|
||||
.style()
|
||||
.textStyleConfiguration
|
||||
.text
|
||||
.height ??
|
||||
PageStyleLineHeightLayout.normal.lineHeight;
|
||||
.style()
|
||||
.textStyleConfiguration
|
||||
.lineHeight;
|
||||
}
|
||||
|
||||
testWidgets('change font size in page style settings', (tester) async {
|
||||
@@ -87,20 +62,24 @@ void main() {
|
||||
await tester.openPage(gettingStarted);
|
||||
// click the layout button
|
||||
await tester.tapButton(find.byType(MobileViewPageLayoutButton));
|
||||
var lineHeight = getCurrentEditorLineHeight();
|
||||
expect(
|
||||
getCurrentEditorLineHeight(),
|
||||
lineHeight,
|
||||
PageStyleLineHeightLayout.normal.lineHeight,
|
||||
);
|
||||
// change line height from normal to large
|
||||
await tester.tapSvgButton(FlowySvgs.m_layout_large_s);
|
||||
await tester.pumpAndSettle();
|
||||
lineHeight = getCurrentEditorLineHeight();
|
||||
expect(
|
||||
getCurrentEditorLineHeight(),
|
||||
lineHeight,
|
||||
PageStyleLineHeightLayout.large.lineHeight,
|
||||
);
|
||||
// change line height from large to small
|
||||
await tester.tapSvgButton(FlowySvgs.m_layout_small_s);
|
||||
lineHeight = getCurrentEditorLineHeight();
|
||||
expect(
|
||||
getCurrentEditorLineHeight(),
|
||||
lineHeight,
|
||||
PageStyleLineHeightLayout.small.lineHeight,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import '../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('document plus menu:', () {
|
||||
testWidgets('add the toggle heading blocks via plus menu', (tester) async {
|
||||
await tester.launchInAnonymousMode();
|
||||
await tester.createNewDocumentOnMobile('toggle heading blocks');
|
||||
|
||||
final editorState = tester.editor.getCurrentEditorState();
|
||||
// focus on the editor
|
||||
unawaited(
|
||||
editorState.updateSelectionWithReason(
|
||||
Selection.collapsed(Position(path: [0])),
|
||||
reason: SelectionUpdateReason.uiEvent,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// open the plus menu and select the toggle heading block
|
||||
await tester.openPlusMenuAndClickButton(
|
||||
LocaleKeys.document_slashMenu_name_toggleHeading1.tr(),
|
||||
);
|
||||
|
||||
// check the block is inserted
|
||||
final block1 = editorState.getNodeAtPath([0])!;
|
||||
expect(block1.type, equals(ToggleListBlockKeys.type));
|
||||
expect(block1.attributes[ToggleListBlockKeys.level], equals(1));
|
||||
|
||||
// click the expand button won't cancel the selection
|
||||
await tester.tapButton(find.byIcon(Icons.arrow_right));
|
||||
expect(
|
||||
editorState.selection,
|
||||
equals(Selection.collapsed(Position(path: [0]))),
|
||||
);
|
||||
|
||||
// focus on the next line
|
||||
unawaited(
|
||||
editorState.updateSelectionWithReason(
|
||||
Selection.collapsed(Position(path: [1])),
|
||||
reason: SelectionUpdateReason.uiEvent,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// open the plus menu and select the toggle heading block
|
||||
await tester.openPlusMenuAndClickButton(
|
||||
LocaleKeys.document_slashMenu_name_toggleHeading2.tr(),
|
||||
);
|
||||
|
||||
// check the block is inserted
|
||||
final block2 = editorState.getNodeAtPath([1])!;
|
||||
expect(block2.type, equals(ToggleListBlockKeys.type));
|
||||
expect(block2.attributes[ToggleListBlockKeys.level], equals(2));
|
||||
|
||||
// focus on the next line
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// open the plus menu and select the toggle heading block
|
||||
await tester.openPlusMenuAndClickButton(
|
||||
LocaleKeys.document_slashMenu_name_toggleHeading3.tr(),
|
||||
);
|
||||
|
||||
// check the block is inserted
|
||||
final block3 = editorState.getNodeAtPath([2])!;
|
||||
expect(block3.type, equals(ToggleListBlockKeys.type));
|
||||
expect(block3.attributes[ToggleListBlockKeys.level], equals(3));
|
||||
|
||||
// wait a few milliseconds to ensure the selection is updated
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
// check the selection is collapsed
|
||||
expect(
|
||||
editorState.selection,
|
||||
equals(Selection.collapsed(Position(path: [2]))),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,39 +1,10 @@
|
||||
// ignore_for_file: unused_import
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/home.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/section_folder/mobile_home_section_folder_header.dart';
|
||||
import 'package:appflowy/mobile/presentation/presentation.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_layout.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
|
||||
import 'package:appflowy/workspace/application/settings/prelude.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.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/uuid.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../../shared/dir.dart';
|
||||
import '../../shared/mock/mock_file_picker.dart';
|
||||
import '../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
|
||||
+4
-26
@@ -1,47 +1,25 @@
|
||||
// ignore_for_file: unused_import
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/home.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/section_folder/mobile_home_section_folder_header.dart';
|
||||
import 'package:appflowy/mobile/presentation/presentation.dart';
|
||||
import 'package:appflowy/plugins/document/document_page.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
|
||||
import 'package:appflowy/workspace/application/settings/prelude.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/uuid.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../../shared/dir.dart';
|
||||
import '../../shared/mock/mock_file_picker.dart';
|
||||
import '../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('create new page', () {
|
||||
group('create new page in home page:', () {
|
||||
testWidgets('create document', (tester) async {
|
||||
await tester.initializeAppFlowy(
|
||||
cloudType: AuthenticatorType.local,
|
||||
);
|
||||
await tester.launchInAnonymousMode();
|
||||
|
||||
// tap the create page button
|
||||
final createPageButton = find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is FlowySvg &&
|
||||
widget.svg.path == FlowySvgs.m_home_unselected_m.path,
|
||||
widget.svg.path == FlowySvgs.m_home_add_m.path,
|
||||
);
|
||||
await tester.tapButton(createPageButton);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(MobileDocumentScreen), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
+2
-20
@@ -1,33 +1,15 @@
|
||||
// ignore_for_file: unused_import
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/home.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
|
||||
import 'package:appflowy/workspace/application/settings/prelude.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/uuid.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../../shared/dir.dart';
|
||||
import '../../shared/mock/mock_file_picker.dart';
|
||||
import '../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('anonymous sign in on mobile', () {
|
||||
group('anonymous sign in on mobile:', () {
|
||||
testWidgets('anon user and then sign in', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.launchInAnonymousMode();
|
||||
|
||||
// expect to see the home page
|
||||
expect(find.byType(MobileHomeScreen), findsOneWidget);
|
||||
|
||||
+8
-3
@@ -1,15 +1,20 @@
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import 'mobile/document/page_style_test.dart' as page_style_test;
|
||||
import 'mobile/document/document_test_runner.dart' as document_test_runner;
|
||||
import 'mobile/home_page/create_new_page_test.dart' as create_new_page_test;
|
||||
import 'mobile/sign_in/anonymous_sign_in_test.dart' as anonymous_sign_in_test;
|
||||
|
||||
Future<void> runIntegrationOnMobile() async {
|
||||
Future<void> main() async {
|
||||
Log.shared.disableLog = true;
|
||||
|
||||
await runIntegration1OnMobile();
|
||||
}
|
||||
|
||||
Future<void> runIntegration1OnMobile() async {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
anonymous_sign_in_test.main();
|
||||
create_new_page_test.main();
|
||||
page_style_test.main();
|
||||
document_test_runner.main();
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import 'desktop_runner_5.dart';
|
||||
import 'desktop_runner_6.dart';
|
||||
import 'desktop_runner_7.dart';
|
||||
import 'desktop_runner_8.dart';
|
||||
import 'mobile_runner.dart';
|
||||
import 'desktop_runner_9.dart';
|
||||
import 'mobile_runner_1.dart';
|
||||
|
||||
/// The main task runner for all integration tests in AppFlowy.
|
||||
///
|
||||
@@ -27,8 +28,9 @@ Future<void> main() async {
|
||||
await runIntegration6OnDesktop();
|
||||
await runIntegration7OnDesktop();
|
||||
await runIntegration8OnDesktop();
|
||||
await runIntegration9OnDesktop();
|
||||
} else if (Platform.isIOS || Platform.isAndroid) {
|
||||
await runIntegrationOnMobile();
|
||||
await runIntegration1OnMobile();
|
||||
} else {
|
||||
throw Exception('Unsupported platform');
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import 'package:appflowy/core/config/kv.dart';
|
||||
import 'package:appflowy/core/config/kv_keys.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/type_option_menu_item.dart';
|
||||
import 'package:appflowy/mobile/presentation/presentation.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart';
|
||||
import 'package:appflowy/plugins/shared/share/share_button.dart';
|
||||
import 'package:appflowy/shared/feature_flags.dart';
|
||||
import 'package:appflowy/shared/text_field/text_filed_with_metric_lines.dart';
|
||||
@@ -42,6 +44,7 @@ import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import 'emoji.dart';
|
||||
import 'util.dart';
|
||||
@@ -787,6 +790,57 @@ extension CommonOperations on WidgetTester {
|
||||
await tap(finder);
|
||||
await pumpAndSettle(const Duration(seconds: 2));
|
||||
}
|
||||
|
||||
/// Create a new document on mobile
|
||||
Future<void> createNewDocumentOnMobile(String name) async {
|
||||
final createPageButton = find.byKey(
|
||||
BottomNavigationBarItemType.add.valueKey,
|
||||
);
|
||||
await tapButton(createPageButton);
|
||||
expect(find.byType(MobileDocumentScreen), findsOneWidget);
|
||||
|
||||
final title = editor.findDocumentTitle('');
|
||||
expect(title, findsOneWidget);
|
||||
final textField = widget<TextField>(title);
|
||||
expect(textField.focusNode!.hasFocus, isTrue);
|
||||
|
||||
// input new name and press done button
|
||||
await enterText(title, name);
|
||||
await testTextInput.receiveAction(TextInputAction.done);
|
||||
await pumpAndSettle();
|
||||
final newTitle = editor.findDocumentTitle(name);
|
||||
expect(newTitle, findsOneWidget);
|
||||
expect(textField.controller!.text, name);
|
||||
}
|
||||
|
||||
/// Open the plus menu
|
||||
Future<void> openPlusMenuAndClickButton(String buttonName) async {
|
||||
assert(
|
||||
UniversalPlatform.isMobile,
|
||||
'This method is only supported on mobile platforms',
|
||||
);
|
||||
|
||||
final plusMenuButton = find.byKey(addBlockToolbarItemKey);
|
||||
final addMenuItem = find.byType(AddBlockMenu);
|
||||
await tapButton(plusMenuButton);
|
||||
await pumpUntilFound(addMenuItem);
|
||||
|
||||
final toggleHeading1 = find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is TypeOptionMenuItem && widget.value.text == buttonName,
|
||||
);
|
||||
final scrollable = find.ancestor(
|
||||
of: find.byType(TypeOptionGridView),
|
||||
matching: find.byType(Scrollable),
|
||||
);
|
||||
await scrollUntilVisible(
|
||||
toggleHeading1,
|
||||
100,
|
||||
scrollable: scrollable,
|
||||
);
|
||||
await tapButton(toggleHeading1);
|
||||
await pumpUntilNotFound(addMenuItem);
|
||||
}
|
||||
}
|
||||
|
||||
extension SettingsFinder on CommonFinders {
|
||||
|
||||
@@ -40,9 +40,13 @@ extension AppFlowyWorkspace on WidgetTester {
|
||||
moreButton,
|
||||
onHover: () async {
|
||||
await tapButton(moreButton);
|
||||
await tapButton(
|
||||
find.findTextInFlowyText(LocaleKeys.button_rename.tr()),
|
||||
// wait for the menu to open
|
||||
final renameButton = find.findTextInFlowyText(
|
||||
LocaleKeys.button_rename.tr(),
|
||||
);
|
||||
await pumpUntilFound(renameButton);
|
||||
expect(renameButton, findsOneWidget);
|
||||
await tapButton(renameButton);
|
||||
final input = find.byType(TextFormField);
|
||||
expect(input, findsOneWidget);
|
||||
await enterText(input, name);
|
||||
|
||||
@@ -50,6 +50,8 @@
|
||||
<string>AppFlowy needs access to your photos to let you add images to your documents</string>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>AppFlowy needs access to your camera to let you add images to your documents from camera</string>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
@@ -65,9 +67,9 @@
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
<key>UISupportsDocumentBrowser</key>
|
||||
<true/>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -49,6 +49,7 @@ class KVKeys {
|
||||
/// {'SidebarFolderCategoryType.value': true}
|
||||
static const String expandedFolders = 'expandedFolders';
|
||||
|
||||
/// @deprecated in version 0.7.6
|
||||
/// The key for saving if showing the rename dialog when creating a new file
|
||||
///
|
||||
/// The value is a boolean string.
|
||||
|
||||
@@ -65,7 +65,10 @@ class _MobileViewPageState extends State<MobileViewPage> {
|
||||
@override
|
||||
void dispose() {
|
||||
_appBarOpacity.dispose();
|
||||
_scrollNotificationObserver?.removeListener(_onScrollNotification);
|
||||
|
||||
// there's no need to remove the listener, because the observer will be disposed when the widget is unmounted.
|
||||
// inside the observer, the listener will be removed automatically.
|
||||
// _scrollNotificationObserver?.removeListener(_onScrollNotification);
|
||||
_scrollNotificationObserver = null;
|
||||
|
||||
super.dispose();
|
||||
|
||||
@@ -9,12 +9,14 @@ class TypeOptionMenuItemValue<T> {
|
||||
required this.text,
|
||||
required this.backgroundColor,
|
||||
required this.onTap,
|
||||
this.iconPadding,
|
||||
});
|
||||
|
||||
final T value;
|
||||
final FlowySvgData icon;
|
||||
final String text;
|
||||
final Color backgroundColor;
|
||||
final EdgeInsets? iconPadding;
|
||||
final void Function(BuildContext context, T value) onTap;
|
||||
}
|
||||
|
||||
@@ -22,7 +24,7 @@ class TypeOptionMenu<T> extends StatelessWidget {
|
||||
const TypeOptionMenu({
|
||||
super.key,
|
||||
required this.values,
|
||||
this.width = 94,
|
||||
this.width = 98,
|
||||
this.iconWidth = 72,
|
||||
this.scaleFactor = 1.0,
|
||||
this.maxAxisSpacing = 18,
|
||||
@@ -39,17 +41,18 @@ class TypeOptionMenu<T> extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _GridView(
|
||||
return TypeOptionGridView(
|
||||
crossAxisCount: crossAxisCount,
|
||||
mainAxisSpacing: maxAxisSpacing * scaleFactor,
|
||||
itemWidth: width * scaleFactor,
|
||||
children: values
|
||||
.map(
|
||||
(value) => _TypeOptionMenuItem<T>(
|
||||
(value) => TypeOptionMenuItem<T>(
|
||||
value: value,
|
||||
width: width,
|
||||
iconWidth: iconWidth,
|
||||
scaleFactor: scaleFactor,
|
||||
iconPadding: value.iconPadding,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
@@ -57,18 +60,21 @@ class TypeOptionMenu<T> extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _TypeOptionMenuItem<T> extends StatelessWidget {
|
||||
const _TypeOptionMenuItem({
|
||||
class TypeOptionMenuItem<T> extends StatelessWidget {
|
||||
const TypeOptionMenuItem({
|
||||
super.key,
|
||||
required this.value,
|
||||
this.width = 94,
|
||||
this.iconWidth = 72,
|
||||
this.scaleFactor = 1.0,
|
||||
this.iconPadding,
|
||||
});
|
||||
|
||||
final TypeOptionMenuItemValue<T> value;
|
||||
final double iconWidth;
|
||||
final double width;
|
||||
final double scaleFactor;
|
||||
final EdgeInsets? iconPadding;
|
||||
|
||||
double get scaledIconWidth => iconWidth * scaleFactor;
|
||||
double get scaledWidth => width * scaleFactor;
|
||||
@@ -88,7 +94,8 @@ class _TypeOptionMenuItem<T> extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(24 * scaleFactor),
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.all(21 * scaleFactor),
|
||||
padding: EdgeInsets.all(21 * scaleFactor) +
|
||||
(iconPadding ?? EdgeInsets.zero),
|
||||
child: FlowySvg(
|
||||
value.icon,
|
||||
),
|
||||
@@ -113,8 +120,9 @@ class _TypeOptionMenuItem<T> extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _GridView extends StatelessWidget {
|
||||
const _GridView({
|
||||
class TypeOptionGridView extends StatelessWidget {
|
||||
const TypeOptionGridView({
|
||||
super.key,
|
||||
required this.children,
|
||||
required this.crossAxisCount,
|
||||
required this.mainAxisSpacing,
|
||||
|
||||
+229
-75
@@ -3,17 +3,24 @@ import 'dart:async';
|
||||
import 'package:appflowy/core/helpers/url_launcher.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/workspaces/create_workspace_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_notification.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
||||
import 'package:appflowy/plugins/shared/share/constants.dart';
|
||||
import 'package:appflowy/plugins/shared/share/publish_name_generator.dart';
|
||||
import 'package:appflowy/plugins/shared/share/share_bloc.dart';
|
||||
import 'package:appflowy/shared/error_code/error_code_map.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/util/string_extension.dart';
|
||||
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/prelude.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -23,83 +30,97 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
|
||||
const MobileViewPageMoreBottomSheet({super.key, required this.view});
|
||||
|
||||
final ViewPB view;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<ViewBloc, ViewState>(
|
||||
listener: (context, state) {
|
||||
if (state.successOrFailure.isSuccess && state.isDeleted) {
|
||||
context.go('/home');
|
||||
}
|
||||
},
|
||||
child: ViewPageBottomSheet(
|
||||
view: view,
|
||||
onAction: (action) async {
|
||||
switch (action) {
|
||||
case MobileViewBottomSheetBodyAction.duplicate:
|
||||
context.read<ViewBloc>().add(const ViewEvent.duplicate());
|
||||
context.pop();
|
||||
break;
|
||||
case MobileViewBottomSheetBodyAction.delete:
|
||||
context.read<ViewBloc>().add(const ViewEvent.delete());
|
||||
context.pop();
|
||||
break;
|
||||
case MobileViewBottomSheetBodyAction.addToFavorites:
|
||||
case MobileViewBottomSheetBodyAction.removeFromFavorites:
|
||||
context.read<FavoriteBloc>().add(FavoriteEvent.toggle(view));
|
||||
context.pop();
|
||||
break;
|
||||
case MobileViewBottomSheetBodyAction.undo:
|
||||
EditorNotification.undo().post();
|
||||
context.pop();
|
||||
break;
|
||||
case MobileViewBottomSheetBodyAction.redo:
|
||||
EditorNotification.redo().post();
|
||||
context.pop();
|
||||
break;
|
||||
case MobileViewBottomSheetBodyAction.helpCenter:
|
||||
// unimplemented
|
||||
context.pop();
|
||||
break;
|
||||
case MobileViewBottomSheetBodyAction.publish:
|
||||
await _publish(context);
|
||||
if (context.mounted) {
|
||||
context.pop();
|
||||
}
|
||||
break;
|
||||
case MobileViewBottomSheetBodyAction.unpublish:
|
||||
_unpublish(context);
|
||||
context.pop();
|
||||
break;
|
||||
case MobileViewBottomSheetBodyAction.copyPublishLink:
|
||||
_copyPublishLink(context);
|
||||
context.pop();
|
||||
break;
|
||||
case MobileViewBottomSheetBodyAction.visitSite:
|
||||
_visitPublishedSite(context);
|
||||
context.pop();
|
||||
break;
|
||||
case MobileViewBottomSheetBodyAction.copyShareLink:
|
||||
_copyShareLink(context);
|
||||
context.pop();
|
||||
break;
|
||||
case MobileViewBottomSheetBodyAction.rename:
|
||||
// no need to implement, rename is handled by the onRename callback.
|
||||
throw UnimplementedError();
|
||||
return BlocListener<ShareBloc, ShareState>(
|
||||
listener: (context, state) => _showToast(context, state),
|
||||
child: BlocListener<ViewBloc, ViewState>(
|
||||
listener: (context, state) {
|
||||
if (state.successOrFailure.isSuccess && state.isDeleted) {
|
||||
context.go('/home');
|
||||
}
|
||||
},
|
||||
onRename: (name) {
|
||||
_onRename(context, name);
|
||||
context.pop();
|
||||
},
|
||||
child: ViewPageBottomSheet(
|
||||
view: view,
|
||||
onAction: (action) async => _onAction(context, action),
|
||||
onRename: (name) {
|
||||
_onRename(context, name);
|
||||
context.pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onAction(
|
||||
BuildContext context,
|
||||
MobileViewBottomSheetBodyAction action,
|
||||
) async {
|
||||
switch (action) {
|
||||
case MobileViewBottomSheetBodyAction.duplicate:
|
||||
_duplicate(context);
|
||||
break;
|
||||
case MobileViewBottomSheetBodyAction.delete:
|
||||
context.read<ViewBloc>().add(const ViewEvent.delete());
|
||||
context.pop();
|
||||
break;
|
||||
case MobileViewBottomSheetBodyAction.addToFavorites:
|
||||
_addFavorite(context);
|
||||
break;
|
||||
case MobileViewBottomSheetBodyAction.removeFromFavorites:
|
||||
_removeFavorite(context);
|
||||
break;
|
||||
case MobileViewBottomSheetBodyAction.undo:
|
||||
EditorNotification.undo().post();
|
||||
context.pop();
|
||||
break;
|
||||
case MobileViewBottomSheetBodyAction.redo:
|
||||
EditorNotification.redo().post();
|
||||
context.pop();
|
||||
break;
|
||||
case MobileViewBottomSheetBodyAction.helpCenter:
|
||||
// unimplemented
|
||||
context.pop();
|
||||
break;
|
||||
case MobileViewBottomSheetBodyAction.publish:
|
||||
await _publish(context);
|
||||
if (context.mounted) {
|
||||
context.pop();
|
||||
}
|
||||
break;
|
||||
case MobileViewBottomSheetBodyAction.unpublish:
|
||||
_unpublish(context);
|
||||
context.pop();
|
||||
break;
|
||||
case MobileViewBottomSheetBodyAction.copyPublishLink:
|
||||
_copyPublishLink(context);
|
||||
context.pop();
|
||||
break;
|
||||
case MobileViewBottomSheetBodyAction.visitSite:
|
||||
_visitPublishedSite(context);
|
||||
context.pop();
|
||||
break;
|
||||
case MobileViewBottomSheetBodyAction.copyShareLink:
|
||||
_copyShareLink(context);
|
||||
context.pop();
|
||||
break;
|
||||
case MobileViewBottomSheetBodyAction.updatePathName:
|
||||
_updatePathName(context);
|
||||
case MobileViewBottomSheetBodyAction.rename:
|
||||
// no need to implement, rename is handled by the onRename callback.
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _publish(BuildContext context) async {
|
||||
final id = context.read<ShareBloc>().view.id;
|
||||
final publishName = await generatePublishName(
|
||||
id,
|
||||
view.name,
|
||||
final lastPublishName = context.read<ShareBloc>().state.pathName;
|
||||
final publishName = lastPublishName.orDefault(
|
||||
await generatePublishName(
|
||||
id,
|
||||
view.name,
|
||||
),
|
||||
);
|
||||
if (context.mounted) {
|
||||
context.read<ShareBloc>().add(
|
||||
@@ -109,19 +130,44 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
|
||||
[view.id],
|
||||
),
|
||||
);
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.publish_publishSuccessfully.tr(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _duplicate(BuildContext context) {
|
||||
context.read<ViewBloc>().add(const ViewEvent.duplicate());
|
||||
context.pop();
|
||||
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.button_duplicateSuccessfully.tr(),
|
||||
);
|
||||
}
|
||||
|
||||
void _addFavorite(BuildContext context) {
|
||||
_toggleFavorite(context);
|
||||
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.button_favoriteSuccessfully.tr(),
|
||||
);
|
||||
}
|
||||
|
||||
void _removeFavorite(BuildContext context) {
|
||||
_toggleFavorite(context);
|
||||
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.button_unfavoriteSuccessfully.tr(),
|
||||
);
|
||||
}
|
||||
|
||||
void _toggleFavorite(BuildContext context) {
|
||||
context.read<FavoriteBloc>().add(FavoriteEvent.toggle(view));
|
||||
context.pop();
|
||||
}
|
||||
|
||||
void _unpublish(BuildContext context) {
|
||||
context.read<ShareBloc>().add(const ShareEvent.unPublish());
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.publish_unpublishSuccessfully.tr(),
|
||||
);
|
||||
}
|
||||
|
||||
void _copyPublishLink(BuildContext context) {
|
||||
@@ -182,4 +228,112 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
|
||||
context.read<ViewBloc>().add(ViewEvent.rename(name));
|
||||
}
|
||||
}
|
||||
|
||||
void _updatePathName(BuildContext context) async {
|
||||
final shareBloc = context.read<ShareBloc>();
|
||||
final pathName = shareBloc.state.pathName;
|
||||
await showMobileBottomSheet(
|
||||
context,
|
||||
showHeader: true,
|
||||
title: LocaleKeys.shareAction_updatePathName.tr(),
|
||||
showCloseButton: true,
|
||||
showDragHandle: true,
|
||||
showDivider: false,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
builder: (bottomSheetContext) {
|
||||
FlowyResult<void, FlowyError>? previousUpdatePathNameResult;
|
||||
return EditWorkspaceNameBottomSheet(
|
||||
type: EditWorkspaceNameType.edit,
|
||||
workspaceName: pathName,
|
||||
hintText: '',
|
||||
validator: (value) => null,
|
||||
validatorBuilder: (context) {
|
||||
return BlocProvider.value(
|
||||
value: shareBloc,
|
||||
child: BlocBuilder<ShareBloc, ShareState>(
|
||||
builder: (context, state) {
|
||||
final updatePathNameResult = state.updatePathNameResult;
|
||||
|
||||
if (updatePathNameResult == null &&
|
||||
previousUpdatePathNameResult == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
if (updatePathNameResult != null) {
|
||||
previousUpdatePathNameResult = updatePathNameResult;
|
||||
}
|
||||
|
||||
final widget = previousUpdatePathNameResult?.fold(
|
||||
(value) => const SizedBox.shrink(),
|
||||
(error) => FlowyText(
|
||||
error.code.publishErrorMessage.orDefault(
|
||||
LocaleKeys.settings_sites_error_updatePathNameFailed
|
||||
.tr(),
|
||||
),
|
||||
maxLines: 3,
|
||||
fontSize: 12,
|
||||
textAlign: TextAlign.left,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
) ??
|
||||
const SizedBox.shrink();
|
||||
|
||||
return widget;
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
onSubmitted: (name) {
|
||||
// rename the path name
|
||||
Log.info('rename the path name, from: $pathName, to: $name');
|
||||
|
||||
shareBloc.add(ShareEvent.updatePathName(name));
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
shareBloc.add(const ShareEvent.clearPathNameResult());
|
||||
}
|
||||
|
||||
void _showToast(BuildContext context, ShareState state) {
|
||||
if (state.publishResult != null) {
|
||||
state.publishResult!.fold(
|
||||
(value) => showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.publish_publishSuccessfully.tr(),
|
||||
),
|
||||
(error) => showToastNotification(
|
||||
context,
|
||||
message: '${LocaleKeys.publish_publishFailed.tr()}: ${error.code}',
|
||||
type: ToastificationType.error,
|
||||
),
|
||||
);
|
||||
} else if (state.unpublishResult != null) {
|
||||
state.unpublishResult!.fold(
|
||||
(value) => showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.publish_unpublishSuccessfully.tr(),
|
||||
),
|
||||
(error) => showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.publish_unpublishFailed.tr(),
|
||||
description: error.msg,
|
||||
type: ToastificationType.error,
|
||||
),
|
||||
);
|
||||
} else if (state.updatePathNameResult != null) {
|
||||
state.updatePathNameResult!.onSuccess(
|
||||
(value) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message:
|
||||
LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(),
|
||||
);
|
||||
|
||||
context.pop();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -24,6 +24,7 @@ enum MobileViewBottomSheetBodyAction {
|
||||
copyPublishLink,
|
||||
visitSite,
|
||||
copyShareLink,
|
||||
updatePathName,
|
||||
}
|
||||
|
||||
typedef MobileViewBottomSheetBodyActionCallback = void Function(
|
||||
@@ -164,6 +165,15 @@ class MobileViewBottomSheetBody extends StatelessWidget {
|
||||
final isPublished = context.watch<ShareBloc>().state.isPublished;
|
||||
if (isPublished) {
|
||||
return [
|
||||
MobileQuickActionButton(
|
||||
text: LocaleKeys.shareAction_updatePathName.tr(),
|
||||
icon: FlowySvgs.view_item_rename_s,
|
||||
iconSize: const Size.square(18),
|
||||
onTap: () => onAction(
|
||||
MobileViewBottomSheetBodyAction.updatePathName,
|
||||
),
|
||||
),
|
||||
_divider(),
|
||||
MobileQuickActionButton(
|
||||
text: LocaleKeys.shareAction_visitSite.tr(),
|
||||
icon: FlowySvgs.m_visit_site_s,
|
||||
|
||||
+11
-1
@@ -47,6 +47,7 @@ Future<T?> showMobileBottomSheet<T>(
|
||||
Color? barrierColor,
|
||||
double? elevation,
|
||||
bool showDoneButton = false,
|
||||
void Function(BuildContext context)? onDone,
|
||||
bool enableDraggableScrollable = false,
|
||||
bool enableScrollable = false,
|
||||
// this field is only used if showDragHandle is true
|
||||
@@ -112,6 +113,7 @@ Future<T?> showMobileBottomSheet<T>(
|
||||
showRemoveButton: showRemoveButton,
|
||||
title: title,
|
||||
onRemove: onRemove,
|
||||
onDone: onDone,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -208,6 +210,7 @@ class BottomSheetHeader extends StatelessWidget {
|
||||
required this.title,
|
||||
required this.showDoneButton,
|
||||
this.onRemove,
|
||||
this.onDone,
|
||||
});
|
||||
|
||||
final bool showBackButton;
|
||||
@@ -216,6 +219,7 @@ class BottomSheetHeader extends StatelessWidget {
|
||||
final String title;
|
||||
final bool showDoneButton;
|
||||
final VoidCallback? onRemove;
|
||||
final void Function(BuildContext context)? onDone;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -257,7 +261,13 @@ class BottomSheetHeader extends StatelessWidget {
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: BottomSheetDoneButton(
|
||||
onDone: () => Navigator.pop(context),
|
||||
onDone: () {
|
||||
if (onDone != null) {
|
||||
onDone?.call(context);
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
+4
-2
@@ -9,10 +9,10 @@ import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/mobile/presentation/database/card/card_detail/widgets/widgets.dart';
|
||||
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
|
||||
import 'package:appflowy/plugins/base/drag_handler.dart';
|
||||
import 'package:appflowy/plugins/database/domain/field_service.dart';
|
||||
import 'package:appflowy/plugins/database/application/field/type_option/number_format_bloc.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/field/type_option_editor/date/date_time_format.dart';
|
||||
import 'package:appflowy/plugins/database/domain/field_service.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/field/type_option_editor/date/date_time_format.dart';
|
||||
import 'package:appflowy/util/field_type_extension.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
@@ -863,6 +863,8 @@ class _SelectOptionListState extends State<_SelectOptionList> {
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
// disable the inner scroll physics, so the outer ListView can scroll
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: widget.selectOptions
|
||||
.mapIndexed(
|
||||
(index, option) => _SelectOptionTile(
|
||||
|
||||
@@ -279,16 +279,10 @@ class _HomePageState extends State<_HomePage> {
|
||||
ToastificationType toastType = ToastificationType.success;
|
||||
switch (actionType) {
|
||||
case UserWorkspaceActionType.open:
|
||||
message = result.fold(
|
||||
(s) {
|
||||
toastType = ToastificationType.success;
|
||||
return LocaleKeys.workspace_openSuccess.tr();
|
||||
},
|
||||
(e) {
|
||||
toastType = ToastificationType.error;
|
||||
return '${LocaleKeys.workspace_openFailed.tr()}: ${e.msg}';
|
||||
},
|
||||
);
|
||||
message = result.onFailure((e) {
|
||||
toastType = ToastificationType.error;
|
||||
return '${LocaleKeys.workspace_openFailed.tr()}: ${e.msg}';
|
||||
});
|
||||
break;
|
||||
case UserWorkspaceActionType.delete:
|
||||
message = result.fold(
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
class SpaceUIConstants {
|
||||
static const itemHeight = 52.0;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:appflowy/shared/icon_emoji_picker/icon.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart' hide Icon;
|
||||
|
||||
import 'widgets.dart';
|
||||
|
||||
enum ManageSpaceType {
|
||||
create,
|
||||
edit,
|
||||
}
|
||||
|
||||
class ManageSpaceWidget extends StatelessWidget {
|
||||
const ManageSpaceWidget({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.permission,
|
||||
required this.selectedColor,
|
||||
required this.selectedIcon,
|
||||
required this.type,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final ValueNotifier<SpacePermission> permission;
|
||||
final ValueNotifier<String> selectedColor;
|
||||
final ValueNotifier<Icon?> selectedIcon;
|
||||
final ManageSpaceType type;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ManageSpaceNameOption(
|
||||
controller: controller,
|
||||
type: type,
|
||||
),
|
||||
ManageSpacePermissionOption(permission: permission),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxHeight: 560,
|
||||
),
|
||||
child: ManageSpaceIconOption(
|
||||
selectedColor: selectedColor,
|
||||
selectedIcon: selectedIcon,
|
||||
),
|
||||
),
|
||||
const VSpace(60),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class MobileSpace extends StatelessWidget {
|
||||
const MobileSpace({super.key});
|
||||
const MobileSpace({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -63,6 +65,8 @@ class MobileSpace extends StatelessWidget {
|
||||
useRootNavigator: true,
|
||||
title: LocaleKeys.space_title.tr(),
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
enableScrollable: true,
|
||||
bottomSheetPadding: context.bottomSheetPadding(),
|
||||
builder: (_) {
|
||||
return BlocProvider.value(
|
||||
value: context.read<SpaceBloc>(),
|
||||
@@ -96,6 +100,7 @@ class MobileSpace extends StatelessWidget {
|
||||
name: layout.defaultName,
|
||||
layout: layout,
|
||||
index: 0,
|
||||
openAfterCreate: true,
|
||||
),
|
||||
);
|
||||
context.read<SpaceBloc>().add(
|
||||
|
||||
+434
-66
@@ -1,50 +1,74 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/animated_gesture.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/space/space_menu_bottom_sheet.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/workspaces/create_workspace_menu.dart';
|
||||
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/icon.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
|
||||
import 'package:appflowy/util/navigator_context_extension.dart';
|
||||
import 'package:appflowy/util/string_extension.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_action_type.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/material.dart' hide Icon;
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'constants.dart';
|
||||
import 'manage_space_widget.dart';
|
||||
|
||||
class MobileSpaceMenu extends StatelessWidget {
|
||||
const MobileSpaceMenu({super.key});
|
||||
const MobileSpaceMenu({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<SpaceBloc, SpaceState>(
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const VSpace(4.0),
|
||||
for (final space in state.spaces)
|
||||
SizedBox(
|
||||
height: 52,
|
||||
child: _SidebarSpaceMenuItem(
|
||||
space: space,
|
||||
isSelected: state.currentSpace?.id == space.id,
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const VSpace(4.0),
|
||||
for (final space in state.spaces)
|
||||
SizedBox(
|
||||
height: SpaceUIConstants.itemHeight,
|
||||
child: MobileSpaceMenuItem(
|
||||
space: space,
|
||||
isSelected: state.currentSpace?.id == space.id,
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Divider(
|
||||
height: 0.5,
|
||||
),
|
||||
),
|
||||
// const Padding(
|
||||
// padding: EdgeInsets.symmetric(vertical: 8.0),
|
||||
// child: Divider(
|
||||
// height: 0.5,
|
||||
// ),
|
||||
// ),
|
||||
// const SizedBox(
|
||||
// height: 52,
|
||||
// child: _CreateSpaceButton(),
|
||||
// ),
|
||||
],
|
||||
const SizedBox(
|
||||
height: SpaceUIConstants.itemHeight,
|
||||
child: _CreateSpaceButton(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SidebarSpaceMenuItem extends StatelessWidget {
|
||||
const _SidebarSpaceMenuItem({
|
||||
class MobileSpaceMenuItem extends StatelessWidget {
|
||||
const MobileSpaceMenuItem({
|
||||
super.key,
|
||||
required this.space,
|
||||
required this.isSelected,
|
||||
});
|
||||
@@ -79,12 +103,11 @@ class _SidebarSpaceMenuItem extends StatelessWidget {
|
||||
cornerRadius: 6.0,
|
||||
),
|
||||
leftIconSize: const Size.square(24),
|
||||
rightIcon: isSelected
|
||||
? const FlowySvg(
|
||||
FlowySvgs.m_blue_check_s,
|
||||
blendMode: null,
|
||||
)
|
||||
: null,
|
||||
rightIcon: SpaceMenuItemTrailing(
|
||||
key: ValueKey('${space.id}_space_menu_item_trailing'),
|
||||
space: space,
|
||||
currentSpace: context.read<SpaceBloc>().state.currentSpace,
|
||||
),
|
||||
onTap: () {
|
||||
context.read<SpaceBloc>().add(SpaceEvent.open(space));
|
||||
Navigator.of(context).pop();
|
||||
@@ -93,40 +116,385 @@ class _SidebarSpaceMenuItem extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// class _CreateSpaceButton extends StatelessWidget {
|
||||
// const _CreateSpaceButton();
|
||||
class _CreateSpaceButton extends StatefulWidget {
|
||||
const _CreateSpaceButton();
|
||||
|
||||
// @override
|
||||
// Widget build(BuildContext context) {
|
||||
// return FlowyButton(
|
||||
// text: FlowyText.regular(LocaleKeys.space_createNewSpace.tr()),
|
||||
// iconPadding: 10,
|
||||
// leftIcon: const FlowySvg(
|
||||
// FlowySvgs.space_add_s,
|
||||
// ),
|
||||
// leftIconSize: const Size.square(20),
|
||||
// onTap: () {
|
||||
// PopoverContainer.of(context).close();
|
||||
// _showCreateSpaceDialog(context);
|
||||
// },
|
||||
// );
|
||||
// }
|
||||
@override
|
||||
State<_CreateSpaceButton> createState() => _CreateSpaceButtonState();
|
||||
}
|
||||
|
||||
// void _showCreateSpaceDialog(BuildContext context) {
|
||||
// final spaceBloc = context.read<SpaceBloc>();
|
||||
// showDialog(
|
||||
// context: context,
|
||||
// builder: (_) {
|
||||
// return Dialog(
|
||||
// shape: RoundedRectangleBorder(
|
||||
// borderRadius: BorderRadius.circular(12.0),
|
||||
// ),
|
||||
// child: BlocProvider.value(
|
||||
// value: spaceBloc,
|
||||
// child: const CreateSpacePopup(),
|
||||
// ),
|
||||
// );
|
||||
// },
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
class _CreateSpaceButtonState extends State<_CreateSpaceButton> {
|
||||
final controller = TextEditingController();
|
||||
final permission = ValueNotifier<SpacePermission>(
|
||||
SpacePermission.publicToAll,
|
||||
);
|
||||
final selectedColor = ValueNotifier<String>(
|
||||
builtInSpaceColors.first,
|
||||
);
|
||||
final selectedIcon = ValueNotifier<Icon?>(
|
||||
kIconGroups?.first.icons.first,
|
||||
);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
permission.dispose();
|
||||
selectedColor.dispose();
|
||||
selectedIcon.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyButton(
|
||||
text: FlowyText.regular(LocaleKeys.space_createNewSpace.tr()),
|
||||
iconPadding: 10,
|
||||
leftIcon: const Padding(
|
||||
padding: EdgeInsets.all(2.0),
|
||||
child: FlowySvg(
|
||||
FlowySvgs.space_add_s,
|
||||
),
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
leftIconSize: const Size.square(24),
|
||||
onTap: () => _showCreateSpaceDialog(context),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showCreateSpaceDialog(BuildContext context) async {
|
||||
await showMobileBottomSheet(
|
||||
context,
|
||||
showHeader: true,
|
||||
title: LocaleKeys.space_createSpace.tr(),
|
||||
showCloseButton: true,
|
||||
showDivider: false,
|
||||
showDoneButton: true,
|
||||
enableScrollable: true,
|
||||
showDragHandle: true,
|
||||
bottomSheetPadding: context.bottomSheetPadding(),
|
||||
onDone: (bottomSheetContext) {
|
||||
final iconPath = selectedIcon.value?.iconPath ?? '';
|
||||
context.read<SpaceBloc>().add(
|
||||
SpaceEvent.create(
|
||||
name: controller.text.orDefault(
|
||||
LocaleKeys.space_defaultSpaceName.tr(),
|
||||
),
|
||||
permission: permission.value,
|
||||
iconColor: selectedColor.value,
|
||||
icon: iconPath,
|
||||
createNewPageByDefault: true,
|
||||
openAfterCreate: false,
|
||||
),
|
||||
);
|
||||
Navigator.pop(bottomSheetContext);
|
||||
Navigator.pop(context);
|
||||
|
||||
Log.info(
|
||||
'create space on mobile, name: ${controller.text}, permission: ${permission.value}, color: ${selectedColor.value}, icon: $iconPath',
|
||||
);
|
||||
},
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
builder: (bottomSheetContext) => ManageSpaceWidget(
|
||||
controller: controller,
|
||||
permission: permission,
|
||||
selectedColor: selectedColor,
|
||||
selectedIcon: selectedIcon,
|
||||
type: ManageSpaceType.create,
|
||||
),
|
||||
);
|
||||
|
||||
_resetState();
|
||||
}
|
||||
|
||||
void _resetState() {
|
||||
controller.clear();
|
||||
permission.value = SpacePermission.publicToAll;
|
||||
selectedColor.value = builtInSpaceColors.first;
|
||||
selectedIcon.value = kIconGroups?.first.icons.first;
|
||||
}
|
||||
}
|
||||
|
||||
class SpaceMenuItemTrailing extends StatefulWidget {
|
||||
const SpaceMenuItemTrailing({
|
||||
super.key,
|
||||
required this.space,
|
||||
this.currentSpace,
|
||||
});
|
||||
|
||||
final ViewPB space;
|
||||
final ViewPB? currentSpace;
|
||||
|
||||
@override
|
||||
State<SpaceMenuItemTrailing> createState() => _SpaceMenuItemTrailingState();
|
||||
}
|
||||
|
||||
class _SpaceMenuItemTrailingState extends State<SpaceMenuItemTrailing> {
|
||||
final controller = TextEditingController();
|
||||
final permission = ValueNotifier<SpacePermission>(
|
||||
SpacePermission.publicToAll,
|
||||
);
|
||||
final selectedColor = ValueNotifier<String>(
|
||||
builtInSpaceColors.first,
|
||||
);
|
||||
final selectedIcon = ValueNotifier<Icon?>(
|
||||
kIconGroups?.first.icons.first,
|
||||
);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
permission.dispose();
|
||||
selectedColor.dispose();
|
||||
selectedIcon.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const iconSize = Size.square(20);
|
||||
return Row(
|
||||
children: [
|
||||
const HSpace(12.0),
|
||||
// show the check icon if the space is the current space
|
||||
if (widget.space.id == widget.currentSpace?.id)
|
||||
const FlowySvg(
|
||||
FlowySvgs.m_blue_check_s,
|
||||
size: iconSize,
|
||||
blendMode: null,
|
||||
),
|
||||
const HSpace(8.0),
|
||||
// more options button
|
||||
AnimatedGestureDetector(
|
||||
onTapUp: () => _showMoreOptions(context),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: FlowySvg(
|
||||
FlowySvgs.workspace_three_dots_s,
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _showMoreOptions(BuildContext context) {
|
||||
final actions = [
|
||||
SpaceMoreActionType.rename,
|
||||
SpaceMoreActionType.duplicate,
|
||||
SpaceMoreActionType.manage,
|
||||
SpaceMoreActionType.delete,
|
||||
];
|
||||
|
||||
showMobileBottomSheet(
|
||||
context,
|
||||
showDragHandle: true,
|
||||
showDivider: false,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
builder: (bottomSheetContext) {
|
||||
return SpaceMenuMoreOptions(
|
||||
actions: actions,
|
||||
onAction: (action) => _onActions(
|
||||
context,
|
||||
bottomSheetContext,
|
||||
action,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onActions(
|
||||
BuildContext context,
|
||||
BuildContext bottomSheetContext,
|
||||
SpaceMoreActionType action,
|
||||
) {
|
||||
Log.info('execute action in space menu bottom sheet: $action');
|
||||
|
||||
switch (action) {
|
||||
case SpaceMoreActionType.rename:
|
||||
_showRenameSpaceBottomSheet(context);
|
||||
break;
|
||||
case SpaceMoreActionType.duplicate:
|
||||
_duplicateSpace(context, bottomSheetContext);
|
||||
break;
|
||||
case SpaceMoreActionType.manage:
|
||||
_showManageSpaceBottomSheet(context);
|
||||
break;
|
||||
case SpaceMoreActionType.delete:
|
||||
_deleteSpace(context, bottomSheetContext);
|
||||
break;
|
||||
default:
|
||||
assert(false, 'Unsupported action: $action');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _duplicateSpace(BuildContext context, BuildContext bottomSheetContext) {
|
||||
Log.info('duplicate the space: ${widget.space.name}');
|
||||
|
||||
context.read<SpaceBloc>().add(const SpaceEvent.duplicate());
|
||||
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.space_success_duplicateSpace.tr(),
|
||||
);
|
||||
|
||||
Navigator.of(bottomSheetContext).pop();
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
void _showRenameSpaceBottomSheet(BuildContext context) {
|
||||
Navigator.of(context).pop();
|
||||
|
||||
showMobileBottomSheet(
|
||||
context,
|
||||
showHeader: true,
|
||||
title: LocaleKeys.space_renameSpace.tr(),
|
||||
showCloseButton: true,
|
||||
showDragHandle: true,
|
||||
showDivider: false,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
builder: (bottomSheetContext) {
|
||||
return EditWorkspaceNameBottomSheet(
|
||||
type: EditWorkspaceNameType.edit,
|
||||
workspaceName: widget.space.name,
|
||||
hintText: LocaleKeys.space_spaceNamePlaceholder.tr(),
|
||||
validator: (value) => null,
|
||||
onSubmitted: (name) {
|
||||
// rename the workspace
|
||||
Log.info('rename the space, from: ${widget.space.name}, to: $name');
|
||||
bottomSheetContext.popToHome();
|
||||
|
||||
context
|
||||
.read<SpaceBloc>()
|
||||
.add(SpaceEvent.rename(space: widget.space, name: name));
|
||||
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.space_success_renameSpace.tr(),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showManageSpaceBottomSheet(BuildContext context) async {
|
||||
controller.text = widget.space.name;
|
||||
permission.value = widget.space.spacePermission;
|
||||
selectedColor.value =
|
||||
widget.space.spaceIconColor ?? builtInSpaceColors.first;
|
||||
selectedIcon.value = widget.space.spaceIcon?.icon;
|
||||
|
||||
await showMobileBottomSheet(
|
||||
context,
|
||||
showHeader: true,
|
||||
title: LocaleKeys.space_manageSpace.tr(),
|
||||
showCloseButton: true,
|
||||
showDivider: false,
|
||||
showDoneButton: true,
|
||||
enableScrollable: true,
|
||||
showDragHandle: true,
|
||||
bottomSheetPadding: context.bottomSheetPadding(),
|
||||
onDone: (bottomSheetContext) {
|
||||
String iconName = '';
|
||||
final icon = selectedIcon.value;
|
||||
final iconGroup = icon?.iconGroup;
|
||||
final iconId = icon?.name;
|
||||
if (icon != null && iconGroup != null) {
|
||||
iconName = '${iconGroup.name}/$iconId';
|
||||
}
|
||||
Log.info(
|
||||
'update space on mobile, name: ${controller.text}, permission: ${permission.value}, color: ${selectedColor.value}, icon: $iconName',
|
||||
);
|
||||
context.read<SpaceBloc>().add(
|
||||
SpaceEvent.update(
|
||||
space: widget.space,
|
||||
name: controller.text.orDefault(
|
||||
LocaleKeys.space_defaultSpaceName.tr(),
|
||||
),
|
||||
permission: permission.value,
|
||||
iconColor: selectedColor.value,
|
||||
icon: iconName,
|
||||
),
|
||||
);
|
||||
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.space_success_updateSpace.tr(),
|
||||
);
|
||||
|
||||
Navigator.pop(bottomSheetContext);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
builder: (bottomSheetContext) => ManageSpaceWidget(
|
||||
controller: controller,
|
||||
permission: permission,
|
||||
selectedColor: selectedColor,
|
||||
selectedIcon: selectedIcon,
|
||||
type: ManageSpaceType.edit,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _deleteSpace(
|
||||
BuildContext context,
|
||||
BuildContext bottomSheetContext,
|
||||
) {
|
||||
Navigator.of(bottomSheetContext).pop();
|
||||
|
||||
_showConfirmDialog(
|
||||
context,
|
||||
'${LocaleKeys.space_delete.tr()}: ${widget.space.name}',
|
||||
LocaleKeys.space_deleteConfirmationDescription.tr(),
|
||||
LocaleKeys.button_delete.tr(),
|
||||
(_) async {
|
||||
context.read<SpaceBloc>().add(SpaceEvent.delete(widget.space));
|
||||
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.space_success_deleteSpace.tr(),
|
||||
);
|
||||
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showConfirmDialog(
|
||||
BuildContext context,
|
||||
String title,
|
||||
String content,
|
||||
String rightButtonText,
|
||||
void Function(BuildContext context)? onRightButtonPressed,
|
||||
) {
|
||||
showFlowyCupertinoConfirmDialog(
|
||||
title: title,
|
||||
content: FlowyText(
|
||||
content,
|
||||
fontSize: 14,
|
||||
maxLines: 10,
|
||||
),
|
||||
leftButton: FlowyText(
|
||||
LocaleKeys.button_cancel.tr(),
|
||||
fontSize: 17.0,
|
||||
figmaLineHeight: 24.0,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: const Color(0xFF007AFF),
|
||||
),
|
||||
rightButton: FlowyText(
|
||||
rightButtonText,
|
||||
fontSize: 17.0,
|
||||
figmaLineHeight: 24.0,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: const Color(0xFFFE0220),
|
||||
),
|
||||
onRightButtonPressed: onRightButtonPressed,
|
||||
);
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_action_type.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'constants.dart';
|
||||
|
||||
class SpaceMenuMoreOptions extends StatelessWidget {
|
||||
const SpaceMenuMoreOptions({
|
||||
super.key,
|
||||
required this.onAction,
|
||||
required this.actions,
|
||||
});
|
||||
|
||||
final void Function(SpaceMoreActionType action) onAction;
|
||||
final List<SpaceMoreActionType> actions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: actions
|
||||
.map(
|
||||
(action) => _buildActionButton(context, action),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButton(
|
||||
BuildContext context,
|
||||
SpaceMoreActionType action,
|
||||
) {
|
||||
switch (action) {
|
||||
case SpaceMoreActionType.rename:
|
||||
return FlowyOptionTile.text(
|
||||
text: LocaleKeys.button_rename.tr(),
|
||||
height: SpaceUIConstants.itemHeight,
|
||||
leftIcon: const FlowySvg(
|
||||
FlowySvgs.view_item_rename_s,
|
||||
size: Size.square(18),
|
||||
),
|
||||
showTopBorder: false,
|
||||
showBottomBorder: false,
|
||||
onTap: () => onAction(
|
||||
SpaceMoreActionType.rename,
|
||||
),
|
||||
);
|
||||
case SpaceMoreActionType.delete:
|
||||
return FlowyOptionTile.text(
|
||||
text: LocaleKeys.button_delete.tr(),
|
||||
height: SpaceUIConstants.itemHeight,
|
||||
textColor: Theme.of(context).colorScheme.error,
|
||||
leftIcon: FlowySvg(
|
||||
FlowySvgs.trash_s,
|
||||
size: const Size.square(18),
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
showTopBorder: false,
|
||||
showBottomBorder: false,
|
||||
onTap: () => onAction(
|
||||
SpaceMoreActionType.delete,
|
||||
),
|
||||
);
|
||||
case SpaceMoreActionType.manage:
|
||||
return FlowyOptionTile.text(
|
||||
text: LocaleKeys.space_manage.tr(),
|
||||
height: SpaceUIConstants.itemHeight,
|
||||
leftIcon: const FlowySvg(
|
||||
FlowySvgs.settings_s,
|
||||
size: Size.square(18),
|
||||
),
|
||||
showTopBorder: false,
|
||||
showBottomBorder: false,
|
||||
onTap: () => onAction(
|
||||
SpaceMoreActionType.manage,
|
||||
),
|
||||
);
|
||||
case SpaceMoreActionType.duplicate:
|
||||
return FlowyOptionTile.text(
|
||||
text: SpaceMoreActionType.duplicate.name,
|
||||
height: SpaceUIConstants.itemHeight,
|
||||
leftIcon: const FlowySvg(
|
||||
FlowySvgs.duplicate_s,
|
||||
size: Size.square(18),
|
||||
),
|
||||
showTopBorder: false,
|
||||
showBottomBorder: false,
|
||||
onTap: () => onAction(
|
||||
SpaceMoreActionType.duplicate,
|
||||
),
|
||||
);
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SpacePermissionBottomSheet extends StatelessWidget {
|
||||
const SpacePermissionBottomSheet({
|
||||
super.key,
|
||||
required this.onAction,
|
||||
required this.permission,
|
||||
});
|
||||
|
||||
final SpacePermission permission;
|
||||
final void Function(SpacePermission action) onAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
FlowyOptionTile.text(
|
||||
text: LocaleKeys.space_publicPermission.tr(),
|
||||
leftIcon: const FlowySvg(
|
||||
FlowySvgs.space_permission_public_s,
|
||||
),
|
||||
trailing: permission == SpacePermission.publicToAll
|
||||
? const FlowySvg(
|
||||
FlowySvgs.m_blue_check_s,
|
||||
blendMode: null,
|
||||
)
|
||||
: null,
|
||||
onTap: () => onAction(SpacePermission.publicToAll),
|
||||
),
|
||||
FlowyOptionTile.text(
|
||||
text: LocaleKeys.space_privatePermission.tr(),
|
||||
showTopBorder: false,
|
||||
leftIcon: const FlowySvg(
|
||||
FlowySvgs.space_permission_private_s,
|
||||
),
|
||||
trailing: permission == SpacePermission.private
|
||||
? const FlowySvg(
|
||||
FlowySvgs.m_blue_check_s,
|
||||
blendMode: null,
|
||||
)
|
||||
: null,
|
||||
onTap: () => onAction(SpacePermission.private),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/animated_gesture.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/colors.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/icon.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart' hide Icon;
|
||||
|
||||
import 'constants.dart';
|
||||
import 'manage_space_widget.dart';
|
||||
import 'space_permission_bottom_sheet.dart';
|
||||
|
||||
class ManageSpaceNameOption extends StatelessWidget {
|
||||
const ManageSpaceNameOption({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.type,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final ManageSpaceType type;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, bottom: 4),
|
||||
child: FlowyText(
|
||||
LocaleKeys.space_spaceName.tr(),
|
||||
fontSize: 14,
|
||||
figmaLineHeight: 20.0,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
FlowyOptionTile.textField(
|
||||
controller: controller,
|
||||
autofocus: type == ManageSpaceType.create ? true : false,
|
||||
textFieldHintText: LocaleKeys.space_spaceNamePlaceholder.tr(),
|
||||
),
|
||||
const VSpace(16),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ManageSpacePermissionOption extends StatelessWidget {
|
||||
const ManageSpacePermissionOption({
|
||||
super.key,
|
||||
required this.permission,
|
||||
});
|
||||
|
||||
final ValueNotifier<SpacePermission> permission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, bottom: 4),
|
||||
child: FlowyText(
|
||||
LocaleKeys.space_permission.tr(),
|
||||
fontSize: 14,
|
||||
figmaLineHeight: 20.0,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: permission,
|
||||
builder: (context, value, child) => FlowyOptionTile.text(
|
||||
height: SpaceUIConstants.itemHeight,
|
||||
text: value.i18n,
|
||||
leftIcon: FlowySvg(value.icon),
|
||||
trailing: const FlowySvg(
|
||||
FlowySvgs.arrow_right_s,
|
||||
),
|
||||
onTap: () {
|
||||
showMobileBottomSheet(
|
||||
context,
|
||||
showHeader: true,
|
||||
title: LocaleKeys.space_permission.tr(),
|
||||
showCloseButton: true,
|
||||
showDivider: false,
|
||||
showDragHandle: true,
|
||||
builder: (context) => SpacePermissionBottomSheet(
|
||||
permission: value,
|
||||
onAction: (value) {
|
||||
permission.value = value;
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const VSpace(16),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ManageSpaceIconOption extends StatefulWidget {
|
||||
const ManageSpaceIconOption({
|
||||
super.key,
|
||||
required this.selectedColor,
|
||||
required this.selectedIcon,
|
||||
});
|
||||
|
||||
final ValueNotifier<String> selectedColor;
|
||||
final ValueNotifier<Icon?> selectedIcon;
|
||||
|
||||
@override
|
||||
State<ManageSpaceIconOption> createState() => _ManageSpaceIconOptionState();
|
||||
}
|
||||
|
||||
class _ManageSpaceIconOptionState extends State<ManageSpaceIconOption> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
..._buildColorOption(context),
|
||||
..._buildSpaceIconOption(context),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildColorOption(BuildContext context) {
|
||||
return [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, bottom: 4),
|
||||
child: FlowyText(
|
||||
LocaleKeys.space_mSpaceIconColor.tr(),
|
||||
fontSize: 14,
|
||||
figmaLineHeight: 20.0,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: widget.selectedColor,
|
||||
builder: (context, selectedColor, child) {
|
||||
return FlowyOptionDecorateBox(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: builtInSpaceColors.map((color) {
|
||||
return SpaceColorItem(
|
||||
color: color,
|
||||
selectedColor: selectedColor,
|
||||
onSelected: (color) => widget.selectedColor.value = color,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const VSpace(16),
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> _buildSpaceIconOption(BuildContext context) {
|
||||
return [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, bottom: 4),
|
||||
child: FlowyText(
|
||||
LocaleKeys.space_mSpaceIcon.tr(),
|
||||
fontSize: 14,
|
||||
figmaLineHeight: 20.0,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: widget.selectedColor,
|
||||
builder: (context, selectedColor, child) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: widget.selectedIcon,
|
||||
builder: (context, selectedIcon, child) {
|
||||
return FlowyOptionDecorateBox(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
),
|
||||
child: _buildIconGroups(
|
||||
context,
|
||||
selectedColor,
|
||||
selectedIcon,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const VSpace(16),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildIconGroups(
|
||||
BuildContext context,
|
||||
String selectedColor,
|
||||
Icon? selectedIcon,
|
||||
) {
|
||||
final iconGroups = kIconGroups;
|
||||
if (iconGroups == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: iconGroups.length,
|
||||
itemBuilder: (context, index) {
|
||||
final iconGroup = iconGroups[index];
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const VSpace(12.0),
|
||||
FlowyText(
|
||||
iconGroup.displayName.capitalize(),
|
||||
fontSize: 12,
|
||||
figmaLineHeight: 18.0,
|
||||
color: context.pickerTextColor,
|
||||
),
|
||||
const VSpace(4.0),
|
||||
Center(
|
||||
child: Wrap(
|
||||
spacing: 10.0,
|
||||
runSpacing: 8.0,
|
||||
children: iconGroup.icons.map((icon) {
|
||||
return SpaceIconItem(
|
||||
icon: icon,
|
||||
isSelected: selectedIcon?.name == icon.name,
|
||||
selectedColor: selectedColor,
|
||||
onSelectedIcon: (icon) => widget.selectedIcon.value = icon,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
const VSpace(12.0),
|
||||
if (index == iconGroups.length - 1) ...[
|
||||
const StreamlinePermit(),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SpaceIconItem extends StatelessWidget {
|
||||
const SpaceIconItem({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.onSelectedIcon,
|
||||
required this.isSelected,
|
||||
required this.selectedColor,
|
||||
});
|
||||
|
||||
final Icon icon;
|
||||
final void Function(Icon icon) onSelectedIcon;
|
||||
final bool isSelected;
|
||||
final String selectedColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedGestureDetector(
|
||||
onTapUp: () => onSelectedIcon(icon),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: isSelected
|
||||
? BoxDecoration(
|
||||
color: Color(int.parse(selectedColor)),
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
)
|
||||
: ShapeDecoration(
|
||||
color: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: const BorderSide(
|
||||
width: 0.5,
|
||||
color: Color(0x661F2329),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: FlowySvg.string(
|
||||
icon.content,
|
||||
size: const Size.square(18),
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.surface
|
||||
: context.pickerIconColor,
|
||||
opacity: isSelected ? 1.0 : 0.7,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SpaceColorItem extends StatelessWidget {
|
||||
const SpaceColorItem({
|
||||
super.key,
|
||||
required this.color,
|
||||
required this.selectedColor,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final String color;
|
||||
final String selectedColor;
|
||||
final void Function(String color) onSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final child = Center(
|
||||
child: Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: Color(int.parse(color)),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final decoration = color != selectedColor
|
||||
? null
|
||||
: ShapeDecoration(
|
||||
color: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: BorderSide(
|
||||
width: 1.50,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(21),
|
||||
),
|
||||
);
|
||||
|
||||
return AnimatedGestureDetector(
|
||||
onTapUp: () => onSelected(color),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: decoration,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension on SpacePermission {
|
||||
String get i18n {
|
||||
switch (this) {
|
||||
case SpacePermission.publicToAll:
|
||||
return LocaleKeys.space_publicPermission.tr();
|
||||
case SpacePermission.private:
|
||||
return LocaleKeys.space_privatePermission.tr();
|
||||
}
|
||||
}
|
||||
|
||||
FlowySvgData get icon {
|
||||
switch (this) {
|
||||
case SpacePermission.publicToAll:
|
||||
return FlowySvgs.space_permission_public_s;
|
||||
case SpacePermission.private:
|
||||
return FlowySvgs.space_permission_private_s;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,15 +16,18 @@ class FloatingAIEntry extends StatelessWidget {
|
||||
scaleFactor: 0.99,
|
||||
onTapUp: () => mobileCreateNewAIChatNotifier.value =
|
||||
mobileCreateNewAIChatNotifier.value + 1,
|
||||
child: DecoratedBox(
|
||||
decoration: _buildShadowDecoration(context),
|
||||
child: Container(
|
||||
decoration: _buildWrapperDecoration(context),
|
||||
height: 48,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 18),
|
||||
child: _buildHintText(context),
|
||||
child: Hero(
|
||||
tag: "ai_chat_prompt",
|
||||
child: DecoratedBox(
|
||||
decoration: _buildShadowDecoration(context),
|
||||
child: Container(
|
||||
decoration: _buildWrapperDecoration(context),
|
||||
height: 48,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 18),
|
||||
child: _buildHintText(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -182,6 +182,7 @@ class _MobileSpaceTabState extends State<MobileSpaceTab>
|
||||
SpaceEvent.createPage(
|
||||
name: layout.defaultName,
|
||||
layout: layout,
|
||||
openAfterCreate: true,
|
||||
),
|
||||
);
|
||||
} else if (layout == ViewLayoutPB.Document) {
|
||||
|
||||
+24
-7
@@ -32,6 +32,9 @@ class EditWorkspaceNameBottomSheet extends StatefulWidget {
|
||||
required this.type,
|
||||
required this.onSubmitted,
|
||||
required this.workspaceName,
|
||||
this.hintText,
|
||||
this.validator,
|
||||
this.validatorBuilder,
|
||||
});
|
||||
|
||||
final EditWorkspaceNameType type;
|
||||
@@ -40,6 +43,12 @@ class EditWorkspaceNameBottomSheet extends StatefulWidget {
|
||||
// if the workspace name is not empty, it will be used as the initial value of the text field.
|
||||
final String? workspaceName;
|
||||
|
||||
final String? hintText;
|
||||
|
||||
final String? Function(String?)? validator;
|
||||
|
||||
final WidgetBuilder? validatorBuilder;
|
||||
|
||||
@override
|
||||
State<EditWorkspaceNameBottomSheet> createState() =>
|
||||
_EditWorkspaceNameBottomSheetState();
|
||||
@@ -68,6 +77,7 @@ class _EditWorkspaceNameBottomSheetState
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Form(
|
||||
@@ -77,17 +87,24 @@ class _EditWorkspaceNameBottomSheetState
|
||||
controller: _textFieldController,
|
||||
keyboardType: TextInputType.text,
|
||||
decoration: InputDecoration(
|
||||
hintText: LocaleKeys.workspace_defaultName.tr(),
|
||||
hintText:
|
||||
widget.hintText ?? LocaleKeys.workspace_defaultName.tr(),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return LocaleKeys.workspace_workspaceNameCannotBeEmpty.tr();
|
||||
}
|
||||
return null;
|
||||
},
|
||||
validator: widget.validator ??
|
||||
(value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return LocaleKeys.workspace_workspaceNameCannotBeEmpty.tr();
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onEditingComplete: _onSubmit,
|
||||
),
|
||||
),
|
||||
if (widget.validatorBuilder != null) ...[
|
||||
const VSpace(4),
|
||||
widget.validatorBuilder!(context),
|
||||
const VSpace(4),
|
||||
],
|
||||
const VSpace(16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
|
||||
+11
-8
@@ -3,7 +3,7 @@ import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/animated_gesture.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
|
||||
import 'package:appflowy/util/navigator_context_exntesion.dart';
|
||||
import 'package:appflowy/util/navigator_context_extension.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart';
|
||||
@@ -234,6 +234,7 @@ class _WorkspaceMenuItemContent extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final memberCount = workspace.memberCount.toInt();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
child: Column(
|
||||
@@ -247,10 +248,10 @@ class _WorkspaceMenuItemContent extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
FlowyText(
|
||||
context.read<WorkspaceMemberBloc>().state.isLoading
|
||||
memberCount == 0
|
||||
? ''
|
||||
: LocaleKeys.settings_appearance_members_membersCount.plural(
|
||||
context.read<WorkspaceMemberBloc>().state.members.length,
|
||||
memberCount,
|
||||
),
|
||||
fontSize: 10.0,
|
||||
color: Theme.of(context).hintColor,
|
||||
@@ -313,16 +314,18 @@ class _WorkspaceMenuItemTrailing extends StatelessWidget {
|
||||
size: iconSize,
|
||||
blendMode: null,
|
||||
),
|
||||
const HSpace(15.0),
|
||||
const HSpace(8.0),
|
||||
// more options button
|
||||
AnimatedGestureDetector(
|
||||
onTapUp: () => _showMoreOptions(context),
|
||||
child: const FlowySvg(
|
||||
FlowySvgs.workspace_three_dots_s,
|
||||
size: iconSize,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: FlowySvg(
|
||||
FlowySvgs.workspace_three_dots_s,
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
const HSpace(8.0),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
+87
-75
@@ -5,58 +5,52 @@ import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.da
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
import 'chat_input_bloc.dart';
|
||||
part 'ai_prompt_input_bloc.freezed.dart';
|
||||
|
||||
part 'chat_file_bloc.freezed.dart';
|
||||
class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
|
||||
AIPromptInputBloc()
|
||||
: _listener = LocalLLMListener(),
|
||||
super(AIPromptInputState.initial()) {
|
||||
_dispatch();
|
||||
_startListening();
|
||||
_init();
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
},
|
||||
);
|
||||
ChatInputFileMetadata consumeMetadata() {
|
||||
final metadata = {
|
||||
for (final file in state.uploadFiles) file.filePath: file,
|
||||
};
|
||||
|
||||
if (metadata.isNotEmpty) {
|
||||
add(const AIPromptInputEvent.clear());
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
final LocalLLMListener _listener;
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await _listener.stop();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
void _dispatch() {
|
||||
on<AIPromptInputEvent>(
|
||||
(event, emit) {
|
||||
event.when(
|
||||
newFile: (String filePath, String fileName) {
|
||||
final files = [...state.uploadFiles];
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
emit(state.copyWith(uploadFiles: files));
|
||||
}
|
||||
},
|
||||
updateChatState: (LocalAIChatPB chatState) {
|
||||
@@ -76,8 +70,8 @@ class ChatFileBloc extends Bloc<ChatFileEvent, ChatFileState> {
|
||||
fileEnabled && chatState.state == RunningStatePB.Running;
|
||||
|
||||
final aiType = chatState.state == RunningStatePB.Running
|
||||
? const AIType.localAI()
|
||||
: const AIType.appflowyAI();
|
||||
? AIType.localAI
|
||||
: AIType.appflowyAI;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
@@ -107,48 +101,66 @@ class ChatFileBloc extends Bloc<ChatFileEvent, ChatFileState> {
|
||||
);
|
||||
}
|
||||
|
||||
ChatInputFileMetadata consumeMetaData() {
|
||||
final metadata = state.uploadFiles.fold(
|
||||
<String, ChatFile>{},
|
||||
(map, file) => map..putIfAbsent(file.filePath, () => file),
|
||||
void _startListening() {
|
||||
_listener.start(
|
||||
stateCallback: (pluginState) {
|
||||
if (!isClosed) {
|
||||
add(AIPromptInputEvent.updatePluginState(pluginState));
|
||||
}
|
||||
},
|
||||
chatStateCallback: (chatState) {
|
||||
if (!isClosed) {
|
||||
add(AIPromptInputEvent.updateChatState(chatState));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (metadata.isNotEmpty) {
|
||||
add(const ChatFileEvent.clear());
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
final LocalLLMListener listener;
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await listener.stop();
|
||||
return super.close();
|
||||
void _init() {
|
||||
AIEventGetLocalAIChatState().send().fold(
|
||||
(chatState) {
|
||||
if (!isClosed) {
|
||||
add(AIPromptInputEvent.updateChatState(chatState));
|
||||
}
|
||||
},
|
||||
Log.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatFileEvent with _$ChatFileEvent {
|
||||
const factory ChatFileEvent.initial() = Initial;
|
||||
const factory ChatFileEvent.newFile(String filePath, String fileName) =
|
||||
class AIPromptInputEvent with _$AIPromptInputEvent {
|
||||
const factory AIPromptInputEvent.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(
|
||||
const factory AIPromptInputEvent.deleteFile(ChatFile file) = _DeleteFile;
|
||||
const factory AIPromptInputEvent.clear() = _ClearFile;
|
||||
const factory AIPromptInputEvent.updateChatState(
|
||||
LocalAIChatPB chatState,
|
||||
) = _UpdateChatState;
|
||||
const factory AIPromptInputEvent.updatePluginState(
|
||||
LocalAIPluginStatePB chatState,
|
||||
) = _UpdatePluginState;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatFileState with _$ChatFileState {
|
||||
const factory ChatFileState({
|
||||
@Default(false) bool supportChatWithFile,
|
||||
class AIPromptInputState with _$AIPromptInputState {
|
||||
const factory AIPromptInputState({
|
||||
required bool supportChatWithFile,
|
||||
LocalAIChatPB? chatState,
|
||||
@Default([]) List<ChatFile> uploadFiles,
|
||||
@Default(AIType.appflowyAI()) AIType aiType,
|
||||
}) = _ChatFileState;
|
||||
required List<ChatFile> uploadFiles,
|
||||
required AIType aiType,
|
||||
}) = _AIPromptInputState;
|
||||
|
||||
factory AIPromptInputState.initial() => const AIPromptInputState(
|
||||
supportChatWithFile: false,
|
||||
uploadFiles: [],
|
||||
aiType: AIType.appflowyAI,
|
||||
);
|
||||
}
|
||||
|
||||
enum AIType {
|
||||
appflowyAI,
|
||||
localAI;
|
||||
|
||||
bool get isLocalAI => this == localAI;
|
||||
}
|
||||
+50
-44
@@ -1,5 +1,3 @@
|
||||
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_backend/dispatch/dispatch.dart';
|
||||
@@ -22,46 +20,29 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
|
||||
}) : super(
|
||||
ChatAIMessageState.initial(
|
||||
message,
|
||||
messageReferenceSource(refSourceJsonString),
|
||||
parseMetadata(refSourceJsonString),
|
||||
),
|
||||
) {
|
||||
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));
|
||||
}
|
||||
},
|
||||
);
|
||||
_dispatch();
|
||||
|
||||
if (state.stream!.error != null) {
|
||||
Future.delayed(const Duration(milliseconds: 300), () {
|
||||
if (!isClosed) {
|
||||
add(ChatAIMessageEvent.receiveError(state.stream!.error!));
|
||||
}
|
||||
});
|
||||
if (state.stream != null) {
|
||||
_startListening();
|
||||
|
||||
if (state.stream!.aiLimitReached) {
|
||||
add(const ChatAIMessageEvent.onAIResponseLimit());
|
||||
} else if (state.stream!.error != null) {
|
||||
add(ChatAIMessageEvent.receiveError(state.stream!.error!));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final String chatId;
|
||||
final Int64? questionId;
|
||||
|
||||
void _dispatch() {
|
||||
on<ChatAIMessageEvent>(
|
||||
(event, emit) async {
|
||||
await event.when(
|
||||
initial: () async {},
|
||||
(event, emit) {
|
||||
event.when(
|
||||
updateText: (newText) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
@@ -117,10 +98,12 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
|
||||
),
|
||||
);
|
||||
},
|
||||
receiveSources: (List<ChatMessageRefSource> sources) {
|
||||
receiveMetadata: (metadata) {
|
||||
Log.debug("AI Steps: ${metadata.progress?.step}");
|
||||
emit(
|
||||
state.copyWith(
|
||||
sources: sources,
|
||||
sources: metadata.sources,
|
||||
progress: metadata.progress,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -129,20 +112,41 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
|
||||
);
|
||||
}
|
||||
|
||||
final String chatId;
|
||||
final Int64? questionId;
|
||||
void _startListening() {
|
||||
state.stream!.listen(
|
||||
onData: (text) {
|
||||
if (!isClosed) {
|
||||
add(ChatAIMessageEvent.updateText(text));
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
if (!isClosed) {
|
||||
add(ChatAIMessageEvent.receiveError(error.toString()));
|
||||
}
|
||||
},
|
||||
onAIResponseLimit: () {
|
||||
if (!isClosed) {
|
||||
add(const ChatAIMessageEvent.onAIResponseLimit());
|
||||
}
|
||||
},
|
||||
onMetadata: (metadata) {
|
||||
if (!isClosed) {
|
||||
add(ChatAIMessageEvent.receiveMetadata(metadata));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatAIMessageEvent with _$ChatAIMessageEvent {
|
||||
const factory ChatAIMessageEvent.initial() = Initial;
|
||||
const factory ChatAIMessageEvent.updateText(String text) = _UpdateText;
|
||||
const factory ChatAIMessageEvent.receiveError(String error) = _ReceiveError;
|
||||
const factory ChatAIMessageEvent.retry() = _Retry;
|
||||
const factory ChatAIMessageEvent.retryResult(String text) = _RetryResult;
|
||||
const factory ChatAIMessageEvent.onAIResponseLimit() = _OnAIResponseLimit;
|
||||
const factory ChatAIMessageEvent.receiveSources(
|
||||
List<ChatMessageRefSource> sources,
|
||||
const factory ChatAIMessageEvent.receiveMetadata(
|
||||
MetadataCollection metadata,
|
||||
) = _ReceiveMetadata;
|
||||
}
|
||||
|
||||
@@ -153,17 +157,19 @@ class ChatAIMessageState with _$ChatAIMessageState {
|
||||
required String text,
|
||||
required MessageState messageState,
|
||||
required List<ChatMessageRefSource> sources,
|
||||
required AIChatProgress? progress,
|
||||
}) = _ChatAIMessageState;
|
||||
|
||||
factory ChatAIMessageState.initial(
|
||||
dynamic text,
|
||||
List<ChatMessageRefSource> sources,
|
||||
MetadataCollection metadata,
|
||||
) {
|
||||
return ChatAIMessageState(
|
||||
text: text is String ? text : "",
|
||||
stream: text is AnswerStream ? text : null,
|
||||
messageState: const MessageState.ready(),
|
||||
sources: sources,
|
||||
sources: metadata.sources,
|
||||
progress: metadata.progress,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +1,50 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart';
|
||||
import 'package:appflowy/util/int64_extension.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:nanoid/nanoid.dart';
|
||||
|
||||
import 'chat_entity.dart';
|
||||
import 'chat_message_listener.dart';
|
||||
import 'chat_message_service.dart';
|
||||
import 'chat_message_stream.dart';
|
||||
|
||||
part 'chat_bloc.freezed.dart';
|
||||
|
||||
class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
ChatBloc({
|
||||
required ViewPB view,
|
||||
required UserProfilePB userProfile,
|
||||
}) : listener = ChatMessageListener(chatId: view.id),
|
||||
chatId = view.id,
|
||||
super(
|
||||
ChatState.initial(view, userProfile),
|
||||
) {
|
||||
required this.chatId,
|
||||
required this.userId,
|
||||
}) : chatController = InMemoryChatController(),
|
||||
listener = ChatMessageListener(chatId: chatId),
|
||||
super(ChatState.initial()) {
|
||||
_startListening();
|
||||
_dispatch();
|
||||
_init();
|
||||
}
|
||||
|
||||
final ChatMessageListener listener;
|
||||
final String chatId;
|
||||
final String userId;
|
||||
final ChatMessageListener listener;
|
||||
|
||||
final ChatController chatController;
|
||||
|
||||
/// The last streaming message id
|
||||
String answerStreamMessageId = '';
|
||||
String questionStreamMessageId = '';
|
||||
|
||||
ChatMessagePB? lastSentMessage;
|
||||
|
||||
/// Using a temporary map to associate the real message ID with the last streaming message ID.
|
||||
///
|
||||
/// When a message is streaming, it does not have a real message ID. To maintain the relationship
|
||||
@@ -51,11 +54,14 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
/// is 3 (AI response).
|
||||
final HashMap<String, String> temporaryMessageIDMap = HashMap();
|
||||
|
||||
bool isLoadingPreviousMessages = false;
|
||||
bool hasMorePreviousMessages = true;
|
||||
AnswerStream? answerStream;
|
||||
int numSendMessage = 0;
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
if (state.answerStream != null) {
|
||||
await state.answerStream?.dispose();
|
||||
}
|
||||
await answerStream?.dispose();
|
||||
await listener.stop();
|
||||
return super.close();
|
||||
}
|
||||
@@ -64,187 +70,166 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
on<ChatEvent>(
|
||||
(event, emit) async {
|
||||
await event.when(
|
||||
initialLoad: () {
|
||||
final payload = LoadNextChatMessagePB(
|
||||
chatId: state.view.id,
|
||||
limit: Int64(10),
|
||||
);
|
||||
AIEventLoadNextMessage(payload).send().then(
|
||||
(result) {
|
||||
result.fold((list) {
|
||||
if (!isClosed) {
|
||||
final messages =
|
||||
list.messages.map(_createTextMessage).toList();
|
||||
add(ChatEvent.didLoadLatestMessages(messages));
|
||||
}
|
||||
}, (err) {
|
||||
Log.error("Failed to load messages: $err");
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
// 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}",
|
||||
);
|
||||
}
|
||||
}
|
||||
_loadPrevMessage(beforeMessageId);
|
||||
emit(
|
||||
state.copyWith(
|
||||
loadingPreviousStatus: const ChatLoadingState.loading(),
|
||||
),
|
||||
);
|
||||
},
|
||||
didLoadPreviousMessages: (List<Message> messages, bool hasMore) {
|
||||
Log.debug("did load previous messages: ${messages.length}");
|
||||
final onetimeMessages = _getOnetimeMessages();
|
||||
final allMessages = _perminentMessages();
|
||||
final uniqueMessages = {...allMessages, ...messages}.toList()
|
||||
..sort((a, b) => b.id.compareTo(a.id));
|
||||
|
||||
uniqueMessages.insertAll(0, onetimeMessages);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
messages: uniqueMessages,
|
||||
loadingPreviousStatus: const ChatLoadingState.finish(),
|
||||
hasMorePrevMessage: hasMore,
|
||||
),
|
||||
);
|
||||
},
|
||||
didLoadLatestMessages: (List<Message> messages) {
|
||||
final onetimeMessages = _getOnetimeMessages();
|
||||
final allMessages = _perminentMessages();
|
||||
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(),
|
||||
),
|
||||
);
|
||||
},
|
||||
// streaming message
|
||||
finishAnswerStreaming: () {
|
||||
emit(
|
||||
state.copyWith(
|
||||
streamingState: const StreamingState.done(),
|
||||
acceptRelatedQuestion: true,
|
||||
canSendMessage:
|
||||
state.sendingState == const SendMessageState.done(),
|
||||
),
|
||||
);
|
||||
},
|
||||
didUpdateAnswerStream: (AnswerStream stream) {
|
||||
emit(state.copyWith(answerStream: stream));
|
||||
},
|
||||
stopStream: () async {
|
||||
if (state.answerStream == null) {
|
||||
return;
|
||||
didLoadLatestMessages: (List<Message> messages) async {
|
||||
for (final message in messages) {
|
||||
await chatController.insert(message, index: 0);
|
||||
}
|
||||
|
||||
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.
|
||||
if (state.loadingState.isLoading) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
messages: allMessages,
|
||||
answerStream: null,
|
||||
streamingState: const StreamingState.done(),
|
||||
),
|
||||
state.copyWith(loadingState: const ChatLoadingState.finish()),
|
||||
);
|
||||
}
|
||||
},
|
||||
receveMessage: (Message message) {
|
||||
final allMessages = _perminentMessages();
|
||||
// remove message with the same id
|
||||
allMessages.removeWhere((element) => element.id == message.id);
|
||||
allMessages.insert(0, message);
|
||||
loadPreviousMessages: () {
|
||||
if (isLoadingPreviousMessages) {
|
||||
return;
|
||||
}
|
||||
|
||||
final oldestMessage = _getOldestMessage();
|
||||
|
||||
if (oldestMessage != null) {
|
||||
final oldestMessageId = Int64.tryParseInt(oldestMessage.id);
|
||||
if (oldestMessageId == null) {
|
||||
Log.error("Failed to parse message_id: ${oldestMessage.id}");
|
||||
return;
|
||||
}
|
||||
isLoadingPreviousMessages = true;
|
||||
_loadPreviousMessages(oldestMessageId);
|
||||
}
|
||||
},
|
||||
didLoadPreviousMessages: (messages, hasMore) {
|
||||
Log.debug("did load previous messages: ${messages.length}");
|
||||
|
||||
for (final message in messages) {
|
||||
chatController.insert(message, index: 0);
|
||||
}
|
||||
|
||||
isLoadingPreviousMessages = false;
|
||||
hasMorePreviousMessages = hasMore;
|
||||
},
|
||||
didFinishAnswerStream: () {
|
||||
emit(
|
||||
state.copyWith(
|
||||
messages: allMessages,
|
||||
),
|
||||
state.copyWith(promptResponseState: PromptResponseState.ready),
|
||||
);
|
||||
},
|
||||
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));
|
||||
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) {
|
||||
didReceiveRelatedQuestions: (List<String> questions) {
|
||||
if (questions.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final allMessages = _perminentMessages();
|
||||
final message = CustomMessage(
|
||||
metadata: OnetimeShotType.relatedQuestion.toMap(),
|
||||
final metadata = {
|
||||
onetimeShotType: OnetimeShotType.relatedQuestion,
|
||||
'questions': questions,
|
||||
};
|
||||
|
||||
final createdAt = DateTime.now();
|
||||
|
||||
final message = TextMessage(
|
||||
id: "related_question_$createdAt",
|
||||
text: '',
|
||||
metadata: metadata,
|
||||
author: const User(id: systemUserId),
|
||||
id: systemUserId,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
allMessages.insert(0, message);
|
||||
|
||||
chatController.insert(message);
|
||||
},
|
||||
receiveMessage: (Message message) {
|
||||
final oldMessage = chatController.messages
|
||||
.firstWhereOrNull((m) => m.id == message.id);
|
||||
if (oldMessage == null) {
|
||||
chatController.insert(message);
|
||||
} else {
|
||||
chatController.update(oldMessage, message);
|
||||
}
|
||||
},
|
||||
sendMessage: (
|
||||
String message,
|
||||
Map<String, dynamic>? metadata,
|
||||
) {
|
||||
numSendMessage += 1;
|
||||
|
||||
final relatedQuestionMessages = chatController.messages
|
||||
.where(
|
||||
(message) =>
|
||||
onetimeMessageTypeFromMeta(message.metadata) ==
|
||||
OnetimeShotType.relatedQuestion,
|
||||
)
|
||||
.toList();
|
||||
|
||||
for (final message in relatedQuestionMessages) {
|
||||
chatController.remove(message);
|
||||
}
|
||||
|
||||
_startStreamingMessage(message, metadata);
|
||||
lastSentMessage = null;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
messages: allMessages,
|
||||
relatedQuestions: questions,
|
||||
promptResponseState: PromptResponseState.sendingQuestion,
|
||||
),
|
||||
);
|
||||
},
|
||||
clearReleatedQuestion: () {
|
||||
finishSending: (ChatMessagePB message) {
|
||||
lastSentMessage = message;
|
||||
emit(
|
||||
state.copyWith(
|
||||
relatedQuestions: [],
|
||||
promptResponseState: PromptResponseState.awaitingAnswer,
|
||||
),
|
||||
);
|
||||
},
|
||||
stopStream: () async {
|
||||
if (answerStream == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// tell backend to stop
|
||||
final payload = StopStreamPB(chatId: chatId);
|
||||
await AIEventStopStream(payload).send();
|
||||
|
||||
// allow user input
|
||||
emit(
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.ready,
|
||||
),
|
||||
);
|
||||
|
||||
// no need to remove old message if stream has started already
|
||||
if (answerStream!.hasStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// remove the non-started message from the list
|
||||
final message = chatController.messages.lastWhereOrNull(
|
||||
(e) => e.id == answerStreamMessageId,
|
||||
);
|
||||
if (message != null) {
|
||||
await chatController.remove(message);
|
||||
}
|
||||
|
||||
// set answer stream to null
|
||||
await answerStream?.dispose();
|
||||
answerStream = null;
|
||||
answerStreamMessageId = '';
|
||||
},
|
||||
startAnswerStreaming: (Message message) {
|
||||
chatController.insert(message);
|
||||
emit(
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.streamingAnswer,
|
||||
),
|
||||
);
|
||||
},
|
||||
failedSending: () {
|
||||
final lastMessage = chatController.messages.lastOrNull;
|
||||
if (lastMessage != null) {
|
||||
chatController.remove(lastMessage);
|
||||
}
|
||||
emit(
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.ready,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -256,29 +241,31 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
void _startListening() {
|
||||
listener.start(
|
||||
chatMessageCallback: (pb) {
|
||||
if (!isClosed) {
|
||||
// 3 mean message response from AI
|
||||
if (pb.authorType == 3 && answerStreamMessageId.isNotEmpty) {
|
||||
temporaryMessageIDMap[pb.messageId.toString()] =
|
||||
answerStreamMessageId;
|
||||
answerStreamMessageId = "";
|
||||
}
|
||||
|
||||
// 1 mean message response from User
|
||||
if (pb.authorType == 1 && questionStreamMessageId.isNotEmpty) {
|
||||
temporaryMessageIDMap[pb.messageId.toString()] =
|
||||
questionStreamMessageId;
|
||||
questionStreamMessageId = "";
|
||||
}
|
||||
|
||||
final message = _createTextMessage(pb);
|
||||
add(ChatEvent.receveMessage(message));
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3 mean message response from AI
|
||||
if (pb.authorType == 3 && answerStreamMessageId.isNotEmpty) {
|
||||
temporaryMessageIDMap[pb.messageId.toString()] =
|
||||
answerStreamMessageId;
|
||||
answerStreamMessageId = '';
|
||||
}
|
||||
|
||||
// 1 mean message response from User
|
||||
if (pb.authorType == 1 && questionStreamMessageId.isNotEmpty) {
|
||||
temporaryMessageIDMap[pb.messageId.toString()] =
|
||||
questionStreamMessageId;
|
||||
questionStreamMessageId = '';
|
||||
}
|
||||
|
||||
final message = _createTextMessage(pb);
|
||||
add(ChatEvent.receiveMessage(message));
|
||||
},
|
||||
chatErrorMessageCallback: (err) {
|
||||
if (!isClosed) {
|
||||
Log.error("chat error: ${err.errorMessage}");
|
||||
add(const ChatEvent.finishAnswerStreaming());
|
||||
add(const ChatEvent.didFinishAnswerStream());
|
||||
}
|
||||
},
|
||||
latestMessageCallback: (list) {
|
||||
@@ -293,65 +280,74 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
add(ChatEvent.didLoadPreviousMessages(messages, list.hasMore));
|
||||
}
|
||||
},
|
||||
finishStreamingCallback: () {
|
||||
if (!isClosed) {
|
||||
add(const ChatEvent.finishAnswerStreaming());
|
||||
// The answer strema will bet set to null after the streaming is finished or canceled.
|
||||
// so if the answer stream is null, we will not get related question.
|
||||
if (state.lastSentMessage != null && state.answerStream != null) {
|
||||
final payload = ChatMessageIdPB(
|
||||
chatId: chatId,
|
||||
messageId: state.lastSentMessage!.messageId,
|
||||
);
|
||||
// When user message was sent to the server, we start gettting related question
|
||||
AIEventGetRelatedQuestion(payload).send().then((result) {
|
||||
if (!isClosed) {
|
||||
result.fold(
|
||||
(list) {
|
||||
if (state.acceptRelatedQuestion) {
|
||||
add(ChatEvent.didReceiveRelatedQuestion(list.items));
|
||||
}
|
||||
},
|
||||
(err) {
|
||||
Log.error("Failed to get related question: $err");
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
finishStreamingCallback: () async {
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
|
||||
add(const ChatEvent.didFinishAnswerStream());
|
||||
|
||||
// The answer stream will bet set to null after the streaming has
|
||||
// finished, got cancelled, or errored. In this case, don't retrieve
|
||||
// related questions.
|
||||
if (answerStream == null || lastSentMessage == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final payload = ChatMessageIdPB(
|
||||
chatId: chatId,
|
||||
messageId: lastSentMessage!.messageId,
|
||||
);
|
||||
|
||||
// when previous numSendMessage is not equal to current numSendMessage, it means that the user
|
||||
// has sent a new message. So we don't need to get related questions.
|
||||
final preNumSendMessage = numSendMessage;
|
||||
await AIEventGetRelatedQuestion(payload).send().fold(
|
||||
(list) {
|
||||
if (!isClosed && preNumSendMessage == numSendMessage) {
|
||||
add(
|
||||
ChatEvent.didReceiveRelatedQuestions(
|
||||
list.items.map((e) => e.content).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
(err) => Log.error("Failed to get related questions: $err"),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Returns the list of messages that are not include one-time messages.
|
||||
List<Message> _perminentMessages() {
|
||||
final allMessages = state.messages.where((element) {
|
||||
return !(element.metadata?.containsKey(onetimeShotType) == true);
|
||||
}).toList();
|
||||
|
||||
return allMessages;
|
||||
void _init() async {
|
||||
final payload = LoadNextChatMessagePB(
|
||||
chatId: chatId,
|
||||
limit: Int64(10),
|
||||
);
|
||||
await AIEventLoadNextMessage(payload).send().fold(
|
||||
(list) {
|
||||
if (!isClosed) {
|
||||
final messages = list.messages.map(_createTextMessage).toList();
|
||||
add(ChatEvent.didLoadLatestMessages(messages));
|
||||
}
|
||||
},
|
||||
(err) => Log.error("Failed to load messages: $err"),
|
||||
);
|
||||
}
|
||||
|
||||
List<Message> _getOnetimeMessages() {
|
||||
final messages = state.messages.where((element) {
|
||||
return (element.metadata?.containsKey(onetimeShotType) == true);
|
||||
}).toList();
|
||||
|
||||
return messages;
|
||||
bool _isOneTimeMessage(Message message) {
|
||||
return message.metadata != null &&
|
||||
message.metadata!.containsKey(onetimeShotType);
|
||||
}
|
||||
|
||||
Message? _getOlderstMessage() {
|
||||
// get the last message that is not a one-time message
|
||||
final message = state.messages.lastWhereOrNull((element) {
|
||||
return !(element.metadata?.containsKey(onetimeShotType) == true);
|
||||
});
|
||||
return message;
|
||||
/// get the last message that is not a one-time message
|
||||
Message? _getOldestMessage() {
|
||||
return chatController.messages
|
||||
.firstWhereOrNull((message) => !_isOneTimeMessage(message));
|
||||
}
|
||||
|
||||
void _loadPrevMessage(Int64? beforeMessageId) {
|
||||
void _loadPreviousMessages(Int64? beforeMessageId) {
|
||||
final payload = LoadPrevChatMessagePB(
|
||||
chatId: state.view.id,
|
||||
chatId: chatId,
|
||||
limit: Int64(10),
|
||||
beforeMessageId: beforeMessageId,
|
||||
);
|
||||
@@ -361,61 +357,60 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
Future<void> _startStreamingMessage(
|
||||
String message,
|
||||
Map<String, dynamic>? metadata,
|
||||
Emitter<ChatState> emit,
|
||||
) async {
|
||||
if (state.answerStream != null) {
|
||||
await state.answerStream?.dispose();
|
||||
}
|
||||
await answerStream?.dispose();
|
||||
|
||||
final answerStream = AnswerStream();
|
||||
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),
|
||||
);
|
||||
|
||||
// add a streaming question message
|
||||
final questionStreamMessage = _createQuestionStreamMessage(
|
||||
questionStream,
|
||||
metadata,
|
||||
);
|
||||
add(ChatEvent.receveMessage(questionStreamMessage));
|
||||
add(ChatEvent.receiveMessage(questionStreamMessage));
|
||||
|
||||
// Stream message to the server
|
||||
final result = await AIEventStreamMessage(payload).send();
|
||||
result.fold(
|
||||
(ChatMessagePB question) {
|
||||
final payload = StreamChatPayloadPB(
|
||||
chatId: chatId,
|
||||
message: message,
|
||||
messageType: ChatMessageTypePB.User,
|
||||
questionStreamPort: Int64(questionStream.nativePort),
|
||||
answerStreamPort: Int64(answerStream!.nativePort),
|
||||
metadata: await metadataPBFromMetadata(metadata),
|
||||
);
|
||||
|
||||
// stream the question to the server
|
||||
await AIEventStreamMessage(payload).send().fold(
|
||||
(question) {
|
||||
if (!isClosed) {
|
||||
final streamAnswer = _createAnswerStreamMessage(
|
||||
answerStream!,
|
||||
question.messageId,
|
||||
);
|
||||
|
||||
add(ChatEvent.finishSending(question));
|
||||
|
||||
// final message = _createTextMessage(question);
|
||||
// add(ChatEvent.receveMessage(message));
|
||||
|
||||
final streamAnswer =
|
||||
_createAnswerStreamMessage(answerStream, question.messageId);
|
||||
add(ChatEvent.startAnswerStreaming(streamAnswer));
|
||||
}
|
||||
},
|
||||
(err) {
|
||||
if (!isClosed) {
|
||||
Log.error("Failed to send message: ${err.msg}");
|
||||
final metadata = OnetimeShotType.invalidSendMesssage.toMap();
|
||||
if (err.code != ErrorCode.Internal) {
|
||||
metadata[sendMessageErrorKey] = err.msg;
|
||||
}
|
||||
|
||||
final error = CustomMessage(
|
||||
final metadata = {
|
||||
onetimeShotType: OnetimeShotType.error,
|
||||
if (err.code != ErrorCode.Internal) errorMessageTextKey: err.msg,
|
||||
};
|
||||
|
||||
final error = TextMessage(
|
||||
text: '',
|
||||
metadata: metadata,
|
||||
author: const User(id: systemUserId),
|
||||
id: systemUserId,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
add(ChatEvent.receveMessage(error));
|
||||
add(const ChatEvent.failedSending());
|
||||
add(ChatEvent.receiveMessage(error));
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -425,19 +420,18 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
AnswerStream stream,
|
||||
Int64 questionMessageId,
|
||||
) {
|
||||
final streamMessageId = (questionMessageId + 1).toString();
|
||||
answerStreamMessageId = streamMessageId;
|
||||
answerStreamMessageId = (questionMessageId + 1).toString();
|
||||
|
||||
return TextMessage(
|
||||
id: answerStreamMessageId,
|
||||
text: '',
|
||||
author: User(id: "streamId:${nanoid()}"),
|
||||
metadata: {
|
||||
"$AnswerStream": stream,
|
||||
messageQuestionIdKey: questionMessageId,
|
||||
"chatId": chatId,
|
||||
},
|
||||
id: streamMessageId,
|
||||
createdAt: DateTime.now().millisecondsSinceEpoch,
|
||||
text: '',
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -446,23 +440,18 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
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;
|
||||
// }
|
||||
questionStreamMessageId = (now.millisecondsSinceEpoch ~/ 1000).toString();
|
||||
|
||||
metadata["$QuestionStream"] = stream;
|
||||
metadata["chatId"] = chatId;
|
||||
metadata[messageChatFileListKey] =
|
||||
chatFilesFromMessageMetadata(sentMetadata);
|
||||
return TextMessage(
|
||||
author: User(id: state.userProfile.id.toString()),
|
||||
metadata: metadata,
|
||||
author: User(id: userId),
|
||||
metadata: {
|
||||
"$QuestionStream": stream,
|
||||
"chatId": chatId,
|
||||
messageChatFileListKey: chatFilesFromMessageMetadata(sentMetadata),
|
||||
},
|
||||
id: questionStreamMessageId,
|
||||
createdAt: DateTime.now().millisecondsSinceEpoch,
|
||||
createdAt: now,
|
||||
text: '',
|
||||
);
|
||||
}
|
||||
@@ -479,7 +468,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
author: User(id: message.authorId),
|
||||
id: messageId,
|
||||
text: message.content,
|
||||
createdAt: message.createdAt.toInt() * 1000,
|
||||
createdAt: message.createdAt.toDateTime(),
|
||||
metadata: {
|
||||
messageRefSourceJsonStringKey: message.metadata,
|
||||
},
|
||||
@@ -489,8 +478,6 @@ 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,
|
||||
@@ -498,72 +485,42 @@ class ChatEvent with _$ChatEvent {
|
||||
}) = _SendMessage;
|
||||
const factory ChatEvent.finishSending(ChatMessagePB message) =
|
||||
_FinishSendMessage;
|
||||
const factory ChatEvent.failedSending() = _FailSendMessage;
|
||||
|
||||
// receive message
|
||||
// streaming answer
|
||||
const factory ChatEvent.startAnswerStreaming(Message message) =
|
||||
_StartAnswerStreaming;
|
||||
const factory ChatEvent.receveMessage(Message message) = _ReceiveMessage;
|
||||
const factory ChatEvent.finishAnswerStreaming() = _FinishAnswerStreaming;
|
||||
const factory ChatEvent.stopStream() = _StopStream;
|
||||
const factory ChatEvent.didFinishAnswerStream() = _DidFinishAnswerStream;
|
||||
|
||||
// loading messages
|
||||
const factory ChatEvent.startLoadingPrevMessage() = _StartLoadPrevMessage;
|
||||
// receive message
|
||||
const factory ChatEvent.receiveMessage(Message message) = _ReceiveMessage;
|
||||
|
||||
// loading messages
|
||||
const factory ChatEvent.didLoadLatestMessages(List<Message> messages) =
|
||||
_DidLoadMessages;
|
||||
const factory ChatEvent.loadPreviousMessages() = _LoadPreviousMessages;
|
||||
const factory ChatEvent.didLoadPreviousMessages(
|
||||
List<Message> messages,
|
||||
bool hasMore,
|
||||
) = _DidLoadPreviousMessages;
|
||||
const factory ChatEvent.didLoadLatestMessages(List<Message> messages) =
|
||||
_DidLoadMessages;
|
||||
|
||||
// related questions
|
||||
const factory ChatEvent.didReceiveRelatedQuestion(
|
||||
List<RelatedQuestionPB> questions,
|
||||
// related questions
|
||||
const factory ChatEvent.didReceiveRelatedQuestions(
|
||||
List<String> questions,
|
||||
) = _DidReceiveRelatedQueston;
|
||||
const factory ChatEvent.clearReleatedQuestion() = _ClearRelatedQuestion;
|
||||
|
||||
const factory ChatEvent.didUpdateAnswerStream(
|
||||
AnswerStream stream,
|
||||
) = _DidUpdateAnswerStream;
|
||||
const factory ChatEvent.stopStream() = _StopStream;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatState with _$ChatState {
|
||||
const factory ChatState({
|
||||
required ViewPB view,
|
||||
required List<Message> messages,
|
||||
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,
|
||||
// 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,
|
||||
// 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,
|
||||
// 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,
|
||||
required ChatLoadingState loadingState,
|
||||
required PromptResponseState promptResponseState,
|
||||
}) = _ChatState;
|
||||
|
||||
factory ChatState.initial(ViewPB view, UserProfilePB userProfile) =>
|
||||
ChatState(
|
||||
view: view,
|
||||
messages: [],
|
||||
userProfile: userProfile,
|
||||
initialLoadingStatus: const ChatLoadingState.finish(),
|
||||
loadingPreviousStatus: const ChatLoadingState.finish(),
|
||||
streamingState: const StreamingState.done(),
|
||||
sendingState: const SendMessageState.done(),
|
||||
hasMorePrevMessage: true,
|
||||
relatedQuestions: [],
|
||||
factory ChatState.initial() => const ChatState(
|
||||
loadingState: ChatLoadingState.loading(),
|
||||
promptResponseState: PromptResponseState.ready,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
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 errorMessageTextKey = "errorMessageText";
|
||||
const systemUserId = "system";
|
||||
const aiResponseUserId = "0";
|
||||
|
||||
@@ -42,16 +39,25 @@ class ChatMessageRefSource {
|
||||
Map<String, dynamic> toJson() => _$ChatMessageRefSourceToJson(this);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class StreamingState with _$StreamingState {
|
||||
const factory StreamingState.streaming() = _Streaming;
|
||||
const factory StreamingState.done({FlowyError? error}) = _StreamDone;
|
||||
@JsonSerializable()
|
||||
class AIChatProgress {
|
||||
AIChatProgress({
|
||||
required this.step,
|
||||
});
|
||||
|
||||
factory AIChatProgress.fromJson(Map<String, dynamic> json) =>
|
||||
_$AIChatProgressFromJson(json);
|
||||
|
||||
final String step;
|
||||
|
||||
Map<String, dynamic> toJson() => _$AIChatProgressToJson(this);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SendMessageState with _$SendMessageState {
|
||||
const factory SendMessageState.sending() = _Sending;
|
||||
const factory SendMessageState.done({FlowyError? error}) = _SendDone;
|
||||
enum PromptResponseState {
|
||||
ready,
|
||||
sendingQuestion,
|
||||
awaitingAnswer,
|
||||
streamingAnswer,
|
||||
}
|
||||
|
||||
class ChatFile extends Equatable {
|
||||
@@ -100,30 +106,6 @@ class ChatFile extends Equatable {
|
||||
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
|
||||
@@ -138,45 +120,13 @@ extension ChatLoadingStateExtension on ChatLoadingState {
|
||||
}
|
||||
|
||||
enum OnetimeShotType {
|
||||
unknown,
|
||||
sendingMessage,
|
||||
relatedQuestion,
|
||||
invalidSendMesssage,
|
||||
error,
|
||||
}
|
||||
|
||||
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;
|
||||
return metadata?[onetimeShotType];
|
||||
}
|
||||
@@ -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,4 +1,3 @@
|
||||
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
@@ -7,14 +6,11 @@ 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 {},
|
||||
event.when(
|
||||
updateUploadState: (UploadFileIndicator indicator) {
|
||||
emit(state.copyWith(uploadFileIndicator: indicator));
|
||||
},
|
||||
@@ -28,7 +24,6 @@ class ChatInputFileBloc extends Bloc<ChatInputFileEvent, ChatInputFileState> {
|
||||
|
||||
@freezed
|
||||
class ChatInputFileEvent with _$ChatInputFileEvent {
|
||||
const factory ChatInputFileEvent.initial() = Initial;
|
||||
const factory ChatInputFileEvent.updateUploadState(
|
||||
UploadFileIndicator indicator,
|
||||
) = _UpdateUploadState;
|
||||
|
||||
@@ -13,7 +13,6 @@ class ChatMemberBloc extends Bloc<ChatMemberEvent, 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);
|
||||
@@ -51,7 +50,6 @@ class ChatMemberBloc extends Bloc<ChatMemberEvent, ChatMemberState> {
|
||||
|
||||
@freezed
|
||||
class ChatMemberEvent with _$ChatMemberEvent {
|
||||
const factory ChatMemberEvent.initial() = Initial;
|
||||
const factory ChatMemberEvent.getMemberInfo(
|
||||
String userId,
|
||||
) = _GetMemberInfo;
|
||||
|
||||
+79
-56
@@ -7,11 +7,11 @@ 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:appflowy_result/appflowy_result.dart';
|
||||
import 'package:nanoid/nanoid.dart';
|
||||
|
||||
/// Indicate file source from appflowy document
|
||||
const appflowySoruce = "appflowy";
|
||||
const appflowySource = "appflowy";
|
||||
|
||||
List<ChatFile> fileListFromMessageMetadata(
|
||||
Map<String, dynamic>? map,
|
||||
@@ -66,78 +66,101 @@ ChatFile? chatFileFromMap(Map<String, dynamic>? map) {
|
||||
return ChatFile.fromFilePath(filePath);
|
||||
}
|
||||
|
||||
List<ChatMessageRefSource> messageReferenceSource(String? s) {
|
||||
if (s == null || s.isEmpty || s == "null") {
|
||||
return [];
|
||||
class MetadataCollection {
|
||||
MetadataCollection({
|
||||
required this.sources,
|
||||
this.progress,
|
||||
});
|
||||
final List<ChatMessageRefSource> sources;
|
||||
final AIChatProgress? progress;
|
||||
}
|
||||
|
||||
MetadataCollection parseMetadata(String? s) {
|
||||
if (s == null || s.trim().isEmpty || s.toLowerCase() == "null") {
|
||||
return MetadataCollection(sources: []);
|
||||
}
|
||||
|
||||
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"}]
|
||||
AIChatProgress? progress;
|
||||
|
||||
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");
|
||||
try {
|
||||
final dynamic decodedJson = jsonDecode(s);
|
||||
if (decodedJson == null) {
|
||||
return MetadataCollection(sources: []);
|
||||
}
|
||||
} catch (e) {
|
||||
Log.error("Failed to parse metadata: $e");
|
||||
|
||||
void processMap(Map<String, dynamic> map) {
|
||||
if (map.containsKey("step") && map["step"] != null) {
|
||||
progress = AIChatProgress.fromJson(map);
|
||||
} else if (map.containsKey("id") && map["id"] != null) {
|
||||
metadata.add(ChatMessageRefSource.fromJson(map));
|
||||
} else {
|
||||
Log.info("Unsupported metadata format: $map");
|
||||
}
|
||||
}
|
||||
|
||||
if (decodedJson is Map<String, dynamic>) {
|
||||
processMap(decodedJson);
|
||||
} else if (decodedJson is List) {
|
||||
for (final element in decodedJson) {
|
||||
if (element is Map<String, dynamic>) {
|
||||
processMap(element);
|
||||
} else {
|
||||
Log.error("Invalid metadata element: $element");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.error("Invalid metadata format: $decodedJson");
|
||||
}
|
||||
} catch (e, stacktrace) {
|
||||
Log.error("Failed to parse metadata: $e, input: $s");
|
||||
Log.debug(stacktrace.toString());
|
||||
}
|
||||
|
||||
return metadata;
|
||||
return MetadataCollection(sources: metadata, progress: progress);
|
||||
}
|
||||
|
||||
Future<List<ChatMessageMetaPB>> metadataPBFromMetadata(
|
||||
Map<String, dynamic>? map,
|
||||
) async {
|
||||
if (map == null) return [];
|
||||
|
||||
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) {
|
||||
|
||||
for (final value in map.values) {
|
||||
switch (value) {
|
||||
case ViewActionPage(view: final view) when view.layout.isDocumentView:
|
||||
final payload = OpenDocumentPayloadPB(documentId: view.id);
|
||||
await DocumentEventGetDocumentText(payload).send().fold(
|
||||
(pb) {
|
||||
metadata.add(
|
||||
ChatMessageMetaPB(
|
||||
id: view.id,
|
||||
name: view.name,
|
||||
data: pb.text,
|
||||
dataType: ChatMessageMetaTypePB.Txt,
|
||||
source: appflowySource,
|
||||
),
|
||||
);
|
||||
},
|
||||
(err) => Log.error('Failed to get document text: $err'),
|
||||
);
|
||||
break;
|
||||
case ChatFile(
|
||||
filePath: final filePath,
|
||||
fileName: final fileName,
|
||||
fileType: final fileType,
|
||||
):
|
||||
metadata.add(
|
||||
ChatMessageMetaPB(
|
||||
id: nanoid(8),
|
||||
name: entry.value.fileName,
|
||||
data: entry.value.filePath,
|
||||
dataType: entry.value.fileType,
|
||||
source: entry.value.filePath,
|
||||
name: fileName,
|
||||
data: filePath,
|
||||
dataType: fileType,
|
||||
source: filePath,
|
||||
),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-22
@@ -2,7 +2,6 @@ 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 {
|
||||
@@ -14,34 +13,25 @@ class AnswerStream {
|
||||
_hasStarted = true;
|
||||
final newText = event.substring(5);
|
||||
_text += newText;
|
||||
if (_onData != null) {
|
||||
_onData!(_text);
|
||||
}
|
||||
_onData?.call(_text);
|
||||
} else if (event.startsWith("error:")) {
|
||||
_error = event.substring(5);
|
||||
if (_onError != null) {
|
||||
_onError!(_error!);
|
||||
}
|
||||
_onError?.call(_error!);
|
||||
} else if (event.startsWith("metadata:")) {
|
||||
if (_onMetadata != null) {
|
||||
final s = event.substring(9);
|
||||
_onMetadata!(messageReferenceSource(s));
|
||||
_onMetadata!(parseMetadata(s));
|
||||
}
|
||||
} else if (event == "AI_RESPONSE_LIMIT") {
|
||||
if (_onAIResponseLimit != null) {
|
||||
_onAIResponseLimit!();
|
||||
}
|
||||
_aiLimitReached = true;
|
||||
_onAIResponseLimit?.call();
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
if (_onEnd != null) {
|
||||
_onEnd!();
|
||||
}
|
||||
_onEnd?.call();
|
||||
},
|
||||
onError: (error) {
|
||||
if (_onError != null) {
|
||||
_onError!(error.toString());
|
||||
}
|
||||
_onError?.call(error.toString());
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -50,6 +40,7 @@ class AnswerStream {
|
||||
final StreamController<String> _controller = StreamController.broadcast();
|
||||
late StreamSubscription<String> _subscription;
|
||||
bool _hasStarted = false;
|
||||
bool _aiLimitReached = false;
|
||||
String? _error;
|
||||
String _text = "";
|
||||
|
||||
@@ -59,10 +50,11 @@ class AnswerStream {
|
||||
void Function()? _onEnd;
|
||||
void Function(String error)? _onError;
|
||||
void Function()? _onAIResponseLimit;
|
||||
void Function(List<ChatMessageRefSource> metadata)? _onMetadata;
|
||||
void Function(MetadataCollection metadataCollection)? _onMetadata;
|
||||
|
||||
int get nativePort => _port.sendPort.nativePort;
|
||||
bool get hasStarted => _hasStarted;
|
||||
bool get aiLimitReached => _aiLimitReached;
|
||||
String? get error => _error;
|
||||
String get text => _text;
|
||||
|
||||
@@ -78,7 +70,7 @@ class AnswerStream {
|
||||
void Function()? onEnd,
|
||||
void Function(String error)? onError,
|
||||
void Function()? onAIResponseLimit,
|
||||
void Function(List<ChatMessageRefSource> metadata)? onMetadata,
|
||||
void Function(MetadataCollection metadata)? onMetadata,
|
||||
}) {
|
||||
_onData = onData;
|
||||
_onStart = onStart;
|
||||
@@ -87,9 +79,7 @@ class AnswerStream {
|
||||
_onAIResponseLimit = onAIResponseLimit;
|
||||
_onMetadata = onMetadata;
|
||||
|
||||
if (_onStart != null) {
|
||||
_onStart!();
|
||||
}
|
||||
_onStart?.call();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_service.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'chat_side_panel_bloc.freezed.dart';
|
||||
|
||||
class ChatSidePanelBloc extends Bloc<ChatSidePanelEvent, ChatSidePanelState> {
|
||||
ChatSidePanelBloc({
|
||||
required this.chatId,
|
||||
}) : super(const ChatSidePanelState()) {
|
||||
on<ChatSidePanelEvent>(
|
||||
(event, emit) async {
|
||||
await event.when(
|
||||
selectedMetadata: (ChatMessageRefSource metadata) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
metadata: metadata,
|
||||
indicator: const ChatSidePanelIndicator.loading(),
|
||||
),
|
||||
);
|
||||
await ViewBackendService.getView(metadata.id).fold(
|
||||
(view) {
|
||||
if (!isClosed) {
|
||||
add(ChatSidePanelEvent.open(view));
|
||||
}
|
||||
},
|
||||
(err) => Log.error("Failed to get view: $err"),
|
||||
);
|
||||
},
|
||||
close: () {
|
||||
emit(state.copyWith(metadata: null, isShowPanel: false));
|
||||
},
|
||||
open: (ViewPB view) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
indicator: ChatSidePanelIndicator.ready(view),
|
||||
isShowPanel: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final String chatId;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatSidePanelEvent with _$ChatSidePanelEvent {
|
||||
const factory ChatSidePanelEvent.selectedMetadata(
|
||||
ChatMessageRefSource metadata,
|
||||
) = _SelectedMetadata;
|
||||
const factory ChatSidePanelEvent.close() = _Close;
|
||||
const factory ChatSidePanelEvent.open(ViewPB view) = _Open;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatSidePanelState with _$ChatSidePanelState {
|
||||
const factory ChatSidePanelState({
|
||||
ChatMessageRefSource? metadata,
|
||||
@Default(ChatSidePanelIndicator.loading()) ChatSidePanelIndicator indicator,
|
||||
@Default(false) bool isShowPanel,
|
||||
}) = _ChatSidePanelState;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatSidePanelIndicator with _$ChatSidePanelIndicator {
|
||||
const factory ChatSidePanelIndicator.ready(ViewPB view) = _Ready;
|
||||
const factory ChatSidePanelIndicator.loading() = _Loading;
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_service.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'chat_side_pannel_bloc.freezed.dart';
|
||||
|
||||
const double kDefaultSidePannelWidth = 500;
|
||||
|
||||
class ChatSidePannelBloc
|
||||
extends Bloc<ChatSidePannelEvent, ChatSidePannelState> {
|
||||
ChatSidePannelBloc({
|
||||
required this.chatId,
|
||||
}) : super(const ChatSidePannelState()) {
|
||||
on<ChatSidePannelEvent>(
|
||||
(event, emit) async {
|
||||
await event.when(
|
||||
selectedMetadata: (ChatMessageRefSource metadata) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
metadata: metadata,
|
||||
indicator: const ChatSidePannelIndicator.loading(),
|
||||
),
|
||||
);
|
||||
unawaited(
|
||||
ViewBackendService.getView(metadata.id).then(
|
||||
(result) {
|
||||
result.fold((view) {
|
||||
if (!isClosed) {
|
||||
add(ChatSidePannelEvent.open(view));
|
||||
}
|
||||
}, (err) {
|
||||
Log.error("Failed to get view: $err");
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
close: () {
|
||||
emit(state.copyWith(metadata: null, isShowPannel: false));
|
||||
},
|
||||
open: (ViewPB view) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
indicator: ChatSidePannelIndicator.ready(view),
|
||||
isShowPannel: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final String chatId;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatSidePannelEvent with _$ChatSidePannelEvent {
|
||||
const factory ChatSidePannelEvent.selectedMetadata(
|
||||
ChatMessageRefSource metadata,
|
||||
) = _SelectedMetadata;
|
||||
const factory ChatSidePannelEvent.close() = _Close;
|
||||
const factory ChatSidePannelEvent.open(ViewPB view) = _Open;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatSidePannelState with _$ChatSidePannelState {
|
||||
const factory ChatSidePannelState({
|
||||
ChatMessageRefSource? metadata,
|
||||
@Default(ChatSidePannelIndicator.loading())
|
||||
ChatSidePannelIndicator indicator,
|
||||
@Default(false) bool isShowPannel,
|
||||
}) = _ChatSidePannelState;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatSidePannelIndicator with _$ChatSidePannelIndicator {
|
||||
const factory ChatSidePannelIndicator.ready(ViewPB view) = _Ready;
|
||||
const factory ChatSidePannelIndicator.loading() = _Loading;
|
||||
}
|
||||
+72
-77
@@ -8,76 +8,19 @@ part 'chat_user_message_bloc.freezed.dart';
|
||||
class ChatUserMessageBloc
|
||||
extends Bloc<ChatUserMessageEvent, ChatUserMessageState> {
|
||||
ChatUserMessageBloc({
|
||||
required dynamic message,
|
||||
}) : super(
|
||||
ChatUserMessageState.initial(
|
||||
message,
|
||||
),
|
||||
) {
|
||||
required this.questionStream,
|
||||
required String text,
|
||||
}) : super(ChatUserMessageState.initial(text)) {
|
||||
_dispatch();
|
||||
_startListening();
|
||||
}
|
||||
|
||||
final QuestionStream? questionStream;
|
||||
|
||||
void _dispatch() {
|
||||
on<ChatUserMessageEvent>(
|
||||
(event, emit) {
|
||||
event.when(
|
||||
initial: () {
|
||||
if (state.stream != null) {
|
||||
if (!isClosed) {
|
||||
add(ChatUserMessageEvent.updateText(state.stream!.text));
|
||||
}
|
||||
}
|
||||
|
||||
state.stream?.listen(
|
||||
onData: (text) {
|
||||
if (!isClosed) {
|
||||
add(ChatUserMessageEvent.updateText(text));
|
||||
}
|
||||
},
|
||||
onMessageId: (messageId) {
|
||||
if (!isClosed) {
|
||||
add(ChatUserMessageEvent.updateMessageId(messageId));
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
if (!isClosed) {
|
||||
add(ChatUserMessageEvent.receiveError(error.toString()));
|
||||
}
|
||||
},
|
||||
onFileIndexStart: (indexName) {
|
||||
Log.debug("index start: $indexName");
|
||||
},
|
||||
onFileIndexEnd: (indexName) {
|
||||
Log.info("index end: $indexName");
|
||||
},
|
||||
onFileIndexFail: (indexName) {
|
||||
Log.debug("index fail: $indexName");
|
||||
},
|
||||
onIndexStart: () {
|
||||
if (!isClosed) {
|
||||
add(
|
||||
const ChatUserMessageEvent.updateQuestionState(
|
||||
QuestionMessageState.indexStart(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onIndexEnd: () {
|
||||
if (!isClosed) {
|
||||
add(
|
||||
const ChatUserMessageEvent.updateQuestionState(
|
||||
QuestionMessageState.indexEnd(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
if (!isClosed) {
|
||||
add(
|
||||
const ChatUserMessageEvent.updateQuestionState(
|
||||
QuestionMessageState.finish(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
updateText: (String text) {
|
||||
emit(state.copyWith(text: text));
|
||||
},
|
||||
@@ -92,11 +35,66 @@ class ChatUserMessageBloc
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _startListening() {
|
||||
questionStream?.listen(
|
||||
onData: (text) {
|
||||
if (!isClosed) {
|
||||
add(ChatUserMessageEvent.updateText(text));
|
||||
}
|
||||
},
|
||||
onMessageId: (messageId) {
|
||||
if (!isClosed) {
|
||||
add(ChatUserMessageEvent.updateMessageId(messageId));
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
if (!isClosed) {
|
||||
add(ChatUserMessageEvent.receiveError(error.toString()));
|
||||
}
|
||||
},
|
||||
onFileIndexStart: (indexName) {
|
||||
Log.debug("index start: $indexName");
|
||||
},
|
||||
onFileIndexEnd: (indexName) {
|
||||
Log.info("index end: $indexName");
|
||||
},
|
||||
onFileIndexFail: (indexName) {
|
||||
Log.debug("index fail: $indexName");
|
||||
},
|
||||
onIndexStart: () {
|
||||
if (!isClosed) {
|
||||
add(
|
||||
const ChatUserMessageEvent.updateQuestionState(
|
||||
QuestionMessageState.indexStart(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onIndexEnd: () {
|
||||
if (!isClosed) {
|
||||
add(
|
||||
const ChatUserMessageEvent.updateQuestionState(
|
||||
QuestionMessageState.indexEnd(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
if (!isClosed) {
|
||||
add(
|
||||
const ChatUserMessageEvent.updateQuestionState(
|
||||
QuestionMessageState.finish(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatUserMessageEvent with _$ChatUserMessageEvent {
|
||||
const factory ChatUserMessageEvent.initial() = Initial;
|
||||
const factory ChatUserMessageEvent.updateText(String text) = _UpdateText;
|
||||
const factory ChatUserMessageEvent.updateQuestionState(
|
||||
QuestionMessageState newState,
|
||||
@@ -110,17 +108,14 @@ class ChatUserMessageEvent with _$ChatUserMessageEvent {
|
||||
class ChatUserMessageState with _$ChatUserMessageState {
|
||||
const factory ChatUserMessageState({
|
||||
required String text,
|
||||
QuestionStream? stream,
|
||||
String? messageId,
|
||||
@Default(QuestionMessageState.finish()) QuestionMessageState messageState,
|
||||
required String? messageId,
|
||||
required QuestionMessageState messageState,
|
||||
}) = _ChatUserMessageState;
|
||||
|
||||
factory ChatUserMessageState.initial(
|
||||
dynamic message,
|
||||
) =>
|
||||
ChatUserMessageState(
|
||||
text: message is String ? message : "",
|
||||
stream: message is QuestionStream ? message : null,
|
||||
factory ChatUserMessageState.initial(String message) => ChatUserMessageState(
|
||||
text: message,
|
||||
messageId: null,
|
||||
messageState: const QuestionMessageState.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
import 'chat_message_service.dart';
|
||||
|
||||
part 'chat_user_message_bubble_bloc.freezed.dart';
|
||||
|
||||
class ChatUserMessageBubbleBloc
|
||||
extends Bloc<ChatUserMessageBubbleEvent, ChatUserMessageBubbleState> {
|
||||
ChatUserMessageBubbleBloc({
|
||||
required Message message,
|
||||
}) : super(
|
||||
ChatUserMessageBubbleState.initial(
|
||||
message,
|
||||
_getFiles(message.metadata),
|
||||
),
|
||||
) {
|
||||
on<ChatUserMessageBubbleEvent>(
|
||||
(event, emit) async {
|
||||
event.when(
|
||||
initial: () {},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<ChatFile> _getFiles(Map<String, dynamic>? metadata) {
|
||||
if (metadata == null) {
|
||||
return [];
|
||||
}
|
||||
final refSourceMetadata = metadata[messageRefSourceJsonStringKey] as String?;
|
||||
final files = metadata[messageChatFileListKey] as List<ChatFile>?;
|
||||
|
||||
if (refSourceMetadata != null) {
|
||||
return chatFilesFromMetadataString(refSourceMetadata);
|
||||
}
|
||||
return files ?? [];
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatUserMessageBubbleEvent with _$ChatUserMessageBubbleEvent {
|
||||
const factory ChatUserMessageBubbleEvent.initial() = Initial;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatUserMessageBubbleState with _$ChatUserMessageBubbleState {
|
||||
const factory ChatUserMessageBubbleState({
|
||||
required Message message,
|
||||
required List<ChatFile> files,
|
||||
}) = _ChatUserMessageBubbleState;
|
||||
|
||||
factory ChatUserMessageBubbleState.initial(
|
||||
Message message,
|
||||
List<ChatFile> files,
|
||||
) =>
|
||||
ChatUserMessageBubbleState(message: message, files: files);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/chat_page.dart';
|
||||
import 'package:appflowy/plugins/util.dart';
|
||||
import 'package:appflowy/startup/plugin/plugin.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart';
|
||||
@@ -85,12 +86,16 @@ class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder
|
||||
final ViewPluginNotifier notifier;
|
||||
int? deletedViewIndex;
|
||||
|
||||
@override
|
||||
String? get viewName => notifier.view.nameOrDefault;
|
||||
|
||||
@override
|
||||
Widget get leftBarItem =>
|
||||
ViewTitleBar(key: ValueKey(notifier.view.id), view: notifier.view);
|
||||
|
||||
@override
|
||||
Widget tabBarItem(String pluginId) => ViewTabBarItem(view: notifier.view);
|
||||
Widget tabBarItem(String pluginId, [bool shortForm = false]) =>
|
||||
ViewTabBarItem(view: notifier.view, shortForm: shortForm);
|
||||
|
||||
@override
|
||||
Widget buildWidget({
|
||||
|
||||
@@ -1,62 +1,34 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_file_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_input_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/ai_prompt_input_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_related_question.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/message/ai_message_bubble.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/message/other_user_message_bubble.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/message/user_message_bubble.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:desktop_drop/desktop_drop.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart';
|
||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart' show Chat;
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart'
|
||||
hide ChatAnimatedListReversed;
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import 'application/chat_member_bloc.dart';
|
||||
import 'application/chat_side_pannel_bloc.dart';
|
||||
import 'presentation/chat_input/chat_input.dart';
|
||||
import 'presentation/chat_side_pannel.dart';
|
||||
import 'presentation/chat_theme.dart';
|
||||
import 'presentation/chat_user_invalid_message.dart';
|
||||
import 'application/chat_side_panel_bloc.dart';
|
||||
import 'presentation/animated_chat_list.dart';
|
||||
import 'presentation/chat_input/desktop_ai_prompt_input.dart';
|
||||
import 'presentation/chat_input/mobile_ai_prompt_input.dart';
|
||||
import 'presentation/chat_side_panel.dart';
|
||||
import 'presentation/chat_welcome_page.dart';
|
||||
import 'presentation/layout_define.dart';
|
||||
import 'presentation/message/ai_text_message.dart';
|
||||
import 'presentation/message/error_text_message.dart';
|
||||
import 'presentation/message/user_text_message.dart';
|
||||
|
||||
class AIChatUILayout {
|
||||
static EdgeInsets get chatPadding =>
|
||||
isMobile ? EdgeInsets.zero : const EdgeInsets.symmetric(horizontal: 20);
|
||||
|
||||
static EdgeInsets get welcomePagePadding => isMobile
|
||||
? const EdgeInsets.symmetric(horizontal: 20)
|
||||
: const EdgeInsets.symmetric(horizontal: 50);
|
||||
|
||||
static double get messageWidthRatio => 0.85;
|
||||
|
||||
static EdgeInsets safeAreaInsets(BuildContext context) {
|
||||
final query = MediaQuery.of(context);
|
||||
return isMobile
|
||||
? EdgeInsets.fromLTRB(
|
||||
query.padding.left,
|
||||
0,
|
||||
query.padding.right,
|
||||
query.viewInsets.bottom + query.padding.bottom,
|
||||
)
|
||||
: const EdgeInsets.symmetric(horizontal: 50) +
|
||||
const EdgeInsets.only(bottom: 20);
|
||||
}
|
||||
}
|
||||
import 'presentation/scroll_to_bottom.dart';
|
||||
|
||||
class AIChatPage extends StatelessWidget {
|
||||
const AIChatPage({
|
||||
@@ -72,62 +44,54 @@ class AIChatPage extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
/// [ChatBloc] is used to handle chat messages including send/receive message
|
||||
BlocProvider(
|
||||
create: (_) => ChatBloc(
|
||||
view: view,
|
||||
userProfile: userProfile,
|
||||
)..add(const ChatEvent.initialLoad()),
|
||||
),
|
||||
|
||||
/// [ChatFileBloc] is used to handle file indexing as a chat context
|
||||
BlocProvider(
|
||||
create: (_) => ChatFileBloc()..add(const ChatFileEvent.initial()),
|
||||
),
|
||||
|
||||
/// [ChatInputStateBloc] is used to handle chat input text field state
|
||||
BlocProvider(
|
||||
create: (_) =>
|
||||
ChatInputStateBloc()..add(const ChatInputStateEvent.started()),
|
||||
),
|
||||
BlocProvider(create: (_) => ChatSidePannelBloc(chatId: view.id)),
|
||||
BlocProvider(create: (_) => ChatMemberBloc()),
|
||||
],
|
||||
child: BlocBuilder<ChatFileBloc, ChatFileState>(
|
||||
builder: (context, state) {
|
||||
return DropTarget(
|
||||
onDragDone: (DropDoneDetails detail) async {
|
||||
if (state.supportChatWithFile) {
|
||||
for (final file in detail.files) {
|
||||
context
|
||||
.read<ChatFileBloc>()
|
||||
.add(ChatFileEvent.newFile(file.path, file.name));
|
||||
}
|
||||
}
|
||||
},
|
||||
child: _ChatContentPage(
|
||||
view: view,
|
||||
userProfile: userProfile,
|
||||
),
|
||||
);
|
||||
},
|
||||
if (userProfile.authenticator != AuthenticatorPB.AppFlowyCloud) {
|
||||
return Center(
|
||||
child: FlowyText(
|
||||
LocaleKeys.chat_unsupportedCloudPrompt.tr(),
|
||||
fontSize: 20,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Center(
|
||||
child: FlowyText(
|
||||
LocaleKeys.chat_unsupportedCloudPrompt.tr(),
|
||||
fontSize: 20,
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
/// [ChatBloc] is used to handle chat messages including send/receive message
|
||||
BlocProvider(
|
||||
create: (_) => ChatBloc(
|
||||
chatId: view.id,
|
||||
userId: userProfile.id.toString(),
|
||||
),
|
||||
),
|
||||
|
||||
/// [AIPromptInputBloc] is used to handle the user prompt
|
||||
BlocProvider(create: (_) => AIPromptInputBloc()),
|
||||
BlocProvider(create: (_) => ChatSidePanelBloc(chatId: view.id)),
|
||||
BlocProvider(create: (_) => ChatMemberBloc()),
|
||||
],
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
return DropTarget(
|
||||
onDragDone: (DropDoneDetails detail) async {
|
||||
if (context.read<AIPromptInputBloc>().state.supportChatWithFile) {
|
||||
for (final file in detail.files) {
|
||||
context
|
||||
.read<AIPromptInputBloc>()
|
||||
.add(AIPromptInputEvent.newFile(file.path, file.name));
|
||||
}
|
||||
}
|
||||
},
|
||||
child: _ChatContentPage(
|
||||
view: view,
|
||||
userProfile: userProfile,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatContentPage extends StatefulWidget {
|
||||
class _ChatContentPage extends StatelessWidget {
|
||||
const _ChatContentPage({
|
||||
required this.view,
|
||||
required this.userProfile,
|
||||
@@ -136,354 +100,282 @@ class _ChatContentPage extends StatefulWidget {
|
||||
final UserProfilePB userProfile;
|
||||
final ViewPB view;
|
||||
|
||||
@override
|
||||
State<_ChatContentPage> createState() => _ChatContentPageState();
|
||||
}
|
||||
|
||||
class _ChatContentPageState extends State<_ChatContentPage> {
|
||||
late types.User _user;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_user = types.User(id: widget.userProfile.id.toString());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) {
|
||||
if (UniversalPlatform.isDesktop) {
|
||||
return BlocSelector<ChatSidePannelBloc, ChatSidePannelState, bool>(
|
||||
selector: (state) => state.isShowPannel,
|
||||
builder: (context, isShowPannel) {
|
||||
return LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
final double chatOffsetX = isShowPannel
|
||||
? 60
|
||||
: (constraints.maxWidth > 784
|
||||
? (constraints.maxWidth - 784) / 2.0
|
||||
: 60);
|
||||
if (UniversalPlatform.isDesktop) {
|
||||
return BlocSelector<ChatSidePanelBloc, ChatSidePanelState, bool>(
|
||||
selector: (state) => state.isShowPanel,
|
||||
builder: (context, isShowPanel) {
|
||||
return LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
final sidePanelRatio = isShowPanel ? 0.33 : 0.0;
|
||||
final chatWidth = constraints.maxWidth * (1 - sidePanelRatio);
|
||||
final sidePanelWidth =
|
||||
constraints.maxWidth * sidePanelRatio - 1.0;
|
||||
|
||||
final double width = isShowPannel
|
||||
? (constraints.maxWidth - chatOffsetX * 2) * 0.46
|
||||
: min(constraints.maxWidth - chatOffsetX * 2, 784);
|
||||
|
||||
final double sidePannelOffsetX = chatOffsetX + width;
|
||||
|
||||
return Stack(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
children: [
|
||||
buildChatWidget()
|
||||
.constrained(width: width)
|
||||
.positioned(
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: chatOffsetX,
|
||||
animate: true,
|
||||
return Row(
|
||||
children: [
|
||||
Center(
|
||||
child: buildChatWidget(context)
|
||||
.constrained(
|
||||
maxWidth: 784,
|
||||
)
|
||||
.padding(horizontal: 60)
|
||||
.animate(
|
||||
const Duration(milliseconds: 200),
|
||||
Curves.easeOut,
|
||||
),
|
||||
).constrained(width: chatWidth),
|
||||
if (isShowPanel) ...[
|
||||
const VerticalDivider(
|
||||
width: 1.0,
|
||||
thickness: 1.0,
|
||||
),
|
||||
buildChatSidePanel()
|
||||
.constrained(width: sidePanelWidth)
|
||||
.animate(
|
||||
const Duration(milliseconds: 200),
|
||||
Curves.easeOut,
|
||||
),
|
||||
if (isShowPannel)
|
||||
buildChatSidePannel()
|
||||
.positioned(
|
||||
left: sidePannelOffsetX,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
animate: true,
|
||||
)
|
||||
.animate(
|
||||
const Duration(milliseconds: 200),
|
||||
Curves.easeOut,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 784),
|
||||
child: buildChatWidget(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Center(
|
||||
child: FlowyText(
|
||||
LocaleKeys.chat_unsupportedCloudPrompt.tr(),
|
||||
fontSize: 20,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildChatSidePannel() {
|
||||
if (UniversalPlatform.isDesktop) {
|
||||
return BlocBuilder<ChatSidePannelBloc, ChatSidePannelState>(
|
||||
builder: (context, state) {
|
||||
if (state.metadata != null) {
|
||||
return const ChatSidePannel();
|
||||
} else {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// TODO(lucas): implement mobile chat side panel
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildChatWidget() {
|
||||
return BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (blocContext, state) => Chat(
|
||||
key: ValueKey(widget.view.id),
|
||||
messages: state.messages,
|
||||
onSendPressed: (_) {
|
||||
// We use custom bottom widget for chat input, so
|
||||
// do not need to handle this event.
|
||||
},
|
||||
customBottomWidget: _buildBottom(blocContext),
|
||||
user: _user,
|
||||
theme: buildTheme(context),
|
||||
onEndReached: () async {
|
||||
if (state.hasMorePrevMessage &&
|
||||
state.loadingPreviousStatus.isFinish) {
|
||||
blocContext
|
||||
.read<ChatBloc>()
|
||||
.add(const ChatEvent.startLoadingPrevMessage());
|
||||
}
|
||||
},
|
||||
emptyState: BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (_, state) => state.initialLoadingStatus.isFinish
|
||||
? Padding(
|
||||
padding: AIChatUILayout.welcomePagePadding,
|
||||
child: ChatWelcomePage(
|
||||
userProfile: widget.userProfile,
|
||||
onSelectedQuestion: (question) => blocContext
|
||||
.read<ChatBloc>()
|
||||
.add(ChatEvent.sendMessage(message: question)),
|
||||
),
|
||||
)
|
||||
: const Center(
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
),
|
||||
),
|
||||
messageWidthRatio: AIChatUILayout.messageWidthRatio,
|
||||
textMessageBuilder: (
|
||||
textMessage, {
|
||||
required messageWidth,
|
||||
required showName,
|
||||
}) =>
|
||||
_buildTextMessage(blocContext, textMessage),
|
||||
bubbleBuilder: (
|
||||
child, {
|
||||
required message,
|
||||
required nextMessageInGroup,
|
||||
}) =>
|
||||
_buildBubble(blocContext, message, child, state),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBubble(
|
||||
BuildContext blocContext,
|
||||
Message message,
|
||||
Widget child,
|
||||
ChatState state,
|
||||
) {
|
||||
if (message.author.id == _user.id) {
|
||||
return ChatUserMessageBubble(
|
||||
message: message,
|
||||
child: child,
|
||||
);
|
||||
} else if (isOtherUserMessage(message)) {
|
||||
return OtherUserMessageBubble(
|
||||
message: message,
|
||||
child: child,
|
||||
);
|
||||
} else {
|
||||
return _buildAIBubble(message, blocContext, state, child);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildTextMessage(BuildContext context, TextMessage message) {
|
||||
if (message.author.id == _user.id) {
|
||||
final stream = message.metadata?["$QuestionStream"];
|
||||
return ChatUserMessageWidget(
|
||||
key: ValueKey(message.id),
|
||||
user: message.author,
|
||||
message: stream is QuestionStream ? stream : message.text,
|
||||
);
|
||||
} else {
|
||||
final stream = message.metadata?["$AnswerStream"];
|
||||
final questionId = message.metadata?[messageQuestionIdKey];
|
||||
final refSourceJsonString =
|
||||
message.metadata?[messageRefSourceJsonStringKey] as String?;
|
||||
return ChatAIMessageWidget(
|
||||
user: message.author,
|
||||
messageUserId: message.id,
|
||||
message: stream is AnswerStream ? stream : message.text,
|
||||
key: ValueKey(message.id),
|
||||
questionId: questionId,
|
||||
chatId: widget.view.id,
|
||||
refSourceJsonString: refSourceJsonString,
|
||||
onSelectedMetadata: (ChatMessageRefSource metadata) {
|
||||
context.read<ChatSidePannelBloc>().add(
|
||||
ChatSidePannelEvent.selectedMetadata(metadata),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 784),
|
||||
child: buildChatWidget(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildAIBubble(
|
||||
Message message,
|
||||
BuildContext blocContext,
|
||||
ChatState state,
|
||||
Widget child,
|
||||
Widget buildChatSidePanel() {
|
||||
return BlocBuilder<ChatSidePanelBloc, ChatSidePanelState>(
|
||||
builder: (context, state) {
|
||||
if (state.metadata == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return const ChatSidePanel();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildChatWidget(BuildContext context) {
|
||||
return ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
|
||||
child: BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (context, state) {
|
||||
return state.loadingState.when(
|
||||
loading: () {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
);
|
||||
},
|
||||
finish: (_) {
|
||||
final chatController = context.read<ChatBloc>().chatController;
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Chat(
|
||||
chatController: chatController,
|
||||
user: User(id: userProfile.id.toString()),
|
||||
darkTheme: ChatTheme.fromThemeData(Theme.of(context)),
|
||||
theme: ChatTheme.fromThemeData(Theme.of(context)),
|
||||
builders: Builders(
|
||||
inputBuilder: (_) => const SizedBox.shrink(),
|
||||
textMessageBuilder: _buildTextMessage,
|
||||
chatMessageBuilder: _buildChatMessage,
|
||||
scrollToBottomBuilder: _buildScrollToBottom,
|
||||
chatAnimatedListBuilder: _buildChatAnimatedList,
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildInput(context),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextMessage(
|
||||
BuildContext context,
|
||||
TextMessage message,
|
||||
) {
|
||||
final messageType = onetimeMessageTypeFromMeta(
|
||||
message.metadata,
|
||||
);
|
||||
|
||||
if (messageType == OnetimeShotType.invalidSendMesssage) {
|
||||
return ChatInvalidUserMessage(
|
||||
message: message,
|
||||
if (messageType == OnetimeShotType.error) {
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage: message.metadata?[errorMessageTextKey] ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
if (messageType == OnetimeShotType.relatedQuestion) {
|
||||
return RelatedQuestionList(
|
||||
relatedQuestions: message.metadata!['questions'],
|
||||
onQuestionSelected: (question) {
|
||||
blocContext
|
||||
context
|
||||
.read<ChatBloc>()
|
||||
.add(ChatEvent.sendMessage(message: question));
|
||||
blocContext
|
||||
.read<ChatBloc>()
|
||||
.add(const ChatEvent.clearReleatedQuestion());
|
||||
},
|
||||
chatId: widget.view.id,
|
||||
relatedQuestions: state.relatedQuestions,
|
||||
);
|
||||
}
|
||||
|
||||
return ChatAIMessageBubble(
|
||||
if (message.author.id == userProfile.id.toString()) {
|
||||
return ChatUserMessageWidget(
|
||||
user: message.author,
|
||||
message: message,
|
||||
isCurrentUser: true,
|
||||
);
|
||||
}
|
||||
|
||||
if (isOtherUserMessage(message)) {
|
||||
return ChatUserMessageWidget(
|
||||
user: message.author,
|
||||
message: message,
|
||||
isCurrentUser: false,
|
||||
);
|
||||
}
|
||||
|
||||
final stream = message.metadata?["$AnswerStream"];
|
||||
final questionId = message.metadata?[messageQuestionIdKey];
|
||||
final refSourceJsonString =
|
||||
message.metadata?[messageRefSourceJsonStringKey] as String?;
|
||||
|
||||
return BlocSelector<ChatBloc, ChatState, bool>(
|
||||
selector: (state) {
|
||||
final chatController = context.read<ChatBloc>().chatController;
|
||||
final messages = chatController.messages
|
||||
.where((e) => onetimeMessageTypeFromMeta(e.metadata) == null);
|
||||
return messages.isEmpty ? false : messages.last.id == message.id;
|
||||
},
|
||||
builder: (context, isLastMessage) {
|
||||
return ChatAIMessageWidget(
|
||||
user: message.author,
|
||||
messageUserId: message.id,
|
||||
message: message,
|
||||
stream: stream is AnswerStream ? stream : null,
|
||||
questionId: questionId,
|
||||
chatId: view.id,
|
||||
refSourceJsonString: refSourceJsonString,
|
||||
isLastMessage: isLastMessage,
|
||||
onSelectedMetadata: (metadata) {
|
||||
context
|
||||
.read<ChatSidePanelBloc>()
|
||||
.add(ChatSidePanelEvent.selectedMetadata(metadata));
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChatMessage(
|
||||
BuildContext context,
|
||||
Message message,
|
||||
Animation<double> animation,
|
||||
Widget child,
|
||||
) {
|
||||
return ChatMessage(
|
||||
message: message,
|
||||
customMessageType: messageType,
|
||||
animation: animation,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12.0),
|
||||
receivedMessageScaleAnimationAlignment: Alignment.center,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottom(BuildContext context) {
|
||||
return ClipRect(
|
||||
child: Padding(
|
||||
padding: AIChatUILayout.safeAreaInsets(context),
|
||||
child: BlocBuilder<ChatInputStateBloc, ChatInputStateState>(
|
||||
builder: (context, state) {
|
||||
// Show different hint text based on the AI type
|
||||
final aiType = state.aiType;
|
||||
final hintText = state.aiType.when(
|
||||
appflowyAI: () => LocaleKeys.chat_inputMessageHint.tr(),
|
||||
localAI: () => LocaleKeys.chat_inputLocalAIMessageHint.tr(),
|
||||
);
|
||||
Widget _buildScrollToBottom(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
VoidCallback onPressed,
|
||||
) {
|
||||
return CustomScrollToBottom(
|
||||
animation: animation,
|
||||
onPressed: onPressed,
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
BlocSelector<ChatBloc, ChatState, bool>(
|
||||
selector: (state) => state.canSendMessage,
|
||||
builder: (context, canSendMessage) {
|
||||
return ChatInput(
|
||||
aiType: aiType,
|
||||
chatId: widget.view.id,
|
||||
onSendPressed: (message) {
|
||||
context.read<ChatBloc>().add(
|
||||
ChatEvent.sendMessage(
|
||||
message: message.text,
|
||||
metadata: message.metadata,
|
||||
),
|
||||
);
|
||||
},
|
||||
isStreaming: !canSendMessage,
|
||||
onStopStreaming: () {
|
||||
context
|
||||
.read<ChatBloc>()
|
||||
.add(const ChatEvent.stopStream());
|
||||
},
|
||||
hintText: hintText,
|
||||
);
|
||||
Widget _buildChatAnimatedList(
|
||||
BuildContext context,
|
||||
ScrollController scrollController,
|
||||
ChatItem itemBuilder,
|
||||
) {
|
||||
final bloc = context.read<ChatBloc>();
|
||||
|
||||
if (bloc.chatController.messages.isEmpty) {
|
||||
return ChatWelcomePage(
|
||||
userProfile: userProfile,
|
||||
onSelectedQuestion: (question) {
|
||||
bloc.add(ChatEvent.sendMessage(message: question));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return ChatAnimatedListReversed(
|
||||
scrollController: scrollController,
|
||||
itemBuilder: itemBuilder,
|
||||
onLoadPreviousMessages: () {
|
||||
bloc.add(const ChatEvent.loadPreviousMessages());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInput(BuildContext context) {
|
||||
return Padding(
|
||||
padding: AIChatUILayout.safeAreaInsets(context),
|
||||
child: BlocSelector<ChatBloc, ChatState, bool>(
|
||||
selector: (state) {
|
||||
return state.promptResponseState == PromptResponseState.ready;
|
||||
},
|
||||
builder: (context, canSendMessage) {
|
||||
return UniversalPlatform.isDesktop
|
||||
? DesktopAIPromptInput(
|
||||
chatId: view.id,
|
||||
indicateFocus: true,
|
||||
onSubmitted: (text, metadata) {
|
||||
context.read<ChatBloc>().add(
|
||||
ChatEvent.sendMessage(
|
||||
message: text,
|
||||
metadata: metadata,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const VSpace(6),
|
||||
if (UniversalPlatform.isDesktop)
|
||||
Opacity(
|
||||
opacity: 0.6,
|
||||
child: FlowyText(
|
||||
LocaleKeys.chat_aiMistakePrompt.tr(),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
isStreaming: !canSendMessage,
|
||||
onStopStreaming: () {
|
||||
context.read<ChatBloc>().add(const ChatEvent.stopStream());
|
||||
},
|
||||
)
|
||||
: MobileAIPromptInput(
|
||||
chatId: view.id,
|
||||
onSubmitted: (text, metadata) {
|
||||
context.read<ChatBloc>().add(
|
||||
ChatEvent.sendMessage(
|
||||
message: text,
|
||||
metadata: metadata,
|
||||
),
|
||||
);
|
||||
},
|
||||
isStreaming: !canSendMessage,
|
||||
onStopStreaming: () {
|
||||
context.read<ChatBloc>().add(const ChatEvent.stopStream());
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AFDefaultChatTheme buildTheme(BuildContext context) {
|
||||
return AFDefaultChatTheme(
|
||||
backgroundColor: AFThemeExtension.of(context).background,
|
||||
primaryColor: Theme.of(context).colorScheme.primary,
|
||||
secondaryColor: AFThemeExtension.of(context).tint1,
|
||||
receivedMessageDocumentIconColor: Theme.of(context).primaryColor,
|
||||
receivedMessageCaptionTextStyle: TextStyle(
|
||||
color: AFThemeExtension.of(context).textColor,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
),
|
||||
receivedMessageBodyTextStyle: TextStyle(
|
||||
color: AFThemeExtension.of(context).textColor,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
),
|
||||
receivedMessageLinkTitleTextStyle: TextStyle(
|
||||
color: AFThemeExtension.of(context).textColor,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
),
|
||||
receivedMessageBodyLinkTextStyle: const TextStyle(
|
||||
color: Colors.lightBlue,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
),
|
||||
sentMessageBodyTextStyle: TextStyle(
|
||||
color: AFThemeExtension.of(context).textColor,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
),
|
||||
sentMessageBodyLinkTextStyle: const TextStyle(
|
||||
color: Colors.blue,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
),
|
||||
inputElevation: 2,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
// ignore_for_file: implementation_imports
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:diffutil_dart/diffutil.dart' as diffutil;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:flutter_chat_ui/src/scroll_to_bottom.dart';
|
||||
import 'package:flutter_chat_ui/src/utils/message_list_diff.dart';
|
||||
|
||||
class ChatAnimatedListReversed extends StatefulWidget {
|
||||
const ChatAnimatedListReversed({
|
||||
super.key,
|
||||
required this.scrollController,
|
||||
required this.itemBuilder,
|
||||
this.insertAnimationDuration = const Duration(milliseconds: 250),
|
||||
this.removeAnimationDuration = const Duration(milliseconds: 250),
|
||||
this.scrollToEndAnimationDuration = const Duration(milliseconds: 250),
|
||||
this.scrollToBottomAppearanceDelay = const Duration(milliseconds: 250),
|
||||
this.bottomPadding = 8,
|
||||
this.onLoadPreviousMessages,
|
||||
});
|
||||
|
||||
final ScrollController scrollController;
|
||||
final ChatItem itemBuilder;
|
||||
final Duration insertAnimationDuration;
|
||||
final Duration removeAnimationDuration;
|
||||
final Duration scrollToEndAnimationDuration;
|
||||
final Duration scrollToBottomAppearanceDelay;
|
||||
final double? bottomPadding;
|
||||
final VoidCallback? onLoadPreviousMessages;
|
||||
|
||||
@override
|
||||
ChatAnimatedListReversedState createState() =>
|
||||
ChatAnimatedListReversedState();
|
||||
}
|
||||
|
||||
class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final GlobalKey<SliverAnimatedListState> _listKey = GlobalKey();
|
||||
late ChatController _chatController;
|
||||
late List<Message> _oldList;
|
||||
late StreamSubscription<ChatOperation> _operationsSubscription;
|
||||
|
||||
late final AnimationController _scrollToBottomController;
|
||||
late final Animation<double> _scrollToBottomAnimation;
|
||||
Timer? _scrollToBottomShowTimer;
|
||||
|
||||
bool _userHasScrolled = false;
|
||||
bool _isScrollingToBottom = false;
|
||||
String _lastInsertedMessageId = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_chatController = Provider.of<ChatController>(context, listen: false);
|
||||
// TODO: Add assert for messages having same id
|
||||
_oldList = List.from(_chatController.messages);
|
||||
_operationsSubscription = _chatController.operationsStream.listen((event) {
|
||||
switch (event.type) {
|
||||
case ChatOperationType.insert:
|
||||
assert(
|
||||
event.index != null,
|
||||
'Index must be provided when inserting a message.',
|
||||
);
|
||||
assert(
|
||||
event.message != null,
|
||||
'Message must be provided when inserting a message.',
|
||||
);
|
||||
_onInserted(0, event.message!);
|
||||
_oldList = List.from(_chatController.messages);
|
||||
break;
|
||||
case ChatOperationType.remove:
|
||||
assert(
|
||||
event.index != null,
|
||||
'Index must be provided when removing a message.',
|
||||
);
|
||||
assert(
|
||||
event.message != null,
|
||||
'Message must be provided when removing a message.',
|
||||
);
|
||||
_onRemoved(event.index!, event.message!);
|
||||
_oldList = List.from(_chatController.messages);
|
||||
break;
|
||||
case ChatOperationType.set:
|
||||
final newList = _chatController.messages;
|
||||
|
||||
final updates = diffutil
|
||||
.calculateDiff<Message>(
|
||||
MessageListDiff(_oldList, newList),
|
||||
)
|
||||
.getUpdatesWithData();
|
||||
|
||||
for (var i = updates.length - 1; i >= 0; i--) {
|
||||
_onDiffUpdate(updates.elementAt(i));
|
||||
}
|
||||
|
||||
_oldList = List.from(newList);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
_scrollToBottomController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
_scrollToBottomAnimation = CurvedAnimation(
|
||||
parent: _scrollToBottomController,
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
|
||||
widget.scrollController.addListener(_handleLoadPreviousMessages);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_handleLoadPreviousMessages();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_scrollToBottomShowTimer?.cancel();
|
||||
_scrollToBottomController.dispose();
|
||||
_operationsSubscription.cancel();
|
||||
widget.scrollController.removeListener(_handleLoadPreviousMessages);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final builders = context.watch<Builders>();
|
||||
|
||||
return NotificationListener<Notification>(
|
||||
onNotification: (notification) {
|
||||
if (notification is UserScrollNotification) {
|
||||
// When user scrolls up, save it to `_userHasScrolled`
|
||||
if (notification.direction == ScrollDirection.reverse) {
|
||||
_userHasScrolled = true;
|
||||
} else {
|
||||
// When user overscolls to the bottom or stays idle at the bottom, set `_userHasScrolled` to false
|
||||
if (notification.metrics.pixels ==
|
||||
notification.metrics.minScrollExtent) {
|
||||
_userHasScrolled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (notification is ScrollUpdateNotification) {
|
||||
_handleToggleScrollToBottom();
|
||||
}
|
||||
|
||||
// Allow other listeners to get the notification
|
||||
return false;
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
CustomScrollView(
|
||||
reverse: true,
|
||||
controller: widget.scrollController,
|
||||
slivers: <Widget>[
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.only(
|
||||
top: widget.bottomPadding ?? 0,
|
||||
),
|
||||
),
|
||||
SliverAnimatedList(
|
||||
key: _listKey,
|
||||
initialItemCount: _chatController.messages.length,
|
||||
itemBuilder: (
|
||||
BuildContext context,
|
||||
int index,
|
||||
Animation<double> animation,
|
||||
) {
|
||||
final message = _chatController.messages[
|
||||
max(_chatController.messages.length - 1 - index, 0)];
|
||||
return widget.itemBuilder(
|
||||
context,
|
||||
animation,
|
||||
message,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
builders.scrollToBottomBuilder?.call(
|
||||
context,
|
||||
_scrollToBottomAnimation,
|
||||
_handleScrollToBottom,
|
||||
) ??
|
||||
ScrollToBottom(
|
||||
animation: _scrollToBottomAnimation,
|
||||
onPressed: _handleScrollToBottom,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _subsequentScrollToEnd(Message data) async {
|
||||
final user = Provider.of<User>(context, listen: false);
|
||||
|
||||
// We only want to scroll to the bottom if user has not scrolled up
|
||||
// or if the message is sent by the current user.
|
||||
if (data.id == _lastInsertedMessageId &&
|
||||
widget.scrollController.offset >
|
||||
widget.scrollController.position.minScrollExtent &&
|
||||
(user.id == data.author.id || !_userHasScrolled)) {
|
||||
if (widget.scrollToEndAnimationDuration == Duration.zero) {
|
||||
widget.scrollController
|
||||
.jumpTo(widget.scrollController.position.minScrollExtent);
|
||||
} else {
|
||||
await widget.scrollController.animateTo(
|
||||
widget.scrollController.position.minScrollExtent,
|
||||
duration: widget.scrollToEndAnimationDuration,
|
||||
curve: Curves.linearToEaseOut,
|
||||
);
|
||||
}
|
||||
|
||||
if (!widget.scrollController.hasClients || !mounted) return;
|
||||
|
||||
// Because of the issue I have opened here https://github.com/flutter/flutter/issues/129768
|
||||
// we need an additional jump to the end. Sometimes Flutter
|
||||
// will not scroll to the very end. Sometimes it will not scroll to the
|
||||
// very end even with this, so this is something that needs to be
|
||||
// addressed by the Flutter team.
|
||||
//
|
||||
// Additionally here we have a check for the message id, because
|
||||
// if new message arrives in the meantime it will trigger another
|
||||
// scroll to the end animation, making this logic redundant.
|
||||
if (data.id == _lastInsertedMessageId &&
|
||||
widget.scrollController.offset >
|
||||
widget.scrollController.position.minScrollExtent &&
|
||||
(user.id == data.author.id || !_userHasScrolled)) {
|
||||
widget.scrollController
|
||||
.jumpTo(widget.scrollController.position.minScrollExtent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollToEnd(Message data) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) {
|
||||
if (!widget.scrollController.hasClients || !mounted) return;
|
||||
|
||||
_subsequentScrollToEnd(data);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _handleScrollToBottom() {
|
||||
_isScrollingToBottom = true;
|
||||
_scrollToBottomController.reverse();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
if (!widget.scrollController.hasClients || !mounted) return;
|
||||
|
||||
if (widget.scrollToEndAnimationDuration == Duration.zero) {
|
||||
widget.scrollController
|
||||
.jumpTo(widget.scrollController.position.minScrollExtent);
|
||||
} else {
|
||||
await widget.scrollController.animateTo(
|
||||
widget.scrollController.position.minScrollExtent,
|
||||
duration: widget.scrollToEndAnimationDuration,
|
||||
curve: Curves.linearToEaseOut,
|
||||
);
|
||||
}
|
||||
|
||||
if (!widget.scrollController.hasClients || !mounted) return;
|
||||
|
||||
if (widget.scrollController.offset <
|
||||
widget.scrollController.position.minScrollExtent) {
|
||||
widget.scrollController.jumpTo(
|
||||
widget.scrollController.position.minScrollExtent,
|
||||
);
|
||||
}
|
||||
|
||||
_isScrollingToBottom = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _handleToggleScrollToBottom() {
|
||||
if (_isScrollingToBottom) {
|
||||
return;
|
||||
}
|
||||
|
||||
_scrollToBottomShowTimer?.cancel();
|
||||
if (widget.scrollController.offset >
|
||||
widget.scrollController.position.minScrollExtent) {
|
||||
_scrollToBottomShowTimer =
|
||||
Timer(widget.scrollToBottomAppearanceDelay, () {
|
||||
if (mounted) {
|
||||
_scrollToBottomController.forward();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (_scrollToBottomController.status != AnimationStatus.completed) {
|
||||
_scrollToBottomController.stop();
|
||||
}
|
||||
_scrollToBottomController.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
void _onInserted(final int position, final Message data) {
|
||||
// There is a scroll notification listener the controls the
|
||||
// `_userHasScrolled` variable.
|
||||
//
|
||||
// If for some reason `_userHasScrolled` is true and the user is not at the
|
||||
// bottom of the list, set `_userHasScrolled` to false so that the scroll
|
||||
// animation is triggered.
|
||||
if (_userHasScrolled &&
|
||||
widget.scrollController.offset >=
|
||||
widget.scrollController.position.minScrollExtent) {
|
||||
_userHasScrolled = false;
|
||||
}
|
||||
|
||||
_listKey.currentState!.insertItem(
|
||||
position,
|
||||
duration: widget.insertAnimationDuration,
|
||||
);
|
||||
|
||||
// Used later to trigger scroll to end only for the last inserted message.
|
||||
_lastInsertedMessageId = data.id;
|
||||
|
||||
if (position == _oldList.length) {
|
||||
_scrollToEnd(data);
|
||||
}
|
||||
}
|
||||
|
||||
void _onRemoved(final int position, final Message data) {
|
||||
final visualPosition = max(_oldList.length - position - 1, 0);
|
||||
_listKey.currentState!.removeItem(
|
||||
visualPosition,
|
||||
(context, animation) => widget.itemBuilder(
|
||||
context,
|
||||
animation,
|
||||
data,
|
||||
isRemoved: true,
|
||||
),
|
||||
duration: widget.removeAnimationDuration,
|
||||
);
|
||||
}
|
||||
|
||||
void _onChanged(int position, Message oldData, Message newData) {
|
||||
_onRemoved(position, oldData);
|
||||
_listKey.currentState!.insertItem(
|
||||
max(_oldList.length - position - 1, 0),
|
||||
duration: widget.insertAnimationDuration,
|
||||
);
|
||||
}
|
||||
|
||||
void _onDiffUpdate(diffutil.DataDiffUpdate<Message> update) {
|
||||
update.when<void>(
|
||||
insert: (pos, data) => _onInserted(max(_oldList.length - pos, 0), data),
|
||||
remove: (pos, data) => _onRemoved(pos, data),
|
||||
change: (pos, oldData, newData) => _onChanged(pos, oldData, newData),
|
||||
move: (_, __, ___) => throw UnimplementedError('unused'),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleLoadPreviousMessages() {
|
||||
if (widget.scrollController.offset >=
|
||||
widget.scrollController.position.maxScrollExtent) {
|
||||
widget.onLoadPreviousMessages?.call();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,49 +4,34 @@ import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/util/built_in_svgs.dart';
|
||||
import 'package:appflowy/util/color_generator/color_generator.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/size.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:string_validator/string_validator.dart';
|
||||
|
||||
const defaultAvatarSize = 30.0;
|
||||
import 'layout_define.dart';
|
||||
|
||||
class ChatChatUserAvatar extends StatelessWidget {
|
||||
const ChatChatUserAvatar({required this.userId, super.key});
|
||||
|
||||
final String userId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const ChatBorderedCircleAvatar();
|
||||
}
|
||||
}
|
||||
|
||||
class ChatBorderedCircleAvatar extends StatelessWidget {
|
||||
const ChatBorderedCircleAvatar({
|
||||
class ChatAIAvatar extends StatelessWidget {
|
||||
const ChatAIAvatar({
|
||||
super.key,
|
||||
this.border = const BorderSide(),
|
||||
this.backgroundImage,
|
||||
this.child,
|
||||
});
|
||||
|
||||
final BorderSide border;
|
||||
final ImageProvider<Object>? backgroundImage;
|
||||
final Widget? child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: defaultAvatarSize,
|
||||
child: CircleAvatar(
|
||||
backgroundColor: border.color,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints.expand(),
|
||||
child: CircleAvatar(
|
||||
backgroundImage: backgroundImage,
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: child,
|
||||
),
|
||||
return Container(
|
||||
width: DesktopAIConvoSizes.avatarSize,
|
||||
height: DesktopAIConvoSizes.avatarSize,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
decoration: const BoxDecoration(shape: BoxShape.circle),
|
||||
foregroundDecoration: ShapeDecoration(
|
||||
shape: CircleBorder(
|
||||
side: BorderSide(color: Theme.of(context).colorScheme.outline),
|
||||
),
|
||||
),
|
||||
child: const CircleAvatar(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: FlowySvg(
|
||||
FlowySvgs.flowy_logo_s,
|
||||
size: Size.square(16),
|
||||
blendMode: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -58,28 +43,35 @@ class ChatUserAvatar extends StatelessWidget {
|
||||
super.key,
|
||||
required this.iconUrl,
|
||||
required this.name,
|
||||
this.size = defaultAvatarSize,
|
||||
this.isHovering = false,
|
||||
this.defaultName,
|
||||
});
|
||||
|
||||
final String iconUrl;
|
||||
final String name;
|
||||
final double size;
|
||||
final String? defaultName;
|
||||
|
||||
// If true, a border will be applied on top of the avatar
|
||||
final bool isHovering;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
late final Widget child;
|
||||
if (iconUrl.isEmpty) {
|
||||
return _buildEmptyAvatar(context);
|
||||
child = _buildEmptyAvatar(context);
|
||||
} else if (isURL(iconUrl)) {
|
||||
return _buildUrlAvatar(context);
|
||||
child = _buildUrlAvatar(context);
|
||||
} else {
|
||||
return _buildEmojiAvatar(context);
|
||||
child = _buildEmojiAvatar(context);
|
||||
}
|
||||
return Container(
|
||||
width: DesktopAIConvoSizes.avatarSize,
|
||||
height: DesktopAIConvoSizes.avatarSize,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
decoration: const BoxDecoration(shape: BoxShape.circle),
|
||||
foregroundDecoration: ShapeDecoration(
|
||||
shape: CircleBorder(
|
||||
side: BorderSide(color: Theme.of(context).colorScheme.outline),
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyAvatar(BuildContext context) {
|
||||
@@ -96,96 +88,50 @@ class ChatUserAvatar extends StatelessWidget {
|
||||
.map((element) => element[0].toUpperCase())
|
||||
.join();
|
||||
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: isHovering
|
||||
? Border.all(
|
||||
color: _darken(color),
|
||||
width: 4,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: FlowyText.regular(
|
||||
nameInitials,
|
||||
color: Colors.black,
|
||||
return ColoredBox(
|
||||
color: color,
|
||||
child: Center(
|
||||
child: FlowyText.regular(
|
||||
nameInitials,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUrlAvatar(BuildContext context) {
|
||||
return SizedBox.square(
|
||||
dimension: size,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: isHovering
|
||||
? Border.all(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
width: 4,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: Corners.s5Border,
|
||||
child: CircleAvatar(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: Image.network(
|
||||
iconUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
_buildEmptyAvatar(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
return CircleAvatar(
|
||||
backgroundColor: Colors.transparent,
|
||||
radius: DesktopAIConvoSizes.avatarSize / 2,
|
||||
child: Image.network(
|
||||
iconUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
_buildEmptyAvatar(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmojiAvatar(BuildContext context) {
|
||||
return SizedBox.square(
|
||||
dimension: size,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: isHovering
|
||||
? Border.all(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 4,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: Corners.s5Border,
|
||||
child: CircleAvatar(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: builtInSVGIcons.contains(iconUrl)
|
||||
? FlowySvg(
|
||||
FlowySvgData('emoji/$iconUrl'),
|
||||
blendMode: null,
|
||||
)
|
||||
: FlowyText.emoji(iconUrl),
|
||||
),
|
||||
),
|
||||
),
|
||||
return CircleAvatar(
|
||||
backgroundColor: Colors.transparent,
|
||||
radius: DesktopAIConvoSizes.avatarSize / 2,
|
||||
child: builtInSVGIcons.contains(iconUrl)
|
||||
? FlowySvg(
|
||||
FlowySvgData('emoji/$iconUrl'),
|
||||
blendMode: null,
|
||||
)
|
||||
: FlowyText.emoji(
|
||||
iconUrl,
|
||||
fontSize: 24, // cannot reduce
|
||||
optimizeEmojiAlign: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Return the user name, if the user name is empty,
|
||||
/// return the default user name.
|
||||
/// Return the user name.
|
||||
///
|
||||
/// If the user name is empty, return the default user name.
|
||||
String _userName(String name, String? defaultName) =>
|
||||
name.isEmpty ? (defaultName ?? LocaleKeys.defaultUsername.tr()) : name;
|
||||
|
||||
/// Used to darken the generated color for the hover border effect.
|
||||
/// The color is darkened by 15% - Hence the 0.15 value.
|
||||
///
|
||||
Color _darken(Color color) {
|
||||
final hsl = HSLColor.fromColor(color);
|
||||
return hsl.withLightness((hsl.lightness - 0.15).clamp(0.0, 1.0)).toColor();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// ref appflowy_flutter/lib/plugins/document/presentation/editor_style.dart
|
||||
|
||||
// diff:
|
||||
// - text style
|
||||
// - heading text style and padding builders
|
||||
// - don't listen to document appearance cubit
|
||||
//
|
||||
|
||||
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
|
||||
import 'package:appflowy/workspace/application/appearance_defaults.dart';
|
||||
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
|
||||
import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
class ChatEditorStyleCustomizer extends EditorStyleCustomizer {
|
||||
ChatEditorStyleCustomizer({
|
||||
required super.context,
|
||||
required super.padding,
|
||||
super.width,
|
||||
});
|
||||
|
||||
@override
|
||||
EditorStyle desktop() {
|
||||
final theme = Theme.of(context);
|
||||
final afThemeExtension = AFThemeExtension.of(context);
|
||||
final appearanceFont = context.read<AppearanceSettingsCubit>().state.font;
|
||||
final appearance = context.read<DocumentAppearanceCubit>().state;
|
||||
const fontSize = 14.0;
|
||||
String fontFamily = appearance.fontFamily;
|
||||
if (fontFamily.isEmpty && appearanceFont.isNotEmpty) {
|
||||
fontFamily = appearanceFont;
|
||||
}
|
||||
|
||||
return EditorStyle.desktop(
|
||||
padding: padding,
|
||||
maxWidth: width,
|
||||
cursorColor: appearance.cursorColor ??
|
||||
DefaultAppearanceSettings.getDefaultCursorColor(context),
|
||||
selectionColor: appearance.selectionColor ??
|
||||
DefaultAppearanceSettings.getDefaultSelectionColor(context),
|
||||
defaultTextDirection: appearance.defaultTextDirection,
|
||||
textStyleConfiguration: TextStyleConfiguration(
|
||||
lineHeight: 1.4,
|
||||
applyHeightToFirstAscent: true,
|
||||
applyHeightToLastDescent: true,
|
||||
text: baseTextStyle(fontFamily).copyWith(
|
||||
fontSize: fontSize,
|
||||
color: afThemeExtension.onBackground,
|
||||
),
|
||||
bold: baseTextStyle(fontFamily, fontWeight: FontWeight.bold).copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
italic: baseTextStyle(fontFamily).copyWith(fontStyle: FontStyle.italic),
|
||||
underline: baseTextStyle(fontFamily).copyWith(
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
strikethrough: baseTextStyle(fontFamily).copyWith(
|
||||
decoration: TextDecoration.lineThrough,
|
||||
),
|
||||
href: baseTextStyle(fontFamily).copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
code: GoogleFonts.robotoMono(
|
||||
textStyle: baseTextStyle(fontFamily).copyWith(
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: Colors.red,
|
||||
backgroundColor: theme.colorScheme.inverseSurface.withOpacity(0.8),
|
||||
),
|
||||
),
|
||||
),
|
||||
textSpanDecorator: customizeAttributeDecorator,
|
||||
textScaleFactor:
|
||||
context.watch<AppearanceSettingsCubit>().state.textScaleFactor,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TextStyle headingStyleBuilder(int level) {
|
||||
final String? fontFamily;
|
||||
final List<double> fontSizes;
|
||||
const fontSize = 14.0;
|
||||
|
||||
fontFamily = context.read<DocumentAppearanceCubit>().state.fontFamily;
|
||||
fontSizes = [
|
||||
fontSize + 12,
|
||||
fontSize + 10,
|
||||
fontSize + 6,
|
||||
fontSize + 2,
|
||||
fontSize,
|
||||
];
|
||||
return baseTextStyle(fontFamily, fontWeight: FontWeight.w600).copyWith(
|
||||
fontSize: fontSizes.elementAtOrNull(level - 1) ?? fontSize,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
CodeBlockStyle codeBlockStyleBuilder() {
|
||||
final fontFamily =
|
||||
context.read<DocumentAppearanceCubit>().state.codeFontFamily;
|
||||
|
||||
return CodeBlockStyle(
|
||||
textStyle: baseTextStyle(fontFamily).copyWith(
|
||||
height: 1.4,
|
||||
color: AFThemeExtension.of(context).onBackground,
|
||||
),
|
||||
backgroundColor: AFThemeExtension.of(context).calloutBGColor,
|
||||
foregroundColor: AFThemeExtension.of(context).textColor.withAlpha(155),
|
||||
wrapLines: true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TextStyle calloutBlockStyleBuilder() {
|
||||
if (UniversalPlatform.isMobile) {
|
||||
final afThemeExtension = AFThemeExtension.of(context);
|
||||
final pageStyle = context.read<DocumentPageStyleBloc>().state;
|
||||
final fontFamily = pageStyle.fontFamily ?? defaultFontFamily;
|
||||
final baseTextStyle = this.baseTextStyle(fontFamily);
|
||||
return baseTextStyle.copyWith(
|
||||
color: afThemeExtension.onBackground,
|
||||
);
|
||||
} else {
|
||||
final fontSize = context.read<DocumentAppearanceCubit>().state.fontSize;
|
||||
return baseTextStyle(null).copyWith(
|
||||
fontSize: fontSize,
|
||||
height: 1.5,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
TextStyle outlineBlockPlaceholderStyleBuilder() {
|
||||
return TextStyle(
|
||||
fontFamily: defaultFontFamily,
|
||||
height: 1.5,
|
||||
color: AFThemeExtension.of(context).onBackground.withOpacity(0.6),
|
||||
);
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
|
||||
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
|
||||
|
||||
import '../layout_define.dart';
|
||||
|
||||
enum SendButtonState { enabled, streaming, disabled }
|
||||
|
||||
class PromptInputAttachmentButton extends StatelessWidget {
|
||||
const PromptInputAttachmentButton({required this.onTap, super.key});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyTooltip(
|
||||
message: LocaleKeys.chat_uploadFile.tr(),
|
||||
child: SizedBox.square(
|
||||
dimension: DesktopAIPromptSizes.actionBarButtonSize,
|
||||
child: FlowyIconButton(
|
||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
radius: BorderRadius.circular(8),
|
||||
icon: FlowySvg(
|
||||
FlowySvgs.ai_attachment_s,
|
||||
size: const Size.square(16),
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
onPressed: onTap,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PromptInputMentionButton extends StatelessWidget {
|
||||
const PromptInputMentionButton({
|
||||
super.key,
|
||||
required this.buttonSize,
|
||||
required this.iconSize,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final double buttonSize;
|
||||
final double iconSize;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyTooltip(
|
||||
message: LocaleKeys.chat_clickToMention.tr(),
|
||||
preferBelow: false,
|
||||
child: FlowyIconButton(
|
||||
width: buttonSize,
|
||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
radius: BorderRadius.circular(8),
|
||||
icon: FlowySvg(
|
||||
FlowySvgs.chat_at_s,
|
||||
size: Size.square(iconSize),
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
onPressed: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PromptInputSendButton extends StatelessWidget {
|
||||
const PromptInputSendButton({
|
||||
super.key,
|
||||
required this.buttonSize,
|
||||
required this.iconSize,
|
||||
required this.state,
|
||||
required this.onSendPressed,
|
||||
required this.onStopStreaming,
|
||||
});
|
||||
|
||||
final double buttonSize;
|
||||
final double iconSize;
|
||||
final SendButtonState state;
|
||||
final VoidCallback onSendPressed;
|
||||
final VoidCallback onStopStreaming;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyIconButton(
|
||||
width: buttonSize,
|
||||
icon: switch (state) {
|
||||
SendButtonState.enabled => FlowySvg(
|
||||
FlowySvgs.ai_send_filled_s,
|
||||
size: Size.square(iconSize),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
SendButtonState.disabled => FlowySvg(
|
||||
FlowySvgs.ai_send_filled_s,
|
||||
size: Size.square(iconSize),
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
SendButtonState.streaming => FlowySvg(
|
||||
FlowySvgs.ai_stop_filled_s,
|
||||
size: Size.square(iconSize),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
},
|
||||
onPressed: () {
|
||||
switch (state) {
|
||||
case SendButtonState.enabled:
|
||||
onSendPressed();
|
||||
break;
|
||||
case SendButtonState.streaming:
|
||||
onStopStreaming();
|
||||
break;
|
||||
case SendButtonState.disabled:
|
||||
break;
|
||||
}
|
||||
},
|
||||
hoverColor: Colors.transparent,
|
||||
);
|
||||
}
|
||||
}
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
|
||||
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ChatInputAtButton extends StatelessWidget {
|
||||
const ChatInputAtButton({required this.onTap, super.key});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyTooltip(
|
||||
message: LocaleKeys.chat_clickToMention.tr(),
|
||||
child: FlowyIconButton(
|
||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
radius: BorderRadius.circular(6),
|
||||
icon: FlowySvg(
|
||||
FlowySvgs.chat_at_s,
|
||||
size: const Size.square(20),
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
onPressed: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
-451
@@ -1,451 +0,0 @@
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_file_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_input_action_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_input_action_control.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_input_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_input/chat_input_file.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_input_action_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mobile_page_selector_sheet.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:extended_text_field/extended_text_field.dart';
|
||||
import 'package:flowy_infra/file_picker/file_picker_service.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
|
||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import 'chat_at_button.dart';
|
||||
import 'chat_input_attachment.dart';
|
||||
import 'chat_input_span.dart';
|
||||
import 'chat_send_button.dart';
|
||||
import 'layout_define.dart';
|
||||
|
||||
class ChatInput extends StatefulWidget {
|
||||
/// Creates [ChatInput] widget.
|
||||
const ChatInput({
|
||||
super.key,
|
||||
this.onAttachmentPressed,
|
||||
required this.onSendPressed,
|
||||
required this.chatId,
|
||||
this.options = const InputOptions(),
|
||||
required this.isStreaming,
|
||||
required this.onStopStreaming,
|
||||
required this.hintText,
|
||||
required this.aiType,
|
||||
});
|
||||
|
||||
final VoidCallback? onAttachmentPressed;
|
||||
final void Function(types.PartialText) onSendPressed;
|
||||
final void Function() onStopStreaming;
|
||||
final InputOptions options;
|
||||
final String chatId;
|
||||
final bool isStreaming;
|
||||
final String hintText;
|
||||
final AIType aiType;
|
||||
|
||||
@override
|
||||
State<ChatInput> createState() => _ChatInputState();
|
||||
}
|
||||
|
||||
/// [ChatInput] widget state.
|
||||
class _ChatInputState extends State<ChatInput> {
|
||||
final GlobalKey _textFieldKey = GlobalKey();
|
||||
final LayerLink _layerLink = LayerLink();
|
||||
late ChatInputActionControl _inputActionControl;
|
||||
late FocusNode _inputFocusNode;
|
||||
late TextEditingController _textController;
|
||||
bool _sendButtonEnabled = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_textController = InputTextFieldController();
|
||||
_inputFocusNode = FocusNode(
|
||||
onKeyEvent: (node, event) {
|
||||
if (UniversalPlatform.isDesktop) {
|
||||
if (_inputActionControl.canHandleKeyEvent(event)) {
|
||||
_inputActionControl.handleKeyEvent(event);
|
||||
return KeyEventResult.handled;
|
||||
} else {
|
||||
return _handleEnterKeyWithoutShift(
|
||||
event,
|
||||
_textController,
|
||||
widget.isStreaming,
|
||||
_handleSendPressed,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
_inputFocusNode.addListener(_updateState);
|
||||
|
||||
_inputActionControl = ChatInputActionControl(
|
||||
chatId: widget.chatId,
|
||||
textController: _textController,
|
||||
textFieldFocusNode: _inputFocusNode,
|
||||
);
|
||||
_inputFocusNode.requestFocus();
|
||||
_handleSendButtonVisibilityModeChange();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_inputFocusNode.removeListener(_updateState);
|
||||
_inputFocusNode.dispose();
|
||||
_textController.dispose();
|
||||
_inputActionControl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _updateState() => setState(() {});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: inputPadding,
|
||||
// ignore: use_decorated_box
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: _inputFocusNode.hasFocus
|
||||
? Theme.of(context).colorScheme.primary.withOpacity(0.6)
|
||||
: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
child: Material(
|
||||
borderRadius: borderRadius,
|
||||
color: color,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (context.read<ChatFileBloc>().state.uploadFiles.isNotEmpty)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: 12,
|
||||
bottom: 12,
|
||||
left: textPadding.left + sendButtonSize,
|
||||
right: textPadding.right,
|
||||
),
|
||||
child: BlocBuilder<ChatFileBloc, ChatFileState>(
|
||||
builder: (context, state) {
|
||||
return ChatInputFile(
|
||||
chatId: widget.chatId,
|
||||
files: state.uploadFiles,
|
||||
onDeleted: (file) => context.read<ChatFileBloc>().add(
|
||||
ChatFileEvent.deleteFile(file),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
//
|
||||
Row(
|
||||
children: [
|
||||
// TODO(lucas): support mobile
|
||||
if (UniversalPlatform.isDesktop &&
|
||||
widget.aiType.isLocalAI())
|
||||
_attachmentButton(buttonPadding),
|
||||
|
||||
// text field
|
||||
Expanded(child: _inputTextField(context, textPadding)),
|
||||
|
||||
// mention button
|
||||
_mentionButton(buttonPadding),
|
||||
|
||||
if (UniversalPlatform.isMobile) const HSpace(6.0),
|
||||
|
||||
// send button
|
||||
_sendButton(buttonPadding),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleSendButtonVisibilityModeChange() {
|
||||
_textController.removeListener(_handleTextControllerChange);
|
||||
_sendButtonEnabled =
|
||||
_textController.text.trim() != '' || widget.isStreaming;
|
||||
_textController.addListener(_handleTextControllerChange);
|
||||
}
|
||||
|
||||
void _handleSendPressed() {
|
||||
final trimmedText = _textController.text.trim();
|
||||
if (trimmedText != '') {
|
||||
// consume metadata
|
||||
final ChatInputMentionMetadata mentionPageMetadata =
|
||||
_inputActionControl.consumeMetaData();
|
||||
final ChatInputFileMetadata fileMetadata =
|
||||
context.read<ChatFileBloc>().consumeMetaData();
|
||||
|
||||
// combine metadata
|
||||
final Map<String, dynamic> metadata = {}
|
||||
..addAll(mentionPageMetadata)
|
||||
..addAll(fileMetadata);
|
||||
|
||||
final partialText = types.PartialText(
|
||||
text: trimmedText,
|
||||
metadata: metadata,
|
||||
);
|
||||
widget.onSendPressed(partialText);
|
||||
_textController.clear();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTextControllerChange() {
|
||||
if (_textController.value.isComposingRangeValid) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_sendButtonEnabled = _textController.text.trim() != '';
|
||||
});
|
||||
}
|
||||
|
||||
Widget _inputTextField(BuildContext context, EdgeInsets textPadding) {
|
||||
return CompositedTransformTarget(
|
||||
link: _layerLink,
|
||||
child: Padding(
|
||||
padding: textPadding,
|
||||
child: ExtendedTextField(
|
||||
key: _textFieldKey,
|
||||
controller: _textController,
|
||||
focusNode: _inputFocusNode,
|
||||
decoration: _buildInputDecoration(context),
|
||||
keyboardType: TextInputType.multiline,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
minLines: 1,
|
||||
maxLines: 10,
|
||||
style: _buildTextStyle(context),
|
||||
specialTextSpanBuilder: ChatInputTextSpanBuilder(
|
||||
inputActionControl: _inputActionControl,
|
||||
),
|
||||
onChanged: (text) {
|
||||
_handleOnTextChange(context, text);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
InputDecoration _buildInputDecoration(BuildContext context) {
|
||||
return InputDecoration(
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
hintText: widget.hintText,
|
||||
focusedBorder: InputBorder.none,
|
||||
hintStyle: TextStyle(
|
||||
color: AFThemeExtension.of(context).textColor.withOpacity(0.5),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TextStyle? _buildTextStyle(BuildContext context) {
|
||||
if (!isMobile) {
|
||||
return TextStyle(
|
||||
color: AFThemeExtension.of(context).textColor,
|
||||
fontSize: 15,
|
||||
);
|
||||
}
|
||||
|
||||
return Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontSize: 15,
|
||||
height: 1.2,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleOnTextChange(BuildContext context, String text) async {
|
||||
if (!_inputActionControl.onTextChanged(text)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (UniversalPlatform.isDesktop) {
|
||||
ChatActionsMenu(
|
||||
anchor: ChatInputAnchor(
|
||||
anchorKey: _textFieldKey,
|
||||
layerLink: _layerLink,
|
||||
),
|
||||
handler: _inputActionControl,
|
||||
context: context,
|
||||
style: Theme.of(context).brightness == Brightness.dark
|
||||
? const ChatActionsMenuStyle.dark()
|
||||
: const ChatActionsMenuStyle.light(),
|
||||
).show();
|
||||
} else {
|
||||
// if the focus node is on focus, unfocus it for better animation
|
||||
// otherwise, the page sheet animation will be blocked by the keyboard
|
||||
if (_inputFocusNode.hasFocus) {
|
||||
_inputFocusNode.unfocus();
|
||||
Future.delayed(const Duration(milliseconds: 100), () async {
|
||||
await _referPage(_inputActionControl);
|
||||
});
|
||||
} else {
|
||||
await _referPage(_inputActionControl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _sendButton(EdgeInsets buttonPadding) {
|
||||
return Padding(
|
||||
padding: buttonPadding,
|
||||
child: SizedBox.square(
|
||||
dimension: sendButtonSize,
|
||||
child: ChatInputSendButton(
|
||||
onSendPressed: () {
|
||||
if (!_sendButtonEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!widget.isStreaming) {
|
||||
widget.onStopStreaming();
|
||||
_handleSendPressed();
|
||||
}
|
||||
},
|
||||
onStopStreaming: () => widget.onStopStreaming(),
|
||||
isStreaming: widget.isStreaming,
|
||||
enabled: _sendButtonEnabled,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _attachmentButton(EdgeInsets buttonPadding) {
|
||||
return Padding(
|
||||
padding: buttonPadding,
|
||||
child: SizedBox.square(
|
||||
dimension: attachButtonSize,
|
||||
child: ChatInputAttachment(
|
||||
onTap: () async {
|
||||
final path = await getIt<FilePickerService>().pickFiles(
|
||||
dialogTitle: '',
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ["pdf"],
|
||||
);
|
||||
if (path == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (final file in path.files) {
|
||||
if (file.path != null) {
|
||||
if (mounted) {
|
||||
context
|
||||
.read<ChatFileBloc>()
|
||||
.add(ChatFileEvent.newFile(file.path!, file.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _mentionButton(EdgeInsets buttonPadding) {
|
||||
return Padding(
|
||||
padding: buttonPadding,
|
||||
child: SizedBox.square(
|
||||
dimension: attachButtonSize,
|
||||
child: ChatInputAtButton(
|
||||
onTap: () {
|
||||
_textController.text += '@';
|
||||
if (!isMobile) {
|
||||
_inputFocusNode.requestFocus();
|
||||
}
|
||||
_handleOnTextChange(context, _textController.text);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _referPage(ChatActionHandler handler) async {
|
||||
handler.onEnter();
|
||||
final selectedView = await showPageSelectorSheet(
|
||||
context,
|
||||
filter: (view) =>
|
||||
view.layout.isDocumentView &&
|
||||
!view.isSpace &&
|
||||
view.parentViewId.isNotEmpty,
|
||||
);
|
||||
if (selectedView == null) {
|
||||
handler.onExit();
|
||||
return;
|
||||
}
|
||||
handler.onSelected(ViewActionPage(view: selectedView));
|
||||
handler.onExit();
|
||||
_inputFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ChatInput oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_handleSendButtonVisibilityModeChange();
|
||||
}
|
||||
}
|
||||
|
||||
final isMobile = defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS;
|
||||
|
||||
class ChatInputAnchor extends ChatAnchor {
|
||||
ChatInputAnchor({
|
||||
required this.anchorKey,
|
||||
required this.layerLink,
|
||||
});
|
||||
|
||||
@override
|
||||
final GlobalKey<State<StatefulWidget>> anchorKey;
|
||||
|
||||
@override
|
||||
final LayerLink layerLink;
|
||||
}
|
||||
|
||||
/// Handles the key press event for the Enter key without Shift.
|
||||
///
|
||||
/// This function checks if the Enter key is pressed without either of the Shift keys.
|
||||
/// If the conditions are met, it performs the action of sending a message if the
|
||||
/// text controller is not in a composing range and if the event is a key down event.
|
||||
///
|
||||
/// - Returns: A `KeyEventResult` indicating whether the key event was handled or ignored.
|
||||
KeyEventResult _handleEnterKeyWithoutShift(
|
||||
KeyEvent event,
|
||||
TextEditingController textController,
|
||||
bool isStreaming,
|
||||
void Function() handleSendPressed,
|
||||
) {
|
||||
if (event.physicalKey == PhysicalKeyboardKey.enter &&
|
||||
!HardwareKeyboard.instance.physicalKeysPressed.any(
|
||||
(el) => <PhysicalKeyboardKey>{
|
||||
PhysicalKeyboardKey.shiftLeft,
|
||||
PhysicalKeyboardKey.shiftRight,
|
||||
}.contains(el),
|
||||
)) {
|
||||
if (textController.value.isComposingRangeValid) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
if (event is KeyDownEvent) {
|
||||
if (!isStreaming) {
|
||||
handleSendPressed();
|
||||
}
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
} else {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
}
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
|
||||
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
|
||||
|
||||
class ChatInputAttachment extends StatelessWidget {
|
||||
const ChatInputAttachment({required this.onTap, super.key});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyTooltip(
|
||||
message: LocaleKeys.chat_uploadFile.tr(),
|
||||
child: FlowyIconButton(
|
||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
radius: BorderRadius.circular(6),
|
||||
icon: FlowySvg(
|
||||
FlowySvgs.ai_attachment_s,
|
||||
size: const Size.square(20),
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
onPressed: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+104
-67
@@ -1,130 +1,167 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/ai_prompt_input_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_input_file_bloc.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
import '../layout_define.dart';
|
||||
|
||||
class ChatInputFile extends StatelessWidget {
|
||||
const ChatInputFile({
|
||||
required this.chatId,
|
||||
required this.files,
|
||||
required this.onDeleted,
|
||||
super.key,
|
||||
required this.chatId,
|
||||
required this.onDeleted,
|
||||
});
|
||||
final List<ChatFile> files;
|
||||
final String chatId;
|
||||
|
||||
final Function(ChatFile) onDeleted;
|
||||
final String chatId;
|
||||
final void Function(ChatFile) onDeleted;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<Widget> children = files
|
||||
.map(
|
||||
(file) => ChatFilePreview(
|
||||
chatId: chatId,
|
||||
file: file,
|
||||
onDeleted: onDeleted,
|
||||
return BlocSelector<AIPromptInputBloc, AIPromptInputState, List<ChatFile>>(
|
||||
selector: (state) => state.uploadFiles,
|
||||
builder: (context, files) {
|
||||
if (files.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: DesktopAIPromptSizes.attachedFilesBarPadding -
|
||||
const EdgeInsets.only(top: 6),
|
||||
separatorBuilder: (context, index) => const HSpace(
|
||||
DesktopAIPromptSizes.attachedFilesPreviewSpacing - 6,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
return Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: children,
|
||||
itemCount: files.length,
|
||||
itemBuilder: (context, index) => ChatFilePreview(
|
||||
chatId: chatId,
|
||||
file: files[index],
|
||||
onDeleted: () => onDeleted(files[index]),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChatFilePreview extends StatelessWidget {
|
||||
class ChatFilePreview extends StatefulWidget {
|
||||
const ChatFilePreview({
|
||||
required this.chatId,
|
||||
required this.file,
|
||||
required this.onDeleted,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String chatId;
|
||||
final ChatFile file;
|
||||
final Function(ChatFile) onDeleted;
|
||||
final VoidCallback onDeleted;
|
||||
|
||||
@override
|
||||
State<ChatFilePreview> createState() => _ChatFilePreviewState();
|
||||
}
|
||||
|
||||
class _ChatFilePreviewState extends State<ChatFilePreview> {
|
||||
bool isHover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => ChatInputFileBloc(chatId: chatId, file: file)
|
||||
..add(const ChatInputFileEvent.initial()),
|
||||
create: (context) => ChatInputFileBloc(file: widget.file),
|
||||
child: BlocBuilder<ChatInputFileBloc, ChatInputFileState>(
|
||||
builder: (context, state) {
|
||||
return FlowyHover(
|
||||
builder: (context, onHover) {
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 260,
|
||||
),
|
||||
child: DecoratedBox(
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setHover(true),
|
||||
onExit: (_) => setHover(false),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsetsDirectional.only(top: 6, end: 6),
|
||||
constraints: const BoxConstraints(maxWidth: 240),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10.0,
|
||||
vertical: 14,
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AFThemeExtension.of(context).tint1,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
height: 32,
|
||||
width: 32,
|
||||
child: Center(
|
||||
child: FlowySvg(
|
||||
FlowySvgs.page_m,
|
||||
size: const Size.square(16),
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const HSpace(8),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
file.fileType.icon,
|
||||
const HSpace(6),
|
||||
Flexible(
|
||||
child: FlowyText(
|
||||
file.fileName,
|
||||
fontSize: 12,
|
||||
maxLines: 6,
|
||||
),
|
||||
FlowyText(
|
||||
widget.file.fileName,
|
||||
fontSize: 12.0,
|
||||
),
|
||||
FlowyText(
|
||||
widget.file.fileType.name,
|
||||
color: Theme.of(context).hintColor,
|
||||
fontSize: 12.0,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (onHover)
|
||||
_CloseButton(
|
||||
onPressed: () => onDeleted(file),
|
||||
).positioned(top: -6, right: -6),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
if (isHover)
|
||||
_CloseButton(
|
||||
onTap: widget.onDeleted,
|
||||
).positioned(top: 0, right: 0),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void setHover(bool value) {
|
||||
if (value != isHover) {
|
||||
setState(() => isHover = value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _CloseButton extends StatelessWidget {
|
||||
const _CloseButton({required this.onPressed});
|
||||
final VoidCallback onPressed;
|
||||
const _CloseButton({required this.onTap});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyIconButton(
|
||||
width: 24,
|
||||
height: 24,
|
||||
isSelected: true,
|
||||
radius: BorderRadius.circular(12),
|
||||
fillColor: Theme.of(context).colorScheme.surfaceContainer,
|
||||
icon: const FlowySvg(
|
||||
FlowySvgs.close_s,
|
||||
size: Size.square(20),
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: FlowySvg(
|
||||
FlowySvgs.ai_close_filled_s,
|
||||
color: AFThemeExtension.of(context).greyHover,
|
||||
size: const Size.square(16),
|
||||
),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 481 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user