Compare commits

..
13 Commits
Author SHA1 Message Date
Lucas.Xu d27f9eb2c1 fix: unable to scroll down in multiple select options 2024-11-26 10:51:42 +08:00
Lucas.Xu c11904c2e9 feat: optimize text insertion 2024-11-25 21:17:48 +08:00
Lucas.Xu 19b1c84ea3 chore: set the minimum windows size to 640 2024-11-25 18:19:08 +08:00
Lucas.Xu e5f30b000c chore: update changelog 2024-11-25 18:07:58 +08:00
Lucas.Xu e64462ca9a chore: bump version 0.7.5 2024-11-25 18:02:53 +08:00
Lucas 570de3e1a5 feat: support skipping in-memory update transaction (#6856)
* feat: support skipping in-memory update transaction

* fix: flutter analyze

* feat: add sentence mode

* test: support skipping in-memory update transaction

* test: add sentence mode

* test: add sentence mode (2)

* chore: set enableDocumentInternalLog to false

* fix: integration test

* fix: integration test
2024-11-25 17:56:20 +08:00
Richard Shiue df82c99755 fix: loading message hot fix (#6858) 2024-11-25 17:28:00 +08:00
Nathan.fooo 6a02679963 chore: parse chat response (#6843)
* chore: parse chat response

* chore: remove unrelated message

* chore: update readme
2024-11-25 16:43:09 +08:00
Richard Shiue 8dcd4c51f0 chore: bump flutter chat ui version (#6835) 2024-11-25 16:43:00 +08:00
Lucas 3a26570de9 feat: support click to create content inside empty toggle list (#6854)
* feat: support click to create content inside empty toggle list

* test: support click to create content inside empty toggle list

* fix: toggle list rtl issue

* chore: optimize cover title request node logic
2024-11-25 16:29:05 +08:00
Lucas 76710ea244 fix: macOS error 50 on uploading files (#6853) 2024-11-25 16:28:57 +08:00
Nathan.fooo 1bcda3944f chore: only fetch billing info when current user is the workspace owner (#6847) 2024-11-25 16:28:50 +08:00
Nathan.fooo 3d8e6ebd72 chore: delete upload task when upload size exceed limit (#6841)
* chore: delete upload tasks

* test: update

* chore: bump client api

* chore: fix test
2024-11-25 16:27:30 +08:00
653 changed files with 9637 additions and 34944 deletions

No files matched your search

+1 -1
View File
@@ -152,7 +152,7 @@ jobs:
rustup target install aarch64-linux-android
rustup target install x86_64-linux-android
rustup target add armv7-linux-androideabi
cargo install --force --locked duckscript_cli
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
+2 -2
View File
@@ -2,9 +2,9 @@ name: Docker-CI
on:
push:
branches: [ "main", "release/*" ]
branches: ["main", "release/*"]
pull_request:
branches: [ "main", "release/*" ]
branches: ["main", "release/*"]
workflow_dispatch:
concurrency:
+1 -1
View File
@@ -85,7 +85,7 @@ jobs:
working-directory: frontend
run: |
rustup target install aarch64-apple-ios-sim
cargo install --force --locked duckscript_cli
cargo install --force duckscript_cli
cargo install cargo-lipo
cargo make appflowy-flutter-deps-tools
shell: bash
+10 -10
View File
@@ -7,7 +7,7 @@ on:
env:
FLUTTER_VERSION: "3.22.0"
RUST_TOOLCHAIN: "1.80.1"
RUST_TOOLCHAIN: "1.77.2"
jobs:
create-release:
@@ -73,8 +73,8 @@ jobs:
working-directory: frontend
run: |
vcpkg integrate install
cargo install --force --locked cargo-make
cargo install --force --locked duckscript_cli
cargo install --force cargo-make
cargo install --force duckscript_cli
- name: Build Windows app
working-directory: frontend
@@ -135,7 +135,7 @@ jobs:
fail-fast: false
matrix:
job:
- { target: x86_64-apple-darwin, os: macos-13, extra-build-args: "" }
- { target: x86_64-apple-darwin, os: macos-12, extra-build-args: "" }
steps:
- name: Checkout source code
uses: actions/checkout@v4
@@ -158,8 +158,8 @@ jobs:
- name: Install prerequisites
working-directory: frontend
run: |
cargo install --force --locked cargo-make
cargo install --force --locked duckscript_cli
cargo install --force cargo-make
cargo install --force duckscript_cli
- name: Build AppFlowy
working-directory: frontend
@@ -256,8 +256,8 @@ jobs:
- name: Install prerequisites
working-directory: frontend
run: |
cargo install --force --locked cargo-make
cargo install --force --locked duckscript_cli
cargo install --force cargo-make
cargo install --force duckscript_cli
- name: Build AppFlowy
working-directory: frontend
@@ -370,8 +370,8 @@ jobs:
sudo apt-get install keybinder-3.0
sudo apt-get install -y alien libnotify-dev
source $HOME/.cargo/env
cargo install --force --locked cargo-make
cargo install --force --locked duckscript_cli
cargo install --force cargo-make
cargo install --force duckscript_cli
rustup target add ${{ matrix.job.target }}
- name: Install gcc-aarch64-linux-gnu
+72 -2
View File
@@ -18,11 +18,81 @@ on:
env:
CARGO_TERM_COLOR: always
CLOUD_VERSION: 0.8.3-amd64
RUST_TOOLCHAIN: "1.80.1"
CLOUD_VERSION: 0.7.6-amd64
RUST_TOOLCHAIN: "1.77.2"
jobs:
self-hosted-job:
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: self-hosted
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Checkout Appflowy Cloud
uses: actions/checkout@v4
with:
repository: AppFlowy-IO/AppFlowy-Cloud
path: AppFlowy-Cloud
- name: Prepare Appflowy Cloud env
working-directory: AppFlowy-Cloud
run: |
cp deploy.env .env
sed -i '' 's|RUST_LOG=.*|RUST_LOG=trace|' .env
sed -i '' 's|API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://localhost|' .env
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
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. Pulling the correct version..."
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: Run rust-lib tests
working-directory: frontend/rust-lib
env:
RUST_LOG: info
RUST_BACKTRACE: 1
af_cloud_test_base_url: http://localhost
af_cloud_test_ws_url: ws://localhost/ws/v1
af_cloud_test_gotrue_url: http://localhost/gotrue
run: |
DISABLE_CI_TEST_LOG="true" cargo test --no-default-features --features="dart"
- name: rustfmt rust-lib
run: cargo fmt --all -- --check
working-directory: frontend/rust-lib/
- name: clippy rust-lib
run: cargo clippy --all-targets -- -D warnings
working-directory: frontend/rust-lib
ubuntu-job:
if: github.event.pull_request.head.repo.full_name != github.repository
runs-on: ubuntu-latest
steps:
- name: Set timezone for action
+3 -3
View File
@@ -11,7 +11,7 @@ on:
env:
CARGO_TERM_COLOR: always
FLUTTER_VERSION: "3.22.0"
RUST_TOOLCHAIN: "1.80.1"
RUST_TOOLCHAIN: "1.77.2"
jobs:
tests:
@@ -40,8 +40,8 @@ jobs:
- name: Install prerequisites
working-directory: frontend
run: |
cargo install --force --locked cargo-make
cargo install --force --locked duckscript_cli
cargo install --force cargo-make
cargo install --force duckscript_cli
- uses: Swatinem/rust-cache@v2
with:
+29 -3
View File
@@ -5,14 +5,13 @@ on:
paths:
- ".github/workflows/tauri2_ci.yaml"
- "frontend/rust-lib/**"
paths-ignore:
- "frontend/appflowy_web_app/**"
- "frontend/resources/**"
env:
NODE_VERSION: "18.16.0"
PNPM_VERSION: "8.5.0"
RUST_TOOLCHAIN: "1.80.1"
RUST_TOOLCHAIN: "1.77.2"
CARGO_MAKE_VERSION: "0.36.6"
CI: true
@@ -21,7 +20,34 @@ concurrency:
cancel-in-progress: true
jobs:
# tauri-build-self-hosted:
# if: github.event.pull_request.head.repo.full_name == github.repository
# runs-on: self-hosted
#
# steps:
# - uses: actions/checkout@v4
# - name: install frontend dependencies
# working-directory: frontend/appflowy_web_app
# run: |
# mkdir dist
# pnpm install
# cd src-tauri && cargo build
#
# - name: test and lint
# working-directory: frontend/appflowy_web_app
# run: |
# pnpm run lint:tauri
#
# - uses: tauri-apps/tauri-action@v0
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# with:
# tauriScript: pnpm tauri
# projectPath: frontend/appflowy_web_app
# args: "--debug"
tauri-build-ubuntu:
#if: github.event.pull_request.head.repo.full_name != github.repository
runs-on: ubuntu-20.04
steps:
@@ -95,4 +121,4 @@ jobs:
with:
tauriScript: pnpm tauri
projectPath: frontend/appflowy_web_app
args: "--debug"
args: "--debug"
+5 -5
View File
@@ -7,7 +7,7 @@ on:
env:
NODE_VERSION: "18.16.0"
PNPM_VERSION: "8.5.0"
RUST_TOOLCHAIN: "1.80.1"
RUST_TOOLCHAIN: "1.77.2"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -19,7 +19,7 @@ jobs:
strategy:
fail-fast: false
matrix:
platform: [ubuntu-20.04]
platform: [ ubuntu-20.04 ]
runs-on: ${{ matrix.platform }}
@@ -73,7 +73,7 @@ jobs:
if: matrix.platform == 'windows-latest'
working-directory: frontend
run: |
cargo install --force --locked duckscript_cli
cargo install --force duckscript_cli
vcpkg integrate install
- name: install dependencies (ubuntu only)
@@ -86,7 +86,7 @@ jobs:
- name: install cargo-make
working-directory: frontend
run: |
cargo install --force --locked cargo-make
cargo install --force cargo-make
cargo make appflowy-tauri-deps-tools
- name: install frontend dependencies
@@ -108,4 +108,4 @@ jobs:
with:
tauriScript: pnpm tauri
projectPath: frontend/appflowy_tauri
args: "--debug"
args: "--debug"
+8 -7
View File
@@ -4,19 +4,20 @@ on:
workflow_dispatch:
inputs:
branch:
description: "The branch to release"
description: 'The branch to release'
required: true
default: "main"
default: 'main'
version:
description: "The version to release"
description: 'The version to release'
required: true
default: "0.0.0"
default: '0.0.0'
env:
NODE_VERSION: "18.16.0"
PNPM_VERSION: "8.5.0"
RUST_TOOLCHAIN: "1.80.1"
RUST_TOOLCHAIN: "1.77.2"
jobs:
publish-tauri:
permissions:
contents: write
@@ -83,7 +84,7 @@ jobs:
if: matrix.settings.platform == 'windows-latest'
working-directory: frontend
run: |
cargo install --force --locked duckscript_cli
cargo install --force duckscript_cli
vcpkg integrate install
- name: install dependencies (ubuntu only)
@@ -96,7 +97,7 @@ jobs:
- name: install cargo-make
working-directory: frontend
run: |
cargo install --force --locked cargo-make
cargo install --force cargo-make
cargo make appflowy-tauri-deps-tools
- name: install frontend dependencies
+1 -44
View File
@@ -1,47 +1,4 @@
# Release Notes
## Version 0.7.9 - 25/12/2024
### New Features
### Bug Fixes
## Version 0.7.8 - 18/12/2024
### New Features
<img width="1068" alt="image" src="https://github.com/user-attachments/assets/cf8bd287-f370-4291-8638-76e2bbf4aaac" />
- Meet Simple Table 2.0:
- Insert a list into a table cell
- Insert images, quotes, callouts, and code blocks into a table cell
- Drag to move rows or columns
- Toggle header rows or columns on/off
- Distribute columns evenly
- Adjust to page width
- Enjoy a new UI/UX for a seamless experience
- Revamped mention page interactions in AI Chat
- Improved AppFlowy AI service
### Bug Fixes
- Fixed an error when opening files in the database in local mode
- Fixed arrow up/down navigation not working for selecting a language in Code Block
- Fixed an issue where deleting multiple blocks using the drag button on the document page didn’t work
## Version 0.7.7 - 09/12/2024
### Bug Fixes
- Fixed sidebar menu resize regression
- Fixed AI chat loading issues
- Fixed inability to open local files in database
- Fixed mentions remaining in notifications after removal from document
- Fixed event card closing when clicking on empty space
- Fixed keyboard shortcut issues
## Version 0.7.6 - 03/12/2024
### New Features
- Revamped the simple table UI
- Added support for capturing images from camera on mobile
### Bug Fixes
- Improved markdown rendering capabilities in AI writer
- Fixed an issue where pressing Enter on a collapsed toggle list would add an unnecessary new line
- Fixed an issue where creating a document from slash menu could insert content at incorrect position
## Version 0.7.5 - 25/11/2024
### Bug Fixes
- Improved chat response parsing
@@ -982,4 +939,4 @@ Bug fixes and improvements
- Increased height of action
- CPU performance issue
- Fix potential data parser error
- More foundation work for online collaboration
- More foundation work for online collaboration
+1 -1
View File
@@ -20,7 +20,7 @@ workflows:
rustup target install aarch64-apple-ios-sim
cargo install --force cargo-make
cargo install --force --locked duckscript_cli
cargo install --force duckscript_cli
cargo install --force cargo-lipo
cargo make appflowy-flutter-deps-tools
+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.9"
APPFLOWY_VERSION = "0.7.5"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"
@@ -36,6 +36,7 @@
<data android:scheme="http" />
<data android:scheme="https" />
<data android:scheme="appflowy-flutter" />
<!-- <data android:host="login-callback" /> -->
</intent-filter>
</activity>
<!--
@@ -49,8 +50,7 @@
<!-- Permission to read files from external storage (outside application container).
As of Android 12 this permission no longer has any effect. Instead use the
READ_MEDIA_IMAGES, READ_MEDIA_VIDEO or READM_MEDIA_AUDIO permissions. -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<!-- Permissions to read media files. -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<queries>
@@ -65,5 +65,4 @@
-->
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.CAMERA" />
</manifest>
@@ -1,19 +1,16 @@
import 'data_migration/data_migration_test_runner.dart'
as data_migration_test_runner;
import 'document/document_test_runner.dart' as document_test_runner;
import 'set_env.dart' as preset_af_cloud_env_test;
import 'sidebar/sidebar_move_page_test.dart' as sidebar_move_page_test;
import 'sidebar/sidebar_rename_untitled_test.dart'
as sidebar_rename_untitled_test;
import 'uncategorized/uncategorized_test_runner.dart'
as uncategorized_test_runner;
import 'workspace/workspace_test_runner.dart' as workspace_test_runner;
import 'data_migration/data_migration_test_runner.dart'
as data_migration_test_runner;
import 'set_env.dart' as preset_af_cloud_env_test;
Future<void> main() async {
preset_af_cloud_env_test.main();
data_migration_test_runner.main();
// uncategorized
uncategorized_test_runner.main();
@@ -25,5 +22,4 @@ Future<void> main() async {
// sidebar
sidebar_move_page_test.main();
sidebar_rename_untitled_test.main();
}
@@ -33,7 +33,7 @@ void main() {
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_aiWriter.tr(),
);
expect(find.byType(AIWriterBlockComponent), findsOneWidget);
expect(find.byType(AutoCompletionBlockComponent), findsOneWidget);
// switch to another page
await tester.openPage(Constants.gettingStartedPageName);
@@ -41,7 +41,7 @@ void main() {
await tester.openPage(pageName);
// expect the ai writer block is not in the document
expect(find.byType(AIWriterBlockComponent), findsNothing);
expect(find.byType(AutoCompletionBlockComponent), findsNothing);
});
});
}
@@ -1,55 +0,0 @@
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text_input.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();
testWidgets('Rename empty name view (untitled)', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
await tester.createNewPageInSpace(
spaceName: Constants.generalSpaceName,
layout: ViewLayoutPB.Document,
);
// click the ... button and open rename dialog
await tester.hoverOnPageName(
ViewLayoutPB.Document.defaultName,
onHover: () async {
await tester.tapPageOptionButton();
await tester.tapButtonWithName(
LocaleKeys.disclosureAction_rename.tr(),
);
},
);
await tester.pumpAndSettle();
expect(find.byType(NavigatorTextFieldDialog), findsOneWidget);
final textField = tester.widget<FlowyFormTextInput>(
find.descendant(
of: find.byType(NavigatorTextFieldDialog),
matching: find.byType(FlowyFormTextInput),
),
);
expect(
textField.controller!.text,
LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
);
});
}
@@ -301,7 +301,6 @@ void main() {
await tester.createOption(name: "qwer");
await tester.selectOption(name: "asdf");
await tester.dismissCellEditor();
await tester.dismissCellEditor();
await tester.tapDatabaseFilterButton();
await tester.tapCreateFilterByFieldType(FieldType.MultiSelect, "Tags");
@@ -333,7 +332,6 @@ void main() {
await tester.tapButton(finderForFieldType(FieldType.MultiSelect));
await tester.selectOption(name: "asdf");
await tester.dismissCellEditor();
await tester.dismissCellEditor();
tester.assertNumberOfEventsInCalendar(0);
@@ -538,8 +538,8 @@ void main() {
// edit the first date cell
await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.DateTime);
final now = DateTime.now();
await tester.toggleIncludeTime();
final now = DateTime.now();
await tester.selectDay(content: now.day);
await tester.dismissCellEditor();
@@ -4,10 +4,12 @@ import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/widgets/card/card.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/media_cell_editor.dart';
import 'package:appflowy/plugins/database/widgets/row/row_banner.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/shared/af_image.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
@@ -24,6 +26,61 @@ void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('database row cover', () {
testWidgets('add image to media field and check if cover is set (grid)',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
// Invoke the field editor
await tester.tapGridFieldWithName('Type');
await tester.tapEditFieldButton();
// Change to media type
await tester.tapSwitchFieldTypeButton();
await tester.selectFieldType(FieldType.Media);
await tester.dismissFieldEditor();
// Prepare file for upload from local
final image = await rootBundle.load('assets/test/images/sample.jpeg');
final tempDirectory = await getTemporaryDirectory();
final imagePath = p.join(tempDirectory.path, 'sample.jpeg');
final file = File(imagePath)
..writeAsBytesSync(image.buffer.asUint8List());
mockPickFilePaths(paths: [imagePath]);
await getIt<KeyValueStorage>().set(KVKeys.kCloudType, '0');
// Open media cell editor
await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.Media);
await tester.findMediaCellEditor(findsOneWidget);
// Click on add file button in the Media Cell Editor
await tester.tap(find.text(LocaleKeys.grid_media_addFileOrImage.tr()));
await tester.pumpAndSettle();
// Tap on the upload interaction
await tester.tapFileUploadHint();
// Expect one file
expect(find.byType(RenderMedia), findsOneWidget);
// Close cell editor
await tester.dismissCellEditor();
// Open first row in row detail view
await tester.openFirstRowDetailPage();
await tester.pumpAndSettle();
// Expect a cover to be shown
expect(find.byType(RowCover), findsOneWidget);
// Remove the temp file
await Future.wait([file.delete()]);
});
testWidgets('add and remove cover from Row Detail Card', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
@@ -5,7 +5,6 @@ import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_cell_edi
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/shared/icon_emoji_picker/recent_icons.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';
@@ -22,14 +21,7 @@ import '../../shared/emoji.dart';
import '../../shared/util.dart';
void main() {
setUpAll(() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
RecentIcons.enable = false;
});
tearDownAll(() {
RecentIcons.enable = true;
});
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('grid row detail page:', () {
testWidgets('opens', (tester) async {
@@ -394,16 +386,11 @@ void main() {
isChecked: false,
);
tester.assertPhantomChecklistItemAtIndex(index: 1);
tester.assertPhantomChecklistItemContent("");
await tester.enterText(find.byType(PhantomChecklistItem), 'task 2');
await tester.pumpAndSettle();
await tester.hoverOnWidget(
find.byType(ChecklistRowDetailCell),
onHover: () async {
await tester.tapButton(find.byType(ChecklistItemControl));
},
);
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle(const Duration(milliseconds: 500));
tester.assertChecklistTaskInEditor(
index: 1,
@@ -411,7 +398,6 @@ void main() {
isChecked: false,
);
tester.assertPhantomChecklistItemAtIndex(index: 2);
tester.assertPhantomChecklistItemContent("");
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
@@ -1,5 +1,7 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
@@ -9,7 +11,6 @@ import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:universal_platform/universal_platform.dart';
@@ -26,13 +27,10 @@ void main() {
await tester.pasteContent(
plainText: List.generate(lines, (index) => 'line $index').join('\n'),
(editorState) {
expect(editorState.document.root.children.length, 1);
final text =
editorState.document.root.children.first.delta!.toPlainText();
final textLines = text.split('\n');
expect(editorState.document.root.children.length, 3);
for (var i = 0; i < lines; i++) {
expect(
textLines[i],
editorState.getNodeAtPath([i])!.delta!.toPlainText(),
'line $i',
);
}
@@ -469,44 +467,6 @@ void main() {
expect(node.attributes[ImageBlockKeys.url], isNotEmpty);
});
});
const testMarkdownText = '''
# I'm h1
## I'm h2
### I'm h3
#### I'm h4
##### I'm h5
###### I'm h6''';
testWidgets('paste markdowns', (tester) async {
await tester.pasteContent(
plainText: testMarkdownText,
(editorState) {
final children = editorState.document.root.children;
expect(children.length, 6);
for (int i = 1; i <= children.length; i++) {
final text = children[i - 1].delta!.toPlainText();
expect(text, 'I\'m h$i');
}
},
);
});
testWidgets('paste markdowns as plain', (tester) async {
await tester.pasteContent(
plainText: testMarkdownText,
pasteAsPlain: true,
(editorState) {
final children = editorState.document.root.children;
expect(children.length, 6);
for (int i = 1; i <= children.length; i++) {
final text = children[i - 1].delta!.toPlainText();
final expectText = '${'#' * i} I\'m h$i';
expect(text, expectText);
}
},
);
});
}
extension on WidgetTester {
@@ -516,7 +476,6 @@ extension on WidgetTester {
String? plainText,
String? html,
String? inAppJson,
bool pasteAsPlain = false,
(String, Uint8List?)? image,
}) async {
await initializeAppFlowy();
@@ -543,7 +502,6 @@ extension on WidgetTester {
await simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed: Platform.isLinux || Platform.isWindows,
isShiftPressed: pasteAsPlain,
isMetaPressed: Platform.isMacOS,
);
await pumpAndSettle();
@@ -37,12 +37,12 @@ void main() {
// set clipboard data
final data = [
"123456\n\n",
...List.generate(100, (_) => "${generateRandomString(50)}\n\n"),
"1234567\n\n",
...List.generate(100, (_) => "${generateRandomString(50)}\n\n"),
"12345678\n\n",
...List.generate(100, (_) => "${generateRandomString(50)}\n\n"),
"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(
@@ -139,22 +139,6 @@ void main() {
),
findsOneWidget,
);
/// 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);
/// press esc to dismiss the find menu
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(find.byType(FindAndReplaceMenuWidget), findsNothing);
},
);
}
@@ -2,12 +2,12 @@ import 'dart:io';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart';
import 'package:appflowy/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/services.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';
@@ -330,23 +330,6 @@ void main() {
expect(find.text("$_createdPageName (copy)"), findsNWidgets(2));
expect(find.text("$_createdPageName (copy) (copy)"), findsOneWidget);
});
testWidgets('Cancel inline page reference menu by space', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createOpenRenameDocumentUnderParent(name: _firstDocName);
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showPlusMenu();
// Cancel by space
await tester.simulateKeyEvent(
LogicalKeyboardKey.space,
);
await tester.pumpAndSettle();
expect(find.byType(InlineActionsMenu), findsNothing);
});
});
}
@@ -11,8 +11,6 @@ import 'document_with_file_test.dart' as document_with_file_test;
import 'document_with_image_block_test.dart' as document_with_image_block_test;
import 'document_with_multi_image_block_test.dart'
as document_with_multi_image_block_test;
import 'document_with_simple_table_test.dart'
as document_with_simple_table_test;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
@@ -27,5 +25,4 @@ void main() {
document_block_option_test.main();
document_find_menu_test.main();
document_toolbar_test.main();
document_with_simple_table_test.main();
}
@@ -1,7 +1,5 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@@ -13,14 +11,7 @@ import '../../shared/emoji.dart';
import '../../shared/util.dart';
void main() {
setUpAll(() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
RecentIcons.enable = false;
});
tearDownAll(() {
RecentIcons.enable = true;
});
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('cover image:', () {
testWidgets('document cover tests', (tester) async {
@@ -156,7 +147,7 @@ void main() {
tester.expectViewHasIcon(
gettingStarted,
ViewLayoutPB.Document,
EmojiIconData.emoji(punch),
punch,
);
});
});
@@ -1,15 +1,12 @@
import 'package:appflowy/plugins/database/board/presentation/board_page.dart';
import 'package:appflowy/plugins/database/calendar/presentation/calendar_page.dart';
import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/footer/grid_footer.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/row/row.dart';
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/text.dart';
import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -177,110 +174,9 @@ void main() {
findsOneWidget,
);
});
testWidgets('insert a referenced grid with many rows (load more option)',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await insertLinkedDatabase(tester, ViewLayoutPB.Grid);
// validate the referenced grid is inserted
expect(
find.descendant(
of: find.byType(AppFlowyEditor),
matching: find.byType(GridPage),
),
findsOneWidget,
);
// https://github.com/AppFlowy-IO/AppFlowy/issues/3533
// test: the selection of editor should be clear when editing the grid
await tester.editor.updateSelection(
Selection.collapsed(
Position(path: [1]),
),
);
final gridTextCell = find.byType(EditableTextCell).first;
await tester.tapButton(gridTextCell);
expect(tester.editor.getCurrentEditorState().selection, isNull);
final editorScrollable = find
.descendant(
of: find.byType(AppFlowyEditor),
matching: find.byWidgetPredicate(
(w) => w is Scrollable && w.axis == Axis.vertical,
),
)
.first;
// Add 100 Rows to the linked database
final addRowFinder = find.byType(GridAddRowButton);
for (var i = 0; i < 100; i++) {
await tester.scrollUntilVisible(
addRowFinder,
100,
scrollable: editorScrollable,
);
await tester.tapButton(addRowFinder);
await tester.pumpAndSettle();
}
// Since all rows visible are those we added, we should see all of them
expect(find.byType(GridRow), findsNWidgets(103));
// Navigate to getting started
await tester.openPage(gettingStarted);
// Navigate back to the document
await tester.openPage('insert_a_reference_${ViewLayoutPB.Grid.name}');
// We see only 25 Grid Rows
expect(find.byType(GridRow), findsNWidgets(25));
// We see Add row and load more button
expect(find.byType(GridAddRowButton), findsOneWidget);
expect(find.byType(GridRowLoadMoreButton), findsOneWidget);
// Load more rows, expect 50 visible
await _loadMoreRows(tester, editorScrollable, 50);
// Load more rows, expect 75 visible
await _loadMoreRows(tester, editorScrollable, 75);
// Load more rows, expect 100 visible
await _loadMoreRows(tester, editorScrollable, 100);
// Load more rows, expect 103 visible
await _loadMoreRows(tester, editorScrollable, 103);
// We no longer see load more option
expect(find.byType(GridRowLoadMoreButton), findsNothing);
});
});
}
Future<void> _loadMoreRows(
WidgetTester tester,
Finder scrollable, [
int? expectedRows,
]) async {
await tester.scrollUntilVisible(
find.byType(GridRowLoadMoreButton),
100,
scrollable: scrollable,
);
await tester.pumpAndSettle();
await tester.tap(find.byType(GridRowLoadMoreButton));
await tester.pumpAndSettle();
if (expectedRows != null) {
expect(find.byType(GridRow), findsNWidgets(expectedRows));
}
}
/// Insert a referenced database of [layout] into the document
Future<void> insertLinkedDatabase(
WidgetTester tester,
@@ -1,21 +1,14 @@
import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/desktop_date_picker.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:table_calendar/table_calendar.dart';
import '../../shared/util.dart';
@@ -25,7 +18,7 @@ void main() {
TestWidgetsFlutterBinding.ensureInitialized();
});
group('date or reminder block in document:', () {
group('date or reminder block in document', () {
testWidgets("insert date with time block", (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
@@ -128,339 +121,5 @@ void main() {
expect(find.text('@$formattedDate'), findsOneWidget);
expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsOneWidget);
});
testWidgets("copy, cut and paste a date mention", (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a new document
await tester.createNewPageWithNameUnderParent(
name: 'copy, cut and paste a date mention',
);
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_dateOrReminder.tr(),
);
final dateTimeSettings = DateTimeSettingsPB(
dateFormat: UserDateFormatPB.Friendly,
timeFormat: UserTimeFormatPB.TwentyFourHour,
);
final DateTime currentDateTime = DateTime.now();
final String formattedDate =
dateTimeSettings.dateFormat.formatDate(currentDateTime, false);
// get current date in editor
expect(find.byType(MentionDateBlock), findsOneWidget);
expect(find.text('@$formattedDate'), findsOneWidget);
// update selection and copy
await tester.editor.updateSelection(
Selection(
start: Position(path: [0]),
end: Position(path: [0], offset: 1),
),
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyC,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
// update selection and paste
await tester.editor.updateSelection(
Selection.collapsed(Position(path: [0], offset: 1)),
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(MentionDateBlock), findsNWidgets(2));
expect(find.text('@$formattedDate'), findsNWidgets(2));
// update selection and cut
await tester.editor.updateSelection(
Selection(
start: Position(path: [0], offset: 1),
end: Position(path: [0], offset: 2),
),
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyX,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(MentionDateBlock), findsOneWidget);
expect(find.text('@$formattedDate'), findsOneWidget);
// update selection and paste
await tester.editor.updateSelection(
Selection.collapsed(Position(path: [0], offset: 1)),
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(MentionDateBlock), findsNWidgets(2));
expect(find.text('@$formattedDate'), findsNWidgets(2));
});
testWidgets("copy, cut and paste a reminder mention", (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a new document
await tester.createNewPageWithNameUnderParent(
name: 'copy, cut and paste a reminder mention',
);
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_dateOrReminder.tr(),
);
// trigger popup
await tester.tapButton(find.byType(MentionDateBlock));
await tester.pumpAndSettle();
// set date to be fifteenth of the next month
await tester.tap(
find.descendant(
of: find.byType(DesktopAppFlowyDatePicker),
matching: find.byFlowySvg(FlowySvgs.arrow_right_s),
),
);
await tester.pumpAndSettle();
await tester.tap(
find.descendant(
of: find.byType(TableCalendar),
matching: find.text(15.toString()),
),
);
await tester.pumpAndSettle();
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
// add a reminder
await tester.tap(find.byType(MentionDateBlock));
await tester.pumpAndSettle();
await tester.tap(find.text(LocaleKeys.datePicker_reminderLabel.tr()));
await tester.pumpAndSettle();
await tester.tap(
find.textContaining(
LocaleKeys.datePicker_reminderOptions_oneDayBefore.tr(),
),
);
await tester.pumpAndSettle();
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
// verify
final dateTimeSettings = DateTimeSettingsPB(
dateFormat: UserDateFormatPB.Friendly,
timeFormat: UserTimeFormatPB.TwentyFourHour,
);
final now = DateTime.now();
final fifteenthOfNextMonth = DateTime(now.year, now.month + 1, 15);
final formattedDate =
dateTimeSettings.dateFormat.formatDate(fifteenthOfNextMonth, false);
expect(find.byType(MentionDateBlock), findsOneWidget);
expect(find.text('@$formattedDate'), findsOneWidget);
expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsOneWidget);
expect(getIt<ReminderBloc>().state.reminders.map((e) => e.id).length, 1);
// update selection and copy
await tester.editor.updateSelection(
Selection(
start: Position(path: [0]),
end: Position(path: [0], offset: 1),
),
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyC,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
// update selection and paste
await tester.editor.updateSelection(
Selection.collapsed(Position(path: [0], offset: 1)),
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(MentionDateBlock), findsNWidgets(2));
expect(find.text('@$formattedDate'), findsNWidgets(2));
expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsNWidgets(2));
expect(
getIt<ReminderBloc>().state.reminders.map((e) => e.id).toSet().length,
2,
);
// update selection and cut
await tester.editor.updateSelection(
Selection(
start: Position(path: [0], offset: 1),
end: Position(path: [0], offset: 2),
),
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyX,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(MentionDateBlock), findsOneWidget);
expect(find.text('@$formattedDate'), findsOneWidget);
expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsOneWidget);
expect(getIt<ReminderBloc>().state.reminders.map((e) => e.id).length, 1);
// update selection and paste
await tester.editor.updateSelection(
Selection.collapsed(Position(path: [0], offset: 1)),
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(MentionDateBlock), findsNWidgets(2));
expect(find.text('@$formattedDate'), findsNWidgets(2));
expect(find.byType(MentionDateBlock), findsNWidgets(2));
expect(find.text('@$formattedDate'), findsNWidgets(2));
expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsNWidgets(2));
expect(
getIt<ReminderBloc>().state.reminders.map((e) => e.id).toSet().length,
2,
);
});
testWidgets("delete, undo and redo a reminder mention", (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a new document
await tester.createNewPageWithNameUnderParent(
name: 'delete, undo and redo a reminder mention',
);
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_dateOrReminder.tr(),
);
// trigger popup
await tester.tapButton(find.byType(MentionDateBlock));
await tester.pumpAndSettle();
// set date to be fifteenth of the next month
await tester.tap(
find.descendant(
of: find.byType(DesktopAppFlowyDatePicker),
matching: find.byFlowySvg(FlowySvgs.arrow_right_s),
),
);
await tester.pumpAndSettle();
await tester.tap(
find.descendant(
of: find.byType(TableCalendar),
matching: find.text(15.toString()),
),
);
await tester.pumpAndSettle();
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
// add a reminder
await tester.tap(find.byType(MentionDateBlock));
await tester.pumpAndSettle();
await tester.tap(find.text(LocaleKeys.datePicker_reminderLabel.tr()));
await tester.pumpAndSettle();
await tester.tap(
find.textContaining(
LocaleKeys.datePicker_reminderOptions_oneDayBefore.tr(),
),
);
await tester.pumpAndSettle();
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
// verify
final dateTimeSettings = DateTimeSettingsPB(
dateFormat: UserDateFormatPB.Friendly,
timeFormat: UserTimeFormatPB.TwentyFourHour,
);
final now = DateTime.now();
final fifteenthOfNextMonth = DateTime(now.year, now.month + 1, 15);
final formattedDate =
dateTimeSettings.dateFormat.formatDate(fifteenthOfNextMonth, false);
expect(find.byType(MentionDateBlock), findsOneWidget);
expect(find.text('@$formattedDate'), findsOneWidget);
expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsOneWidget);
expect(getIt<ReminderBloc>().state.reminders.map((e) => e.id).length, 1);
// update selection and backspace to delete the mention
await tester.editor.updateSelection(
Selection.collapsed(Position(path: [0], offset: 1)),
);
await tester.simulateKeyEvent(LogicalKeyboardKey.backspace);
await tester.pumpAndSettle();
expect(find.byType(MentionDateBlock), findsNothing);
expect(find.text('@$formattedDate'), findsNothing);
expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsNothing);
expect(getIt<ReminderBloc>().state.reminders.isEmpty, isTrue);
// undo
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyZ,
isControlPressed: Platform.isWindows || Platform.isLinux,
isMetaPressed: Platform.isMacOS,
);
expect(find.byType(MentionDateBlock), findsOneWidget);
expect(find.text('@$formattedDate'), findsOneWidget);
expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsOneWidget);
expect(getIt<ReminderBloc>().state.reminders.map((e) => e.id).length, 1);
// redo
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyZ,
isControlPressed: Platform.isWindows || Platform.isLinux,
isMetaPressed: Platform.isMacOS,
isShiftPressed: true,
);
expect(find.byType(MentionDateBlock), findsNothing);
expect(find.text('@$formattedDate'), findsNothing);
expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsNothing);
expect(getIt<ReminderBloc>().state.reminders.isEmpty, isTrue);
});
});
}
@@ -24,6 +24,13 @@ import 'package:run_with_network_images/run_with_network_images.dart';
import '../../shared/mock/mock_file_picker.dart';
import '../../shared/util.dart';
const _testImageUrls = [
'https://images.unsplash.com/photo-1469474968028-56623f02e42e?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&dl=david-marcu-78A265wPiO4-unsplash.jpg&w=640',
'https://www.easygifanimator.net/images/samples/eglite.gif',
'https://people.math.sc.edu/Burkardt/data/bmp/snail.bmp',
'https://file-examples.com/storage/fe9566cb7d67345489a5a97/2017/10/file_example_JPG_100kB.jpg',
];
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
TestWidgetsFlutterBinding.ensureInitialized();
@@ -131,12 +138,11 @@ void main() {
file.deleteSync();
});
testWidgets('insert a gif image from network', (tester) async {
await testEmbedImage(
tester,
'https://www.easygifanimator.net/images/samples/sparkles.gif',
);
});
for (final url in _testImageUrls) {
testWidgets('insert an image from network: $url', (tester) async {
await testEmbedImage(tester, url);
});
}
testWidgets('insert an image from unsplash', (tester) async {
await runWithNetworkImages(() async {
@@ -113,55 +113,5 @@ void main() {
tester.expectToSeeText(formula);
});
testWidgets('insert a inline math equation and type something after it',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a new document
await tester.createNewPageWithNameUnderParent(
name: 'math equation',
);
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
// insert a inline page
const formula = 'E = MC ^ 2';
await tester.ime.insertText(formula);
await tester.editor.updateSelection(
Selection.single(path: [0], startOffset: 0, endOffset: formula.length),
);
// tap the inline math equation button
final inlineMathEquationButton = find.findFlowyTooltip(
LocaleKeys.document_plugins_createInlineMathEquation.tr(),
);
await tester.tapButton(inlineMathEquationButton);
// expect to see the math equation block
final inlineMathEquation = find.byType(InlineMathEquation);
expect(inlineMathEquation, findsOneWidget);
await tester.editor.tapLineOfEditorAt(0);
const text = 'Hello World';
await tester.ime.insertText(text);
final inlineText = find.textContaining(text, findRichText: true);
expect(inlineText, findsOneWidget);
// the text should be in the same line with the math equation
final inlineMathEquationPosition = tester.getRect(inlineMathEquation);
final textPosition = tester.getRect(inlineText);
// allow 5px difference
expect(
(textPosition.top - inlineMathEquationPosition.top).abs(),
lessThan(5),
);
expect(
(textPosition.bottom - inlineMathEquationPosition.bottom).abs(),
lessThan(5),
);
});
});
}
@@ -94,20 +94,6 @@ void main() {
await tester.tapButton(finder);
expect(find.byType(GridPage), findsOneWidget);
});
testWidgets('insert a inline page and type something after the page',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await insertInlinePage(tester, ViewLayoutPB.Grid);
await tester.editor.tapLineOfEditorAt(0);
const text = 'Hello World';
await tester.ime.insertText(text);
expect(find.textContaining(text, findRichText: true), findsOneWidget);
});
});
}
@@ -1,783 +0,0 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:universal_platform/universal_platform.dart';
import '../../shared/util.dart';
const String heading1 = "Heading 1";
const String heading2 = "Heading 2";
const String heading3 = "Heading 3";
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('simple table block test:', () {
testWidgets('insert a simple table block', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
// validate the table is inserted
expect(find.byType(SimpleTableBlockWidget), findsOneWidget);
final editorState = tester.editor.getCurrentEditorState();
expect(
editorState.selection,
// table -> row -> cell -> paragraph
Selection.collapsed(Position(path: [0, 0, 0, 0])),
);
final firstCell = find.byType(SimpleTableCellBlockWidget).first;
expect(
tester
.state<SimpleTableCellBlockWidgetState>(firstCell)
.isEditingCellNotifier
.value,
isTrue,
);
});
testWidgets('select all in table cell', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
const cell1Content = 'Cell 1';
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText('New Table');
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
await tester.editor.tapLineOfEditorAt(1);
await tester.insertTableInDocument();
await tester.ime.insertText(cell1Content);
await tester.pumpAndSettle();
// Select all in the cell
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyA,
isControlPressed: !UniversalPlatform.isMacOS,
isMetaPressed: UniversalPlatform.isMacOS,
);
expect(
tester.editor.getCurrentEditorState().selection,
Selection(
start: Position(path: [1, 0, 0, 0]),
end: Position(path: [1, 0, 0, 0], offset: cell1Content.length),
),
);
// Press select all again, the selection should be the entire document
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyA,
isControlPressed: !UniversalPlatform.isMacOS,
isMetaPressed: UniversalPlatform.isMacOS,
);
expect(
tester.editor.getCurrentEditorState().selection,
Selection(
start: Position(path: [0]),
end: Position(path: [1, 1, 1, 0]),
),
);
});
testWidgets('''
1. hover on the table
1.1 click the add row button
1.2 click the add column button
1.3 click the add row and column button
2. validate the table is updated
3. delete the last column
4. delete the last row
5. validate the table is updated
''', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
// add a new row
final row = find.byWidgetPredicate((w) {
return w is SimpleTableRowBlockWidget && w.node.rowIndex == 1;
});
await tester.hoverOnWidget(
row,
onHover: () async {
final addRowButton = find.byType(SimpleTableAddRowButton).first;
await tester.tap(addRowButton);
},
);
await tester.pumpAndSettle();
// add a new column
final column = find.byWidgetPredicate((w) {
return w is SimpleTableCellBlockWidget && w.node.columnIndex == 1;
}).first;
await tester.hoverOnWidget(
column,
onHover: () async {
final addColumnButton = find.byType(SimpleTableAddColumnButton).first;
await tester.tap(addColumnButton);
},
);
await tester.pumpAndSettle();
// add a new row and a new column
final row2 = find.byWidgetPredicate((w) {
return w is SimpleTableCellBlockWidget &&
w.node.rowIndex == 2 &&
w.node.columnIndex == 2;
}).first;
await tester.hoverOnWidget(
row2,
onHover: () async {
// click the add row and column button
final addRowAndColumnButton =
find.byType(SimpleTableAddColumnAndRowButton).first;
await tester.tap(addRowAndColumnButton);
},
);
await tester.pumpAndSettle();
final tableNode =
tester.editor.getCurrentEditorState().document.nodeAtPath([0])!;
expect(tableNode.columnLength, 4);
expect(tableNode.rowLength, 4);
// delete the last row
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.row,
index: tableNode.rowLength - 1,
action: SimpleTableMoreAction.delete,
);
await tester.pumpAndSettle();
expect(tableNode.rowLength, 3);
expect(tableNode.columnLength, 4);
// delete the last column
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.column,
index: tableNode.columnLength - 1,
action: SimpleTableMoreAction.delete,
);
await tester.pumpAndSettle();
expect(tableNode.columnLength, 3);
expect(tableNode.rowLength, 3);
});
testWidgets('enable header column and header row', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
// enable the header row
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.row,
index: 0,
action: SimpleTableMoreAction.enableHeaderRow,
);
await tester.pumpAndSettle();
// enable the header column
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.column,
index: 0,
action: SimpleTableMoreAction.enableHeaderColumn,
);
await tester.pumpAndSettle();
final tableNode =
tester.editor.getCurrentEditorState().document.nodeAtPath([0])!;
expect(tableNode.isHeaderColumnEnabled, isTrue);
expect(tableNode.isHeaderRowEnabled, isTrue);
// disable the header row
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.row,
index: 0,
action: SimpleTableMoreAction.enableHeaderRow,
);
await tester.pumpAndSettle();
expect(tableNode.isHeaderColumnEnabled, isTrue);
expect(tableNode.isHeaderRowEnabled, isFalse);
// disable the header column
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.column,
index: 0,
action: SimpleTableMoreAction.enableHeaderColumn,
);
await tester.pumpAndSettle();
expect(tableNode.isHeaderColumnEnabled, isFalse);
expect(tableNode.isHeaderRowEnabled, isFalse);
});
testWidgets('duplicate a column / row', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
// duplicate the row
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.row,
index: 0,
action: SimpleTableMoreAction.duplicate,
);
await tester.pumpAndSettle();
// duplicate the column
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.column,
index: 0,
action: SimpleTableMoreAction.duplicate,
);
await tester.pumpAndSettle();
final tableNode =
tester.editor.getCurrentEditorState().document.nodeAtPath([0])!;
expect(tableNode.columnLength, 3);
expect(tableNode.rowLength, 3);
});
testWidgets('insert left / insert right', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
// insert left
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.column,
index: 0,
action: SimpleTableMoreAction.insertLeft,
);
await tester.pumpAndSettle();
// insert right
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.column,
index: 0,
action: SimpleTableMoreAction.insertRight,
);
await tester.pumpAndSettle();
final tableNode =
tester.editor.getCurrentEditorState().document.nodeAtPath([0])!;
expect(tableNode.columnLength, 4);
expect(tableNode.rowLength, 2);
});
testWidgets('insert above / insert below', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
// insert above
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.row,
index: 0,
action: SimpleTableMoreAction.insertAbove,
);
await tester.pumpAndSettle();
// insert below
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.row,
index: 0,
action: SimpleTableMoreAction.insertBelow,
);
await tester.pumpAndSettle();
final tableNode =
tester.editor.getCurrentEditorState().document.nodeAtPath([0])!;
expect(tableNode.rowLength, 4);
expect(tableNode.columnLength, 2);
});
});
testWidgets('set column width to page width (1)', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
final tableNode = tester.editor.getNodeAtPath([0]);
final beforeWidth = tableNode.width;
// set the column width to page width
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.column,
index: 0,
action: SimpleTableMoreAction.setToPageWidth,
);
await tester.pumpAndSettle();
final afterWidth = tableNode.width;
expect(afterWidth, greaterThan(beforeWidth));
});
testWidgets('set column width to page width (2)', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
final tableNode = tester.editor.getNodeAtPath([0]);
final beforeWidth = tableNode.width;
// set the column width to page width
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.row,
index: 0,
action: SimpleTableMoreAction.setToPageWidth,
);
await tester.pumpAndSettle();
final afterWidth = tableNode.width;
expect(afterWidth, greaterThan(beforeWidth));
});
testWidgets('distribute columns evenly (1)', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
final tableNode = tester.editor.getNodeAtPath([0]);
final beforeWidth = tableNode.width;
// set the column width to page width
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.row,
index: 0,
action: SimpleTableMoreAction.distributeColumnsEvenly,
);
await tester.pumpAndSettle();
final afterWidth = tableNode.width;
expect(afterWidth, equals(beforeWidth));
final distributeColumnWidthsEvenly =
tableNode.attributes[SimpleTableBlockKeys.distributeColumnWidthsEvenly];
expect(distributeColumnWidthsEvenly, isTrue);
});
testWidgets('distribute columns evenly (2)', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
final tableNode = tester.editor.getNodeAtPath([0]);
final beforeWidth = tableNode.width;
// set the column width to page width
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.column,
index: 0,
action: SimpleTableMoreAction.distributeColumnsEvenly,
);
await tester.pumpAndSettle();
final afterWidth = tableNode.width;
expect(afterWidth, equals(beforeWidth));
final distributeColumnWidthsEvenly =
tableNode.attributes[SimpleTableBlockKeys.distributeColumnWidthsEvenly];
expect(distributeColumnWidthsEvenly, isTrue);
});
testWidgets('using option menu to set column width', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
await tester.editor.hoverAndClickOptionMenuButton([0]);
final editorState = tester.editor.getCurrentEditorState();
final beforeWidth = editorState.document.nodeAtPath([0])!.width;
await tester.tapButton(
find.text(
LocaleKeys.document_plugins_simpleTable_moreActions_setToPageWidth.tr(),
),
);
await tester.pumpAndSettle();
final afterWidth = editorState.document.nodeAtPath([0])!.width;
expect(afterWidth, greaterThan(beforeWidth));
await tester.editor.hoverAndClickOptionMenuButton([0]);
await tester.tapButton(
find.text(
LocaleKeys
.document_plugins_simpleTable_moreActions_distributeColumnsWidth
.tr(),
),
);
await tester.pumpAndSettle();
final afterWidth2 = editorState.document.nodeAtPath([0])!.width;
expect(afterWidth2, equals(afterWidth));
});
testWidgets('insert a table and use select all the delete it',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
await tester.editor.tapLineOfEditorAt(1);
await tester.ime.insertText('Hello World');
// select all
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyA,
isMetaPressed: UniversalPlatform.isMacOS,
isControlPressed: !UniversalPlatform.isMacOS,
);
await tester.simulateKeyEvent(LogicalKeyboardKey.backspace);
await tester.pumpAndSettle();
final editorState = tester.editor.getCurrentEditorState();
// only one paragraph left
expect(editorState.document.root.children.length, 1);
final paragraphNode = editorState.document.nodeAtPath([0])!;
expect(paragraphNode.delta, isNull);
});
testWidgets('use tab or shift+tab to navigate in table', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
await tester.simulateKeyEvent(LogicalKeyboardKey.tab);
await tester.pumpAndSettle();
final editorState = tester.editor.getCurrentEditorState();
final selection = editorState.selection;
expect(selection, isNotNull);
expect(selection!.start.path, [0, 0, 1, 0]);
expect(selection.end.path, [0, 0, 1, 0]);
await tester.simulateKeyEvent(
LogicalKeyboardKey.tab,
isShiftPressed: true,
);
await tester.pumpAndSettle();
final selection2 = editorState.selection;
expect(selection2, isNotNull);
expect(selection2!.start.path, [0, 0, 0, 0]);
expect(selection2.end.path, [0, 0, 0, 0]);
});
testWidgets('shift+enter to insert a new line in table', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
await tester.simulateKeyEvent(
LogicalKeyboardKey.enter,
isShiftPressed: true,
);
await tester.pumpAndSettle();
final editorState = tester.editor.getCurrentEditorState();
final node = editorState.document.nodeAtPath([0, 0, 0])!;
expect(node.children.length, 1);
});
testWidgets('using option menu to set table align', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
await tester.editor.hoverAndClickOptionMenuButton([0]);
final editorState = tester.editor.getCurrentEditorState();
final beforeAlign = editorState.document.nodeAtPath([0])!.tableAlign;
expect(beforeAlign, TableAlign.left);
await tester.tapButton(
find.text(
LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(),
),
);
await tester.pumpAndSettle();
await tester.tapButton(
find.text(
LocaleKeys.document_plugins_optionAction_center.tr(),
),
);
await tester.pumpAndSettle();
final afterAlign = editorState.document.nodeAtPath([0])!.tableAlign;
expect(afterAlign, TableAlign.center);
await tester.editor.hoverAndClickOptionMenuButton([0]);
await tester.tapButton(
find.text(
LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(),
),
);
await tester.pumpAndSettle();
await tester.tapButton(
find.text(
LocaleKeys.document_plugins_optionAction_right.tr(),
),
);
await tester.pumpAndSettle();
final afterAlign2 = editorState.document.nodeAtPath([0])!.tableAlign;
expect(afterAlign2, TableAlign.right);
await tester.editor.hoverAndClickOptionMenuButton([0]);
await tester.tapButton(
find.text(
LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(),
),
);
await tester.pumpAndSettle();
await tester.tapButton(
find.text(
LocaleKeys.document_plugins_optionAction_left.tr(),
),
);
await tester.pumpAndSettle();
final afterAlign3 = editorState.document.nodeAtPath([0])!.tableAlign;
expect(afterAlign3, TableAlign.left);
});
testWidgets('using option menu to set table align', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
await tester.editor.hoverAndClickOptionMenuButton([0]);
final editorState = tester.editor.getCurrentEditorState();
final beforeAlign = editorState.document.nodeAtPath([0])!.tableAlign;
expect(beforeAlign, TableAlign.left);
await tester.tapButton(
find.text(
LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(),
),
);
await tester.pumpAndSettle();
await tester.tapButton(
find.text(
LocaleKeys.document_plugins_optionAction_center.tr(),
),
);
await tester.pumpAndSettle();
final afterAlign = editorState.document.nodeAtPath([0])!.tableAlign;
expect(afterAlign, TableAlign.center);
await tester.editor.hoverAndClickOptionMenuButton([0]);
await tester.tapButton(
find.text(
LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(),
),
);
await tester.pumpAndSettle();
await tester.tapButton(
find.text(
LocaleKeys.document_plugins_optionAction_right.tr(),
),
);
await tester.pumpAndSettle();
final afterAlign2 = editorState.document.nodeAtPath([0])!.tableAlign;
expect(afterAlign2, TableAlign.right);
await tester.editor.hoverAndClickOptionMenuButton([0]);
await tester.tapButton(
find.text(
LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(),
),
);
await tester.pumpAndSettle();
await tester.tapButton(
find.text(
LocaleKeys.document_plugins_optionAction_left.tr(),
),
);
await tester.pumpAndSettle();
final afterAlign3 = editorState.document.nodeAtPath([0])!.tableAlign;
expect(afterAlign3, TableAlign.left);
});
testWidgets('support slash menu in table', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
final editorState = tester.editor.getCurrentEditorState();
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
final path = [0, 0, 0, 0];
final selection = Selection.collapsed(Position(path: path));
editorState.selection = selection;
await tester.editor.showSlashMenu();
await tester.pumpAndSettle();
final paragraphItem = find.byWidgetPredicate((w) {
return w is SelectionMenuItemWidget &&
w.item.name == LocaleKeys.document_slashMenu_name_text.tr();
});
expect(paragraphItem, findsOneWidget);
await tester.tap(paragraphItem);
await tester.pumpAndSettle();
final paragraphNode = editorState.document.nodeAtPath(path)!;
expect(paragraphNode.type, equals(ParagraphBlockKeys.type));
});
}
extension on WidgetTester {
/// Insert a table in the document
Future<void> insertTableInDocument() async {
// open the actions menu and insert the outline block
await editor.showSlashMenu();
await editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_table.tr(),
);
await pumpAndSettle();
}
Future<void> clickMoreActionItemInTableMenu({
required SimpleTableMoreActionType type,
required int index,
required SimpleTableMoreAction action,
}) async {
if (type == SimpleTableMoreActionType.row) {
final row = find.byWidgetPredicate((w) {
return w is SimpleTableRowBlockWidget && w.node.rowIndex == index;
});
await hoverOnWidget(
row,
onHover: () async {
final moreActionButton = find.byWidgetPredicate((w) {
return w is SimpleTableMoreActionMenu &&
w.type == SimpleTableMoreActionType.row &&
w.index == index;
});
await tapButton(moreActionButton);
await tapButton(find.text(action.name));
},
);
await pumpAndSettle();
} else if (type == SimpleTableMoreActionType.column) {
final column = find.byWidgetPredicate((w) {
return w is SimpleTableCellBlockWidget && w.node.columnIndex == index;
}).first;
await hoverOnWidget(
column,
onHover: () async {
final moreActionButton = find.byWidgetPredicate((w) {
return w is SimpleTableMoreActionMenu &&
w.type == SimpleTableMoreActionType.column &&
w.index == index;
});
await tapButton(moreActionButton);
await tapButton(find.text(action.name));
},
);
await pumpAndSettle();
}
await tapAt(Offset.zero);
}
}
@@ -82,7 +82,7 @@ void main() {
// reset to appflowy cloud
await tester.tapButton(
findServerType(AuthenticatorType.appflowyCloudSelfHost),
);
);
// change the server type to appflowy cloud
await tester.tapButton(
findServerType(AuthenticatorType.appflowyCloud),
@@ -1,229 +1,83 @@
import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart';
import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flowy_infra_ui/style_widget/text_field.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/emoji.dart';
import '../../shared/expectation.dart';
void main() {
final emoji = EmojiIconData.emoji('😁');
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
RecentIcons.enable = false;
});
const emoji = '😁';
tearDownAll(() {
RecentIcons.enable = true;
});
group('Icon:', () {
testWidgets('Update page icon in sidebar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
Future<EmojiIconData> loadIcon() async {
await loadIconGroups();
final groups = kIconGroups!;
final firstGroup = groups.first;
final firstIcon = firstGroup.icons.first;
return EmojiIconData.icon(
IconsData(
firstGroup.name,
firstIcon.content,
firstIcon.name,
builtInSpaceColors.first,
),
);
}
// create document, board, grid and calendar views
for (final value in ViewLayoutPB.values) {
if (value == ViewLayoutPB.Chat) {
continue;
}
await tester.createNewPageWithNameUnderParent(
name: value.name,
parentName: gettingStarted,
layout: value,
);
testWidgets('Update page emoji in sidebar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// update its icon
await tester.updatePageIconInSidebarByName(
name: value.name,
parentName: gettingStarted,
layout: value,
icon: emoji,
);
// create document, board, grid and calendar views
for (final value in ViewLayoutPB.values) {
if (value == ViewLayoutPB.Chat) {
continue;
tester.expectViewHasIcon(
value.name,
value,
emoji,
);
}
await tester.createNewPageWithNameUnderParent(
name: value.name,
parentName: gettingStarted,
layout: value,
);
});
// update its emoji
await tester.updatePageIconInSidebarByName(
name: value.name,
parentName: gettingStarted,
layout: value,
icon: emoji,
);
testWidgets('Update page icon in title bar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
tester.expectViewHasIcon(
value.name,
value,
emoji,
);
}
});
// create document, board, grid and calendar views
for (final value in ViewLayoutPB.values) {
if (value == ViewLayoutPB.Chat) {
continue;
}
testWidgets('Update page emoji in title bar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: value.name,
parentName: gettingStarted,
layout: value,
);
// create document, board, grid and calendar views
for (final value in ViewLayoutPB.values) {
if (value == ViewLayoutPB.Chat) {
continue;
// update its icon
await tester.updatePageIconInTitleBarByName(
name: value.name,
layout: value,
icon: emoji,
);
tester.expectViewHasIcon(
value.name,
value,
emoji,
);
tester.expectViewTitleHasIcon(
value.name,
value,
emoji,
);
}
await tester.createNewPageWithNameUnderParent(
name: value.name,
parentName: gettingStarted,
layout: value,
);
// update its emoji
await tester.updatePageIconInTitleBarByName(
name: value.name,
layout: value,
icon: emoji,
);
tester.expectViewHasIcon(
value.name,
value,
emoji,
);
tester.expectViewTitleHasIcon(
value.name,
value,
emoji,
);
}
});
testWidgets('Emoji Search Bar Get Focus', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create document, board, grid and calendar views
for (final value in ViewLayoutPB.values) {
if (value == ViewLayoutPB.Chat) {
continue;
}
await tester.createNewPageWithNameUnderParent(
name: value.name,
parentName: gettingStarted,
layout: value,
);
await tester.openPage(
value.name,
layout: value,
);
final title = find.descendant(
of: find.byType(ViewTitleBar),
matching: find.text(value.name),
);
await tester.tapButton(title);
await tester.tapButton(find.byType(EmojiPickerButton));
final emojiPicker = find.byType(FlowyEmojiPicker);
expect(emojiPicker, findsOneWidget);
final textField = find.descendant(
of: emojiPicker,
matching: find.byType(FlowyTextField),
);
expect(textField, findsOneWidget);
final textFieldWidget =
textField.evaluate().first.widget as FlowyTextField;
assert(textFieldWidget.focusNode!.hasFocus);
await tester.tapEmoji(emoji.emoji);
await tester.pumpAndSettle();
tester.expectViewHasIcon(
value.name,
value,
emoji,
);
}
});
testWidgets('Update page icon in sidebar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
final iconData = await loadIcon();
// create document, board, grid and calendar views
for (final value in ViewLayoutPB.values) {
if (value == ViewLayoutPB.Chat) {
continue;
}
await tester.createNewPageWithNameUnderParent(
name: value.name,
parentName: gettingStarted,
layout: value,
);
// update its icon
await tester.updatePageIconInSidebarByName(
name: value.name,
parentName: gettingStarted,
layout: value,
icon: iconData,
);
tester.expectViewHasIcon(
value.name,
value,
iconData,
);
}
});
testWidgets('Update page icon in title bar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
final iconData = await loadIcon();
// create document, board, grid and calendar views
for (final value in ViewLayoutPB.values) {
if (value == ViewLayoutPB.Chat) {
continue;
}
await tester.createNewPageWithNameUnderParent(
name: value.name,
parentName: gettingStarted,
layout: value,
);
// update its icon
await tester.updatePageIconInTitleBarByName(
name: value.name,
layout: value,
icon: iconData,
);
tester.expectViewHasIcon(
value.name,
value,
iconData,
);
tester.expectViewTitleHasIcon(
value.name,
value,
iconData,
);
}
});
});
}
@@ -1,6 +1,5 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -39,11 +38,7 @@ void main() {
await tester.tapEmoji(emoji);
await tester.pumpAndSettle();
tester.expectViewHasIcon(
gettingStarted,
ViewLayoutPB.Document,
EmojiIconData.emoji(emoji),
);
tester.expectViewHasIcon(gettingStarted, ViewLayoutPB.Document, emoji);
});
});
}
@@ -1,91 +0,0 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/code_block/code_block_language_selector.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/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/document_test_operations.dart';
import '../document/document_codeblock_paste_test.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('Code Block Language Selector 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));
expect(find.byType(CodeBlockLanguageSelector), findsNothing);
await insertCodeBlockInDocument(tester);
///tap button
await tester.hoverOnWidget(find.byType(CodeBlockComponentWidget));
await tester
.tapButtonWithName(LocaleKeys.document_codeBlock_language_auto.tr());
expect(find.byType(CodeBlockLanguageSelector), findsOneWidget);
for (var i = 0; i < 3; ++i) {
await onKey(tester, LogicalKeyboardKey.arrowDown);
}
for (var i = 0; i < 2; ++i) {
await onKey(tester, LogicalKeyboardKey.arrowUp);
}
await onKey(tester, LogicalKeyboardKey.enter);
final editorState = tester.editor.getCurrentEditorState();
String language = editorState
.getNodeAtPath([0])!
.attributes[CodeBlockKeys.language]
.toString();
expect(
language.toLowerCase(),
defaultCodeBlockSupportedLanguages.first.toLowerCase(),
);
await tester.hoverOnWidget(find.byType(CodeBlockComponentWidget));
await tester.tapButtonWithName(language);
await onKey(tester, LogicalKeyboardKey.arrowUp);
await onKey(tester, LogicalKeyboardKey.enter);
language = editorState
.getNodeAtPath([0])!
.attributes[CodeBlockKeys.language]
.toString();
expect(
language.toLowerCase(),
defaultCodeBlockSupportedLanguages.last.toLowerCase(),
);
await tester.hoverOnWidget(find.byType(CodeBlockComponentWidget));
await tester.tapButtonWithName(language);
tester.testTextInput.enterText("rust");
await onKey(tester, LogicalKeyboardKey.delete);
await onKey(tester, LogicalKeyboardKey.delete);
await onKey(tester, LogicalKeyboardKey.arrowDown);
tester.testTextInput.enterText("st");
await onKey(tester, LogicalKeyboardKey.arrowDown);
await onKey(tester, LogicalKeyboardKey.enter);
language = editorState
.getNodeAtPath([0])!
.attributes[CodeBlockKeys.language]
.toString();
expect(language.toLowerCase(), 'rust');
});
}
Future<void> onKey(WidgetTester tester, LogicalKeyboardKey key) async {
await tester.simulateKeyEvent(key);
await tester.pumpAndSettle();
}
@@ -1,6 +1,5 @@
import 'dart:io';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -88,7 +87,7 @@ void main() {
);
expect(
importedPageEditorState.getNodeAtPath([2])!.type,
SimpleTableBlockKeys.type,
TableBlockKeys.type,
);
});
});
@@ -3,7 +3,6 @@ import 'dart:io';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/home/tabs/flowy_tab.dart';
import 'package:appflowy/workspace/presentation/home/tabs/tabs_manager.dart';
import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/gestures.dart';
@@ -146,189 +145,5 @@ void main() {
// and in this case view name in sidebar)
expect(find.text(gettingStarted), findsNWidgets(3));
});
testWidgets('cannot close pinned tabs', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
expect(
find.descendant(
of: find.byType(TabsManager),
matching: find.byType(TabBar),
),
findsNothing,
);
await tester.createNewPageWithNameUnderParent(name: _documentName);
await tester.createNewPageWithNameUnderParent(name: _documentTwoName);
// Open second menu item in a new tab
await tester.openAppInNewTab(gettingStarted, ViewLayoutPB.Document);
// Open third menu item in a new tab
await tester.openAppInNewTab(_documentName, ViewLayoutPB.Document);
expect(
find.descendant(
of: find.byType(TabsManager),
matching: find.byType(FlowyTab),
),
findsNWidgets(3),
);
const firstTab = _documentTwoName;
const secondTab = gettingStarted;
const thirdTab = _documentName;
expect(tester.isTabAtIndex(firstTab, 0), isTrue);
expect(tester.isTabAtIndex(secondTab, 1), isTrue);
expect(tester.isTabAtIndex(thirdTab, 2), isTrue);
expect(tester.isTabPinned(gettingStarted), isFalse);
// Right click on second tab
await tester.openTabMenu(gettingStarted);
expect(find.byType(TabMenu), findsOneWidget);
// Pin second tab
await tester.tap(find.text(LocaleKeys.tabMenu_pinTab.tr()));
await tester.pumpAndSettle();
expect(tester.isTabPinned(gettingStarted), isTrue);
/// Right click on first unpinned tab (second tab)
await tester.openTabMenu(_documentTwoName);
// Close others
await tester.tap(find.text(LocaleKeys.tabMenu_closeOthers.tr()));
await tester.pumpAndSettle();
// We expect to find 2 tabs, the first pinned tab and the second tab
expect(find.byType(FlowyTab), findsNWidgets(2));
expect(tester.isTabAtIndex(gettingStarted, 0), isTrue);
expect(tester.isTabAtIndex(_documentTwoName, 1), isTrue);
});
testWidgets('pin/unpin tabs proper order', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
expect(
find.descendant(
of: find.byType(TabsManager),
matching: find.byType(TabBar),
),
findsNothing,
);
await tester.createNewPageWithNameUnderParent(name: _documentName);
await tester.createNewPageWithNameUnderParent(name: _documentTwoName);
// Open second menu item in a new tab
await tester.openAppInNewTab(gettingStarted, ViewLayoutPB.Document);
// Open third menu item in a new tab
await tester.openAppInNewTab(_documentName, ViewLayoutPB.Document);
expect(
find.descendant(
of: find.byType(TabsManager),
matching: find.byType(FlowyTab),
),
findsNWidgets(3),
);
const firstTabName = _documentTwoName;
const secondTabName = gettingStarted;
const thirdTabName = _documentName;
// Expect correct order
expect(tester.isTabAtIndex(firstTabName, 0), isTrue);
expect(tester.isTabAtIndex(secondTabName, 1), isTrue);
expect(tester.isTabAtIndex(thirdTabName, 2), isTrue);
// Pin second tab
await tester.openTabMenu(secondTabName);
await tester.tap(find.text(LocaleKeys.tabMenu_pinTab.tr()));
await tester.pumpAndSettle();
expect(tester.isTabPinned(secondTabName), isTrue);
// Expect correct order
expect(tester.isTabAtIndex(secondTabName, 0), isTrue);
expect(tester.isTabAtIndex(firstTabName, 1), isTrue);
expect(tester.isTabAtIndex(thirdTabName, 2), isTrue);
// Pin new second tab (first tab)
await tester.openTabMenu(firstTabName);
await tester.tap(find.text(LocaleKeys.tabMenu_pinTab.tr()));
await tester.pumpAndSettle();
expect(tester.isTabPinned(firstTabName), isTrue);
expect(tester.isTabPinned(secondTabName), isTrue);
expect(tester.isTabPinned(thirdTabName), isFalse);
expect(tester.isTabAtIndex(secondTabName, 0), isTrue);
expect(tester.isTabAtIndex(firstTabName, 1), isTrue);
expect(tester.isTabAtIndex(thirdTabName, 2), isTrue);
// Unpin second tab
await tester.openTabMenu(secondTabName);
await tester.tap(find.text(LocaleKeys.tabMenu_unpinTab.tr()));
await tester.pumpAndSettle();
expect(tester.isTabPinned(firstTabName), isTrue);
expect(tester.isTabPinned(secondTabName), isFalse);
expect(tester.isTabPinned(thirdTabName), isFalse);
expect(tester.isTabAtIndex(firstTabName, 0), isTrue);
expect(tester.isTabAtIndex(secondTabName, 1), isTrue);
expect(tester.isTabAtIndex(thirdTabName, 2), isTrue);
});
});
}
extension _TabsTester on WidgetTester {
bool isTabPinned(String tabName) {
final tabFinder = find.ancestor(
of: find.byWidgetPredicate(
(w) => w is ViewTabBarItem && w.view.name == tabName,
),
matching: find.byType(FlowyTab),
);
final FlowyTab tabWidget = widget(tabFinder);
return tabWidget.pageManager.isPinned;
}
bool isTabAtIndex(String tabName, int index) {
final tabFinder = find.ancestor(
of: find.byWidgetPredicate(
(w) => w is ViewTabBarItem && w.view.name == tabName,
),
matching: find.byType(FlowyTab),
);
final pluginId = (widget(tabFinder) as FlowyTab).pageManager.plugin.id;
final pluginIds = find
.byType(FlowyTab)
.evaluate()
.map((e) => (e.widget as FlowyTab).pageManager.plugin.id);
return pluginIds.elementAt(index) == pluginId;
}
Future<void> openTabMenu(String tabName) async {
await tap(
buttons: kSecondaryButton,
find.ancestor(
of: find.byWidgetPredicate(
(w) => w is ViewTabBarItem && w.view.name == tabName,
),
matching: find.byType(FlowyTab),
),
);
await pumpAndSettle();
}
}
@@ -1,8 +1,6 @@
import 'package:integration_test/integration_test.dart';
import 'desktop/uncategorized/tabs_test.dart' as tabs_test;
import 'desktop/uncategorized/code_block_language_selector_test.dart'
as code_language_selector;
import 'desktop/first_test/first_test.dart' as first_test;
Future<void> main() async {
@@ -13,6 +11,6 @@ Future<void> runIntegration9OnDesktop() async {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
first_test.main();
tabs_test.main();
code_language_selector.main();
}
@@ -2,7 +2,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 'simple_table_test.dart' as simple_table_test;
import 'title_test.dart' as title_test;
void main() {
@@ -12,5 +11,4 @@ void main() {
title_test.main();
page_style_test.main();
plus_menu_test.main();
simple_table_test.main();
}
@@ -1,440 +0,0 @@
import 'dart:async';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('simple table:', () {
testWidgets('''
1. insert a simple table via + menu
2. insert a row above the table
3. insert a row below the table
4. insert a column left to the table
5. insert a column right to the table
6. delete the first row
7. delete the first column
''', (tester) async {
await tester.launchInAnonymousMode();
await tester.createNewDocumentOnMobile('simple table');
final editorState = tester.editor.getCurrentEditorState();
// focus on the editor
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: [0])),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
final firstParagraphPath = [0, 0, 0, 0];
// open the plus menu and select the table block
{
await tester.openPlusMenuAndClickButton(
LocaleKeys.document_slashMenu_name_table.tr(),
);
// check the block is inserted
final table = editorState.getNodeAtPath([0])!;
expect(table.type, equals(SimpleTableBlockKeys.type));
expect(table.rowLength, equals(2));
expect(table.columnLength, equals(2));
// focus on the first cell
final selection = editorState.selection!;
expect(selection.isCollapsed, isTrue);
expect(selection.start.path, equals(firstParagraphPath));
}
// insert left and insert right
{
// click the column menu button
await tester.clickColumnMenuButton(0);
// insert left, insert right
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_simpleTable_moreActions_insertLeft.tr(),
),
);
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_simpleTable_moreActions_insertRight
.tr(),
),
);
await tester.cancelTableActionMenu();
// check the table is updated
final table = editorState.getNodeAtPath([0])!;
expect(table.type, equals(SimpleTableBlockKeys.type));
expect(table.rowLength, equals(2));
expect(table.columnLength, equals(4));
}
// insert above and insert below
{
// focus on the first cell
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: firstParagraphPath)),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
// click the row menu button
await tester.clickRowMenuButton(0);
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_simpleTable_moreActions_insertAbove
.tr(),
),
);
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_simpleTable_moreActions_insertBelow
.tr(),
),
);
await tester.cancelTableActionMenu();
// check the table is updated
final table = editorState.getNodeAtPath([0])!;
expect(table.rowLength, equals(4));
expect(table.columnLength, equals(4));
}
// delete the first row
{
// focus on the first cell
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: firstParagraphPath)),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
// delete the first row
await tester.clickRowMenuButton(0);
await tester.clickSimpleTableQuickAction(SimpleTableMoreAction.delete);
await tester.cancelTableActionMenu();
// check the table is updated
final table = editorState.getNodeAtPath([0])!;
expect(table.rowLength, equals(3));
expect(table.columnLength, equals(4));
}
// delete the first column
{
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: firstParagraphPath)),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
await tester.clickColumnMenuButton(0);
await tester.clickSimpleTableQuickAction(SimpleTableMoreAction.delete);
await tester.cancelTableActionMenu();
// check the table is updated
final table = editorState.getNodeAtPath([0])!;
expect(table.rowLength, equals(3));
expect(table.columnLength, equals(3));
}
});
testWidgets('''
1. insert a simple table via + menu
2. enable header column
3. enable header row
4. set to page width
5. distribute columns evenly
''', (tester) async {
await tester.launchInAnonymousMode();
await tester.createNewDocumentOnMobile('simple table');
final editorState = tester.editor.getCurrentEditorState();
// focus on the editor
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: [0])),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
final firstParagraphPath = [0, 0, 0, 0];
// open the plus menu and select the table block
{
await tester.openPlusMenuAndClickButton(
LocaleKeys.document_slashMenu_name_table.tr(),
);
// check the block is inserted
final table = editorState.getNodeAtPath([0])!;
expect(table.type, equals(SimpleTableBlockKeys.type));
expect(table.rowLength, equals(2));
expect(table.columnLength, equals(2));
// focus on the first cell
final selection = editorState.selection!;
expect(selection.isCollapsed, isTrue);
expect(selection.start.path, equals(firstParagraphPath));
}
// enable header column
{
// click the column menu button
await tester.clickColumnMenuButton(0);
// enable header column
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_simpleTable_moreActions_headerColumn
.tr(),
),
);
}
// enable header row
{
// focus on the first cell
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: firstParagraphPath)),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
// click the row menu button
await tester.clickRowMenuButton(0);
// enable header column
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_simpleTable_moreActions_headerRow.tr(),
),
);
}
// check the table is updated
final table = editorState.getNodeAtPath([0])!;
expect(table.type, equals(SimpleTableBlockKeys.type));
expect(table.isHeaderColumnEnabled, isTrue);
expect(table.isHeaderRowEnabled, isTrue);
// set to page width
{
final table = editorState.getNodeAtPath([0])!;
final beforeWidth = table.width;
// focus on the first cell
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: firstParagraphPath)),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
// click the row menu button
await tester.clickRowMenuButton(0);
// enable header column
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_simpleTable_moreActions_setToPageWidth
.tr(),
),
);
// check the table is updated
expect(table.width, greaterThan(beforeWidth));
}
// distribute columns evenly
{
final table = editorState.getNodeAtPath([0])!;
final beforeWidth = table.width;
// focus on the first cell
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: firstParagraphPath)),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
// click the column menu button
await tester.clickColumnMenuButton(0);
// distribute columns evenly
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys
.document_plugins_simpleTable_moreActions_distributeColumnsWidth
.tr(),
),
);
// check the table is updated
expect(table.width, equals(beforeWidth));
}
});
testWidgets('''
1. insert a simple table via + menu
2. bold
3. clear content
''', (tester) async {
await tester.launchInAnonymousMode();
await tester.createNewDocumentOnMobile('simple table');
final editorState = tester.editor.getCurrentEditorState();
// focus on the editor
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: [0])),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
final firstParagraphPath = [0, 0, 0, 0];
// open the plus menu and select the table block
{
await tester.openPlusMenuAndClickButton(
LocaleKeys.document_slashMenu_name_table.tr(),
);
// check the block is inserted
final table = editorState.getNodeAtPath([0])!;
expect(table.type, equals(SimpleTableBlockKeys.type));
expect(table.rowLength, equals(2));
expect(table.columnLength, equals(2));
// focus on the first cell
final selection = editorState.selection!;
expect(selection.isCollapsed, isTrue);
expect(selection.start.path, equals(firstParagraphPath));
}
await tester.ime.insertText('Hello');
// enable bold
{
// click the column menu button
await tester.clickColumnMenuButton(0);
// enable bold
await tester.clickSimpleTableBoldContentAction();
await tester.cancelTableActionMenu();
// check the first cell is bold
final paragraph = editorState.getNodeAtPath(firstParagraphPath)!;
expect(paragraph.isInBoldColumn, isTrue);
}
// clear content
{
// focus on the first cell
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: firstParagraphPath)),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
// click the column menu button
await tester.clickColumnMenuButton(0);
// clear content
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_simpleTable_moreActions_clearContents
.tr(),
),
);
await tester.cancelTableActionMenu();
// check the first cell is empty
final paragraph = editorState.getNodeAtPath(firstParagraphPath)!;
expect(paragraph.delta!, isEmpty);
}
});
testWidgets('''
1. insert a simple table via + menu
2. insert a heading block in table cell
''', (tester) async {
await tester.launchInAnonymousMode();
await tester.createNewDocumentOnMobile('simple table');
final editorState = tester.editor.getCurrentEditorState();
// focus on the editor
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: [0])),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
final firstParagraphPath = [0, 0, 0, 0];
// open the plus menu and select the table block
{
await tester.openPlusMenuAndClickButton(
LocaleKeys.document_slashMenu_name_table.tr(),
);
// check the block is inserted
final table = editorState.getNodeAtPath([0])!;
expect(table.type, equals(SimpleTableBlockKeys.type));
expect(table.rowLength, equals(2));
expect(table.columnLength, equals(2));
// focus on the first cell
final selection = editorState.selection!;
expect(selection.isCollapsed, isTrue);
expect(selection.start.path, equals(firstParagraphPath));
}
// open the plus menu and select the heading block
{
await tester.openPlusMenuAndClickButton(
LocaleKeys.editor_toggleHeading1ShortForm.tr(),
);
// check the heading block is inserted
final heading = editorState.getNodeAtPath([0, 0, 0, 0])!;
expect(heading.type, equals(HeadingBlockKeys.type));
expect(heading.level, equals(1));
}
});
});
}
@@ -8,11 +8,8 @@ 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/document/presentation/editor_plugins/simple_table/simple_table.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/_simple_table_bottom_sheet_actions.dart';
import 'package:appflowy/plugins/shared/share/share_button.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/text_field/text_filed_with_metric_lines.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/presentation/screens/screens.dart';
@@ -452,8 +449,11 @@ extension CommonOperations on WidgetTester {
// open the page after created
if (openAfterCreated) {
// if the name is null, use empty string
await openPage(pageName ?? '', layout: layout);
await openPage(
// if the name is null, use the default name
pageName ?? LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
layout: layout,
);
await pumpAndSettle();
}
}
@@ -611,7 +611,7 @@ extension CommonOperations on WidgetTester {
required String name,
required String parentName,
required ViewLayoutPB layout,
required EmojiIconData icon,
required String icon,
}) async {
final iconButton = find.descendant(
of: findPageName(
@@ -623,11 +623,7 @@ extension CommonOperations on WidgetTester {
find.byTooltip(LocaleKeys.document_plugins_cover_changeIcon.tr()),
);
await tapButton(iconButton);
if (icon.type == FlowyIconType.emoji) {
await tapEmoji(icon.emoji);
} else if (icon.type == FlowyIconType.icon) {
await tapIcon(icon);
}
await tapEmoji(icon);
await pumpAndSettle();
}
@@ -635,7 +631,7 @@ extension CommonOperations on WidgetTester {
Future<void> updatePageIconInTitleBarByName({
required String name,
required ViewLayoutPB layout,
required EmojiIconData icon,
required String icon,
}) async {
await openPage(
name,
@@ -647,11 +643,7 @@ extension CommonOperations on WidgetTester {
);
await tapButton(title);
await tapButton(find.byType(EmojiPickerButton));
if (icon.type == FlowyIconType.emoji) {
await tapEmoji(icon.emoji);
} else if (icon.type == FlowyIconType.icon) {
await tapIcon(icon);
}
await tapEmoji(icon);
await pumpAndSettle();
}
@@ -849,55 +841,6 @@ extension CommonOperations on WidgetTester {
await tapButton(toggleHeading1);
await pumpUntilNotFound(addMenuItem);
}
/// Click the column menu button in the simple table
Future<void> clickColumnMenuButton(int index) async {
final columnMenuButton = find.byWidgetPredicate(
(w) =>
w is SimpleTableMobileReorderButton &&
w.index == index &&
w.type == SimpleTableMoreActionType.column,
);
await tapButton(columnMenuButton);
await pumpUntilFound(find.byType(SimpleTableCellBottomSheet));
}
/// Click the row menu button in the simple table
Future<void> clickRowMenuButton(int index) async {
final rowMenuButton = find.byWidgetPredicate(
(w) =>
w is SimpleTableMobileReorderButton &&
w.index == index &&
w.type == SimpleTableMoreActionType.row,
);
await tapButton(rowMenuButton);
await pumpUntilFound(find.byType(SimpleTableCellBottomSheet));
}
/// Click the SimpleTableQuickAction
Future<void> clickSimpleTableQuickAction(SimpleTableMoreAction action) async {
final button = find.byWidgetPredicate(
(widget) => widget is SimpleTableQuickAction && widget.type == action,
);
await tapButton(button);
}
/// Click the SimpleTableContentAction
Future<void> clickSimpleTableBoldContentAction() async {
final button = find.byType(SimpleTableContentBoldAction);
await tapButton(button);
}
/// Cancel the table action menu
Future<void> cancelTableActionMenu() async {
final finder = find.byType(SimpleTableCellBottomSheet);
if (finder.evaluate().isEmpty) {
return;
}
await tapAt(Offset.zero);
await pumpUntilNotFound(finder);
}
}
extension SettingsFinder on CommonFinders {
@@ -567,12 +567,6 @@ extension AppFlowyDatabaseTest on WidgetTester {
expect(phantom is PhantomChecklistItem, true);
}
void assertPhantomChecklistItemContent(String content) {
final phantom = find.byType(PhantomChecklistItem);
final text = find.text(content);
expect(find.descendant(of: phantom, matching: text), findsOneWidget);
}
Future<void> openFirstRowDetailPage() async {
await hoverOnFirstRowOfGrid();
@@ -1,12 +1,3 @@
import 'dart:convert';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_color_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart';
import 'package:flowy_svg/flowy_svg.dart';
import 'package:flutter/material.dart';
import 'package:flutter_emoji_mart/flutter_emoji_mart.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -20,46 +11,4 @@ extension EmojiTestExtension on WidgetTester {
);
await tapButton(emojiWidget);
}
Future<void> tapIcon(EmojiIconData icon) async {
final iconsData = IconsData.fromJson(jsonDecode(icon.emoji));
final pickTab = find.byType(PickerTab);
expect(pickTab, findsOneWidget);
await pumpAndSettle();
final iconTab = find.descendant(
of: pickTab,
matching: find.text(PickerTabType.icon.tr),
);
expect(iconTab, findsOneWidget);
expect(find.byType(FlowyIconPicker), findsNothing);
await tap(iconTab);
await pumpAndSettle();
expect(find.byType(FlowyIconPicker), findsOneWidget);
final selectedSvg = find.descendant(
of: find.byType(FlowyIconPicker),
matching: find.byWidgetPredicate(
(w) => w is FlowySvg && w.svgString == iconsData.iconContent,
),
);
expect(find.byType(IconColorPicker), findsNothing);
await tapButton(selectedSvg);
final colorPicker = find.byType(IconColorPicker);
expect(colorPicker, findsOneWidget);
final selectedColor = find.descendant(
of: colorPicker,
matching: find.byWidgetPredicate((w) {
if (w is Container) {
final d = w.decoration;
if (d is ShapeDecoration) {
if (d.color ==
Color(int.parse(iconsData.color ?? builtInSpaceColors.first))) {
return true;
}
}
}
return false;
}),
);
await tapButton(selectedColor);
}
}
@@ -1,5 +1,3 @@
import 'dart:convert';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/page_item/mobile_view_item.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';
@@ -7,8 +5,6 @@ import 'package:appflowy/plugins/database/widgets/row/row_detail.dart';
import 'package:appflowy/plugins/document/presentation/banner.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
@@ -18,7 +14,6 @@ import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_svg/flowy_svg.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:universal_platform/universal_platform.dart';
@@ -122,7 +117,7 @@ extension Expectation on WidgetTester {
return;
}
final iconWidget = find.byWidgetPredicate(
(widget) => widget is EmojiIconWidget && widget.emoji.emoji == emoji,
(widget) => widget is EmojiIconWidget && widget.emoji == emoji,
);
expect(iconWidget, findsOneWidget);
}
@@ -228,52 +223,24 @@ extension Expectation on WidgetTester {
);
}
void expectViewHasIcon(String name, ViewLayoutPB layout, EmojiIconData data) {
void expectViewHasIcon(String name, ViewLayoutPB layout, String emoji) {
final pageName = findPageName(
name,
layout: layout,
);
final type = data.type;
if (type == FlowyIconType.emoji) {
final icon = find.descendant(
of: pageName,
matching: find.text(data.emoji),
);
expect(icon, findsOneWidget);
} else if (type == FlowyIconType.icon) {
final iconsData = IconsData.fromJson(jsonDecode(data.emoji));
final icon = find.descendant(
of: pageName,
matching: find.byWidgetPredicate(
(w) => w is FlowySvg && w.svgString == iconsData.iconContent,
),
);
expect(icon, findsOneWidget);
}
final icon = find.descendant(
of: pageName,
matching: find.text(emoji),
);
expect(icon, findsOneWidget);
}
void expectViewTitleHasIcon(
String name,
ViewLayoutPB layout,
EmojiIconData data,
) {
final type = data.type;
if (type == FlowyIconType.emoji) {
final icon = find.descendant(
of: find.byType(ViewTitleBar),
matching: find.text(data.emoji),
);
expect(icon, findsOneWidget);
} else if (type == FlowyIconType.icon) {
final iconsData = IconsData.fromJson(jsonDecode(data.emoji));
final icon = find.descendant(
of: find.byType(ViewTitleBar),
matching: find.byWidgetPredicate(
(w) => w is FlowySvg && w.svgString == iconsData.iconContent,
),
);
expect(icon, findsOneWidget);
}
void expectViewTitleHasIcon(String name, ViewLayoutPB layout, String emoji) {
final icon = find.descendant(
of: find.byType(ViewTitleBar),
matching: find.text(emoji),
);
expect(icon, findsOneWidget);
}
void expectSelectedReminder(ReminderOption option) {
@@ -88,8 +88,6 @@ PODS:
- Toast (4.0.0)
- url_launcher_ios (0.0.1):
- Flutter
- webview_flutter_wkwebview (0.0.1):
- Flutter
DEPENDENCIES:
- app_links (from `.symlinks/plugins/app_links/ios`)
@@ -114,7 +112,6 @@ DEPENDENCIES:
- sqflite (from `.symlinks/plugins/sqflite/darwin`)
- super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`)
SPEC REPOS:
trunk:
@@ -171,8 +168,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/super_native_extensions/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
webview_flutter_wkwebview:
:path: ".symlinks/plugins/webview_flutter_wkwebview/ios"
SPEC CHECKSUMS:
app_links: e70ca16b4b0f88253b3b3660200d4a10b4ea9795
@@ -204,7 +199,6 @@ SPEC CHECKSUMS:
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
webview_flutter_wkwebview: 2a23822e9039b7b1bc52e5add778e5d89ad488d1
PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca
@@ -50,8 +50,6 @@
<string>AppFlowy needs access to your photos to let you add images to your documents</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>AppFlowy needs access to your camera to let you add images to your documents from camera</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
@@ -67,9 +65,9 @@
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UISupportsDocumentBrowser</key>
<true/>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>UISupportsDocumentBrowser</key>
<true/>
</dict>
</plist>
@@ -8,12 +8,5 @@
<array>
<string>Default</string>
</array>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:appflowy.com</string>
<string>applinks:appflowy.io</string>
<string>applinks:test.appflowy.com</string>
<string>applinks:test.appflowy.io</string>
</array>
</dict>
</plist>
@@ -49,7 +49,6 @@ class KVKeys {
/// {'SidebarFolderCategoryType.value': true}
static const String expandedFolders = 'expandedFolders';
/// @deprecated in version 0.7.6
/// The key for saving if showing the rename dialog when creating a new file
///
/// The value is a boolean string.
@@ -58,7 +57,6 @@ class KVKeys {
static const String kCloudType = 'kCloudType';
static const String kAppflowyCloudBaseURL = 'kAppFlowyCloudBaseURL';
static const String kAppFlowyEnableSyncTrace = 'kAppFlowyEnableSyncTrace';
/// The key for saving the text scale factor.
///
@@ -109,9 +107,4 @@ class KVKeys {
///
/// The value is a boolean string
static const String hasUpgradedSpace = 'hasUpgradedSpace060';
/// The key for saving the recent icons
///
/// The value is a json string of [RecentIcons]
static const String recentIcons = 'kRecentIcons';
}
@@ -1,24 +1,16 @@
import 'dart:io';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/shared/patterns/common_patterns.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:open_filex/open_filex.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy_backend/log.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:string_validator/string_validator.dart';
import 'package:url_launcher/url_launcher.dart' as launcher;
typedef OnFailureCallback = void Function(Uri uri);
/// Launch the uri
///
/// If the uri is a local file path, it will be opened with the OpenFilex.
/// Otherwise, it will be launched with the url_launcher.
Future<bool> afLaunchUri(
Future<bool> afLaunchUrl(
Uri uri, {
BuildContext? context,
OnFailureCallback? onFailure,
@@ -26,18 +18,6 @@ Future<bool> afLaunchUri(
String? webOnlyWindowName,
bool addingHttpSchemeWhenFailed = false,
}) async {
final url = uri.toString();
final decodedUrl = Uri.decodeComponent(url);
// check if the uri is the local file path
if (localPathRegex.hasMatch(decodedUrl)) {
return _afLaunchLocalUri(
uri,
context: context,
onFailure: onFailure,
);
}
// try to launch the uri directly
bool result;
try {
@@ -52,7 +32,7 @@ Future<bool> afLaunchUri(
}
// if the uri is not a valid url, try to launch it with http scheme
final url = uri.toString();
if (addingHttpSchemeWhenFailed &&
!result &&
!isURL(url, {'require_protocol': true})) {
@@ -74,14 +54,9 @@ Future<bool> afLaunchUri(
return result;
}
/// Launch the url string
///
/// See [afLaunchUri] for more details.
Future<bool> afLaunchUrlString(
String url, {
bool addingHttpSchemeWhenFailed = false,
BuildContext? context,
OnFailureCallback? onFailure,
}) async {
final Uri uri;
try {
@@ -92,56 +67,12 @@ Future<bool> afLaunchUrlString(
}
// try to launch the uri directly
return afLaunchUri(
return afLaunchUrl(
uri,
addingHttpSchemeWhenFailed: addingHttpSchemeWhenFailed,
context: context,
onFailure: onFailure,
);
}
/// Launch the local uri
///
/// See [afLaunchUri] for more details.
Future<bool> _afLaunchLocalUri(
Uri uri, {
BuildContext? context,
OnFailureCallback? onFailure,
}) async {
final decodedUrl = Uri.decodeComponent(uri.toString());
// open the file with the OpenfileX
var result = await OpenFilex.open(decodedUrl);
if (result.type != ResultType.done) {
// For the file cant be opened, fallback to open the folder
final parentFolder = Directory(decodedUrl).parent.path;
result = await OpenFilex.open(parentFolder);
}
// show the toast if the file is not found
final message = switch (result.type) {
ResultType.done => LocaleKeys.openFileMessage_success.tr(),
ResultType.fileNotFound => LocaleKeys.openFileMessage_fileNotFound.tr(),
ResultType.noAppToOpen => LocaleKeys.openFileMessage_noAppToOpenFile.tr(),
ResultType.permissionDenied =>
LocaleKeys.openFileMessage_permissionDenied.tr(),
ResultType.error => LocaleKeys.failedToOpenUrl.tr(),
};
if (context != null && context.mounted) {
showToastNotification(
context,
message: message,
type: result.type == ResultType.done
? ToastificationType.success
: ToastificationType.error,
);
}
final openFileSuccess = result.type == ResultType.done;
if (!openFileSuccess && onFailure != null) {
onFailure(uri);
Log.error('Failed to open file: $result.message');
}
return openFileSuccess;
}
void _errorHandler(
Uri uri, {
BuildContext? context,
-3
View File
@@ -39,7 +39,6 @@ class AppFlowyCloudConfiguration {
required this.base_url,
required this.ws_base_url,
required this.gotrue_url,
required this.enable_sync_trace,
});
factory AppFlowyCloudConfiguration.fromJson(Map<String, dynamic> json) =>
@@ -48,7 +47,6 @@ class AppFlowyCloudConfiguration {
final String base_url;
final String ws_base_url;
final String gotrue_url;
final bool enable_sync_trace;
Map<String, dynamic> toJson() => _$AppFlowyCloudConfigurationToJson(this);
@@ -57,7 +55,6 @@ class AppFlowyCloudConfiguration {
base_url: '',
ws_base_url: '',
gotrue_url: '',
enable_sync_trace: false,
);
}
-21
View File
@@ -212,7 +212,6 @@ class AppFlowyCloudSharedEnv {
base_url: Env.afCloudUrl,
ws_base_url: await _getAppFlowyCloudWSUrl(Env.afCloudUrl),
gotrue_url: await _getAppFlowyCloudGotrueUrl(Env.afCloudUrl),
enable_sync_trace: false,
);
return AppFlowyCloudSharedEnv(
@@ -242,14 +241,12 @@ Future<AppFlowyCloudConfiguration> configurationFromUri(
base_url: "$baseUrl:8000",
ws_base_url: "ws://${baseUri.host}:8000/ws/v1",
gotrue_url: "$baseUrl:9999",
enable_sync_trace: true,
);
} else {
return AppFlowyCloudConfiguration(
base_url: baseUrl,
ws_base_url: await _getAppFlowyCloudWSUrl(baseUrl),
gotrue_url: await _getAppFlowyCloudGotrueUrl(baseUrl),
enable_sync_trace: await getSyncLogEnabled(),
);
}
}
@@ -274,24 +271,6 @@ Future<String> getAppFlowyCloudUrl() async {
return result ?? kAppflowyCloudUrl;
}
Future<bool> getSyncLogEnabled() async {
final result =
await getIt<KeyValueStorage>().get(KVKeys.kAppFlowyEnableSyncTrace);
if (result == null) {
return false;
}
return result.toLowerCase() == "true";
}
Future<void> setSyncLogEnabled(bool enable) async {
await getIt<KeyValueStorage>().set(
KVKeys.kAppFlowyEnableSyncTrace,
enable.toString().toLowerCase(),
);
}
Future<String> _getAppFlowyCloudWSUrl(String baseURL) async {
try {
final uri = Uri.parse(baseURL);
@@ -6,6 +6,7 @@ import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:collection/collection.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@@ -25,13 +26,11 @@ class DocumentPageStyleBloc
if (view.id.isEmpty) {
return;
}
Map layoutObject = {};
final data = await ViewBackendService.getView(view.id);
data.onSuccess((s) {
if (s.extra.isNotEmpty) {
layoutObject = jsonDecode(s.extra);
}
});
final layoutObject =
await ViewBackendService.getView(view.id).fold(
(s) => jsonDecode(s.extra),
(f) => {},
);
final fontLayout = _getSelectedFontLayout(layoutObject);
final lineHeightLayout = _getSelectedLineHeightLayout(
layoutObject,
@@ -7,8 +7,6 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../../shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
part 'recent_view_bloc.freezed.dart';
class RecentViewBloc extends Bloc<RecentViewEvent, RecentViewState> {
@@ -41,7 +39,7 @@ class RecentViewBloc extends Bloc<RecentViewEvent, RecentViewState> {
add(
RecentViewEvent.updateNameOrIcon(
view.name,
view.icon.toEmojiIconData(),
view.icon.value,
),
);
@@ -63,7 +61,7 @@ class RecentViewBloc extends Bloc<RecentViewEvent, RecentViewState> {
emit(
state.copyWith(
name: view.name,
icon: view.icon.toEmojiIconData(),
icon: view.icon.value,
),
);
}
@@ -74,7 +72,7 @@ class RecentViewBloc extends Bloc<RecentViewEvent, RecentViewState> {
emit(
state.copyWith(
name: view.name,
icon: view.icon.toEmojiIconData(),
icon: view.icon.value,
coverTypeV2: cover.type,
coverValue: cover.value,
),
@@ -84,7 +82,7 @@ class RecentViewBloc extends Bloc<RecentViewEvent, RecentViewState> {
emit(
state.copyWith(
name: view.name,
icon: view.icon.toEmojiIconData(),
icon: view.icon.value,
coverTypeV1: coverTypeV1,
coverValue: coverValue,
),
@@ -137,17 +135,14 @@ class RecentViewBloc extends Bloc<RecentViewEvent, RecentViewState> {
@freezed
class RecentViewEvent with _$RecentViewEvent {
const factory RecentViewEvent.initial() = Initial;
const factory RecentViewEvent.updateCover(
CoverType coverTypeV1,
// for the version under 0.5.5, including 0.5.5
CoverType coverTypeV1, // for the version under 0.5.5, including 0.5.5
PageStyleCoverImageType? coverTypeV2, // for the version above 0.5.5
String? coverValue,
) = UpdateCover;
const factory RecentViewEvent.updateNameOrIcon(
String name,
EmojiIconData icon,
String icon,
) = UpdateNameOrIcon;
}
@@ -155,12 +150,12 @@ class RecentViewEvent with _$RecentViewEvent {
class RecentViewState with _$RecentViewState {
const factory RecentViewState({
required String name,
required EmojiIconData icon,
required String icon,
@Default(CoverType.none) CoverType coverTypeV1,
PageStyleCoverImageType? coverTypeV2,
@Default(null) String? coverValue,
}) = _RecentViewState;
factory RecentViewState.initial() =>
RecentViewState(name: '', icon: EmojiIconData.none());
const RecentViewState(name: '', icon: '');
}
@@ -7,14 +7,11 @@ import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_state_container.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/plugins/document/presentation/document_collaborators.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
@@ -108,12 +105,6 @@ class _MobileViewPageState extends State<MobileViewPage> {
create: (_) =>
ShareBloc(view: view)..add(const ShareEvent.initial()),
),
if (state.userProfilePB != null)
BlocProvider(
create: (_) =>
UserWorkspaceBloc(userProfile: state.userProfilePB!)
..add(const UserWorkspaceEvent.initial()),
),
if (view.layout.isDocumentView)
BlocProvider(
create: (_) => DocumentPageStyleBloc(view: view)
@@ -264,14 +255,15 @@ class _MobileViewPageState extends State<MobileViewPage> {
}
Widget _buildTitle(BuildContext context, ViewPB? view) {
final icon = view?.icon;
final icon = view?.icon.value;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
EmojiIconWidget(
emoji: icon.toEmojiIconData(),
emojiSize: 15,
if (icon != null && icon.isNotEmpty) ...[
FlowyText.emoji(
icon,
fontSize: 15.0,
figmaLineHeight: 18.0,
),
const HSpace(4),
],
@@ -189,7 +189,7 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
final url = context.read<ShareBloc>().state.url;
if (url.isNotEmpty) {
unawaited(
afLaunchUri(
afLaunchUrl(
Uri.parse(url),
mode: LaunchMode.externalApplication,
),
@@ -98,7 +98,7 @@ class BottomSheetBackButton extends StatelessWidget {
width: 18,
height: 18,
child: FlowySvg(
FlowySvgs.m_bottom_sheet_back_s,
FlowySvgs.m_app_bar_back_s,
),
),
),
@@ -138,6 +138,7 @@ class MobileViewBottomSheetBody extends StatelessWidget {
),
_divider(),
..._buildPublishActions(context),
_divider(),
MobileQuickActionButton(
text: LocaleKeys.button_delete.tr(),
textColor: Theme.of(context).colorScheme.error,
@@ -190,7 +191,6 @@ class MobileViewBottomSheetBody extends StatelessWidget {
MobileViewBottomSheetBodyAction.unpublish,
),
),
_divider(),
];
} else {
return [
@@ -201,7 +201,6 @@ class MobileViewBottomSheetBody extends StatelessWidget {
MobileViewBottomSheetBodyAction.publish,
),
),
_divider(),
];
}
}
@@ -211,21 +211,14 @@ class BottomSheetHeader extends StatelessWidget {
required this.showDoneButton,
this.onRemove,
this.onDone,
this.onBack,
this.onClose,
});
final String title;
final bool showBackButton;
final bool showCloseButton;
final bool showRemoveButton;
final String title;
final bool showDoneButton;
final VoidCallback? onRemove;
final VoidCallback? onBack;
final VoidCallback? onClose;
final void Function(BuildContext context)? onDone;
@override
@@ -237,18 +230,14 @@ class BottomSheetHeader extends StatelessWidget {
child: Stack(
children: [
if (showBackButton)
Align(
const Align(
alignment: Alignment.centerLeft,
child: BottomSheetBackButton(
onTap: onBack,
),
child: BottomSheetBackButton(),
),
if (showCloseButton)
Align(
const Align(
alignment: Alignment.centerLeft,
child: BottomSheetCloseButton(
onTap: onClose,
),
child: BottomSheetCloseButton(),
),
if (showRemoveButton)
Align(
@@ -228,7 +228,7 @@ class _UserIcon extends StatelessWidget {
fontSize: 26,
),
onTap: () async {
final icon = await context.push<EmojiIconData>(
final icon = await context.push<EmojiPickerResult>(
Uri(
path: MobileEmojiPickerScreen.routeName,
queryParameters: {
@@ -111,7 +111,7 @@ class MobileRecentView extends StatelessWidget {
padding: const EdgeInsets.only(left: 8.0),
child: state.icon.isNotEmpty
? EmojiText(
emoji: state.icon.emoji,
emoji: state.icon,
fontSize: 30.0,
)
: SizedBox.square(
@@ -7,7 +7,6 @@ import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.
import 'package:appflowy/mobile/application/recent/recent_view_bloc.dart';
import 'package:appflowy/mobile/presentation/base/animated_gesture.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/shared/appflowy_network_image.dart';
import 'package:appflowy/shared/flowy_gradient_colors.dart';
@@ -27,6 +26,7 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:provider/provider.dart';
import 'package:string_validator/string_validator.dart';
import 'package:time/time.dart';
@@ -118,7 +118,7 @@ class MobileViewPage extends StatelessWidget {
}
Widget _buildNameAndLastViewed(BuildContext context, RecentViewState state) {
final supportAvatar = isURL(state.icon.emoji);
final supportAvatar = isURL(state.icon);
if (!supportAvatar) {
return _buildLastViewed(context);
}
@@ -173,19 +173,21 @@ class MobileViewPage extends StatelessWidget {
Widget _buildTitle(BuildContext context, RecentViewState state) {
final name = state.name;
final icon = state.icon;
final fontFamily = Platform.isAndroid || Platform.isLinux
? GoogleFonts.notoColorEmoji().fontFamily
: null;
return RichText(
maxLines: 3,
overflow: TextOverflow.ellipsis,
text: TextSpan(
children: [
WidgetSpan(
child: SizedBox(
width: 20,
child: EmojiIconWidget(
emoji: icon,
emojiSize: 17.0,
),
),
TextSpan(
text: icon,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 17.0,
fontWeight: FontWeight.w600,
fontFamily: fontFamily,
),
),
if (icon.isNotEmpty) const WidgetSpan(child: HSpace(2.0)),
TextSpan(
@@ -14,7 +14,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:universal_platform/universal_platform.dart';
const _kNotificationIconHeight = 36.0;
@@ -265,11 +264,7 @@ class NotificationDocumentContent extends StatelessWidget {
styleCustomizer: styleCustomizer,
// the editor is not editable in the chat
editable: false,
customHeadingPadding: UniversalPlatform.isDesktop
? EdgeInsets.zero
: EdgeInsets.symmetric(
vertical: EditorStyleCustomizer.nodeHorizontalPadding,
),
customHeadingPadding: EdgeInsets.zero,
);
return IgnorePointer(
@@ -2,8 +2,6 @@ import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/mobile/application/mobile_router.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
@@ -123,7 +121,6 @@ class InnerMobileViewItem extends StatelessWidget {
final bool isDraggable;
final bool isExpanded;
final bool isFirstChild;
// identify if the view item is rendered as feedback widget inside DraggableItem
final bool isFeedback;
@@ -233,7 +230,6 @@ class SingleMobileInnerViewItem extends StatefulWidget {
final ViewPB view;
final ViewPB? parentView;
final bool isExpanded;
// identify if the view item is rendered as feedback widget inside DraggableItem
final bool isFeedback;
@@ -301,9 +297,11 @@ class _SingleMobileInnerViewItemState extends State<SingleMobileInnerViewItem> {
Widget _buildViewIcon() {
final icon = widget.view.icon.value.isNotEmpty
? EmojiIconWidget(
emoji: widget.view.icon.toEmojiIconData(),
emojiSize: Platform.isAndroid ? 16.0 : 18.0,
? FlowyText.emoji(
widget.view.icon.value,
fontSize: Platform.isAndroid ? 16.0 : 18.0,
figmaLineHeight: 20.0,
optimizeEmojiAlign: true,
)
: Opacity(
opacity: 0.7,
@@ -34,7 +34,7 @@ class MobileQuickActionButton extends StatelessWidget {
enable ? null : const WidgetStatePropertyAll(Colors.transparent),
splashColor: Colors.transparent,
child: Container(
height: 44,
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
@@ -5,7 +5,6 @@ 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_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@@ -21,6 +20,18 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
_init();
}
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
@@ -33,6 +44,15 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
on<AIPromptInputEvent>(
(event, emit) {
event.when(
newFile: (String filePath, String fileName) {
final files = [...state.uploadFiles];
final newFile = ChatFile.fromFilePath(filePath);
if (newFile != null) {
files.add(newFile);
emit(state.copyWith(uploadFiles: files));
}
},
updateChatState: (LocalAIChatPB chatState) {
// Only user enable chat with file and the plugin is already running
final supportChatWithFile = chatState.fileEnabled &&
@@ -60,37 +80,19 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
),
);
},
attachFile: (filePath, fileName) {
final newFile = ChatFile.fromFilePath(filePath);
if (newFile != null) {
emit(
state.copyWith(
attachedFiles: [...state.attachedFiles, newFile],
),
);
}
},
removeFile: (file) {
final files = [...state.attachedFiles];
deleteFile: (file) {
final files = List<ChatFile>.from(state.uploadFiles);
files.remove(file);
emit(
state.copyWith(
attachedFiles: files,
uploadFiles: files,
),
);
},
updateMentionedViews: (views) {
clear: () {
emit(
state.copyWith(
mentionedPages: views,
),
);
},
clearMetadata: () {
emit(
state.copyWith(
attachedFiles: [],
mentionedPages: [],
uploadFiles: [],
),
);
},
@@ -124,55 +126,35 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
Log.error,
);
}
Map<String, dynamic> consumeMetadata() {
final metadata = {
for (final file in state.attachedFiles) file.filePath: file,
for (final page in state.mentionedPages) page.id: page,
};
if (metadata.isNotEmpty && !isClosed) {
add(const AIPromptInputEvent.clearMetadata());
}
return metadata;
}
}
@freezed
class AIPromptInputEvent with _$AIPromptInputEvent {
const factory AIPromptInputEvent.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(
LocalAIPluginStatePB chatState,
) = _UpdatePluginState;
const factory AIPromptInputEvent.attachFile(
String filePath,
String fileName,
) = _AttachFile;
const factory AIPromptInputEvent.removeFile(ChatFile file) = _RemoveFile;
const factory AIPromptInputEvent.updateMentionedViews(List<ViewPB> views) =
_UpdateMentionedViews;
const factory AIPromptInputEvent.clearMetadata() = _ClearMetadata;
}
@freezed
class AIPromptInputState with _$AIPromptInputState {
const factory AIPromptInputState({
required AIType aiType,
required bool supportChatWithFile,
required LocalAIChatPB? chatState,
required List<ChatFile> attachedFiles,
required List<ViewPB> mentionedPages,
LocalAIChatPB? chatState,
required List<ChatFile> uploadFiles,
required AIType aiType,
}) = _AIPromptInputState;
factory AIPromptInputState.initial() => const AIPromptInputState(
aiType: AIType.appflowyAI,
supportChatWithFile: false,
chatState: null,
attachedFiles: [],
mentionedPages: [],
uploadFiles: [],
aiType: AIType.appflowyAI,
);
}
@@ -28,10 +28,11 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
if (state.stream != null) {
_startListening();
if (state.stream!.error != null) {
add(ChatAIMessageEvent.receiveError(state.stream!.error!));
}
if (state.stream!.aiLimitReached) {
add(const ChatAIMessageEvent.onAIResponseLimit());
} else if (state.stream!.error != null) {
add(ChatAIMessageEvent.receiveError(state.stream!.error!));
}
}
}
@@ -6,7 +6,6 @@ import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:collection/collection.dart';
import 'package:fixnum/fixnum.dart';
@@ -31,8 +30,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
super(ChatState.initial()) {
_startListening();
_dispatch();
_loadMessages();
_loadSetting();
_init();
}
final String chatId;
@@ -65,9 +63,6 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
Future<void> close() async {
await answerStream?.dispose();
await listener.stop();
final request = ViewIdPB(value: chatId);
unawaited(FolderEventCloseView(request).send());
return super.close();
}
@@ -81,23 +76,10 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
await chatController.insert(message, index: 0);
}
switch (state.loadingState) {
case LoadChatMessageStatus.loading
when chatController.messages.isEmpty:
emit(
state.copyWith(
loadingState: LoadChatMessageStatus.loadingRemote,
),
);
break;
case LoadChatMessageStatus.loading:
case LoadChatMessageStatus.loadingRemote:
emit(
state.copyWith(loadingState: LoadChatMessageStatus.ready),
);
break;
default:
break;
if (state.loadingState.isLoading) {
emit(
state.copyWith(loadingState: const ChatLoadingState.finish()),
);
}
},
loadPreviousMessages: () {
@@ -137,19 +119,15 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
return;
}
final metadata = {
onetimeShotType: OnetimeShotType.relatedQuestion,
'questions': questions,
};
final createdAt = DateTime.now();
final metatdata = OnetimeShotType.relatedQuestion.toMap();
metatdata['questions'] = questions;
final message = TextMessage(
id: "related_question_$createdAt",
text: '',
metadata: metadata,
metadata: metatdata,
author: const User(id: systemUserId),
createdAt: createdAt,
id: systemUserId,
createdAt: DateTime.now(),
);
chatController.insert(message);
@@ -169,7 +147,17 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
) {
numSendMessage += 1;
_clearRelatedQuestions();
final relatedQuestionMessages = chatController.messages.where(
(message) {
return onetimeMessageTypeFromMeta(message.metadata) ==
OnetimeShotType.relatedQuestion;
},
).toList();
for (final message in relatedQuestionMessages) {
chatController.remove(message);
}
_startStreamingMessage(message, metadata);
lastSentMessage = null;
@@ -179,10 +167,11 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
),
);
},
finishSending: () {
finishSending: (ChatMessagePB message) {
lastSentMessage = message;
emit(
state.copyWith(
promptResponseState: PromptResponseState.streamingAnswer,
promptResponseState: PromptResponseState.awaitingAnswer,
),
);
},
@@ -220,6 +209,14 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
answerStream = null;
answerStreamMessageId = '';
},
startAnswerStreaming: (Message message) {
chatController.insert(message);
emit(
state.copyWith(
promptResponseState: PromptResponseState.streamingAnswer,
),
);
},
failedSending: () {
final lastMessage = chatController.messages.lastOrNull;
if (lastMessage != null) {
@@ -231,46 +228,6 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
),
);
},
regenerateAnswer: (id) {
_clearRelatedQuestions();
_regenerateAnswer(id);
lastSentMessage = null;
emit(
state.copyWith(
promptResponseState: PromptResponseState.sendingQuestion,
),
);
},
didReceiveChatSettings: (settings) {
emit(
state.copyWith(
selectedSourceIds: settings.ragIds.ragIds,
onlyUseSelectedSources: settings.ragOnly,
),
);
},
updateSelectedSources: (selectedSourcesIds) async {
emit(state.copyWith(selectedSourceIds: selectedSourcesIds));
final ragIds = RepeatedRagId(ragIds: selectedSourcesIds);
final payload = UpdateChatSettingsPB.create()
..chatId = ChatId(value: chatId)
..ragIds = ragIds;
await AIEventUpdateChatSettings(payload)
.send()
.onFailure(Log.error);
},
setRagOnly: (ragOnly) async {
emit(state.copyWith(onlyUseSelectedSources: ragOnly));
final payload = UpdateChatSettingsPB.create()
..chatId = ChatId(value: chatId)
..ragOnly = ragOnly;
await AIEventUpdateChatSettings(payload)
.send()
.onFailure(Log.error);
},
);
},
);
@@ -356,25 +313,12 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
);
}
void _loadSetting() async {
final getChatSettingsPayload =
AIEventGetChatSettings(ChatId(value: chatId));
await getChatSettingsPayload.send().fold(
(settings) {
if (!isClosed) {
add(ChatEvent.didReceiveChatSettings(settings: settings));
}
},
Log.error,
);
}
void _loadMessages() async {
final loadMessagesPayload = LoadNextChatMessagePB(
void _init() async {
final payload = LoadNextChatMessagePB(
chatId: chatId,
limit: Int64(10),
);
await AIEventLoadNextMessage(loadMessagesPayload).send().fold(
await AIEventLoadNextMessage(payload).send().fold(
(list) {
if (!isClosed) {
final messages = list.messages.map(_createTextMessage).toList();
@@ -434,24 +378,20 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
await AIEventStreamMessage(payload).send().fold(
(question) {
if (!isClosed) {
final streamAnswer = _createAnswerStreamMessage(
answerStream!,
question.messageId,
);
add(ChatEvent.finishSending(question));
lastSentMessage = question;
add(const ChatEvent.finishSending());
add(ChatEvent.receiveMessage(streamAnswer));
final streamAnswer =
_createAnswerStreamMessage(answerStream!, question.messageId);
add(ChatEvent.startAnswerStreaming(streamAnswer));
}
},
(err) {
if (!isClosed) {
Log.error("Failed to send message: ${err.msg}");
final metadata = {
onetimeShotType: OnetimeShotType.error,
if (err.code != ErrorCode.Internal) errorMessageTextKey: err.msg,
};
final metadata = OnetimeShotType.invalidSendMesssage.toMap();
if (err.code != ErrorCode.Internal) {
metadata[sendMessageErrorKey] = err.msg;
}
final error = TextMessage(
text: '',
@@ -468,58 +408,23 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
);
}
void _regenerateAnswer(String answerMessageIdString) async {
final id = temporaryMessageIDMap.entries
.firstWhereOrNull((e) => e.value == answerMessageIdString)
?.key ??
answerMessageIdString;
final answerMessageId = Int64.tryParseInt(id);
if (answerMessageId == null) {
return;
}
await answerStream?.dispose();
answerStream = AnswerStream();
final payload = RegenerateResponsePB(
chatId: chatId,
answerMessageId: answerMessageId,
answerStreamPort: Int64(answerStream!.nativePort),
);
await AIEventRegenerateResponse(payload).send().fold(
(success) {
if (!isClosed) {
final streamAnswer = _createAnswerStreamMessage(
answerStream!,
answerMessageId - 1,
).copyWith(id: answerMessageIdString);
add(ChatEvent.receiveMessage(streamAnswer));
add(const ChatEvent.finishSending());
}
},
(err) => Log.error("Failed to send message: ${err.msg}"),
);
}
Message _createAnswerStreamMessage(
AnswerStream stream,
Int64 questionMessageId,
) {
answerStreamMessageId = (questionMessageId + 1).toString();
final streamMessageId = (questionMessageId + 1).toString();
answerStreamMessageId = streamMessageId;
return TextMessage(
id: answerStreamMessageId,
text: '',
author: User(id: "streamId:${nanoid()}"),
metadata: {
"$AnswerStream": stream,
messageQuestionIdKey: questionMessageId,
"chatId": chatId,
},
id: streamMessageId,
createdAt: DateTime.now(),
text: '',
);
}
@@ -528,16 +433,17 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
Map<String, dynamic>? sentMetadata,
) {
final now = DateTime.now();
questionStreamMessageId = (now.millisecondsSinceEpoch ~/ 1000).toString();
final Map<String, dynamic> metadata = {
"$QuestionStream": stream,
"chatId": chatId,
messageChatFileListKey: chatFilesFromMessageMetadata(sentMetadata),
};
return TextMessage(
author: User(id: userId),
metadata: {
"$QuestionStream": stream,
"chatId": chatId,
messageChatFileListKey: chatFilesFromMessageMetadata(sentMetadata),
},
metadata: metadata,
id: questionStreamMessageId,
createdAt: now,
text: '',
@@ -562,82 +468,51 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
},
);
}
void _clearRelatedQuestions() {
final relatedQuestionMessages = chatController.messages
.where(
(message) =>
onetimeMessageTypeFromMeta(message.metadata) ==
OnetimeShotType.relatedQuestion,
)
.toList();
for (final message in relatedQuestionMessages) {
chatController.remove(message);
}
}
}
@freezed
class ChatEvent with _$ChatEvent {
// chat settings
const factory ChatEvent.didReceiveChatSettings({
required ChatSettingsPB settings,
}) = _DidReceiveChatSettings;
const factory ChatEvent.updateSelectedSources({
required List<String> selectedSourcesIds,
}) = _UpdateSelectedSources;
const factory ChatEvent.setRagOnly({
required bool ragOnly,
}) = _SetRagOnly;
// send message
const factory ChatEvent.sendMessage({
required String message,
Map<String, dynamic>? metadata,
}) = _SendMessage;
const factory ChatEvent.finishSending() = _FinishSendMessage;
const factory ChatEvent.finishSending(ChatMessagePB message) =
_FinishSendMessage;
const factory ChatEvent.failedSending() = _FailSendMessage;
// regenerate
const factory ChatEvent.regenerateAnswer(String id) = _RegenerateAnswer;
// streaming answer
const factory ChatEvent.stopStream() = _StopStream;
// receive message
const factory ChatEvent.startAnswerStreaming(Message message) =
_StartAnswerStreaming;
const factory ChatEvent.receiveMessage(Message message) = _ReceiveMessage;
const factory ChatEvent.didFinishAnswerStream() = _DidFinishAnswerStream;
// receive message
const factory ChatEvent.receiveMessage(Message message) = _ReceiveMessage;
// loading messages
const factory ChatEvent.didLoadLatestMessages(List<Message> messages) =
_DidLoadMessages;
const factory ChatEvent.loadPreviousMessages() = _LoadPreviousMessages;
const factory ChatEvent.didLoadPreviousMessages(
List<Message> messages,
bool hasMore,
) = _DidLoadPreviousMessages;
const factory ChatEvent.didLoadLatestMessages(List<Message> messages) =
_DidLoadMessages;
// related questions
const factory ChatEvent.didReceiveRelatedQuestions(
List<String> questions,
) = _DidReceiveRelatedQueston;
const factory ChatEvent.stopStream() = _StopStream;
}
@freezed
class ChatState with _$ChatState {
const factory ChatState({
required bool onlyUseSelectedSources,
required List<String> selectedSourceIds,
required LoadChatMessageStatus loadingState,
required ChatLoadingState loadingState,
required PromptResponseState promptResponseState,
}) = _ChatState;
factory ChatState.initial() => const ChatState(
onlyUseSelectedSources: false,
selectedSourceIds: [],
loadingState: LoadChatMessageStatus.loading,
loadingState: ChatLoadingState.loading(),
promptResponseState: PromptResponseState.ready,
);
}
@@ -1,96 +0,0 @@
import 'dart:async';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/shared/markdown_to_document.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
class ChatEditDocumentService {
static Future<ViewPB?> saveMessagesToNewPage(
String chatPageName,
String parentViewId,
List<TextMessage> messages,
) async {
if (messages.isEmpty) {
return null;
}
// Convert messages to markdown and trim the last empty newline.
final completeMessage = messages.map((m) => m.text).join('\n').trimRight();
if (completeMessage.isEmpty) {
return null;
}
final document = customMarkdownToDocument(completeMessage);
final initialBytes =
DocumentDataPBFromTo.fromDocument(document)?.writeToBuffer();
if (initialBytes == null) {
Log.error('Failed to convert messages to document');
return null;
}
return ViewBackendService.createView(
name: LocaleKeys.chat_addToNewPageName.tr(args: [chatPageName]),
layoutType: ViewLayoutPB.Document,
parentViewId: parentViewId,
initialDataBytes: initialBytes,
).toNullable();
}
static Future<void> addMessageToPage(
String documentId,
TextMessage message,
) async {
if (message.text.isEmpty) {
Log.error('Message is empty');
return;
}
final bloc = DocumentBloc(
documentId: documentId,
saveToBlocMap: false,
)..add(const DocumentEvent.initial());
if (bloc.state.editorState == null) {
await bloc.stream.firstWhere((state) => state.editorState != null);
}
final editorState = bloc.state.editorState;
if (editorState == null) {
Log.error("Can't get EditorState of document");
return;
}
final messageDocument = customMarkdownToDocument(message.text);
if (messageDocument.isEmpty) {
Log.error('Failed to convert message to document');
return;
}
final lastNodeOrNull = editorState.document.root.children.lastOrNull;
final rootIsEmpty = lastNodeOrNull == null;
final isLastLineEmpty = lastNodeOrNull?.children.isNotEmpty == false &&
lastNodeOrNull?.delta?.isNotEmpty == false;
final nodes = [
if (rootIsEmpty || !isLastLineEmpty) paragraphNode(),
...messageDocument.root.children,
];
final insertPath = rootIsEmpty || listEquals(lastNodeOrNull.path, const [0])
? const [0]
: lastNodeOrNull.path.next;
final transaction = editorState.transaction..insertNodes(insertPath, nodes);
await editorState.apply(transaction);
await bloc.close();
}
}
@@ -1,8 +1,8 @@
import 'dart:io';
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:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:equatable/equatable.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:path/path.dart' as path;
@@ -10,7 +10,7 @@ import 'package:path/path.dart' as path;
part 'chat_entity.g.dart';
part 'chat_entity.freezed.dart';
const errorMessageTextKey = "errorMessageText";
const sendMessageErrorKey = "sendMessageError";
const systemUserId = "system";
const aiResponseUserId = "0";
@@ -57,6 +57,7 @@ class AIChatProgress {
enum PromptResponseState {
ready,
sendingQuestion,
awaitingAnswer,
streamingAnswer,
}
@@ -76,19 +77,19 @@ class ChatFile extends Equatable {
final fileName = path.basename(filePath);
final extension = path.extension(filePath).toLowerCase();
ContextLoaderTypePB fileType;
ChatMessageMetaTypePB fileType;
switch (extension) {
case '.pdf':
fileType = ContextLoaderTypePB.PDF;
fileType = ChatMessageMetaTypePB.PDF;
break;
case '.txt':
fileType = ContextLoaderTypePB.Txt;
fileType = ChatMessageMetaTypePB.Txt;
break;
case '.md':
fileType = ContextLoaderTypePB.Markdown;
fileType = ChatMessageMetaTypePB.Markdown;
break;
default:
fileType = ContextLoaderTypePB.UnknownLoaderType;
fileType = ChatMessageMetaTypePB.UnknownMetaType;
}
return ChatFile(
@@ -100,14 +101,13 @@ class ChatFile extends Equatable {
final String filePath;
final String fileName;
final ContextLoaderTypePB fileType;
final ChatMessageMetaTypePB fileType;
@override
List<Object?> get props => [filePath];
}
typedef ChatFileMap = Map<String, ChatFile>;
typedef ChatMentionedPageMap = Map<String, ViewPB>;
typedef ChatInputFileMetadata = Map<String, ChatFile>;
@freezed
class ChatLoadingState with _$ChatLoadingState {
@@ -121,19 +121,45 @@ extension ChatLoadingStateExtension on ChatLoadingState {
}
enum OnetimeShotType {
unknown,
sendingMessage,
relatedQuestion,
error,
invalidSendMesssage,
}
const onetimeShotType = "OnetimeShotType";
OnetimeShotType? onetimeMessageTypeFromMeta(Map<String, dynamic>? metadata) {
return metadata?[onetimeShotType];
extension OnetimeMessageTypeExtension on OnetimeShotType {
static OnetimeShotType fromString(String value) {
switch (value) {
case 'OnetimeShotType.sendingMessage':
return OnetimeShotType.sendingMessage;
case 'OnetimeShotType.relatedQuestion':
return OnetimeShotType.relatedQuestion;
case 'OnetimeShotType.invalidSendMesssage':
return OnetimeShotType.invalidSendMesssage;
default:
Log.error('Unknown OnetimeShotType: $value');
return OnetimeShotType.unknown;
}
}
Map<String, dynamic> toMap() {
return {
onetimeShotType: toString(),
};
}
}
enum LoadChatMessageStatus {
loading,
loadingRemote,
ready,
OnetimeShotType? onetimeMessageTypeFromMeta(Map<String, dynamic>? metadata) {
if (metadata == null) {
return null;
}
for (final entry in metadata.entries) {
if (entry.key == onetimeShotType) {
return OnetimeMessageTypeExtension.fromString(entry.value as String);
}
}
return null;
}
@@ -0,0 +1,217 @@
import 'dart:async';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'chat_input_action_control.dart';
part 'chat_input_action_bloc.freezed.dart';
class ChatInputActionBloc
extends Bloc<ChatInputActionEvent, ChatInputActionState> {
ChatInputActionBloc({required this.chatId})
: super(const ChatInputActionState()) {
on<ChatInputActionEvent>(_handleEvent);
}
final String chatId;
Future<void> _handleEvent(
ChatInputActionEvent event,
Emitter<ChatInputActionState> emit,
) async {
await event.when(
started: () async {
unawaited(
ViewBackendService.getAllViews().then(
(result) {
final views = result
.toNullable()
?.items
.where(
(v) =>
v.layout.isDocumentView &&
!v.isSpace &&
v.parentViewId.isNotEmpty,
)
.toList() ??
[];
if (!isClosed) {
add(ChatInputActionEvent.refreshViews(views));
}
},
),
);
},
refreshViews: (List<ViewPB> views) {
final List<ViewActionPage> pages = _filterPages(
views,
state.selectedPages,
state.filter,
);
emit(
state.copyWith(
views: views,
pages: pages,
indicator: const ChatActionMenuIndicator.ready(),
),
);
},
filter: (String filter) {
Log.debug("Filter chat input pages: $filter");
final List<ViewActionPage> pages = _filterPages(
state.views,
state.selectedPages,
filter,
);
emit(state.copyWith(pages: pages, filter: filter));
},
handleKeyEvent: (PhysicalKeyboardKey physicalKey) {
emit(
state.copyWith(
keyboardKey: ChatInputKeyboardEvent(physicalKey: physicalKey),
),
);
},
addPage: (ChatInputMention page) {
if (!state.selectedPages.any((p) => p.pageId == page.pageId)) {
final List<ViewActionPage> pages = _filterPages(
state.views,
state.selectedPages,
state.filter,
);
emit(
state.copyWith(
pages: pages,
selectedPages: [...state.selectedPages, page],
),
);
}
},
removePage: (String text) {
final List<ChatInputMention> selectedPages =
List.from(state.selectedPages);
selectedPages.retainWhere((t) => !text.contains(t.title));
final List<ViewActionPage> allPages = _filterPages(
state.views,
state.selectedPages,
state.filter,
);
emit(
state.copyWith(
selectedPages: selectedPages,
pages: allPages,
),
);
},
clear: () {
emit(
state.copyWith(
selectedPages: [],
filter: "",
),
);
},
);
}
}
List<ViewActionPage> _filterPages(
List<ViewPB> views,
List<ChatInputMention> selectedPages,
String filter,
) {
final pages = views
.map(
(v) => ViewActionPage(view: v),
)
.toList();
pages.retainWhere((page) {
return !selectedPages.contains(page);
});
if (filter.isEmpty) {
return pages;
}
return pages
.where(
(v) => v.title.toLowerCase().contains(filter.toLowerCase()),
)
.toList();
}
class ViewActionPage extends ChatInputMention {
ViewActionPage({required this.view});
final ViewPB view;
@override
String get pageId => view.id;
@override
String get title => view.name;
@override
List<Object?> get props => [pageId];
@override
dynamic get page => view;
@override
Widget get icon => view.defaultIcon();
}
@freezed
class ChatInputActionEvent with _$ChatInputActionEvent {
const factory ChatInputActionEvent.started() = _Started;
const factory ChatInputActionEvent.refreshViews(List<ViewPB> views) =
_RefreshViews;
const factory ChatInputActionEvent.filter(String filter) = _Filter;
const factory ChatInputActionEvent.handleKeyEvent(
PhysicalKeyboardKey keyboardKey,
) = _HandleKeyEvent;
const factory ChatInputActionEvent.addPage(ChatInputMention page) = _AddPage;
const factory ChatInputActionEvent.removePage(String text) = _RemovePage;
const factory ChatInputActionEvent.clear() = _Clear;
}
@freezed
class ChatInputActionState with _$ChatInputActionState {
const factory ChatInputActionState({
@Default([]) List<ViewPB> views,
@Default([]) List<ChatInputMention> pages,
@Default([]) List<ChatInputMention> selectedPages,
@Default("") String filter,
ChatInputKeyboardEvent? keyboardKey,
@Default(ChatActionMenuIndicator.loading())
ChatActionMenuIndicator indicator,
}) = _ChatInputActionState;
}
class ChatInputKeyboardEvent extends Equatable {
ChatInputKeyboardEvent({required this.physicalKey});
final PhysicalKeyboardKey physicalKey;
final int timestamp = DateTime.now().millisecondsSinceEpoch;
@override
List<Object?> get props => [timestamp];
}
@freezed
class ChatActionMenuIndicator with _$ChatActionMenuIndicator {
const factory ChatActionMenuIndicator.ready() = _Ready;
const factory ChatActionMenuIndicator.loading() = _Loading;
}
@@ -0,0 +1,172 @@
import 'package:appflowy/plugins/ai_chat/application/chat_input_action_bloc.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_input_action_menu.dart';
import 'package:appflowy_backend/log.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
abstract class ChatInputMention extends Equatable {
String get title;
String get pageId;
dynamic get page;
Widget get icon;
}
/// Key: the key is the pageId
typedef ChatInputMentionMetadata = Map<String, ChatInputMention>;
class ChatInputActionControl extends ChatActionHandler {
ChatInputActionControl({
required this.textController,
required this.textFieldFocusNode,
required this.chatId,
}) : _commandBloc = ChatInputActionBloc(chatId: chatId);
final TextEditingController textController;
final ChatInputActionBloc _commandBloc;
final FocusNode textFieldFocusNode;
final String chatId;
// Private attributes
String _atText = "";
String _prevText = "";
String _showMenuText = "";
// Getter
List<String> get tags =>
_commandBloc.state.selectedPages.map((e) => e.title).toList();
ChatInputMentionMetadata consumeMetaData() {
final metadata = _commandBloc.state.selectedPages.fold(
<String, ChatInputMention>{},
(map, page) => map..putIfAbsent(page.pageId, () => page),
);
if (metadata.isNotEmpty) {
_commandBloc.add(const ChatInputActionEvent.clear());
}
return metadata;
}
void handleKeyEvent(KeyEvent event) {
// ignore: deprecated_member_use
if (event is KeyDownEvent || event is RawKeyDownEvent) {
_commandBloc.add(ChatInputActionEvent.handleKeyEvent(event.physicalKey));
}
}
bool canHandleKeyEvent(KeyEvent event) {
return _showMenuText.isNotEmpty &&
<PhysicalKeyboardKey>{
PhysicalKeyboardKey.arrowDown,
PhysicalKeyboardKey.arrowUp,
PhysicalKeyboardKey.enter,
PhysicalKeyboardKey.escape,
}.contains(event.physicalKey);
}
void dispose() {
_commandBloc.close();
}
@override
void onSelected(ChatInputMention page) {
_commandBloc.add(ChatInputActionEvent.addPage(page));
textController.text = "$_showMenuText${page.title}";
onExit();
}
@override
void onExit() {
_atText = "";
_showMenuText = "";
_prevText = "";
_commandBloc.add(const ChatInputActionEvent.filter(""));
}
@override
void onEnter() {
_commandBloc.add(const ChatInputActionEvent.started());
_showMenuText = textController.text;
}
@override
double actionMenuOffsetX() {
final TextPosition textPosition = textController.selection.extent;
if (textFieldFocusNode.context == null) {
return 0;
}
final RenderBox renderBox =
textFieldFocusNode.context?.findRenderObject() as RenderBox;
final TextPainter textPainter = TextPainter(
text: TextSpan(text: textController.text),
textDirection: TextDirection.ltr,
);
textPainter.layout(
minWidth: renderBox.size.width,
maxWidth: renderBox.size.width,
);
final Offset caretOffset =
textPainter.getOffsetForCaret(textPosition, Rect.zero);
final List<TextBox> boxes = textPainter.getBoxesForSelection(
TextSelection(
baseOffset: textPosition.offset,
extentOffset: textPosition.offset,
),
);
if (boxes.isNotEmpty) {
return boxes.last.right;
}
return caretOffset.dx;
}
bool onTextChanged(String text) {
final String inputText = text;
if (_prevText.length > inputText.length) {
final deleteStartIndex = textController.selection.baseOffset;
final deleteEndIndex =
_prevText.length - inputText.length + deleteStartIndex;
final deletedText = _prevText.substring(deleteStartIndex, deleteEndIndex);
_commandBloc.add(ChatInputActionEvent.removePage(deletedText));
}
// If the action menu is shown, filter the views
if (_showMenuText.isNotEmpty) {
if (text.length >= _showMenuText.length) {
final filterText = inputText.substring(_showMenuText.length);
_commandBloc.add(ChatInputActionEvent.filter(filterText));
}
// If the text change from "xxx @"" to "xxx", which means user delete the @, we should hide the action menu
if (_atText.isNotEmpty && !inputText.contains(_atText)) {
_commandBloc.add(
const ChatInputActionEvent.handleKeyEvent(PhysicalKeyboardKey.escape),
);
}
} else {
final isTypingNewAt =
text.endsWith("@") && _prevText.length < text.length;
if (isTypingNewAt) {
_atText = text;
_prevText = text;
return true;
}
}
_prevText = text;
return false;
}
@override
void onFilter(String filter) {
Log.info("filter: $filter");
}
@override
ChatInputActionBloc get commandBloc => _commandBloc;
}
@@ -1,246 +0,0 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.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:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_input_control_cubit.freezed.dart';
class ChatInputControlCubit extends Cubit<ChatInputControlState> {
ChatInputControlCubit() : super(const ChatInputControlState.loading());
final List<ViewPB> allViews = [];
final List<String> selectedViewIds = [];
/// used when mentioning a page
///
/// the text position after the @ character
int _filterStartPosition = -1;
/// used when mentioning a page
///
/// the text position after the @ character, at the end of the filter
int _filterEndPosition = -1;
/// used when mentioning a page
///
/// the entire string input in the prompt
String _inputText = "";
/// used when mentioning a page
///
/// the current filtering text, after the @ characater
String _filter = "";
String get inputText => _inputText;
int get filterStartPosition => _filterStartPosition;
int get filterEndPosition => _filterEndPosition;
void refreshViews() async {
final newViews = await ViewBackendService.getAllViews().fold(
(result) {
return result.items
.where(
(v) =>
!v.isSpace &&
v.layout.isDocumentView &&
v.parentViewId != v.id,
)
.toList();
},
(err) {
Log.error(err);
return <ViewPB>[];
},
);
allViews
..clear()
..addAll(newViews);
// update visible views
newViews.retainWhere((v) => !selectedViewIds.contains(v.id));
if (_filter.isNotEmpty) {
newViews.retainWhere(
(v) {
final nonEmptyName = v.name.isEmpty
? LocaleKeys.document_title_placeholder.tr()
: v.name;
return nonEmptyName.toLowerCase().contains(_filter);
},
);
}
final focusedViewIndex = newViews.isEmpty ? -1 : 0;
emit(
ChatInputControlState.ready(
visibleViews: newViews,
focusedViewIndex: focusedViewIndex,
),
);
}
void startSearching(TextEditingValue textEditingValue) {
_filterStartPosition =
_filterEndPosition = textEditingValue.selection.baseOffset;
_filter = "";
_inputText = textEditingValue.text;
state.maybeMap(
ready: (readyState) {
emit(
readyState.copyWith(
visibleViews: allViews,
focusedViewIndex: allViews.isEmpty ? -1 : 0,
),
);
},
orElse: () {},
);
}
void reset() {
_filterStartPosition = _filterEndPosition = -1;
_filter = _inputText = "";
state.maybeMap(
ready: (readyState) {
emit(
readyState.copyWith(
visibleViews: allViews,
focusedViewIndex: allViews.isEmpty ? -1 : 0,
),
);
},
orElse: () {},
);
}
void updateFilter(
String newInputText,
String newFilter, {
int? newEndPosition,
}) {
updateInputText(newInputText);
// filter the views
_filter = newFilter.toLowerCase();
if (newEndPosition != null) {
_filterEndPosition = newEndPosition;
}
final newVisibleViews =
allViews.where((v) => !selectedViewIds.contains(v.id)).toList();
if (_filter.isNotEmpty) {
newVisibleViews.retainWhere(
(v) {
final nonEmptyName = v.name.isEmpty
? LocaleKeys.document_title_placeholder.tr()
: v.name;
return nonEmptyName.toLowerCase().contains(_filter);
},
);
}
state.maybeWhen(
ready: (_, oldFocusedIndex) {
final newFocusedViewIndex = oldFocusedIndex < newVisibleViews.length
? oldFocusedIndex
: (newVisibleViews.isEmpty ? -1 : 0);
emit(
ChatInputControlState.ready(
visibleViews: newVisibleViews,
focusedViewIndex: newFocusedViewIndex,
),
);
},
orElse: () {},
);
}
void updateInputText(String newInputText) {
_inputText = newInputText;
// input text is changed, see if there are any deletions
selectedViewIds.retainWhere(_inputText.contains);
_notifyUpdateSelectedViews();
}
void updateSelectionUp() {
state.maybeMap(
ready: (readyState) {
final newIndex = readyState.visibleViews.isEmpty
? -1
: (readyState.focusedViewIndex - 1) %
readyState.visibleViews.length;
emit(
readyState.copyWith(focusedViewIndex: newIndex),
);
},
orElse: () {},
);
}
void updateSelectionDown() {
state.maybeMap(
ready: (readyState) {
final newIndex = readyState.visibleViews.isEmpty
? -1
: (readyState.focusedViewIndex + 1) %
readyState.visibleViews.length;
emit(
readyState.copyWith(focusedViewIndex: newIndex),
);
},
orElse: () {},
);
}
void selectPage(ViewPB view) {
selectedViewIds.add(view.id);
_notifyUpdateSelectedViews();
reset();
}
String formatIntputText(final String input) {
String result = input;
for (final viewId in selectedViewIds) {
if (!result.contains(viewId)) {
continue;
}
final view = allViews.firstWhereOrNull((view) => view.id == viewId);
if (view != null) {
final nonEmptyName = view.name.isEmpty
? LocaleKeys.document_title_placeholder.tr()
: view.name;
result = result.replaceAll(RegExp(viewId), nonEmptyName);
}
}
return result;
}
void _notifyUpdateSelectedViews() {
final stateCopy = state;
final selectedViews =
allViews.where((view) => selectedViewIds.contains(view.id)).toList();
emit(ChatInputControlState.updateSelectedViews(selectedViews));
emit(stateCopy);
}
}
@freezed
class ChatInputControlState with _$ChatInputControlState {
const factory ChatInputControlState.loading() = _Loading;
const factory ChatInputControlState.ready({
required List<ViewPB> visibleViews,
required int focusedViewIndex,
}) = _Ready;
const factory ChatInputControlState.updateSelectedViews(
List<ViewPB> selectedViews,
) = _UpdateOneShot;
}
@@ -1,12 +1,12 @@
import 'dart:convert';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_action_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:nanoid/nanoid.dart';
@@ -129,16 +129,16 @@ Future<List<ChatMessageMetaPB>> metadataPBFromMetadata(
for (final value in map.values) {
switch (value) {
case ViewPB _ when value.layout.isDocumentView:
final payload = OpenDocumentPayloadPB(documentId: value.id);
case ViewActionPage(view: final view) when view.layout.isDocumentView:
final payload = OpenDocumentPayloadPB(documentId: view.id);
await DocumentEventGetDocumentText(payload).send().fold(
(pb) {
metadata.add(
ChatMessageMetaPB(
id: value.id,
name: value.name,
id: view.id,
name: view.name,
data: pb.text,
loaderType: ContextLoaderTypePB.Txt,
dataType: ChatMessageMetaTypePB.Txt,
source: appflowySource,
),
);
@@ -156,7 +156,7 @@ Future<List<ChatMessageMetaPB>> metadataPBFromMetadata(
id: nanoid(8),
name: fileName,
data: filePath,
loaderType: fileType,
dataType: fileType,
source: filePath,
),
);
@@ -13,10 +13,14 @@ class AnswerStream {
_hasStarted = true;
final newText = event.substring(5);
_text += newText;
_onData?.call(_text);
if (_onData != null) {
_onData!(_text);
}
} else if (event.startsWith("error:")) {
_error = event.substring(5);
_onError?.call(_error!);
if (_onError != null) {
_onError!(_error!);
}
} else if (event.startsWith("metadata:")) {
if (_onMetadata != null) {
final s = event.substring(9);
@@ -24,14 +28,20 @@ class AnswerStream {
}
} else if (event == "AI_RESPONSE_LIMIT") {
_aiLimitReached = true;
_onAIResponseLimit?.call();
if (_onAIResponseLimit != null) {
_onAIResponseLimit!();
}
}
},
onDone: () {
_onEnd?.call();
if (_onEnd != null) {
_onEnd!();
}
},
onError: (error) {
_onError?.call(error.toString());
if (_onError != null) {
_onError!(error.toString());
}
},
);
}
@@ -54,8 +64,8 @@ class AnswerStream {
int get nativePort => _port.sendPort.nativePort;
bool get hasStarted => _hasStarted;
bool get aiLimitReached => _aiLimitReached;
String? get error => _error;
bool get aiLimitReached => _aiLimitReached;
String get text => _text;
Future<void> dispose() async {
@@ -79,7 +89,9 @@ class AnswerStream {
_onAIResponseLimit = onAIResponseLimit;
_onMetadata = onMetadata;
_onStart?.call();
if (_onStart != null) {
_onStart!();
}
}
}
@@ -1,399 +0,0 @@
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:bloc/bloc.dart';
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_select_sources_cubit.freezed.dart';
const int _kMaxSelectedParentPageCount = 3;
enum SourceSelectedStatus {
unselected,
selected,
partiallySelected;
bool get isUnselected => this == unselected;
bool get isSelected => this == selected;
bool get isPartiallySelected => this == partiallySelected;
}
class ChatSource {
ChatSource({
required this.view,
required this.parentView,
required this.children,
required bool isExpanded,
required SourceSelectedStatus selectedStatus,
required IgnoreViewType ignoreStatus,
}) : isExpandedNotifier = ValueNotifier(isExpanded),
selectedStatusNotifier = ValueNotifier(selectedStatus),
ignoreStatusNotifier = ValueNotifier(ignoreStatus);
final ViewPB view;
final ViewPB? parentView;
final List<ChatSource> children;
final ValueNotifier<bool> isExpandedNotifier;
final ValueNotifier<SourceSelectedStatus> selectedStatusNotifier;
final ValueNotifier<IgnoreViewType> ignoreStatusNotifier;
bool get isExpanded => isExpandedNotifier.value;
SourceSelectedStatus get selectedStatus => selectedStatusNotifier.value;
IgnoreViewType get ignoreStatus => ignoreStatusNotifier.value;
void toggleIsExpanded() {
isExpandedNotifier.value = !isExpanded;
}
ChatSource copy() {
return ChatSource(
view: view,
parentView: parentView,
children: children.map<ChatSource>((child) => child.copy()).toList(),
ignoreStatus: ignoreStatus,
isExpanded: isExpanded,
selectedStatus: selectedStatus,
);
}
ChatSource? findChildBySourceId(String sourceId) {
if (view.id == sourceId) {
return this;
}
for (final child in children) {
final childResult = child.findChildBySourceId(sourceId);
if (childResult != null) {
return childResult;
}
}
return null;
}
void resetIgnoreViewTypeRecursive() {
ignoreStatusNotifier.value = view.layout.isDocumentView
? IgnoreViewType.none
: IgnoreViewType.disable;
for (final child in children) {
child.resetIgnoreViewTypeRecursive();
}
}
void updateIgnoreViewTypeRecursive(IgnoreViewType newIgnoreViewType) {
ignoreStatusNotifier.value = newIgnoreViewType;
for (final child in children) {
child.updateIgnoreViewTypeRecursive(newIgnoreViewType);
}
}
void dispose() {
for (final child in children) {
child.dispose();
}
isExpandedNotifier.dispose();
selectedStatusNotifier.dispose();
ignoreStatusNotifier.dispose();
}
}
class ChatSettingsCubit extends Cubit<ChatSettingsState> {
ChatSettingsCubit() : super(ChatSettingsState.initial());
List<String> selectedSourceIds = [];
ChatSource? source;
List<ChatSource> selectedSources = [];
String filter = '';
void updateOnlyUseSelectedSources(bool onlyUseSelectedSources) {
if (state.onlyUseSelectedSources != onlyUseSelectedSources) {
emit(state.copyWith(onlyUseSelectedSources: onlyUseSelectedSources));
}
}
void updateSelectedSources(List<String> newSelectedSourceIds) {
selectedSourceIds = [...newSelectedSourceIds];
}
void refreshSources(ViewPB view) async {
filter = "";
final newSource = await _recursiveBuild(view, null);
_restrictSelectionIfNecessary(newSource.children);
final selected = _buildSelectedSources(newSource).toList();
newSource.toggleIsExpanded();
emit(
state.copyWith(
selectedSources: selected,
visibleSources: [newSource],
),
);
source?.dispose();
source = newSource.copy();
selectedSources
..forEach((e) => e.dispose())
..clear()
..addAll(selected.map((e) => e.copy()));
}
Future<ChatSource> _recursiveBuild(ViewPB view, ViewPB? parentView) async {
SourceSelectedStatus selectedStatus = SourceSelectedStatus.unselected;
final isThisSourceSelected = selectedSourceIds.contains(view.id);
final childrenViews =
await ViewBackendService.getChildViews(viewId: view.id).toNullable();
int selectedCount = 0;
final children = <ChatSource>[];
if (childrenViews != null) {
for (final childView in childrenViews) {
if (childView.layout == ViewLayoutPB.Chat) {
continue;
}
final childChatSource = await _recursiveBuild(childView, view);
if (childChatSource.selectedStatus.isSelected) {
selectedCount++;
}
children.add(childChatSource);
}
final areAllChildrenSelectedOrNoChildren =
children.length == selectedCount;
final isAnyChildNotUnselected =
children.any((e) => !e.selectedStatus.isUnselected);
if (isThisSourceSelected && areAllChildrenSelectedOrNoChildren) {
selectedStatus = SourceSelectedStatus.selected;
} else if (isThisSourceSelected || isAnyChildNotUnselected) {
selectedStatus = SourceSelectedStatus.partiallySelected;
}
} else if (isThisSourceSelected) {
selectedStatus = SourceSelectedStatus.selected;
}
return ChatSource(
view: view,
parentView: parentView,
children: children,
ignoreStatus: view.layout.isDocumentView
? IgnoreViewType.none
: IgnoreViewType.disable,
isExpanded: false,
selectedStatus: selectedStatus,
);
}
void _restrictSelectionIfNecessary(List<ChatSource> sources) {
for (final source in sources) {
source.resetIgnoreViewTypeRecursive();
}
if (sources.where((e) => !e.selectedStatus.isUnselected).length >=
_kMaxSelectedParentPageCount) {
sources
.where((e) => e.selectedStatus == SourceSelectedStatus.unselected)
.forEach(
(e) => e.updateIgnoreViewTypeRecursive(IgnoreViewType.disable),
);
}
}
void updateFilter(String filter) {
this.filter = filter;
for (final source in state.visibleSources) {
source.dispose();
}
if (source == null) {
emit(ChatSettingsState.initial());
} else {
final selected =
selectedSources.map(_buildSearchResults).nonNulls.toList();
final visible = [_buildSearchResults(source!)].nonNulls.toList();
emit(
state.copyWith(
selectedSources: selected,
visibleSources: visible,
),
);
}
}
/// traverse tree to build up search query
ChatSource? _buildSearchResults(ChatSource chatSource) {
final isVisible = chatSource.view.nameOrDefault
.toLowerCase()
.contains(filter.toLowerCase());
final childrenResults = <ChatSource>[];
for (final childSource in chatSource.children) {
final childResult = _buildSearchResults(childSource);
if (childResult != null) {
childrenResults.add(childResult);
}
}
return isVisible || childrenResults.isNotEmpty
? ChatSource(
view: chatSource.view,
parentView: chatSource.parentView,
children: childrenResults,
ignoreStatus: chatSource.ignoreStatus,
isExpanded: chatSource.isExpanded,
selectedStatus: chatSource.selectedStatus,
)
: null;
}
/// traverse tree to build up selected sources
Iterable<ChatSource> _buildSelectedSources(ChatSource chatSource) {
final children = <ChatSource>[];
for (final childSource in chatSource.children) {
children.addAll(_buildSelectedSources(childSource));
}
return selectedSourceIds.contains(chatSource.view.id)
? [
ChatSource(
view: chatSource.view,
parentView: chatSource.parentView,
children: children,
ignoreStatus: chatSource.ignoreStatus,
selectedStatus: chatSource.selectedStatus,
isExpanded: true,
),
]
: children;
}
void toggleSelectedStatus(ChatSource chatSource) {
if (chatSource.view.isSpace) {
return;
}
final allIds = _recursiveGetSourceIds(chatSource);
if (chatSource.selectedStatus.isUnselected ||
chatSource.selectedStatus.isPartiallySelected &&
!chatSource.view.layout.isDocumentView) {
for (final id in allIds) {
if (!selectedSourceIds.contains(id)) {
selectedSourceIds.add(id);
}
}
} else {
for (final id in allIds) {
if (selectedSourceIds.contains(id)) {
selectedSourceIds.remove(id);
}
}
}
updateSelectedStatus();
}
List<String> _recursiveGetSourceIds(ChatSource chatSource) {
return [
if (chatSource.view.layout.isDocumentView) chatSource.view.id,
for (final childSource in chatSource.children)
..._recursiveGetSourceIds(childSource),
];
}
void updateSelectedStatus() {
if (source == null) {
return;
}
_recursiveUpdateSelectedStatus(source!);
_restrictSelectionIfNecessary(source!.children);
for (final visibleSource in state.visibleSources) {
visibleSource.dispose();
}
final visible = [_buildSearchResults(source!)].nonNulls.toList();
selectedSources
..forEach((e) => e.dispose())
..clear()
..addAll(_buildSelectedSources(source!));
emit(
state.copyWith(
visibleSources: visible,
selectedSources:
selectedSources.map(_buildSearchResults).nonNulls.toList(),
),
);
}
SourceSelectedStatus _recursiveUpdateSelectedStatus(ChatSource chatSource) {
SourceSelectedStatus selectedStatus = SourceSelectedStatus.unselected;
int selectedCount = 0;
for (final childSource in chatSource.children) {
final childStatus = _recursiveUpdateSelectedStatus(childSource);
if (childStatus.isSelected) {
selectedCount++;
}
}
final isThisSourceSelected = selectedSourceIds.contains(chatSource.view.id);
final areAllChildrenSelectedOrNoChildren =
chatSource.children.length == selectedCount;
final isAnyChildNotUnselected =
chatSource.children.any((e) => !e.selectedStatus.isUnselected);
if (isThisSourceSelected && areAllChildrenSelectedOrNoChildren) {
selectedStatus = SourceSelectedStatus.selected;
} else if (isThisSourceSelected || isAnyChildNotUnselected) {
selectedStatus = SourceSelectedStatus.partiallySelected;
}
chatSource.selectedStatusNotifier.value = selectedStatus;
return selectedStatus;
}
void toggleIsExpanded(ChatSource chatSource, bool isSelectedSection) {
chatSource.toggleIsExpanded();
if (isSelectedSection) {
for (final selectedSource in selectedSources) {
selectedSource
.findChildBySourceId(chatSource.view.id)
?.toggleIsExpanded();
}
} else {
source?.findChildBySourceId(chatSource.view.id)?.toggleIsExpanded();
}
}
@override
Future<void> close() {
source?.dispose();
for (final child in state.selectedSources) {
child.dispose();
}
for (final child in state.visibleSources) {
child.dispose();
}
return super.close();
}
}
@freezed
class ChatSettingsState with _$ChatSettingsState {
const factory ChatSettingsState({
required bool onlyUseSelectedSources,
required List<ChatSource> visibleSources,
required List<ChatSource> selectedSources,
}) = _ChatSettingsState;
factory ChatSettingsState.initial() => const ChatSettingsState(
onlyUseSelectedSources: false,
visibleSources: [],
selectedSources: [],
);
}
@@ -0,0 +1,75 @@
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_side_panel_bloc.freezed.dart';
class ChatSidePanelBloc extends Bloc<ChatSidePanelEvent, ChatSidePanelState> {
ChatSidePanelBloc({
required this.chatId,
}) : super(const ChatSidePanelState()) {
on<ChatSidePanelEvent>(
(event, emit) async {
await event.when(
selectedMetadata: (ChatMessageRefSource metadata) async {
emit(
state.copyWith(
metadata: metadata,
indicator: const ChatSidePanelIndicator.loading(),
),
);
await ViewBackendService.getView(metadata.id).fold(
(view) {
if (!isClosed) {
add(ChatSidePanelEvent.open(view));
}
},
(err) => Log.error("Failed to get view: $err"),
);
},
close: () {
emit(state.copyWith(metadata: null, isShowPanel: false));
},
open: (ViewPB view) {
emit(
state.copyWith(
indicator: ChatSidePanelIndicator.ready(view),
isShowPanel: true,
),
);
},
);
},
);
}
final String chatId;
}
@freezed
class ChatSidePanelEvent with _$ChatSidePanelEvent {
const factory ChatSidePanelEvent.selectedMetadata(
ChatMessageRefSource metadata,
) = _SelectedMetadata;
const factory ChatSidePanelEvent.close() = _Close;
const factory ChatSidePanelEvent.open(ViewPB view) = _Open;
}
@freezed
class ChatSidePanelState with _$ChatSidePanelState {
const factory ChatSidePanelState({
ChatMessageRefSource? metadata,
@Default(ChatSidePanelIndicator.loading()) ChatSidePanelIndicator indicator,
@Default(false) bool isShowPanel,
}) = _ChatSidePanelState;
}
@freezed
class ChatSidePanelIndicator with _$ChatSidePanelIndicator {
const factory ChatSidePanelIndicator.ready(ViewPB view) = _Ready;
const factory ChatSidePanelIndicator.loading() = _Loading;
}
@@ -8,19 +8,72 @@ part 'chat_user_message_bloc.freezed.dart';
class ChatUserMessageBloc
extends Bloc<ChatUserMessageEvent, ChatUserMessageState> {
ChatUserMessageBloc({
required this.questionStream,
required String text,
}) : super(ChatUserMessageState.initial(text)) {
_dispatch();
_startListening();
}
final QuestionStream? questionStream;
void _dispatch() {
required dynamic message,
}) : super(ChatUserMessageState.initial(message)) {
on<ChatUserMessageEvent>(
(event, emit) {
event.when(
initial: () {
if (state.stream != null) {
if (!isClosed) {
add(ChatUserMessageEvent.updateText(state.stream!.text));
}
}
state.stream?.listen(
onData: (text) {
if (!isClosed) {
add(ChatUserMessageEvent.updateText(text));
}
},
onMessageId: (messageId) {
if (!isClosed) {
add(ChatUserMessageEvent.updateMessageId(messageId));
}
},
onError: (error) {
if (!isClosed) {
add(ChatUserMessageEvent.receiveError(error.toString()));
}
},
onFileIndexStart: (indexName) {
Log.debug("index start: $indexName");
},
onFileIndexEnd: (indexName) {
Log.info("index end: $indexName");
},
onFileIndexFail: (indexName) {
Log.debug("index fail: $indexName");
},
onIndexStart: () {
if (!isClosed) {
add(
const ChatUserMessageEvent.updateQuestionState(
QuestionMessageState.indexStart(),
),
);
}
},
onIndexEnd: () {
if (!isClosed) {
add(
const ChatUserMessageEvent.updateQuestionState(
QuestionMessageState.indexEnd(),
),
);
}
},
onDone: () {
if (!isClosed) {
add(
const ChatUserMessageEvent.updateQuestionState(
QuestionMessageState.finish(),
),
);
}
},
);
},
updateText: (String text) {
emit(state.copyWith(text: text));
},
@@ -35,66 +88,11 @@ class ChatUserMessageBloc
},
);
}
void _startListening() {
questionStream?.listen(
onData: (text) {
if (!isClosed) {
add(ChatUserMessageEvent.updateText(text));
}
},
onMessageId: (messageId) {
if (!isClosed) {
add(ChatUserMessageEvent.updateMessageId(messageId));
}
},
onError: (error) {
if (!isClosed) {
add(ChatUserMessageEvent.receiveError(error.toString()));
}
},
onFileIndexStart: (indexName) {
Log.debug("index start: $indexName");
},
onFileIndexEnd: (indexName) {
Log.info("index end: $indexName");
},
onFileIndexFail: (indexName) {
Log.debug("index fail: $indexName");
},
onIndexStart: () {
if (!isClosed) {
add(
const ChatUserMessageEvent.updateQuestionState(
QuestionMessageState.indexStart(),
),
);
}
},
onIndexEnd: () {
if (!isClosed) {
add(
const ChatUserMessageEvent.updateQuestionState(
QuestionMessageState.indexEnd(),
),
);
}
},
onDone: () {
if (!isClosed) {
add(
const ChatUserMessageEvent.updateQuestionState(
QuestionMessageState.finish(),
),
);
}
},
);
}
}
@freezed
class ChatUserMessageEvent with _$ChatUserMessageEvent {
const factory ChatUserMessageEvent.initial() = Initial;
const factory ChatUserMessageEvent.updateText(String text) = _UpdateText;
const factory ChatUserMessageEvent.updateQuestionState(
QuestionMessageState newState,
@@ -108,14 +106,17 @@ class ChatUserMessageEvent with _$ChatUserMessageEvent {
class ChatUserMessageState with _$ChatUserMessageState {
const factory ChatUserMessageState({
required String text,
required String? messageId,
required QuestionMessageState messageState,
QuestionStream? stream,
String? messageId,
@Default(QuestionMessageState.finish()) QuestionMessageState messageState,
}) = _ChatUserMessageState;
factory ChatUserMessageState.initial(String message) => ChatUserMessageState(
text: message,
messageId: null,
messageState: const QuestionMessageState.finish(),
factory ChatUserMessageState.initial(
dynamic message,
) =>
ChatUserMessageState(
text: message is String ? message : "",
stream: message is QuestionStream ? message : null,
);
}
@@ -0,0 +1,60 @@
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'chat_message_service.dart';
part 'chat_user_message_bubble_bloc.freezed.dart';
class ChatUserMessageBubbleBloc
extends Bloc<ChatUserMessageBubbleEvent, ChatUserMessageBubbleState> {
ChatUserMessageBubbleBloc({
required Message message,
}) : super(
ChatUserMessageBubbleState.initial(
message,
_getFiles(message.metadata),
),
) {
on<ChatUserMessageBubbleEvent>(
(event, emit) async {
event.when(
initial: () {},
);
},
);
}
}
List<ChatFile> _getFiles(Map<String, dynamic>? metadata) {
if (metadata == null) {
return [];
}
final refSourceMetadata = metadata[messageRefSourceJsonStringKey] as String?;
final files = metadata[messageChatFileListKey] as List<ChatFile>?;
if (refSourceMetadata != null) {
return chatFilesFromMetadataString(refSourceMetadata);
}
return files ?? [];
}
@freezed
class ChatUserMessageBubbleEvent with _$ChatUserMessageBubbleEvent {
const factory ChatUserMessageBubbleEvent.initial() = Initial;
}
@freezed
class ChatUserMessageBubbleState with _$ChatUserMessageBubbleState {
const factory ChatUserMessageBubbleState({
required Message message,
required List<ChatFile> files,
}) = _ChatUserMessageBubbleState;
factory ChatUserMessageBubbleState.initial(
Message message,
List<ChatFile> files,
) =>
ChatUserMessageBubbleState(message: message, files: files);
}
@@ -2,7 +2,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/ai_chat/chat_page.dart';
import 'package:appflowy/plugins/util.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart';
@@ -86,16 +85,12 @@ class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder
final ViewPluginNotifier notifier;
int? deletedViewIndex;
@override
String? get viewName => notifier.view.nameOrDefault;
@override
Widget get leftBarItem =>
ViewTitleBar(key: ValueKey(notifier.view.id), view: notifier.view);
@override
Widget tabBarItem(String pluginId, [bool shortForm = false]) =>
ViewTabBarItem(view: notifier.view, shortForm: shortForm);
Widget tabBarItem(String pluginId) => ViewTabBarItem(view: notifier.view);
@override
Widget buildWidget({
@@ -110,7 +105,7 @@ class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder
return const SizedBox();
}
return BlocProvider.value(
return BlocProvider<ViewInfoBloc>.value(
value: bloc,
child: AIChatPage(
userProfile: context.userProfile!,
@@ -1,38 +1,33 @@
import 'dart:io';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/log.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_message_stream.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_related_question.dart';
import 'package:appflowy/plugins/ai_chat/presentation/message/user_message_bubble.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:flutter_chat_ui/flutter_chat_ui.dart'
hide ChatAnimatedListReversed;
import 'package:string_validator/string_validator.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:universal_platform/universal_platform.dart';
import 'package:url_launcher/url_launcher.dart';
import 'application/ai_prompt_input_bloc.dart';
import 'application/chat_bloc.dart';
import 'application/chat_entity.dart';
import 'application/chat_member_bloc.dart';
import 'application/chat_message_stream.dart';
import 'application/chat_side_panel_bloc.dart';
import 'presentation/animated_chat_list.dart';
import 'presentation/chat_input/desktop_ai_prompt_input.dart';
import 'presentation/chat_input/mobile_ai_prompt_input.dart';
import 'presentation/chat_related_question.dart';
import 'presentation/chat_side_panel.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/error_text_message.dart';
import 'presentation/message/message_util.dart';
import 'presentation/message/user_text_message.dart';
import 'presentation/scroll_to_bottom.dart';
@@ -71,26 +66,23 @@ class AIChatPage extends StatelessWidget {
/// [AIPromptInputBloc] is used to handle the user prompt
BlocProvider(create: (_) => AIPromptInputBloc()),
BlocProvider(create: (_) => ChatSidePanelBloc(chatId: view.id)),
BlocProvider(create: (_) => ChatMemberBloc()),
],
child: Builder(
builder: (context) {
return DropTarget(
onDragDone: (DropDoneDetails detail) async {
if (context.read<AIPromptInputBloc>().state.supportChatWithFile) {
for (final file in detail.files) {
context
.read<AIPromptInputBloc>()
.add(AIPromptInputEvent.attachFile(file.path, file.name));
}
}
},
child: _ChatContentPage(
view: view,
userProfile: userProfile,
),
);
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,
),
),
);
}
@@ -107,43 +99,110 @@ class _ChatContentPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 784),
margin: UniversalPlatform.isDesktop
? const EdgeInsets.symmetric(horizontal: 60.0)
: null,
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
return switch (state.loadingState) {
LoadChatMessageStatus.ready => Column(
children: [
Expanded(
child: Chat(
chatController:
context.read<ChatBloc>().chatController,
user: User(id: userProfile.id.toString()),
darkTheme: ChatTheme.fromThemeData(Theme.of(context)),
theme: ChatTheme.fromThemeData(Theme.of(context)),
builders: Builders(
inputBuilder: (_) => const SizedBox.shrink(),
textMessageBuilder: _buildTextMessage,
chatMessageBuilder: _buildChatMessage,
scrollToBottomBuilder: _buildScrollToBottom,
chatAnimatedListBuilder: _buildChatAnimatedList,
),
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;
return Row(
children: [
Center(
child: buildChatWidget(context)
.constrained(
maxWidth: 784,
)
.padding(horizontal: 32)
.animate(
const Duration(milliseconds: 200),
Curves.easeOut,
),
),
_buildInput(context),
],
),
_ => const Center(child: CircularProgressIndicator.adaptive()),
};
).constrained(width: chatWidth),
if (isShowPanel) ...[
const VerticalDivider(
width: 1.0,
thickness: 1.0,
),
buildChatSidePanel()
.constrained(width: sidePanelWidth)
.animate(
const Duration(milliseconds: 200),
Curves.easeOut,
),
],
],
);
},
);
},
);
} else {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 784),
child: buildChatWidget(context),
),
),
),
],
);
}
}
Widget buildChatSidePanel() {
return BlocBuilder<ChatSidePanelBloc, ChatSidePanelState>(
builder: (context, state) {
if (state.metadata == null) {
return const SizedBox.shrink();
}
return const ChatSidePanel();
},
);
}
Widget buildChatWidget(BuildContext context) {
return ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
return state.loadingState.when(
loading: () {
return const Center(
child: CircularProgressIndicator.adaptive(),
);
},
finish: (_) {
final chatController = context.read<ChatBloc>().chatController;
return Column(
children: [
Expanded(
child: Chat(
chatController: chatController,
user: User(id: userProfile.id.toString()),
darkTheme: ChatTheme.fromThemeData(Theme.of(context)),
theme: ChatTheme.fromThemeData(Theme.of(context)),
builders: Builders(
inputBuilder: (_) => const SizedBox.shrink(),
textMessageBuilder: _buildTextMessage,
chatMessageBuilder: _buildChatMessage,
scrollToBottomBuilder: _buildScrollToBottom,
chatAnimatedListBuilder: _buildChatAnimatedList,
),
),
),
_buildInput(context),
],
);
},
);
},
),
);
}
@@ -156,9 +215,9 @@ class _ChatContentPage extends StatelessWidget {
message.metadata,
);
if (messageType == OnetimeShotType.error) {
return ChatErrorMessageWidget(
errorMessage: message.metadata?[errorMessageTextKey] ?? "",
if (messageType == OnetimeShotType.invalidSendMesssage) {
return ChatInvalidUserMessage(
message: message,
);
}
@@ -174,18 +233,27 @@ class _ChatContentPage extends StatelessWidget {
}
if (message.author.id == userProfile.id.toString()) {
return ChatUserMessageWidget(
user: message.author,
final stream = message.metadata?["$QuestionStream"];
return ChatUserMessageBubble(
key: ValueKey(message.id),
message: message,
isCurrentUser: true,
child: ChatUserMessageWidget(
user: message.author,
message: stream is QuestionStream ? stream : message.text,
),
);
}
if (isOtherUserMessage(message)) {
return ChatUserMessageWidget(
user: message.author,
final stream = message.metadata?["$QuestionStream"];
return ChatUserMessageBubble(
key: ValueKey(message.id),
message: message,
isCurrentUser: false,
child: ChatUserMessageWidget(
user: message.author,
message: stream is QuestionStream ? stream : message.text,
),
);
}
@@ -194,13 +262,26 @@ class _ChatContentPage extends StatelessWidget {
final refSourceJsonString =
message.metadata?[messageRefSourceJsonStringKey] as String?;
return BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
return BlocSelector<ChatBloc, ChatState, bool>(
key: ValueKey(message.id),
selector: (state) {
final chatController = context.read<ChatBloc>().chatController;
final messages = chatController.messages
.where((e) => onetimeMessageTypeFromMeta(e.metadata) == null);
final isLastMessage =
messages.isEmpty ? false : messages.last.id == message.id;
final messages = chatController.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.last.id == message.id;
},
builder: (context, isLastMessage) {
return ChatAIMessageWidget(
user: message.author,
messageUserId: message.id,
@@ -209,13 +290,12 @@ class _ChatContentPage extends StatelessWidget {
questionId: questionId,
chatId: view.id,
refSourceJsonString: refSourceJsonString,
isStreaming: state.promptResponseState != PromptResponseState.ready,
isLastMessage: isLastMessage,
onSelectedMetadata: (metadata) =>
_onSelectMetadata(context, metadata),
onRegenerate: () => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id)),
onSelectedMetadata: (metadata) {
context
.read<ChatSidePanelBloc>()
.add(ChatSidePanelEvent.selectedMetadata(metadata));
},
);
},
);
@@ -230,8 +310,7 @@ class _ChatContentPage extends StatelessWidget {
return ChatMessage(
message: message,
animation: animation,
padding: const EdgeInsets.symmetric(vertical: 12.0),
receivedMessageScaleAnimationAlignment: Alignment.center,
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: child,
);
}
@@ -280,90 +359,40 @@ class _ChatContentPage extends StatelessWidget {
return state.promptResponseState == PromptResponseState.ready;
},
builder: (context, canSendMessage) {
final chatBloc = context.read<ChatBloc>();
return UniversalPlatform.isDesktop
? DesktopAIPromptInput(
chatId: view.id,
indicateFocus: true,
onSubmitted: (text, metadata) {
context.read<ChatBloc>().add(
ChatEvent.sendMessage(
message: text,
metadata: metadata,
),
);
},
isStreaming: !canSendMessage,
onStopStreaming: () {
chatBloc.add(const ChatEvent.stopStream());
},
onSubmitted: (text, metadata) {
chatBloc.add(
ChatEvent.sendMessage(
message: text,
metadata: metadata,
),
);
},
onUpdateSelectedSources: (ragOnly, ids) {
if (ragOnly != chatBloc.state.onlyUseSelectedSources) {
chatBloc.add(
ChatEvent.setRagOnly(
ragOnly: ragOnly,
),
);
}
if (!listEquals(ids, chatBloc.state.selectedSourceIds)) {
chatBloc.add(
ChatEvent.updateSelectedSources(
selectedSourcesIds: ids,
),
);
}
context.read<ChatBloc>().add(const ChatEvent.stopStream());
},
)
: MobileAIPromptInput(
chatId: view.id,
onSubmitted: (text, metadata) {
context.read<ChatBloc>().add(
ChatEvent.sendMessage(
message: text,
metadata: metadata,
),
);
},
isStreaming: !canSendMessage,
onStopStreaming: () {
chatBloc.add(const ChatEvent.stopStream());
},
onSubmitted: (text, metadata) {
chatBloc.add(
ChatEvent.sendMessage(
message: text,
metadata: metadata,
),
);
},
onUpdateSelectedSources: (ids) {
chatBloc.add(
ChatEvent.updateSelectedSources(
selectedSourcesIds: ids,
),
);
context.read<ChatBloc>().add(const ChatEvent.stopStream());
},
);
},
),
);
}
void _onSelectMetadata(
BuildContext context,
ChatMessageRefSource metadata,
) async {
if (isURL(metadata.name)) {
late Uri uri;
try {
uri = Uri.parse(metadata.name);
// `Uri` identifies `localhost` as a scheme
if (!uri.hasScheme || uri.scheme == 'localhost') {
uri = Uri.parse("http://${metadata.name}");
await InternetAddress.lookup(uri.host);
}
await launchUrl(uri);
} catch (err) {
Log.error("failed to open url $err");
}
} else {
final sidebarView =
await ViewBackendService.getView(metadata.id).toNullable();
if (context.mounted) {
openPageFromMessage(context, sidebarView);
}
}
}
}
@@ -10,6 +10,7 @@ import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:provider/provider.dart';
import 'package:flutter_chat_ui/src/scroll_to_bottom.dart';
import 'package:flutter_chat_ui/src/utils/chat_input_height_notifier.dart';
import 'package:flutter_chat_ui/src/utils/message_list_diff.dart';
class ChatAnimatedListReversed extends StatefulWidget {
@@ -46,8 +47,8 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
late List<Message> _oldList;
late StreamSubscription<ChatOperation> _operationsSubscription;
late final AnimationController _scrollToBottomController;
late final Animation<double> _scrollToBottomAnimation;
late AnimationController _scrollToBottomController;
late Animation<double> _scrollToBottomAnimation;
Timer? _scrollToBottomShowTimer;
bool _userHasScrolled = false;
@@ -71,7 +72,7 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
event.message != null,
'Message must be provided when inserting a message.',
);
_onInserted(0, event.message!);
_onInserted(event.index!, event.message!);
_oldList = List.from(_chatController.messages);
break;
case ChatOperationType.remove:
@@ -138,12 +139,12 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
onNotification: (notification) {
if (notification is UserScrollNotification) {
// When user scrolls up, save it to `_userHasScrolled`
if (notification.direction == ScrollDirection.reverse) {
if (notification.direction == ScrollDirection.forward) {
_userHasScrolled = true;
} else {
// When user overscolls to the bottom or stays idle at the bottom, set `_userHasScrolled` to false
if (notification.metrics.pixels ==
notification.metrics.minScrollExtent) {
notification.metrics.maxScrollExtent) {
_userHasScrolled = false;
}
}
@@ -162,10 +163,14 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
reverse: true,
controller: widget.scrollController,
slivers: <Widget>[
SliverPadding(
padding: EdgeInsets.only(
top: widget.bottomPadding ?? 0,
),
Consumer<ChatInputHeightNotifier>(
builder: (context, heightNotifier, child) {
return SliverPadding(
padding: EdgeInsets.only(
top: heightNotifier.height + (widget.bottomPadding ?? 0),
),
);
},
),
SliverAnimatedList(
key: _listKey,
@@ -200,10 +205,32 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
);
}
void _initialScrollToEnd() async {
// Delay the scroll to the end animation so new message is painted, otherwise
// maxScrollExtent is not yet updated and the animation might not work.
await Future.delayed(widget.insertAnimationDuration);
if (!widget.scrollController.hasClients || !mounted) return;
if (widget.scrollController.offset >
widget.scrollController.position.minScrollExtent) {
if (widget.scrollToEndAnimationDuration == Duration.zero) {
widget.scrollController
.jumpTo(widget.scrollController.position.minScrollExtent);
} else {
await widget.scrollController.animateTo(
widget.scrollController.position.minScrollExtent,
duration: widget.scrollToEndAnimationDuration,
curve: Curves.linearToEaseOut,
);
}
}
}
void _subsequentScrollToEnd(Message data) async {
final user = Provider.of<User>(context, listen: false);
// We only want to scroll to the bottom if user has not scrolled up
// In this case we only want to scroll to the bottom if user has not scrolled up
// or if the message is sent by the current user.
if (data.id == _lastInsertedMessageId &&
widget.scrollController.offset >
@@ -246,7 +273,15 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
(_) {
if (!widget.scrollController.hasClients || !mounted) return;
_subsequentScrollToEnd(data);
// We need this condition because if scroll view is not yet scrollable,
// we want to wait for the insert animation to finish before scrolling to the end.
if (widget.scrollController.position.maxScrollExtent == 0) {
// Scroll view is not yet scrollable, scroll to the end if
// new message makes it scrollable.
_initialScrollToEnd();
} else {
_subsequentScrollToEnd(data);
}
},
);
}
@@ -283,37 +318,37 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
}
void _handleToggleScrollToBottom() {
if (_isScrollingToBottom) {
return;
}
_scrollToBottomShowTimer?.cancel();
if (widget.scrollController.offset >
widget.scrollController.position.minScrollExtent) {
_scrollToBottomShowTimer =
Timer(widget.scrollToBottomAppearanceDelay, () {
if (mounted) {
_scrollToBottomController.forward();
if (!_isScrollingToBottom) {
_scrollToBottomShowTimer?.cancel();
if (widget.scrollController.offset >
widget.scrollController.position.minScrollExtent) {
_scrollToBottomShowTimer =
Timer(widget.scrollToBottomAppearanceDelay, () {
if (mounted) {
_scrollToBottomController.forward();
}
});
} else {
if (_scrollToBottomController.status == AnimationStatus.completed) {
_scrollToBottomController.reverse();
}
});
} else {
if (_scrollToBottomController.status != AnimationStatus.completed) {
_scrollToBottomController.stop();
}
_scrollToBottomController.reverse();
}
}
void _onInserted(final int position, final Message data) {
// There is a scroll notification listener the controls the
// `_userHasScrolled` variable.
final user = Provider.of<User>(context, listen: false);
// There is a scroll notification listener the controls the `_userHasScrolled` variable.
// However, when a new message is sent by the current user we want to
// set `_userHasScrolled` to false so that the scroll animation is triggered.
//
// If for some reason `_userHasScrolled` is true and the user is not at the
// bottom of the list, set `_userHasScrolled` to false so that the scroll
// animation is triggered.
if (_userHasScrolled &&
widget.scrollController.offset >=
widget.scrollController.position.minScrollExtent) {
// Also, if for some reason `_userHasScrolled` is true and the user is not at the bottom of the list,
// set `_userHasScrolled` to false so that the scroll animation is triggered.
if (user.id == data.author.id ||
(_userHasScrolled == true &&
widget.scrollController.offset >=
widget.scrollController.position.maxScrollExtent)) {
_userHasScrolled = false;
}
@@ -325,7 +360,7 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
// Used later to trigger scroll to end only for the last inserted message.
_lastInsertedMessageId = data.id;
if (position == _oldList.length) {
if (user.id == data.author.id && position == _oldList.length) {
_scrollToEnd(data);
}
}
@@ -23,7 +23,7 @@ class ChatInputFile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocSelector<AIPromptInputBloc, AIPromptInputState, List<ChatFile>>(
selector: (state) => state.attachedFiles,
selector: (state) => state.uploadFiles,
builder: (context, files) {
if (files.isEmpty) {
return const SizedBox.shrink();
@@ -1,18 +1,15 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:extended_text_library/extended_text_library.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../../application/chat_input_action_control.dart';
class ChatInputTextSpanBuilder extends SpecialTextSpanBuilder {
ChatInputTextSpanBuilder({
required this.inputControlCubit,
this.specialTextStyle,
required this.inputActionControl,
});
final ChatInputControlCubit inputControlCubit;
final TextStyle? specialTextStyle;
final ChatInputActionControl inputActionControl;
@override
SpecialText? createSpecialText(
@@ -25,54 +22,51 @@ class ChatInputTextSpanBuilder extends SpecialTextSpanBuilder {
return null;
}
if (!isStart(flag, AtText.flag)) {
return null;
//index is end index of start flag, so text start index should be index-(flag.length-1)
if (isStart(flag, AtText.flag)) {
return AtText(
inputActionControl,
textStyle,
onTap,
start: index! - (AtText.flag.length - 1),
);
}
// index is at the end of the start flag, so the start index should be index - (flag.length - 1)
return AtText(
inputControlCubit,
specialTextStyle ?? textStyle,
onTap,
// scrubbing over text is kinda funky
start: index! - (AtText.flag.length - 1),
);
return null;
}
}
class AtText extends SpecialText {
AtText(
this.inputControlCubit,
this.inputActionControl,
TextStyle? textStyle,
SpecialTextGestureTapCallback? onTap, {
this.start,
}) : super(flag, '', textStyle, onTap: onTap);
static const String flag = '@';
final int? start;
final ChatInputControlCubit inputControlCubit;
final ChatInputActionControl inputActionControl;
@override
bool isEnd(String value) => inputControlCubit.selectedViewIds.contains(value);
bool isEnd(String value) {
return inputActionControl.tags.contains(value);
}
@override
InlineSpan finishText() {
final String actualText = toString();
final TextStyle? textStyle =
this.textStyle?.copyWith(color: Colors.blue, fontSize: 15.0);
final viewName = inputControlCubit.allViews
.firstWhereOrNull((view) => view.id == actualText.substring(1))
?.name ??
"";
final nonEmptyName = viewName.isEmpty
? LocaleKeys.document_title_placeholder.tr()
: viewName;
final String atText = toString();
return SpecialTextSpan(
text: "@$nonEmptyName",
actualText: actualText,
text: atText,
actualText: atText,
start: start!,
style: textStyle,
recognizer: (TapGestureRecognizer()
..onTap = () {
onTap?.call(atText);
}),
);
}
}
@@ -1,204 +0,0 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/flowy_search_text_field.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart';
import 'package:appflowy/plugins/base/drag_handler.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'chat_mention_page_menu.dart';
Future<ViewPB?> showPageSelectorSheet(
BuildContext context, {
required bool Function(ViewPB view) filter,
}) async {
return showMobileBottomSheet<ViewPB>(
context,
backgroundColor: Theme.of(context).colorScheme.surface,
maxChildSize: 0.98,
enableDraggableScrollable: true,
scrollableWidgetBuilder: (context, scrollController) {
return Expanded(
child: _MobilePageSelectorBody(
filter: filter,
scrollController: scrollController,
),
);
},
builder: (context) => const SizedBox.shrink(),
);
}
class _MobilePageSelectorBody extends StatefulWidget {
const _MobilePageSelectorBody({
this.filter,
this.scrollController,
});
final bool Function(ViewPB view)? filter;
final ScrollController? scrollController;
@override
State<_MobilePageSelectorBody> createState() =>
_MobilePageSelectorBodyState();
}
class _MobilePageSelectorBodyState extends State<_MobilePageSelectorBody> {
final textController = TextEditingController();
late final Future<List<ViewPB>> _viewsFuture = _fetchViews();
@override
void dispose() {
textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return CustomScrollView(
controller: widget.scrollController,
shrinkWrap: true,
slivers: [
SliverPersistentHeader(
pinned: true,
delegate: _Header(
child: ColoredBox(
color: Theme.of(context).cardColor,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const DragHandle(),
SizedBox(
height: 44.0,
child: Center(
child: FlowyText.medium(
LocaleKeys.document_mobilePageSelector_title.tr(),
fontSize: 16.0,
),
),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
child: SizedBox(
height: 44.0,
child: FlowySearchTextField(
controller: textController,
onChanged: (_) => setState(() {}),
),
),
),
const Divider(height: 0.5, thickness: 0.5),
],
),
),
),
),
FutureBuilder(
future: _viewsFuture,
builder: (_, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const SliverToBoxAdapter(
child: CircularProgressIndicator.adaptive(),
);
}
if (snapshot.hasError || snapshot.data == null) {
return SliverToBoxAdapter(
child: FlowyText(
LocaleKeys.document_mobilePageSelector_failedToLoad.tr(),
),
);
}
final views = snapshot.data!
.where((v) => widget.filter?.call(v) ?? true)
.toList();
final filtered = views.where(
(v) =>
textController.text.isEmpty ||
v.name
.toLowerCase()
.contains(textController.text.toLowerCase()),
);
if (filtered.isEmpty) {
return SliverToBoxAdapter(
child: FlowyText(
LocaleKeys.document_mobilePageSelector_noPagesFound.tr(),
),
);
}
return SliverPadding(
padding:
const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final view = filtered.elementAt(index);
return InkWell(
onTap: () => Navigator.of(context).pop(view),
borderRadius: BorderRadius.circular(12),
splashColor: Colors.transparent,
child: Container(
height: 44,
padding: const EdgeInsets.all(4.0),
child: Row(
children: [
MentionViewIcon(view: view),
const HSpace(8),
Expanded(
child: MentionViewTitleAndAncestors(view: view),
),
],
),
),
);
},
childCount: filtered.length,
),
),
);
},
),
],
);
}
Future<List<ViewPB>> _fetchViews() async =>
(await ViewBackendService.getAllViews()).toNullable()?.items ?? [];
}
class _Header extends SliverPersistentHeaderDelegate {
const _Header({
required this.child,
});
final Widget child;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return child;
}
@override
double get maxExtent => 120.5;
@override
double get minExtent => 120.5;
@override
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) {
return false;
}
}
@@ -1,429 +0,0 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view_title/view_title_bar_bloc.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:easy_localization/easy_localization.dart' hide TextDirection;
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:scroll_to_index/scroll_to_index.dart';
const double _itemHeight = 44.0;
const double _noPageHeight = 20.0;
const double _fixedWidth = 360.0;
const double _maxHeight = 328.0;
class ChatInputAnchor {
ChatInputAnchor(this.anchorKey, this.layerLink);
final GlobalKey<State<StatefulWidget>> anchorKey;
final LayerLink layerLink;
}
class ChatMentionPageMenu extends StatefulWidget {
const ChatMentionPageMenu({
super.key,
required this.anchor,
required this.textController,
required this.onPageSelected,
});
final ChatInputAnchor anchor;
final TextEditingController textController;
final void Function(ViewPB view) onPageSelected;
@override
State<ChatMentionPageMenu> createState() => _ChatMentionPageMenuState();
}
class _ChatMentionPageMenuState extends State<ChatMentionPageMenu> {
@override
void initState() {
super.initState();
Future.delayed(Duration.zero, () {
context.read<ChatInputControlCubit>().refreshViews();
});
}
@override
Widget build(BuildContext context) {
return BlocBuilder<ChatInputControlCubit, ChatInputControlState>(
builder: (context, state) {
return Stack(
children: [
CompositedTransformFollower(
link: widget.anchor.layerLink,
showWhenUnlinked: false,
offset: Offset(getPopupOffsetX(), 0.0),
followerAnchor: Alignment.bottomLeft,
child: Container(
constraints: const BoxConstraints(
minWidth: _fixedWidth,
maxWidth: _fixedWidth,
maxHeight: _maxHeight,
),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(6.0),
boxShadow: const [
BoxShadow(
color: Color(0x0A1F2329),
blurRadius: 24,
offset: Offset(0, 8),
spreadRadius: 8,
),
BoxShadow(
color: Color(0x0A1F2329),
blurRadius: 12,
offset: Offset(0, 6),
),
BoxShadow(
color: Color(0x0F1F2329),
blurRadius: 8,
offset: Offset(0, 4),
spreadRadius: -8,
),
],
),
child: TextFieldTapRegion(
child: ChatMentionPageList(
onPageSelected: widget.onPageSelected,
),
),
),
),
],
);
},
);
}
double getPopupOffsetX() {
if (widget.anchor.anchorKey.currentContext == null) {
return 0.0;
}
final cubit = context.read<ChatInputControlCubit>();
if (cubit.filterStartPosition == -1) {
return 0.0;
}
final textPosition = TextPosition(offset: cubit.filterEndPosition);
final renderBox =
widget.anchor.anchorKey.currentContext?.findRenderObject() as RenderBox;
final textPainter = TextPainter(
text: TextSpan(text: cubit.formatIntputText(widget.textController.text)),
textDirection: TextDirection.ltr,
);
textPainter.layout(
minWidth: renderBox.size.width,
maxWidth: renderBox.size.width,
);
final caretOffset = textPainter.getOffsetForCaret(textPosition, Rect.zero);
final boxes = textPainter.getBoxesForSelection(
TextSelection(
baseOffset: textPosition.offset,
extentOffset: textPosition.offset,
),
);
if (boxes.isNotEmpty) {
return boxes.last.right;
}
return caretOffset.dx;
}
}
class ChatMentionPageList extends StatefulWidget {
const ChatMentionPageList({
super.key,
required this.onPageSelected,
});
final void Function(ViewPB view) onPageSelected;
@override
State<ChatMentionPageList> createState() => _ChatMentionPageListState();
}
class _ChatMentionPageListState extends State<ChatMentionPageList> {
final autoScrollController = SimpleAutoScrollController(
suggestedRowHeight: _itemHeight,
beginGetter: (rect) => rect.top + 8.0,
endGetter: (rect) => rect.bottom - 8.0,
);
@override
void dispose() {
autoScrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocConsumer<ChatInputControlCubit, ChatInputControlState>(
listenWhen: (previous, current) {
return previous.maybeWhen(
ready: (_, pFocusedViewIndex) => current.maybeWhen(
ready: (_, cFocusedViewIndex) =>
pFocusedViewIndex != cFocusedViewIndex,
orElse: () => false,
),
orElse: () => false,
);
},
listener: (context, state) {
state.maybeWhen(
ready: (views, focusedViewIndex) {
if (focusedViewIndex == -1 || !autoScrollController.hasClients) {
return;
}
if (autoScrollController.isAutoScrolling) {
autoScrollController.position
.jumpTo(autoScrollController.position.pixels);
}
autoScrollController.scrollToIndex(
focusedViewIndex,
duration: const Duration(milliseconds: 200),
preferPosition: AutoScrollPosition.begin,
);
},
orElse: () {},
);
},
builder: (context, state) {
return state.maybeWhen(
loading: () {
return const Padding(
padding: EdgeInsets.all(8.0),
child: SizedBox(
height: _noPageHeight,
child: Center(
child: CircularProgressIndicator.adaptive(),
),
),
);
},
ready: (views, focusedViewIndex) {
if (views.isEmpty) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: SizedBox(
height: _noPageHeight,
child: Center(
child: FlowyText(
LocaleKeys.chat_inputActionNoPages.tr(),
),
),
),
);
}
return ListView.builder(
shrinkWrap: true,
controller: autoScrollController,
padding: const EdgeInsets.all(8.0),
itemCount: views.length,
itemBuilder: (context, index) {
final view = views[index];
return AutoScrollTag(
key: ValueKey("chat_mention_page_item_${view.id}"),
index: index,
controller: autoScrollController,
child: _ChatMentionPageItem(
view: view,
onTap: () => widget.onPageSelected(view),
isSelected: focusedViewIndex == index,
),
);
},
);
},
orElse: () => const SizedBox.shrink(),
);
},
);
}
}
class _ChatMentionPageItem extends StatelessWidget {
const _ChatMentionPageItem({
required this.view,
required this.isSelected,
required this.onTap,
});
final ViewPB view;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return FlowyTooltip(
message: view.name,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: FlowyHover(
isSelected: () => isSelected,
child: Container(
height: _itemHeight,
padding: const EdgeInsets.all(4.0),
child: Row(
children: [
MentionViewIcon(view: view),
const HSpace(8.0),
Expanded(child: MentionViewTitleAndAncestors(view: view)),
],
),
),
),
),
),
);
}
}
class MentionViewIcon extends StatelessWidget {
const MentionViewIcon({
super.key,
required this.view,
});
final ViewPB view;
@override
Widget build(BuildContext context) {
final spaceIcon = view.buildSpaceIconSvg(context);
if (view.icon.value.isNotEmpty) {
return SizedBox(
width: 16.0,
child: EmojiIconWidget(
emoji: view.icon.toEmojiIconData(),
emojiSize: 14,
),
);
}
if (view.isSpace == true && spaceIcon != null) {
return SpaceIcon(
dimension: 16.0,
svgSize: 9.68,
space: view,
cornerRadius: 4,
);
}
return FlowySvg(
view.layout.icon,
size: const Size.square(16),
color: Theme.of(context).hintColor,
);
}
}
class MentionViewTitleAndAncestors extends StatelessWidget {
const MentionViewTitleAndAncestors({
super.key,
required this.view,
});
final ViewPB view;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => ViewTitleBarBloc(view: view),
child: BlocBuilder<ViewTitleBarBloc, ViewTitleBarState>(
builder: (context, state) {
final nonEmptyName = view.name.isEmpty
? LocaleKeys.document_title_placeholder.tr()
: view.name;
final ancestorList = _getViewAncestorList(state.ancestors);
if (state.ancestors.isEmpty || ancestorList.trim().isEmpty) {
return FlowyText(
nonEmptyName,
fontSize: 14.0,
overflow: TextOverflow.ellipsis,
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowyText(
nonEmptyName,
fontSize: 14.0,
figmaLineHeight: 20.0,
overflow: TextOverflow.ellipsis,
),
FlowyText(
ancestorList,
fontSize: 12.0,
figmaLineHeight: 16.0,
color: Theme.of(context).hintColor,
overflow: TextOverflow.ellipsis,
),
],
);
},
),
);
}
/// see workspace/presentation/widgets/view_title_bar.dart, upon which this
/// function was based. This version doesn't include the current view in the
/// result, and returns a string rather than a list of widgets
String _getViewAncestorList(
List<ViewPB> views,
) {
const lowerBound = 2;
final upperBound = views.length - 2;
bool hasAddedEllipsis = false;
String result = "";
if (views.length <= 1) {
return "";
}
// ignore the workspace name, use section name instead in the future
// skip the workspace view
for (var i = 1; i < views.length - 1; i++) {
final view = views[i];
if (i >= lowerBound && i < upperBound) {
if (!hasAddedEllipsis) {
hasAddedEllipsis = true;
result += "… / ";
}
continue;
}
final nonEmptyName = view.name.isEmpty
? LocaleKeys.document_title_placeholder.tr()
: view.name;
result += nonEmptyName;
if (i != views.length - 2) {
// if not the last one, add a divider
result += " / ";
}
}
return result;
}
}
@@ -1,8 +1,10 @@
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_control_cubit.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:appflowy_backend/protobuf/flowy-folder/protobuf.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';
@@ -10,41 +12,39 @@ 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:universal_platform/universal_platform.dart';
import '../layout_define.dart';
import 'ai_prompt_buttons.dart';
import 'chat_input_file.dart';
import 'chat_input_span.dart';
import 'chat_mention_page_menu.dart';
import 'select_sources_menu.dart';
import '../layout_define.dart';
class DesktopAIPromptInput extends StatefulWidget {
const DesktopAIPromptInput({
super.key,
required this.chatId,
required this.indicateFocus,
required this.isStreaming,
required this.onStopStreaming,
required this.onSubmitted,
required this.onUpdateSelectedSources,
});
final String chatId;
final bool indicateFocus;
final bool isStreaming;
final void Function() onStopStreaming;
final void Function(String, Map<String, dynamic>) onSubmitted;
final void Function(bool, List<String>) onUpdateSelectedSources;
@override
State<DesktopAIPromptInput> createState() => _DesktopAIPromptInputState();
}
class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
final textFieldKey = GlobalKey();
final layerLink = LayerLink();
final overlayController = OverlayPortalController();
final inputControlCubit = ChatInputControlCubit();
final focusNode = FocusNode();
final textController = TextEditingController();
final GlobalKey _textFieldKey = GlobalKey();
final LayerLink _layerLink = LayerLink();
late final ChatInputActionControl _inputActionControl;
late final FocusNode _inputFocusNode;
late final TextEditingController _textController;
late SendButtonState sendButtonState;
@@ -52,22 +52,40 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
void initState() {
super.initState();
textController.addListener(handleTextControllerChanged);
_textController = TextEditingController()
..addListener(_handleTextControllerChange);
// refresh border color on focus change and hide menu when lost focus
focusNode.addListener(
() => setState(() {
if (!focusNode.hasFocus) {
cancelMentionPage();
_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();
WidgetsBinding.instance.addPostFrameCallback((_) {
focusNode.requestFocus();
});
}
@override
@@ -78,491 +96,205 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
@override
void dispose() {
focusNode.dispose();
textController.dispose();
inputControlCubit.close();
_inputFocusNode.dispose();
_textController.dispose();
_inputActionControl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: inputControlCubit,
child: BlocListener<ChatInputControlCubit, ChatInputControlState>(
listener: (context, state) {
state.maybeWhen(
updateSelectedViews: (selectedViews) {
context
.read<AIPromptInputBloc>()
.add(AIPromptInputEvent.updateMentionedViews(selectedViews));
},
orElse: () {},
);
},
child: OverlayPortal(
controller: overlayController,
overlayChildBuilder: (context) {
return ChatMentionPageMenu(
anchor: ChatInputAnchor(textFieldKey, layerLink),
textController: textController,
onPageSelected: handlePageSelected,
);
},
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: focusNode.hasFocus
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline,
width: focusNode.hasFocus ? 1.5 : 1.0,
),
borderRadius: DesktopAIPromptSizes.promptFrameRadius,
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(
mainAxisSize: MainAxisSize.min,
children: [
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: DesktopAIPromptSizes.attachedFilesBarPadding.vertical +
DesktopAIPromptSizes.attachedFilesPreviewHeight,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ConstrainedBox(
constraints: BoxConstraints(
maxHeight:
DesktopAIPromptSizes.attachedFilesBarPadding.vertical +
DesktopAIPromptSizes.attachedFilesPreviewHeight,
),
child: TextFieldTapRegion(
child: ChatInputFile(
chatId: widget.chatId,
onDeleted: (file) => context
.read<AIPromptInputBloc>()
.add(AIPromptInputEvent.removeFile(file)),
),
),
),
Stack(
children: [
ConstrainedBox(
constraints: BoxConstraints(
minHeight: DesktopAIPromptSizes.textFieldMinHeight +
DesktopAIPromptSizes.actionBarHeight +
DesktopAIPromptSizes.actionBarPadding.vertical,
maxHeight: 300,
),
child: inputTextField(),
),
Positioned.fill(
top: null,
child: TextFieldTapRegion(
child: _PromptBottomActions(
textController: textController,
overlayController: overlayController,
focusNode: focusNode,
sendButtonState: sendButtonState,
onSendPressed: handleSendPressed,
onStopStreaming: widget.onStopStreaming,
onUpdateSelectedSources:
widget.onUpdateSelectedSources,
),
),
),
],
),
],
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 cancelMentionPage() {
if (overlayController.isShowing) {
inputControlCubit.reset();
overlayController.hide();
}
}
void updateSendButtonState() {
if (widget.isStreaming) {
sendButtonState = SendButtonState.streaming;
} else if (textController.text.trim().isEmpty) {
} else if (_textController.text.trim().isEmpty) {
sendButtonState = SendButtonState.disabled;
} else {
sendButtonState = SendButtonState.enabled;
}
}
void handleSendPressed() {
if (widget.isStreaming) {
return;
}
final trimmedText = inputControlCubit.formatIntputText(
textController.text.trim(),
);
textController.clear();
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();
// get the attached files and mentioned pages
final metadata = context.read<AIPromptInputBloc>().consumeMetadata();
// combine metadata
final Map<String, dynamic> metadata = {}
..addAll(mentionPageMetadata)
..addAll(fileMetadata);
widget.onSubmitted(trimmedText, metadata);
}
void handleTextControllerChanged() {
if (!textController.value.composing.isCollapsed) {
void _handleTextControllerChange() {
if (_textController.value.isComposingRangeValid) {
return;
}
// update whether send button is clickable
setState(() => updateSendButtonState());
// handle text and selection changes ONLY when mentioning a page
// disable mention
return;
// ignore: dead_code
if (!overlayController.isShowing ||
inputControlCubit.filterStartPosition == -1) {
return;
}
// handle cases where mention a page is cancelled
final textSelection = textController.value.selection;
final isSelectingMultipleCharacters = !textSelection.isCollapsed;
final isCaretBeforeStartOfRange =
textSelection.baseOffset < inputControlCubit.filterStartPosition;
final isCaretAfterEndOfRange =
textSelection.baseOffset > inputControlCubit.filterEndPosition;
final isTextSame = inputControlCubit.inputText == textController.text;
if (isSelectingMultipleCharacters ||
isTextSame && (isCaretBeforeStartOfRange || isCaretAfterEndOfRange)) {
cancelMentionPage();
return;
}
final previousLength = inputControlCubit.inputText.characters.length;
final currentLength = textController.text.characters.length;
// delete "@"
if (previousLength != currentLength && isCaretBeforeStartOfRange) {
cancelMentionPage();
return;
}
// handle cases where mention the filter is updated
if (previousLength != currentLength) {
final diff = currentLength - previousLength;
final newEndPosition = inputControlCubit.filterEndPosition + diff;
final newFilter = textController.text.substring(
inputControlCubit.filterStartPosition,
newEndPosition,
);
inputControlCubit.updateFilter(
textController.text,
newFilter,
newEndPosition: newEndPosition,
);
} else if (!isTextSame) {
final newFilter = textController.text.substring(
inputControlCubit.filterStartPosition,
inputControlCubit.filterEndPosition,
);
inputControlCubit.updateFilter(textController.text, newFilter);
}
}
void handlePageSelected(ViewPB view) {
final newText = textController.text.replaceRange(
inputControlCubit.filterStartPosition,
inputControlCubit.filterEndPosition,
view.id,
);
textController.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(
offset: inputControlCubit.filterStartPosition + view.id.length,
affinity: TextAffinity.upstream,
),
);
inputControlCubit.selectPage(view);
overlayController.hide();
}
Widget inputTextField() {
return Actions(
actions: buildActions(),
child: CompositedTransformTarget(
link: layerLink,
child: BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
builder: (context, state) {
return _PromptTextField(
key: textFieldKey,
cubit: inputControlCubit,
textController: textController,
textFieldFocusNode: focusNode,
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()
},
// onStartMentioningPage: () {
// WidgetsBinding.instance.addPostFrameCallback((_) {
// inputControlCubit.startSearching(textController.value);
// overlayController.show();
// });
// },
);
},
),
),
);
}
Map<Type, Action<Intent>> buildActions() {
return {
_FocusPreviousItemIntent: CallbackAction<_FocusPreviousItemIntent>(
onInvoke: (intent) {
inputControlCubit.updateSelectionUp();
return;
},
),
_FocusNextItemIntent: CallbackAction<_FocusNextItemIntent>(
onInvoke: (intent) {
inputControlCubit.updateSelectionDown();
return;
},
),
_CancelMentionPageIntent: CallbackAction<_CancelMentionPageIntent>(
onInvoke: (intent) {
cancelMentionPage();
return;
},
),
_SubmitOrMentionPageIntent: CallbackAction<_SubmitOrMentionPageIntent>(
onInvoke: (intent) {
if (overlayController.isShowing) {
inputControlCubit.state.maybeWhen(
ready: (visibleViews, focusedViewIndex) {
if (focusedViewIndex != -1 &&
focusedViewIndex < visibleViews.length) {
handlePageSelected(visibleViews[focusedViewIndex]);
}
},
orElse: () {},
);
} else {
handleSendPressed();
}
return;
},
),
};
}
}
class _PromptTextField extends StatefulWidget {
const _PromptTextField({
super.key,
required this.cubit,
required this.textController,
required this.textFieldFocusNode,
// required this.onStartMentioningPage,
this.hintText = "",
});
final ChatInputControlCubit cubit;
final TextEditingController textController;
final FocusNode textFieldFocusNode;
// final void Function() onStartMentioningPage;
final String hintText;
@override
State<_PromptTextField> createState() => _PromptTextFieldState();
}
class _PromptTextFieldState extends State<_PromptTextField> {
bool isComposing = false;
@override
void initState() {
super.initState();
// widget.textFieldFocusNode.onKeyEvent = handleKeyEvent;
widget.textController.addListener(onTextChanged);
}
@override
void dispose() {
// widget.textFieldFocusNode.onKeyEvent = null;
widget.textController.removeListener(onTextChanged);
super.dispose();
}
@override
Widget build(BuildContext context) {
return Shortcuts(
shortcuts: buildShortcuts(),
child: ExtendedTextField(
controller: widget.textController,
focusNode: widget.textFieldFocusNode,
decoration: InputDecoration(
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: DesktopAIPromptSizes.textFieldContentPadding.add(
const EdgeInsets.only(
bottom: DesktopAIPromptSizes.actionBarHeight,
hintStyle: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: Theme.of(context).hintColor),
isCollapsed: true,
isDense: true,
),
),
hintText: widget.hintText,
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(
inputControlCubit: widget.cubit,
specialTextStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
);
}
// KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) {
// if (event.character == '@') {
// widget.onStartMentioningPage();
// }
// return KeyEventResult.ignored;
// }
void onTextChanged() {
setState(
() => isComposing = !widget.textController.value.composing.isCollapsed,
);
}
Map<ShortcutActivator, Intent> buildShortcuts() {
if (isComposing) {
return const {};
}
return const {
SingleActivator(LogicalKeyboardKey.arrowUp): _FocusPreviousItemIntent(),
SingleActivator(LogicalKeyboardKey.arrowDown): _FocusNextItemIntent(),
SingleActivator(LogicalKeyboardKey.escape): _CancelMentionPageIntent(),
SingleActivator(LogicalKeyboardKey.enter): _SubmitOrMentionPageIntent(),
};
}
}
class _SubmitOrMentionPageIntent extends Intent {
const _SubmitOrMentionPageIntent();
}
class _CancelMentionPageIntent extends Intent {
const _CancelMentionPageIntent();
}
class _FocusPreviousItemIntent extends Intent {
const _FocusPreviousItemIntent();
}
class _FocusNextItemIntent extends Intent {
const _FocusNextItemIntent();
}
class _PromptBottomActions extends StatelessWidget {
const _PromptBottomActions({
required this.textController,
required this.overlayController,
required this.focusNode,
required this.sendButtonState,
required this.onSendPressed,
required this.onStopStreaming,
required this.onUpdateSelectedSources,
});
final TextEditingController textController;
final OverlayPortalController overlayController;
final FocusNode focusNode;
final SendButtonState sendButtonState;
final void Function() onSendPressed;
final void Function() onStopStreaming;
final void Function(bool, List<String>) onUpdateSelectedSources;
@override
Widget build(BuildContext context) {
return Container(
height: DesktopAIPromptSizes.actionBarHeight,
margin: DesktopAIPromptSizes.actionBarPadding,
child: BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
builder: (context, state) {
return Row(
children: [
// predefinedFormatButton(),
const Spacer(),
if (state.aiType == AIType.appflowyAI) ...[
_selectSourcesButton(context),
const HSpace(
DesktopAIPromptSizes.actionBarButtonSpacing,
),
],
// _mentionButton(context),
// const HSpace(
// DesktopAIPromptSizes.actionBarButtonSpacing,
// ),
if (state.supportChatWithFile) ...[
_attachmentButton(context),
const HSpace(
DesktopAIPromptSizes.actionBarButtonSpacing,
),
],
_sendButton(),
],
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);
},
);
},
),
);
}
Widget _selectSourcesButton(BuildContext context) {
return PromptInputDesktopSelectSourcesButton(
onUpdateSelectedSources: onUpdateSelectedSources,
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 _mentionButton(BuildContext context) {
// return PromptInputMentionButton(
// iconSize: DesktopAIPromptSizes.actionBarIconSize,
// buttonSize: DesktopAIPromptSizes.actionBarButtonSize,
// onTap: () {
// if (overlayController.isShowing) {
// return;
// }
// if (!focusNode.hasFocus) {
// focusNode.requestFocus();
// }
// textController.text += '@';
// Future.delayed(Duration.zero, () {
// context
// .read<ChatInputControlCubit>()
// .startSearching(textController.value);
// overlayController.show();
// });
// },
// );
// }
Widget _attachmentButton(BuildContext context) {
Widget _attachmentButton() {
return PromptInputAttachmentButton(
onTap: () async {
final path = await getIt<FilePickerService>().pickFiles(
@@ -576,10 +308,12 @@ class _PromptBottomActions extends StatelessWidget {
}
for (final file in path.files) {
if (file.path != null && context.mounted) {
context
.read<AIPromptInputBloc>()
.add(AIPromptInputEvent.attachFile(file.path!, file.name));
if (file.path != null) {
if (mounted) {
context
.read<AIPromptInputBloc>()
.add(AIPromptInputEvent.newFile(file.path!, file.name));
}
}
}
},
@@ -588,11 +322,59 @@ class _PromptBottomActions extends StatelessWidget {
Widget _sendButton() {
return PromptInputSendButton(
buttonSize: DesktopAIPromptSizes.actionBarHeight,
buttonSize: DesktopAIPromptSizes.actionBarButtonSize,
iconSize: DesktopAIPromptSizes.sendButtonSize,
state: sendButtonState,
onSendPressed: onSendPressed,
onStopStreaming: onStopStreaming,
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;
}
}
@@ -1,6 +1,11 @@
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_control_cubit.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';
@@ -8,12 +13,9 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../layout_define.dart';
import 'ai_prompt_buttons.dart';
import 'chat_input_file.dart';
import 'chat_input_span.dart';
import 'chat_mention_page_bottom_sheet.dart';
import 'select_sources_bottom_sheet.dart';
import '../layout_define.dart';
class MobileAIPromptInput extends StatefulWidget {
const MobileAIPromptInput({
@@ -22,23 +24,21 @@ class MobileAIPromptInput extends StatefulWidget {
required this.isStreaming,
required this.onStopStreaming,
required this.onSubmitted,
required this.onUpdateSelectedSources,
});
final String chatId;
final bool isStreaming;
final void Function() onStopStreaming;
final void Function(String, Map<String, dynamic>) onSubmitted;
final void Function(List<String>) onUpdateSelectedSources;
@override
State<MobileAIPromptInput> createState() => _MobileAIPromptInputState();
}
class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
final inputControlCubit = ChatInputControlCubit();
final focusNode = FocusNode();
final textController = TextEditingController();
late final ChatInputActionControl _inputActionControl;
late final FocusNode _inputFocusNode;
late final TextEditingController _textController;
late SendButtonState sendButtonState;
@@ -46,13 +46,20 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
void initState() {
super.initState();
textController.addListener(handleTextControllerChange);
// focusNode.onKeyEvent = handleKeyEvent;
_textController = TextEditingController()
..addListener(_handleTextControllerChange);
_inputFocusNode = FocusNode();
WidgetsBinding.instance.addPostFrameCallback((_) {
focusNode.requestFocus();
_inputFocusNode.requestFocus();
});
_inputActionControl = ChatInputActionControl(
chatId: widget.chatId,
textController: _textController,
textFieldFocusNode: _inputFocusNode,
);
updateSendButtonState();
}
@@ -64,9 +71,9 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
@override
void dispose() {
focusNode.dispose();
textController.dispose();
inputControlCubit.close();
_inputFocusNode.dispose();
_textController.dispose();
_inputActionControl.dispose();
super.dispose();
}
@@ -74,71 +81,55 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
Widget build(BuildContext context) {
return Hero(
tag: "ai_chat_prompt",
child: BlocProvider.value(
value: inputControlCubit,
child: BlocListener<ChatInputControlCubit, ChatInputControlState>(
listener: (context, state) {
state.maybeWhen(
updateSelectedViews: (selectedViews) {
context.read<AIPromptInputBloc>().add(
AIPromptInputEvent.updateMentionedViews(selectedViews),
);
},
orElse: () {},
);
},
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.removeFile(file)),
),
),
Container(
constraints: const BoxConstraints(
minHeight: MobileAIPromptSizes.textFieldMinHeight,
maxHeight: 220,
),
child: IntrinsicHeight(
child: Row(
children: [
const HSpace(8.0),
leadingButtons(context),
Expanded(
child: inputTextField(context),
),
sendButton(),
const HSpace(12.0),
],
),
),
),
],
),
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(),
],
),
),
),
],
),
),
);
@@ -147,175 +138,138 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
void updateSendButtonState() {
if (widget.isStreaming) {
sendButtonState = SendButtonState.streaming;
} else if (textController.text.trim().isEmpty) {
} else if (_textController.text.trim().isEmpty) {
sendButtonState = SendButtonState.disabled;
} else {
sendButtonState = SendButtonState.enabled;
}
}
void handleSendPressed() {
if (widget.isStreaming) {
return;
}
final trimmedText = inputControlCubit.formatIntputText(
textController.text.trim(),
);
textController.clear();
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();
// get the attached files and mentioned pages
final metadata = context.read<AIPromptInputBloc>().consumeMetadata();
// combine metadata
final Map<String, dynamic> metadata = {}
..addAll(mentionPageMetadata)
..addAll(fileMetadata);
widget.onSubmitted(trimmedText, metadata);
}
void handleTextControllerChange() {
if (textController.value.isComposingRangeValid) {
void _handleTextControllerChange() {
if (_textController.value.isComposingRangeValid) {
return;
}
// inputControlCubit.updateInputText(textController.text);
setState(() => updateSendButtonState());
}
// KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) {
// if (event.character == '@') {
// WidgetsBinding.instance.addPostFrameCallback((_) {
// mentionPage(context);
// });
// }
// return KeyEventResult.ignored;
// }
Future<void> mentionPage(BuildContext context) async {
// if the focus node is on focus, unfocus it for better animation
// otherwise, the page sheet animation will be blocked by the keyboard
inputControlCubit.refreshViews();
inputControlCubit.startSearching(textController.value);
if (focusNode.hasFocus) {
focusNode.unfocus();
await Future.delayed(const Duration(milliseconds: 100));
}
if (context.mounted) {
final selectedView = await showPageSelectorSheet(
context,
filter: (view) =>
!view.isSpace &&
view.layout.isDocumentView &&
view.parentViewId != view.id &&
!inputControlCubit.selectedViewIds.contains(view.id),
);
if (selectedView != null) {
final newText = textController.text.replaceRange(
inputControlCubit.filterStartPosition,
inputControlCubit.filterStartPosition,
selectedView.id,
);
textController.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(
offset:
textController.selection.baseOffset + selectedView.id.length,
affinity: TextAffinity.upstream,
),
);
inputControlCubit.selectPage(selectedView);
}
focusNode.requestFocus();
inputControlCubit.reset();
}
}
Widget inputTextField(BuildContext context) {
Widget _inputTextField(BuildContext context) {
return BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
builder: (context, state) {
return ExtendedTextField(
controller: textController,
focusNode: focusNode,
decoration: 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,
),
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(
inputControlCubit: inputControlCubit,
specialTextStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
inputActionControl: _inputActionControl,
),
onChanged: (text) {
_handleOnTextChange(context, text);
},
);
},
);
}
Widget leadingButtons(BuildContext context) {
return Container(
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,
padding: const EdgeInsets.only(bottom: 8.0),
child: _LeadingActions(
textController: textController,
// onMention: () {
// textController.text += '@';
// if (!focusNode.hasFocus) {
// focusNode.requestFocus();
// }
// WidgetsBinding.instance.addPostFrameCallback((_) {
// mentionPage(context);
// });
// },
onUpdateSelectedSources: widget.onUpdateSelectedSources,
child: PromptInputMentionButton(
iconSize: MobileAIPromptSizes.mentionIconSize,
buttonSize: MobileAIPromptSizes.sendButtonSize,
onTap: () {
_textController.text += '@';
if (!_inputFocusNode.hasFocus) {
_inputFocusNode.requestFocus();
}
_handleOnTextChange(context, _textController.text);
},
),
);
}
Widget sendButton() {
return Container(
Widget _sendButton() {
return Align(
alignment: Alignment.bottomCenter,
padding: const EdgeInsets.only(bottom: 8.0),
child: PromptInputSendButton(
buttonSize: MobileAIPromptSizes.sendButtonSize,
iconSize: MobileAIPromptSizes.sendButtonSize,
onSendPressed: handleSendPressed,
onSendPressed: _handleSendPressed,
onStopStreaming: widget.onStopStreaming,
state: sendButtonState,
),
);
}
}
class _LeadingActions extends StatelessWidget {
const _LeadingActions({
required this.textController,
required this.onUpdateSelectedSources,
});
final TextEditingController textController;
final void Function(List<String>) onUpdateSelectedSources;
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).cardColor,
child: PromptInputMobileSelectSourcesButton(
onUpdateSelectedSources: onUpdateSelectedSources,
),
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();
}
}
@@ -1,263 +0,0 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/flowy_search_text_field.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
import 'package:appflowy/plugins/base/drag_handler.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'select_sources_menu.dart';
class PromptInputMobileSelectSourcesButton extends StatefulWidget {
const PromptInputMobileSelectSourcesButton({
super.key,
required this.onUpdateSelectedSources,
});
final void Function(List<String>) onUpdateSelectedSources;
@override
State<PromptInputMobileSelectSourcesButton> createState() =>
_PromptInputMobileSelectSourcesButtonState();
}
class _PromptInputMobileSelectSourcesButtonState
extends State<PromptInputMobileSelectSourcesButton> {
late final cubit = ChatSettingsCubit();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
cubit.updateSelectedSources(
context.read<ChatBloc>().state.selectedSourceIds,
);
});
}
@override
void dispose() {
cubit.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<UserWorkspaceBloc, UserWorkspaceState>(
builder: (context, state) {
final userProfile = context.read<UserWorkspaceBloc>().userProfile;
final workspaceId = state.currentWorkspace?.workspaceId ?? '';
return MultiBlocProvider(
providers: [
BlocProvider(
key: ValueKey(workspaceId),
create: (context) => SpaceBloc(
userProfile: userProfile,
workspaceId: workspaceId,
)..add(const SpaceEvent.initial(openFirstPage: false)),
),
BlocProvider.value(
value: cubit,
),
],
child: BlocSelector<SpaceBloc, SpaceState, ViewPB?>(
selector: (state) => state.currentSpace,
builder: (context, spaceView) {
return BlocListener<ChatBloc, ChatState>(
listener: (context, state) {
cubit
..updateSelectedSources(state.selectedSourceIds)
..updateSelectedStatus();
},
child: FlowyButton(
margin: const EdgeInsetsDirectional.fromSTEB(4, 6, 2, 6),
expandText: false,
text: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowySvg(
FlowySvgs.ai_page_s,
color: Theme.of(context).iconTheme.color,
size: const Size.square(20.0),
),
FlowySvg(
FlowySvgs.ai_source_drop_down_s,
color: Theme.of(context).hintColor,
size: const Size.square(10),
),
],
),
onTap: () async {
if (spaceView != null) {
context
.read<ChatSettingsCubit>()
.refreshSources(spaceView);
}
await showMobileBottomSheet<void>(
context,
backgroundColor: Theme.of(context).colorScheme.surface,
maxChildSize: 0.98,
enableDraggableScrollable: true,
scrollableWidgetBuilder: (_, scrollController) {
return Expanded(
child: BlocProvider.value(
value: cubit,
child: _MobileSelectSourcesSheetBody(
scrollController: scrollController,
),
),
);
},
builder: (context) => const SizedBox.shrink(),
);
if (context.mounted) {
widget.onUpdateSelectedSources(cubit.selectedSourceIds);
}
},
),
);
},
),
);
},
);
}
}
class _MobileSelectSourcesSheetBody extends StatefulWidget {
const _MobileSelectSourcesSheetBody({
required this.scrollController,
});
final ScrollController scrollController;
@override
State<_MobileSelectSourcesSheetBody> createState() =>
_MobileSelectSourcesSheetBodyState();
}
class _MobileSelectSourcesSheetBodyState
extends State<_MobileSelectSourcesSheetBody> {
final textController = TextEditingController();
@override
void dispose() {
textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return CustomScrollView(
controller: widget.scrollController,
shrinkWrap: true,
slivers: [
SliverPersistentHeader(
pinned: true,
delegate: _Header(
child: ColoredBox(
color: Theme.of(context).cardColor,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const DragHandle(),
SizedBox(
height: 44.0,
child: Center(
child: FlowyText.medium(
LocaleKeys.chat_selectSources.tr(),
fontSize: 16.0,
),
),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
child: SizedBox(
height: 44.0,
child: FlowySearchTextField(
controller: textController,
onChanged: (value) => context
.read<ChatSettingsCubit>()
.updateFilter(value),
),
),
),
const Divider(height: 0.5, thickness: 0.5),
],
),
),
),
),
BlocBuilder<ChatSettingsCubit, ChatSettingsState>(
builder: (context, state) {
final sources = state.visibleSources
.where((e) => e.ignoreStatus != IgnoreViewType.hide);
return SliverList(
delegate: SliverChildBuilderDelegate(
childCount: sources.length,
(context, index) {
final source = sources.elementAt(index);
return ChatSourceTreeItem(
key: ValueKey(
'visible_select_sources_tree_item_${source.view.id}',
),
chatSource: source,
level: 0,
isDescendentOfSpace: source.view.isSpace,
isSelectedSection: false,
onSelected: (chatSource) {
context
.read<ChatSettingsCubit>()
.toggleSelectedStatus(chatSource);
},
height: 40.0,
);
},
),
);
},
),
],
);
}
}
class _Header extends SliverPersistentHeaderDelegate {
const _Header({
required this.child,
});
final Widget child;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return child;
}
@override
double get maxExtent => 120.5;
@override
double get minExtent => 120.5;
@override
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) {
return false;
}
}
@@ -1,551 +0,0 @@
import 'dart:math';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../layout_define.dart';
import 'chat_mention_page_menu.dart';
class PromptInputDesktopSelectSourcesButton extends StatefulWidget {
const PromptInputDesktopSelectSourcesButton({
super.key,
required this.onUpdateSelectedSources,
});
final void Function(bool, List<String>) onUpdateSelectedSources;
@override
State<PromptInputDesktopSelectSourcesButton> createState() =>
_PromptInputDesktopSelectSourcesButtonState();
}
class _PromptInputDesktopSelectSourcesButtonState
extends State<PromptInputDesktopSelectSourcesButton> {
late final cubit = ChatSettingsCubit();
final popoverController = PopoverController();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final chatBlocState = context.read<ChatBloc>().state;
cubit
..updateSelectedSources(chatBlocState.selectedSourceIds)
..updateOnlyUseSelectedSources(chatBlocState.onlyUseSelectedSources);
});
}
@override
void dispose() {
cubit.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
final userProfile = userWorkspaceBloc.userProfile;
final workspaceId =
userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? '';
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => SpaceBloc(
userProfile: userProfile,
workspaceId: workspaceId,
)..add(const SpaceEvent.initial(openFirstPage: false)),
),
BlocProvider.value(
value: cubit,
),
],
child: BlocSelector<SpaceBloc, SpaceState, ViewPB?>(
selector: (state) => state.currentSpace,
builder: (context, spaceView) {
return BlocListener<ChatBloc, ChatState>(
listener: (context, state) {
cubit
..updateOnlyUseSelectedSources(state.onlyUseSelectedSources)
..updateSelectedSources(state.selectedSourceIds)
..updateSelectedStatus();
},
child: AppFlowyPopover(
constraints: BoxConstraints.loose(const Size(320, 380)),
offset: const Offset(0.0, -10.0),
direction: PopoverDirection.topWithCenterAligned,
margin: EdgeInsets.zero,
controller: popoverController,
onOpen: () {
if (spaceView != null) {
context.read<ChatSettingsCubit>().refreshSources(spaceView);
}
},
onClose: () {
widget.onUpdateSelectedSources(
cubit.state.onlyUseSelectedSources,
cubit.selectedSourceIds,
);
if (spaceView != null) {
context.read<ChatSettingsCubit>().refreshSources(spaceView);
}
},
popupBuilder: (_) {
return BlocProvider.value(
value: context.read<ChatSettingsCubit>(),
child: const _PopoverContent(),
);
},
child: _IndicatorButton(
onTap: () => popoverController.show(),
),
),
);
},
),
);
}
}
class _IndicatorButton extends StatelessWidget {
const _IndicatorButton({
required this.onTap,
});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: SizedBox(
height: DesktopAIPromptSizes.actionBarButtonSize,
child: FlowyHover(
style: const HoverStyle(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: Padding(
padding: const EdgeInsetsDirectional.fromSTEB(6, 6, 4, 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowySvg(
FlowySvgs.ai_page_s,
color: Theme.of(context).iconTheme.color,
),
const HSpace(2.0),
BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
return FlowyText(
state.selectedSourceIds.length.toString(),
fontSize: 14,
figmaLineHeight: 16,
color: Theme.of(context).hintColor,
);
},
),
const HSpace(2.0),
FlowySvg(
FlowySvgs.ai_source_drop_down_s,
color: Theme.of(context).hintColor,
size: const Size.square(10),
),
],
),
),
),
),
);
}
}
class _PopoverContent extends StatelessWidget {
const _PopoverContent();
@override
Widget build(BuildContext context) {
return BlocBuilder<ChatSettingsCubit, ChatSettingsState>(
builder: (context, state) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 12, 8, 8),
child: SpaceSearchField(
width: 600,
onSearch: (context, value) =>
context.read<ChatSettingsCubit>().updateFilter(value),
),
),
Container(
margin: const EdgeInsets.fromLTRB(8, 0, 8, 8),
height: 30,
child: FlowyButton(
text: FlowyText(
LocaleKeys.chat_onlyUseRags.tr(),
overflow: TextOverflow.ellipsis,
),
onTap: () {
context
.read<ChatSettingsCubit>()
.updateOnlyUseSelectedSources(
!state.onlyUseSelectedSources,
);
},
rightIcon: state.onlyUseSelectedSources
? FlowySvg(
FlowySvgs.check_s,
color: Theme.of(context).colorScheme.primary,
)
: null,
),
),
_buildDivider(),
Flexible(
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
children: [
..._buildSelectedSources(context, state),
if (state.selectedSources.isNotEmpty &&
state.visibleSources.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: _buildDivider(),
),
..._buildVisibleSources(context, state),
],
),
),
],
);
},
);
}
Widget _buildDivider() {
return const Divider(
height: 1.0,
thickness: 1.0,
indent: 8.0,
endIndent: 8.0,
);
}
Iterable<Widget> _buildSelectedSources(
BuildContext context,
ChatSettingsState state,
) {
return state.selectedSources
.where((e) => e.ignoreStatus != IgnoreViewType.hide)
.map(
(e) => ChatSourceTreeItem(
key: ValueKey(
'selected_select_sources_tree_item_${e.view.id}',
),
chatSource: e,
level: 0,
isDescendentOfSpace: e.view.isSpace,
isSelectedSection: true,
onSelected: (chatSource) {
context
.read<ChatSettingsCubit>()
.toggleSelectedStatus(chatSource);
},
height: 30.0,
),
);
}
Iterable<Widget> _buildVisibleSources(
BuildContext context,
ChatSettingsState state,
) {
return state.visibleSources
.where((e) => e.ignoreStatus != IgnoreViewType.hide)
.map(
(e) => ChatSourceTreeItem(
key: ValueKey(
'visible_select_sources_tree_item_${e.view.id}',
),
chatSource: e,
level: 0,
isDescendentOfSpace: e.view.isSpace,
isSelectedSection: false,
onSelected: (chatSource) {
context
.read<ChatSettingsCubit>()
.toggleSelectedStatus(chatSource);
},
height: 30.0,
),
);
}
}
class ChatSourceTreeItem extends StatefulWidget {
const ChatSourceTreeItem({
super.key,
required this.chatSource,
required this.level,
required this.isDescendentOfSpace,
required this.isSelectedSection,
required this.onSelected,
required this.height,
this.showCheckbox = true,
});
final ChatSource chatSource;
/// nested level of the view item
final int level;
final bool isDescendentOfSpace;
final bool isSelectedSection;
final void Function(ChatSource chatSource) onSelected;
final double height;
final bool showCheckbox;
@override
State<ChatSourceTreeItem> createState() => _ChatSourceTreeItemState();
}
class _ChatSourceTreeItemState extends State<ChatSourceTreeItem> {
@override
Widget build(BuildContext context) {
final child = SizedBox(
height: widget.height,
child: ChatSourceTreeItemInner(
chatSource: widget.chatSource,
level: widget.level,
isDescendentOfSpace: widget.isDescendentOfSpace,
isSelectedSection: widget.isSelectedSection,
showCheckbox: widget.showCheckbox,
onSelected: widget.onSelected,
),
);
final disabledEnabledChild =
widget.chatSource.ignoreStatus == IgnoreViewType.disable
? FlowyTooltip(
message: widget.showCheckbox
? switch (widget.chatSource.view.layout) {
ViewLayoutPB.Document =>
LocaleKeys.chat_sourcesLimitReached.tr(),
_ => LocaleKeys.chat_sourceUnsupported.tr(),
}
: "",
child: Opacity(
opacity: 0.5,
child: MouseRegion(
cursor: SystemMouseCursors.forbidden,
child: IgnorePointer(child: child),
),
),
)
: child;
return ValueListenableBuilder(
valueListenable: widget.chatSource.isExpandedNotifier,
builder: (context, isExpanded, child) {
// filter the child views that should be ignored
final childViews = widget.chatSource.children
.where((e) => e.ignoreStatus != IgnoreViewType.hide)
.toList();
if (!isExpanded || childViews.isEmpty) {
return disabledEnabledChild;
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
disabledEnabledChild,
...childViews.map(
(childSource) => ChatSourceTreeItem(
key: ValueKey(
'select_sources_tree_item_${childSource.view.id}',
),
chatSource: childSource,
level: widget.level + 1,
isDescendentOfSpace: widget.isDescendentOfSpace,
isSelectedSection: widget.isSelectedSection,
onSelected: widget.onSelected,
height: widget.height,
showCheckbox: widget.showCheckbox,
),
),
],
);
},
);
}
}
class ChatSourceTreeItemInner extends StatelessWidget {
const ChatSourceTreeItemInner({
super.key,
required this.chatSource,
required this.level,
required this.isDescendentOfSpace,
required this.isSelectedSection,
required this.showCheckbox,
this.onSelected,
});
final ChatSource chatSource;
final int level;
final bool isDescendentOfSpace;
final bool isSelectedSection;
final bool showCheckbox;
final void Function(ChatSource)? onSelected;
@override
Widget build(BuildContext context) {
return FlowyHover(
style: HoverStyle(
hoverColor: AFThemeExtension.of(context).lightGreyHover,
),
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => onSelected?.call(chatSource),
child: Row(
children: [
const HSpace(4.0),
HSpace(max(20.0 * level - (isDescendentOfSpace ? 2 : 0), 0)),
// builds the >, ^ or · button
ToggleIsExpandedButton(
chatSource: chatSource,
isSelectedSection: isSelectedSection,
),
const HSpace(2.0),
// checkbox
if (!chatSource.view.isSpace && showCheckbox) ...[
SourceSelectedStatusCheckbox(
chatSource: chatSource,
),
const HSpace(4.0),
],
// icon
MentionViewIcon(
view: chatSource.view,
),
const HSpace(6.0),
// title
Expanded(
child: FlowyText(
chatSource.view.nameOrDefault,
overflow: TextOverflow.ellipsis,
fontSize: 14.0,
figmaLineHeight: 18.0,
),
),
],
),
),
);
}
}
class ToggleIsExpandedButton extends StatelessWidget {
const ToggleIsExpandedButton({
super.key,
required this.chatSource,
required this.isSelectedSection,
});
final ChatSource chatSource;
final bool isSelectedSection;
@override
Widget build(BuildContext context) {
if (isReferencedDatabaseView(chatSource.view, chatSource.parentView)) {
return const _DotIconWidget();
}
if (chatSource.children.isEmpty) {
return const SizedBox.square(dimension: 16.0);
}
return FlowyHover(
child: GestureDetector(
child: ValueListenableBuilder(
valueListenable: chatSource.isExpandedNotifier,
builder: (context, value, _) => FlowySvg(
value
? FlowySvgs.view_item_expand_s
: FlowySvgs.view_item_unexpand_s,
size: const Size.square(16.0),
),
),
onTap: () => context
.read<ChatSettingsCubit>()
.toggleIsExpanded(chatSource, isSelectedSection),
),
);
}
}
class _DotIconWidget extends StatelessWidget {
const _DotIconWidget();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(6.0),
child: Container(
width: 4,
height: 4,
decoration: BoxDecoration(
color: Theme.of(context).iconTheme.color,
borderRadius: BorderRadius.circular(2),
),
),
);
}
}
class SourceSelectedStatusCheckbox extends StatelessWidget {
const SourceSelectedStatusCheckbox({
super.key,
required this.chatSource,
});
final ChatSource chatSource;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: chatSource.selectedStatusNotifier,
builder: (context, selectedStatus, _) => FlowySvg(
switch (selectedStatus) {
SourceSelectedStatus.unselected => FlowySvgs.uncheck_s,
SourceSelectedStatus.selected => FlowySvgs.check_filled_s,
SourceSelectedStatus.partiallySelected => FlowySvgs.check_partial_s,
},
size: const Size.square(18.0),
blendMode: null,
),
);
}
}
@@ -0,0 +1,320 @@
import 'dart:math';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_action_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_action_control.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:scroll_to_index/scroll_to_index.dart';
abstract class ChatActionHandler {
void onEnter();
void onSelected(ChatInputMention page);
void onExit();
ChatInputActionBloc get commandBloc;
void onFilter(String filter);
double actionMenuOffsetX();
}
abstract class ChatAnchor {
GlobalKey get anchorKey;
LayerLink get layerLink;
}
const int _itemHeight = 34;
const int _itemVerticalPadding = 4;
const int _noPageHeight = 20;
class ChatActionsMenu {
ChatActionsMenu({
required this.anchor,
required this.context,
required this.handler,
required this.style,
});
final BuildContext context;
final ChatAnchor anchor;
final ChatActionsMenuStyle style;
final ChatActionHandler handler;
OverlayEntry? _overlayEntry;
void dismiss() {
_overlayEntry?.remove();
_overlayEntry = null;
handler.onExit();
}
void show() {
WidgetsBinding.instance.addPostFrameCallback((_) => _show());
}
void _show() {
if (_overlayEntry != null) {
dismiss();
}
if (anchor.anchorKey.currentContext == null) {
return;
}
handler.onEnter();
const double maxHeight = 300;
_overlayEntry = OverlayEntry(
builder: (context) => BlocProvider.value(
value: handler.commandBloc,
child: BlocBuilder<ChatInputActionBloc, ChatInputActionState>(
builder: (context, state) {
final height = min(
max(
state.pages.length * (_itemHeight + _itemVerticalPadding),
_noPageHeight,
),
maxHeight,
);
final isLoading =
state.indicator == const ChatActionMenuIndicator.loading();
return Stack(
children: [
CompositedTransformFollower(
link: anchor.layerLink,
showWhenUnlinked: false,
offset: Offset(handler.actionMenuOffsetX(), -height - 4),
child: Material(
elevation: 4.0,
child: ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 200,
maxWidth: 200,
maxHeight: maxHeight,
),
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6.0),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 2,
vertical: 2,
),
child: ActionList(
isLoading: isLoading,
handler: handler,
onDismiss: () => dismiss(),
pages: state.pages,
),
),
),
),
),
),
],
);
},
),
),
);
Overlay.of(context).insert(_overlayEntry!);
}
}
class _ActionItem extends StatelessWidget {
const _ActionItem({
required this.item,
required this.onTap,
required this.isSelected,
});
final ChatInputMention item;
final VoidCallback? onTap;
final bool isSelected;
@override
Widget build(BuildContext context) {
return Container(
height: _itemHeight.toDouble(),
padding: const EdgeInsets.symmetric(vertical: _itemVerticalPadding / 2.0),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.primary.withOpacity(0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(4.0),
),
child: 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,
),
),
);
}
}
class ActionList extends StatefulWidget {
const ActionList({
super.key,
required this.handler,
required this.onDismiss,
required this.pages,
required this.isLoading,
});
final ChatActionHandler handler;
final VoidCallback? onDismiss;
final List<ChatInputMention> pages;
final bool isLoading;
@override
State<ActionList> createState() => _ActionListState();
}
class _ActionListState extends State<ActionList> {
int _selectedIndex = 0;
final _scrollController = AutoScrollController();
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
KeyEventResult _handleKeyPress(logicalKey) {
bool isHandle = false;
setState(() {
if (logicalKey == PhysicalKeyboardKey.arrowDown) {
_selectedIndex = (_selectedIndex + 1) % widget.pages.length;
_scrollToSelectedIndex();
isHandle = true;
} else if (logicalKey == PhysicalKeyboardKey.arrowUp) {
_selectedIndex =
(_selectedIndex - 1 + widget.pages.length) % widget.pages.length;
_scrollToSelectedIndex();
isHandle = true;
} else if (logicalKey == PhysicalKeyboardKey.enter) {
widget.handler.onSelected(widget.pages[_selectedIndex]);
widget.onDismiss?.call();
isHandle = true;
} else if (logicalKey == PhysicalKeyboardKey.escape) {
widget.onDismiss?.call();
isHandle = true;
}
});
return isHandle ? KeyEventResult.handled : KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
return BlocListener<ChatInputActionBloc, ChatInputActionState>(
listenWhen: (previous, current) =>
previous.keyboardKey != current.keyboardKey,
listener: (context, state) {
if (state.keyboardKey != null) {
_handleKeyPress(state.keyboardKey!.physicalKey);
}
},
child: ListView(
shrinkWrap: true,
controller: _scrollController,
padding: const EdgeInsets.all(4),
children: _buildPages(),
),
);
}
List<Widget> _buildPages() {
if (widget.isLoading) {
return [
SizedBox(
height: _noPageHeight.toDouble(),
child: const Center(child: CircularProgressIndicator.adaptive()),
),
];
}
if (widget.pages.isEmpty) {
return [
SizedBox(
height: _noPageHeight.toDouble(),
child:
Center(child: FlowyText(LocaleKeys.chat_inputActionNoPages.tr())),
),
];
}
return widget.pages.asMap().entries.map((entry) {
final index = entry.key;
final ChatInputMention item = entry.value;
return AutoScrollTag(
key: ValueKey(item.pageId),
index: index,
controller: _scrollController,
child: _ActionItem(
item: item,
onTap: () {
widget.handler.onSelected(item);
widget.onDismiss?.call();
},
isSelected: _selectedIndex == index,
),
);
}).toList();
}
void _scrollToSelectedIndex() {
_scrollController.scrollToIndex(
_selectedIndex,
duration: const Duration(milliseconds: 200),
preferPosition: AutoScrollPosition.begin,
);
}
}
class ChatActionsMenuStyle {
ChatActionsMenuStyle({
required this.backgroundColor,
required this.groupTextColor,
required this.menuItemTextColor,
required this.menuItemSelectedColor,
required this.menuItemSelectedTextColor,
});
const ChatActionsMenuStyle.light()
: backgroundColor = Colors.white,
groupTextColor = const Color(0xFF555555),
menuItemTextColor = const Color(0xFF333333),
menuItemSelectedColor = const Color(0xFFE0F8FF),
menuItemSelectedTextColor = const Color.fromARGB(255, 56, 91, 247);
const ChatActionsMenuStyle.dark()
: backgroundColor = const Color(0xFF282E3A),
groupTextColor = const Color(0xFFBBC3CD),
menuItemTextColor = const Color(0xFFBBC3CD),
menuItemSelectedColor = const Color(0xFF00BCF0),
menuItemSelectedTextColor = const Color(0xFF131720);
final Color backgroundColor;
final Color groupTextColor;
final Color menuItemTextColor;
final Color menuItemSelectedColor;
final Color menuItemSelectedTextColor;
}
@@ -0,0 +1,83 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_bloc/flutter_bloc.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;
@override
Widget build(BuildContext context) {
final slice = Duration(milliseconds: duration.inMilliseconds ~/ 5);
return SizedBox(
height: 20,
child: SeparatedRow(
separatorBuilder: () => const HSpace(4),
children: [
Padding(
padding: const EdgeInsetsDirectional.only(end: 4.0),
child: BlocBuilder<ChatAIMessageBloc, ChatAIMessageState>(
builder: (context, state) {
return FlowyText(
state.progress?.step ??
LocaleKeys.chat_generatingResponse.tr(),
color: Theme.of(context).hintColor,
);
},
),
),
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),
],
),
);
}
Widget buildDot(Color color) {
return SizedBox.square(
dimension: 4,
child: DecoratedBox(
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
),
),
);
}
}
@@ -7,8 +7,6 @@ import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:universal_platform/universal_platform.dart';
import 'layout_define.dart';
class RelatedQuestionList extends StatelessWidget {
const RelatedQuestionList({
super.key,
@@ -25,8 +23,10 @@ class RelatedQuestionList extends StatelessWidget {
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: relatedQuestions.length + 1,
padding:
const EdgeInsets.only(bottom: 8.0) + AIChatUILayout.messageMargin,
padding: const EdgeInsets.only(bottom: 8.0) +
(UniversalPlatform.isMobile
? const EdgeInsets.symmetric(horizontal: 16)
: EdgeInsets.zero),
separatorBuilder: (context, index) => const VSpace(4.0),
itemBuilder: (context, index) {
if (index == 0) {
@@ -39,12 +39,9 @@ class RelatedQuestionList extends StatelessWidget {
),
);
} else {
return Align(
alignment: AlignmentDirectional.centerStart,
child: RelatedQuestionItem(
question: relatedQuestions[index - 1],
onQuestionSelected: onQuestionSelected,
),
return RelatedQuestionItem(
question: relatedQuestions[index - 1],
onQuestionSelected: onQuestionSelected,
);
}
},
@@ -65,15 +62,11 @@ class RelatedQuestionItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
return FlowyButton(
mainAxisAlignment: MainAxisAlignment.start,
text: Flexible(
child: FlowyText(
question,
lineHeight: 1.4,
overflow: TextOverflow.ellipsis,
),
text: FlowyText(
question,
lineHeight: 1.4,
overflow: TextOverflow.ellipsis,
),
expandText: false,
margin: UniversalPlatform.isMobile
? const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0)
: const EdgeInsets.all(8.0),
@@ -0,0 +1,50 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_side_panel_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});
@override
Widget build(BuildContext context) {
return BlocBuilder<ChatSidePanelBloc, ChatSidePanelState>(
builder: (context, state) {
return state.indicator.when(
loading: () {
return const CircularProgressIndicator.adaptive();
},
ready: (view) {
final plugin = view.plugin();
plugin.init();
final pluginContext = PluginContext();
final child = plugin.widgetBuilder
.buildWidget(context: pluginContext, shrinkWrap: false);
return 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());
},
),
),
const VSpace(6),
Expanded(child: child),
],
);
},
);
},
);
}
}
@@ -0,0 +1,52 @@
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_core/flutter_chat_core.dart';
import 'package:universal_platform/universal_platform.dart';
class ChatInvalidUserMessage extends StatelessWidget {
const ChatInvalidUserMessage({
required this.message,
super.key,
});
final Message message;
@override
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,
),
const HSpace(8.0),
Flexible(
child: FlowyText(
errorMessage,
lineHeight: 1.4,
maxLines: null,
),
),
],
),
),
);
}
}
@@ -180,7 +180,7 @@ class WelcomeSampleQuestion extends StatelessWidget {
}
}
class _AutoScrollingSampleQuestions extends StatefulWidget {
class _AutoScrollingSampleQuestions extends StatelessWidget {
const _AutoScrollingSampleQuestions({
super.key,
required this.questions,
@@ -194,29 +194,17 @@ class _AutoScrollingSampleQuestions extends StatefulWidget {
final double offset;
final bool reverse;
@override
State<_AutoScrollingSampleQuestions> createState() =>
_AutoScrollingSampleQuestionsState();
}
class _AutoScrollingSampleQuestionsState
extends State<_AutoScrollingSampleQuestions> {
late final scrollController = ScrollController(
initialScrollOffset: widget.offset,
);
@override
Widget build(BuildContext context) {
return SizedBox(
height: 36,
child: InfiniteScrollView(
scrollController: scrollController,
centerKey: UniqueKey(),
itemCount: widget.questions.length,
itemCount: questions.length,
itemBuilder: (context, index) {
return WelcomeSampleQuestion(
question: widget.questions[index],
onSelected: widget.onSelected,
question: questions[index],
onSelected: onSelected,
);
},
separatorBuilder: (context, index) => const HSpace(8),
@@ -11,32 +11,27 @@ class AIChatUILayout {
query.padding.right,
query.viewInsets.bottom + query.padding.bottom,
)
: const EdgeInsets.only(bottom: 24);
: const EdgeInsets.only(bottom: 16);
}
static EdgeInsets get messageMargin => UniversalPlatform.isMobile
? const EdgeInsets.symmetric(horizontal: 16)
: EdgeInsets.zero;
}
class DesktopAIPromptSizes {
static const promptFrameRadius = BorderRadius.all(Radius.circular(12.0));
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 = 40.0;
static const textFieldMinHeight = 36.0;
static const textFieldContentPadding =
EdgeInsetsDirectional.fromSTEB(14.0, 12.0, 14.0, 8.0);
EdgeInsetsDirectional.fromSTEB(12.0, 12.0, 12.0, 4.0);
static const actionBarHeight = 32.0;
static const actionBarPadding = EdgeInsetsDirectional.fromSTEB(8, 0, 8, 4);
static const actionBarButtonSize = 28.0;
static const actionBarHeight = 28.0;
static const actionBarButtonSize = 24.0;
static const actionBarIconSize = 16.0;
static const actionBarButtonSpacing = 4.0;
static const sendButtonSize = 24.0;
static const sendButtonSize = 20.0;
}
class MobileAIPromptSizes {
@@ -61,11 +56,7 @@ class DesktopAIConvoSizes {
static const avatarAndChatBubbleSpacing = 12.0;
static const actionBarIconSize = 28.0;
static const actionBarIconSize = 24.0;
static const actionBarIconSpacing = 8.0;
static const hoverActionBarPadding = EdgeInsets.all(2.0);
static const hoverActionBarRadius = BorderRadius.all(Radius.circular(8.0));
static const hoverActionBarIconRadius =
BorderRadius.all(Radius.circular(6.0));
static const actionBarIconRadius = BorderRadius.all(Radius.circular(8.0));
}
@@ -8,7 +8,6 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:universal_platform/universal_platform.dart';
import '../chat_editor_style.dart';
@@ -107,7 +106,7 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
shrinkWrap: true,
// the editor is not editable in the chat
editable: false,
disableKeyboardService: UniversalPlatform.isMobile,
disableKeyboardService: true,
editorStyle: editorStyle,
editorScrollController: scrollController,
blockComponentBuilders: blockBuilders,
@@ -1,451 +0,0 @@
import 'dart:convert';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_edit_document_service.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/shared/markdown_to_document.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/application/user/prelude.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import '../chat_input/select_sources_menu.dart';
import '../layout_define.dart';
import 'message_util.dart';
class AIMessageActionBar extends StatelessWidget {
const AIMessageActionBar({
super.key,
required this.message,
required this.showDecoration,
this.onRegenerate,
this.onOverrideVisibility,
});
final Message message;
final bool showDecoration;
final void Function()? onRegenerate;
final void Function(bool)? onOverrideVisibility;
@override
Widget build(BuildContext context) {
final isLightMode = Theme.of(context).isLightMode;
final child = SeparatedRow(
mainAxisSize: MainAxisSize.min,
separatorBuilder: () =>
const HSpace(DesktopAIConvoSizes.actionBarIconSpacing),
children: _buildChildren(),
);
return showDecoration
? Container(
padding: const EdgeInsets.all(2.0),
decoration: BoxDecoration(
borderRadius: DesktopAIConvoSizes.hoverActionBarRadius,
border: Border.all(
color: isLightMode
? const Color(0x1F1F2329)
: Theme.of(context).dividerColor,
),
color: Theme.of(context).cardColor,
boxShadow: [
BoxShadow(
offset: const Offset(0, 1),
blurRadius: 2,
spreadRadius: -2,
color: isLightMode
? const Color(0x051F2329)
: Theme.of(context).shadowColor.withOpacity(0.02),
),
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: child,
)
: child;
}
List<Widget> _buildChildren() {
return [
CopyButton(
isInHoverBar: showDecoration,
textMessage: message as TextMessage,
),
RegenerateButton(
isInHoverBar: showDecoration,
onTap: () => onRegenerate?.call(),
),
SaveToPageButton(
textMessage: message as TextMessage,
isInHoverBar: showDecoration,
onOverrideVisibility: onOverrideVisibility,
),
];
}
}
class CopyButton extends StatelessWidget {
const CopyButton({
super.key,
required this.isInHoverBar,
required this.textMessage,
});
final bool isInHoverBar;
final TextMessage textMessage;
@override
Widget build(BuildContext context) {
return FlowyTooltip(
message: LocaleKeys.settings_menu_clickToCopy.tr(),
child: FlowyIconButton(
width: DesktopAIConvoSizes.actionBarIconSize,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
radius: isInHoverBar
? DesktopAIConvoSizes.hoverActionBarIconRadius
: DesktopAIConvoSizes.actionBarIconRadius,
icon: FlowySvg(
FlowySvgs.copy_s,
color: Theme.of(context).hintColor,
size: const Size.square(16),
),
onPressed: () async {
final document = customMarkdownToDocument(textMessage.text);
await getIt<ClipboardService>().setData(
ClipboardServiceData(
plainText: textMessage.text,
inAppJson: jsonEncode(document.toJson()),
),
);
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.grid_url_copiedNotification.tr(),
);
}
},
),
);
}
}
class RegenerateButton extends StatelessWidget {
const RegenerateButton({
super.key,
required this.isInHoverBar,
required this.onTap,
});
final bool isInHoverBar;
final void Function() onTap;
@override
Widget build(BuildContext context) {
return FlowyTooltip(
message: LocaleKeys.chat_regenerate.tr(),
child: FlowyIconButton(
width: DesktopAIConvoSizes.actionBarIconSize,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
radius: isInHoverBar
? DesktopAIConvoSizes.hoverActionBarIconRadius
: DesktopAIConvoSizes.actionBarIconRadius,
icon: FlowySvg(
FlowySvgs.ai_undo_s,
color: Theme.of(context).hintColor,
size: const Size.square(16),
),
onPressed: onTap,
),
);
}
}
class SaveToPageButton extends StatefulWidget {
const SaveToPageButton({
super.key,
required this.textMessage,
required this.isInHoverBar,
this.onOverrideVisibility,
});
final TextMessage textMessage;
final bool isInHoverBar;
final void Function(bool)? onOverrideVisibility;
@override
State<SaveToPageButton> createState() => _SaveToPageButtonState();
}
class _SaveToPageButtonState extends State<SaveToPageButton> {
final popoverController = PopoverController();
@override
Widget build(BuildContext context) {
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
final userProfile = userWorkspaceBloc.userProfile;
final workspaceId =
userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? '';
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => SpaceBloc(
userProfile: userProfile,
workspaceId: workspaceId,
)..add(const SpaceEvent.initial(openFirstPage: false)),
),
BlocProvider(
create: (context) => ChatSettingsCubit(),
),
],
child: BlocSelector<SpaceBloc, SpaceState, ViewPB?>(
selector: (state) => state.currentSpace,
builder: (context, spaceView) {
return AppFlowyPopover(
controller: popoverController,
triggerActions: PopoverTriggerFlags.none,
margin: EdgeInsets.zero,
offset: const Offset(8, 0),
direction: PopoverDirection.rightWithBottomAligned,
constraints: const BoxConstraints.tightFor(width: 300, height: 400),
onClose: () {
if (spaceView != null) {
context.read<ChatSettingsCubit>().refreshSources(spaceView);
}
widget.onOverrideVisibility?.call(false);
},
child: buildButton(context, spaceView),
popupBuilder: (_) => buildPopover(context),
);
},
),
);
}
Widget buildButton(BuildContext context, ViewPB? spaceView) {
return FlowyTooltip(
message: LocaleKeys.chat_addToPageButton.tr(),
child: FlowyIconButton(
width: DesktopAIConvoSizes.actionBarIconSize,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
radius: widget.isInHoverBar
? DesktopAIConvoSizes.hoverActionBarIconRadius
: DesktopAIConvoSizes.actionBarIconRadius,
icon: FlowySvg(
FlowySvgs.ai_add_to_page_s,
color: Theme.of(context).hintColor,
size: const Size.square(16),
),
onPressed: () async {
final documentId = getOpenedDocumentId();
if (documentId != null) {
await onAddToExistingPage(documentId);
DocumentBloc.findOpen(documentId)?.forceReloadDocumentState();
} else {
widget.onOverrideVisibility?.call(true);
if (spaceView != null) {
context.read<ChatSettingsCubit>().refreshSources(spaceView);
}
popoverController.show();
}
},
),
);
}
Widget buildPopover(BuildContext context) {
return BlocProvider.value(
value: context.read<ChatSettingsCubit>(),
child: _SaveToPagePopoverContent(
onAddToNewPage: () {
addMessageToNewPage(context);
popoverController.close();
},
onAddToExistingPage: (documentId) async {
popoverController.close();
await onAddToExistingPage(documentId);
final view =
await ViewBackendService.getView(documentId).toNullable();
if (context.mounted) {
openPageFromMessage(context, view);
}
},
),
);
}
Future<void> onAddToExistingPage(String documentId) async {
await ChatEditDocumentService.addMessageToPage(
documentId,
widget.textMessage,
);
await Future.delayed(const Duration(milliseconds: 500));
}
void addMessageToNewPage(BuildContext context) async {
final chatView = await ViewBackendService.getView(
context.read<ChatAIMessageBloc>().chatId,
).toNullable();
if (chatView != null) {
final newView = await ChatEditDocumentService.saveMessagesToNewPage(
chatView.nameOrDefault,
chatView.parentViewId,
[widget.textMessage],
);
if (context.mounted) {
openPageFromMessage(context, newView);
}
}
}
String? getOpenedDocumentId() {
final pageManager = getIt<TabsBloc>().state.currentPageManager;
if (!pageManager.showSecondaryPluginNotifier.value) {
return null;
}
return pageManager.secondaryNotifier.plugin.id;
}
}
class _SaveToPagePopoverContent extends StatelessWidget {
const _SaveToPagePopoverContent({
required this.onAddToNewPage,
required this.onAddToExistingPage,
});
final void Function() onAddToNewPage;
final void Function(String) onAddToExistingPage;
@override
Widget build(BuildContext context) {
return BlocBuilder<ChatSettingsCubit, ChatSettingsState>(
builder: (context, state) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
height: 24,
margin: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: Align(
alignment: AlignmentDirectional.centerStart,
child: FlowyText(
LocaleKeys.chat_addToPageTitle.tr(),
fontSize: 12.0,
color: Theme.of(context).hintColor,
),
),
),
Padding(
padding: const EdgeInsets.only(left: 12, right: 12, bottom: 8),
child: SpaceSearchField(
width: 600,
onSearch: (context, value) =>
context.read<ChatSettingsCubit>().updateFilter(value),
),
),
_buildDivider(),
Expanded(
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
children: _buildVisibleSources(context, state).toList(),
),
),
_buildDivider(),
_addToNewPageButton(context),
],
);
},
);
}
Widget _buildDivider() {
return const Divider(
height: 1.0,
thickness: 1.0,
indent: 12.0,
endIndent: 12.0,
);
}
Iterable<Widget> _buildVisibleSources(
BuildContext context,
ChatSettingsState state,
) {
return state.visibleSources
.where((e) => e.ignoreStatus != IgnoreViewType.hide)
.map(
(e) => ChatSourceTreeItem(
key: ValueKey(
'save_to_page_tree_item_${e.view.id}',
),
chatSource: e,
level: 0,
isDescendentOfSpace: e.view.isSpace,
isSelectedSection: false,
showCheckbox: false,
onSelected: (source) {
if (!source.view.isSpace) {
onAddToExistingPage(source.view.id);
}
},
height: 30.0,
),
);
}
Widget _addToNewPageButton(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: SizedBox(
height: 30,
child: FlowyButton(
iconPadding: 8,
onTap: onAddToNewPage,
text: FlowyText(
LocaleKeys.chat_addToNewPage.tr(),
figmaLineHeight: 20,
),
leftIcon: FlowySvg(
FlowySvgs.add_m,
size: const Size.square(16),
color: Theme.of(context).hintColor,
),
),
),
);
}
}
@@ -4,30 +4,26 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_edit_document_service.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_input/chat_mention_page_bottom_sheet.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_avatar.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/shared/markdown_to_document.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:go_router/go_router.dart';
import 'package:universal_platform/universal_platform.dart';
import '../chat_avatar.dart';
import '../layout_define.dart';
import 'ai_message_action_bar.dart';
import 'message_util.dart';
/// Wraps an AI response message with the avatar and actions. On desktop,
/// the actions will be displayed below the response if the response is the
/// last message in the chat. For the others, the actions will be shown on hover
/// On mobile, the actions will be displayed in a bottom sheet on long press.
/// last message in the chat. For other AI responses, the actions will be shown
/// on hover. On mobile, the actions will be displayed in a bottom sheet on
/// long press.
class ChatAIMessageBubble extends StatelessWidget {
const ChatAIMessageBubble({
super.key,
@@ -35,14 +31,12 @@ class ChatAIMessageBubble extends StatelessWidget {
required this.child,
required this.showActions,
this.isLastMessage = false,
this.onRegenerate,
});
final Message message;
final Widget child;
final bool showActions;
final bool isLastMessage;
final void Function()? onRegenerate;
@override
Widget build(BuildContext context) {
@@ -67,7 +61,6 @@ class ChatAIMessageBubble extends StatelessWidget {
Widget _wrapBottomActions(Widget child) {
return ChatAIBottomInlineActions(
message: message,
onRegenerate: onRegenerate,
child: child,
);
}
@@ -75,7 +68,6 @@ class ChatAIMessageBubble extends StatelessWidget {
Widget _wrapHover(Widget child) {
return ChatAIMessageHover(
message: message,
onRegenerate: onRegenerate,
child: child,
);
}
@@ -83,7 +75,6 @@ class ChatAIMessageBubble extends StatelessWidget {
Widget _wrapPopMenu(Widget child) {
return ChatAIMessagePopup(
message: message,
onRegenerate: onRegenerate,
child: child,
);
}
@@ -94,12 +85,10 @@ class ChatAIBottomInlineActions extends StatelessWidget {
super.key,
required this.child,
required this.message,
this.onRegenerate,
});
final Widget child;
final Message message;
final void Function()? onRegenerate;
@override
Widget build(BuildContext context) {
@@ -113,10 +102,13 @@ class ChatAIBottomInlineActions extends StatelessWidget {
start: DesktopAIConvoSizes.avatarSize +
DesktopAIConvoSizes.avatarAndChatBubbleSpacing,
),
child: AIMessageActionBar(
message: message,
child: AIResponseActionBar(
showDecoration: false,
onRegenerate: onRegenerate,
children: [
CopyButton(
textMessage: message as TextMessage,
),
],
),
),
const VSpace(32.0),
@@ -130,12 +122,10 @@ class ChatAIMessageHover extends StatefulWidget {
super.key,
required this.child,
required this.message,
this.onRegenerate,
});
final Widget child;
final Message message;
final void Function()? onRegenerate;
@override
State<ChatAIMessageHover> createState() => _ChatAIMessageHoverState();
@@ -147,7 +137,6 @@ class _ChatAIMessageHoverState extends State<ChatAIMessageHover> {
bool hoverBubble = false;
bool hoverActionBar = false;
bool overrideVisibility = false;
ScrollPosition? scrollPosition;
@@ -206,20 +195,19 @@ class _ChatAIMessageHoverState extends State<ChatAIMessageHover> {
}
},
child: Container(
constraints: BoxConstraints(
constraints: const BoxConstraints(
maxWidth: 784,
maxHeight: DesktopAIConvoSizes.actionBarIconSize +
DesktopAIConvoSizes.hoverActionBarPadding.vertical,
maxHeight: 28,
),
alignment: Alignment.topLeft,
child: hoverBubble || hoverActionBar || overrideVisibility
? AIMessageActionBar(
message: widget.message,
child: hoverBubble || hoverActionBar
? AIResponseActionBar(
showDecoration: true,
onRegenerate: widget.onRegenerate,
onOverrideVisibility: (visibility) {
overrideVisibility = visibility;
},
children: [
CopyButton(
textMessage: widget.message as TextMessage,
),
],
)
: null,
),
@@ -268,10 +256,7 @@ class _ChatAIMessageHoverState extends State<ChatAIMessageHover> {
final messageOffset = messageRenderBox.localToGlobal(Offset.zero);
final messageHeight = messageRenderBox.size.height;
return messageOffset.dy +
messageHeight +
DesktopAIConvoSizes.actionBarIconSize +
DesktopAIConvoSizes.hoverActionBarPadding.vertical <=
return messageOffset.dy + messageHeight + 28 <=
scrollableOffset.dy + scrollableHeight;
}
@@ -282,17 +267,115 @@ class _ChatAIMessageHoverState extends State<ChatAIMessageHover> {
}
}
class AIResponseActionBar extends StatelessWidget {
const AIResponseActionBar({
super.key,
required this.showDecoration,
required this.children,
});
final List<Widget> children;
final bool showDecoration;
@override
Widget build(BuildContext context) {
final isLightMode = Theme.of(context).isLightMode;
final child = SeparatedRow(
mainAxisSize: MainAxisSize.min,
separatorBuilder: () =>
const HSpace(DesktopAIConvoSizes.actionBarIconSpacing),
children: children,
);
return showDecoration
? Container(
padding: const EdgeInsets.all(2.0),
decoration: BoxDecoration(
borderRadius: DesktopAIConvoSizes.actionBarIconRadius,
border: Border.all(color: Theme.of(context).dividerColor),
color: Theme.of(context).cardColor,
boxShadow: [
BoxShadow(
offset: const Offset(0, 1),
blurRadius: 2,
spreadRadius: -2,
color: isLightMode
? const Color(0x051F2329)
: Theme.of(context).shadowColor.withOpacity(0.02),
),
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: child,
)
: child;
}
}
class CopyButton extends StatelessWidget {
const CopyButton({
super.key,
required this.textMessage,
});
final TextMessage textMessage;
@override
Widget build(BuildContext context) {
return FlowyTooltip(
message: LocaleKeys.settings_menu_clickToCopy.tr(),
child: FlowyIconButton(
width: DesktopAIConvoSizes.actionBarIconSize,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
radius: DesktopAIConvoSizes.actionBarIconRadius,
icon: FlowySvg(
FlowySvgs.copy_s,
color: Theme.of(context).hintColor,
size: const Size.square(16),
),
onPressed: () async {
final document = customMarkdownToDocument(textMessage.text);
await getIt<ClipboardService>().setData(
ClipboardServiceData(
plainText: textMessage.text,
inAppJson: jsonEncode(document.toJson()),
),
);
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.grid_url_copiedNotification.tr(),
);
}
},
),
);
}
}
class ChatAIMessagePopup extends StatelessWidget {
const ChatAIMessagePopup({
super.key,
required this.child,
required this.message,
this.onRegenerate,
});
final Widget child;
final Message message;
final void Function()? onRegenerate;
@override
Widget build(BuildContext context) {
@@ -307,91 +390,41 @@ class ChatAIMessagePopup extends StatelessWidget {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const VSpace(16.0),
_copyButton(context, bottomSheetContext),
const Divider(height: 8.5, thickness: 0.5),
_regenerateButton(context),
const Divider(height: 8.5, thickness: 0.5),
_saveToPageButton(context),
const Divider(height: 8.5, thickness: 0.5),
MobileQuickActionButton(
onTap: () async {
if (message is! TextMessage) {
return;
}
final textMessage = message as TextMessage;
final document = customMarkdownToDocument(textMessage.text);
await getIt<ClipboardService>().setData(
ClipboardServiceData(
plainText: textMessage.text,
inAppJson: jsonEncode(document.toJson()),
),
);
if (bottomSheetContext.mounted) {
Navigator.of(bottomSheetContext).pop();
}
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.grid_url_copiedNotification.tr(),
);
}
},
icon: FlowySvgs.copy_s,
iconSize: const Size.square(20),
text: LocaleKeys.button_copy.tr(),
),
],
);
},
);
},
child: child,
);
}
Widget _copyButton(BuildContext context, BuildContext bottomSheetContext) {
return MobileQuickActionButton(
onTap: () async {
if (message is! TextMessage) {
return;
}
final textMessage = message as TextMessage;
final document = customMarkdownToDocument(textMessage.text);
await getIt<ClipboardService>().setData(
ClipboardServiceData(
plainText: textMessage.text,
inAppJson: jsonEncode(document.toJson()),
),
);
if (bottomSheetContext.mounted) {
Navigator.of(bottomSheetContext).pop();
}
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.grid_url_copiedNotification.tr(),
);
}
},
icon: FlowySvgs.copy_s,
iconSize: const Size.square(20),
text: LocaleKeys.button_copy.tr(),
);
}
Widget _regenerateButton(BuildContext context) {
return MobileQuickActionButton(
onTap: () {
onRegenerate?.call();
Navigator.of(context).pop();
},
icon: FlowySvgs.ai_undo_s,
iconSize: const Size.square(20),
text: LocaleKeys.chat_regenerate.tr(),
);
}
Widget _saveToPageButton(BuildContext context) {
return MobileQuickActionButton(
onTap: () async {
final selectedView = await showPageSelectorSheet(
context,
filter: (view) =>
!view.isSpace &&
view.layout.isDocumentView &&
view.parentViewId != view.id,
);
if (selectedView == null) {
return;
}
await ChatEditDocumentService.addMessageToPage(
selectedView.id,
message as TextMessage,
);
if (context.mounted) {
context.pop();
openPageFromMessage(context, selectedView);
}
},
icon: FlowySvgs.ai_add_to_page_s,
iconSize: const Size.square(20),
text: LocaleKeys.chat_addToPageButton.tr(),
child: IgnorePointer(
child: child,
),
);
}
}
@@ -1,6 +1,7 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_message_service.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
@@ -16,7 +17,7 @@ class AIMessageMetadata extends StatefulWidget {
});
final List<ChatMessageRefSource> sources;
final void Function(ChatMessageRefSource metadata)? onSelectedMetadata;
final void Function(ChatMessageRefSource metadata) onSelectedMetadata;
@override
State<AIMessageMetadata> createState() => _AIMessageMetadataState();
@@ -42,7 +43,6 @@ class _AIMessageMetadataState extends State<AIMessageMetadata> {
child: FlowyButton(
margin: const EdgeInsets.all(4.0),
useIntrinsicWidth: true,
hoverColor: Colors.transparent,
radius: BorderRadius.circular(8.0),
text: FlowyText(
LocaleKeys.chat_referenceSource.plural(
@@ -87,8 +87,12 @@ class _AIMessageMetadataState extends State<AIMessageMetadata> {
size: const Size.square(16),
color: Theme.of(context).hintColor,
),
disable: m.source != appflowySource,
onTap: () {
widget.onSelectedMetadata?.call(m);
if (m.source != appflowySource) {
return;
}
widget.onSelectedMetadata(m);
},
),
);
Loaded 100 of 653 files, more files were not shown because too many files have changed in this diff. Show more