Compare commits
77
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14f656be75 | ||
|
|
56bbe29058 | ||
|
|
f03d8d35ef | ||
|
|
171c5634f2 | ||
|
|
d7bda10e6f | ||
|
|
20bff9003e | ||
|
|
6e0534400b | ||
|
|
17c116a53b | ||
|
|
27b769362b | ||
|
|
b8e7d57ee6 | ||
|
|
ed052c6792 | ||
|
|
5cd2c63320 | ||
|
|
15567a81e9 | ||
|
|
7ab68dcc2c | ||
|
|
30131fd9e4 | ||
|
|
ddcdd545d9 | ||
|
|
d25a399aba | ||
|
|
ee96a44fef | ||
|
|
dda3962249 | ||
|
|
c2743472bc | ||
|
|
e73fd56152 | ||
|
|
04a013f7ee | ||
|
|
b966e3ea07 | ||
|
|
9d53f758d4 | ||
|
|
0689f4e7e1 | ||
|
|
867d515a35 | ||
|
|
381d946808 | ||
|
|
f307300b96 | ||
|
|
d74e7b63ca | ||
|
|
792eec7d4b | ||
|
|
67e93a12e6 | ||
|
|
e188552c1e | ||
|
|
ebb1b6dffb | ||
|
|
25f9bee963 | ||
|
|
6a6fac7f82 | ||
|
|
555b4b48bb | ||
|
|
3522569f97 | ||
|
|
699ea150ce | ||
|
|
e4385adfa9 | ||
|
|
1d46923c47 | ||
|
|
0bf706f438 | ||
|
|
399b7dd682 | ||
|
|
e8f2940024 | ||
|
|
62d5d66d20 | ||
|
|
8b672a159f | ||
|
|
d68212f4ce | ||
|
|
592390dc84 | ||
|
|
d2b2f17b1c | ||
|
|
2c88653a69 | ||
|
|
45b4eb4b3a | ||
|
|
7b93bbe5ff | ||
|
|
d21c0c0dfc | ||
|
|
f5e46967ec | ||
|
|
e0885e2567 | ||
|
|
3b56887267 | ||
|
|
b5d5312c70 | ||
|
|
722b436cad | ||
|
|
bb50466aa9 | ||
|
|
da0395be5e | ||
|
|
9e82f3d7b8 | ||
|
|
67fe0d6bfd | ||
|
|
dddf5aa195 | ||
|
|
92945cafdf | ||
|
|
5cf6617231 | ||
|
|
03c84ff8b5 | ||
|
|
0cf3ade332 | ||
|
|
7c24b6feb0 | ||
|
|
687121ff14 | ||
|
|
e32c584b3b | ||
|
|
64e4416f54 | ||
|
|
d110e9446e | ||
|
|
826170e110 | ||
|
|
c590d59ed5 | ||
|
|
89b4e334fb | ||
|
|
79c186fa99 | ||
|
|
398a9010bf | ||
|
|
61b66291cb |
No files matched your search
@@ -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 duckscript_cli
|
||||
cargo install --force --locked 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,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:
|
||||
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
working-directory: frontend
|
||||
run: |
|
||||
rustup target install aarch64-apple-ios-sim
|
||||
cargo install --force duckscript_cli
|
||||
cargo install --force --locked duckscript_cli
|
||||
cargo install cargo-lipo
|
||||
cargo make appflowy-flutter-deps-tools
|
||||
shell: bash
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
env:
|
||||
FLUTTER_VERSION: "3.22.0"
|
||||
RUST_TOOLCHAIN: "1.77.2"
|
||||
RUST_TOOLCHAIN: "1.80.1"
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
run: |
|
||||
vcpkg integrate install
|
||||
cargo install --force cargo-make
|
||||
cargo install --force duckscript_cli
|
||||
cargo install --force --locked duckscript_cli
|
||||
|
||||
- name: Build Windows app
|
||||
working-directory: frontend
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
working-directory: frontend
|
||||
run: |
|
||||
cargo install --force cargo-make
|
||||
cargo install --force duckscript_cli
|
||||
cargo install --force --locked duckscript_cli
|
||||
|
||||
- name: Build AppFlowy
|
||||
working-directory: frontend
|
||||
@@ -257,7 +257,7 @@ jobs:
|
||||
working-directory: frontend
|
||||
run: |
|
||||
cargo install --force cargo-make
|
||||
cargo install --force duckscript_cli
|
||||
cargo install --force --locked duckscript_cli
|
||||
|
||||
- name: Build AppFlowy
|
||||
working-directory: frontend
|
||||
@@ -371,7 +371,7 @@ jobs:
|
||||
sudo apt-get install -y alien libnotify-dev
|
||||
source $HOME/.cargo/env
|
||||
cargo install --force cargo-make
|
||||
cargo install --force duckscript_cli
|
||||
cargo install --force --locked duckscript_cli
|
||||
rustup target add ${{ matrix.job.target }}
|
||||
|
||||
- name: Install gcc-aarch64-linux-gnu
|
||||
|
||||
@@ -18,81 +18,11 @@ on:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CLOUD_VERSION: 0.7.6-amd64
|
||||
RUST_TOOLCHAIN: "1.77.2"
|
||||
CLOUD_VERSION: 0.8.3-amd64
|
||||
RUST_TOOLCHAIN: "1.80.1"
|
||||
|
||||
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
|
||||
|
||||
@@ -11,7 +11,7 @@ on:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
FLUTTER_VERSION: "3.22.0"
|
||||
RUST_TOOLCHAIN: "1.77.2"
|
||||
RUST_TOOLCHAIN: "1.80.1"
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
working-directory: frontend
|
||||
run: |
|
||||
cargo install --force cargo-make
|
||||
cargo install --force duckscript_cli
|
||||
cargo install --force --locked duckscript_cli
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
|
||||
@@ -5,13 +5,14 @@ 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.77.2"
|
||||
RUST_TOOLCHAIN: "1.80.1"
|
||||
CARGO_MAKE_VERSION: "0.36.6"
|
||||
CI: true
|
||||
|
||||
@@ -20,34 +21,7 @@ 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:
|
||||
@@ -121,4 +95,4 @@ jobs:
|
||||
with:
|
||||
tauriScript: pnpm tauri
|
||||
projectPath: frontend/appflowy_web_app
|
||||
args: "--debug"
|
||||
args: "--debug"
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
env:
|
||||
NODE_VERSION: "18.16.0"
|
||||
PNPM_VERSION: "8.5.0"
|
||||
RUST_TOOLCHAIN: "1.77.2"
|
||||
RUST_TOOLCHAIN: "1.80.1"
|
||||
|
||||
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 duckscript_cli
|
||||
cargo install --force --locked duckscript_cli
|
||||
vcpkg integrate install
|
||||
|
||||
- name: install dependencies (ubuntu only)
|
||||
@@ -108,4 +108,4 @@ jobs:
|
||||
with:
|
||||
tauriScript: pnpm tauri
|
||||
projectPath: frontend/appflowy_tauri
|
||||
args: "--debug"
|
||||
args: "--debug"
|
||||
@@ -4,20 +4,19 @@ 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.77.2"
|
||||
RUST_TOOLCHAIN: "1.80.1"
|
||||
|
||||
jobs:
|
||||
|
||||
publish-tauri:
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -84,7 +83,7 @@ jobs:
|
||||
if: matrix.settings.platform == 'windows-latest'
|
||||
working-directory: frontend
|
||||
run: |
|
||||
cargo install --force duckscript_cli
|
||||
cargo install --force --locked duckscript_cli
|
||||
vcpkg integrate install
|
||||
|
||||
- name: install dependencies (ubuntu only)
|
||||
|
||||
+32
-1
@@ -1,4 +1,35 @@
|
||||
# Release Notes
|
||||
## Version 0.7.7 - 09/12/2024
|
||||
### New Features
|
||||
- Added support "set to page width" and "distribute columns evenly" for table block
|
||||
- Added support for reordering column/row in table block
|
||||
- Added support inserting multiple lines in table block
|
||||
- Revamped the mention page interactions in AI chat page
|
||||
- Improve the AI referenced sources UI in AI chat page
|
||||
|
||||
### 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
|
||||
@@ -939,4 +970,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
@@ -20,7 +20,7 @@ workflows:
|
||||
|
||||
rustup target install aarch64-apple-ios-sim
|
||||
cargo install --force cargo-make
|
||||
cargo install --force duckscript_cli
|
||||
cargo install --force --locked duckscript_cli
|
||||
cargo install --force cargo-lipo
|
||||
|
||||
cargo make appflowy-flutter-deps-tools
|
||||
|
||||
@@ -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.6"
|
||||
APPFLOWY_VERSION = "0.7.8"
|
||||
FLUTTER_DESKTOP_FEATURES = "dart"
|
||||
PRODUCT_NAME = "AppFlowy"
|
||||
MACOSX_DEPLOYMENT_TARGET = "11.0"
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
<data android:scheme="http" />
|
||||
<data android:scheme="https" />
|
||||
<data android:scheme="appflowy-flutter" />
|
||||
<!-- <data android:host="login-callback" /> -->
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!--
|
||||
@@ -50,7 +49,8 @@
|
||||
<!-- 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>
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
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();
|
||||
|
||||
@@ -22,4 +25,5 @@ Future<void> main() async {
|
||||
|
||||
// sidebar
|
||||
sidebar_move_page_test.main();
|
||||
sidebar_rename_untitled_test.main();
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
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(),
|
||||
);
|
||||
});
|
||||
}
|
||||
+2
@@ -301,6 +301,7 @@ 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");
|
||||
@@ -332,6 +333,7 @@ void main() {
|
||||
await tester.tapButton(finderForFieldType(FieldType.MultiSelect));
|
||||
await tester.selectOption(name: "asdf");
|
||||
await tester.dismissCellEditor();
|
||||
await tester.dismissCellEditor();
|
||||
|
||||
tester.assertNumberOfEventsInCalendar(0);
|
||||
|
||||
|
||||
+1
-1
@@ -538,8 +538,8 @@ void main() {
|
||||
|
||||
// edit the first date cell
|
||||
await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.DateTime);
|
||||
await tester.toggleIncludeTime();
|
||||
final now = DateTime.now();
|
||||
await tester.toggleIncludeTime();
|
||||
await tester.selectDay(content: now.day);
|
||||
|
||||
await tester.dismissCellEditor();
|
||||
|
||||
+16
@@ -139,6 +139,22 @@ 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
+19
-2
@@ -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,6 +330,23 @@ 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
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_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -147,7 +148,7 @@ void main() {
|
||||
tester.expectViewHasIcon(
|
||||
gettingStarted,
|
||||
ViewLayoutPB.Document,
|
||||
punch,
|
||||
EmojiIconData.emoji(punch),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+104
@@ -1,12 +1,15 @@
|
||||
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';
|
||||
|
||||
@@ -174,9 +177,110 @@ 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,
|
||||
|
||||
+342
-1
@@ -1,14 +1,21 @@
|
||||
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';
|
||||
|
||||
@@ -18,7 +25,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();
|
||||
@@ -121,5 +128,339 @@ 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
+13
-12
@@ -24,13 +24,6 @@ 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();
|
||||
@@ -138,11 +131,19 @@ void main() {
|
||||
file.deleteSync();
|
||||
});
|
||||
|
||||
for (final url in _testImageUrls) {
|
||||
testWidgets('insert an image from network: $url', (tester) async {
|
||||
await testEmbedImage(tester, url);
|
||||
});
|
||||
}
|
||||
testWidgets('insert a gif image from network', (tester) async {
|
||||
await testEmbedImage(
|
||||
tester,
|
||||
'https://www.easygifanimator.net/images/samples/sparkles.gif',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('insert a jpg image from network', (tester) async {
|
||||
await testEmbedImage(
|
||||
tester,
|
||||
'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',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('insert an image from unsplash', (tester) async {
|
||||
await runWithNetworkImages(() async {
|
||||
|
||||
+50
@@ -113,5 +113,55 @@ 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
+14
@@ -94,6 +94,20 @@ 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+322
-6
@@ -1,5 +1,4 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/_shared_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
@@ -115,19 +114,41 @@ void main() {
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.insertTableInDocument();
|
||||
|
||||
// hover on the table
|
||||
final tableBlock = find.byType(SimpleTableBlockWidget).first;
|
||||
// add a new row
|
||||
final row = find.byWidgetPredicate((w) {
|
||||
return w is SimpleTableRowBlockWidget && w.node.rowIndex == 1;
|
||||
});
|
||||
await tester.hoverOnWidget(
|
||||
tableBlock,
|
||||
row,
|
||||
onHover: () async {
|
||||
// click the add row button
|
||||
final addRowButton = find.byType(SimpleTableAddRowButton).first;
|
||||
await tester.tap(addRowButton);
|
||||
},
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// click the add column button
|
||||
// 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;
|
||||
@@ -311,6 +332,301 @@ void main() {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
extension on WidgetTester {
|
||||
|
||||
+574
-5
@@ -1,6 +1,13 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/calculate_cell.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/text.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/number.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
@@ -19,9 +26,6 @@ void main() {
|
||||
|
||||
// Change one Field to Number
|
||||
await tester.changeFieldTypeOfFieldWithName('Type', FieldType.Number);
|
||||
|
||||
expect(find.text('Calculate'), findsOneWidget);
|
||||
|
||||
await tester.changeCalculateAtIndex(1, CalculationType.Sum);
|
||||
|
||||
// Enter values in cells
|
||||
@@ -55,7 +59,7 @@ void main() {
|
||||
await tester.changeFieldTypeOfFieldWithName('Type', FieldType.Number);
|
||||
await tester.changeFieldTypeOfFieldWithName('Done', FieldType.Number);
|
||||
|
||||
expect(find.text('Calculate'), findsNWidgets(2));
|
||||
expect(find.text('Calculate'), findsNWidgets(3));
|
||||
|
||||
await tester.changeCalculateAtIndex(1, CalculationType.Sum);
|
||||
await tester.changeCalculateAtIndex(2, CalculationType.Min);
|
||||
@@ -97,11 +101,576 @@ void main() {
|
||||
await tester.hoverOnFirstRowOfGrid();
|
||||
await tester.tapRowMenuButtonInGrid();
|
||||
await tester.tapDeleteOnRowMenu();
|
||||
await tester.tap(find.text(LocaleKeys.button_delete.tr()));
|
||||
|
||||
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||||
|
||||
expect(find.text('150'), findsNWidgets(2));
|
||||
expect(find.text('100'), findsNWidgets(2));
|
||||
});
|
||||
|
||||
testWidgets('Calculations with filter', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
|
||||
|
||||
// Change two Fields to Number
|
||||
await tester.changeFieldTypeOfFieldWithName('Type', FieldType.Number);
|
||||
await tester.changeFieldTypeOfFieldWithName('Done', FieldType.Number);
|
||||
|
||||
expect(find.text('Calculate'), findsNWidgets(3));
|
||||
|
||||
await tester.changeCalculateAtIndex(1, CalculationType.Sum);
|
||||
await tester.changeCalculateAtIndex(2, CalculationType.Min);
|
||||
|
||||
// Enter values in cells
|
||||
await tester.editCell(
|
||||
rowIndex: 0,
|
||||
fieldType: FieldType.Number,
|
||||
input: '100',
|
||||
);
|
||||
await tester.editCell(
|
||||
rowIndex: 1,
|
||||
fieldType: FieldType.Number,
|
||||
input: '150',
|
||||
);
|
||||
await tester.editCell(
|
||||
rowIndex: 2,
|
||||
fieldType: FieldType.Number,
|
||||
input: '100',
|
||||
);
|
||||
await tester.editCell(
|
||||
rowIndex: 0,
|
||||
fieldType: FieldType.Number,
|
||||
input: '150',
|
||||
cellIndex: 1,
|
||||
);
|
||||
await tester.editCell(
|
||||
rowIndex: 1,
|
||||
fieldType: FieldType.Number,
|
||||
input: '100',
|
||||
cellIndex: 1,
|
||||
);
|
||||
await tester.editCell(
|
||||
rowIndex: 2,
|
||||
fieldType: FieldType.Number,
|
||||
input: '50',
|
||||
cellIndex: 1,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Dismiss edit cell
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||||
|
||||
// Expect sum to be 100 + 150 + 100 = 350
|
||||
expect(find.text('350'), findsOneWidget);
|
||||
|
||||
// Expect min to be 50
|
||||
expect(find.text('50'), findsNWidgets(2));
|
||||
|
||||
await tester.tapDatabaseFilterButton();
|
||||
await tester.tapCreateFilterByFieldType(FieldType.Number, 'Type');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byType(NumberFilterChoiceChip));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.descendant(
|
||||
of: find.byType(NumberFilterEditor),
|
||||
matching: find.byType(FlowyTextField),
|
||||
),
|
||||
'100',
|
||||
);
|
||||
await tester.pumpAndSettle(const Duration(seconds: 2));
|
||||
|
||||
// Expect the sum to be 100+100 = 200
|
||||
expect(find.text('200'), findsOneWidget);
|
||||
|
||||
// Expect the min to be 50
|
||||
expect(find.text('50'), findsNWidgets(2));
|
||||
|
||||
await tester.enterText(
|
||||
find.descendant(
|
||||
of: find.byType(NumberFilterEditor),
|
||||
matching: find.byType(FlowyTextField),
|
||||
),
|
||||
'150',
|
||||
);
|
||||
await tester.pumpAndSettle(const Duration(seconds: 2));
|
||||
|
||||
// Expect the sum to be 150 (3 times, text field, cell, and calculate cell)
|
||||
expect(find.text('150'), findsNWidgets(3));
|
||||
|
||||
// Expect the min to be 100
|
||||
expect(find.text('100'), findsNWidgets(2));
|
||||
});
|
||||
|
||||
testWidgets('Calculations count + count empty w/ filter', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
|
||||
expect(find.text('Calculate'), findsNWidgets(3));
|
||||
|
||||
await tester.changeFieldTypeOfFieldWithName('Type', FieldType.RichText);
|
||||
|
||||
await tester.changeCalculateAtIndex(0, CalculationType.Count);
|
||||
await tester.changeCalculateAtIndex(1, CalculationType.CountEmpty);
|
||||
|
||||
// Enter values in 2nd column (count empty)
|
||||
await tester.editCell(
|
||||
rowIndex: 0,
|
||||
fieldType: FieldType.RichText,
|
||||
input: 'A',
|
||||
cellIndex: 1,
|
||||
);
|
||||
await tester.editCell(
|
||||
rowIndex: 1,
|
||||
fieldType: FieldType.RichText,
|
||||
input: 'A',
|
||||
cellIndex: 1,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Dismiss edit cell
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||||
|
||||
// Expect count to be 3
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) =>
|
||||
w is CalculateCell &&
|
||||
w.calculation != null &&
|
||||
w.calculation!.value == '3',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
// Expect count empty to be 1
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) =>
|
||||
w is CalculateCell &&
|
||||
w.calculation != null &&
|
||||
w.calculation!.value == '1',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tapDatabaseFilterButton();
|
||||
await tester.tapCreateFilterByFieldType(FieldType.RichText, 'Type');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byType(TextFilterChoicechip));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.descendant(
|
||||
of: find.byType(TextFilterEditor),
|
||||
matching: find.byType(FlowyTextField),
|
||||
),
|
||||
'A',
|
||||
);
|
||||
await tester.pumpAndSettle(const Duration(seconds: 2));
|
||||
|
||||
// Expect the count to be 2
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) =>
|
||||
w is CalculateCell &&
|
||||
w.calculation != null &&
|
||||
w.calculation!.value == '2',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
// Expect the count empty to be 0
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) =>
|
||||
w is CalculateCell &&
|
||||
w.calculation != null &&
|
||||
w.calculation!.value == '0',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.enterText(
|
||||
find.descendant(
|
||||
of: find.byType(TextFilterEditor),
|
||||
matching: find.byType(FlowyTextField),
|
||||
),
|
||||
'B',
|
||||
);
|
||||
await tester.pumpAndSettle(const Duration(seconds: 2));
|
||||
|
||||
// Expect the count to be 0
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) =>
|
||||
w is CalculateCell &&
|
||||
w.calculation != null &&
|
||||
w.calculation!.value == '0',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
// Expect the count empty to be 0
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) =>
|
||||
w is CalculateCell &&
|
||||
w.calculation != null &&
|
||||
w.calculation!.value == '0',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.enterText(
|
||||
find.descendant(
|
||||
of: find.byType(TextFilterEditor),
|
||||
matching: find.byType(FlowyTextField),
|
||||
),
|
||||
'',
|
||||
);
|
||||
await tester.pumpAndSettle(const Duration(seconds: 2));
|
||||
await tester.dismissCellEditor();
|
||||
|
||||
await tester.tapCreateRowButtonInGrid();
|
||||
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||||
|
||||
// Expect the count to be 4
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) =>
|
||||
w is CalculateCell &&
|
||||
w.calculation != null &&
|
||||
w.calculation!.value == '4',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
// Expect the count empty to be 2
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) =>
|
||||
w is CalculateCell &&
|
||||
w.calculation != null &&
|
||||
w.calculation!.value == '2',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('Calculations count not empty w/ filter', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
|
||||
expect(find.text('Calculate'), findsNWidgets(3));
|
||||
|
||||
await tester.changeFieldTypeOfFieldWithName('Type', FieldType.Number);
|
||||
|
||||
await tester.changeCalculateAtIndex(0, CalculationType.CountNonEmpty);
|
||||
await tester.changeCalculateAtIndex(1, CalculationType.CountNonEmpty);
|
||||
|
||||
await tester.editCell(
|
||||
rowIndex: 0,
|
||||
fieldType: FieldType.Number,
|
||||
input: '1',
|
||||
);
|
||||
await tester.editCell(
|
||||
rowIndex: 1,
|
||||
fieldType: FieldType.Number,
|
||||
input: '2',
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Dismiss edit cell
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) =>
|
||||
w is CalculateCell &&
|
||||
w.calculation != null &&
|
||||
w.calculation!.value == '0',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) =>
|
||||
w is CalculateCell &&
|
||||
w.calculation != null &&
|
||||
w.calculation!.value == '2',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tapDatabaseFilterButton();
|
||||
await tester.tapCreateFilterByFieldType(FieldType.Number, 'Type');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byType(NumberFilterChoiceChip));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.descendant(
|
||||
of: find.byType(NumberFilterEditor),
|
||||
matching: find.byType(FlowyTextField),
|
||||
),
|
||||
'1',
|
||||
);
|
||||
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) =>
|
||||
w is CalculateCell &&
|
||||
w.calculation != null &&
|
||||
w.calculation!.value == '0',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) =>
|
||||
w is CalculateCell &&
|
||||
w.calculation != null &&
|
||||
w.calculation!.value == '1',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||||
await tester.dismissCellEditor();
|
||||
|
||||
await tester.tapCreateRowButtonInGrid();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) =>
|
||||
w is CalculateCell &&
|
||||
w.calculation != null &&
|
||||
w.calculation!.value == '0',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) =>
|
||||
w is CalculateCell &&
|
||||
w.calculation != null &&
|
||||
w.calculation!.value == '2',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('Median calculation', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
|
||||
expect(find.text('Calculate'), findsNWidgets(3));
|
||||
|
||||
await tester.changeFieldTypeOfFieldWithName('Type', FieldType.Number);
|
||||
await tester.changeCalculateAtIndex(1, CalculationType.Median);
|
||||
|
||||
await tester.tapCreateRowButtonInGrid();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.editCell(
|
||||
rowIndex: 0,
|
||||
fieldType: FieldType.Number,
|
||||
input: '10',
|
||||
);
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(CalculateCell),
|
||||
matching: find.text('10'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.editCell(
|
||||
rowIndex: 1,
|
||||
fieldType: FieldType.Number,
|
||||
input: '20',
|
||||
);
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(CalculateCell),
|
||||
matching: find.text('15'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.editCell(
|
||||
rowIndex: 2,
|
||||
fieldType: FieldType.Number,
|
||||
input: '30',
|
||||
);
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(CalculateCell),
|
||||
matching: find.text('20'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.editCell(
|
||||
rowIndex: 3,
|
||||
fieldType: FieldType.Number,
|
||||
input: '40',
|
||||
);
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(CalculateCell),
|
||||
matching: find.text('25'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
// Dismiss edit cell
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tapDatabaseFilterButton();
|
||||
await tester.tapCreateFilterByFieldType(FieldType.Number, 'Type');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byType(NumberFilterChoiceChip));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.descendant(
|
||||
of: find.byType(NumberFilterEditor),
|
||||
matching: find.byType(FlowyTextField),
|
||||
),
|
||||
'30',
|
||||
);
|
||||
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(CalculateCell),
|
||||
matching: find.text('30'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('Median calculation', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
|
||||
expect(find.text('Calculate'), findsNWidgets(3));
|
||||
|
||||
await tester.changeFieldTypeOfFieldWithName('Type', FieldType.Number);
|
||||
await tester.changeCalculateAtIndex(1, CalculationType.Median);
|
||||
|
||||
await tester.tapCreateRowButtonInGrid();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.editCell(
|
||||
rowIndex: 0,
|
||||
fieldType: FieldType.Number,
|
||||
input: '10',
|
||||
);
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(CalculateCell),
|
||||
matching: find.text('10'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.editCell(
|
||||
rowIndex: 1,
|
||||
fieldType: FieldType.Number,
|
||||
input: '20',
|
||||
);
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(CalculateCell),
|
||||
matching: find.text('15'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.editCell(
|
||||
rowIndex: 2,
|
||||
fieldType: FieldType.Number,
|
||||
input: '30',
|
||||
);
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(CalculateCell),
|
||||
matching: find.text('20'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.editCell(
|
||||
rowIndex: 3,
|
||||
fieldType: FieldType.Number,
|
||||
input: '40',
|
||||
);
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(CalculateCell),
|
||||
matching: find.text('25'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
// Dismiss edit cell
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tapDatabaseFilterButton();
|
||||
await tester.tapCreateFilterByFieldType(FieldType.Number, 'Type');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byType(NumberFilterChoiceChip));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.descendant(
|
||||
of: find.byType(NumberFilterEditor),
|
||||
matching: find.byType(FlowyTextField),
|
||||
),
|
||||
'30',
|
||||
);
|
||||
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(CalculateCell),
|
||||
matching: find.text('30'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
+1
-1
@@ -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),
|
||||
|
||||
+147
-61
@@ -1,3 +1,6 @@
|
||||
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/presentation/home/menu/sidebar/space/space_icon_popup.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';
|
||||
@@ -9,75 +12,158 @@ import '../../shared/expectation.dart';
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
const emoji = '😁';
|
||||
final emoji = EmojiIconData.emoji('😁');
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
tester.expectViewHasIcon(
|
||||
value.name,
|
||||
value,
|
||||
emoji,
|
||||
);
|
||||
// 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 icon in title bar', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
// update its emoji
|
||||
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,
|
||||
);
|
||||
testWidgets('Update page emoji in title bar', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
// 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,
|
||||
);
|
||||
// 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 emoji
|
||||
await tester.updatePageIconInTitleBarByName(
|
||||
name: value.name,
|
||||
layout: value,
|
||||
icon: emoji,
|
||||
);
|
||||
|
||||
tester.expectViewHasIcon(
|
||||
value.name,
|
||||
value,
|
||||
emoji,
|
||||
);
|
||||
|
||||
tester.expectViewTitleHasIcon(
|
||||
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,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
+6
-1
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
@@ -38,7 +39,11 @@ void main() {
|
||||
await tester.tapEmoji(emoji);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
tester.expectViewHasIcon(gettingStarted, ViewLayoutPB.Document, emoji);
|
||||
tester.expectViewHasIcon(
|
||||
gettingStarted,
|
||||
ViewLayoutPB.Document,
|
||||
EmojiIconData.emoji(emoji),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
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,7 @@
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import 'desktop/document/document_test_runner_2.dart' as document_test_runner_2;
|
||||
import 'desktop/grid/grid_calculations_test.dart' as grid_calculations_test;
|
||||
import 'desktop/first_test/first_test.dart' as first_test;
|
||||
|
||||
Future<void> main() async {
|
||||
@@ -13,5 +14,6 @@ Future<void> runIntegration4OnDesktop() async {
|
||||
first_test.main();
|
||||
|
||||
document_test_runner_2.main();
|
||||
grid_calculations_test.main();
|
||||
// DON'T add more tests here.
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
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 {
|
||||
@@ -11,6 +13,6 @@ Future<void> runIntegration9OnDesktop() async {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
first_test.main();
|
||||
|
||||
tabs_test.main();
|
||||
code_language_selector.main();
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import 'page_style_test.dart' as page_style_test;
|
||||
import 'plus_menu_test.dart' as plus_menu_test;
|
||||
import 'simple_table_test.dart' as simple_table_test;
|
||||
import 'title_test.dart' as title_test;
|
||||
|
||||
void main() {
|
||||
@@ -11,4 +12,5 @@ void main() {
|
||||
title_test.main();
|
||||
page_style_test.main();
|
||||
plus_menu_test.main();
|
||||
simple_table_test.main();
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
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.delta!, isNotEmpty);
|
||||
expect(
|
||||
paragraph.delta!.toJson(),
|
||||
equals([
|
||||
{
|
||||
'insert': 'Hello',
|
||||
'attributes': {'bold': true},
|
||||
}
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -8,8 +8,11 @@ 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';
|
||||
@@ -449,11 +452,8 @@ extension CommonOperations on WidgetTester {
|
||||
|
||||
// open the page after created
|
||||
if (openAfterCreated) {
|
||||
await openPage(
|
||||
// if the name is null, use the default name
|
||||
pageName ?? LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
|
||||
layout: layout,
|
||||
);
|
||||
// if the name is null, use empty string
|
||||
await openPage(pageName ?? '', layout: layout);
|
||||
await pumpAndSettle();
|
||||
}
|
||||
}
|
||||
@@ -611,7 +611,7 @@ extension CommonOperations on WidgetTester {
|
||||
required String name,
|
||||
required String parentName,
|
||||
required ViewLayoutPB layout,
|
||||
required String icon,
|
||||
required EmojiIconData icon,
|
||||
}) async {
|
||||
final iconButton = find.descendant(
|
||||
of: findPageName(
|
||||
@@ -623,7 +623,11 @@ extension CommonOperations on WidgetTester {
|
||||
find.byTooltip(LocaleKeys.document_plugins_cover_changeIcon.tr()),
|
||||
);
|
||||
await tapButton(iconButton);
|
||||
await tapEmoji(icon);
|
||||
if (icon.type == FlowyIconType.emoji) {
|
||||
await tapEmoji(icon.emoji);
|
||||
} else if (icon.type == FlowyIconType.icon) {
|
||||
await tapIcon(icon);
|
||||
}
|
||||
await pumpAndSettle();
|
||||
}
|
||||
|
||||
@@ -631,7 +635,7 @@ extension CommonOperations on WidgetTester {
|
||||
Future<void> updatePageIconInTitleBarByName({
|
||||
required String name,
|
||||
required ViewLayoutPB layout,
|
||||
required String icon,
|
||||
required EmojiIconData icon,
|
||||
}) async {
|
||||
await openPage(
|
||||
name,
|
||||
@@ -643,7 +647,11 @@ extension CommonOperations on WidgetTester {
|
||||
);
|
||||
await tapButton(title);
|
||||
await tapButton(find.byType(EmojiPickerButton));
|
||||
await tapEmoji(icon);
|
||||
if (icon.type == FlowyIconType.emoji) {
|
||||
await tapEmoji(icon.emoji);
|
||||
} else if (icon.type == FlowyIconType.icon) {
|
||||
await tapIcon(icon);
|
||||
}
|
||||
await pumpAndSettle();
|
||||
}
|
||||
|
||||
@@ -841,6 +849,55 @@ 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 {
|
||||
|
||||
@@ -763,12 +763,26 @@ extension AppFlowyDatabaseTest on WidgetTester {
|
||||
await tap(find.byType(CalculateCell).at(index));
|
||||
await pumpAndSettle();
|
||||
|
||||
await tap(
|
||||
find.descendant(
|
||||
of: find.byType(CalculationTypeItem),
|
||||
matching: find.text(type.label),
|
||||
),
|
||||
final calculateMenu = find
|
||||
.descendant(
|
||||
of: find.byType(CalculateSelector),
|
||||
matching: find.byWidgetPredicate((w) => w is Scrollable),
|
||||
)
|
||||
.first;
|
||||
|
||||
final calculateType = find.descendant(
|
||||
of: find.byType(CalculationTypeItem),
|
||||
matching: find.text(type.label),
|
||||
);
|
||||
|
||||
await scrollUntilVisible(
|
||||
calculateType,
|
||||
20,
|
||||
scrollable: calculateMenu,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
);
|
||||
|
||||
await tap(calculateType);
|
||||
await pumpAndSettle();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
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';
|
||||
|
||||
@@ -11,4 +20,46 @@ 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,3 +1,5 @@
|
||||
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';
|
||||
@@ -5,6 +7,8 @@ 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';
|
||||
@@ -14,6 +18,7 @@ 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';
|
||||
@@ -117,7 +122,7 @@ extension Expectation on WidgetTester {
|
||||
return;
|
||||
}
|
||||
final iconWidget = find.byWidgetPredicate(
|
||||
(widget) => widget is EmojiIconWidget && widget.emoji == emoji,
|
||||
(widget) => widget is EmojiIconWidget && widget.emoji.emoji == emoji,
|
||||
);
|
||||
expect(iconWidget, findsOneWidget);
|
||||
}
|
||||
@@ -223,24 +228,52 @@ extension Expectation on WidgetTester {
|
||||
);
|
||||
}
|
||||
|
||||
void expectViewHasIcon(String name, ViewLayoutPB layout, String emoji) {
|
||||
void expectViewHasIcon(String name, ViewLayoutPB layout, EmojiIconData data) {
|
||||
final pageName = findPageName(
|
||||
name,
|
||||
layout: layout,
|
||||
);
|
||||
final icon = find.descendant(
|
||||
of: pageName,
|
||||
matching: find.text(emoji),
|
||||
);
|
||||
expect(icon, findsOneWidget);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
void expectViewTitleHasIcon(String name, ViewLayoutPB layout, String emoji) {
|
||||
final icon = find.descendant(
|
||||
of: find.byType(ViewTitleBar),
|
||||
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 expectSelectedReminder(ReminderOption option) {
|
||||
|
||||
@@ -88,6 +88,8 @@ 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`)
|
||||
@@ -112,6 +114,7 @@ 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:
|
||||
@@ -168,6 +171,8 @@ 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
|
||||
@@ -199,6 +204,7 @@ SPEC CHECKSUMS:
|
||||
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
|
||||
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
|
||||
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||
webview_flutter_wkwebview: 2a23822e9039b7b1bc52e5add778e5d89ad488d1
|
||||
|
||||
PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca
|
||||
|
||||
|
||||
@@ -8,5 +8,12 @@
|
||||
<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>
|
||||
@@ -58,6 +58,7 @@ 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.
|
||||
///
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
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:string_validator/string_validator.dart';
|
||||
import 'package:url_launcher/url_launcher.dart' as launcher;
|
||||
|
||||
typedef OnFailureCallback = void Function(Uri uri);
|
||||
|
||||
Future<bool> afLaunchUrl(
|
||||
/// 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(
|
||||
Uri uri, {
|
||||
BuildContext? context,
|
||||
OnFailureCallback? onFailure,
|
||||
@@ -18,6 +26,18 @@ Future<bool> afLaunchUrl(
|
||||
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 {
|
||||
@@ -32,7 +52,7 @@ Future<bool> afLaunchUrl(
|
||||
}
|
||||
|
||||
// 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})) {
|
||||
@@ -54,9 +74,14 @@ Future<bool> afLaunchUrl(
|
||||
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 {
|
||||
@@ -67,12 +92,56 @@ Future<bool> afLaunchUrlString(
|
||||
}
|
||||
|
||||
// try to launch the uri directly
|
||||
return afLaunchUrl(
|
||||
return afLaunchUri(
|
||||
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,
|
||||
|
||||
@@ -39,6 +39,7 @@ 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) =>
|
||||
@@ -47,6 +48,7 @@ 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);
|
||||
|
||||
@@ -55,6 +57,7 @@ class AppFlowyCloudConfiguration {
|
||||
base_url: '',
|
||||
ws_base_url: '',
|
||||
gotrue_url: '',
|
||||
enable_sync_trace: false,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -212,6 +212,7 @@ 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(
|
||||
@@ -241,12 +242,14 @@ 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -271,6 +274,24 @@ 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);
|
||||
|
||||
+7
-6
@@ -6,7 +6,6 @@ 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';
|
||||
@@ -26,11 +25,13 @@ class DocumentPageStyleBloc
|
||||
if (view.id.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final layoutObject =
|
||||
await ViewBackendService.getView(view.id).fold(
|
||||
(s) => jsonDecode(s.extra),
|
||||
(f) => {},
|
||||
);
|
||||
Map layoutObject = {};
|
||||
final data = await ViewBackendService.getView(view.id);
|
||||
data.onSuccess((s) {
|
||||
if (s.extra.isNotEmpty) {
|
||||
layoutObject = jsonDecode(s.extra);
|
||||
}
|
||||
});
|
||||
final fontLayout = _getSelectedFontLayout(layoutObject);
|
||||
final lineHeightLayout = _getSelectedLineHeightLayout(
|
||||
layoutObject,
|
||||
|
||||
@@ -7,6 +7,8 @@ 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> {
|
||||
@@ -39,7 +41,7 @@ class RecentViewBloc extends Bloc<RecentViewEvent, RecentViewState> {
|
||||
add(
|
||||
RecentViewEvent.updateNameOrIcon(
|
||||
view.name,
|
||||
view.icon.value,
|
||||
view.icon.toEmojiIconData(),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -61,7 +63,7 @@ class RecentViewBloc extends Bloc<RecentViewEvent, RecentViewState> {
|
||||
emit(
|
||||
state.copyWith(
|
||||
name: view.name,
|
||||
icon: view.icon.value,
|
||||
icon: view.icon.toEmojiIconData(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -72,7 +74,7 @@ class RecentViewBloc extends Bloc<RecentViewEvent, RecentViewState> {
|
||||
emit(
|
||||
state.copyWith(
|
||||
name: view.name,
|
||||
icon: view.icon.value,
|
||||
icon: view.icon.toEmojiIconData(),
|
||||
coverTypeV2: cover.type,
|
||||
coverValue: cover.value,
|
||||
),
|
||||
@@ -82,7 +84,7 @@ class RecentViewBloc extends Bloc<RecentViewEvent, RecentViewState> {
|
||||
emit(
|
||||
state.copyWith(
|
||||
name: view.name,
|
||||
icon: view.icon.value,
|
||||
icon: view.icon.toEmojiIconData(),
|
||||
coverTypeV1: coverTypeV1,
|
||||
coverValue: coverValue,
|
||||
),
|
||||
@@ -135,14 +137,17 @@ 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,
|
||||
String icon,
|
||||
EmojiIconData icon,
|
||||
) = UpdateNameOrIcon;
|
||||
}
|
||||
|
||||
@@ -150,12 +155,12 @@ class RecentViewEvent with _$RecentViewEvent {
|
||||
class RecentViewState with _$RecentViewState {
|
||||
const factory RecentViewState({
|
||||
required String name,
|
||||
required String icon,
|
||||
required EmojiIconData icon,
|
||||
@Default(CoverType.none) CoverType coverTypeV1,
|
||||
PageStyleCoverImageType? coverTypeV2,
|
||||
@Default(null) String? coverValue,
|
||||
}) = _RecentViewState;
|
||||
|
||||
factory RecentViewState.initial() =>
|
||||
const RecentViewState(name: '', icon: '');
|
||||
RecentViewState(name: '', icon: EmojiIconData.none());
|
||||
}
|
||||
@@ -7,11 +7,14 @@ 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';
|
||||
@@ -105,6 +108,12 @@ 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)
|
||||
@@ -255,15 +264,14 @@ class _MobileViewPageState extends State<MobileViewPage> {
|
||||
}
|
||||
|
||||
Widget _buildTitle(BuildContext context, ViewPB? view) {
|
||||
final icon = view?.icon.value;
|
||||
final icon = view?.icon;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null && icon.isNotEmpty) ...[
|
||||
FlowyText.emoji(
|
||||
icon,
|
||||
fontSize: 15.0,
|
||||
figmaLineHeight: 18.0,
|
||||
if (icon != null) ...[
|
||||
EmojiIconWidget(
|
||||
emoji: icon.toEmojiIconData(),
|
||||
emojiSize: 15,
|
||||
),
|
||||
const HSpace(4),
|
||||
],
|
||||
|
||||
+1
-1
@@ -189,7 +189,7 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
|
||||
final url = context.read<ShareBloc>().state.url;
|
||||
if (url.isNotEmpty) {
|
||||
unawaited(
|
||||
afLaunchUrl(
|
||||
afLaunchUri(
|
||||
Uri.parse(url),
|
||||
mode: LaunchMode.externalApplication,
|
||||
),
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ class BottomSheetBackButton extends StatelessWidget {
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: FlowySvg(
|
||||
FlowySvgs.m_app_bar_back_s,
|
||||
FlowySvgs.m_bottom_sheet_back_s,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
+16
-5
@@ -211,14 +211,21 @@ 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
|
||||
@@ -230,14 +237,18 @@ class BottomSheetHeader extends StatelessWidget {
|
||||
child: Stack(
|
||||
children: [
|
||||
if (showBackButton)
|
||||
const Align(
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: BottomSheetBackButton(),
|
||||
child: BottomSheetBackButton(
|
||||
onTap: onBack,
|
||||
),
|
||||
),
|
||||
if (showCloseButton)
|
||||
const Align(
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: BottomSheetCloseButton(),
|
||||
child: BottomSheetCloseButton(
|
||||
onTap: onClose,
|
||||
),
|
||||
),
|
||||
if (showRemoveButton)
|
||||
Align(
|
||||
|
||||
@@ -228,7 +228,7 @@ class _UserIcon extends StatelessWidget {
|
||||
fontSize: 26,
|
||||
),
|
||||
onTap: () async {
|
||||
final icon = await context.push<EmojiPickerResult>(
|
||||
final icon = await context.push<EmojiIconData>(
|
||||
Uri(
|
||||
path: MobileEmojiPickerScreen.routeName,
|
||||
queryParameters: {
|
||||
|
||||
+1
-1
@@ -111,7 +111,7 @@ class MobileRecentView extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: state.icon.isNotEmpty
|
||||
? EmojiText(
|
||||
emoji: state.icon,
|
||||
emoji: state.icon.emoji,
|
||||
fontSize: 30.0,
|
||||
)
|
||||
: SizedBox.square(
|
||||
|
||||
+10
-12
@@ -7,6 +7,7 @@ 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';
|
||||
@@ -26,7 +27,6 @@ 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);
|
||||
final supportAvatar = isURL(state.icon.emoji);
|
||||
if (!supportAvatar) {
|
||||
return _buildLastViewed(context);
|
||||
}
|
||||
@@ -173,21 +173,19 @@ 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: [
|
||||
TextSpan(
|
||||
text: icon,
|
||||
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
|
||||
fontSize: 17.0,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: fontFamily,
|
||||
),
|
||||
WidgetSpan(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
child: EmojiIconWidget(
|
||||
emoji: icon,
|
||||
emojiSize: 17.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (icon.isNotEmpty) const WidgetSpan(child: HSpace(2.0)),
|
||||
TextSpan(
|
||||
|
||||
@@ -14,6 +14,7 @@ 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;
|
||||
|
||||
@@ -264,7 +265,11 @@ class NotificationDocumentContent extends StatelessWidget {
|
||||
styleCustomizer: styleCustomizer,
|
||||
// the editor is not editable in the chat
|
||||
editable: false,
|
||||
customHeadingPadding: EdgeInsets.zero,
|
||||
customHeadingPadding: UniversalPlatform.isDesktop
|
||||
? EdgeInsets.zero
|
||||
: EdgeInsets.symmetric(
|
||||
vertical: EditorStyleCustomizer.nodeHorizontalPadding,
|
||||
),
|
||||
);
|
||||
|
||||
return IgnorePointer(
|
||||
|
||||
@@ -2,6 +2,8 @@ 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';
|
||||
@@ -121,6 +123,7 @@ 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;
|
||||
|
||||
@@ -230,6 +233,7 @@ 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;
|
||||
|
||||
@@ -297,11 +301,9 @@ class _SingleMobileInnerViewItemState extends State<SingleMobileInnerViewItem> {
|
||||
|
||||
Widget _buildViewIcon() {
|
||||
final icon = widget.view.icon.value.isNotEmpty
|
||||
? FlowyText.emoji(
|
||||
widget.view.icon.value,
|
||||
fontSize: Platform.isAndroid ? 16.0 : 18.0,
|
||||
figmaLineHeight: 20.0,
|
||||
optimizeEmojiAlign: true,
|
||||
? EmojiIconWidget(
|
||||
emoji: widget.view.icon.toEmojiIconData(),
|
||||
emojiSize: Platform.isAndroid ? 16.0 : 18.0,
|
||||
)
|
||||
: Opacity(
|
||||
opacity: 0.7,
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ class MobileQuickActionButton extends StatelessWidget {
|
||||
enable ? null : const WidgetStatePropertyAll(Colors.transparent),
|
||||
splashColor: Colors.transparent,
|
||||
child: Container(
|
||||
height: 52,
|
||||
height: 44,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
+53
-35
@@ -5,6 +5,7 @@ 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';
|
||||
@@ -20,18 +21,6 @@ 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
|
||||
@@ -44,15 +33,6 @@ 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 &&
|
||||
@@ -80,19 +60,37 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
|
||||
),
|
||||
);
|
||||
},
|
||||
deleteFile: (file) {
|
||||
final files = List<ChatFile>.from(state.uploadFiles);
|
||||
attachFile: (filePath, fileName) {
|
||||
final newFile = ChatFile.fromFilePath(filePath);
|
||||
if (newFile != null) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
attachedFiles: [...state.attachedFiles, newFile],
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
removeFile: (file) {
|
||||
final files = [...state.attachedFiles];
|
||||
files.remove(file);
|
||||
emit(
|
||||
state.copyWith(
|
||||
uploadFiles: files,
|
||||
attachedFiles: files,
|
||||
),
|
||||
);
|
||||
},
|
||||
clear: () {
|
||||
updateMentionedViews: (views) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
uploadFiles: [],
|
||||
mentionedPages: views,
|
||||
),
|
||||
);
|
||||
},
|
||||
clearMetadata: () {
|
||||
emit(
|
||||
state.copyWith(
|
||||
attachedFiles: [],
|
||||
mentionedPages: [],
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -126,35 +124,55 @@ 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 bool supportChatWithFile,
|
||||
LocalAIChatPB? chatState,
|
||||
required List<ChatFile> uploadFiles,
|
||||
required AIType aiType,
|
||||
required bool supportChatWithFile,
|
||||
required LocalAIChatPB? chatState,
|
||||
required List<ChatFile> attachedFiles,
|
||||
required List<ViewPB> mentionedPages,
|
||||
}) = _AIPromptInputState;
|
||||
|
||||
factory AIPromptInputState.initial() => const AIPromptInputState(
|
||||
supportChatWithFile: false,
|
||||
uploadFiles: [],
|
||||
aiType: AIType.appflowyAI,
|
||||
supportChatWithFile: false,
|
||||
chatState: null,
|
||||
attachedFiles: [],
|
||||
mentionedPages: [],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ 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';
|
||||
@@ -30,7 +31,8 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
super(ChatState.initial()) {
|
||||
_startListening();
|
||||
_dispatch();
|
||||
_init();
|
||||
_loadMessages();
|
||||
_loadSetting();
|
||||
}
|
||||
|
||||
final String chatId;
|
||||
@@ -63,6 +65,9 @@ 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();
|
||||
}
|
||||
|
||||
@@ -76,10 +81,23 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
await chatController.insert(message, index: 0);
|
||||
}
|
||||
|
||||
if (state.loadingState.isLoading) {
|
||||
emit(
|
||||
state.copyWith(loadingState: const ChatLoadingState.finish()),
|
||||
);
|
||||
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;
|
||||
}
|
||||
},
|
||||
loadPreviousMessages: () {
|
||||
@@ -151,18 +169,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
) {
|
||||
numSendMessage += 1;
|
||||
|
||||
final relatedQuestionMessages = chatController.messages
|
||||
.where(
|
||||
(message) =>
|
||||
onetimeMessageTypeFromMeta(message.metadata) ==
|
||||
OnetimeShotType.relatedQuestion,
|
||||
)
|
||||
.toList();
|
||||
|
||||
for (final message in relatedQuestionMessages) {
|
||||
chatController.remove(message);
|
||||
}
|
||||
|
||||
_clearRelatedQuestions();
|
||||
_startStreamingMessage(message, metadata);
|
||||
lastSentMessage = null;
|
||||
|
||||
@@ -172,11 +179,10 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
),
|
||||
);
|
||||
},
|
||||
finishSending: (ChatMessagePB message) {
|
||||
lastSentMessage = message;
|
||||
finishSending: () {
|
||||
emit(
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.awaitingAnswer,
|
||||
promptResponseState: PromptResponseState.streamingAnswer,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -214,14 +220,6 @@ 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) {
|
||||
@@ -233,6 +231,33 @@ 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),
|
||||
);
|
||||
},
|
||||
updateSelectedSources: (selectedSourcesIds) async {
|
||||
emit(state.copyWith(selectedSourceIds: selectedSourcesIds));
|
||||
|
||||
final payload = UpdateChatSettingsPB(
|
||||
chatId: ChatId(value: chatId),
|
||||
ragIds: selectedSourcesIds,
|
||||
);
|
||||
await AIEventUpdateChatSettings(payload)
|
||||
.send()
|
||||
.onFailure(Log.error);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -318,12 +343,25 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
);
|
||||
}
|
||||
|
||||
void _init() async {
|
||||
final payload = LoadNextChatMessagePB(
|
||||
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(
|
||||
chatId: chatId,
|
||||
limit: Int64(10),
|
||||
);
|
||||
await AIEventLoadNextMessage(payload).send().fold(
|
||||
await AIEventLoadNextMessage(loadMessagesPayload).send().fold(
|
||||
(list) {
|
||||
if (!isClosed) {
|
||||
final messages = list.messages.map(_createTextMessage).toList();
|
||||
@@ -388,8 +426,9 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
question.messageId,
|
||||
);
|
||||
|
||||
add(ChatEvent.finishSending(question));
|
||||
add(ChatEvent.startAnswerStreaming(streamAnswer));
|
||||
lastSentMessage = question;
|
||||
add(const ChatEvent.finishSending());
|
||||
add(ChatEvent.receiveMessage(streamAnswer));
|
||||
}
|
||||
},
|
||||
(err) {
|
||||
@@ -416,6 +455,42 @@ 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,
|
||||
@@ -474,22 +549,44 @@ 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;
|
||||
|
||||
// send message
|
||||
const factory ChatEvent.sendMessage({
|
||||
required String message,
|
||||
Map<String, dynamic>? metadata,
|
||||
}) = _SendMessage;
|
||||
const factory ChatEvent.finishSending(ChatMessagePB message) =
|
||||
_FinishSendMessage;
|
||||
const factory ChatEvent.finishSending() = _FinishSendMessage;
|
||||
const factory ChatEvent.failedSending() = _FailSendMessage;
|
||||
|
||||
// regenerate
|
||||
const factory ChatEvent.regenerateAnswer(String id) = _RegenerateAnswer;
|
||||
|
||||
// streaming answer
|
||||
const factory ChatEvent.startAnswerStreaming(Message message) =
|
||||
_StartAnswerStreaming;
|
||||
const factory ChatEvent.stopStream() = _StopStream;
|
||||
const factory ChatEvent.didFinishAnswerStream() = _DidFinishAnswerStream;
|
||||
|
||||
@@ -514,12 +611,14 @@ class ChatEvent with _$ChatEvent {
|
||||
@freezed
|
||||
class ChatState with _$ChatState {
|
||||
const factory ChatState({
|
||||
required ChatLoadingState loadingState,
|
||||
required List<String> selectedSourceIds,
|
||||
required LoadChatMessageStatus loadingState,
|
||||
required PromptResponseState promptResponseState,
|
||||
}) = _ChatState;
|
||||
|
||||
factory ChatState.initial() => const ChatState(
|
||||
loadingState: ChatLoadingState.loading(),
|
||||
selectedSourceIds: [],
|
||||
loadingState: LoadChatMessageStatus.loading,
|
||||
promptResponseState: PromptResponseState.ready,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:io';
|
||||
|
||||
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;
|
||||
@@ -56,7 +57,6 @@ class AIChatProgress {
|
||||
enum PromptResponseState {
|
||||
ready,
|
||||
sendingQuestion,
|
||||
awaitingAnswer,
|
||||
streamingAnswer,
|
||||
}
|
||||
|
||||
@@ -76,19 +76,19 @@ class ChatFile extends Equatable {
|
||||
final fileName = path.basename(filePath);
|
||||
final extension = path.extension(filePath).toLowerCase();
|
||||
|
||||
ChatMessageMetaTypePB fileType;
|
||||
ContextLoaderTypePB fileType;
|
||||
switch (extension) {
|
||||
case '.pdf':
|
||||
fileType = ChatMessageMetaTypePB.PDF;
|
||||
fileType = ContextLoaderTypePB.PDF;
|
||||
break;
|
||||
case '.txt':
|
||||
fileType = ChatMessageMetaTypePB.Txt;
|
||||
fileType = ContextLoaderTypePB.Txt;
|
||||
break;
|
||||
case '.md':
|
||||
fileType = ChatMessageMetaTypePB.Markdown;
|
||||
fileType = ContextLoaderTypePB.Markdown;
|
||||
break;
|
||||
default:
|
||||
fileType = ChatMessageMetaTypePB.UnknownMetaType;
|
||||
fileType = ContextLoaderTypePB.UnknownLoaderType;
|
||||
}
|
||||
|
||||
return ChatFile(
|
||||
@@ -100,13 +100,14 @@ class ChatFile extends Equatable {
|
||||
|
||||
final String filePath;
|
||||
final String fileName;
|
||||
final ChatMessageMetaTypePB fileType;
|
||||
final ContextLoaderTypePB fileType;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [filePath];
|
||||
}
|
||||
|
||||
typedef ChatInputFileMetadata = Map<String, ChatFile>;
|
||||
typedef ChatFileMap = Map<String, ChatFile>;
|
||||
typedef ChatMentionedPageMap = Map<String, ViewPB>;
|
||||
|
||||
@freezed
|
||||
class ChatLoadingState with _$ChatLoadingState {
|
||||
@@ -130,3 +131,9 @@ const onetimeShotType = "OnetimeShotType";
|
||||
OnetimeShotType? onetimeMessageTypeFromMeta(Map<String, dynamic>? metadata) {
|
||||
return metadata?[onetimeShotType];
|
||||
}
|
||||
|
||||
enum LoadChatMessageStatus {
|
||||
loading,
|
||||
loadingRemote,
|
||||
ready,
|
||||
}
|
||||
-217
@@ -1,217 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_service.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
import 'chat_input_action_control.dart';
|
||||
|
||||
part 'chat_input_action_bloc.freezed.dart';
|
||||
|
||||
class ChatInputActionBloc
|
||||
extends Bloc<ChatInputActionEvent, ChatInputActionState> {
|
||||
ChatInputActionBloc({required this.chatId})
|
||||
: super(const ChatInputActionState()) {
|
||||
on<ChatInputActionEvent>(_handleEvent);
|
||||
}
|
||||
|
||||
final String chatId;
|
||||
|
||||
Future<void> _handleEvent(
|
||||
ChatInputActionEvent event,
|
||||
Emitter<ChatInputActionState> emit,
|
||||
) async {
|
||||
await event.when(
|
||||
started: () async {
|
||||
unawaited(
|
||||
ViewBackendService.getAllViews().then(
|
||||
(result) {
|
||||
final views = result
|
||||
.toNullable()
|
||||
?.items
|
||||
.where(
|
||||
(v) =>
|
||||
v.layout.isDocumentView &&
|
||||
!v.isSpace &&
|
||||
v.parentViewId.isNotEmpty,
|
||||
)
|
||||
.toList() ??
|
||||
[];
|
||||
if (!isClosed) {
|
||||
add(ChatInputActionEvent.refreshViews(views));
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
refreshViews: (List<ViewPB> views) {
|
||||
final List<ViewActionPage> pages = _filterPages(
|
||||
views,
|
||||
state.selectedPages,
|
||||
state.filter,
|
||||
);
|
||||
emit(
|
||||
state.copyWith(
|
||||
views: views,
|
||||
pages: pages,
|
||||
indicator: const ChatActionMenuIndicator.ready(),
|
||||
),
|
||||
);
|
||||
},
|
||||
filter: (String filter) {
|
||||
Log.debug("Filter chat input pages: $filter");
|
||||
final List<ViewActionPage> pages = _filterPages(
|
||||
state.views,
|
||||
state.selectedPages,
|
||||
filter,
|
||||
);
|
||||
|
||||
emit(state.copyWith(pages: pages, filter: filter));
|
||||
},
|
||||
handleKeyEvent: (PhysicalKeyboardKey physicalKey) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
keyboardKey: ChatInputKeyboardEvent(physicalKey: physicalKey),
|
||||
),
|
||||
);
|
||||
},
|
||||
addPage: (ChatInputMention page) {
|
||||
if (!state.selectedPages.any((p) => p.pageId == page.pageId)) {
|
||||
final List<ViewActionPage> pages = _filterPages(
|
||||
state.views,
|
||||
state.selectedPages,
|
||||
state.filter,
|
||||
);
|
||||
emit(
|
||||
state.copyWith(
|
||||
pages: pages,
|
||||
selectedPages: [...state.selectedPages, page],
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
removePage: (String text) {
|
||||
final List<ChatInputMention> selectedPages =
|
||||
List.from(state.selectedPages);
|
||||
selectedPages.retainWhere((t) => !text.contains(t.title));
|
||||
|
||||
final List<ViewActionPage> allPages = _filterPages(
|
||||
state.views,
|
||||
state.selectedPages,
|
||||
state.filter,
|
||||
);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
selectedPages: selectedPages,
|
||||
pages: allPages,
|
||||
),
|
||||
);
|
||||
},
|
||||
clear: () {
|
||||
emit(
|
||||
state.copyWith(
|
||||
selectedPages: [],
|
||||
filter: "",
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<ViewActionPage> _filterPages(
|
||||
List<ViewPB> views,
|
||||
List<ChatInputMention> selectedPages,
|
||||
String filter,
|
||||
) {
|
||||
final pages = views
|
||||
.map(
|
||||
(v) => ViewActionPage(view: v),
|
||||
)
|
||||
.toList();
|
||||
|
||||
pages.retainWhere((page) {
|
||||
return !selectedPages.contains(page);
|
||||
});
|
||||
|
||||
if (filter.isEmpty) {
|
||||
return pages;
|
||||
}
|
||||
|
||||
return pages
|
||||
.where(
|
||||
(v) => v.title.toLowerCase().contains(filter.toLowerCase()),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
class ViewActionPage extends ChatInputMention {
|
||||
ViewActionPage({required this.view});
|
||||
|
||||
final ViewPB view;
|
||||
|
||||
@override
|
||||
String get pageId => view.id;
|
||||
|
||||
@override
|
||||
String get title => view.name;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [pageId];
|
||||
|
||||
@override
|
||||
dynamic get page => view;
|
||||
|
||||
@override
|
||||
Widget get icon => view.defaultIcon();
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatInputActionEvent with _$ChatInputActionEvent {
|
||||
const factory ChatInputActionEvent.started() = _Started;
|
||||
const factory ChatInputActionEvent.refreshViews(List<ViewPB> views) =
|
||||
_RefreshViews;
|
||||
const factory ChatInputActionEvent.filter(String filter) = _Filter;
|
||||
const factory ChatInputActionEvent.handleKeyEvent(
|
||||
PhysicalKeyboardKey keyboardKey,
|
||||
) = _HandleKeyEvent;
|
||||
const factory ChatInputActionEvent.addPage(ChatInputMention page) = _AddPage;
|
||||
const factory ChatInputActionEvent.removePage(String text) = _RemovePage;
|
||||
const factory ChatInputActionEvent.clear() = _Clear;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatInputActionState with _$ChatInputActionState {
|
||||
const factory ChatInputActionState({
|
||||
@Default([]) List<ViewPB> views,
|
||||
@Default([]) List<ChatInputMention> pages,
|
||||
@Default([]) List<ChatInputMention> selectedPages,
|
||||
@Default("") String filter,
|
||||
ChatInputKeyboardEvent? keyboardKey,
|
||||
@Default(ChatActionMenuIndicator.loading())
|
||||
ChatActionMenuIndicator indicator,
|
||||
}) = _ChatInputActionState;
|
||||
}
|
||||
|
||||
class ChatInputKeyboardEvent extends Equatable {
|
||||
ChatInputKeyboardEvent({required this.physicalKey});
|
||||
|
||||
final PhysicalKeyboardKey physicalKey;
|
||||
final int timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [timestamp];
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatActionMenuIndicator with _$ChatActionMenuIndicator {
|
||||
const factory ChatActionMenuIndicator.ready() = _Ready;
|
||||
const factory ChatActionMenuIndicator.loading() = _Loading;
|
||||
}
|
||||
-172
@@ -1,172 +0,0 @@
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_input_action_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_input_action_menu.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
abstract class ChatInputMention extends Equatable {
|
||||
String get title;
|
||||
String get pageId;
|
||||
dynamic get page;
|
||||
Widget get icon;
|
||||
}
|
||||
|
||||
/// Key: the key is the pageId
|
||||
typedef ChatInputMentionMetadata = Map<String, ChatInputMention>;
|
||||
|
||||
class ChatInputActionControl extends ChatActionHandler {
|
||||
ChatInputActionControl({
|
||||
required this.textController,
|
||||
required this.textFieldFocusNode,
|
||||
required this.chatId,
|
||||
}) : _commandBloc = ChatInputActionBloc(chatId: chatId);
|
||||
|
||||
final TextEditingController textController;
|
||||
final ChatInputActionBloc _commandBloc;
|
||||
final FocusNode textFieldFocusNode;
|
||||
final String chatId;
|
||||
|
||||
// Private attributes
|
||||
String _atText = "";
|
||||
String _prevText = "";
|
||||
String _showMenuText = "";
|
||||
|
||||
// Getter
|
||||
List<String> get tags =>
|
||||
_commandBloc.state.selectedPages.map((e) => e.title).toList();
|
||||
|
||||
ChatInputMentionMetadata consumeMetaData() {
|
||||
final metadata = _commandBloc.state.selectedPages.fold(
|
||||
<String, ChatInputMention>{},
|
||||
(map, page) => map..putIfAbsent(page.pageId, () => page),
|
||||
);
|
||||
|
||||
if (metadata.isNotEmpty) {
|
||||
_commandBloc.add(const ChatInputActionEvent.clear());
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
void handleKeyEvent(KeyEvent event) {
|
||||
// ignore: deprecated_member_use
|
||||
if (event is KeyDownEvent || event is RawKeyDownEvent) {
|
||||
_commandBloc.add(ChatInputActionEvent.handleKeyEvent(event.physicalKey));
|
||||
}
|
||||
}
|
||||
|
||||
bool canHandleKeyEvent(KeyEvent event) {
|
||||
return _showMenuText.isNotEmpty &&
|
||||
<PhysicalKeyboardKey>{
|
||||
PhysicalKeyboardKey.arrowDown,
|
||||
PhysicalKeyboardKey.arrowUp,
|
||||
PhysicalKeyboardKey.enter,
|
||||
PhysicalKeyboardKey.escape,
|
||||
}.contains(event.physicalKey);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_commandBloc.close();
|
||||
}
|
||||
|
||||
@override
|
||||
void onSelected(ChatInputMention page) {
|
||||
_commandBloc.add(ChatInputActionEvent.addPage(page));
|
||||
textController.text = "$_showMenuText${page.title}";
|
||||
|
||||
onExit();
|
||||
}
|
||||
|
||||
@override
|
||||
void onExit() {
|
||||
_atText = "";
|
||||
_showMenuText = "";
|
||||
_prevText = "";
|
||||
_commandBloc.add(const ChatInputActionEvent.filter(""));
|
||||
}
|
||||
|
||||
@override
|
||||
void onEnter() {
|
||||
_commandBloc.add(const ChatInputActionEvent.started());
|
||||
_showMenuText = textController.text;
|
||||
}
|
||||
|
||||
@override
|
||||
double actionMenuOffsetX() {
|
||||
final TextPosition textPosition = textController.selection.extent;
|
||||
if (textFieldFocusNode.context == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
final RenderBox renderBox =
|
||||
textFieldFocusNode.context?.findRenderObject() as RenderBox;
|
||||
|
||||
final TextPainter textPainter = TextPainter(
|
||||
text: TextSpan(text: textController.text),
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
textPainter.layout(
|
||||
minWidth: renderBox.size.width,
|
||||
maxWidth: renderBox.size.width,
|
||||
);
|
||||
|
||||
final Offset caretOffset =
|
||||
textPainter.getOffsetForCaret(textPosition, Rect.zero);
|
||||
final List<TextBox> boxes = textPainter.getBoxesForSelection(
|
||||
TextSelection(
|
||||
baseOffset: textPosition.offset,
|
||||
extentOffset: textPosition.offset,
|
||||
),
|
||||
);
|
||||
|
||||
if (boxes.isNotEmpty) {
|
||||
return boxes.last.right;
|
||||
}
|
||||
return caretOffset.dx;
|
||||
}
|
||||
|
||||
bool onTextChanged(String text) {
|
||||
final String inputText = text;
|
||||
if (_prevText.length > inputText.length) {
|
||||
final deleteStartIndex = textController.selection.baseOffset;
|
||||
final deleteEndIndex =
|
||||
_prevText.length - inputText.length + deleteStartIndex;
|
||||
final deletedText = _prevText.substring(deleteStartIndex, deleteEndIndex);
|
||||
_commandBloc.add(ChatInputActionEvent.removePage(deletedText));
|
||||
}
|
||||
|
||||
// If the action menu is shown, filter the views
|
||||
if (_showMenuText.isNotEmpty) {
|
||||
if (text.length >= _showMenuText.length) {
|
||||
final filterText = inputText.substring(_showMenuText.length);
|
||||
_commandBloc.add(ChatInputActionEvent.filter(filterText));
|
||||
}
|
||||
|
||||
// If the text change from "xxx @"" to "xxx", which means user delete the @, we should hide the action menu
|
||||
if (_atText.isNotEmpty && !inputText.contains(_atText)) {
|
||||
_commandBloc.add(
|
||||
const ChatInputActionEvent.handleKeyEvent(PhysicalKeyboardKey.escape),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
final isTypingNewAt =
|
||||
text.endsWith("@") && _prevText.length < text.length;
|
||||
if (isTypingNewAt) {
|
||||
_atText = text;
|
||||
_prevText = text;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
_prevText = text;
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
void onFilter(String filter) {
|
||||
Log.info("filter: $filter");
|
||||
}
|
||||
|
||||
@override
|
||||
ChatInputActionBloc get commandBloc => _commandBloc;
|
||||
}
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
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 ViewActionPage(view: final view) when view.layout.isDocumentView:
|
||||
final payload = OpenDocumentPayloadPB(documentId: view.id);
|
||||
case ViewPB _ when value.layout.isDocumentView:
|
||||
final payload = OpenDocumentPayloadPB(documentId: value.id);
|
||||
await DocumentEventGetDocumentText(payload).send().fold(
|
||||
(pb) {
|
||||
metadata.add(
|
||||
ChatMessageMetaPB(
|
||||
id: view.id,
|
||||
name: view.name,
|
||||
id: value.id,
|
||||
name: value.name,
|
||||
data: pb.text,
|
||||
dataType: ChatMessageMetaTypePB.Txt,
|
||||
loaderType: ContextLoaderTypePB.Txt,
|
||||
source: appflowySource,
|
||||
),
|
||||
);
|
||||
@@ -156,7 +156,7 @@ Future<List<ChatMessageMetaPB>> metadataPBFromMetadata(
|
||||
id: nanoid(8),
|
||||
name: fileName,
|
||||
data: filePath,
|
||||
dataType: fileType,
|
||||
loaderType: fileType,
|
||||
source: filePath,
|
||||
),
|
||||
);
|
||||
|
||||
+393
@@ -0,0 +1,393 @@
|
||||
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({required this.chatId})
|
||||
: super(ChatSettingsState.initial());
|
||||
|
||||
final String chatId;
|
||||
List<String> selectedSourceIds = [];
|
||||
ChatSource? source;
|
||||
List<ChatSource> selectedSources = [];
|
||||
String filter = '';
|
||||
|
||||
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 List<ChatSource> visibleSources,
|
||||
required List<ChatSource> selectedSources,
|
||||
}) = _ChatSettingsState;
|
||||
|
||||
factory ChatSettingsState.initial() => const ChatSettingsState(
|
||||
visibleSources: [],
|
||||
selectedSources: [],
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_service.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'chat_side_panel_bloc.freezed.dart';
|
||||
|
||||
class ChatSidePanelBloc extends Bloc<ChatSidePanelEvent, ChatSidePanelState> {
|
||||
ChatSidePanelBloc({
|
||||
required this.chatId,
|
||||
}) : super(const ChatSidePanelState()) {
|
||||
on<ChatSidePanelEvent>(
|
||||
(event, emit) async {
|
||||
await event.when(
|
||||
selectedMetadata: (ChatMessageRefSource metadata) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
metadata: metadata,
|
||||
indicator: const ChatSidePanelIndicator.loading(),
|
||||
),
|
||||
);
|
||||
await ViewBackendService.getView(metadata.id).fold(
|
||||
(view) {
|
||||
if (!isClosed) {
|
||||
add(ChatSidePanelEvent.open(view));
|
||||
}
|
||||
},
|
||||
(err) => Log.error("Failed to get view: $err"),
|
||||
);
|
||||
},
|
||||
close: () {
|
||||
emit(state.copyWith(metadata: null, isShowPanel: false));
|
||||
},
|
||||
open: (ViewPB view) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
indicator: ChatSidePanelIndicator.ready(view),
|
||||
isShowPanel: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final String chatId;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatSidePanelEvent with _$ChatSidePanelEvent {
|
||||
const factory ChatSidePanelEvent.selectedMetadata(
|
||||
ChatMessageRefSource metadata,
|
||||
) = _SelectedMetadata;
|
||||
const factory ChatSidePanelEvent.close() = _Close;
|
||||
const factory ChatSidePanelEvent.open(ViewPB view) = _Open;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatSidePanelState with _$ChatSidePanelState {
|
||||
const factory ChatSidePanelState({
|
||||
ChatMessageRefSource? metadata,
|
||||
@Default(ChatSidePanelIndicator.loading()) ChatSidePanelIndicator indicator,
|
||||
@Default(false) bool isShowPanel,
|
||||
}) = _ChatSidePanelState;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatSidePanelIndicator with _$ChatSidePanelIndicator {
|
||||
const factory ChatSidePanelIndicator.ready(ViewPB view) = _Ready;
|
||||
const factory ChatSidePanelIndicator.loading() = _Loading;
|
||||
}
|
||||
@@ -110,7 +110,7 @@ class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
return BlocProvider<ViewInfoBloc>.value(
|
||||
return BlocProvider.value(
|
||||
value: bloc,
|
||||
child: AIChatPage(
|
||||
userProfile: context.userProfile!,
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/ai_prompt_input_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_related_question.dart';
|
||||
import 'package:appflowy/mobile/application/mobile_router.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
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_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';
|
||||
@@ -14,15 +19,19 @@ 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:styled_widget/styled_widget.dart';
|
||||
import 'package:string_validator/string_validator.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_side_panel_bloc.dart';
|
||||
import 'application/chat_message_stream.dart';
|
||||
import 'presentation/animated_chat_list.dart';
|
||||
import 'presentation/chat_input/desktop_ai_prompt_input.dart';
|
||||
import 'presentation/chat_input/mobile_ai_prompt_input.dart';
|
||||
import 'presentation/chat_side_panel.dart';
|
||||
import 'presentation/chat_related_question.dart';
|
||||
import 'presentation/chat_welcome_page.dart';
|
||||
import 'presentation/layout_define.dart';
|
||||
import 'presentation/message/ai_text_message.dart';
|
||||
@@ -65,7 +74,6 @@ 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(
|
||||
@@ -76,7 +84,7 @@ class AIChatPage extends StatelessWidget {
|
||||
for (final file in detail.files) {
|
||||
context
|
||||
.read<AIPromptInputBloc>()
|
||||
.add(AIPromptInputEvent.newFile(file.path, file.name));
|
||||
.add(AIPromptInputEvent.attachFile(file.path, file.name));
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -102,110 +110,43 @@ class _ChatContentPage extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (UniversalPlatform.isDesktop) {
|
||||
return BlocSelector<ChatSidePanelBloc, ChatSidePanelState, bool>(
|
||||
selector: (state) => state.isShowPanel,
|
||||
builder: (context, isShowPanel) {
|
||||
return LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
final sidePanelRatio = isShowPanel ? 0.33 : 0.0;
|
||||
final chatWidth = constraints.maxWidth * (1 - sidePanelRatio);
|
||||
final sidePanelWidth =
|
||||
constraints.maxWidth * sidePanelRatio - 1.0;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Center(
|
||||
child: buildChatWidget(context)
|
||||
.constrained(
|
||||
maxWidth: 784,
|
||||
)
|
||||
.padding(horizontal: 60)
|
||||
.animate(
|
||||
const Duration(milliseconds: 200),
|
||||
Curves.easeOut,
|
||||
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,
|
||||
),
|
||||
),
|
||||
).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),
|
||||
],
|
||||
),
|
||||
_buildInput(context),
|
||||
],
|
||||
);
|
||||
_ => const Center(child: CircularProgressIndicator.adaptive()),
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -256,14 +197,13 @@ class _ChatContentPage extends StatelessWidget {
|
||||
final refSourceJsonString =
|
||||
message.metadata?[messageRefSourceJsonStringKey] as String?;
|
||||
|
||||
return BlocSelector<ChatBloc, ChatState, bool>(
|
||||
selector: (state) {
|
||||
return BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (context, state) {
|
||||
final chatController = context.read<ChatBloc>().chatController;
|
||||
final messages = chatController.messages
|
||||
.where((e) => onetimeMessageTypeFromMeta(e.metadata) == null);
|
||||
return messages.isEmpty ? false : messages.last.id == message.id;
|
||||
},
|
||||
builder: (context, isLastMessage) {
|
||||
final isLastMessage =
|
||||
messages.isEmpty ? false : messages.last.id == message.id;
|
||||
return ChatAIMessageWidget(
|
||||
user: message.author,
|
||||
messageUserId: message.id,
|
||||
@@ -272,12 +212,13 @@ class _ChatContentPage extends StatelessWidget {
|
||||
questionId: questionId,
|
||||
chatId: view.id,
|
||||
refSourceJsonString: refSourceJsonString,
|
||||
isStreaming: state.promptResponseState != PromptResponseState.ready,
|
||||
isLastMessage: isLastMessage,
|
||||
onSelectedMetadata: (metadata) {
|
||||
context
|
||||
.read<ChatSidePanelBloc>()
|
||||
.add(ChatSidePanelEvent.selectedMetadata(metadata));
|
||||
},
|
||||
onSelectedMetadata: (metadata) =>
|
||||
_onSelectMetadata(context, metadata),
|
||||
onRegenerate: () => context
|
||||
.read<ChatBloc>()
|
||||
.add(ChatEvent.regenerateAnswer(message.id)),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -342,40 +283,92 @@ 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: () {
|
||||
context.read<ChatBloc>().add(const ChatEvent.stopStream());
|
||||
chatBloc.add(const ChatEvent.stopStream());
|
||||
},
|
||||
onSubmitted: (text, metadata) {
|
||||
chatBloc.add(
|
||||
ChatEvent.sendMessage(
|
||||
message: text,
|
||||
metadata: metadata,
|
||||
),
|
||||
);
|
||||
},
|
||||
onUpdateSelectedSources: (ids) {
|
||||
chatBloc.add(
|
||||
ChatEvent.updateSelectedSources(
|
||||
selectedSourcesIds: ids,
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: MobileAIPromptInput(
|
||||
chatId: view.id,
|
||||
onSubmitted: (text, metadata) {
|
||||
context.read<ChatBloc>().add(
|
||||
ChatEvent.sendMessage(
|
||||
message: text,
|
||||
metadata: metadata,
|
||||
),
|
||||
);
|
||||
},
|
||||
isStreaming: !canSendMessage,
|
||||
onStopStreaming: () {
|
||||
context.read<ChatBloc>().add(const ChatEvent.stopStream());
|
||||
chatBloc.add(const ChatEvent.stopStream());
|
||||
},
|
||||
onSubmitted: (text, metadata) {
|
||||
chatBloc.add(
|
||||
ChatEvent.sendMessage(
|
||||
message: text,
|
||||
metadata: metadata,
|
||||
),
|
||||
);
|
||||
},
|
||||
onUpdateSelectedSources: (ids) {
|
||||
chatBloc.add(
|
||||
ChatEvent.updateSelectedSources(
|
||||
selectedSourcesIds: ids,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 (sidebarView == null) {
|
||||
return;
|
||||
}
|
||||
if (UniversalPlatform.isDesktop) {
|
||||
getIt<TabsBloc>().add(
|
||||
TabsEvent.openSecondaryPlugin(
|
||||
plugin: sidebarView.plugin(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
unawaited(context.pushView(sidebarView));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -23,7 +23,7 @@ class ChatInputFile extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocSelector<AIPromptInputBloc, AIPromptInputState, List<ChatFile>>(
|
||||
selector: (state) => state.uploadFiles,
|
||||
selector: (state) => state.attachedFiles,
|
||||
builder: (context, files) {
|
||||
if (files.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
|
||||
+34
-28
@@ -1,15 +1,18 @@
|
||||
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.inputActionControl,
|
||||
required this.inputControlCubit,
|
||||
this.specialTextStyle,
|
||||
});
|
||||
|
||||
final ChatInputActionControl inputActionControl;
|
||||
final ChatInputControlCubit inputControlCubit;
|
||||
final TextStyle? specialTextStyle;
|
||||
|
||||
@override
|
||||
SpecialText? createSpecialText(
|
||||
@@ -22,51 +25,54 @@ class ChatInputTextSpanBuilder extends SpecialTextSpanBuilder {
|
||||
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),
|
||||
);
|
||||
if (!isStart(flag, AtText.flag)) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
|
||||
// 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AtText extends SpecialText {
|
||||
AtText(
|
||||
this.inputActionControl,
|
||||
this.inputControlCubit,
|
||||
TextStyle? textStyle,
|
||||
SpecialTextGestureTapCallback? onTap, {
|
||||
this.start,
|
||||
}) : super(flag, '', textStyle, onTap: onTap);
|
||||
|
||||
static const String flag = '@';
|
||||
|
||||
final int? start;
|
||||
final ChatInputActionControl inputActionControl;
|
||||
final ChatInputControlCubit inputControlCubit;
|
||||
|
||||
@override
|
||||
bool isEnd(String value) {
|
||||
return inputActionControl.tags.contains(value);
|
||||
}
|
||||
bool isEnd(String value) => inputControlCubit.selectedViewIds.contains(value);
|
||||
|
||||
@override
|
||||
InlineSpan finishText() {
|
||||
final TextStyle? textStyle =
|
||||
this.textStyle?.copyWith(color: Colors.blue, fontSize: 15.0);
|
||||
final String actualText = toString();
|
||||
|
||||
final String atText = toString();
|
||||
final viewName = inputControlCubit.allViews
|
||||
.firstWhereOrNull((view) => view.id == actualText.substring(1))
|
||||
?.name ??
|
||||
"";
|
||||
final nonEmptyName = viewName.isEmpty
|
||||
? LocaleKeys.document_title_placeholder.tr()
|
||||
: viewName;
|
||||
|
||||
return SpecialTextSpan(
|
||||
text: atText,
|
||||
actualText: atText,
|
||||
text: "@$nonEmptyName",
|
||||
actualText: actualText,
|
||||
start: start!,
|
||||
style: textStyle,
|
||||
recognizer: (TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
onTap?.call(atText);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
+429
@@ -0,0 +1,429 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
+471
-249
@@ -1,10 +1,8 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/ai_prompt_input_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_input_action_control.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_input/chat_input_file.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_input_action_menu.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.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';
|
||||
@@ -12,39 +10,41 @@ 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 'ai_prompt_buttons.dart';
|
||||
import 'chat_input_span.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';
|
||||
|
||||
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(List<String>) onUpdateSelectedSources;
|
||||
|
||||
@override
|
||||
State<DesktopAIPromptInput> createState() => _DesktopAIPromptInputState();
|
||||
}
|
||||
|
||||
class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
|
||||
final GlobalKey _textFieldKey = GlobalKey();
|
||||
final LayerLink _layerLink = LayerLink();
|
||||
|
||||
late final ChatInputActionControl _inputActionControl;
|
||||
late final FocusNode _inputFocusNode;
|
||||
late final TextEditingController _textController;
|
||||
final textFieldKey = GlobalKey();
|
||||
final layerLink = LayerLink();
|
||||
final overlayController = OverlayPortalController();
|
||||
final inputControlCubit = ChatInputControlCubit();
|
||||
final focusNode = FocusNode();
|
||||
final textController = TextEditingController();
|
||||
|
||||
late SendButtonState sendButtonState;
|
||||
|
||||
@@ -52,40 +52,22 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_textController = TextEditingController()
|
||||
..addListener(_handleTextControllerChange);
|
||||
textController.addListener(handleTextControllerChanged);
|
||||
|
||||
_inputFocusNode = FocusNode(
|
||||
onKeyEvent: (node, event) {
|
||||
if (_inputActionControl.canHandleKeyEvent(event)) {
|
||||
_inputActionControl.handleKeyEvent(event);
|
||||
return KeyEventResult.handled;
|
||||
} else {
|
||||
return _handleEnterKeyWithoutShift(
|
||||
event,
|
||||
_textController,
|
||||
widget.isStreaming,
|
||||
_handleSendPressed,
|
||||
);
|
||||
// refresh border color on focus change and hide menu when lost focus
|
||||
focusNode.addListener(
|
||||
() => setState(() {
|
||||
if (!focusNode.hasFocus) {
|
||||
cancelMentionPage();
|
||||
}
|
||||
},
|
||||
)..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
|
||||
@@ -96,205 +78,495 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_inputFocusNode.dispose();
|
||||
_textController.dispose();
|
||||
_inputActionControl.dispose();
|
||||
focusNode.dispose();
|
||||
textController.dispose();
|
||||
inputControlCubit.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: _inputFocusNode.hasFocus && widget.indicateFocus
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
borderRadius: DesktopAIPromptSizes.promptFrameRadius,
|
||||
),
|
||||
child: Column(
|
||||
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.deleteFile(file)),
|
||||
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,
|
||||
),
|
||||
),
|
||||
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(),
|
||||
],
|
||||
);
|
||||
},
|
||||
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(
|
||||
chatId: widget.chatId,
|
||||
textController: textController,
|
||||
overlayController: overlayController,
|
||||
focusNode: focusNode,
|
||||
sendButtonState: sendButtonState,
|
||||
onSendPressed: handleSendPressed,
|
||||
onStopStreaming: widget.onStopStreaming,
|
||||
onUpdateSelectedSources:
|
||||
widget.onUpdateSelectedSources,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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() {
|
||||
final trimmedText = _textController.text.trim();
|
||||
_textController.clear();
|
||||
void handleSendPressed() {
|
||||
if (widget.isStreaming) {
|
||||
return;
|
||||
}
|
||||
final trimmedText = inputControlCubit.formatIntputText(
|
||||
textController.text.trim(),
|
||||
);
|
||||
textController.clear();
|
||||
if (trimmedText.isEmpty) {
|
||||
return;
|
||||
}
|
||||
// consume metadata
|
||||
final ChatInputMentionMetadata mentionPageMetadata =
|
||||
_inputActionControl.consumeMetaData();
|
||||
final ChatInputFileMetadata fileMetadata =
|
||||
context.read<AIPromptInputBloc>().consumeMetadata();
|
||||
|
||||
// combine metadata
|
||||
final Map<String, dynamic> metadata = {}
|
||||
..addAll(mentionPageMetadata)
|
||||
..addAll(fileMetadata);
|
||||
// get the attached files and mentioned pages
|
||||
final metadata = context.read<AIPromptInputBloc>().consumeMetadata();
|
||||
|
||||
widget.onSubmitted(trimmedText, metadata);
|
||||
}
|
||||
|
||||
void _handleTextControllerChange() {
|
||||
if (_textController.value.isComposingRangeValid) {
|
||||
void handleTextControllerChanged() {
|
||||
if (!textController.value.composing.isCollapsed) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
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,
|
||||
hintText: switch (state.aiType) {
|
||||
AIType.appflowyAI => LocaleKeys.chat_inputMessageHint.tr(),
|
||||
AIType.localAI => LocaleKeys.chat_inputLocalAIMessageHint.tr()
|
||||
},
|
||||
hintStyle: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(color: Theme.of(context).hintColor),
|
||||
isCollapsed: true,
|
||||
isDense: true,
|
||||
// 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,
|
||||
),
|
||||
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);
|
||||
},
|
||||
),
|
||||
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.chatId,
|
||||
required this.textController,
|
||||
required this.overlayController,
|
||||
required this.focusNode,
|
||||
required this.sendButtonState,
|
||||
required this.onSendPressed,
|
||||
required this.onStopStreaming,
|
||||
required this.onUpdateSelectedSources,
|
||||
});
|
||||
|
||||
final String chatId;
|
||||
final TextEditingController textController;
|
||||
final OverlayPortalController overlayController;
|
||||
final FocusNode focusNode;
|
||||
final SendButtonState sendButtonState;
|
||||
final void Function() onSendPressed;
|
||||
final void Function() onStopStreaming;
|
||||
final void Function(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(),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 _selectSourcesButton(BuildContext context) {
|
||||
return PromptInputDesktopSelectSourcesButton(
|
||||
chatId: chatId,
|
||||
onUpdateSelectedSources: onUpdateSelectedSources,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _attachmentButton() {
|
||||
// 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) {
|
||||
return PromptInputAttachmentButton(
|
||||
onTap: () async {
|
||||
final path = await getIt<FilePickerService>().pickFiles(
|
||||
@@ -308,12 +580,10 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
|
||||
}
|
||||
|
||||
for (final file in path.files) {
|
||||
if (file.path != null) {
|
||||
if (mounted) {
|
||||
context
|
||||
.read<AIPromptInputBloc>()
|
||||
.add(AIPromptInputEvent.newFile(file.path!, file.name));
|
||||
}
|
||||
if (file.path != null && context.mounted) {
|
||||
context
|
||||
.read<AIPromptInputBloc>()
|
||||
.add(AIPromptInputEvent.attachFile(file.path!, file.name));
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -322,59 +592,11 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
|
||||
|
||||
Widget _sendButton() {
|
||||
return PromptInputSendButton(
|
||||
buttonSize: DesktopAIPromptSizes.actionBarButtonSize,
|
||||
buttonSize: DesktopAIPromptSizes.actionBarHeight,
|
||||
iconSize: DesktopAIPromptSizes.sendButtonSize,
|
||||
state: sendButtonState,
|
||||
onSendPressed: _handleSendPressed,
|
||||
onStopStreaming: widget.onStopStreaming,
|
||||
onSendPressed: onSendPressed,
|
||||
onStopStreaming: 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;
|
||||
}
|
||||
}
|
||||
+203
-153
@@ -1,11 +1,6 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/ai_prompt_input_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_input_action_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_input_action_control.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_input/chat_input_file.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_input_action_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mobile_page_selector_sheet.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.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';
|
||||
@@ -13,9 +8,12 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'ai_prompt_buttons.dart';
|
||||
import 'chat_input_span.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';
|
||||
|
||||
class MobileAIPromptInput extends StatefulWidget {
|
||||
const MobileAIPromptInput({
|
||||
@@ -24,21 +22,23 @@ 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> {
|
||||
late final ChatInputActionControl _inputActionControl;
|
||||
late final FocusNode _inputFocusNode;
|
||||
late final TextEditingController _textController;
|
||||
final inputControlCubit = ChatInputControlCubit();
|
||||
final focusNode = FocusNode();
|
||||
final textController = TextEditingController();
|
||||
|
||||
late SendButtonState sendButtonState;
|
||||
|
||||
@@ -46,20 +46,13 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_textController = TextEditingController()
|
||||
..addListener(_handleTextControllerChange);
|
||||
textController.addListener(handleTextControllerChange);
|
||||
// focusNode.onKeyEvent = handleKeyEvent;
|
||||
|
||||
_inputFocusNode = FocusNode();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_inputFocusNode.requestFocus();
|
||||
focusNode.requestFocus();
|
||||
});
|
||||
|
||||
_inputActionControl = ChatInputActionControl(
|
||||
chatId: widget.chatId,
|
||||
textController: _textController,
|
||||
textFieldFocusNode: _inputFocusNode,
|
||||
);
|
||||
|
||||
updateSendButtonState();
|
||||
}
|
||||
|
||||
@@ -71,9 +64,9 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_inputFocusNode.dispose();
|
||||
_textController.dispose();
|
||||
_inputActionControl.dispose();
|
||||
focusNode.dispose();
|
||||
textController.dispose();
|
||||
inputControlCubit.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -81,55 +74,71 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
|
||||
Widget build(BuildContext context) {
|
||||
return Hero(
|
||||
tag: "ai_chat_prompt",
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(color: Theme.of(context).colorScheme.outline),
|
||||
),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
blurRadius: 4.0,
|
||||
offset: Offset(0, -2),
|
||||
color: Color.fromRGBO(0, 0, 0, 0.05),
|
||||
),
|
||||
],
|
||||
borderRadius: MobileAIPromptSizes.promptFrameRadius,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight:
|
||||
MobileAIPromptSizes.attachedFilesBarPadding.vertical +
|
||||
MobileAIPromptSizes.attachedFilesPreviewHeight,
|
||||
child: 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),
|
||||
),
|
||||
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(),
|
||||
],
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -138,138 +147,179 @@ 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() {
|
||||
final trimmedText = _textController.text.trim();
|
||||
_textController.clear();
|
||||
void handleSendPressed() {
|
||||
if (widget.isStreaming) {
|
||||
return;
|
||||
}
|
||||
final trimmedText = inputControlCubit.formatIntputText(
|
||||
textController.text.trim(),
|
||||
);
|
||||
textController.clear();
|
||||
if (trimmedText.isEmpty) {
|
||||
return;
|
||||
}
|
||||
// consume metadata
|
||||
final ChatInputMentionMetadata mentionPageMetadata =
|
||||
_inputActionControl.consumeMetaData();
|
||||
final ChatInputFileMetadata fileMetadata =
|
||||
context.read<AIPromptInputBloc>().consumeMetadata();
|
||||
|
||||
// combine metadata
|
||||
final Map<String, dynamic> metadata = {}
|
||||
..addAll(mentionPageMetadata)
|
||||
..addAll(fileMetadata);
|
||||
// get the attached files and mentioned pages
|
||||
final metadata = context.read<AIPromptInputBloc>().consumeMetadata();
|
||||
|
||||
widget.onSubmitted(trimmedText, metadata);
|
||||
}
|
||||
|
||||
void _handleTextControllerChange() {
|
||||
if (_textController.value.isComposingRangeValid) {
|
||||
void handleTextControllerChange() {
|
||||
if (textController.value.isComposingRangeValid) {
|
||||
return;
|
||||
}
|
||||
// inputControlCubit.updateInputText(textController.text);
|
||||
setState(() => updateSendButtonState());
|
||||
}
|
||||
|
||||
Widget _inputTextField(BuildContext context) {
|
||||
// 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) {
|
||||
return BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
|
||||
builder: (context, state) {
|
||||
return ExtendedTextField(
|
||||
controller: _textController,
|
||||
focusNode: _inputFocusNode,
|
||||
decoration: _buildInputDecoration(state),
|
||||
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,
|
||||
),
|
||||
keyboardType: TextInputType.multiline,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
minLines: 1,
|
||||
maxLines: null,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
specialTextSpanBuilder: ChatInputTextSpanBuilder(
|
||||
inputActionControl: _inputActionControl,
|
||||
inputControlCubit: inputControlCubit,
|
||||
specialTextStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
onChanged: (text) {
|
||||
_handleOnTextChange(context, text);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
InputDecoration _buildInputDecoration(AIPromptInputState state) {
|
||||
return InputDecoration(
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
contentPadding: MobileAIPromptSizes.textFieldContentPadding,
|
||||
hintText: switch (state.aiType) {
|
||||
AIType.appflowyAI => LocaleKeys.chat_inputMessageHint.tr(),
|
||||
AIType.localAI => LocaleKeys.chat_inputLocalAIMessageHint.tr()
|
||||
},
|
||||
isCollapsed: true,
|
||||
isDense: true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleOnTextChange(BuildContext context, String text) async {
|
||||
if (!_inputActionControl.onTextChanged(text)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if the focus node is on focus, unfocus it for better animation
|
||||
// otherwise, the page sheet animation will be blocked by the keyboard
|
||||
if (_inputFocusNode.hasFocus) {
|
||||
_inputFocusNode.unfocus();
|
||||
Future.delayed(const Duration(milliseconds: 100), () async {
|
||||
await _referPage(_inputActionControl);
|
||||
});
|
||||
} else {
|
||||
await _referPage(_inputActionControl);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _mentionButton() {
|
||||
return Align(
|
||||
Widget leadingButtons(BuildContext context) {
|
||||
return Container(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: PromptInputMentionButton(
|
||||
iconSize: MobileAIPromptSizes.mentionIconSize,
|
||||
buttonSize: MobileAIPromptSizes.sendButtonSize,
|
||||
onTap: () {
|
||||
_textController.text += '@';
|
||||
if (!_inputFocusNode.hasFocus) {
|
||||
_inputFocusNode.requestFocus();
|
||||
}
|
||||
_handleOnTextChange(context, _textController.text);
|
||||
},
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: _LeadingActions(
|
||||
chatId: widget.chatId,
|
||||
textController: textController,
|
||||
// onMention: () {
|
||||
// textController.text += '@';
|
||||
// if (!focusNode.hasFocus) {
|
||||
// focusNode.requestFocus();
|
||||
// }
|
||||
// WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// mentionPage(context);
|
||||
// });
|
||||
// },
|
||||
onUpdateSelectedSources: widget.onUpdateSelectedSources,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sendButton() {
|
||||
return Align(
|
||||
Widget sendButton() {
|
||||
return Container(
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _referPage(ChatActionHandler handler) async {
|
||||
handler.onEnter();
|
||||
final selectedView = await showPageSelectorSheet(
|
||||
context,
|
||||
filter: (view) =>
|
||||
view.layout.isDocumentView &&
|
||||
!view.isSpace &&
|
||||
view.parentViewId.isNotEmpty,
|
||||
class _LeadingActions extends StatelessWidget {
|
||||
const _LeadingActions({
|
||||
required this.chatId,
|
||||
required this.textController,
|
||||
required this.onUpdateSelectedSources,
|
||||
});
|
||||
|
||||
final String chatId;
|
||||
final TextEditingController textController;
|
||||
final void Function(List<String>) onUpdateSelectedSources;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Theme.of(context).cardColor,
|
||||
child: PromptInputMobileSelectSourcesButton(
|
||||
chatId: chatId,
|
||||
onUpdateSelectedSources: onUpdateSelectedSources,
|
||||
),
|
||||
);
|
||||
if (selectedView != null) {
|
||||
handler.onSelected(ViewActionPage(view: selectedView));
|
||||
}
|
||||
handler.onExit();
|
||||
_inputFocusNode.requestFocus();
|
||||
}
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
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.chatId,
|
||||
required this.onUpdateSelectedSources,
|
||||
});
|
||||
|
||||
final String chatId;
|
||||
final void Function(List<String>) onUpdateSelectedSources;
|
||||
|
||||
@override
|
||||
State<PromptInputMobileSelectSourcesButton> createState() =>
|
||||
_PromptInputMobileSelectSourcesButtonState();
|
||||
}
|
||||
|
||||
class _PromptInputMobileSelectSourcesButtonState
|
||||
extends State<PromptInputMobileSelectSourcesButton> {
|
||||
late final cubit = ChatSettingsCubit(chatId: widget.chatId);
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
+514
@@ -0,0 +1,514 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.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: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.chatId,
|
||||
required this.onUpdateSelectedSources,
|
||||
});
|
||||
|
||||
final String chatId;
|
||||
final void Function(List<String>) onUpdateSelectedSources;
|
||||
|
||||
@override
|
||||
State<PromptInputDesktopSelectSourcesButton> createState() =>
|
||||
_PromptInputDesktopSelectSourcesButtonState();
|
||||
}
|
||||
|
||||
class _PromptInputDesktopSelectSourcesButtonState
|
||||
extends State<PromptInputDesktopSelectSourcesButton> {
|
||||
late final cubit = ChatSettingsCubit(chatId: widget.chatId);
|
||||
final popoverController = PopoverController();
|
||||
|
||||
@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) {
|
||||
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
|
||||
..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.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),
|
||||
),
|
||||
),
|
||||
_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,
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@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,
|
||||
onSelected: widget.onSelected,
|
||||
),
|
||||
);
|
||||
|
||||
final disabledEnabledChild =
|
||||
widget.chatSource.ignoreStatus == IgnoreViewType.disable
|
||||
? FlowyTooltip(
|
||||
message: switch (widget.chatSource.view.layout) {
|
||||
ViewLayoutPB.Document =>
|
||||
"You can only select up to 3 top-level documents and its children",
|
||||
_ => "We don't support chatting with databases at this time",
|
||||
},
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChatSourceTreeItemInner extends StatelessWidget {
|
||||
const ChatSourceTreeItemInner({
|
||||
super.key,
|
||||
required this.chatSource,
|
||||
required this.level,
|
||||
required this.isDescendentOfSpace,
|
||||
required this.isSelectedSection,
|
||||
this.onSelected,
|
||||
});
|
||||
|
||||
final ChatSource chatSource;
|
||||
final int level;
|
||||
final bool isDescendentOfSpace;
|
||||
final bool isSelectedSection;
|
||||
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) ...[
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
-320
@@ -1,320 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ class AIChatUILayout {
|
||||
query.padding.right,
|
||||
query.viewInsets.bottom + query.padding.bottom,
|
||||
)
|
||||
: const EdgeInsets.only(bottom: 16);
|
||||
: const EdgeInsets.only(bottom: 24);
|
||||
}
|
||||
|
||||
static EdgeInsets get messageMargin => UniversalPlatform.isMobile
|
||||
@@ -20,22 +20,23 @@ class AIChatUILayout {
|
||||
}
|
||||
|
||||
class DesktopAIPromptSizes {
|
||||
static const promptFrameRadius = BorderRadius.all(Radius.circular(8));
|
||||
static const promptFrameRadius = BorderRadius.all(Radius.circular(12.0));
|
||||
|
||||
static const attachedFilesBarPadding =
|
||||
EdgeInsets.only(top: 8.0, left: 8.0, right: 8.0);
|
||||
static const attachedFilesPreviewHeight = 48.0;
|
||||
static const attachedFilesPreviewSpacing = 12.0;
|
||||
|
||||
static const textFieldMinHeight = 36.0;
|
||||
static const textFieldMinHeight = 40.0;
|
||||
static const textFieldContentPadding =
|
||||
EdgeInsetsDirectional.fromSTEB(12.0, 12.0, 12.0, 4.0);
|
||||
EdgeInsetsDirectional.fromSTEB(14.0, 12.0, 14.0, 8.0);
|
||||
|
||||
static const actionBarHeight = 28.0;
|
||||
static const actionBarButtonSize = 24.0;
|
||||
static const actionBarHeight = 32.0;
|
||||
static const actionBarPadding = EdgeInsetsDirectional.fromSTEB(8, 0, 8, 4);
|
||||
static const actionBarButtonSize = 28.0;
|
||||
static const actionBarIconSize = 16.0;
|
||||
static const actionBarButtonSpacing = 4.0;
|
||||
static const sendButtonSize = 20.0;
|
||||
static const sendButtonSize = 24.0;
|
||||
}
|
||||
|
||||
class MobileAIPromptSizes {
|
||||
@@ -60,7 +61,11 @@ class DesktopAIConvoSizes {
|
||||
|
||||
static const avatarAndChatBubbleSpacing = 12.0;
|
||||
|
||||
static const actionBarIconSize = 24.0;
|
||||
static const actionBarIconSize = 28.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));
|
||||
}
|
||||
+2
-1
@@ -8,6 +8,7 @@ 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';
|
||||
|
||||
@@ -106,7 +107,7 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
|
||||
shrinkWrap: true,
|
||||
// the editor is not editable in the chat
|
||||
editable: false,
|
||||
disableKeyboardService: true,
|
||||
disableKeyboardService: UniversalPlatform.isMobile,
|
||||
editorStyle: editorStyle,
|
||||
editorScrollController: scrollController,
|
||||
blockComponentBuilders: blockBuilders,
|
||||
|
||||
+122
-47
@@ -30,12 +30,14 @@ 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) {
|
||||
@@ -60,6 +62,7 @@ class ChatAIMessageBubble extends StatelessWidget {
|
||||
Widget _wrapBottomActions(Widget child) {
|
||||
return ChatAIBottomInlineActions(
|
||||
message: message,
|
||||
onRegenerate: onRegenerate,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
@@ -67,6 +70,7 @@ class ChatAIMessageBubble extends StatelessWidget {
|
||||
Widget _wrapHover(Widget child) {
|
||||
return ChatAIMessageHover(
|
||||
message: message,
|
||||
onRegenerate: onRegenerate,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
@@ -74,6 +78,7 @@ class ChatAIMessageBubble extends StatelessWidget {
|
||||
Widget _wrapPopMenu(Widget child) {
|
||||
return ChatAIMessagePopup(
|
||||
message: message,
|
||||
onRegenerate: onRegenerate,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
@@ -84,10 +89,12 @@ 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) {
|
||||
@@ -102,12 +109,9 @@ class ChatAIBottomInlineActions extends StatelessWidget {
|
||||
DesktopAIConvoSizes.avatarAndChatBubbleSpacing,
|
||||
),
|
||||
child: AIResponseActionBar(
|
||||
message: message,
|
||||
showDecoration: false,
|
||||
children: [
|
||||
CopyButton(
|
||||
textMessage: message as TextMessage,
|
||||
),
|
||||
],
|
||||
onRegenerate: onRegenerate,
|
||||
),
|
||||
),
|
||||
const VSpace(32.0),
|
||||
@@ -121,10 +125,12 @@ 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();
|
||||
@@ -194,19 +200,17 @@ class _ChatAIMessageHoverState extends State<ChatAIMessageHover> {
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 784,
|
||||
maxHeight: 28,
|
||||
maxHeight: DesktopAIConvoSizes.actionBarIconSize +
|
||||
DesktopAIConvoSizes.hoverActionBarPadding.vertical,
|
||||
),
|
||||
alignment: Alignment.topLeft,
|
||||
child: hoverBubble || hoverActionBar
|
||||
? AIResponseActionBar(
|
||||
message: widget.message,
|
||||
showDecoration: true,
|
||||
children: [
|
||||
CopyButton(
|
||||
textMessage: widget.message as TextMessage,
|
||||
),
|
||||
],
|
||||
onRegenerate: widget.onRegenerate,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
@@ -269,12 +273,14 @@ class _ChatAIMessageHoverState extends State<ChatAIMessageHover> {
|
||||
class AIResponseActionBar extends StatelessWidget {
|
||||
const AIResponseActionBar({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.showDecoration,
|
||||
required this.children,
|
||||
this.onRegenerate,
|
||||
});
|
||||
|
||||
final List<Widget> children;
|
||||
final Message message;
|
||||
final bool showDecoration;
|
||||
final void Function()? onRegenerate;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -284,14 +290,14 @@ class AIResponseActionBar extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
separatorBuilder: () =>
|
||||
const HSpace(DesktopAIConvoSizes.actionBarIconSpacing),
|
||||
children: children,
|
||||
children: _buildChildren(),
|
||||
);
|
||||
|
||||
return showDecoration
|
||||
? Container(
|
||||
padding: const EdgeInsets.all(2.0),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: DesktopAIConvoSizes.actionBarIconRadius,
|
||||
borderRadius: DesktopAIConvoSizes.hoverActionBarRadius,
|
||||
border: Border.all(
|
||||
color: isLightMode
|
||||
? const Color(0x1F1F2329)
|
||||
@@ -328,13 +334,29 @@ class AIResponseActionBar extends StatelessWidget {
|
||||
)
|
||||
: child;
|
||||
}
|
||||
|
||||
List<Widget> _buildChildren() {
|
||||
return [
|
||||
CopyButton(
|
||||
isInHoverBar: showDecoration,
|
||||
textMessage: message as TextMessage,
|
||||
),
|
||||
RegenerateButton(
|
||||
isInHoverBar: showDecoration,
|
||||
onTap: () => onRegenerate?.call(),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class CopyButton extends StatelessWidget {
|
||||
const CopyButton({
|
||||
super.key,
|
||||
required this.isInHoverBar,
|
||||
required this.textMessage,
|
||||
});
|
||||
|
||||
final bool isInHoverBar;
|
||||
final TextMessage textMessage;
|
||||
|
||||
@override
|
||||
@@ -344,7 +366,9 @@ class CopyButton extends StatelessWidget {
|
||||
child: FlowyIconButton(
|
||||
width: DesktopAIConvoSizes.actionBarIconSize,
|
||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
radius: DesktopAIConvoSizes.actionBarIconRadius,
|
||||
radius: isInHoverBar
|
||||
? DesktopAIConvoSizes.hoverActionBarIconRadius
|
||||
: DesktopAIConvoSizes.actionBarIconRadius,
|
||||
icon: FlowySvg(
|
||||
FlowySvgs.copy_s,
|
||||
color: Theme.of(context).hintColor,
|
||||
@@ -370,15 +394,48 @@ class CopyButton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
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 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) {
|
||||
@@ -393,41 +450,59 @@ class ChatAIMessagePopup extends StatelessWidget {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
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(),
|
||||
),
|
||||
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),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
child: IgnorePointer(
|
||||
child: child,
|
||||
),
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
@@ -43,6 +42,7 @@ 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,11 +87,7 @@ class _AIMessageMetadataState extends State<AIMessageMetadata> {
|
||||
size: const Size.square(16),
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
disable: m.source != appflowySource,
|
||||
onTap: () {
|
||||
if (m.source != appflowySource) {
|
||||
return;
|
||||
}
|
||||
widget.onSelectedMetadata?.call(m);
|
||||
},
|
||||
),
|
||||
|
||||
+19
-10
@@ -8,12 +8,13 @@ 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 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import '../chat_loading.dart';
|
||||
import '../layout_define.dart';
|
||||
import 'ai_markdown_text.dart';
|
||||
import 'ai_message_bubble.dart';
|
||||
import 'ai_metadata.dart';
|
||||
import 'loading_indicator.dart';
|
||||
import 'error_text_message.dart';
|
||||
|
||||
/// [ChatAIMessageWidget] includes both the text of the AI response as well as
|
||||
@@ -32,7 +33,9 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
required this.chatId,
|
||||
required this.refSourceJsonString,
|
||||
this.onSelectedMetadata,
|
||||
this.onRegenerate,
|
||||
this.isLastMessage = false,
|
||||
this.isStreaming = false,
|
||||
});
|
||||
|
||||
final User user;
|
||||
@@ -44,6 +47,8 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
final String chatId;
|
||||
final String? refSourceJsonString;
|
||||
final void Function(ChatMessageRefSource metadata)? onSelectedMetadata;
|
||||
final void Function()? onRegenerate;
|
||||
final bool isStreaming;
|
||||
final bool isLastMessage;
|
||||
|
||||
@override
|
||||
@@ -57,29 +62,38 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
),
|
||||
child: BlocBuilder<ChatAIMessageBloc, ChatAIMessageState>(
|
||||
builder: (context, state) {
|
||||
final loadingText =
|
||||
state.progress?.step ?? LocaleKeys.chat_generatingResponse.tr();
|
||||
|
||||
return Padding(
|
||||
padding: AIChatUILayout.messageMargin,
|
||||
child: state.messageState.when(
|
||||
loading: () => ChatAIMessageBubble(
|
||||
message: message,
|
||||
showActions: false,
|
||||
child: const ChatAILoading(),
|
||||
child: ChatAILoading(text: loadingText),
|
||||
),
|
||||
ready: () {
|
||||
return state.text.isEmpty
|
||||
? ChatAIMessageBubble(
|
||||
message: message,
|
||||
showActions: false,
|
||||
child: const ChatAILoading(),
|
||||
child: ChatAILoading(text: loadingText),
|
||||
)
|
||||
: ChatAIMessageBubble(
|
||||
message: message,
|
||||
isLastMessage: isLastMessage,
|
||||
showActions: stream == null && state.text.isNotEmpty,
|
||||
showActions: stream == null &&
|
||||
state.text.isNotEmpty &&
|
||||
!isStreaming,
|
||||
onRegenerate: onRegenerate,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AIMarkdownText(markdown: state.text),
|
||||
IgnorePointer(
|
||||
ignoring: UniversalPlatform.isMobile,
|
||||
child: AIMarkdownText(markdown: state.text),
|
||||
),
|
||||
if (state.sources.isNotEmpty)
|
||||
AIMessageMetadata(
|
||||
sources: state.sources,
|
||||
@@ -94,11 +108,6 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
onError: (error) {
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage: LocaleKeys.chat_aiServerUnavailable.tr(),
|
||||
onRetry: () {
|
||||
context
|
||||
.read<ChatAIMessageBloc>()
|
||||
.add(const ChatAIMessageEvent.retry());
|
||||
},
|
||||
);
|
||||
},
|
||||
onAIResponseLimit: () {
|
||||
|
||||
+5
-12
@@ -1,18 +1,16 @@
|
||||
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.text = "",
|
||||
this.duration = const Duration(seconds: 1),
|
||||
});
|
||||
|
||||
final String text;
|
||||
final Duration duration;
|
||||
|
||||
@override
|
||||
@@ -27,14 +25,9 @@ class ChatAILoading extends StatelessWidget {
|
||||
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,
|
||||
);
|
||||
},
|
||||
child: FlowyText(
|
||||
text,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
buildDot(const Color(0xFF9327FF))
|
||||
@@ -1,23 +1,40 @@
|
||||
import 'package:appflowy/plugins/base/icon/icon_picker_page.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../generated/locale_keys.g.dart';
|
||||
import '../../../mobile/presentation/base/app_bar/app_bar.dart';
|
||||
import '../../../shared/icon_emoji_picker/tab.dart';
|
||||
|
||||
class MobileEmojiPickerScreen extends StatelessWidget {
|
||||
const MobileEmojiPickerScreen({super.key, this.title});
|
||||
const MobileEmojiPickerScreen({
|
||||
super.key,
|
||||
this.title,
|
||||
this.tabs = const [PickerTabType.emoji, PickerTabType.icon],
|
||||
});
|
||||
|
||||
final String? title;
|
||||
final List<PickerTabType> tabs;
|
||||
|
||||
static const routeName = '/emoji_picker';
|
||||
static const pageTitle = 'title';
|
||||
static const selectTabs = 'tabs';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconPickerPage(
|
||||
title: title,
|
||||
onSelected: (result) {
|
||||
context.pop<EmojiPickerResult>(result);
|
||||
},
|
||||
return Scaffold(
|
||||
appBar: FlowyAppBar(
|
||||
titleText: title ?? LocaleKeys.titleBar_pageIcon.tr(),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: FlowyIconEmojiPicker(
|
||||
tabs: tabs,
|
||||
onSelectedEmoji: (r) {
|
||||
context.pop<EmojiIconData>(r);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class IconPickerPage extends StatelessWidget {
|
||||
const IconPickerPage({
|
||||
super.key,
|
||||
this.title,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final void Function(EmojiPickerResult) onSelected;
|
||||
final String? title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: FlowyAppBar(
|
||||
titleText: title ?? LocaleKeys.titleBar_pageIcon.tr(),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: FlowyIconEmojiPicker(onSelectedEmoji: onSelected),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../generated/flowy_svgs.g.dart';
|
||||
|
||||
class IconWidget extends StatelessWidget {
|
||||
const IconWidget({
|
||||
super.key,
|
||||
required this.size,
|
||||
required this.data,
|
||||
});
|
||||
|
||||
final IconsData data;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorValue = int.tryParse(data.color ?? '');
|
||||
Color? color;
|
||||
if (colorValue != null) {
|
||||
color = Color(colorValue);
|
||||
}
|
||||
return FlowySvg.string(
|
||||
data.iconContent,
|
||||
size: Size.square(size),
|
||||
color: color,
|
||||
);
|
||||
}
|
||||
}
|
||||
-2
@@ -9,11 +9,9 @@ class CalculationsBackendService {
|
||||
final String viewId;
|
||||
|
||||
// Get Calculations (initial fetch)
|
||||
|
||||
Future<FlowyResult<RepeatedCalculationsPB, FlowyError>>
|
||||
getCalculations() async {
|
||||
final payload = DatabaseViewIdPB()..value = viewId;
|
||||
|
||||
return DatabaseEventGetAllCalculations(payload).send();
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ class BoardPageTabBarBuilderImpl extends DatabaseTabBarItemBuilder {
|
||||
key: _makeValueKey(controller),
|
||||
view: view,
|
||||
databaseController: controller,
|
||||
shrinkWrap: shrinkWrap,
|
||||
)
|
||||
: MobileBoardPage(
|
||||
key: _makeValueKey(controller),
|
||||
@@ -98,6 +99,7 @@ class DesktopBoardPage extends StatefulWidget {
|
||||
required this.view,
|
||||
required this.databaseController,
|
||||
this.onEditStateChanged,
|
||||
this.shrinkWrap = false,
|
||||
});
|
||||
|
||||
final ViewPB view;
|
||||
@@ -107,6 +109,9 @@ class DesktopBoardPage extends StatefulWidget {
|
||||
/// Called when edit state changed
|
||||
final VoidCallback? onEditStateChanged;
|
||||
|
||||
/// If true, the board will shrink wrap its content
|
||||
final bool shrinkWrap;
|
||||
|
||||
@override
|
||||
State<DesktopBoardPage> createState() => _DesktopBoardPageState();
|
||||
}
|
||||
@@ -187,24 +192,17 @@ class _DesktopBoardPageState extends State<DesktopBoardPage> {
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(
|
||||
value: _boardBloc,
|
||||
),
|
||||
BlocProvider.value(
|
||||
value: _boardActionsCubit,
|
||||
),
|
||||
BlocProvider<BoardBloc>.value(value: _boardBloc),
|
||||
BlocProvider.value(value: _boardActionsCubit),
|
||||
],
|
||||
child: BlocBuilder<BoardBloc, BoardState>(
|
||||
builder: (context, state) => state.maybeMap(
|
||||
loading: (_) => const Center(
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
),
|
||||
error: (err) => Center(
|
||||
child: AppFlowyErrorPage(
|
||||
error: err.error,
|
||||
),
|
||||
),
|
||||
error: (err) => Center(child: AppFlowyErrorPage(error: err.error)),
|
||||
orElse: () => _BoardContent(
|
||||
shrinkWrap: widget.shrinkWrap,
|
||||
onEditStateChanged: widget.onEditStateChanged,
|
||||
focusScope: _focusScope,
|
||||
boardController: _boardController,
|
||||
@@ -243,11 +241,13 @@ class _BoardContent extends StatefulWidget {
|
||||
required this.boardController,
|
||||
required this.focusScope,
|
||||
this.onEditStateChanged,
|
||||
this.shrinkWrap = false,
|
||||
});
|
||||
|
||||
final AppFlowyBoardController boardController;
|
||||
final BoardFocusScope focusScope;
|
||||
final VoidCallback? onEditStateChanged;
|
||||
final bool shrinkWrap;
|
||||
|
||||
@override
|
||||
State<_BoardContent> createState() => _BoardContentState();
|
||||
@@ -358,12 +358,8 @@ class _BoardContentState extends State<_BoardContent> {
|
||||
),
|
||||
footerBuilder: (_, groupData) => MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(
|
||||
value: context.read<BoardBloc>(),
|
||||
),
|
||||
BlocProvider.value(
|
||||
value: context.read<BoardActionsCubit>(),
|
||||
),
|
||||
BlocProvider.value(value: context.read<BoardBloc>()),
|
||||
BlocProvider.value(value: context.read<BoardActionsCubit>()),
|
||||
],
|
||||
child: BoardColumnFooter(
|
||||
columnData: groupData,
|
||||
|
||||
@@ -20,13 +20,20 @@ import '../../application/database_controller.dart';
|
||||
part 'grid_bloc.freezed.dart';
|
||||
|
||||
class GridBloc extends Bloc<GridEvent, GridState> {
|
||||
GridBloc({required ViewPB view, required this.databaseController})
|
||||
: super(GridState.initial(view.id)) {
|
||||
GridBloc({
|
||||
required ViewPB view,
|
||||
required this.databaseController,
|
||||
this.shrinkWrapped = false,
|
||||
}) : super(GridState.initial(view.id)) {
|
||||
_dispatch();
|
||||
}
|
||||
|
||||
final DatabaseController databaseController;
|
||||
|
||||
/// When true will emit the count of visible rows to show
|
||||
///
|
||||
final bool shrinkWrapped;
|
||||
|
||||
String get viewId => databaseController.viewId;
|
||||
|
||||
UserProfilePB? _userProfile;
|
||||
@@ -64,12 +71,22 @@ class GridBloc extends Bloc<GridEvent, GridState> {
|
||||
);
|
||||
},
|
||||
createRow: (openRowDetail) async {
|
||||
final result = await RowBackendService.createRow(viewId: viewId);
|
||||
final lastVisibleRowId =
|
||||
shrinkWrapped ? state.lastVisibleRow?.rowId : null;
|
||||
|
||||
final result = await RowBackendService.createRow(
|
||||
viewId: viewId,
|
||||
position: lastVisibleRowId != null
|
||||
? OrderObjectPositionTypePB.After
|
||||
: null,
|
||||
targetRowId: lastVisibleRowId,
|
||||
);
|
||||
result.fold(
|
||||
(createdRow) => emit(
|
||||
state.copyWith(
|
||||
createdRow: createdRow,
|
||||
openRowDetail: openRowDetail ?? false,
|
||||
visibleRows: state.visibleRows + 1,
|
||||
),
|
||||
),
|
||||
(err) => Log.error(err),
|
||||
@@ -93,11 +110,7 @@ class GridBloc extends Bloc<GridEvent, GridState> {
|
||||
databaseController.moveRow(fromRowId: fromRow, toRowId: toRow);
|
||||
},
|
||||
didReceiveFieldUpdate: (fields) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
fields: fields,
|
||||
),
|
||||
);
|
||||
emit(state.copyWith(fields: fields));
|
||||
},
|
||||
didLoadRows: (newRowInfos, reason) {
|
||||
emit(
|
||||
@@ -109,17 +122,13 @@ class GridBloc extends Bloc<GridEvent, GridState> {
|
||||
);
|
||||
},
|
||||
didReceveFilters: (filters) {
|
||||
emit(
|
||||
state.copyWith(filters: filters),
|
||||
);
|
||||
emit(state.copyWith(filters: filters));
|
||||
},
|
||||
didReceveSorts: (sorts) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
reorderable: sorts.isEmpty,
|
||||
sorts: sorts,
|
||||
),
|
||||
);
|
||||
emit(state.copyWith(reorderable: sorts.isEmpty, sorts: sorts));
|
||||
},
|
||||
loadMoreRows: () {
|
||||
emit(state.copyWith(visibleRows: state.visibleRows + 25));
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -204,11 +213,11 @@ class GridEvent with _$GridEvent {
|
||||
const factory GridEvent.didReceiveFieldUpdate(
|
||||
List<FieldInfo> fields,
|
||||
) = _DidReceiveFieldUpdate;
|
||||
|
||||
const factory GridEvent.didReceveFilters(List<DatabaseFilter> filters) =
|
||||
_DidReceiveFilters;
|
||||
const factory GridEvent.didReceveSorts(List<DatabaseSort> sorts) =
|
||||
_DidReceiveSorts;
|
||||
const factory GridEvent.loadMoreRows() = _LoadMoreRows;
|
||||
}
|
||||
|
||||
@freezed
|
||||
@@ -225,6 +234,7 @@ class GridState with _$GridState {
|
||||
required List<DatabaseSort> sorts,
|
||||
required List<DatabaseFilter> filters,
|
||||
required bool openRowDetail,
|
||||
@Default(0) int visibleRows,
|
||||
}) = _GridState;
|
||||
|
||||
factory GridState.initial(String viewId) => GridState(
|
||||
@@ -239,5 +249,19 @@ class GridState with _$GridState {
|
||||
filters: [],
|
||||
sorts: [],
|
||||
openRowDetail: false,
|
||||
visibleRows: 25,
|
||||
);
|
||||
}
|
||||
|
||||
extension _LastVisibleRow on GridState {
|
||||
/// Returns the last visible [RowInfo] in the list of [rowInfos].
|
||||
/// Only returns if the visibleRows is less than the rowCount, otherwise returns null.
|
||||
///
|
||||
RowInfo? get lastVisibleRow {
|
||||
if (visibleRows < rowCount) {
|
||||
return rowInfos[visibleRows - 1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/grid_setting_bar.dart';
|
||||
import 'package:appflowy/plugins/database/tab_bar/desktop/setting_menu.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
@@ -8,8 +9,6 @@ import 'package:appflowy/plugins/database/application/row/row_service.dart';
|
||||
import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart';
|
||||
import 'package:appflowy/plugins/database/domain/sort_service.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/calculations_row.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/grid_setting_bar.dart';
|
||||
import 'package:appflowy/plugins/database/tab_bar/desktop/setting_menu.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart';
|
||||
import 'package:appflowy/shared/flowy_error_page.dart';
|
||||
import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart';
|
||||
@@ -67,6 +66,7 @@ class DesktopGridTabBarBuilderImpl extends DatabaseTabBarItemBuilder {
|
||||
view: view,
|
||||
databaseController: controller,
|
||||
initialRowId: initialRowId,
|
||||
shrinkWrap: shrinkWrap,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -110,12 +110,14 @@ class GridPage extends StatefulWidget {
|
||||
required this.databaseController,
|
||||
this.onDeleted,
|
||||
this.initialRowId,
|
||||
this.shrinkWrap = false,
|
||||
});
|
||||
|
||||
final ViewPB view;
|
||||
final DatabaseController databaseController;
|
||||
final VoidCallback? onDeleted;
|
||||
final String? initialRowId;
|
||||
final bool shrinkWrap;
|
||||
|
||||
@override
|
||||
State<GridPage> createState() => _GridPageState();
|
||||
@@ -124,13 +126,22 @@ class GridPage extends StatefulWidget {
|
||||
class _GridPageState extends State<GridPage> {
|
||||
bool _didOpenInitialRow = false;
|
||||
|
||||
late final GridBloc gridBloc = GridBloc(
|
||||
view: widget.view,
|
||||
databaseController: widget.databaseController,
|
||||
shrinkWrapped: widget.shrinkWrap,
|
||||
)..add(const GridEvent.initial());
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
gridBloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<GridBloc>(
|
||||
create: (context) => GridBloc(
|
||||
view: widget.view,
|
||||
databaseController: widget.databaseController,
|
||||
)..add(const GridEvent.initial()),
|
||||
create: (_) => gridBloc,
|
||||
child: BlocListener<ActionNavigationBloc, ActionNavigationState>(
|
||||
listener: (context, state) {
|
||||
final action = state.action;
|
||||
@@ -147,6 +158,7 @@ class _GridPageState extends State<GridPage> {
|
||||
child: BlocConsumer<GridBloc, GridState>(
|
||||
listener: listener,
|
||||
builder: (context, state) => state.loadingState.map(
|
||||
idle: (_) => const SizedBox.shrink(),
|
||||
loading: (_) => const Center(
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
),
|
||||
@@ -155,25 +167,18 @@ class _GridPageState extends State<GridPage> {
|
||||
child: GridPageContent(
|
||||
key: ValueKey(widget.view.id),
|
||||
view: widget.view,
|
||||
shrinkWrap: widget.shrinkWrap,
|
||||
),
|
||||
),
|
||||
(err) => Center(
|
||||
child: AppFlowyErrorPage(
|
||||
error: err,
|
||||
),
|
||||
),
|
||||
(err) => Center(child: AppFlowyErrorPage(error: err)),
|
||||
),
|
||||
idle: (_) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openRow(
|
||||
BuildContext context,
|
||||
String rowId,
|
||||
) {
|
||||
void _openRow(BuildContext context, String rowId) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final gridBloc = context.read<GridBloc>();
|
||||
final rowCache = gridBloc.rowCache;
|
||||
@@ -249,9 +254,11 @@ class GridPageContent extends StatefulWidget {
|
||||
const GridPageContent({
|
||||
super.key,
|
||||
required this.view,
|
||||
this.shrinkWrap = false,
|
||||
});
|
||||
|
||||
final ViewPB view;
|
||||
final bool shrinkWrap;
|
||||
|
||||
@override
|
||||
State<GridPageContent> createState() => _GridPageContentState();
|
||||
@@ -279,13 +286,13 @@ class _GridPageContentState extends State<GridPageContent> {
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_GridHeader(
|
||||
headerScrollController: headerScrollController,
|
||||
),
|
||||
_GridHeader(headerScrollController: headerScrollController),
|
||||
_GridRows(
|
||||
viewId: widget.view.id,
|
||||
scrollController: _scrollController,
|
||||
shrinkWrap: widget.shrinkWrap,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -300,12 +307,10 @@ class _GridHeader extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<GridBloc, GridState>(
|
||||
builder: (context, state) {
|
||||
return GridHeaderSliverAdaptor(
|
||||
viewId: state.viewId,
|
||||
anchorScrollController: headerScrollController,
|
||||
);
|
||||
},
|
||||
builder: (_, state) => GridHeaderSliverAdaptor(
|
||||
viewId: state.viewId,
|
||||
anchorScrollController: headerScrollController,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -314,11 +319,17 @@ class _GridRows extends StatefulWidget {
|
||||
const _GridRows({
|
||||
required this.viewId,
|
||||
required this.scrollController,
|
||||
this.shrinkWrap = false,
|
||||
});
|
||||
|
||||
final String viewId;
|
||||
final GridScrollController scrollController;
|
||||
|
||||
/// When [shrinkWrap] is active, the Grid will show items according to
|
||||
/// GridState.visibleRows and will not have a vertical scroll area.
|
||||
///
|
||||
final bool shrinkWrap;
|
||||
|
||||
@override
|
||||
State<_GridRows> createState() => _GridRowsState();
|
||||
}
|
||||
@@ -330,8 +341,10 @@ class _GridRowsState extends State<_GridRows> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_evaluateFloatingCalculations();
|
||||
widget.scrollController.verticalController.addListener(_onScrollChanged);
|
||||
if (!widget.shrinkWrap) {
|
||||
_evaluateFloatingCalculations();
|
||||
widget.scrollController.verticalController.addListener(_onScrollChanged);
|
||||
}
|
||||
}
|
||||
|
||||
void _onScrollChanged() {
|
||||
@@ -345,13 +358,16 @@ class _GridRowsState extends State<_GridRows> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.scrollController.verticalController.removeListener(_onScrollChanged);
|
||||
if (!widget.shrinkWrap) {
|
||||
widget.scrollController.verticalController
|
||||
.removeListener(_onScrollChanged);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _evaluateFloatingCalculations() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
if (mounted && !widget.shrinkWrap) {
|
||||
setState(() {
|
||||
final verticalController = widget.scrollController.verticalController;
|
||||
// maxScrollExtent is 0.0 if scrolling is not possible
|
||||
@@ -367,121 +383,64 @@ class _GridRowsState extends State<_GridRows> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<GridBloc, GridState>(
|
||||
buildWhen: (previous, current) => previous.fields != current.fields,
|
||||
builder: (context, state) {
|
||||
return Flexible(
|
||||
child: LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints layoutConstraits) {
|
||||
return _WrapScrollView(
|
||||
scrollController: widget.scrollController,
|
||||
contentWidth: GridLayout.headerWidth(
|
||||
context
|
||||
.read<DatabasePluginWidgetBuilderSize>()
|
||||
.horizontalPadding,
|
||||
state.fields,
|
||||
),
|
||||
child: BlocConsumer<GridBloc, GridState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.rowCount != current.rowCount,
|
||||
listener: (context, state) => _evaluateFloatingCalculations(),
|
||||
builder: (context, state) {
|
||||
return ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(
|
||||
scrollbars: false,
|
||||
),
|
||||
child: _renderList(context, state, layoutConstraits),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _renderList(
|
||||
BuildContext context,
|
||||
GridState state,
|
||||
BoxConstraints layoutConstraints,
|
||||
) {
|
||||
// 1. GridRowBottomBar
|
||||
// 2. GridCalculationsRow
|
||||
final itemCount =
|
||||
state.rowInfos.length + (showFloatingCalculations ? 1 : 2);
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ReorderableListView.builder(
|
||||
/// This is a workaround related to
|
||||
/// https://github.com/flutter/flutter/issues/25652
|
||||
cacheExtent: max(layoutConstraints.maxHeight, 500),
|
||||
scrollController: widget.scrollController.verticalController,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
buildDefaultDragHandles: false,
|
||||
proxyDecorator: (child, _, __) => Provider.value(
|
||||
value: context.read<DatabasePluginWidgetBuilderSize>(),
|
||||
child: Material(
|
||||
color: Colors.white.withOpacity(.1),
|
||||
child: Opacity(opacity: .5, child: child),
|
||||
Widget child;
|
||||
if (widget.shrinkWrap) {
|
||||
child = Scrollbar(
|
||||
controller: widget.scrollController.horizontalController,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
controller: widget.scrollController.horizontalController,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: GridLayout.headerWidth(
|
||||
context
|
||||
.read<DatabasePluginWidgetBuilderSize>()
|
||||
.horizontalPadding,
|
||||
context.read<GridBloc>().state.fields,
|
||||
),
|
||||
),
|
||||
onReorder: (fromIndex, newIndex) {
|
||||
void moveRow() {
|
||||
final toIndex = newIndex > fromIndex ? newIndex - 1 : newIndex;
|
||||
if (fromIndex != toIndex) {
|
||||
context
|
||||
.read<GridBloc>()
|
||||
.add(GridEvent.moveRow(fromIndex, toIndex));
|
||||
}
|
||||
}
|
||||
|
||||
if (state.sorts.isNotEmpty) {
|
||||
showCancelAndDeleteDialog(
|
||||
context: context,
|
||||
title: LocaleKeys.grid_sort_sortsActive.tr(
|
||||
namedArgs: {
|
||||
'intention':
|
||||
LocaleKeys.grid_row_reorderRowDescription.tr(),
|
||||
},
|
||||
),
|
||||
description: LocaleKeys.grid_sort_removeSorting.tr(),
|
||||
confirmLabel: LocaleKeys.button_remove.tr(),
|
||||
closeOnAction: true,
|
||||
onDelete: () {
|
||||
SortBackendService(viewId: widget.viewId).deleteAllSorts();
|
||||
moveRow();
|
||||
},
|
||||
);
|
||||
} else {
|
||||
moveRow();
|
||||
}
|
||||
},
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == state.rowInfos.length) {
|
||||
return const GridRowBottomBar(key: Key('grid_footer'));
|
||||
}
|
||||
|
||||
if (index == state.rowInfos.length + 1 &&
|
||||
!showFloatingCalculations) {
|
||||
return GridCalculationsRow(
|
||||
key: const Key('grid_calculations'),
|
||||
viewId: widget.viewId,
|
||||
);
|
||||
}
|
||||
|
||||
return _renderRow(
|
||||
context,
|
||||
state.rowInfos[index].rowId,
|
||||
index: index,
|
||||
);
|
||||
},
|
||||
child: _renderList(context),
|
||||
),
|
||||
),
|
||||
if (showFloatingCalculations) ...[
|
||||
);
|
||||
} else {
|
||||
child = _WrapScrollView(
|
||||
scrollController: widget.scrollController,
|
||||
contentWidth: GridLayout.headerWidth(
|
||||
context.read<DatabasePluginWidgetBuilderSize>().horizontalPadding,
|
||||
context.read<GridBloc>().state.fields,
|
||||
),
|
||||
child: BlocListener<GridBloc, GridState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.rowCount != current.rowCount,
|
||||
listener: (context, state) => _evaluateFloatingCalculations(),
|
||||
child: ScrollConfiguration(
|
||||
behavior:
|
||||
ScrollConfiguration.of(context).copyWith(scrollbars: false),
|
||||
child: _renderList(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.shrinkWrap) {
|
||||
return child;
|
||||
}
|
||||
|
||||
return Flexible(child: child);
|
||||
}
|
||||
|
||||
Widget _renderList(BuildContext context) {
|
||||
final state = context.read<GridBloc>().state;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
widget.shrinkWrap
|
||||
? _reorderableListView(state)
|
||||
: Expanded(child: _reorderableListView(state)),
|
||||
if (showFloatingCalculations && !widget.shrinkWrap) ...[
|
||||
_PositionedCalculationsRow(
|
||||
viewId: widget.viewId,
|
||||
isAtBottom: isAtBottom,
|
||||
@@ -491,6 +450,77 @@ class _GridRowsState extends State<_GridRows> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _reorderableListView(GridState state) {
|
||||
final List<Widget> footer = [
|
||||
const GridRowBottomBar(),
|
||||
if (widget.shrinkWrap && state.visibleRows < state.rowInfos.length)
|
||||
const GridRowLoadMoreButton(),
|
||||
if (!showFloatingCalculations) GridCalculationsRow(viewId: widget.viewId),
|
||||
];
|
||||
|
||||
// If we are using shrinkWrap, we need to show at most
|
||||
// state.visibleRows + 1 items. The visibleRows can be larger
|
||||
// than the actual rowInfos length.
|
||||
final itemCount = widget.shrinkWrap
|
||||
? (state.visibleRows + 1).clamp(0, state.rowInfos.length + 1)
|
||||
: state.rowInfos.length + 1;
|
||||
|
||||
return ReorderableListView.builder(
|
||||
cacheExtent: 500,
|
||||
scrollController: widget.scrollController.verticalController,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
buildDefaultDragHandles: false,
|
||||
shrinkWrap: widget.shrinkWrap,
|
||||
proxyDecorator: (child, _, __) => Provider.value(
|
||||
value: context.read<DatabasePluginWidgetBuilderSize>(),
|
||||
child: Material(
|
||||
color: Colors.white.withOpacity(.1),
|
||||
child: Opacity(opacity: .5, child: child),
|
||||
),
|
||||
),
|
||||
onReorder: (fromIndex, newIndex) {
|
||||
final toIndex = newIndex > fromIndex ? newIndex - 1 : newIndex;
|
||||
|
||||
if (state.sorts.isNotEmpty) {
|
||||
showCancelAndDeleteDialog(
|
||||
context: context,
|
||||
title: LocaleKeys.grid_sort_sortsActive.tr(
|
||||
namedArgs: {
|
||||
'intention': LocaleKeys.grid_row_reorderRowDescription.tr(),
|
||||
},
|
||||
),
|
||||
description: LocaleKeys.grid_sort_removeSorting.tr(),
|
||||
confirmLabel: LocaleKeys.button_remove.tr(),
|
||||
closeOnAction: true,
|
||||
onDelete: () {
|
||||
SortBackendService(viewId: widget.viewId).deleteAllSorts();
|
||||
moveRow(fromIndex, toIndex);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
moveRow(fromIndex, toIndex);
|
||||
}
|
||||
},
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == itemCount - 1) {
|
||||
return Column(
|
||||
key: const Key('grid_footer'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: footer,
|
||||
);
|
||||
}
|
||||
|
||||
return _renderRow(
|
||||
context,
|
||||
state.rowInfos[index].rowId,
|
||||
index: index,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _renderRow(
|
||||
BuildContext context,
|
||||
RowId rowId, {
|
||||
@@ -509,6 +539,7 @@ class _GridRowsState extends State<_GridRows> {
|
||||
|
||||
final child = GridRow(
|
||||
key: ValueKey("grid_row_$rowId"),
|
||||
shrinkWrap: widget.shrinkWrap,
|
||||
fieldController: databaseController.fieldController,
|
||||
rowId: rowId,
|
||||
viewId: viewId,
|
||||
@@ -523,20 +554,22 @@ class _GridRowsState extends State<_GridRows> {
|
||||
context: rowDetailContext,
|
||||
builder: (_) {
|
||||
final rowMeta = rowCache.getRow(rowId)?.rowMeta;
|
||||
return rowMeta == null
|
||||
? const SizedBox.shrink()
|
||||
: BlocProvider.value(
|
||||
value: context.read<ViewBloc>(),
|
||||
child: RowDetailPage(
|
||||
rowController: RowController(
|
||||
viewId: viewId,
|
||||
rowMeta: rowMeta,
|
||||
rowCache: rowCache,
|
||||
),
|
||||
databaseController: databaseController,
|
||||
userProfile: context.read<GridBloc>().userProfile,
|
||||
),
|
||||
);
|
||||
if (rowMeta == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return BlocProvider.value(
|
||||
value: context.read<ViewBloc>(),
|
||||
child: RowDetailPage(
|
||||
rowController: RowController(
|
||||
viewId: viewId,
|
||||
rowMeta: rowMeta,
|
||||
rowCache: rowCache,
|
||||
),
|
||||
databaseController: databaseController,
|
||||
userProfile: context.read<GridBloc>().userProfile,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -547,6 +580,12 @@ class _GridRowsState extends State<_GridRows> {
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
void moveRow(int from, int to) {
|
||||
if (from != to) {
|
||||
context.read<GridBloc>().add(GridEvent.moveRow(from, to));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _WrapScrollView extends StatelessWidget {
|
||||
|
||||
+54
-31
@@ -1,3 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/plugins/database/application/calculations/calculation_type_ext.dart';
|
||||
import 'package:appflowy/plugins/database/application/field/field_info.dart';
|
||||
import 'package:appflowy/plugins/database/application/field/type_option/number_format_bloc.dart';
|
||||
@@ -12,7 +14,6 @@ import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/number_entities.pb.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';
|
||||
|
||||
class CalculateCell extends StatefulWidget {
|
||||
@@ -85,35 +86,11 @@ class _CalculateCellState extends State<CalculateCell> {
|
||||
}
|
||||
});
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
if (widget.calculation != null)
|
||||
RemoveCalculationButton(
|
||||
onTap: () => context.read<CalculationsBloc>().add(
|
||||
CalculationsEvent.removeCalculation(
|
||||
widget.fieldInfo.id,
|
||||
widget.calculation!.id,
|
||||
),
|
||||
),
|
||||
),
|
||||
...widget.fieldInfo.fieldType.calculationsForFieldType().map(
|
||||
(type) => CalculationTypeItem(
|
||||
type: type,
|
||||
onTap: () {
|
||||
if (type != widget.calculation?.calculationType) {
|
||||
context.read<CalculationsBloc>().add(
|
||||
CalculationsEvent.updateCalculationType(
|
||||
widget.fieldInfo.id,
|
||||
type,
|
||||
calculationId: widget.calculation?.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
return BlocProvider.value(
|
||||
value: context.read<CalculationsBloc>(),
|
||||
child: CalculateSelector(
|
||||
fieldInfo: widget.fieldInfo,
|
||||
calculation: widget.calculation,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -125,7 +102,7 @@ class _CalculateCellState extends State<CalculateCell> {
|
||||
}
|
||||
|
||||
Widget _showCalculateValue(BuildContext context, String? prefix) {
|
||||
prefix = prefix != null ? '$prefix ' : '';
|
||||
prefix = prefix != null && prefix.isNotEmpty ? '$prefix ' : '';
|
||||
final calculateValue =
|
||||
'$prefix${_withoutTrailingZeros(widget.calculation!.value)}';
|
||||
|
||||
@@ -208,3 +185,49 @@ class _CalculateCellState extends State<CalculateCell> {
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
class CalculateSelector extends StatelessWidget {
|
||||
const CalculateSelector({
|
||||
super.key,
|
||||
required this.fieldInfo,
|
||||
this.calculation,
|
||||
});
|
||||
|
||||
final FieldInfo fieldInfo;
|
||||
final CalculationPB? calculation;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
if (calculation != null)
|
||||
RemoveCalculationButton(
|
||||
onTap: () => context.read<CalculationsBloc>().add(
|
||||
CalculationsEvent.removeCalculation(
|
||||
fieldInfo.id,
|
||||
calculation!.id,
|
||||
),
|
||||
),
|
||||
),
|
||||
...fieldInfo.fieldType.calculationsForFieldType().map(
|
||||
(type) => CalculationTypeItem(
|
||||
type: type,
|
||||
onTap: () {
|
||||
if (type != calculation?.calculationType) {
|
||||
context.read<CalculationsBloc>().add(
|
||||
CalculationsEvent.updateCalculationType(
|
||||
fieldInfo.id,
|
||||
type,
|
||||
calculationId: calculation?.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+41
@@ -57,3 +57,44 @@ class GridRowBottomBar extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GridRowLoadMoreButton extends StatelessWidget {
|
||||
const GridRowLoadMoreButton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final padding =
|
||||
context.read<DatabasePluginWidgetBuilderSize>().horizontalPadding;
|
||||
final color = Theme.of(context).brightness == Brightness.light
|
||||
? const Color(0xFF171717).withOpacity(0.4)
|
||||
: const Color(0xFFFFFFFF).withOpacity(0.4);
|
||||
|
||||
return Container(
|
||||
padding: GridSize.footerContentInsets.copyWith(left: 0) +
|
||||
EdgeInsets.only(left: padding),
|
||||
height: GridSize.footerHeight,
|
||||
child: FlowyButton(
|
||||
radius: BorderRadius.zero,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: AFThemeExtension.of(context).borderColor),
|
||||
),
|
||||
),
|
||||
text: FlowyText(
|
||||
lineHeight: 1.0,
|
||||
LocaleKeys.grid_row_loadMore.tr(),
|
||||
color: color,
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
onTap: () => context.read<GridBloc>().add(
|
||||
const GridEvent.loadMoreRows(),
|
||||
),
|
||||
leftIcon: FlowySvg(
|
||||
FlowySvgs.load_more_s,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-6
@@ -7,7 +7,6 @@ import 'package:appflowy/plugins/database/grid/application/grid_header_bloc.dart
|
||||
import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/size.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -154,11 +153,8 @@ class _CellTrailing extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: GridSize.newPropertyButtonWidth,
|
||||
minHeight: GridSize.headerHeight,
|
||||
),
|
||||
margin: EdgeInsets.only(right: GridSize.scrollBarSize + Insets.m),
|
||||
width: GridSize.newPropertyButtonWidth,
|
||||
height: GridSize.headerHeight,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: AFThemeExtension.of(context).borderColor),
|
||||
|
||||
+14
-11
@@ -34,6 +34,7 @@ class GridRow extends StatelessWidget {
|
||||
required this.cellBuilder,
|
||||
required this.openDetailPage,
|
||||
required this.index,
|
||||
this.shrinkWrap = false,
|
||||
});
|
||||
|
||||
final FieldController fieldController;
|
||||
@@ -43,9 +44,20 @@ class GridRow extends StatelessWidget {
|
||||
final EditableCellBuilder cellBuilder;
|
||||
final void Function(BuildContext context) openDetailPage;
|
||||
final int index;
|
||||
final bool shrinkWrap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget rowContent = RowContent(
|
||||
fieldController: fieldController,
|
||||
cellBuilder: cellBuilder,
|
||||
onExpand: () => openDetailPage(context),
|
||||
);
|
||||
|
||||
if (!shrinkWrap) {
|
||||
rowContent = Expanded(child: rowContent);
|
||||
}
|
||||
|
||||
return BlocProvider(
|
||||
create: (_) => RowBloc(
|
||||
fieldController: fieldController,
|
||||
@@ -56,17 +68,8 @@ class GridRow extends StatelessWidget {
|
||||
child: _RowEnterRegion(
|
||||
child: Row(
|
||||
children: [
|
||||
_RowLeading(
|
||||
viewId: viewId,
|
||||
index: index,
|
||||
),
|
||||
Expanded(
|
||||
child: RowContent(
|
||||
fieldController: fieldController,
|
||||
cellBuilder: cellBuilder,
|
||||
onExpand: () => openDetailPage(context),
|
||||
),
|
||||
),
|
||||
_RowLeading(viewId: viewId, index: index),
|
||||
rowContent,
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -219,9 +219,7 @@ class TabBarItemButton extends StatelessWidget {
|
||||
color: color,
|
||||
),
|
||||
text: FlowyText(
|
||||
view.name.isEmpty
|
||||
? LocaleKeys.document_title_placeholder.tr()
|
||||
: view.name,
|
||||
view.nameOrDefault,
|
||||
lineHeight: 1.0,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -236,7 +234,7 @@ class TabBarItemButton extends StatelessWidget {
|
||||
case TabBarViewAction.rename:
|
||||
NavigatorTextFieldDialog(
|
||||
title: LocaleKeys.menuAppHeader_renameDialog.tr(),
|
||||
value: view.name,
|
||||
value: view.nameOrDefault,
|
||||
onConfirm: (newValue, _) {
|
||||
context.read<DatabaseTabBarBloc>().add(
|
||||
DatabaseTabBarEvent.renameView(view.id, newValue),
|
||||
|
||||
+1
-5
@@ -1,6 +1,4 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
@@ -107,9 +105,7 @@ class _DatabaseViewSelectorButton extends StatelessWidget {
|
||||
const HSpace(6),
|
||||
Flexible(
|
||||
child: FlowyText.medium(
|
||||
tabBar.view.name.isEmpty
|
||||
? LocaleKeys.document_title_placeholder.tr()
|
||||
: tabBar.view.name,
|
||||
tabBar.view.nameOrDefault,
|
||||
fontSize: 14,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
@@ -55,7 +55,7 @@ abstract class DatabaseTabBarItemBuilder {
|
||||
void dispose() {}
|
||||
}
|
||||
|
||||
class DatabaseTabBarView extends StatefulWidget {
|
||||
class DatabaseTabBarView extends StatelessWidget {
|
||||
const DatabaseTabBarView({
|
||||
super.key,
|
||||
required this.view,
|
||||
@@ -70,108 +70,84 @@ class DatabaseTabBarView extends StatefulWidget {
|
||||
///
|
||||
final String? initialRowId;
|
||||
|
||||
@override
|
||||
State<DatabaseTabBarView> createState() => _DatabaseTabBarViewState();
|
||||
}
|
||||
|
||||
class _DatabaseTabBarViewState extends State<DatabaseTabBarView> {
|
||||
final PageController _pageController = PageController();
|
||||
late String? _initialRowId = widget.initialRowId;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<DatabaseTabBarBloc>(
|
||||
create: (_) => DatabaseTabBarBloc(view: widget.view)
|
||||
create: (_) => DatabaseTabBarBloc(view: view)
|
||||
..add(const DatabaseTabBarEvent.initial()),
|
||||
),
|
||||
BlocProvider<ViewBloc>(
|
||||
create: (_) =>
|
||||
ViewBloc(view: widget.view)..add(const ViewEvent.initial()),
|
||||
create: (_) => ViewBloc(view: view)..add(const ViewEvent.initial()),
|
||||
),
|
||||
],
|
||||
child: MultiBlocListener(
|
||||
listeners: [
|
||||
BlocListener<DatabaseTabBarBloc, DatabaseTabBarState>(
|
||||
listenWhen: (p, c) => p.selectedIndex != c.selectedIndex,
|
||||
listener: (_, state) {
|
||||
_initialRowId = null;
|
||||
_pageController.jumpToPage(state.selectedIndex);
|
||||
},
|
||||
),
|
||||
],
|
||||
child: Column(
|
||||
children: [
|
||||
if (UniversalPlatform.isMobile) const VSpace(12),
|
||||
BlocBuilder<DatabaseTabBarBloc, DatabaseTabBarState>(
|
||||
builder: (context, state) {
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: state
|
||||
.tabBarControllerByViewId[state.parentView.id]!
|
||||
.controller
|
||||
.isLoading,
|
||||
builder: (_, value, ___) {
|
||||
if (value) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
child: BlocBuilder<DatabaseTabBarBloc, DatabaseTabBarState>(
|
||||
builder: (_, state) {
|
||||
final layout = state.tabBars[state.selectedIndex].layout;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (UniversalPlatform.isMobile) const VSpace(12),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: state
|
||||
.tabBarControllerByViewId[state.parentView.id]!
|
||||
.controller
|
||||
.isLoading,
|
||||
builder: (_, value, ___) {
|
||||
if (value) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return UniversalPlatform.isDesktop
|
||||
? const TabBarHeader()
|
||||
: const MobileTabBarHeader();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
BlocBuilder<DatabaseTabBarBloc, DatabaseTabBarState>(
|
||||
builder: (context, state) =>
|
||||
pageSettingBarExtensionFromState(state),
|
||||
),
|
||||
BlocBuilder<DatabaseTabBarBloc, DatabaseTabBarState>(
|
||||
builder: (context, state) {
|
||||
final content = PageView(
|
||||
controller: _pageController,
|
||||
pageSnapping: false,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: pageContentFromState(state),
|
||||
);
|
||||
|
||||
if (widget.shrinkWrap) {
|
||||
final layout = state.tabBars[state.selectedIndex].layout;
|
||||
return SizedBox(height: layout.pluginHeight, child: content);
|
||||
}
|
||||
|
||||
return Expanded(child: content);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
return UniversalPlatform.isDesktop
|
||||
? const TabBarHeader()
|
||||
: const MobileTabBarHeader();
|
||||
},
|
||||
),
|
||||
pageSettingBarExtensionFromState(context, state),
|
||||
wrapContent(
|
||||
layout: layout,
|
||||
child: pageContentFromState(context, state),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> pageContentFromState(DatabaseTabBarState state) {
|
||||
return state.tabBars.map((tabBar) {
|
||||
final controller =
|
||||
state.tabBarControllerByViewId[tabBar.viewId]!.controller;
|
||||
Widget wrapContent({required ViewLayoutPB layout, required Widget child}) {
|
||||
if (shrinkWrap) {
|
||||
if (layout.shrinkWrappable) {
|
||||
return child;
|
||||
}
|
||||
|
||||
return tabBar.builder.content(
|
||||
context,
|
||||
tabBar.view,
|
||||
controller,
|
||||
widget.shrinkWrap,
|
||||
_initialRowId,
|
||||
return SizedBox(
|
||||
height: layout.pluginHeight,
|
||||
child: child,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
return Expanded(child: child);
|
||||
}
|
||||
|
||||
Widget pageSettingBarExtensionFromState(DatabaseTabBarState state) {
|
||||
Widget pageContentFromState(BuildContext context, DatabaseTabBarState state) {
|
||||
final tab = state.tabBars[state.selectedIndex];
|
||||
final controller = state.tabBarControllerByViewId[tab.viewId]!.controller;
|
||||
|
||||
return tab.builder.content(
|
||||
context,
|
||||
tab.view,
|
||||
controller,
|
||||
shrinkWrap,
|
||||
initialRowId,
|
||||
);
|
||||
}
|
||||
|
||||
Widget pageSettingBarExtensionFromState(
|
||||
BuildContext context,
|
||||
DatabaseTabBarState state,
|
||||
) {
|
||||
if (state.tabBars.length < state.selectedIndex) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
+1
-2
@@ -28,8 +28,7 @@ class RowCardContainer extends StatelessWidget {
|
||||
create: (_) => _CardContainerNotifier(),
|
||||
child: Consumer<_CardContainerNotifier>(
|
||||
builder: (context, notifier, _) {
|
||||
final shouldBuildAccessory =
|
||||
buildAccessoryWhen?.call() ?? true;
|
||||
final shouldBuildAccessory = buildAccessoryWhen?.call() ?? true;
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
|
||||
+6
-7
@@ -1,24 +1,23 @@
|
||||
import 'package:appflowy/core/helpers/url_launcher.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_media_upload.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_media_upload.dart';
|
||||
import 'package:appflowy/plugins/database/application/cell/bloc/media_cell_bloc.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/media.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/cell_editor/media_cell_editor.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/cell_editor/mobile_media_cell_editor.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/media_file_type_ext.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
|
||||
import 'package:appflowy/shared/af_image.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.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:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
@@ -159,7 +158,7 @@ class GridMediaCellSkin extends IEditableMediaCellSkin {
|
||||
List<MediaFilePB> files,
|
||||
) {
|
||||
if (file.fileType != MediaFileTypePB.Image) {
|
||||
afLaunchUrlString(file.url);
|
||||
afLaunchUrlString(file.url, context: context);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 511 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user