Compare commits

..
Author SHA1 Message Date
Zack Fu Zi Xiang f78b5b4561 chore: update dep 2024-11-05 20:30:12 +08:00
297 changed files with 5792 additions and 9466 deletions

No files matched your search

+108 -179
View File
@@ -1,197 +1,126 @@
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.3"
RUST_TOOLCHAIN: "1.80.1"
CARGO_MAKE_VERSION: "0.37.18"
CLOUD_VERSION: 0.6.54-amd64
# env:
# CARGO_TERM_COLOR: always
# FLUTTER_VERSION: "3.22.0"
# RUST_TOOLCHAIN: "1.77.2"
# CARGO_MAKE_VERSION: "0.36.6"
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: [ubuntu-latest]
runs-on: ${{ matrix.os }}
# jobs:
# build:
# if: github.event.pull_request.draft != true
# strategy:
# fail-fast: true
# matrix:
# os: [macos-14]
# 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
# # 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
- name: Check storage space
run: df -h
# - name: Check storage space
# run: df -h
- name: Checkout appflowy cloud code
uses: actions/checkout@v4
with:
repository: AppFlowy-IO/AppFlowy-Cloud
path: AppFlowy-Cloud
# - name: Checkout source code
# uses: actions/checkout@v4
- 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
# - uses: actions/setup-java@v4
# with:
# distribution: temurin
# java-version: 11
- 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 Rust toolchain
# id: rust_toolchain
# uses: actions-rs/toolchain@v1
# with:
# toolchain: ${{ env.RUST_TOOLCHAIN }}
# override: true
# profile: minimal
# Remove all volumes if any exist
if [ "$(docker volume ls -q)" ]; then
docker volume rm $(docker volume ls -q)
else
echo "No volumes to remove."
fi
docker compose pull
docker compose up -d
echo "Waiting for the container to be ready..."
sleep 10
docker ps -a
docker compose logs
else
echo "AppFlowy-Cloud is running with the correct version."
fi
fi
# - name: Install flutter
# id: flutter
# uses: subosito/flutter-action@v2
# with:
# channel: "stable"
# flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Checkout source code
uses: actions/checkout@v4
# - uses: gradle/gradle-build-action@v3
# with:
# gradle-version: 7.4.2
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 11
# - uses: davidB/rust-cargo-make@v1
# with:
# version: "0.36.6"
- name: Install Rust toolchain
id: rust_toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
override: true
profile: minimal
# - 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 flutter
id: flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
# - name: Build AppFlowy
# working-directory: frontend
# run: |
# cargo make --profile development-android appflowy-android-dev-ci
- uses: gradle/gradle-build-action@v3
with:
gradle-version: 8.10
- 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
# - 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
+1 -1
View File
@@ -346,7 +346,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest]
test_number: [1, 2, 3, 4, 5, 6, 7, 8, 9]
test_number: [1, 2, 3, 4, 5, 6, 7, 8]
include:
- os: ubuntu-latest
target: "x86_64-unknown-linux-gnu"
+18 -22
View File
@@ -20,8 +20,9 @@ on:
- "!frontend/appflowy_web_app/**"
env:
FLUTTER_VERSION: "3.22.3"
FLUTTER_VERSION: "3.22.0"
RUST_TOOLCHAIN: "1.80.1"
CLOUD_VERSION: 0.6.51
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -96,26 +97,21 @@ 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
# 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 }}
# - name: Run integration tests
# working-directory: frontend/appflowy_flutter
# run: flutter test integration_test/runner.dart -d ${{ steps.simulator-action.outputs.udid }}
-83
View File
@@ -1,83 +0,0 @@
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 }}
+5 -4
View File
@@ -15,10 +15,12 @@ on:
- "main"
- "develop"
- "release/*"
paths:
- "frontend/rust-lib/**"
env:
CARGO_TERM_COLOR: always
CLOUD_VERSION: 0.7.6-amd64
CLOUD_VERSION: 0.6.51-amd64
RUST_TOOLCHAIN: "1.77.2"
jobs:
@@ -41,7 +43,6 @@ 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
@@ -136,7 +137,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
@@ -159,7 +160,7 @@ jobs:
else
echo "No volumes to remove."
fi
docker compose pull
docker compose up -d
echo "Waiting for the container to be ready..."
-26
View File
@@ -1,30 +1,4 @@
# Release Notes
# Release Notes
## 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
-47
View File
@@ -1,47 +0,0 @@
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
+1 -1
View File
@@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
CARGO_MAKE_CRATE_FS_NAME = "dart_ffi"
CARGO_MAKE_CRATE_NAME = "dart-ffi"
LIB_NAME = "dart_ffi"
APPFLOWY_VERSION = "0.7.4"
APPFLOWY_VERSION = "0.7.3"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"
@@ -1,10 +1,33 @@
// 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();
@@ -1,17 +1,33 @@
// 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();
@@ -106,21 +122,9 @@ 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()));
@@ -149,72 +153,5 @@ 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,
);
});
});
}
@@ -1,13 +1,35 @@
// 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() {
@@ -1,12 +1,21 @@
// 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() {
@@ -1,9 +1,24 @@
// 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() {
@@ -1,11 +1,33 @@
// 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();
@@ -1,12 +1,21 @@
// 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';
@@ -40,10 +49,6 @@ 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),
);
@@ -1,19 +1,38 @@
// 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 {
@@ -74,79 +93,5 @@ 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,
);
},
);
});
});
}
@@ -1,17 +1,29 @@
// 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();
@@ -1,75 +0,0 @@
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);
});
});
}
@@ -1,11 +1,35 @@
// 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';
@@ -1,23 +1,46 @@
// 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() {
@@ -3,7 +3,6 @@ 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;
@@ -15,5 +14,4 @@ void main() {
collaborative_workspace_test.main();
change_name_and_icon_test.main();
workspace_icon_test.main();
tabs_test.main();
}
@@ -1,17 +1,17 @@
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: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/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,24 +76,6 @@ 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 {
@@ -1,47 +0,0 @@
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);
});
});
}
@@ -1,144 +0,0 @@
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,
);
},
);
}
@@ -1,13 +1,14 @@
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';
@@ -1,12 +1,9 @@
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'
@@ -22,7 +19,7 @@ void main() {
document_more_actions_test.main();
document_with_file_test.main();
document_shortcuts_test.main();
document_block_option_test.main();
document_find_menu_test.main();
document_toolbar_test.main();
// Disable subPage test temporarily, enable it in version 0.7.2
// document_sub_page_test.main();
}
@@ -347,27 +347,5 @@ 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);
});
});
}
@@ -1,50 +0,0 @@
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,
);
});
});
}
@@ -24,71 +24,11 @@ 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://mathiasbynens.be/demo/animated-webp-supported.webp',
'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();
@@ -139,11 +79,59 @@ void main() {
file.deleteSync();
});
for (final url in _testImageUrls) {
testWidgets('insert an image from network: $url', (tester) async {
await testEmbedImage(tester, url);
});
}
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);
});
testWidgets('insert an image from unsplash', (tester) async {
await runWithNetworkImages(() async {
@@ -1,7 +1,6 @@
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';
@@ -44,9 +43,6 @@ void main() {
* # Heading 1
* ## Heading 2
* ### Heading 3
* > # Heading 1
* > ## Heading 2
* > ### Heading 3
*/
await tester.editor.tapLineOfEditorAt(3);
@@ -57,7 +53,7 @@ void main() {
of: find.byType(OutlineBlockWidget),
matching: find.text(heading1),
),
findsNWidgets(2),
findsOneWidget,
);
// Heading 2 is prefixed with a bullet
@@ -66,7 +62,7 @@ void main() {
of: find.byType(OutlineBlockWidget),
matching: find.text(heading2),
),
findsNWidgets(2),
findsOneWidget,
);
// Heading 3 is prefixed with a dash
@@ -75,7 +71,7 @@ void main() {
of: find.byType(OutlineBlockWidget),
matching: find.text(heading3),
),
findsNWidgets(2),
findsOneWidget,
);
// update the Heading 1 to Heading 1Hello world
@@ -103,16 +99,13 @@ void main() {
* # Heading 1
* ## Heading 2
* ### Heading 3
* > # Heading 1
* > ## Heading 2
* > ### Heading 3
*/
await tester.editor.tapLineOfEditorAt(7);
await tester.editor.tapLineOfEditorAt(3);
await insertOutlineInDocument(tester);
// expect to find only the `heading1` widget under the [OutlineBlockWidget]
await hoverAndClickDepthOptionAction(tester, [6], 1);
await hoverAndClickDepthOptionAction(tester, [3], 1);
expect(
find.descendant(
of: find.byType(OutlineBlockWidget),
@@ -130,7 +123,7 @@ void main() {
//////
/// expect to find only the 'heading1' and 'heading2' under the [OutlineBlockWidget]
await hoverAndClickDepthOptionAction(tester, [6], 2);
await hoverAndClickDepthOptionAction(tester, [3], 2);
expect(
find.descendant(
of: find.byType(OutlineBlockWidget),
@@ -141,13 +134,13 @@ void main() {
//////
// expect to find all the headings under the [OutlineBlockWidget]
await hoverAndClickDepthOptionAction(tester, [6], 3);
await hoverAndClickDepthOptionAction(tester, [3], 3);
expect(
find.descendant(
of: find.byType(OutlineBlockWidget),
matching: find.text(heading1),
),
findsNWidgets(2),
findsOneWidget,
);
expect(
@@ -155,7 +148,7 @@ void main() {
of: find.byType(OutlineBlockWidget),
matching: find.text(heading2),
),
findsNWidgets(2),
findsOneWidget,
);
expect(
@@ -163,7 +156,7 @@ void main() {
of: find.byType(OutlineBlockWidget),
matching: find.text(heading3),
),
findsNWidgets(2),
findsOneWidget,
);
//////
});
@@ -193,17 +186,7 @@ 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);
}
@@ -1,18 +1,17 @@
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_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';
@@ -21,12 +20,17 @@ void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Tabs', () {
testWidgets('open/navigate/close tabs', (tester) async {
testWidgets('Open AppFlowy and open/navigate/close tabs', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// No tabs rendered yet
expect(find.byType(FlowyTab), findsNothing);
expect(
find.descendant(
of: find.byType(TabsManager),
matching: find.byType(TabBar),
),
findsNothing,
);
await tester.createNewPageWithNameUnderParent(name: _documentName);
@@ -40,7 +44,7 @@ void main() {
expect(
find.descendant(
of: find.byType(TabsManager),
of: find.byType(TabBar),
matching: find.byType(FlowyTab),
),
findsNWidgets(3),
@@ -67,83 +71,11 @@ void main() {
expect(
find.descendant(
of: find.byType(TabsManager),
of: find.byType(TabBar),
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));
});
});
}
@@ -4,6 +4,7 @@ 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() {
@@ -16,6 +17,7 @@ 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.
}
@@ -1,16 +0,0 @@
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,11 +1,9 @@
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();
}
@@ -1,23 +1,47 @@
// 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/presentation/home/workspaces/create_workspace_menu.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() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('publish:', () {
testWidgets('''
1. publish document
2. update path name
3. unpublish document
''', (tester) async {
testWidgets('publish document', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
@@ -51,51 +75,6 @@ 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,12 +1,39 @@
// 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() {
@@ -1,287 +0,0 @@
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));
});
});
}
@@ -1,11 +1,37 @@
// 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,7 +1,6 @@
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() {
@@ -10,5 +9,4 @@ void main() {
// Document integration tests
title_test.main();
page_style_test.main();
plus_menu_test.main();
}
@@ -1,19 +1,42 @@
// 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)
@@ -34,9 +57,11 @@ void main() {
.single
.widget as AppFlowyEditorPage;
return editorPage.styleCustomizer
.style()
.textStyleConfiguration
.lineHeight;
.style()
.textStyleConfiguration
.text
.height ??
PageStyleLineHeightLayout.normal.lineHeight;
}
testWidgets('change font size in page style settings', (tester) async {
@@ -62,24 +87,20 @@ void main() {
await tester.openPage(gettingStarted);
// click the layout button
await tester.tapButton(find.byType(MobileViewPageLayoutButton));
var lineHeight = getCurrentEditorLineHeight();
expect(
lineHeight,
getCurrentEditorLineHeight(),
PageStyleLineHeightLayout.normal.lineHeight,
);
// change line height from normal to large
await tester.tapSvgButton(FlowySvgs.m_layout_large_s);
await tester.pumpAndSettle();
lineHeight = getCurrentEditorLineHeight();
expect(
lineHeight,
getCurrentEditorLineHeight(),
PageStyleLineHeightLayout.large.lineHeight,
);
// change line height from large to small
await tester.tapSvgButton(FlowySvgs.m_layout_small_s);
lineHeight = getCurrentEditorLineHeight();
expect(
lineHeight,
getCurrentEditorLineHeight(),
PageStyleLineHeightLayout.small.lineHeight,
);
});
@@ -1,89 +0,0 @@
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,10 +1,39 @@
// 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() {
@@ -1,25 +1,47 @@
// 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 in home page:', () {
group('create new page', () {
testWidgets('create document', (tester) async {
await tester.launchInAnonymousMode();
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.local,
);
// tap the create page button
final createPageButton = find.byWidgetPredicate(
(widget) =>
widget is FlowySvg &&
widget.svg.path == FlowySvgs.m_home_add_m.path,
widget.svg.path == FlowySvgs.m_home_unselected_m.path,
);
await tester.tapButton(createPageButton);
await tester.pumpAndSettle();
expect(find.byType(MobileDocumentScreen), findsOneWidget);
});
});
@@ -1,15 +1,33 @@
// 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.launchInAnonymousMode();
await tester.initializeAppFlowy();
// expect to see the home page
expect(find.byType(MobileHomeScreen), findsOneWidget);
@@ -1,20 +1,15 @@
import 'package:appflowy_backend/log.dart';
import 'package:integration_test/integration_test.dart';
import 'mobile/document/document_test_runner.dart' as document_test_runner;
import 'mobile/document/page_style_test.dart' as page_style_test;
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> main() async {
Future<void> runIntegrationOnMobile() async {
Log.shared.disableLog = true;
await runIntegration1OnMobile();
}
Future<void> runIntegration1OnMobile() async {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
anonymous_sign_in_test.main();
create_new_page_test.main();
document_test_runner.main();
page_style_test.main();
}
@@ -8,8 +8,7 @@ import 'desktop_runner_5.dart';
import 'desktop_runner_6.dart';
import 'desktop_runner_7.dart';
import 'desktop_runner_8.dart';
import 'desktop_runner_9.dart';
import 'mobile_runner_1.dart';
import 'mobile_runner.dart';
/// The main task runner for all integration tests in AppFlowy.
///
@@ -28,9 +27,8 @@ Future<void> main() async {
await runIntegration6OnDesktop();
await runIntegration7OnDesktop();
await runIntegration8OnDesktop();
await runIntegration9OnDesktop();
} else if (Platform.isIOS || Platform.isAndroid) {
await runIntegration1OnMobile();
await runIntegrationOnMobile();
} else {
throw Exception('Unsupported platform');
}
@@ -4,10 +4,8 @@ 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';
@@ -44,7 +42,6 @@ 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';
@@ -790,57 +787,6 @@ 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,13 +40,9 @@ extension AppFlowyWorkspace on WidgetTester {
moreButton,
onHover: () async {
await tapButton(moreButton);
// wait for the menu to open
final renameButton = find.findTextInFlowyText(
LocaleKeys.button_rename.tr(),
await tapButton(
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);
@@ -65,10 +65,7 @@ class _MobileViewPageState extends State<MobileViewPage> {
@override
void dispose() {
_appBarOpacity.dispose();
// 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?.removeListener(_onScrollNotification);
_scrollNotificationObserver = null;
super.dispose();
@@ -9,14 +9,12 @@ 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;
}
@@ -24,7 +22,7 @@ class TypeOptionMenu<T> extends StatelessWidget {
const TypeOptionMenu({
super.key,
required this.values,
this.width = 98,
this.width = 94,
this.iconWidth = 72,
this.scaleFactor = 1.0,
this.maxAxisSpacing = 18,
@@ -41,18 +39,17 @@ class TypeOptionMenu<T> extends StatelessWidget {
@override
Widget build(BuildContext context) {
return TypeOptionGridView(
return _GridView(
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(),
@@ -60,21 +57,18 @@ class TypeOptionMenu<T> extends StatelessWidget {
}
}
class TypeOptionMenuItem<T> extends StatelessWidget {
const TypeOptionMenuItem({
super.key,
class _TypeOptionMenuItem<T> extends StatelessWidget {
const _TypeOptionMenuItem({
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;
@@ -94,8 +88,7 @@ class TypeOptionMenuItem<T> extends StatelessWidget {
borderRadius: BorderRadius.circular(24 * scaleFactor),
),
),
padding: EdgeInsets.all(21 * scaleFactor) +
(iconPadding ?? EdgeInsets.zero),
padding: EdgeInsets.all(21 * scaleFactor),
child: FlowySvg(
value.icon,
),
@@ -120,9 +113,8 @@ class TypeOptionMenuItem<T> extends StatelessWidget {
}
}
class TypeOptionGridView extends StatelessWidget {
const TypeOptionGridView({
super.key,
class _GridView extends StatelessWidget {
const _GridView({
required this.children,
required this.crossAxisCount,
required this.mainAxisSpacing,
@@ -3,24 +3,17 @@ 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';
@@ -30,97 +23,83 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
const MobileViewPageMoreBottomSheet({super.key, required this.view});
final ViewPB view;
@override
Widget build(BuildContext context) {
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');
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();
}
},
child: ViewPageBottomSheet(
view: view,
onAction: (action) async => _onAction(context, action),
onRename: (name) {
_onRename(context, name);
context.pop();
},
),
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 lastPublishName = context.read<ShareBloc>().state.pathName;
final publishName = lastPublishName.orDefault(
await generatePublishName(
id,
view.name,
),
final publishName = await generatePublishName(
id,
view.name,
);
if (context.mounted) {
context.read<ShareBloc>().add(
@@ -130,44 +109,19 @@ 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) {
@@ -228,112 +182,4 @@ 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();
},
);
}
}
}
@@ -24,7 +24,6 @@ enum MobileViewBottomSheetBodyAction {
copyPublishLink,
visitSite,
copyShareLink,
updatePathName,
}
typedef MobileViewBottomSheetBodyActionCallback = void Function(
@@ -165,15 +164,6 @@ 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,
@@ -47,7 +47,6 @@ 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
@@ -113,7 +112,6 @@ Future<T?> showMobileBottomSheet<T>(
showRemoveButton: showRemoveButton,
title: title,
onRemove: onRemove,
onDone: onDone,
),
);
@@ -210,7 +208,6 @@ class BottomSheetHeader extends StatelessWidget {
required this.title,
required this.showDoneButton,
this.onRemove,
this.onDone,
});
final bool showBackButton;
@@ -219,7 +216,6 @@ class BottomSheetHeader extends StatelessWidget {
final String title;
final bool showDoneButton;
final VoidCallback? onRemove;
final void Function(BuildContext context)? onDone;
@override
Widget build(BuildContext context) {
@@ -261,13 +257,7 @@ class BottomSheetHeader extends StatelessWidget {
Align(
alignment: Alignment.centerRight,
child: BottomSheetDoneButton(
onDone: () {
if (onDone != null) {
onDone?.call(context);
} else {
Navigator.pop(context);
}
},
onDone: () => Navigator.pop(context),
),
),
],
@@ -279,10 +279,16 @@ class _HomePageState extends State<_HomePage> {
ToastificationType toastType = ToastificationType.success;
switch (actionType) {
case UserWorkspaceActionType.open:
message = result.onFailure((e) {
toastType = ToastificationType.error;
return '${LocaleKeys.workspace_openFailed.tr()}: ${e.msg}';
});
message = result.fold(
(s) {
toastType = ToastificationType.success;
return LocaleKeys.workspace_openSuccess.tr();
},
(e) {
toastType = ToastificationType.error;
return '${LocaleKeys.workspace_openFailed.tr()}: ${e.msg}';
},
);
break;
case UserWorkspaceActionType.delete:
message = result.fold(
@@ -1,3 +0,0 @@
class SpaceUIConstants {
static const itemHeight = 52.0;
}
@@ -1,53 +0,0 @@
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,9 +17,7 @@ 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) {
@@ -65,8 +63,6 @@ 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>(),
@@ -100,7 +96,6 @@ class MobileSpace extends StatelessWidget {
name: layout.defaultName,
layout: layout,
index: 0,
openAfterCreate: true,
),
);
context.read<SpaceBloc>().add(
@@ -1,74 +1,50 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/animated_gesture.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/home/space/space_menu_bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/home/workspaces/create_workspace_menu.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:appflowy/util/navigator_context_extension.dart';
import 'package:appflowy/util/string_extension.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_action_type.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart' hide Icon;
import 'package:flutter/material.dart';
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 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,
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,
),
),
const SizedBox(
height: SpaceUIConstants.itemHeight,
child: _CreateSpaceButton(),
),
],
),
// const Padding(
// padding: EdgeInsets.symmetric(vertical: 8.0),
// child: Divider(
// height: 0.5,
// ),
// ),
// const SizedBox(
// height: 52,
// child: _CreateSpaceButton(),
// ),
],
);
},
);
}
}
class MobileSpaceMenuItem extends StatelessWidget {
const MobileSpaceMenuItem({
super.key,
class _SidebarSpaceMenuItem extends StatelessWidget {
const _SidebarSpaceMenuItem({
required this.space,
required this.isSelected,
});
@@ -103,11 +79,12 @@ class MobileSpaceMenuItem extends StatelessWidget {
cornerRadius: 6.0,
),
leftIconSize: const Size.square(24),
rightIcon: SpaceMenuItemTrailing(
key: ValueKey('${space.id}_space_menu_item_trailing'),
space: space,
currentSpace: context.read<SpaceBloc>().state.currentSpace,
),
rightIcon: isSelected
? const FlowySvg(
FlowySvgs.m_blue_check_s,
blendMode: null,
)
: null,
onTap: () {
context.read<SpaceBloc>().add(SpaceEvent.open(space));
Navigator.of(context).pop();
@@ -116,385 +93,40 @@ class MobileSpaceMenuItem extends StatelessWidget {
}
}
class _CreateSpaceButton extends StatefulWidget {
const _CreateSpaceButton();
// class _CreateSpaceButton extends StatelessWidget {
// const _CreateSpaceButton();
@override
State<_CreateSpaceButton> createState() => _CreateSpaceButtonState();
}
// @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);
// },
// );
// }
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,
);
}
}
// 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(),
// ),
// );
// },
// );
// }
// }
@@ -1,99 +0,0 @@
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();
}
}
}
@@ -1,53 +0,0 @@
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),
),
],
);
}
}
@@ -1,389 +0,0 @@
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,18 +16,15 @@ class FloatingAIEntry extends StatelessWidget {
scaleFactor: 0.99,
onTapUp: () => mobileCreateNewAIChatNotifier.value =
mobileCreateNewAIChatNotifier.value + 1,
child: Hero(
tag: "ai_chat_prompt",
child: DecoratedBox(
decoration: _buildShadowDecoration(context),
child: Container(
decoration: _buildWrapperDecoration(context),
height: 48,
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(left: 18),
child: _buildHintText(context),
),
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,7 +182,6 @@ class _MobileSpaceTabState extends State<MobileSpaceTab>
SpaceEvent.createPage(
name: layout.defaultName,
layout: layout,
openAfterCreate: true,
),
);
} else if (layout == ViewLayoutPB.Document) {
@@ -32,9 +32,6 @@ class EditWorkspaceNameBottomSheet extends StatefulWidget {
required this.type,
required this.onSubmitted,
required this.workspaceName,
this.hintText,
this.validator,
this.validatorBuilder,
});
final EditWorkspaceNameType type;
@@ -43,12 +40,6 @@ 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();
@@ -77,7 +68,6 @@ class _EditWorkspaceNameBottomSheetState
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Form(
@@ -87,24 +77,17 @@ class _EditWorkspaceNameBottomSheetState
controller: _textFieldController,
keyboardType: TextInputType.text,
decoration: InputDecoration(
hintText:
widget.hintText ?? LocaleKeys.workspace_defaultName.tr(),
hintText: LocaleKeys.workspace_defaultName.tr(),
),
validator: widget.validator ??
(value) {
if (value == null || value.isEmpty) {
return LocaleKeys.workspace_workspaceNameCannotBeEmpty.tr();
}
return null;
},
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,
@@ -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_extension.dart';
import 'package:appflowy/util/navigator_context_exntesion.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,7 +234,6 @@ class _WorkspaceMenuItemContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
final memberCount = workspace.memberCount.toInt();
return Padding(
padding: const EdgeInsets.only(left: 12),
child: Column(
@@ -248,10 +247,10 @@ class _WorkspaceMenuItemContent extends StatelessWidget {
overflow: TextOverflow.ellipsis,
),
FlowyText(
memberCount == 0
context.read<WorkspaceMemberBloc>().state.isLoading
? ''
: LocaleKeys.settings_appearance_members_membersCount.plural(
memberCount,
context.read<WorkspaceMemberBloc>().state.members.length,
),
fontSize: 10.0,
color: Theme.of(context).hintColor,
@@ -314,18 +313,16 @@ class _WorkspaceMenuItemTrailing extends StatelessWidget {
size: iconSize,
blendMode: null,
),
const HSpace(8.0),
const HSpace(15.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,
),
child: const FlowySvg(
FlowySvgs.workspace_three_dots_s,
size: iconSize,
),
),
const HSpace(8.0),
],
);
}
@@ -59,8 +59,9 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
}
on<ChatAIMessageEvent>(
(event, emit) {
event.when(
(event, emit) async {
await event.when(
initial: () async {},
updateText: (newText) {
emit(
state.copyWith(
@@ -134,6 +135,7 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
@freezed
class ChatAIMessageEvent with _$ChatAIMessageEvent {
const factory ChatAIMessageEvent.initial() = Initial;
const factory ChatAIMessageEvent.updateText(String text) = _UpdateText;
const factory ChatAIMessageEvent.receiveError(String error) = _ReceiveError;
const factory ChatAIMessageEvent.retry() = _Retry;
@@ -86,7 +86,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
// Loading messages
startLoadingPrevMessage: () async {
Int64? beforeMessageId;
final oldestMessage = _getOldestMessage();
final oldestMessage = _getOlderstMessage();
if (oldestMessage != null) {
try {
beforeMessageId = Int64.parseInt(oldestMessage.id);
@@ -106,8 +106,9 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
didLoadPreviousMessages: (List<Message> messages, bool hasMore) {
Log.debug("did load previous messages: ${messages.length}");
final onetimeMessages = _getOnetimeMessages();
final allMessages = _permanentMessages();
final uniqueMessages = {...allMessages, ...messages}.toList();
final allMessages = _perminentMessages();
final uniqueMessages = {...allMessages, ...messages}.toList()
..sort((a, b) => b.id.compareTo(a.id));
uniqueMessages.insertAll(0, onetimeMessages);
@@ -121,8 +122,9 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
},
didLoadLatestMessages: (List<Message> messages) {
final onetimeMessages = _getOnetimeMessages();
final allMessages = _permanentMessages();
final uniqueMessages = {...allMessages, ...messages}.toList();
final allMessages = _perminentMessages();
final uniqueMessages = {...allMessages, ...messages}.toList()
..sort((a, b) => b.id.compareTo(a.id));
uniqueMessages.insertAll(0, onetimeMessages);
emit(
state.copyWith(
@@ -152,7 +154,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
final payload = StopStreamPB(chatId: chatId);
await AIEventStopStream(payload).send();
final allMessages = _permanentMessages();
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) {
@@ -173,8 +175,8 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
);
}
},
receiveMessage: (Message message) {
final allMessages = _permanentMessages();
receveMessage: (Message message) {
final allMessages = _perminentMessages();
// remove message with the same id
allMessages.removeWhere((element) => element.id == message.id);
allMessages.insert(0, message);
@@ -185,7 +187,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
);
},
startAnswerStreaming: (Message message) {
final allMessages = _permanentMessages();
final allMessages = _perminentMessages();
allMessages.insert(0, message);
emit(
state.copyWith(
@@ -197,7 +199,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
},
sendMessage: (String message, Map<String, dynamic>? metadata) async {
unawaited(_startStreamingMessage(message, metadata, emit));
final allMessages = _permanentMessages();
final allMessages = _perminentMessages();
emit(
state.copyWith(
lastSentMessage: null,
@@ -219,26 +221,16 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
),
);
},
failedSending: () {
emit(
state.copyWith(
messages: _permanentMessages()..removeAt(0),
sendingState: const SendMessageState.done(),
canSendMessage: true,
),
);
},
// related question
didReceiveRelatedQuestion: (List<RelatedQuestionPB> questions) {
if (questions.isEmpty) {
return;
}
final allMessages = _permanentMessages();
final allMessages = _perminentMessages();
final message = CustomMessage(
metadata: OnetimeShotType.relatedQuestion.toMap(),
author: const User(id: systemUserId),
showStatus: false,
id: systemUserId,
);
allMessages.insert(0, message);
@@ -249,7 +241,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
),
);
},
clearRelatedQuestions: () {
clearReleatedQuestion: () {
emit(
state.copyWith(
relatedQuestions: [],
@@ -280,7 +272,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
}
final message = _createTextMessage(pb);
add(ChatEvent.receiveMessage(message));
add(ChatEvent.receveMessage(message));
}
},
chatErrorMessageCallback: (err) {
@@ -333,7 +325,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
}
// Returns the list of messages that are not include one-time messages.
List<Message> _permanentMessages() {
List<Message> _perminentMessages() {
final allMessages = state.messages.where((element) {
return !(element.metadata?.containsKey(onetimeShotType) == true);
}).toList();
@@ -349,7 +341,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
return messages;
}
Message? _getOldestMessage() {
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);
@@ -392,7 +384,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
questionStream,
metadata,
);
add(ChatEvent.receiveMessage(questionStreamMessage));
add(ChatEvent.receveMessage(questionStreamMessage));
// Stream message to the server
final result = await AIEventStreamMessage(payload).send();
@@ -402,7 +394,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
add(ChatEvent.finishSending(question));
// final message = _createTextMessage(question);
// add(ChatEvent.receiveMessage(message));
// add(ChatEvent.receveMessage(message));
final streamAnswer =
_createAnswerStreamMessage(answerStream, question.messageId);
@@ -420,12 +412,10 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
final error = CustomMessage(
metadata: metadata,
author: const User(id: systemUserId),
showStatus: false,
id: systemUserId,
);
add(const ChatEvent.failedSending());
add(ChatEvent.receiveMessage(error));
add(ChatEvent.receveMessage(error));
}
},
);
@@ -446,7 +436,6 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
"chatId": chatId,
},
id: streamMessageId,
showStatus: false,
createdAt: DateTime.now().millisecondsSinceEpoch,
text: '',
);
@@ -473,7 +462,6 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
author: User(id: state.userProfile.id.toString()),
metadata: metadata,
id: questionStreamMessageId,
showStatus: false,
createdAt: DateTime.now().millisecondsSinceEpoch,
text: '',
);
@@ -492,7 +480,6 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
id: messageId,
text: message.content,
createdAt: message.createdAt.toInt() * 1000,
showStatus: false,
metadata: {
messageRefSourceJsonStringKey: message.metadata,
},
@@ -511,12 +498,11 @@ class ChatEvent with _$ChatEvent {
}) = _SendMessage;
const factory ChatEvent.finishSending(ChatMessagePB message) =
_FinishSendMessage;
const factory ChatEvent.failedSending() = _FailSendMessage;
// receive message
const factory ChatEvent.startAnswerStreaming(Message message) =
_StartAnswerStreaming;
const factory ChatEvent.receiveMessage(Message message) = _ReceiveMessage;
const factory ChatEvent.receveMessage(Message message) = _ReceiveMessage;
const factory ChatEvent.finishAnswerStreaming() = _FinishAnswerStreaming;
// loading messages
@@ -532,7 +518,7 @@ class ChatEvent with _$ChatEvent {
const factory ChatEvent.didReceiveRelatedQuestion(
List<RelatedQuestionPB> questions,
) = _DidReceiveRelatedQueston;
const factory ChatEvent.clearRelatedQuestions() = _ClearRelatedQuestions;
const factory ChatEvent.clearReleatedQuestion() = _ClearRelatedQuestion;
const factory ChatEvent.didUpdateAnswerStream(
AnswerStream stream,
@@ -1,9 +1,11 @@
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;
@@ -98,6 +100,30 @@ 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
@@ -5,52 +5,58 @@ 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';
part 'ai_prompt_input_bloc.freezed.dart';
import 'chat_input_bloc.dart';
class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
AIPromptInputBloc()
: _listener = LocalLLMListener(),
super(AIPromptInputState.initial()) {
_dispatch();
_startListening();
_init();
}
part 'chat_file_bloc.freezed.dart';
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];
class ChatFileBloc extends Bloc<ChatFileEvent, ChatFileState> {
ChatFileBloc()
: listener = LocalLLMListener(),
super(const ChatFileState()) {
listener.start(
stateCallback: (pluginState) {
if (!isClosed) {
add(ChatFileEvent.updatePluginState(pluginState));
}
},
chatStateCallback: (chatState) {
if (!isClosed) {
add(ChatFileEvent.updateChatState(chatState));
}
},
);
on<ChatFileEvent>(
(event, emit) async {
await event.when(
initial: () async {
final result = await AIEventGetLocalAIChatState().send();
result.fold(
(chatState) {
if (!isClosed) {
add(
ChatFileEvent.updateChatState(chatState),
);
}
},
(err) {
Log.error(err.toString());
},
);
},
newFile: (String filePath, String fileName) async {
final files = List<ChatFile>.from(state.uploadFiles);
final newFile = ChatFile.fromFilePath(filePath);
if (newFile != null) {
files.add(newFile);
emit(state.copyWith(uploadFiles: files));
emit(
state.copyWith(
uploadFiles: files,
),
);
}
},
updateChatState: (LocalAIChatPB chatState) {
@@ -70,8 +76,8 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
fileEnabled && chatState.state == RunningStatePB.Running;
final aiType = chatState.state == RunningStatePB.Running
? AIType.localAI
: AIType.appflowyAI;
? const AIType.localAI()
: const AIType.appflowyAI();
emit(
state.copyWith(
@@ -101,66 +107,48 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
);
}
void _startListening() {
_listener.start(
stateCallback: (pluginState) {
if (!isClosed) {
add(AIPromptInputEvent.updatePluginState(pluginState));
}
},
chatStateCallback: (chatState) {
if (!isClosed) {
add(AIPromptInputEvent.updateChatState(chatState));
}
},
ChatInputFileMetadata consumeMetaData() {
final metadata = state.uploadFiles.fold(
<String, ChatFile>{},
(map, file) => map..putIfAbsent(file.filePath, () => file),
);
if (metadata.isNotEmpty) {
add(const ChatFileEvent.clear());
}
return metadata;
}
void _init() {
AIEventGetLocalAIChatState().send().fold(
(chatState) {
if (!isClosed) {
add(AIPromptInputEvent.updateChatState(chatState));
}
},
Log.error,
);
final LocalLLMListener listener;
@override
Future<void> close() async {
await listener.stop();
return super.close();
}
}
@freezed
class AIPromptInputEvent with _$AIPromptInputEvent {
const factory AIPromptInputEvent.newFile(String filePath, String fileName) =
class ChatFileEvent with _$ChatFileEvent {
const factory ChatFileEvent.initial() = Initial;
const factory ChatFileEvent.newFile(String filePath, String fileName) =
_NewFile;
const factory AIPromptInputEvent.deleteFile(ChatFile file) = _DeleteFile;
const factory AIPromptInputEvent.clear() = _ClearFile;
const factory AIPromptInputEvent.updateChatState(
LocalAIChatPB chatState,
) = _UpdateChatState;
const factory AIPromptInputEvent.updatePluginState(
const factory ChatFileEvent.deleteFile(ChatFile file) = _DeleteFile;
const factory ChatFileEvent.clear() = _ClearFile;
const factory ChatFileEvent.updateChatState(LocalAIChatPB chatState) =
_UpdateChatState;
const factory ChatFileEvent.updatePluginState(
LocalAIPluginStatePB chatState,
) = _UpdatePluginState;
}
@freezed
class AIPromptInputState with _$AIPromptInputState {
const factory AIPromptInputState({
required bool supportChatWithFile,
class ChatFileState with _$ChatFileState {
const factory ChatFileState({
@Default(false) bool supportChatWithFile,
LocalAIChatPB? chatState,
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;
@Default([]) List<ChatFile> uploadFiles,
@Default(AIType.appflowyAI()) AIType aiType,
}) = _ChatFileState;
}
@@ -0,0 +1,87 @@
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,3 +1,4 @@
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@@ -6,11 +7,14 @@ 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 {
event.when(
await event.when(
initial: () async {},
updateUploadState: (UploadFileIndicator indicator) {
emit(state.copyWith(uploadFileIndicator: indicator));
},
@@ -24,6 +28,7 @@ class ChatInputFileBloc extends Bloc<ChatInputFileEvent, ChatInputFileState> {
@freezed
class ChatInputFileEvent with _$ChatInputFileEvent {
const factory ChatInputFileEvent.initial() = Initial;
const factory ChatInputFileEvent.updateUploadState(
UploadFileIndicator indicator,
) = _UpdateUploadState;
@@ -13,6 +13,7 @@ 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);
@@ -50,6 +51,7 @@ class ChatMemberBloc extends Bloc<ChatMemberEvent, ChatMemberState> {
@freezed
class ChatMemberEvent with _$ChatMemberEvent {
const factory ChatMemberEvent.initial() = Initial;
const factory ChatMemberEvent.getMemberInfo(
String userId,
) = _GetMemberInfo;
@@ -11,7 +11,7 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:nanoid/nanoid.dart';
/// Indicate file source from appflowy document
const appflowySource = "appflowy";
const appflowySoruce = "appflowy";
List<ChatFile> fileListFromMessageMetadata(
Map<String, dynamic>? map,
@@ -119,7 +119,7 @@ Future<List<ChatMessageMetaPB>> metadataPBFromMetadata(
name: view.name,
data: pb.text,
dataType: ChatMessageMetaTypePB.Txt,
source: appflowySource,
source: appflowySoruce,
),
);
}, (err) {
@@ -1,75 +0,0 @@
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;
}
@@ -0,0 +1,85 @@
import 'dart:async';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_side_pannel_bloc.freezed.dart';
const double kDefaultSidePannelWidth = 500;
class ChatSidePannelBloc
extends Bloc<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;
}
@@ -1,9 +1,14 @@
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/ai_prompt_input_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_file_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_related_question.dart';
import 'package:appflowy/plugins/ai_chat/presentation/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';
@@ -20,17 +25,39 @@ import 'package:styled_widget/styled_widget.dart';
import 'package:universal_platform/universal_platform.dart';
import 'application/chat_member_bloc.dart';
import 'application/chat_side_panel_bloc.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 'application/chat_side_pannel_bloc.dart';
import 'presentation/chat_input/chat_input.dart';
import 'presentation/chat_side_pannel.dart';
import 'presentation/chat_theme.dart';
import 'presentation/chat_user_invalid_message.dart';
import 'presentation/chat_welcome_page.dart';
import 'presentation/layout_define.dart';
import 'presentation/message/ai_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);
}
}
class AIChatPage extends StatelessWidget {
const AIChatPage({
super.key,
@@ -45,50 +72,62 @@ class AIChatPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (userProfile.authenticator != AuthenticatorPB.AppFlowyCloud) {
return Center(
child: FlowyText(
LocaleKeys.chat_unsupportedCloudPrompt.tr(),
fontSize: 20,
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,
),
);
},
),
);
}
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()),
),
/// [AIPromptInputBloc] is used to handle the user prompt
BlocProvider(create: (_) => AIPromptInputBloc()),
BlocProvider(create: (_) => ChatSidePanelBloc(chatId: view.id)),
BlocProvider(create: (_) => ChatMemberBloc()),
],
child: 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,
),
return Center(
child: FlowyText(
LocaleKeys.chat_unsupportedCloudPrompt.tr(),
fontSize: 20,
),
);
}
}
class _ChatContentPage extends StatelessWidget {
class _ChatContentPage extends StatefulWidget {
const _ChatContentPage({
required this.view,
required this.userProfile,
@@ -98,168 +137,188 @@ class _ChatContentPage extends StatelessWidget {
final ViewPB view;
@override
Widget build(BuildContext context) {
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;
State<_ChatContentPage> createState() => _ChatContentPageState();
}
return Row(
children: [
Center(
child: buildChatWidget()
.constrained(
maxWidth: 784,
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);
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,
)
.padding(horizontal: 32)
.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 {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 784),
child: buildChatWidget(),
),
),
],
);
// TODO(lucas): implement mobile chat side panel
return const SizedBox.shrink();
}
}
Widget buildChatSidePanel() {
return BlocBuilder<ChatSidePanelBloc, ChatSidePanelState>(
builder: (context, state) {
if (state.metadata == null) {
return const SizedBox.shrink();
}
return const ChatSidePanel();
},
);
}
Widget buildChatWidget() {
return BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
return ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: BlocBuilder<ChatBloc, ChatState>(
builder: (_, state) => state.initialLoadingStatus.isFinish
? Chat(
messages: state.messages,
dateHeaderBuilder: (_) => const SizedBox.shrink(),
onSendPressed: (_) {
// We use custom bottom widget for chat input, so
// do not need to handle this event.
},
customBottomWidget: _buildBottom(context),
user: types.User(id: userProfile.id.toString()),
theme: _buildTheme(context),
onEndReached: () async {
if (state.hasMorePrevMessage &&
state.loadingPreviousStatus.isFinish) {
context
.read<ChatBloc>()
.add(const ChatEvent.startLoadingPrevMessage());
}
},
emptyState: TextFieldTapRegion(
child: ChatWelcomePage(
userProfile: userProfile,
onSelectedQuestion: (question) => context
.read<ChatBloc>()
.add(ChatEvent.sendMessage(message: question)),
),
),
messageWidthRatio: AIChatUILayout.messageWidthRatio,
textMessageBuilder: (
textMessage, {
required messageWidth,
required showName,
}) =>
_buildTextMessage(context, textMessage, state),
customMessageBuilder: (message, {required messageWidth}) {
final messageType = onetimeMessageTypeFromMeta(
message.metadata,
);
if (messageType == OnetimeShotType.invalidSendMesssage) {
return ChatInvalidUserMessage(
message: message,
);
}
if (messageType == OnetimeShotType.relatedQuestion) {
return RelatedQuestionList(
relatedQuestions: state.relatedQuestions,
onQuestionSelected: (question) {
final bloc = context.read<ChatBloc>();
bloc
..add(ChatEvent.sendMessage(message: question))
..add(const ChatEvent.clearRelatedQuestions());
},
);
}
return const SizedBox.shrink();
},
bubbleBuilder: (
child, {
required message,
required nextMessageInGroup,
}) =>
_buildBubble(context, message, child),
)
: const Center(
child: CircularProgressIndicator.adaptive(),
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 _buildTextMessage(
BuildContext context,
TextMessage message,
Widget _buildBubble(
BuildContext blocContext,
Message message,
Widget child,
ChatState state,
) {
if (message.author.id == userProfile.id.toString()) {
final stream = message.metadata?["$QuestionStream"];
return ChatUserMessageWidget(
key: ValueKey(message.id),
user: message.author,
message: stream is QuestionStream ? stream : message.text,
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),
@@ -271,115 +330,160 @@ class _ChatContentPage extends StatelessWidget {
final questionId = message.metadata?[messageQuestionIdKey];
final refSourceJsonString =
message.metadata?[messageRefSourceJsonStringKey] as String?;
return BlocSelector<ChatBloc, ChatState, bool>(
return ChatAIMessageWidget(
user: message.author,
messageUserId: message.id,
message: stream is AnswerStream ? stream : message.text,
key: ValueKey(message.id),
selector: (state) {
final messages = state.messages.where((e) {
final oneTimeMessageType = onetimeMessageTypeFromMeta(e.metadata);
if (oneTimeMessageType == null) {
return true;
}
if (oneTimeMessageType
case OnetimeShotType.relatedQuestion ||
OnetimeShotType.sendingMessage ||
OnetimeShotType.invalidSendMesssage) {
return false;
}
return true;
});
return messages.isEmpty ? false : messages.first.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));
},
);
questionId: questionId,
chatId: widget.view.id,
refSourceJsonString: refSourceJsonString,
onSelectedMetadata: (ChatMessageRefSource metadata) {
context.read<ChatSidePannelBloc>().add(
ChatSidePannelEvent.selectedMetadata(metadata),
);
},
);
}
}
Widget _buildBubble(
BuildContext context,
Widget _buildAIBubble(
Message message,
BuildContext blocContext,
ChatState state,
Widget child,
) {
if (message.author.id == userProfile.id.toString()) {
return ChatUserMessageBubble(
final messageType = onetimeMessageTypeFromMeta(
message.metadata,
);
if (messageType == OnetimeShotType.invalidSendMesssage) {
return ChatInvalidUserMessage(
message: message,
child: child,
);
} else if (isOtherUserMessage(message)) {
return ChatUserMessageBubble(
message: message,
isCurrentUser: false,
child: child,
);
} else {
// The bubble is rendered in the child already
return child;
}
if (messageType == OnetimeShotType.relatedQuestion) {
return RelatedQuestionList(
onQuestionSelected: (question) {
blocContext
.read<ChatBloc>()
.add(ChatEvent.sendMessage(message: question));
blocContext
.read<ChatBloc>()
.add(const ChatEvent.clearReleatedQuestion());
},
chatId: widget.view.id,
relatedQuestions: state.relatedQuestions,
);
}
return ChatAIMessageBubble(
message: message,
customMessageType: messageType,
child: child,
);
}
Widget _buildBottom(BuildContext context) {
return Padding(
padding: AIChatUILayout.safeAreaInsets(context),
child: BlocSelector<ChatBloc, ChatState, bool>(
selector: (state) => state.canSendMessage,
builder: (context, canSendMessage) {
return UniversalPlatform.isDesktop
? DesktopAIPromptInput(
chatId: view.id,
indicateFocus: true,
onSubmitted: (message) {
context.read<ChatBloc>().add(
ChatEvent.sendMessage(
message: message.text,
metadata: message.metadata,
),
);
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(),
);
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,
);
},
isStreaming: !canSendMessage,
onStopStreaming: () {
context.read<ChatBloc>().add(const ChatEvent.stopStream());
},
)
: MobileAIPromptInput(
chatId: view.id,
onSubmitted: (message) {
context.read<ChatBloc>().add(
ChatEvent.sendMessage(
message: message.text,
metadata: message.metadata,
),
);
},
isStreaming: !canSendMessage,
onStopStreaming: () {
context.read<ChatBloc>().add(const ChatEvent.stopStream());
},
);
},
),
const VSpace(6),
if (UniversalPlatform.isDesktop)
Opacity(
opacity: 0.6,
child: FlowyText(
LocaleKeys.chat_aiMistakePrompt.tr(),
fontSize: 12,
),
),
],
);
},
),
),
);
}
}
AFDefaultChatTheme _buildTheme(BuildContext context) {
return AFDefaultChatTheme(
primaryColor: Theme.of(context).colorScheme.primary,
secondaryColor: AFThemeExtension.of(context).tint1,
);
}
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,
);
}
@@ -4,34 +4,49 @@ 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';
import 'layout_define.dart';
const defaultAvatarSize = 30.0;
class ChatAIAvatar extends StatelessWidget {
const ChatAIAvatar({
super.key,
});
class ChatChatUserAvatar extends StatelessWidget {
const ChatChatUserAvatar({required this.userId, super.key});
final String userId;
@override
Widget build(BuildContext 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: const CircleAvatar(
backgroundColor: Colors.transparent,
child: FlowySvg(
FlowySvgs.flowy_logo_s,
size: Size.square(16),
blendMode: null,
return const ChatBorderedCircleAvatar();
}
}
class ChatBorderedCircleAvatar extends StatelessWidget {
const ChatBorderedCircleAvatar({
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,
),
),
),
);
@@ -43,35 +58,28 @@ 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) {
child = _buildEmptyAvatar(context);
return _buildEmptyAvatar(context);
} else if (isURL(iconUrl)) {
child = _buildUrlAvatar(context);
return _buildUrlAvatar(context);
} else {
child = _buildEmojiAvatar(context);
return _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) {
@@ -88,50 +96,96 @@ class ChatUserAvatar extends StatelessWidget {
.map((element) => element[0].toUpperCase())
.join();
return ColoredBox(
color: color,
child: Center(
child: FlowyText.regular(
nameInitials,
color: Colors.black,
),
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,
),
);
}
Widget _buildUrlAvatar(BuildContext context) {
return CircleAvatar(
backgroundColor: Colors.transparent,
radius: DesktopAIConvoSizes.avatarSize / 2,
child: Image.network(
iconUrl,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
_buildEmptyAvatar(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),
),
),
),
),
);
}
Widget _buildEmojiAvatar(BuildContext context) {
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 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 the user name.
/// Return the user name, if the user name is empty,
/// return the default 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();
}
}
@@ -1,150 +0,0 @@
// 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),
);
}
}
@@ -1,123 +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';
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,
);
}
}
@@ -0,0 +1,30 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/material.dart';
class ChatInputAtButton extends StatelessWidget {
const ChatInputAtButton({required this.onTap, super.key});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return FlowyTooltip(
message: LocaleKeys.chat_clickToMention.tr(),
child: FlowyIconButton(
hoverColor: AFThemeExtension.of(context).lightGreyHover,
radius: BorderRadius.circular(6),
icon: FlowySvg(
FlowySvgs.chat_at_s,
size: const Size.square(20),
color: Colors.grey.shade600,
),
onPressed: onTap,
),
);
}
}
@@ -0,0 +1,451 @@
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;
}
}
@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
class ChatInputAttachment extends StatelessWidget {
const ChatInputAttachment({required this.onTap, super.key});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return FlowyTooltip(
message: LocaleKeys.chat_uploadFile.tr(),
child: FlowyIconButton(
hoverColor: AFThemeExtension.of(context).lightGreyHover,
radius: BorderRadius.circular(6),
icon: FlowySvg(
FlowySvgs.ai_attachment_s,
size: const Size.square(20),
color: Colors.grey.shade600,
),
onPressed: onTap,
),
);
}
}
@@ -1,167 +1,130 @@
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({
super.key,
required this.chatId,
required this.files,
required this.onDeleted,
super.key,
});
final List<ChatFile> files;
final String chatId;
final void Function(ChatFile) onDeleted;
final Function(ChatFile) onDeleted;
@override
Widget build(BuildContext context) {
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,
),
itemCount: files.length,
itemBuilder: (context, index) => ChatFilePreview(
final List<Widget> children = files
.map(
(file) => ChatFilePreview(
chatId: chatId,
file: files[index],
onDeleted: () => onDeleted(files[index]),
file: file,
onDeleted: onDeleted,
),
);
},
)
.toList();
return Wrap(
spacing: 6,
runSpacing: 6,
children: children,
);
}
}
class ChatFilePreview extends StatefulWidget {
class ChatFilePreview extends StatelessWidget {
const ChatFilePreview({
required this.chatId,
required this.file,
required this.onDeleted,
super.key,
});
final String chatId;
final ChatFile file;
final VoidCallback onDeleted;
@override
State<ChatFilePreview> createState() => _ChatFilePreviewState();
}
class _ChatFilePreviewState extends State<ChatFilePreview> {
bool isHover = false;
final Function(ChatFile) onDeleted;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => ChatInputFileBloc(file: widget.file),
create: (context) => ChatInputFileBloc(chatId: chatId, file: file)
..add(const ChatInputFileEvent.initial()),
child: BlocBuilder<ChatInputFileBloc, ChatInputFileState>(
builder: (context, state) {
return MouseRegion(
onEnter: (_) => setHover(true),
onExit: (_) => setHover(false),
child: Stack(
children: [
Container(
margin: const EdgeInsetsDirectional.only(top: 6, end: 6),
constraints: const BoxConstraints(maxWidth: 240),
return FlowyHover(
builder: (context, onHover) {
return ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 260,
),
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).dividerColor,
),
color:
Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisSize: MainAxisSize.min,
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
decoration: BoxDecoration(
color: AFThemeExtension.of(context).tint1,
borderRadius: BorderRadius.circular(8),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10.0,
vertical: 14,
),
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,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowyText(
widget.file.fileName,
fontSize: 12.0,
),
FlowyText(
widget.file.fileType.name,
color: Theme.of(context).hintColor,
fontSize: 12.0,
file.fileType.icon,
const HSpace(6),
Flexible(
child: FlowyText(
file.fileName,
fontSize: 12,
maxLines: 6,
),
),
],
),
),
if (onHover)
_CloseButton(
onPressed: () => onDeleted(file),
).positioned(top: -6, right: -6),
],
),
),
if (isHover)
_CloseButton(
onTap: widget.onDeleted,
).positioned(top: 0, right: 0),
],
),
);
},
);
},
),
);
}
void setHover(bool value) {
if (value != isHover) {
setState(() => isHover = value);
}
}
}
class _CloseButton extends StatelessWidget {
const _CloseButton({required this.onTap});
final VoidCallback onTap;
const _CloseButton({required this.onPressed});
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onTap,
child: FlowySvg(
FlowySvgs.ai_close_filled_s,
color: AFThemeExtension.of(context).greyHover,
size: const Size.square(16),
),
return FlowyIconButton(
width: 24,
height: 24,
isSelected: true,
radius: BorderRadius.circular(12),
fillColor: Theme.of(context).colorScheme.surfaceContainer,
icon: const FlowySvg(
FlowySvgs.close_s,
size: Size.square(20),
),
onPressed: onPressed,
);
}
}
@@ -65,7 +65,9 @@ class AtText extends SpecialText {
style: textStyle,
recognizer: (TapGestureRecognizer()
..onTap = () {
onTap?.call(atText);
if (onTap != null) {
onTap!(atText);
}
}),
);
}
@@ -0,0 +1,50 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flutter/material.dart';
class ChatInputSendButton extends StatelessWidget {
const ChatInputSendButton({
required this.onSendPressed,
required this.onStopStreaming,
required this.isStreaming,
required this.enabled,
super.key,
});
final void Function() onSendPressed;
final void Function() onStopStreaming;
final bool isStreaming;
final bool enabled;
@override
Widget build(BuildContext context) {
if (isStreaming) {
return FlowyIconButton(
icon: FlowySvg(
FlowySvgs.ai_stream_stop_s,
size: const Size.square(20),
color: Theme.of(context).colorScheme.primary,
),
onPressed: onStopStreaming,
radius: BorderRadius.circular(18),
fillColor: AFThemeExtension.of(context).lightGreyHover,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
);
} else {
return FlowyIconButton(
fillColor: AFThemeExtension.of(context).lightGreyHover,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
radius: BorderRadius.circular(18),
icon: FlowySvg(
FlowySvgs.send_s,
size: const Size.square(14),
color: enabled
? Theme.of(context).colorScheme.primary
: Colors.grey.shade600,
),
onPressed: onSendPressed,
);
}
}
}
@@ -1,387 +0,0 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/ai_prompt_input_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_action_control.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/startup/startup.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:extended_text_field/extended_text_field.dart';
import 'package:flowy_infra/file_picker/file_picker_service.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
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 'ai_prompt_buttons.dart';
import 'chat_input_span.dart';
import '../layout_define.dart';
class DesktopAIPromptInput extends StatefulWidget {
const DesktopAIPromptInput({
super.key,
required this.chatId,
required this.indicateFocus,
this.options = const InputOptions(),
required this.isStreaming,
required this.onStopStreaming,
required this.onSubmitted,
});
final String chatId;
final bool indicateFocus;
final InputOptions options;
final bool isStreaming;
final void Function() onStopStreaming;
final void Function(types.PartialText) onSubmitted;
@override
State<DesktopAIPromptInput> createState() => _DesktopAIPromptInputState();
}
class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
final GlobalKey _textFieldKey = GlobalKey();
final LayerLink _layerLink = LayerLink();
late final ChatInputActionControl _inputActionControl;
late final FocusNode _inputFocusNode;
late final TextEditingController _textController;
late SendButtonState sendButtonState;
@override
void initState() {
super.initState();
_textController = InputTextFieldController()
..addListener(_handleTextControllerChange);
_inputFocusNode = FocusNode(
onKeyEvent: (node, event) {
if (_inputActionControl.canHandleKeyEvent(event)) {
_inputActionControl.handleKeyEvent(event);
return KeyEventResult.handled;
} else {
return _handleEnterKeyWithoutShift(
event,
_textController,
widget.isStreaming,
_handleSendPressed,
);
}
},
)..addListener(() {
// refresh border color on focus change
if (widget.indicateFocus) {
setState(() {});
}
});
WidgetsBinding.instance.addPostFrameCallback((_) {
_inputFocusNode.requestFocus();
});
_inputActionControl = ChatInputActionControl(
chatId: widget.chatId,
textController: _textController,
textFieldFocusNode: _inputFocusNode,
);
updateSendButtonState();
}
@override
void didUpdateWidget(covariant oldWidget) {
updateSendButtonState();
super.didUpdateWidget(oldWidget);
}
@override
void dispose() {
_inputFocusNode.dispose();
_textController.dispose();
_inputActionControl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: _inputFocusNode.hasFocus && widget.indicateFocus
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline,
),
borderRadius: DesktopAIPromptSizes.promptFrameRadius,
),
child: Column(
children: [
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: DesktopAIPromptSizes.attachedFilesBarPadding.vertical +
DesktopAIPromptSizes.attachedFilesPreviewHeight,
),
child: TextFieldTapRegion(
child: ChatInputFile(
chatId: widget.chatId,
onDeleted: (file) => context
.read<AIPromptInputBloc>()
.add(AIPromptInputEvent.deleteFile(file)),
),
),
),
Stack(
children: [
ConstrainedBox(
constraints: const BoxConstraints(
minHeight: DesktopAIPromptSizes.textFieldMinHeight +
DesktopAIPromptSizes.actionBarHeight,
maxHeight: 300,
),
child: _inputTextField(context),
),
Positioned.fill(
top: null,
child: TextFieldTapRegion(
child: Container(
height: DesktopAIPromptSizes.actionBarHeight,
padding: const EdgeInsets.symmetric(horizontal: 8),
child: BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
builder: (context, state) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// _predefinedFormatButton(),
const Spacer(),
_mentionButton(),
const HSpace(
DesktopAIPromptSizes.actionBarButtonSpacing,
),
if (UniversalPlatform.isDesktop &&
state.supportChatWithFile) ...[
_attachmentButton(),
const HSpace(
DesktopAIPromptSizes.actionBarButtonSpacing,
),
],
_sendButton(),
],
);
},
),
),
),
),
],
),
],
),
);
}
void updateSendButtonState() {
if (widget.isStreaming) {
sendButtonState = SendButtonState.streaming;
} else if (_textController.text.trim().isEmpty) {
sendButtonState = SendButtonState.disabled;
} else {
sendButtonState = SendButtonState.enabled;
}
}
void _handleSendPressed() {
final trimmedText = _textController.text.trim();
_textController.clear();
if (trimmedText.isEmpty) {
return;
}
// consume metadata
final ChatInputMentionMetadata mentionPageMetadata =
_inputActionControl.consumeMetaData();
final ChatInputFileMetadata fileMetadata =
context.read<AIPromptInputBloc>().consumeMetadata();
// combine metadata
final Map<String, dynamic> metadata = {}
..addAll(mentionPageMetadata)
..addAll(fileMetadata);
final partialText = types.PartialText(
text: trimmedText,
metadata: metadata,
);
widget.onSubmitted(partialText);
}
void _handleTextControllerChange() {
if (_textController.value.isComposingRangeValid) {
return;
}
setState(() => updateSendButtonState());
}
Widget _inputTextField(BuildContext context) {
return CompositedTransformTarget(
link: _layerLink,
child: BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
builder: (context, state) {
return ExtendedTextField(
key: _textFieldKey,
controller: _textController,
focusNode: _inputFocusNode,
decoration: InputDecoration(
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: DesktopAIPromptSizes.textFieldContentPadding.add(
const EdgeInsets.only(
bottom: DesktopAIPromptSizes.actionBarHeight,
),
),
hintText: switch (state.aiType) {
AIType.appflowyAI => LocaleKeys.chat_inputMessageHint.tr(),
AIType.localAI => LocaleKeys.chat_inputLocalAIMessageHint.tr()
},
hintStyle: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: Theme.of(context).hintColor),
isCollapsed: true,
isDense: true,
),
keyboardType: TextInputType.multiline,
textCapitalization: TextCapitalization.sentences,
minLines: 1,
maxLines: null,
style: Theme.of(context).textTheme.bodyMedium,
specialTextSpanBuilder: ChatInputTextSpanBuilder(
inputActionControl: _inputActionControl,
),
onChanged: (text) {
_handleOnTextChange(context, text);
},
);
},
),
);
}
Future<void> _handleOnTextChange(BuildContext context, String text) async {
if (!_inputActionControl.onTextChanged(text)) {
return;
}
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();
}
Widget _mentionButton() {
return PromptInputMentionButton(
iconSize: DesktopAIPromptSizes.actionBarIconSize,
buttonSize: DesktopAIPromptSizes.actionBarButtonSize,
onTap: () {
_textController.text += '@';
if (!_inputFocusNode.hasFocus) {
_inputFocusNode.requestFocus();
}
_handleOnTextChange(context, _textController.text);
},
);
}
Widget _attachmentButton() {
return PromptInputAttachmentButton(
onTap: () async {
final path = await getIt<FilePickerService>().pickFiles(
dialogTitle: '',
type: FileType.custom,
allowedExtensions: ["pdf", "txt", "md"],
);
if (path == null) {
return;
}
for (final file in path.files) {
if (file.path != null) {
if (mounted) {
context
.read<AIPromptInputBloc>()
.add(AIPromptInputEvent.newFile(file.path!, file.name));
}
}
}
},
);
}
Widget _sendButton() {
return PromptInputSendButton(
buttonSize: DesktopAIPromptSizes.actionBarButtonSize,
iconSize: DesktopAIPromptSizes.sendButtonSize,
state: sendButtonState,
onSendPressed: _handleSendPressed,
onStopStreaming: widget.onStopStreaming,
);
}
}
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;
}
}
@@ -0,0 +1,13 @@
import 'package:flutter/material.dart';
import 'chat_input.dart';
const double sendButtonSize = 26;
const double attachButtonSize = 26;
const buttonPadding = EdgeInsets.symmetric(horizontal: 2);
const inputPadding = EdgeInsets.all(6);
final textPadding = isMobile
? const EdgeInsets.only(left: 8.0, right: 4.0)
: const EdgeInsets.symmetric(horizontal: 16);
final borderRadius = BorderRadius.circular(30);
const color = Colors.transparent;
@@ -1,283 +0,0 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/ai_prompt_input_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/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/workspace/application/view/view_ext.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:extended_text_field/extended_text_field.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
import 'ai_prompt_buttons.dart';
import 'chat_input_span.dart';
import '../layout_define.dart';
class MobileAIPromptInput extends StatefulWidget {
const MobileAIPromptInput({
super.key,
required this.chatId,
this.options = const InputOptions(),
required this.isStreaming,
required this.onStopStreaming,
required this.onSubmitted,
});
final String chatId;
final InputOptions options;
final bool isStreaming;
final void Function() onStopStreaming;
final void Function(types.PartialText) onSubmitted;
@override
State<MobileAIPromptInput> createState() => _MobileAIPromptInputState();
}
class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
late final ChatInputActionControl _inputActionControl;
late final FocusNode _inputFocusNode;
late final TextEditingController _textController;
late SendButtonState sendButtonState;
@override
void initState() {
super.initState();
_textController = InputTextFieldController()
..addListener(_handleTextControllerChange);
_inputFocusNode = FocusNode();
WidgetsBinding.instance.addPostFrameCallback((_) {
_inputFocusNode.requestFocus();
});
_inputActionControl = ChatInputActionControl(
chatId: widget.chatId,
textController: _textController,
textFieldFocusNode: _inputFocusNode,
);
updateSendButtonState();
}
@override
void didUpdateWidget(covariant oldWidget) {
updateSendButtonState();
super.didUpdateWidget(oldWidget);
}
@override
void dispose() {
_inputFocusNode.dispose();
_textController.dispose();
_inputActionControl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Hero(
tag: "ai_chat_prompt",
child: DecoratedBox(
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: Theme.of(context).colorScheme.outline),
),
color: Theme.of(context).colorScheme.surface,
boxShadow: const [
BoxShadow(
blurRadius: 4.0,
offset: Offset(0, -2),
color: Color.fromRGBO(0, 0, 0, 0.05),
),
],
borderRadius: MobileAIPromptSizes.promptFrameRadius,
),
child: Column(
children: [
ConstrainedBox(
constraints: BoxConstraints(
maxHeight:
MobileAIPromptSizes.attachedFilesBarPadding.vertical +
MobileAIPromptSizes.attachedFilesPreviewHeight,
),
child: ChatInputFile(
chatId: widget.chatId,
onDeleted: (file) => context
.read<AIPromptInputBloc>()
.add(AIPromptInputEvent.deleteFile(file)),
),
),
Container(
constraints: const BoxConstraints(
minHeight: MobileAIPromptSizes.textFieldMinHeight,
maxHeight: 220,
),
padding: const EdgeInsetsDirectional.fromSTEB(0, 8.0, 12.0, 8.0),
child: IntrinsicHeight(
child: Row(
children: [
const HSpace(8.0),
Expanded(child: _inputTextField(context)),
_mentionButton(),
const HSpace(6.0),
_sendButton(),
],
),
),
),
],
),
),
);
}
void updateSendButtonState() {
if (widget.isStreaming) {
sendButtonState = SendButtonState.streaming;
} else if (_textController.text.trim().isEmpty) {
sendButtonState = SendButtonState.disabled;
} else {
sendButtonState = SendButtonState.enabled;
}
}
void _handleSendPressed() {
final trimmedText = _textController.text.trim();
_textController.clear();
if (trimmedText.isEmpty) {
return;
}
// consume metadata
final ChatInputMentionMetadata mentionPageMetadata =
_inputActionControl.consumeMetaData();
final ChatInputFileMetadata fileMetadata =
context.read<AIPromptInputBloc>().consumeMetadata();
// combine metadata
final Map<String, dynamic> metadata = {}
..addAll(mentionPageMetadata)
..addAll(fileMetadata);
final partialText = types.PartialText(
text: trimmedText,
metadata: metadata,
);
widget.onSubmitted(partialText);
}
void _handleTextControllerChange() {
if (_textController.value.isComposingRangeValid) {
return;
}
setState(() => updateSendButtonState());
}
Widget _inputTextField(BuildContext context) {
return BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
builder: (context, state) {
return ExtendedTextField(
controller: _textController,
focusNode: _inputFocusNode,
decoration: _buildInputDecoration(state),
keyboardType: TextInputType.multiline,
textCapitalization: TextCapitalization.sentences,
minLines: 1,
maxLines: null,
style: Theme.of(context).textTheme.bodyMedium,
specialTextSpanBuilder: ChatInputTextSpanBuilder(
inputActionControl: _inputActionControl,
),
onChanged: (text) {
_handleOnTextChange(context, text);
},
);
},
);
}
InputDecoration _buildInputDecoration(AIPromptInputState state) {
return InputDecoration(
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: MobileAIPromptSizes.textFieldContentPadding,
hintText: switch (state.aiType) {
AIType.appflowyAI => LocaleKeys.chat_inputMessageHint.tr(),
AIType.localAI => LocaleKeys.chat_inputLocalAIMessageHint.tr()
},
isCollapsed: true,
isDense: true,
);
}
Future<void> _handleOnTextChange(BuildContext context, String text) async {
if (!_inputActionControl.onTextChanged(text)) {
return;
}
// 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 _mentionButton() {
return Align(
alignment: Alignment.bottomCenter,
child: PromptInputMentionButton(
iconSize: MobileAIPromptSizes.mentionIconSize,
buttonSize: MobileAIPromptSizes.sendButtonSize,
onTap: () {
_textController.text += '@';
if (!_inputFocusNode.hasFocus) {
_inputFocusNode.requestFocus();
}
_handleOnTextChange(context, _textController.text);
},
),
);
}
Widget _sendButton() {
return Align(
alignment: Alignment.bottomCenter,
child: PromptInputSendButton(
buttonSize: MobileAIPromptSizes.sendButtonSize,
iconSize: MobileAIPromptSizes.sendButtonSize,
onSendPressed: _handleSendPressed,
onStopStreaming: widget.onStopStreaming,
state: sendButtonState,
),
);
}
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.onSelected(ViewActionPage(view: selectedView));
}
handler.onExit();
_inputFocusNode.requestFocus();
}
}
@@ -4,7 +4,8 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_action_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_action_control.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -150,21 +151,15 @@ class _ActionItem extends StatelessWidget {
: Colors.transparent,
borderRadius: BorderRadius.circular(4.0),
),
child: FlowyTooltip(
message: item.title,
child: FlowyButton(
leftIcon: item.icon,
margin: const EdgeInsets.symmetric(horizontal: 6),
iconPadding: 10.0,
text: FlowyText(
item.title.isEmpty
? LocaleKeys.document_title_placeholder.tr()
: item.title,
lineHeight: 1.0,
overflow: TextOverflow.ellipsis,
),
onTap: onTap,
child: FlowyButton(
leftIcon: item.icon,
margin: const EdgeInsets.symmetric(horizontal: 6),
iconPadding: 10.0,
text: FlowyText.regular(
lineHeight: 1.0,
item.title,
),
onTap: onTap,
),
);
}
@@ -1,76 +1,69 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:shimmer/shimmer.dart';
/// An animated generating indicator for an AI response
class ChatAILoading extends StatelessWidget {
const ChatAILoading({
super.key,
this.duration = const Duration(seconds: 1),
});
final Duration duration;
const ChatAILoading({super.key});
@override
Widget build(BuildContext context) {
final slice = Duration(milliseconds: duration.inMilliseconds ~/ 5);
return SizedBox(
height: 20,
child: SeparatedRow(
separatorBuilder: () => const HSpace(4),
return Shimmer.fromColors(
baseColor: AFThemeExtension.of(context).lightGreyHover,
highlightColor:
AFThemeExtension.of(context).lightGreyHover.withOpacity(0.5),
period: const Duration(seconds: 3),
child: const ContentPlaceholder(),
);
}
}
class ContentPlaceholder extends StatelessWidget {
const ContentPlaceholder({super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsetsDirectional.only(end: 4.0),
child: FlowyText(
LocaleKeys.chat_generatingResponse.tr(),
color: Theme.of(context).hintColor,
),
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 30,
height: 16.0,
margin: const EdgeInsets.only(bottom: 8.0),
decoration: BoxDecoration(
color: AFThemeExtension.of(context).lightGreyHover,
borderRadius: BorderRadius.circular(4.0),
),
),
const HSpace(10),
Container(
width: 100,
height: 16.0,
margin: const EdgeInsets.only(bottom: 8.0),
decoration: BoxDecoration(
color: AFThemeExtension.of(context).lightGreyHover,
borderRadius: BorderRadius.circular(4.0),
),
),
],
),
buildDot(const Color(0xFF9327FF))
.animate(onPlay: (controller) => controller.repeat())
.slideY(duration: slice, begin: 0, end: -1)
.then()
.slideY(begin: -1, end: 1)
.then()
.slideY(begin: 1, end: 0)
.then()
.slideY(duration: slice * 2, begin: 0, end: 0),
buildDot(const Color(0xFFFB006D))
.animate(onPlay: (controller) => controller.repeat())
.slideY(duration: slice, begin: 0, end: 0)
.then()
.slideY(begin: 0, end: -1)
.then()
.slideY(begin: -1, end: 1)
.then()
.slideY(begin: 1, end: 0)
.then()
.slideY(begin: 0, end: 0),
buildDot(const Color(0xFFFFCE00))
.animate(onPlay: (controller) => controller.repeat())
.slideY(duration: slice * 2, begin: 0, end: 0)
.then()
.slideY(duration: slice, begin: 0, end: -1)
.then()
.slideY(begin: -1, end: 1)
.then()
.slideY(begin: 1, end: 0),
// Container(
// width: 140,
// height: 16.0,
// margin: const EdgeInsets.only(bottom: 8.0),
// decoration: BoxDecoration(
// color: AFThemeExtension.of(context).lightGreyHover,
// borderRadius: BorderRadius.circular(4.0),
// ),
// ),
],
),
);
}
Widget buildDot(Color color) {
return SizedBox.square(
dimension: 4,
child: DecoratedBox(
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
),
),
);
}
}
@@ -0,0 +1,70 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
class ChatPopupMenu extends StatefulWidget {
const ChatPopupMenu({
super.key,
required this.onAction,
required this.builder,
});
final Function(ChatMessageAction) onAction;
final Widget Function(BuildContext context) builder;
@override
State<ChatPopupMenu> createState() => _ChatPopupMenuState();
}
class _ChatPopupMenuState extends State<ChatPopupMenu> {
@override
Widget build(BuildContext context) {
return PopoverActionList<ChatMessageActionWrapper>(
asBarrier: true,
actions: ChatMessageAction.values
.map((action) => ChatMessageActionWrapper(action))
.toList(),
buildChild: (controller) {
return GestureDetector(
onLongPress: () {
controller.show();
},
child: widget.builder(context),
);
},
onSelected: (action, controller) async {
widget.onAction(action.inner);
controller.close();
},
direction: PopoverDirection.bottomWithCenterAligned,
);
}
}
enum ChatMessageAction {
copy,
}
class ChatMessageActionWrapper extends ActionCell {
ChatMessageActionWrapper(this.inner);
final ChatMessageAction inner;
@override
Widget? leftIcon(Color iconColor) => null;
@override
String get name => inner.name;
}
extension ChatMessageActionExtension on ChatMessageAction {
String get name {
switch (this) {
case ChatMessageAction.copy:
return LocaleKeys.document_plugins_contextMenu_copy.tr();
}
}
}
@@ -2,46 +2,61 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:universal_platform/universal_platform.dart';
class RelatedQuestionList extends StatelessWidget {
const RelatedQuestionList({
super.key,
required this.chatId,
required this.onQuestionSelected,
required this.relatedQuestions,
super.key,
});
final String chatId;
final Function(String) onQuestionSelected;
final List<RelatedQuestionPB> relatedQuestions;
@override
Widget build(BuildContext context) {
return ListView.separated(
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: relatedQuestions.length + 1,
padding: const EdgeInsets.only(bottom: 8.0) +
(UniversalPlatform.isMobile
? const EdgeInsets.symmetric(horizontal: 16)
: EdgeInsets.zero),
separatorBuilder: (context, index) => const VSpace(4.0),
itemCount: relatedQuestions.length,
itemBuilder: (context, index) {
final question = relatedQuestions[index];
if (index == 0) {
return Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: FlowyText(
LocaleKeys.chat_relatedQuestion.tr(),
color: Theme.of(context).hintColor,
fontWeight: FontWeight.w600,
),
return Column(
children: [
const Divider(height: 36),
Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
FlowySvg(
FlowySvgs.ai_summary_generate_s,
size: const Size.square(24),
color: Theme.of(context).colorScheme.primary,
),
const HSpace(6),
FlowyText(
LocaleKeys.chat_relatedQuestion.tr(),
fontSize: 18,
),
],
),
),
const Divider(height: 6),
RelatedQuestionItem(
question: question,
onQuestionSelected: onQuestionSelected,
),
],
);
} else {
return RelatedQuestionItem(
question: relatedQuestions[index - 1],
question: question,
onQuestionSelected: onQuestionSelected,
);
}
@@ -50,7 +65,7 @@ class RelatedQuestionList extends StatelessWidget {
}
}
class RelatedQuestionItem extends StatelessWidget {
class RelatedQuestionItem extends StatefulWidget {
const RelatedQuestionItem({
required this.question,
required this.onQuestionSelected,
@@ -60,23 +75,38 @@ class RelatedQuestionItem extends StatelessWidget {
final RelatedQuestionPB question;
final Function(String) onQuestionSelected;
@override
State<RelatedQuestionItem> createState() => _RelatedQuestionItemState();
}
class _RelatedQuestionItemState extends State<RelatedQuestionItem> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
return FlowyButton(
text: FlowyText(
question.content,
lineHeight: 1.4,
overflow: TextOverflow.ellipsis,
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
),
title: Text(
widget.question.content,
style: TextStyle(
color: _isHovered ? Theme.of(context).colorScheme.primary : null,
fontSize: 14,
height: 1.5,
),
),
onTap: () {
widget.onQuestionSelected(widget.question.content);
},
trailing: FlowySvg(
FlowySvgs.add_m,
color: Theme.of(context).colorScheme.primary,
),
),
margin: UniversalPlatform.isMobile
? const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0)
: const EdgeInsets.all(8.0),
leftIcon: FlowySvg(
FlowySvgs.ai_chat_outlined_s,
color: Theme.of(context).colorScheme.primary,
size: const Size.square(16.0),
),
onTap: () => onQuestionSelected(question.content),
);
}
}
@@ -1,17 +1,17 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_side_panel_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_side_pannel_bloc.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class ChatSidePanel extends StatelessWidget {
const ChatSidePanel({super.key});
class ChatSidePannel extends StatelessWidget {
const ChatSidePannel({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<ChatSidePanelBloc, ChatSidePanelState>(
return BlocBuilder<ChatSidePannelBloc, ChatSidePannelState>(
builder: (context, state) {
return state.indicator.when(
loading: () {
@@ -24,23 +24,26 @@ class ChatSidePanel extends StatelessWidget {
final pluginContext = PluginContext();
final child = plugin.widgetBuilder
.buildWidget(context: pluginContext, shrinkWrap: false);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: FlowyIconButton(
icon: const FlowySvg(FlowySvgs.show_menu_s),
onPressed: () {
context
.read<ChatSidePanelBloc>()
.add(const ChatSidePanelEvent.close());
},
return Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: FlowyIconButton(
icon: const FlowySvg(FlowySvgs.show_menu_s),
onPressed: () {
context
.read<ChatSidePannelBloc>()
.add(const ChatSidePannelEvent.close());
},
),
),
),
const VSpace(6),
Expanded(child: child),
],
const VSpace(6),
Expanded(child: child),
],
),
);
},
);
@@ -0,0 +1,10 @@
import 'package:flutter/widgets.dart';
class StreamTextField extends StatelessWidget {
const StreamTextField({super.key});
@override
Widget build(BuildContext context) {
return const Placeholder();
}
}
@@ -0,0 +1,83 @@
// import 'package:appflowy/generated/locale_keys.g.dart';
// import 'package:easy_localization/easy_localization.dart';
// import 'package:flowy_infra_ui/flowy_infra_ui.dart';
// import 'package:flutter/material.dart';
// import 'package:flutter_chat_types/flutter_chat_types.dart';
// class ChatStreamingError extends StatelessWidget {
// const ChatStreamingError({
// required this.message,
// required this.onRetryPressed,
// super.key,
// });
// final void Function() onRetryPressed;
// final Message message;
// @override
// Widget build(BuildContext context) {
// final canRetry = message.metadata?[canRetryKey] != null;
// if (canRetry) {
// return Column(
// children: [
// const Divider(height: 4, thickness: 1),
// const VSpace(16),
// Center(
// child: Column(
// children: [
// _aiUnvaliable(),
// const VSpace(10),
// _retryButton(),
// ],
// ),
// ),
// ],
// );
// } else {
// return Center(
// child: Column(
// children: [
// const Divider(height: 20, thickness: 1),
// Padding(
// padding: const EdgeInsets.all(8.0),
// child: FlowyText(
// LocaleKeys.chat_serverUnavailable.tr(),
// fontSize: 14,
// ),
// ),
// ],
// ),
// );
// }
// }
// FlowyButton _retryButton() {
// return FlowyButton(
// radius: BorderRadius.circular(20),
// useIntrinsicWidth: true,
// text: Padding(
// padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
// child: FlowyText(
// LocaleKeys.chat_regenerateAnswer.tr(),
// fontSize: 14,
// ),
// ),
// onTap: onRetryPressed,
// iconPadding: 0,
// leftIcon: const Icon(
// Icons.refresh,
// size: 20,
// ),
// );
// }
// Padding _aiUnvaliable() {
// return Padding(
// padding: const EdgeInsets.all(8.0),
// child: FlowyText(
// LocaleKeys.chat_aiServerUnavailable.tr(),
// fontSize: 14,
// ),
// );
// }
// }
@@ -1,85 +1,222 @@
import 'package:flutter/material.dart';
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
// For internal usage only. Use values from theme itself.
/// See [ChatTheme.userAvatarNameColors].
const colors = [
Color(0xffff6767),
Color(0xff66e0da),
Color(0xfff5a2d9),
Color(0xfff0c722),
Color(0xff6a85e5),
Color(0xfffd9a6f),
Color(0xff92db6e),
Color(0xff73b8e5),
Color(0xfffd7590),
Color(0xffc78ae5),
];
/// Dark.
const dark = Color(0xff1f1c38);
/// Error.
const error = Color(0xffff6767);
/// N0.
const neutral0 = Color(0xff1d1c21);
/// N1.
const neutral1 = Color(0xff615e6e);
/// N2.
const neutral2 = Color(0xff9e9cab);
/// N7.
const neutral7 = Color(0xffffffff);
/// N7 with opacity.
const neutral7WithOpacity = Color(0x80ffffff);
/// Primary.
const primary = Color(0xff6f61e8);
/// Secondary.
const secondary = Color(0xfff5f5f7);
/// Secondary dark.
const secondaryDark = Color(0xff2b2250);
/// Default chat theme which extends [ChatTheme].
@immutable
class AFDefaultChatTheme extends ChatTheme {
/// Creates a default chat theme. Use this constructor if you want to
/// override only a couple of properties, otherwise create a new class
/// which extends [ChatTheme].
const AFDefaultChatTheme({
required super.primaryColor,
required super.secondaryColor,
}) : super(
backgroundColor: Colors.transparent,
// TODO: think how to offset the default 12 pixels set by chat package
bubbleMargin: const EdgeInsets.symmetric(vertical: 4.0),
messageMaxWidth: double.infinity,
// unused
dateDividerMargin: EdgeInsets.zero,
dateDividerTextStyle: const TextStyle(),
attachmentButtonIcon: null,
attachmentButtonMargin: null,
deliveredIcon: null,
documentIcon: null,
emptyChatPlaceholderTextStyle: const TextStyle(),
errorColor: error,
errorIcon: null,
inputBackgroundColor: neutral0,
inputSurfaceTintColor: neutral0,
inputElevation: 0,
inputBorderRadius: BorderRadius.zero,
inputContainerDecoration: null,
inputMargin: EdgeInsets.zero,
inputPadding: EdgeInsets.zero,
inputTextColor: neutral7,
inputTextCursorColor: null,
inputTextDecoration: const InputDecoration(),
inputTextStyle: const TextStyle(),
messageBorderRadius: 0,
messageInsetsHorizontal: 0,
messageInsetsVertical: 0,
receivedEmojiMessageTextStyle: const TextStyle(),
receivedMessageBodyBoldTextStyle: null,
receivedMessageBodyCodeTextStyle: null,
receivedMessageBodyLinkTextStyle: null,
receivedMessageBodyTextStyle: const TextStyle(),
receivedMessageDocumentIconColor: primary,
receivedMessageCaptionTextStyle: const TextStyle(),
receivedMessageLinkDescriptionTextStyle: const TextStyle(),
receivedMessageLinkTitleTextStyle: const TextStyle(),
seenIcon: null,
sendButtonIcon: null,
sendButtonMargin: null,
sendingIcon: null,
sentEmojiMessageTextStyle: const TextStyle(),
sentMessageBodyBoldTextStyle: null,
sentMessageBodyCodeTextStyle: null,
sentMessageBodyLinkTextStyle: null,
sentMessageBodyTextStyle: const TextStyle(),
sentMessageCaptionTextStyle: const TextStyle(),
sentMessageDocumentIconColor: neutral7,
sentMessageLinkDescriptionTextStyle: const TextStyle(),
sentMessageLinkTitleTextStyle: const TextStyle(),
statusIconPadding: EdgeInsets.zero,
systemMessageTheme: const SystemMessageTheme(
margin: EdgeInsets.zero,
textStyle: TextStyle(),
),
typingIndicatorTheme: const TypingIndicatorTheme(
animatedCirclesColor: neutral1,
animatedCircleSize: 0.0,
bubbleBorder: BorderRadius.zero,
bubbleColor: neutral7,
countAvatarColor: primary,
countTextColor: secondary,
multipleUserTextStyle: TextStyle(),
),
unreadHeaderTheme: const UnreadHeaderTheme(
color: secondary,
textStyle: TextStyle(),
),
userAvatarImageBackgroundColor: Colors.transparent,
userAvatarNameColors: colors,
userAvatarTextStyle: const TextStyle(),
userNameTextStyle: const TextStyle(),
highlightMessageColor: null,
);
super.attachmentButtonIcon,
super.attachmentButtonMargin,
super.backgroundColor = neutral7,
super.bubbleMargin,
super.dateDividerMargin = const EdgeInsets.only(
bottom: 32,
top: 16,
),
super.dateDividerTextStyle = const TextStyle(
color: neutral2,
fontSize: 12,
fontWeight: FontWeight.w800,
height: 1.333,
),
super.deliveredIcon,
super.documentIcon,
super.emptyChatPlaceholderTextStyle = const TextStyle(
color: neutral2,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
),
super.errorColor = error,
super.errorIcon,
super.inputBackgroundColor = neutral0,
super.inputSurfaceTintColor = neutral0,
super.inputElevation = 0,
super.inputBorderRadius = const BorderRadius.vertical(
top: Radius.circular(20),
),
super.inputContainerDecoration,
super.inputMargin = EdgeInsets.zero,
super.inputPadding = const EdgeInsets.fromLTRB(14, 20, 14, 20),
super.inputTextColor = neutral7,
super.inputTextCursorColor,
super.inputTextDecoration = const InputDecoration(
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
isCollapsed: true,
),
super.inputTextStyle = const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
),
super.messageBorderRadius = 20,
super.messageInsetsHorizontal = 0,
super.messageInsetsVertical = 0,
super.messageMaxWidth = 1000,
super.primaryColor = primary,
super.receivedEmojiMessageTextStyle = const TextStyle(fontSize: 40),
super.receivedMessageBodyBoldTextStyle,
super.receivedMessageBodyCodeTextStyle,
super.receivedMessageBodyLinkTextStyle,
super.receivedMessageBodyTextStyle = const TextStyle(
color: neutral0,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
),
super.receivedMessageCaptionTextStyle = const TextStyle(
color: neutral2,
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1.333,
),
super.receivedMessageDocumentIconColor = primary,
super.receivedMessageLinkDescriptionTextStyle = const TextStyle(
color: neutral0,
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.428,
),
super.receivedMessageLinkTitleTextStyle = const TextStyle(
color: neutral0,
fontSize: 16,
fontWeight: FontWeight.w800,
height: 1.375,
),
super.secondaryColor = secondary,
super.seenIcon,
super.sendButtonIcon,
super.sendButtonMargin,
super.sendingIcon,
super.sentEmojiMessageTextStyle = const TextStyle(fontSize: 40),
super.sentMessageBodyBoldTextStyle,
super.sentMessageBodyCodeTextStyle,
super.sentMessageBodyLinkTextStyle,
super.sentMessageBodyTextStyle = const TextStyle(
color: neutral7,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
),
super.sentMessageCaptionTextStyle = const TextStyle(
color: neutral7WithOpacity,
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1.333,
),
super.sentMessageDocumentIconColor = neutral7,
super.sentMessageLinkDescriptionTextStyle = const TextStyle(
color: neutral7,
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.428,
),
super.sentMessageLinkTitleTextStyle = const TextStyle(
color: neutral7,
fontSize: 16,
fontWeight: FontWeight.w800,
height: 1.375,
),
super.statusIconPadding = const EdgeInsets.symmetric(horizontal: 4),
super.systemMessageTheme = const SystemMessageTheme(
margin: EdgeInsets.only(
bottom: 24,
top: 8,
left: 8,
right: 8,
),
textStyle: TextStyle(
color: neutral2,
fontSize: 12,
fontWeight: FontWeight.w800,
height: 1.333,
),
),
super.typingIndicatorTheme = const TypingIndicatorTheme(
animatedCirclesColor: neutral1,
animatedCircleSize: 5.0,
bubbleBorder: BorderRadius.all(Radius.circular(27.0)),
bubbleColor: neutral7,
countAvatarColor: primary,
countTextColor: secondary,
multipleUserTextStyle: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: neutral2,
),
),
super.unreadHeaderTheme = const UnreadHeaderTheme(
color: secondary,
textStyle: TextStyle(
color: neutral2,
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1.333,
),
),
super.userAvatarImageBackgroundColor = Colors.transparent,
super.userAvatarNameColors = colors,
super.userAvatarTextStyle = const TextStyle(
color: neutral7,
fontSize: 12,
fontWeight: FontWeight.w800,
height: 1.333,
),
super.userNameTextStyle = const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w800,
height: 1.333,
),
super.highlightMessageColor,
});
}
@@ -1,10 +1,7 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart';
import 'package:universal_platform/universal_platform.dart';
class ChatInvalidUserMessage extends StatelessWidget {
const ChatInvalidUserMessage({
@@ -17,35 +14,17 @@ class ChatInvalidUserMessage extends StatelessWidget {
Widget build(BuildContext context) {
final errorMessage = message.metadata?[sendMessageErrorKey] ?? "";
return Center(
child: Container(
margin: const EdgeInsets.only(top: 16.0, bottom: 24.0),
constraints: UniversalPlatform.isDesktop
? const BoxConstraints(maxWidth: 480)
: const BoxConstraints(),
padding: const EdgeInsets.all(8.0),
decoration: BoxDecoration(
color: Theme.of(context).isLightMode
? const Color(0x80FFE7EE)
: const Color(0x80591734),
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const FlowySvg(
FlowySvgs.warning_filled_s,
blendMode: null,
child: Column(
children: [
const Divider(height: 20, thickness: 1),
Padding(
padding: const EdgeInsets.all(8.0),
child: FlowyText(
errorMessage,
fontSize: 14,
),
const HSpace(8.0),
Flexible(
child: FlowyText(
errorMessage,
lineHeight: 1.4,
maxLines: null,
),
),
],
),
),
],
),
);
}
@@ -1,15 +1,24 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:universal_platform/universal_platform.dart';
import 'chat_input/chat_input.dart';
class WelcomeQuestion {
WelcomeQuestion({
required this.text,
required this.iconData,
});
final String text;
final FlowySvgData iconData;
}
class ChatWelcomePage extends StatelessWidget {
const ChatWelcomePage({
ChatWelcomePage({
required this.userProfile,
required this.onSelectedQuestion,
super.key,
@@ -18,252 +27,112 @@ class ChatWelcomePage extends StatelessWidget {
final void Function(String) onSelectedQuestion;
final UserProfilePB userProfile;
static final List<String> desktopItems = [
LocaleKeys.chat_question1.tr(),
LocaleKeys.chat_question2.tr(),
LocaleKeys.chat_question3.tr(),
LocaleKeys.chat_question4.tr(),
final List<WelcomeQuestion> items = [
WelcomeQuestion(
text: LocaleKeys.chat_question1.tr(),
iconData: FlowySvgs.chat_lightbulb_s,
),
WelcomeQuestion(
text: LocaleKeys.chat_question2.tr(),
iconData: FlowySvgs.chat_scholar_s,
),
WelcomeQuestion(
text: LocaleKeys.chat_question3.tr(),
iconData: FlowySvgs.chat_question_s,
),
WelcomeQuestion(
text: LocaleKeys.chat_question4.tr(),
iconData: FlowySvgs.chat_feather_s,
),
];
static final List<List<String>> mobileItems = [
[
LocaleKeys.chat_question1.tr(),
LocaleKeys.chat_question2.tr(),
],
[
LocaleKeys.chat_question3.tr(),
LocaleKeys.chat_question4.tr(),
],
[
LocaleKeys.chat_question5.tr(),
LocaleKeys.chat_question6.tr(),
],
];
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const FlowySvg(
FlowySvgs.flowy_logo_xl,
size: Size.square(32),
blendMode: null,
),
const VSpace(16),
FlowyText(
fontSize: 15,
LocaleKeys.chat_questionDetail.tr(args: [userProfile.name]),
),
UniversalPlatform.isDesktop ? const VSpace(32 - 16) : const VSpace(24),
...UniversalPlatform.isDesktop
? buildDesktopSampleQuestions(context)
: buildMobileSampleQuestions(context),
],
);
}
Iterable<Widget> buildDesktopSampleQuestions(BuildContext context) {
return desktopItems.map(
(question) => Padding(
padding: const EdgeInsets.only(top: 16.0),
child: WelcomeSampleQuestion(
question: question,
onSelected: onSelectedQuestion,
return AnimatedOpacity(
opacity: 1.0,
duration: const Duration(seconds: 3),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Spacer(),
Opacity(
opacity: 0.8,
child: FlowyText(
fontSize: 15,
LocaleKeys.chat_questionDetail.tr(args: [userProfile.name]),
),
),
const VSpace(18),
Opacity(
opacity: 0.6,
child: FlowyText(
LocaleKeys.chat_questionTitle.tr(),
),
),
const VSpace(8),
Wrap(
direction: Axis.vertical,
spacing: isMobile ? 12.0 : 0.0,
children: items
.map(
(i) => WelcomeQuestionWidget(
question: i,
onSelected: onSelectedQuestion,
),
)
.toList(),
),
const VSpace(20),
],
),
),
);
}
Iterable<Widget> buildMobileSampleQuestions(BuildContext context) {
return [
_AutoScrollingSampleQuestions(
key: const ValueKey('inf_scroll_1'),
onSelected: onSelectedQuestion,
questions: mobileItems[0],
offset: 60.0,
),
const VSpace(8),
_AutoScrollingSampleQuestions(
key: const ValueKey('inf_scroll_2'),
onSelected: onSelectedQuestion,
questions: mobileItems[1],
offset: -50.0,
reverse: true,
),
const VSpace(8),
_AutoScrollingSampleQuestions(
key: const ValueKey('inf_scroll_3'),
onSelected: onSelectedQuestion,
questions: mobileItems[2],
offset: 120.0,
),
];
}
}
class WelcomeSampleQuestion extends StatelessWidget {
const WelcomeSampleQuestion({
class WelcomeQuestionWidget extends StatelessWidget {
const WelcomeQuestionWidget({
required this.question,
required this.onSelected,
super.key,
});
final void Function(String) onSelected;
final String question;
final WelcomeQuestion question;
@override
Widget build(BuildContext context) {
final isLightMode = Theme.of(context).isLightMode;
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
offset: const Offset(0, 1),
blurRadius: 2,
spreadRadius: -2,
color: isLightMode
? const Color(0x051F2329)
: Theme.of(context).shadowColor.withOpacity(0.02),
return InkWell(
onTap: () => onSelected(question.text),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
child: FlowyHover(
// Make the hover effect only available on mobile
isSelected: () => isMobile,
style: HoverStyle(
borderRadius: BorderRadius.circular(6),
),
BoxShadow(
offset: const Offset(0, 2),
blurRadius: 4,
color: isLightMode
? const Color(0x051F2329)
: Theme.of(context).shadowColor.withOpacity(0.02),
),
BoxShadow(
offset: const Offset(0, 2),
blurRadius: 8,
spreadRadius: 2,
color: isLightMode
? const Color(0x051F2329)
: Theme.of(context).shadowColor.withOpacity(0.02),
),
],
),
child: TextButton(
onPressed: () => onSelected(question),
style: ButtonStyle(
padding: WidgetStatePropertyAll(
EdgeInsets.symmetric(
horizontal: 16,
vertical: UniversalPlatform.isDesktop ? 8 : 0,
),
),
backgroundColor: WidgetStateProperty.resolveWith<Color?>((state) {
if (state.contains(WidgetState.hovered)) {
return isLightMode
? const Color(0xFFF9FAFD)
: AFThemeExtension.of(context).lightGreyHover;
}
return Theme.of(context).colorScheme.surface;
}),
overlayColor: WidgetStateColor.transparent,
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: Theme.of(context).dividerColor),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FlowySvg(
question.iconData,
size: const Size.square(18),
blendMode: null,
),
const HSpace(16),
FlowyText(
question.text,
maxLines: null,
),
],
),
),
),
child: FlowyText(
question,
color: isLightMode
? Theme.of(context).hintColor
: const Color(0xFF666D76),
),
),
);
}
}
class _AutoScrollingSampleQuestions extends StatelessWidget {
const _AutoScrollingSampleQuestions({
super.key,
required this.questions,
this.offset = 0.0,
this.reverse = false,
required this.onSelected,
});
final List<String> questions;
final void Function(String) onSelected;
final double offset;
final bool reverse;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 36,
child: InfiniteScrollView(
centerKey: UniqueKey(),
itemCount: questions.length,
itemBuilder: (context, index) {
return WelcomeSampleQuestion(
question: questions[index],
onSelected: onSelected,
);
},
separatorBuilder: (context, index) => const HSpace(8),
),
);
}
}
class InfiniteScrollView extends StatelessWidget {
const InfiniteScrollView({
super.key,
required this.itemCount,
required this.centerKey,
required this.itemBuilder,
required this.separatorBuilder,
this.scrollController,
});
final int itemCount;
final Widget Function(BuildContext context, int index) itemBuilder;
final Widget Function(BuildContext context, int index) separatorBuilder;
final Key centerKey;
final ScrollController? scrollController;
@override
Widget build(BuildContext context) {
return CustomScrollView(
scrollDirection: Axis.horizontal,
controller: scrollController,
center: centerKey,
anchor: 0.5,
slivers: [
_buildList(isForward: false),
SliverToBoxAdapter(
child: separatorBuilder.call(context, 0),
),
SliverToBoxAdapter(
key: centerKey,
child: itemBuilder.call(context, 0),
),
SliverToBoxAdapter(
child: separatorBuilder.call(context, 0),
),
_buildList(isForward: true),
],
);
}
Widget _buildList({required bool isForward}) {
return SliverList.separated(
itemBuilder: (context, index) {
index = (index + 1) % itemCount;
return itemBuilder(context, index);
},
separatorBuilder: (context, index) {
index = (index + 1) % itemCount;
return separatorBuilder(context, index);
},
);
}
}
@@ -1,64 +0,0 @@
import 'package:flutter/material.dart';
import 'package:universal_platform/universal_platform.dart';
class AIChatUILayout {
static double get messageWidthRatio => 0.94; // Chat adds extra 0.06
static EdgeInsets safeAreaInsets(BuildContext context) {
final query = MediaQuery.of(context);
return UniversalPlatform.isMobile
? EdgeInsets.fromLTRB(
query.padding.left,
0,
query.padding.right,
query.viewInsets.bottom + query.padding.bottom,
)
: const EdgeInsets.only(bottom: 16);
}
}
class DesktopAIPromptSizes {
static const promptFrameRadius = BorderRadius.all(Radius.circular(8));
static const attachedFilesBarPadding =
EdgeInsets.only(top: 8.0, left: 8.0, right: 8.0);
static const attachedFilesPreviewHeight = 48.0;
static const attachedFilesPreviewSpacing = 12.0;
static const textFieldMinHeight = 36.0;
static const textFieldContentPadding =
EdgeInsetsDirectional.fromSTEB(12.0, 12.0, 12.0, 4.0);
static const actionBarHeight = 28.0;
static const actionBarButtonSize = 24.0;
static const actionBarIconSize = 16.0;
static const actionBarButtonSpacing = 4.0;
static const sendButtonSize = 20.0;
}
class MobileAIPromptSizes {
static const promptFrameRadius =
BorderRadius.vertical(top: Radius.circular(8.0));
static const attachedFilesBarHeight = 68.0;
static const attachedFilesBarPadding =
EdgeInsets.only(top: 8.0, left: 8.0, right: 8.0, bottom: 4.0);
static const attachedFilesPreviewHeight = 56.0;
static const attachedFilesPreviewSpacing = 8.0;
static const textFieldMinHeight = 48.0;
static const textFieldContentPadding = EdgeInsets.all(8.0);
static const mentionIconSize = 20.0;
static const sendButtonSize = 32.0;
}
class DesktopAIConvoSizes {
static const avatarSize = 32.0;
static const avatarAndChatBubbleSpacing = 12.0;
static const actionBarIconSize = 24.0;
static const actionBarIconSpacing = 8.0;
static const actionBarIconRadius = BorderRadius.all(Radius.circular(8.0));
}
@@ -1,32 +1,46 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_configuration.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/shared/markdown_to_document.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:markdown_widget/markdown_widget.dart';
import '../chat_editor_style.dart';
import 'selectable_highlight.dart';
// Wrap the appflowy_editor as a chat text message widget
enum AIMarkdownType {
appflowyEditor,
markdownWidget,
}
// Wrap the appflowy_editor or markdown_widget as a chat text message widget
class AIMarkdownText extends StatelessWidget {
const AIMarkdownText({
super.key,
required this.markdown,
this.type = AIMarkdownType.appflowyEditor,
});
final String markdown;
final AIMarkdownType type;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => DocumentPageStyleBloc(view: ViewPB())
..add(const DocumentPageStyleEvent.initial()),
child: _AppFlowyEditorMarkdown(markdown: markdown),
);
switch (type) {
case AIMarkdownType.appflowyEditor:
return BlocProvider(
create: (context) => DocumentPageStyleBloc(view: ViewPB())
..add(const DocumentPageStyleEvent.initial()),
child: _AppFlowyEditorMarkdown(markdown: markdown),
);
case AIMarkdownType.markdownWidget:
return _ThirdPartyMarkdown(markdown: markdown);
}
}
}
@@ -84,8 +98,8 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
@override
Widget build(BuildContext context) {
// don't lazy load the styleCustomizer and blockBuilders,
// it needs the context to get the theme.
final styleCustomizer = ChatEditorStyleCustomizer(
// it needs the context to get the theme.
final styleCustomizer = EditorStyleCustomizer(
context: context,
padding: EdgeInsets.zero,
);
@@ -106,22 +120,12 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
shrinkWrap: true,
// the editor is not editable in the chat
editable: false,
disableKeyboardService: true,
editorStyle: editorStyle,
editorScrollController: scrollController,
blockComponentBuilders: blockBuilders,
commandShortcutEvents: [customCopyCommand],
disableAutoScroll: true,
editorState: editorState,
contextMenuItems: [
[
ContextMenuItem(
getName: LocaleKeys.document_plugins_contextMenu_copy.tr,
onPressed: (editorState) =>
customCopyCommand.execute(editorState),
),
]
],
),
);
}
@@ -132,3 +136,248 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
return editorState;
}
}
class _ThirdPartyMarkdown extends StatelessWidget {
const _ThirdPartyMarkdown({
required this.markdown,
});
final String markdown;
@override
Widget build(BuildContext context) {
return MarkdownWidget(
data: markdown,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
config: configFromContext(context),
);
}
MarkdownConfig configFromContext(BuildContext context) {
return MarkdownConfig(
configs: [
HrConfig(color: AFThemeExtension.of(context).textColor),
_ChatH1Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 24,
fontWeight: FontWeight.bold,
height: 1.5,
),
dividerColor: AFThemeExtension.of(context).lightGreyHover,
),
_ChatH2Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 20,
fontWeight: FontWeight.bold,
height: 1.5,
),
dividerColor: AFThemeExtension.of(context).lightGreyHover,
),
_ChatH3Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 18,
fontWeight: FontWeight.bold,
height: 1.5,
),
dividerColor: AFThemeExtension.of(context).lightGreyHover,
),
H4Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 16,
fontWeight: FontWeight.bold,
height: 1.5,
),
),
H5Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 14,
fontWeight: FontWeight.bold,
height: 1.5,
),
),
H6Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 12,
fontWeight: FontWeight.bold,
height: 1.5,
),
),
PreConfig(
builder: (code, language) {
return ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 800,
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(6.0)),
child: SelectableHighlightView(
code,
language: language,
theme: getHighlightTheme(context),
padding: const EdgeInsets.all(14),
textStyle: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 14,
fontWeight: FontWeight.bold,
height: 1.5,
),
),
),
);
},
),
PConfig(
textStyle: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
),
),
CodeConfig(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
),
),
BlockquoteConfig(
sideColor: AFThemeExtension.of(context).lightGreyHover,
textColor: AFThemeExtension.of(context).textColor,
),
],
);
}
Map<String, TextStyle> getHighlightTheme(BuildContext context) {
return {
'root': TextStyle(
color: const Color(0xffabb2bf),
backgroundColor:
Theme.of(context).isLightMode ? Colors.white : Colors.black38,
),
'comment': const TextStyle(
color: Color(0xff5c6370),
fontStyle: FontStyle.italic,
),
'quote': const TextStyle(
color: Color(0xff5c6370),
fontStyle: FontStyle.italic,
),
'doctag': const TextStyle(color: Color(0xffc678dd)),
'keyword': const TextStyle(color: Color(0xffc678dd)),
'formula': const TextStyle(color: Color(0xffc678dd)),
'section': const TextStyle(color: Color(0xffe06c75)),
'name': const TextStyle(color: Color(0xffe06c75)),
'selector-tag': const TextStyle(color: Color(0xffe06c75)),
'deletion': const TextStyle(color: Color(0xffe06c75)),
'subst': const TextStyle(color: Color(0xffe06c75)),
'literal': const TextStyle(color: Color(0xff56b6c2)),
'string': const TextStyle(color: Color(0xff98c379)),
'regexp': const TextStyle(color: Color(0xff98c379)),
'addition': const TextStyle(color: Color(0xff98c379)),
'attribute': const TextStyle(color: Color(0xff98c379)),
'meta-string': const TextStyle(color: Color(0xff98c379)),
'built_in': const TextStyle(color: Color(0xffe6c07b)),
'attr': const TextStyle(color: Color(0xffd19a66)),
'variable': const TextStyle(color: Color(0xffd19a66)),
'template-variable': const TextStyle(color: Color(0xffd19a66)),
'type': const TextStyle(color: Color(0xffd19a66)),
'selector-class': const TextStyle(color: Color(0xffd19a66)),
'selector-attr': const TextStyle(color: Color(0xffd19a66)),
'selector-pseudo': const TextStyle(color: Color(0xffd19a66)),
'number': const TextStyle(color: Color(0xffd19a66)),
'symbol': const TextStyle(color: Color(0xff61aeee)),
'bullet': const TextStyle(color: Color(0xff61aeee)),
'link': const TextStyle(color: Color(0xff61aeee)),
'meta': const TextStyle(color: Color(0xff61aeee)),
'selector-id': const TextStyle(color: Color(0xff61aeee)),
'title': const TextStyle(color: Color(0xff61aeee)),
'emphasis': const TextStyle(fontStyle: FontStyle.italic),
'strong': const TextStyle(fontWeight: FontWeight.bold),
};
}
}
class _ChatH1Config extends HeadingConfig {
const _ChatH1Config({
this.style = const TextStyle(
fontSize: 32,
height: 40 / 32,
fontWeight: FontWeight.bold,
),
required this.dividerColor,
});
@override
final TextStyle style;
final Color dividerColor;
@override
String get tag => MarkdownTag.h1.name;
@override
HeadingDivider? get divider => HeadingDivider(
space: 10,
color: dividerColor,
height: 10,
);
}
///config class for h2
class _ChatH2Config extends HeadingConfig {
const _ChatH2Config({
this.style = const TextStyle(
fontSize: 24,
height: 30 / 24,
fontWeight: FontWeight.bold,
),
required this.dividerColor,
});
@override
final TextStyle style;
final Color dividerColor;
@override
String get tag => MarkdownTag.h2.name;
@override
HeadingDivider? get divider => HeadingDivider(
space: 10,
color: dividerColor,
height: 10,
);
}
class _ChatH3Config extends HeadingConfig {
const _ChatH3Config({
this.style = const TextStyle(
fontSize: 24,
height: 30 / 24,
fontWeight: FontWeight.bold,
),
required this.dividerColor,
});
@override
final TextStyle style;
final Color dividerColor;
@override
String get tag => MarkdownTag.h3.name;
@override
HeadingDivider? get divider => HeadingDivider(
space: 10,
color: dividerColor,
height: 10,
);
}
Loaded 100 of 297 files, more files were not shown because too many files have changed in this diff. Show more