Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87643e71a6 | ||
|
|
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 |
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:
|
||||
@@ -73,8 +73,8 @@ jobs:
|
||||
working-directory: frontend
|
||||
run: |
|
||||
vcpkg integrate install
|
||||
cargo install --force cargo-make
|
||||
cargo install --force duckscript_cli
|
||||
cargo install --force --locked cargo-make
|
||||
cargo install --force --locked duckscript_cli
|
||||
|
||||
- name: Build Windows app
|
||||
working-directory: frontend
|
||||
@@ -158,8 +158,8 @@ jobs:
|
||||
- name: Install prerequisites
|
||||
working-directory: frontend
|
||||
run: |
|
||||
cargo install --force cargo-make
|
||||
cargo install --force duckscript_cli
|
||||
cargo install --force --locked cargo-make
|
||||
cargo install --force --locked duckscript_cli
|
||||
|
||||
- name: Build AppFlowy
|
||||
working-directory: frontend
|
||||
@@ -256,8 +256,8 @@ jobs:
|
||||
- name: Install prerequisites
|
||||
working-directory: frontend
|
||||
run: |
|
||||
cargo install --force cargo-make
|
||||
cargo install --force duckscript_cli
|
||||
cargo install --force --locked cargo-make
|
||||
cargo install --force --locked duckscript_cli
|
||||
|
||||
- name: Build AppFlowy
|
||||
working-directory: frontend
|
||||
@@ -370,8 +370,8 @@ jobs:
|
||||
sudo apt-get install keybinder-3.0
|
||||
sudo apt-get install -y alien libnotify-dev
|
||||
source $HOME/.cargo/env
|
||||
cargo install --force cargo-make
|
||||
cargo install --force duckscript_cli
|
||||
cargo install --force --locked cargo-make
|
||||
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:
|
||||
@@ -40,8 +40,8 @@ jobs:
|
||||
- name: Install prerequisites
|
||||
working-directory: frontend
|
||||
run: |
|
||||
cargo install --force cargo-make
|
||||
cargo install --force duckscript_cli
|
||||
cargo install --force --locked cargo-make
|
||||
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)
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
- name: install cargo-make
|
||||
working-directory: frontend
|
||||
run: |
|
||||
cargo install --force cargo-make
|
||||
cargo install --force --locked cargo-make
|
||||
cargo make appflowy-tauri-deps-tools
|
||||
|
||||
- name: install frontend dependencies
|
||||
@@ -108,4 +108,4 @@ jobs:
|
||||
with:
|
||||
tauriScript: pnpm tauri
|
||||
projectPath: frontend/appflowy_tauri
|
||||
args: "--debug"
|
||||
args: "--debug"
|
||||
@@ -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)
|
||||
@@ -97,7 +96,7 @@ jobs:
|
||||
- name: install cargo-make
|
||||
working-directory: frontend
|
||||
run: |
|
||||
cargo install --force cargo-make
|
||||
cargo install --force --locked cargo-make
|
||||
cargo make appflowy-tauri-deps-tools
|
||||
|
||||
- name: install frontend dependencies
|
||||
|
||||
+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();
|
||||
|
||||
+17
@@ -139,6 +139,23 @@ 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+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 {
|
||||
|
||||
+36
@@ -113,5 +113,41 @@ 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);
|
||||
|
||||
expect(find.textContaining(text, findRichText: true), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
+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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+256
-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,235 @@ 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);
|
||||
});
|
||||
}
|
||||
|
||||
extension on WidgetTester {
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
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();
|
||||
final language =
|
||||
editorState.getNodeAtPath([0])!.attributes['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);
|
||||
|
||||
expect(
|
||||
editorState
|
||||
.getNodeAtPath([0])!
|
||||
.attributes['language']
|
||||
.toString()
|
||||
.toLowerCase(),
|
||||
defaultCodeBlockSupportedLanguages.last.toLowerCase(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> onKey(WidgetTester tester, LogicalKeyboardKey key) async {
|
||||
await tester.simulateKeyEvent(key);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -449,11 +449,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+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,
|
||||
),
|
||||
|
||||
+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: [],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,16 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
AnswerStream? answerStream;
|
||||
int numSendMessage = 0;
|
||||
|
||||
/// a counter used to determine whether the initial loading state should be
|
||||
/// set to finish. It should hit two before we emit: one for the local fetch
|
||||
/// and another for the server fetch.
|
||||
///
|
||||
/// This is to work around a bug where if an ai chat that is not yet on the
|
||||
/// user local storage is opened but has messages in the server, it will
|
||||
/// remain stuck on the welcome screen until the user switches to another page
|
||||
/// then come back.
|
||||
int initialFetchCounter = 0;
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await answerStream?.dispose();
|
||||
@@ -76,7 +86,11 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
await chatController.insert(message, index: 0);
|
||||
}
|
||||
|
||||
if (state.loadingState.isLoading) {
|
||||
if (initialFetchCounter < 2) {
|
||||
initialFetchCounter++;
|
||||
}
|
||||
|
||||
if (state.loadingState.isLoading && initialFetchCounter >= 2) {
|
||||
emit(
|
||||
state.copyWith(loadingState: const ChatLoadingState.finish()),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -76,19 +77,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 +101,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 {
|
||||
|
||||
-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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,21 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/application/mobile_router.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/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 +24,14 @@ 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/chat_member_bloc.dart';
|
||||
import 'application/chat_side_panel_bloc.dart';
|
||||
import 'presentation/animated_chat_list.dart';
|
||||
import 'presentation/chat_input/desktop_ai_prompt_input.dart';
|
||||
import 'presentation/chat_input/mobile_ai_prompt_input.dart';
|
||||
import 'presentation/chat_side_panel.dart';
|
||||
import 'presentation/chat_welcome_page.dart';
|
||||
import 'presentation/layout_define.dart';
|
||||
import 'presentation/message/ai_text_message.dart';
|
||||
@@ -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,50 @@ 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 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,
|
||||
),
|
||||
),
|
||||
).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),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -273,11 +221,8 @@ class _ChatContentPage extends StatelessWidget {
|
||||
chatId: view.id,
|
||||
refSourceJsonString: refSourceJsonString,
|
||||
isLastMessage: isLastMessage,
|
||||
onSelectedMetadata: (metadata) {
|
||||
context
|
||||
.read<ChatSidePanelBloc>()
|
||||
.add(ChatSidePanelEvent.selectedMetadata(metadata));
|
||||
},
|
||||
onSelectedMetadata: (metadata) =>
|
||||
_onSelectMetadata(context, metadata),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -345,7 +290,6 @@ class _ChatContentPage extends StatelessWidget {
|
||||
return UniversalPlatform.isDesktop
|
||||
? DesktopAIPromptInput(
|
||||
chatId: view.id,
|
||||
indicateFocus: true,
|
||||
onSubmitted: (text, metadata) {
|
||||
context.read<ChatBloc>().add(
|
||||
ChatEvent.sendMessage(
|
||||
@@ -355,9 +299,9 @@ class _ChatContentPage extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
isStreaming: !canSendMessage,
|
||||
onStopStreaming: () {
|
||||
context.read<ChatBloc>().add(const ChatEvent.stopStream());
|
||||
},
|
||||
onStopStreaming: () => context
|
||||
.read<ChatBloc>()
|
||||
.add(const ChatEvent.stopStream()),
|
||||
)
|
||||
: MobileAIPromptInput(
|
||||
chatId: view.id,
|
||||
@@ -370,12 +314,49 @@ class _ChatContentPage extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
isStreaming: !canSendMessage,
|
||||
onStopStreaming: () {
|
||||
context.read<ChatBloc>().add(const ChatEvent.stopStream());
|
||||
},
|
||||
onStopStreaming: () => context
|
||||
.read<ChatBloc>()
|
||||
.add(const ChatEvent.stopStream()),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onSelectMetadata(
|
||||
BuildContext context,
|
||||
ChatMessageRefSource metadata,
|
||||
) async {
|
||||
if (isURL(metadata.name)) {
|
||||
late Uri uri;
|
||||
try {
|
||||
uri = Uri.parse(metadata.name);
|
||||
// `Uri` identifies `localhost` as a scheme
|
||||
if (!uri.hasScheme || uri.scheme == 'localhost') {
|
||||
uri = Uri.parse("http://${metadata.name}");
|
||||
await InternetAddress.lookup(uri.host);
|
||||
}
|
||||
await launchUrl(uri);
|
||||
} catch (err) {
|
||||
Log.error("failed to open url $err");
|
||||
}
|
||||
} else {
|
||||
final sidebarView =
|
||||
await ViewBackendService.getView(metadata.id).toNullable();
|
||||
if (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;
|
||||
}
|
||||
}
|
||||
+428
@@ -0,0 +1,428 @@
|
||||
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/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: FlowyText.emoji(
|
||||
view.icon.value,
|
||||
fontSize: 14.0,
|
||||
figmaLineHeight: 21.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
+433
-241
@@ -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';
|
||||
@@ -14,22 +12,22 @@ 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 'chat_mention_page_menu.dart';
|
||||
import '../layout_define.dart';
|
||||
import 'ai_prompt_buttons.dart';
|
||||
import 'chat_input_file.dart';
|
||||
import 'chat_input_span.dart';
|
||||
|
||||
class DesktopAIPromptInput extends StatefulWidget {
|
||||
const DesktopAIPromptInput({
|
||||
super.key,
|
||||
required this.chatId,
|
||||
required this.indicateFocus,
|
||||
required this.isStreaming,
|
||||
required this.onStopStreaming,
|
||||
required this.onSubmitted,
|
||||
});
|
||||
|
||||
final String chatId;
|
||||
final bool indicateFocus;
|
||||
final bool isStreaming;
|
||||
final void Function() onStopStreaming;
|
||||
final void Function(String, Map<String, dynamic>) onSubmitted;
|
||||
@@ -39,12 +37,12 @@ class DesktopAIPromptInput extends StatefulWidget {
|
||||
}
|
||||
|
||||
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 +50,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 +76,467 @@ 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,
|
||||
),
|
||||
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: const BoxConstraints(
|
||||
minHeight: DesktopAIPromptSizes.textFieldMinHeight +
|
||||
DesktopAIPromptSizes.actionBarHeight,
|
||||
maxHeight: 300,
|
||||
),
|
||||
child: inputTextField(),
|
||||
),
|
||||
Positioned.fill(
|
||||
top: null,
|
||||
child: TextFieldTapRegion(
|
||||
child: _PromptBottomActions(
|
||||
textController: textController,
|
||||
overlayController: overlayController,
|
||||
focusNode: focusNode,
|
||||
sendButtonState: sendButtonState,
|
||||
onSendPressed: handleSendPressed,
|
||||
onStopStreaming: widget.onStopStreaming,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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() {
|
||||
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
|
||||
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.textController,
|
||||
required this.overlayController,
|
||||
required this.focusNode,
|
||||
required this.sendButtonState,
|
||||
required this.onSendPressed,
|
||||
required this.onStopStreaming,
|
||||
});
|
||||
|
||||
final TextEditingController textController;
|
||||
final OverlayPortalController overlayController;
|
||||
final FocusNode focusNode;
|
||||
final SendButtonState sendButtonState;
|
||||
final void Function() onSendPressed;
|
||||
final void Function() onStopStreaming;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return 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(context),
|
||||
const HSpace(
|
||||
DesktopAIPromptSizes.actionBarButtonSpacing,
|
||||
),
|
||||
if (UniversalPlatform.isDesktop && 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() {
|
||||
Widget _mentionButton(BuildContext context) {
|
||||
return PromptInputMentionButton(
|
||||
iconSize: DesktopAIPromptSizes.actionBarIconSize,
|
||||
buttonSize: DesktopAIPromptSizes.actionBarButtonSize,
|
||||
onTap: () {
|
||||
_textController.text += '@';
|
||||
if (!_inputFocusNode.hasFocus) {
|
||||
_inputFocusNode.requestFocus();
|
||||
if (overlayController.isShowing) {
|
||||
return;
|
||||
}
|
||||
_handleOnTextChange(context, _textController.text);
|
||||
if (!focusNode.hasFocus) {
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
textController.text += '@';
|
||||
Future.delayed(Duration.zero, () {
|
||||
context
|
||||
.read<ChatInputControlCubit>()
|
||||
.startSearching(textController.value);
|
||||
overlayController.show();
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _attachmentButton() {
|
||||
Widget _attachmentButton(BuildContext context) {
|
||||
return PromptInputAttachmentButton(
|
||||
onTap: () async {
|
||||
final path = await getIt<FilePickerService>().pickFiles(
|
||||
@@ -308,12 +550,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));
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -325,56 +565,8 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
|
||||
buttonSize: DesktopAIPromptSizes.actionBarButtonSize,
|
||||
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;
|
||||
}
|
||||
}
|
||||
+166
-148
@@ -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,11 @@ 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';
|
||||
|
||||
class MobileAIPromptInput extends StatefulWidget {
|
||||
const MobileAIPromptInput({
|
||||
@@ -36,9 +33,9 @@ class MobileAIPromptInput extends StatefulWidget {
|
||||
}
|
||||
|
||||
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 +43,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 +61,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 +71,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,
|
||||
),
|
||||
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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -138,138 +144,150 @@ 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() {
|
||||
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() {
|
||||
Widget mentionButton() {
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: PromptInputMentionButton(
|
||||
iconSize: MobileAIPromptSizes.mentionIconSize,
|
||||
buttonSize: MobileAIPromptSizes.sendButtonSize,
|
||||
onTap: () {
|
||||
_textController.text += '@';
|
||||
if (!_inputFocusNode.hasFocus) {
|
||||
_inputFocusNode.requestFocus();
|
||||
textController.text += '@';
|
||||
if (!focusNode.hasFocus) {
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
_handleOnTextChange(context, _textController.text);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
mentionPage(context);
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sendButton() {
|
||||
Widget sendButton() {
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
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,
|
||||
);
|
||||
if (selectedView != null) {
|
||||
handler.onSelected(ViewActionPage(view: selectedView));
|
||||
}
|
||||
handler.onExit();
|
||||
_inputFocusNode.requestFocus();
|
||||
}
|
||||
}
|
||||
-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),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+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,
|
||||
|
||||
+1
-3
@@ -425,9 +425,7 @@ class ChatAIMessagePopup extends StatelessWidget {
|
||||
},
|
||||
);
|
||||
},
|
||||
child: IgnorePointer(
|
||||
child: child,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
|
||||
+11
-4
@@ -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
|
||||
@@ -57,20 +58,23 @@ 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,
|
||||
@@ -79,7 +83,10 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
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,
|
||||
|
||||
+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))
|
||||
@@ -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 {
|
||||
|
||||
+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();
|
||||
}
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -31,6 +31,7 @@ class DesktopRowDetailDateCellSkin extends IEditableDateCellSkin {
|
||||
direction: PopoverDirection.bottomWithLeftAligned,
|
||||
constraints: BoxConstraints.loose(const Size(260, 620)),
|
||||
margin: EdgeInsets.zero,
|
||||
asBarrier: true,
|
||||
child: Container(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
|
||||
+3
-2
@@ -27,7 +27,6 @@ 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:open_filex/open_filex.dart';
|
||||
import 'package:reorderables/reorderables.dart';
|
||||
|
||||
const _dropFileKey = 'files_media';
|
||||
@@ -253,6 +252,7 @@ class _AddFileButtonState extends State<_AddFileButton> {
|
||||
direction: widget.direction,
|
||||
constraints: const BoxConstraints(maxWidth: _menuWidth),
|
||||
margin: EdgeInsets.zero,
|
||||
asBarrier: true,
|
||||
onClose: () =>
|
||||
context.read<EditorDropManagerState>().remove(_dropFileKey),
|
||||
popupBuilder: (_) {
|
||||
@@ -459,6 +459,7 @@ class _FilePreviewRenderState extends State<_FilePreviewRender> {
|
||||
offset: const Offset(0, 5),
|
||||
triggerActions: PopoverTriggerFlags.none,
|
||||
onClose: () => setState(() => isSelected = false),
|
||||
asBarrier: true,
|
||||
popupBuilder: (popoverContext) => MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: context.read<RowDetailBloc>()),
|
||||
@@ -479,7 +480,7 @@ class _FilePreviewRenderState extends State<_FilePreviewRender> {
|
||||
? null
|
||||
: () {
|
||||
if (file.uploadType == FileUploadTypePB.LocalFile) {
|
||||
OpenFilex.open(file.url);
|
||||
afLaunchUrlString(file.url);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -24,6 +24,7 @@ class DesktopRowDetailRelationCellSkin extends IEditableRelationCellSkin {
|
||||
direction: PopoverDirection.bottomWithLeftAligned,
|
||||
constraints: const BoxConstraints(maxWidth: 400, maxHeight: 400),
|
||||
margin: EdgeInsets.zero,
|
||||
asBarrier: true,
|
||||
onClose: () => cellContainerNotifier.isFocus = false,
|
||||
popupBuilder: (context) {
|
||||
return BlocProvider.value(
|
||||
|
||||
+1
@@ -25,6 +25,7 @@ class DesktopRowDetailSelectOptionCellSkin
|
||||
controller: popoverController,
|
||||
constraints: const BoxConstraints.tightFor(width: 300),
|
||||
margin: EdgeInsets.zero,
|
||||
asBarrier: true,
|
||||
triggerActions: PopoverTriggerFlags.none,
|
||||
direction: PopoverDirection.bottomWithLeftAligned,
|
||||
onClose: () => cellContainerNotifier.isFocus = false,
|
||||
|
||||
@@ -199,22 +199,30 @@ class FieldActionCell extends StatelessWidget {
|
||||
(action == FieldAction.duplicate || action == FieldAction.delete)) {
|
||||
enable = false;
|
||||
}
|
||||
|
||||
return FlowyButton(
|
||||
return FlowyIconTextButton(
|
||||
resetHoverOnRebuild: false,
|
||||
disable: !enable,
|
||||
text: FlowyText(
|
||||
action.title(fieldInfo),
|
||||
lineHeight: 1.0,
|
||||
color: enable ? null : Theme.of(context).disabledColor,
|
||||
),
|
||||
onHover: (_) => popoverMutex?.close(),
|
||||
onTap: () => action.run(context, viewId, fieldInfo),
|
||||
leftIcon: action.leading(
|
||||
fieldInfo,
|
||||
enable ? null : Theme.of(context).disabledColor,
|
||||
// show the error color when delete is hovered
|
||||
textBuilder: (onHover) => FlowyText(
|
||||
action.title(fieldInfo),
|
||||
lineHeight: 1.0,
|
||||
color: enable
|
||||
? action == FieldAction.delete && onHover
|
||||
? Theme.of(context).colorScheme.error
|
||||
: null
|
||||
: Theme.of(context).disabledColor,
|
||||
),
|
||||
rightIcon: action.trailing(context, fieldInfo),
|
||||
leftIconBuilder: (onHover) => action.leading(
|
||||
fieldInfo,
|
||||
enable
|
||||
? action == FieldAction.delete && onHover
|
||||
? Theme.of(context).colorScheme.error
|
||||
: null
|
||||
: Theme.of(context).disabledColor,
|
||||
),
|
||||
rightIconBuilder: (_) => action.trailing(context, fieldInfo),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
|
||||
class DocumentValidator {
|
||||
const DocumentValidator({
|
||||
required this.editorState,
|
||||
required this.rules,
|
||||
});
|
||||
|
||||
final EditorState editorState;
|
||||
final List<DocumentRule> rules;
|
||||
|
||||
Future<bool> validate(Transaction transaction) async {
|
||||
// deep copy the document
|
||||
final root = this.editorState.document.root.copyWith();
|
||||
final dummyDocument = Document(root: root);
|
||||
if (dummyDocument.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final editorState = EditorState(document: dummyDocument);
|
||||
await editorState.apply(transaction);
|
||||
|
||||
final iterator = NodeIterator(
|
||||
document: editorState.document,
|
||||
startNode: editorState.document.root,
|
||||
);
|
||||
|
||||
for (final rule in rules) {
|
||||
while (iterator.moveNext()) {
|
||||
if (!rule.validate(iterator.current)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> applyTransactionInDummyDocument(Transaction transaction) async {
|
||||
// deep copy the document
|
||||
final root = this.editorState.document.root.copyWith();
|
||||
final dummyDocument = Document(root: root);
|
||||
if (dummyDocument.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final editorState = EditorState(document: dummyDocument);
|
||||
await editorState.apply(transaction);
|
||||
|
||||
final iterator = NodeIterator(
|
||||
document: editorState.document,
|
||||
startNode: editorState.document.root,
|
||||
);
|
||||
|
||||
for (final rule in rules) {
|
||||
while (iterator.moveNext()) {
|
||||
if (!rule.validate(iterator.current)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class DocumentRule {
|
||||
const DocumentRule({
|
||||
required this.type,
|
||||
});
|
||||
|
||||
final String type;
|
||||
|
||||
bool validate(Node node) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+60
-9
@@ -15,6 +15,24 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
/// The node types that support slash menu.
|
||||
final Set<String> supportSlashMenuNodeTypes = {
|
||||
ParagraphBlockKeys.type,
|
||||
HeadingBlockKeys.type,
|
||||
|
||||
// Lists
|
||||
TodoListBlockKeys.type,
|
||||
BulletedListBlockKeys.type,
|
||||
NumberedListBlockKeys.type,
|
||||
QuoteBlockKeys.type,
|
||||
ToggleListBlockKeys.type,
|
||||
|
||||
// Simple table
|
||||
// SimpleTableBlockKeys.type,
|
||||
// SimpleTableRowBlockKeys.type,
|
||||
// SimpleTableCellBlockKeys.type,
|
||||
};
|
||||
|
||||
/// Build the block component builders.
|
||||
///
|
||||
/// Every block type should have a corresponding builder in the map.
|
||||
@@ -107,6 +125,14 @@ List<OptionAction> _buildOptionActions(BuildContext context, String type) {
|
||||
standardActions.addAll([OptionAction.divider, OptionAction.depth]);
|
||||
}
|
||||
|
||||
if (SimpleTableBlockKeys.type == type) {
|
||||
standardActions.addAll([
|
||||
OptionAction.divider,
|
||||
OptionAction.setToPageWidth,
|
||||
OptionAction.distributeColumnsEvenly,
|
||||
]);
|
||||
}
|
||||
|
||||
return standardActions;
|
||||
}
|
||||
|
||||
@@ -125,9 +151,14 @@ void _customBlockOptionActions(
|
||||
final actions = _buildOptionActions(context, entry.key);
|
||||
|
||||
if (UniversalPlatform.isDesktop) {
|
||||
builder.showActions = (node) =>
|
||||
node.parent?.type != TableCellBlockKeys.type &&
|
||||
node.parent?.type != SimpleTableCellBlockKeys.type;
|
||||
builder.showActions = (node) {
|
||||
final parentTableNode = node.parentTableNode;
|
||||
if (node.type != SimpleTableBlockKeys.type && parentTableNode != null) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
builder.configuration = builder.configuration.copyWith(
|
||||
blockSelectionAreaMargin: (_) => const EdgeInsets.symmetric(
|
||||
vertical: 1,
|
||||
@@ -141,7 +172,7 @@ void _customBlockOptionActions(
|
||||
if ((type == HeadingBlockKeys.type ||
|
||||
type == ToggleListBlockKeys.type) &&
|
||||
level > 0) {
|
||||
final offset = [14.0, 11.0, 8.0, 6.0, 4.0, 2.0];
|
||||
final offset = [13.0, 11.0, 8.0, 6.0, 4.0, 2.0];
|
||||
top += offset[level - 1];
|
||||
} else if (type == SimpleTableBlockKeys.type) {
|
||||
top += 8.0;
|
||||
@@ -162,6 +193,7 @@ void _customBlockOptionActions(
|
||||
shouldInsertSlash: false,
|
||||
deleteKeywordsByDefault: true,
|
||||
style: styleCustomizer.selectionMenuStyleBuilder(),
|
||||
supportSlashMenuNodeTypes: supportSlashMenuNodeTypes,
|
||||
).handler.call(editorState)
|
||||
: () {},
|
||||
),
|
||||
@@ -316,7 +348,17 @@ SimpleTableBlockComponentBuilder _buildSimpleTableBlockComponentBuilder(
|
||||
BuildContext context,
|
||||
BlockComponentConfiguration configuration,
|
||||
) {
|
||||
return SimpleTableBlockComponentBuilder(configuration: configuration);
|
||||
final copiedConfiguration = configuration.copyWith(
|
||||
padding: (node) {
|
||||
final padding = configuration.padding(node);
|
||||
if (UniversalPlatform.isDesktop) {
|
||||
return padding;
|
||||
} else {
|
||||
return padding.copyWith(right: padding.left);
|
||||
}
|
||||
},
|
||||
);
|
||||
return SimpleTableBlockComponentBuilder(configuration: copiedConfiguration);
|
||||
}
|
||||
|
||||
SimpleTableRowBlockComponentBuilder _buildSimpleTableRowBlockComponentBuilder(
|
||||
@@ -419,10 +461,19 @@ NumberedListBlockComponentBuilder _buildNumberedListBlockComponentBuilder(
|
||||
return configuration.textStyle(node);
|
||||
},
|
||||
),
|
||||
iconBuilder: (_, node, textDirection) => NumberedListIcon(
|
||||
node: node,
|
||||
textDirection: textDirection,
|
||||
),
|
||||
iconBuilder: (_, node, textDirection) {
|
||||
TextStyle? textStyle;
|
||||
if (node.isInHeaderColumn || node.isInHeaderRow) {
|
||||
textStyle = configuration.textStyle(node).copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
);
|
||||
}
|
||||
return NumberedListIcon(
|
||||
node: node,
|
||||
textDirection: textDirection,
|
||||
textStyle: textStyle,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -92,9 +92,9 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
|
||||
inlineMathEquationItem,
|
||||
linkItem,
|
||||
alignToolbarItem,
|
||||
buildTextColorItem(),
|
||||
buildHighlightColorItem(),
|
||||
customizeFontToolbarItem,
|
||||
buildTextColorItem()..isActive = showInAnyTextType,
|
||||
buildHighlightColorItem()..isActive = showInAnyTextType,
|
||||
customizeFontToolbarItem..isActive = showInAnyTextType,
|
||||
];
|
||||
|
||||
late List<SelectionMenuItem> slashMenuItems;
|
||||
@@ -122,6 +122,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
|
||||
slashMenuItems,
|
||||
shouldInsertSlash: false,
|
||||
style: styleCustomizer.selectionMenuStyleBuilder(),
|
||||
supportSlashMenuNodeTypes: supportSlashMenuNodeTypes,
|
||||
).handler(editorState);
|
||||
|
||||
AFFocusManager? focusManager;
|
||||
@@ -143,6 +144,11 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
|
||||
_initEditorL10n();
|
||||
_initializeShortcuts();
|
||||
|
||||
AppFlowyRichTextKeys.partialSliced.addAll([
|
||||
MentionBlockKeys.mention,
|
||||
InlineMathEquationKeys.formula,
|
||||
]);
|
||||
|
||||
indentableBlockTypes.add(ToggleListBlockKeys.type);
|
||||
convertibleBlockTypes.add(ToggleListBlockKeys.type);
|
||||
slashMenuItems = _customSlashMenuItems();
|
||||
@@ -156,14 +162,13 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
|
||||
scrollController: effectiveScrollController,
|
||||
);
|
||||
|
||||
// keep the previous font style when typing new text.
|
||||
supportSlashMenuNodeWhiteList.addAll([
|
||||
ToggleListBlockKeys.type,
|
||||
]);
|
||||
toolbarItemWhiteList.addAll([
|
||||
ToggleListBlockKeys.type,
|
||||
CalloutBlockKeys.type,
|
||||
TableBlockKeys.type,
|
||||
SimpleTableBlockKeys.type,
|
||||
SimpleTableCellBlockKeys.type,
|
||||
SimpleTableRowBlockKeys.type,
|
||||
]);
|
||||
AppFlowyRichTextKeys.supportSliced.add(AppFlowyRichTextKeys.fontFamily);
|
||||
|
||||
|
||||
+73
-11
@@ -28,12 +28,11 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
|
||||
final transaction = editorState.transaction;
|
||||
switch (action) {
|
||||
case OptionAction.delete:
|
||||
transaction.deleteNode(node);
|
||||
_deleteBlocks(transaction, node);
|
||||
break;
|
||||
case OptionAction.duplicate:
|
||||
await _duplicateBlock(transaction, node);
|
||||
EditorNotification.paste().post();
|
||||
|
||||
break;
|
||||
case OptionAction.moveUp:
|
||||
transaction.moveNode(node.path.previous, node);
|
||||
@@ -44,6 +43,12 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
|
||||
case OptionAction.copyLinkToBlock:
|
||||
await _copyLinkToBlock(node);
|
||||
break;
|
||||
case OptionAction.setToPageWidth:
|
||||
await _setToPageWidth(node);
|
||||
break;
|
||||
case OptionAction.distributeColumnsEvenly:
|
||||
await _distributeColumnsEvenly(node);
|
||||
break;
|
||||
case OptionAction.align:
|
||||
case OptionAction.color:
|
||||
case OptionAction.divider:
|
||||
@@ -55,9 +60,40 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
|
||||
/// If the selection is a block selection, delete the selected blocks.
|
||||
/// Otherwise, delete the selected block.
|
||||
void _deleteBlocks(Transaction transaction, Node selectedNode) {
|
||||
final selection = editorState.selection;
|
||||
final selectionType = editorState.selectionType;
|
||||
if (selectionType == SelectionType.block && selection != null) {
|
||||
final nodes = editorState.getNodesInSelection(selection.normalized);
|
||||
transaction.deleteNodes(nodes);
|
||||
} else {
|
||||
transaction.deleteNode(selectedNode);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _duplicateBlock(Transaction transaction, Node node) async {
|
||||
final selection = editorState.selection;
|
||||
final selectionType = editorState.selectionType;
|
||||
if (selectionType == SelectionType.block && selection != null) {
|
||||
final nodes = editorState.getNodesInSelection(selection.normalized);
|
||||
for (final node in nodes) {
|
||||
_validateNode(node);
|
||||
}
|
||||
transaction.insertNodes(
|
||||
selection.normalized.end.path.next,
|
||||
nodes.map((e) => _copyBlock(e)).toList(),
|
||||
);
|
||||
} else {
|
||||
_validateNode(node);
|
||||
transaction.insertNode(node.path.next, _copyBlock(node));
|
||||
}
|
||||
}
|
||||
|
||||
void _validateNode(Node node) {
|
||||
final type = node.type;
|
||||
final builder = editorState.renderer.blockComponentBuilder(type);
|
||||
final builder = blockComponentBuilder[type];
|
||||
|
||||
if (builder == null) {
|
||||
Log.error('Block type $type is not supported');
|
||||
@@ -68,15 +104,13 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
|
||||
if (!valid) {
|
||||
Log.error('Block type $type is not valid');
|
||||
}
|
||||
|
||||
transaction.insertNode(node.path.next, _copyBlock(node));
|
||||
}
|
||||
|
||||
Node _copyBlock(Node node) {
|
||||
Node copiedNode = node.copyWith();
|
||||
|
||||
final type = node.type;
|
||||
final builder = editorState.renderer.blockComponentBuilder(type);
|
||||
final builder = blockComponentBuilder[type];
|
||||
|
||||
if (builder == null) {
|
||||
Log.error('Block type $type is not supported');
|
||||
@@ -182,6 +216,14 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
|
||||
}
|
||||
|
||||
Future<void> _copyLinkToBlock(Node node) async {
|
||||
List<Node> nodes = [node];
|
||||
|
||||
final selection = editorState.selection;
|
||||
final selectionType = editorState.selectionType;
|
||||
if (selectionType == SelectionType.block && selection != null) {
|
||||
nodes = editorState.getNodesInSelection(selection.normalized);
|
||||
}
|
||||
|
||||
final context = editorState.document.root.context;
|
||||
final viewId = context?.read<DocumentBloc>().documentId;
|
||||
if (viewId == null) {
|
||||
@@ -200,13 +242,17 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
|
||||
return;
|
||||
}
|
||||
|
||||
final link = ShareConstants.buildShareUrl(
|
||||
workspaceId: workspaceId,
|
||||
viewId: viewId,
|
||||
blockId: node.id,
|
||||
final blockIds = nodes.map((e) => e.id);
|
||||
final links = blockIds.map(
|
||||
(e) => ShareConstants.buildShareUrl(
|
||||
workspaceId: workspaceId,
|
||||
viewId: viewId,
|
||||
blockId: e,
|
||||
),
|
||||
);
|
||||
|
||||
await getIt<ClipboardService>().setData(
|
||||
ClipboardServiceData(plainText: link),
|
||||
ClipboardServiceData(plainText: links.join('\n')),
|
||||
);
|
||||
|
||||
emit(BlockActionOptionState()); // Emit a new state to trigger UI update
|
||||
@@ -617,4 +663,20 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
|
||||
// then updating the selection with the beforeSelection that may contains multiple blocks
|
||||
return beforeSelection;
|
||||
}
|
||||
|
||||
Future<void> _setToPageWidth(Node node) async {
|
||||
if (node.type != SimpleTableBlockKeys.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
await editorState.setColumnWidthToPageWidth(tableNode: node);
|
||||
}
|
||||
|
||||
Future<void> _distributeColumnsEvenly(Node node) async {
|
||||
if (node.type != SimpleTableBlockKeys.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
await editorState.distributeColumnWidthToPageWidth(tableNode: node);
|
||||
}
|
||||
}
|
||||
+2
@@ -25,6 +25,7 @@ class DraggableOptionButton extends StatefulWidget {
|
||||
final EditorState editorState;
|
||||
final BlockComponentContext blockComponentContext;
|
||||
final Map<String, BlockComponentBuilder> blockComponentBuilder;
|
||||
|
||||
@override
|
||||
State<DraggableOptionButton> createState() => _DraggableOptionButtonState();
|
||||
}
|
||||
@@ -85,6 +86,7 @@ class _DraggableOptionButtonState extends State<DraggableOptionButton> {
|
||||
details.globalPosition,
|
||||
builder: (context, data) {
|
||||
return VisualDragArea(
|
||||
editorState: widget.editorState,
|
||||
data: data,
|
||||
dragNode: widget.blockComponentContext.node,
|
||||
);
|
||||
|
||||
+26
-5
@@ -1,3 +1,4 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -37,13 +38,24 @@ Future<void> dragToMoveNode(
|
||||
// Determine the new path based on drop position
|
||||
// For VerticalPosition.top, we keep the target node's path
|
||||
if (verticalPosition == VerticalPosition.bottom) {
|
||||
newPath = horizontalPosition == HorizontalPosition.left
|
||||
? newPath.next // Insert after target node
|
||||
: newPath.child(0); // Insert as first child of target node
|
||||
if (horizontalPosition == HorizontalPosition.left) {
|
||||
newPath = newPath.next;
|
||||
final node = editorState.document.nodeAtPath(newPath);
|
||||
if (node == null) {
|
||||
// if node is null, it means the node is the last one of the document.
|
||||
newPath = targetNode.path;
|
||||
}
|
||||
} else {
|
||||
newPath = newPath.child(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the drop should be ignored
|
||||
if (shouldIgnoreDragTarget(node, newPath)) {
|
||||
if (shouldIgnoreDragTarget(
|
||||
editorState: editorState,
|
||||
dragNode: node,
|
||||
targetPath: newPath,
|
||||
)) {
|
||||
Log.info(
|
||||
'Drop ignored: node($node, ${node.path}), path($acceptedPath)',
|
||||
);
|
||||
@@ -110,7 +122,11 @@ Future<void> dragToMoveNode(
|
||||
return (verticalPosition, horizontalPosition, globalBlockRect);
|
||||
}
|
||||
|
||||
bool shouldIgnoreDragTarget(Node dragNode, Path? targetPath) {
|
||||
bool shouldIgnoreDragTarget({
|
||||
required EditorState editorState,
|
||||
required Node dragNode,
|
||||
required Path? targetPath,
|
||||
}) {
|
||||
if (targetPath == null) {
|
||||
return true;
|
||||
}
|
||||
@@ -123,5 +139,10 @@ bool shouldIgnoreDragTarget(Node dragNode, Path? targetPath) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final targetNode = editorState.getNodeAtPath(targetPath);
|
||||
if (targetNode != null && targetNode.isInTable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
+7
-2
@@ -10,16 +10,21 @@ class VisualDragArea extends StatelessWidget {
|
||||
super.key,
|
||||
required this.data,
|
||||
required this.dragNode,
|
||||
required this.editorState,
|
||||
});
|
||||
|
||||
final DragAreaBuilderData data;
|
||||
final Node dragNode;
|
||||
|
||||
final EditorState editorState;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final targetNode = data.targetNode;
|
||||
|
||||
final ignore = shouldIgnoreDragTarget(dragNode, targetNode.path);
|
||||
final ignore = shouldIgnoreDragTarget(
|
||||
editorState: editorState,
|
||||
dragNode: dragNode,
|
||||
targetPath: targetNode.path,
|
||||
);
|
||||
if (ignore) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
+19
-4
@@ -147,10 +147,25 @@ class _ColorOptionButtonState extends State<ColorOptionButton> {
|
||||
color: AFThemeExtension.of(context).onBackground,
|
||||
),
|
||||
onTap: (option, index) async {
|
||||
final transaction = widget.editorState.transaction;
|
||||
transaction.updateNode(node, {
|
||||
blockComponentBackgroundColor: option.id,
|
||||
});
|
||||
final editorState = widget.editorState;
|
||||
final transaction = editorState.transaction;
|
||||
final selectionType = editorState.selectionType;
|
||||
final selection = editorState.selection;
|
||||
|
||||
// In multiple selection, we need to update all the nodes in the selection
|
||||
if (selectionType == SelectionType.block && selection != null) {
|
||||
final nodes = editorState.getNodesInSelection(selection.normalized);
|
||||
for (final node in nodes) {
|
||||
transaction.updateNode(node, {
|
||||
blockComponentBackgroundColor: option.id,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
transaction.updateNode(node, {
|
||||
blockComponentBackgroundColor: option.id,
|
||||
});
|
||||
}
|
||||
|
||||
await widget.editorState.apply(transaction);
|
||||
|
||||
innerController.close();
|
||||
|
||||
+19
-1
@@ -67,7 +67,13 @@ enum OptionAction {
|
||||
color,
|
||||
divider,
|
||||
align,
|
||||
depth;
|
||||
|
||||
// Outline block
|
||||
depth,
|
||||
|
||||
// Simple table
|
||||
setToPageWidth,
|
||||
distributeColumnsEvenly;
|
||||
|
||||
FlowySvgData get svg {
|
||||
switch (this) {
|
||||
@@ -91,6 +97,10 @@ enum OptionAction {
|
||||
return FlowySvgs.tag_s;
|
||||
case OptionAction.copyLinkToBlock:
|
||||
return FlowySvgs.share_tab_copy_s;
|
||||
case OptionAction.setToPageWidth:
|
||||
return FlowySvgs.table_set_to_page_width_s;
|
||||
case OptionAction.distributeColumnsEvenly:
|
||||
return FlowySvgs.table_distribute_columns_evenly_s;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +126,14 @@ enum OptionAction {
|
||||
return LocaleKeys.document_plugins_optionAction_copyLinkToBlock.tr();
|
||||
case OptionAction.divider:
|
||||
throw UnsupportedError('Divider does not have description');
|
||||
case OptionAction.setToPageWidth:
|
||||
return LocaleKeys
|
||||
.document_plugins_simpleTable_moreActions_setToPageWidth
|
||||
.tr();
|
||||
case OptionAction.distributeColumnsEvenly:
|
||||
return LocaleKeys
|
||||
.document_plugins_simpleTable_moreActions_distributeColumnsWidth
|
||||
.tr();
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -75,15 +75,16 @@ class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _build(BuildContext context, ViewPB view) {
|
||||
Widget _build(BuildContext context, ViewPB viewPB) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => widget.editorState.service.scrollService?.disable(),
|
||||
onExit: (_) => widget.editorState.service.scrollService?.enable(),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildMenu(context, view),
|
||||
_buildPage(context, view),
|
||||
_buildMenu(context, viewPB),
|
||||
Flexible(child: _buildPage(context, viewPB)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
+30
@@ -117,6 +117,22 @@ Future<bool> inlinePageReferenceCommandHandler(
|
||||
initialResults: initialResults,
|
||||
style: style,
|
||||
startCharAmount: previousChar != null ? 2 : 1,
|
||||
cancelBySpaceHandler: () {
|
||||
if (character == _plusChar) {
|
||||
final currentSelection = editorState.selection;
|
||||
if (currentSelection == null) {
|
||||
return false;
|
||||
}
|
||||
// check if the space is after the character
|
||||
if (currentSelection.isCollapsed &&
|
||||
currentSelection.start.offset ==
|
||||
selection.start.offset + character.length) {
|
||||
_cancelInlinePageReferenceMenu(editorState);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
selectionMenuService?.show();
|
||||
@@ -124,3 +140,17 @@ Future<bool> inlinePageReferenceCommandHandler(
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void _cancelInlinePageReferenceMenu(EditorState editorState) {
|
||||
selectionMenuService?.dismiss();
|
||||
selectionMenuService = null;
|
||||
|
||||
// re-focus the selection
|
||||
final selection = editorState.selection;
|
||||
if (selection != null) {
|
||||
editorState.updateSelectionWithReason(
|
||||
selection,
|
||||
reason: SelectionUpdateReason.uiEvent,
|
||||
);
|
||||
}
|
||||
}
|
||||
+9
-3
@@ -1,7 +1,9 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
class SelectableItemListMenu extends StatelessWidget {
|
||||
const SelectableItemListMenu({
|
||||
@@ -10,11 +12,13 @@ class SelectableItemListMenu extends StatelessWidget {
|
||||
required this.selectedIndex,
|
||||
required this.onSelected,
|
||||
this.shrinkWrap = false,
|
||||
this.controller,
|
||||
});
|
||||
|
||||
final List<String> items;
|
||||
final int selectedIndex;
|
||||
final void Function(int) onSelected;
|
||||
final ItemScrollController? controller;
|
||||
|
||||
/// shrinkWrapping is useful in cases where you have a list of
|
||||
/// limited amount of items. It will make the list take the minimum
|
||||
@@ -24,9 +28,11 @@ class SelectableItemListMenu extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.builder(
|
||||
return ScrollablePositionedList.builder(
|
||||
shrinkWrap: shrinkWrap,
|
||||
itemCount: items.length,
|
||||
itemScrollController: controller,
|
||||
initialScrollIndex: max(0, selectedIndex),
|
||||
itemBuilder: (context, index) => SelectableItem(
|
||||
isSelected: index == selectedIndex,
|
||||
item: items[index],
|
||||
@@ -57,7 +63,7 @@ class SelectableItem extends StatelessWidget {
|
||||
item,
|
||||
lineHeight: 1.0,
|
||||
),
|
||||
rightIcon: isSelected ? const FlowySvg(FlowySvgs.check_s) : null,
|
||||
isSelected: isSelected,
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
|
||||
+1
-12
@@ -1,7 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
|
||||
enum TextRobotInputType {
|
||||
@@ -109,18 +108,12 @@ class TextRobot {
|
||||
Position(path: next),
|
||||
);
|
||||
await editorState.apply(transaction);
|
||||
debugPrint(
|
||||
'AI insertNewParagraph: path: ${editorState.selection!.end.path}, index: ${editorState.selection!.endIndex}',
|
||||
);
|
||||
await Future.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
|
||||
Future<void> insertText(String text, Duration delay) async {
|
||||
final selection = editorState.selection;
|
||||
debugPrint(
|
||||
'AI insertText: get selection, path: ${selection!.end.path}, index: ${selection.endIndex}',
|
||||
);
|
||||
if (!selection.isCollapsed) {
|
||||
if (selection == null || !selection.isCollapsed) {
|
||||
return;
|
||||
}
|
||||
final node = editorState.getNodeAtPath(selection.end.path);
|
||||
@@ -131,10 +124,6 @@ class TextRobot {
|
||||
transaction.insertText(node, selection.endIndex, text);
|
||||
await editorState.apply(transaction);
|
||||
await Future.delayed(delay);
|
||||
|
||||
debugPrint(
|
||||
'AI insertText: path: ${selection.end.path}, index: ${selection.endIndex}, text: "$text"',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+89
-33
@@ -8,7 +8,9 @@ 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/services.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
CodeBlockLanguagePickerBuilder codeBlockLanguagePickerBuilder = (
|
||||
@@ -19,7 +21,7 @@ CodeBlockLanguagePickerBuilder codeBlockLanguagePickerBuilder = (
|
||||
onMenuClose,
|
||||
onMenuOpen,
|
||||
}) =>
|
||||
_CodeBlockLanguageSelector(
|
||||
CodeBlockLanguageSelector(
|
||||
editorState: editorState,
|
||||
language: selectedLanguage,
|
||||
supportedLanguages: supportedLanguages,
|
||||
@@ -28,8 +30,9 @@ CodeBlockLanguagePickerBuilder codeBlockLanguagePickerBuilder = (
|
||||
onMenuOpen: onMenuOpen,
|
||||
);
|
||||
|
||||
class _CodeBlockLanguageSelector extends StatefulWidget {
|
||||
const _CodeBlockLanguageSelector({
|
||||
class CodeBlockLanguageSelector extends StatefulWidget {
|
||||
const CodeBlockLanguageSelector({
|
||||
super.key,
|
||||
required this.editorState,
|
||||
required this.supportedLanguages,
|
||||
this.language,
|
||||
@@ -46,12 +49,11 @@ class _CodeBlockLanguageSelector extends StatefulWidget {
|
||||
final VoidCallback? onMenuClose;
|
||||
|
||||
@override
|
||||
State<_CodeBlockLanguageSelector> createState() =>
|
||||
State<CodeBlockLanguageSelector> createState() =>
|
||||
_CodeBlockLanguageSelectorState();
|
||||
}
|
||||
|
||||
class _CodeBlockLanguageSelectorState
|
||||
extends State<_CodeBlockLanguageSelector> {
|
||||
class _CodeBlockLanguageSelectorState extends State<CodeBlockLanguageSelector> {
|
||||
final controller = PopoverController();
|
||||
|
||||
@override
|
||||
@@ -136,7 +138,8 @@ class _LanguageSelectionPopoverState extends State<_LanguageSelectionPopover> {
|
||||
late List<String> filteredLanguages =
|
||||
widget.supportedLanguages.map((e) => e.capitalize()).toList();
|
||||
late int selectedIndex =
|
||||
widget.supportedLanguages.indexOf(widget.language ?? '');
|
||||
widget.supportedLanguages.indexOf(widget.language?.toLowerCase() ?? '');
|
||||
final ItemScrollController languageListController = ItemScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -160,34 +163,87 @@ class _LanguageSelectionPopoverState extends State<_LanguageSelectionPopover> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FlowyTextField(
|
||||
focusNode: focusNode,
|
||||
autoFocus: false,
|
||||
controller: searchController,
|
||||
hintText: LocaleKeys.document_codeBlock_searchLanguageHint.tr(),
|
||||
onChanged: (_) => setState(() {
|
||||
filteredLanguages = widget.supportedLanguages
|
||||
.where((e) => e.contains(searchController.text.toLowerCase()))
|
||||
.map((e) => e.capitalize())
|
||||
.toList();
|
||||
selectedIndex =
|
||||
widget.supportedLanguages.indexOf(widget.language ?? '');
|
||||
}),
|
||||
),
|
||||
const VSpace(8),
|
||||
Flexible(
|
||||
child: SelectableItemListMenu(
|
||||
shrinkWrap: true,
|
||||
items: filteredLanguages,
|
||||
selectedIndex: selectedIndex,
|
||||
onSelected: (index) =>
|
||||
widget.onLanguageSelected(filteredLanguages[index]),
|
||||
return Shortcuts(
|
||||
shortcuts: const {
|
||||
SingleActivator(LogicalKeyboardKey.arrowUp):
|
||||
_DirectionIntent(AxisDirection.up),
|
||||
SingleActivator(LogicalKeyboardKey.arrowDown):
|
||||
_DirectionIntent(AxisDirection.down),
|
||||
SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(),
|
||||
},
|
||||
child: Actions(
|
||||
actions: {
|
||||
_DirectionIntent: CallbackAction<_DirectionIntent>(
|
||||
onInvoke: (intent) => onArrowKey(intent.direction),
|
||||
),
|
||||
ActivateIntent: CallbackAction<ActivateIntent>(
|
||||
onInvoke: (intent) {
|
||||
if (selectedIndex < 0) return;
|
||||
selectLanguage(selectedIndex);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FlowyTextField(
|
||||
focusNode: focusNode,
|
||||
autoFocus: false,
|
||||
controller: searchController,
|
||||
hintText: LocaleKeys.document_codeBlock_searchLanguageHint.tr(),
|
||||
onChanged: (_) => setState(() {
|
||||
filteredLanguages = widget.supportedLanguages
|
||||
.where(
|
||||
(e) => e.contains(searchController.text.toLowerCase()),
|
||||
)
|
||||
.map((e) => e.capitalize())
|
||||
.toList();
|
||||
selectedIndex =
|
||||
widget.supportedLanguages.indexOf(widget.language ?? '');
|
||||
}),
|
||||
),
|
||||
const VSpace(8),
|
||||
Flexible(
|
||||
child: SelectableItemListMenu(
|
||||
controller: languageListController,
|
||||
shrinkWrap: true,
|
||||
items: filteredLanguages,
|
||||
selectedIndex: selectedIndex,
|
||||
onSelected: selectLanguage,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void onArrowKey(AxisDirection direction) {
|
||||
if (filteredLanguages.isEmpty) return;
|
||||
final isUp = direction == AxisDirection.up;
|
||||
final length = filteredLanguages.length;
|
||||
setState(() {
|
||||
if (isUp) {
|
||||
selectedIndex = selectedIndex == 0 ? length - 1 : selectedIndex - 1;
|
||||
} else {
|
||||
selectedIndex = selectedIndex == length - 1 ? 0 : selectedIndex + 1;
|
||||
}
|
||||
});
|
||||
languageListController.scrollTo(
|
||||
index: selectedIndex,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
}
|
||||
|
||||
void selectLanguage(int index) {
|
||||
widget.onLanguageSelected(filteredLanguages[index]);
|
||||
}
|
||||
}
|
||||
|
||||
/// [ScrollIntent] is not working, so using this custom Intent
|
||||
class _DirectionIntent extends Intent {
|
||||
const _DirectionIntent(this.direction);
|
||||
|
||||
final AxisDirection direction;
|
||||
}
|
||||
+9
-3
@@ -101,8 +101,10 @@ extension PasteFromImage on EditorState {
|
||||
await File(copyToPath).writeAsBytes(imageBytes);
|
||||
final String? path;
|
||||
|
||||
CustomImageType type;
|
||||
if (isLocalMode) {
|
||||
path = await saveImageToLocalStorage(copyToPath);
|
||||
type = CustomImageType.local;
|
||||
} else {
|
||||
final result = await saveImageToCloudStorage(copyToPath, documentId);
|
||||
|
||||
@@ -117,10 +119,11 @@ extension PasteFromImage on EditorState {
|
||||
}
|
||||
|
||||
path = result.$1;
|
||||
type = CustomImageType.internal;
|
||||
}
|
||||
|
||||
if (path != null) {
|
||||
await insertImageNode(path, selection: selection);
|
||||
await insertImageNode(path, selection: selection, type: type);
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -140,6 +143,7 @@ extension PasteFromImage on EditorState {
|
||||
Future<void> insertImageNode(
|
||||
String src, {
|
||||
Selection? selection,
|
||||
required CustomImageType type,
|
||||
}) async {
|
||||
selection ??= this.selection;
|
||||
if (selection == null || !selection.isCollapsed) {
|
||||
@@ -156,16 +160,18 @@ extension PasteFromImage on EditorState {
|
||||
transaction
|
||||
..insertNode(
|
||||
node.path,
|
||||
imageNode(
|
||||
customImageNode(
|
||||
url: src,
|
||||
type: type,
|
||||
),
|
||||
)
|
||||
..deleteNode(node);
|
||||
} else {
|
||||
transaction.insertNode(
|
||||
node.path.next,
|
||||
imageNode(
|
||||
customImageNode(
|
||||
url: src,
|
||||
type: type,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+1
-6
@@ -71,12 +71,7 @@ class _DatabaseBlockComponentWidgetState
|
||||
Widget child = BuiltInPageWidget(
|
||||
node: widget.node,
|
||||
editorState: editorState,
|
||||
builder: (viewPB) {
|
||||
return DatabaseViewWidget(
|
||||
key: ValueKey(viewPB.id),
|
||||
view: viewPB,
|
||||
);
|
||||
},
|
||||
builder: (view) => DatabaseViewWidget(key: ValueKey(view.id), view: view),
|
||||
);
|
||||
|
||||
child = Padding(
|
||||
|
||||
+1
-18
@@ -10,7 +10,6 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_p
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/toast.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:cross_file/cross_file.dart';
|
||||
import 'package:desktop_drop/desktop_drop.dart';
|
||||
@@ -19,7 +18,6 @@ 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:go_router/go_router.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:string_validator/string_validator.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
@@ -323,22 +321,7 @@ class FileBlockComponentState extends State<FileBlockComponent>
|
||||
FileUrlType urlType,
|
||||
String url,
|
||||
) async {
|
||||
if ([FileUrlType.cloud, FileUrlType.network].contains(urlType)) {
|
||||
await afLaunchUrlString(url);
|
||||
} else {
|
||||
final result = await OpenFilex.open(url);
|
||||
if (result.type == ResultType.done) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.document_plugins_file_failedToOpenMsg.tr(),
|
||||
type: ToastificationType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
await afLaunchUrlString(url, context: context);
|
||||
}
|
||||
|
||||
void _openMenu() {
|
||||
|
||||
+2
-3
@@ -1,8 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/core/helpers/url_launcher.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_service.dart';
|
||||
@@ -22,6 +20,7 @@ import 'package:cross_file/cross_file.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/file_picker/file_picker_impl.dart';
|
||||
import 'package:flowy_infra/uuid.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
@@ -102,7 +101,7 @@ Future<void> downloadMediaFile(
|
||||
FileUploadTypePB.LocalFile,
|
||||
].contains(file.uploadType)) {
|
||||
/// When the file is a network file or a local file, we can directly open the file.
|
||||
await afLaunchUrl(Uri.parse(file.url));
|
||||
await afLaunchUrlString(file.url);
|
||||
} else {
|
||||
if (userProfile == null) {
|
||||
return showToastNotification(
|
||||
|
||||
+40
-27
@@ -4,6 +4,7 @@ import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class FindAndReplaceMenuWidget extends StatefulWidget {
|
||||
const FindAndReplaceMenuWidget({
|
||||
@@ -55,35 +56,47 @@ class _FindAndReplaceMenuWidgetState extends State<FindAndReplaceMenuWidget> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFieldTapRegion(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: FindMenu(
|
||||
onDismiss: widget.onDismiss,
|
||||
editorState: widget.editorState,
|
||||
searchService: searchService,
|
||||
focusNode: findFocusNode,
|
||||
showReplaceMenu: showReplaceMenu,
|
||||
onToggleShowReplace: () => setState(() {
|
||||
showReplaceMenu = !showReplaceMenu;
|
||||
}),
|
||||
),
|
||||
return Shortcuts(
|
||||
shortcuts: const {
|
||||
SingleActivator(LogicalKeyboardKey.escape): DismissIntent(),
|
||||
},
|
||||
child: Actions(
|
||||
actions: {
|
||||
DismissIntent: CallbackAction<DismissIntent>(
|
||||
onInvoke: (t) => widget.onDismiss.call(),
|
||||
),
|
||||
if (showReplaceMenu)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 8.0,
|
||||
},
|
||||
child: TextFieldTapRegion(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: FindMenu(
|
||||
onDismiss: widget.onDismiss,
|
||||
editorState: widget.editorState,
|
||||
searchService: searchService,
|
||||
focusNode: findFocusNode,
|
||||
showReplaceMenu: showReplaceMenu,
|
||||
onToggleShowReplace: () => setState(() {
|
||||
showReplaceMenu = !showReplaceMenu;
|
||||
}),
|
||||
),
|
||||
),
|
||||
child: ReplaceMenu(
|
||||
editorState: widget.editorState,
|
||||
searchService: searchService,
|
||||
focusNode: replaceFocusNode,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (showReplaceMenu)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 8.0,
|
||||
),
|
||||
child: ReplaceMenu(
|
||||
editorState: widget.editorState,
|
||||
searchService: searchService,
|
||||
focusNode: replaceFocusNode,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+29
-21
@@ -36,7 +36,6 @@ class _InlineMathEquationState extends State<InlineMathEquation> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return _IgnoreParentPointer(
|
||||
child: AppFlowyPopover(
|
||||
controller: popoverController,
|
||||
@@ -60,33 +59,42 @@ class _InlineMathEquationState extends State<InlineMathEquation> {
|
||||
},
|
||||
offset: const Offset(0, 10),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
padding: const EdgeInsets.symmetric(vertical: 2.0),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const HSpace(2),
|
||||
Math.tex(
|
||||
widget.formula,
|
||||
options: MathOptions(
|
||||
style: MathStyle.text,
|
||||
mathFontOptions: const FontOptions(
|
||||
fontShape: FontStyle.italic,
|
||||
),
|
||||
fontSize: 14.0,
|
||||
color: widget.textStyle?.color ??
|
||||
theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const HSpace(2),
|
||||
],
|
||||
),
|
||||
child: _buildMathEquation(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMathEquation(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final longEq = Math.tex(
|
||||
widget.formula,
|
||||
textStyle: widget.textStyle,
|
||||
mathStyle: MathStyle.text,
|
||||
options: MathOptions(
|
||||
style: MathStyle.text,
|
||||
mathFontOptions: const FontOptions(
|
||||
fontShape: FontStyle.italic,
|
||||
),
|
||||
fontSize: 14.0,
|
||||
color: widget.textStyle?.color ?? theme.colorScheme.onSurface,
|
||||
),
|
||||
onErrorFallback: (errmsg) {
|
||||
return FlowyText(
|
||||
errmsg.message,
|
||||
);
|
||||
},
|
||||
);
|
||||
return Row(
|
||||
children: [
|
||||
longEq,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MathInputTextField extends StatefulWidget {
|
||||
|
||||
+2
-1
@@ -16,11 +16,12 @@ import '../transaction_handler/mention_transaction_handler.dart';
|
||||
const _pasteIdentifier = 'child_page_transaction';
|
||||
|
||||
class ChildPageTransactionHandler extends MentionTransactionHandler {
|
||||
ChildPageTransactionHandler() : super(subType: MentionType.childPage.name);
|
||||
ChildPageTransactionHandler();
|
||||
|
||||
@override
|
||||
Future<void> onTransaction(
|
||||
BuildContext context,
|
||||
String viewId,
|
||||
EditorState editorState,
|
||||
List<MentionBlockData> added,
|
||||
List<MentionBlockData> removed, {
|
||||
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
import 'package:appflowy/shared/clipboard_state.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
|
||||
import 'package:appflowy/user/application/reminder/reminder_extension.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:nanoid/nanoid.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../plugins.dart';
|
||||
import '../transaction_handler/mention_transaction_handler.dart';
|
||||
|
||||
const _pasteIdentifier = 'date_transaction';
|
||||
|
||||
class DateTransactionHandler extends MentionTransactionHandler {
|
||||
DateTransactionHandler();
|
||||
|
||||
@override
|
||||
Future<void> onTransaction(
|
||||
BuildContext context,
|
||||
String viewId,
|
||||
EditorState editorState,
|
||||
List<MentionBlockData> added,
|
||||
List<MentionBlockData> removed, {
|
||||
bool isCut = false,
|
||||
bool isUndoRedo = false,
|
||||
bool isPaste = false,
|
||||
bool isDraggingNode = false,
|
||||
bool isTurnInto = false,
|
||||
String? parentViewId,
|
||||
}) async {
|
||||
if (isDraggingNode || isTurnInto) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the mentions that were both added and removed in the same transaction.
|
||||
// These were just moved around.
|
||||
final moved = <MentionBlockData>[];
|
||||
for (final mention in added) {
|
||||
if (removed.any((r) => r.$2 == mention.$2)) {
|
||||
moved.add(mention);
|
||||
}
|
||||
}
|
||||
|
||||
for (final mention in removed) {
|
||||
if (!context.mounted || moved.any((m) => m.$2 == mention.$2)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mention.$2[MentionBlockKeys.type] != MentionType.date.name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
_handleDeletion(context, mention);
|
||||
}
|
||||
|
||||
if (isPaste || isUndoRedo) {
|
||||
if (context.mounted) {
|
||||
context.read<ClipboardState>().startHandlingPaste(_pasteIdentifier);
|
||||
}
|
||||
|
||||
for (final mention in added) {
|
||||
if (!context.mounted || moved.any((m) => m.$2 == mention.$2)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mention.$2[MentionBlockKeys.type] != MentionType.date.name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
_handleAddition(
|
||||
context,
|
||||
viewId,
|
||||
editorState,
|
||||
mention,
|
||||
isPaste,
|
||||
isCut,
|
||||
);
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
context.read<ClipboardState>().endHandlingPaste(_pasteIdentifier);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handleDeletion(
|
||||
BuildContext context,
|
||||
MentionBlockData data,
|
||||
) {
|
||||
final reminderId = data.$2[MentionBlockKeys.reminderId];
|
||||
|
||||
if (reminderId case String _ when reminderId.isNotEmpty) {
|
||||
getIt<ReminderBloc>().add(ReminderEvent.remove(reminderId: reminderId));
|
||||
}
|
||||
}
|
||||
|
||||
void _handleAddition(
|
||||
BuildContext context,
|
||||
String viewId,
|
||||
EditorState editorState,
|
||||
MentionBlockData data,
|
||||
bool isPaste,
|
||||
bool isCut,
|
||||
) {
|
||||
final dateData = _MentionDateBlockData.fromData(data.$2);
|
||||
if (dateData.dateString.isEmpty) {
|
||||
Log.error("mention date block doesn't have a valid date string");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPaste && !isCut) {
|
||||
_handlePasteFromCopy(
|
||||
context,
|
||||
viewId,
|
||||
editorState,
|
||||
data.$1,
|
||||
data.$3,
|
||||
dateData,
|
||||
);
|
||||
} else {
|
||||
_handlePasteFromCut(viewId, data.$1, dateData);
|
||||
}
|
||||
}
|
||||
|
||||
void _handlePasteFromCut(
|
||||
String viewId,
|
||||
Node node,
|
||||
_MentionDateBlockData data,
|
||||
) {
|
||||
final dateTime = DateTime.tryParse(data.dateString);
|
||||
|
||||
if (data.reminderId == null || dateTime == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
getIt<ReminderBloc>().add(
|
||||
ReminderEvent.addById(
|
||||
reminderId: data.reminderId!,
|
||||
objectId: viewId,
|
||||
scheduledAt: Int64(
|
||||
data.reminderOption
|
||||
.getNotificationDateTime(dateTime)
|
||||
.millisecondsSinceEpoch ~/
|
||||
1000,
|
||||
),
|
||||
meta: {
|
||||
ReminderMetaKeys.includeTime: data.includeTime.toString(),
|
||||
ReminderMetaKeys.blockId: node.id,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handlePasteFromCopy(
|
||||
BuildContext context,
|
||||
String viewId,
|
||||
EditorState editorState,
|
||||
Node node,
|
||||
int index,
|
||||
_MentionDateBlockData data,
|
||||
) async {
|
||||
final dateTime = DateTime.tryParse(data.dateString);
|
||||
|
||||
if (node.delta == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.reminderId == null || dateTime == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final reminderId = nanoid();
|
||||
getIt<ReminderBloc>().add(
|
||||
ReminderEvent.addById(
|
||||
reminderId: reminderId,
|
||||
objectId: viewId,
|
||||
scheduledAt: Int64(
|
||||
data.reminderOption
|
||||
.getNotificationDateTime(dateTime)
|
||||
.millisecondsSinceEpoch ~/
|
||||
1000,
|
||||
),
|
||||
meta: {
|
||||
ReminderMetaKeys.includeTime: data.includeTime.toString(),
|
||||
ReminderMetaKeys.blockId: node.id,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final newMentionAttributes = {
|
||||
MentionBlockKeys.mention: {
|
||||
MentionBlockKeys.type: MentionType.date.name,
|
||||
MentionBlockKeys.date: dateTime.toIso8601String(),
|
||||
MentionBlockKeys.reminderId: reminderId,
|
||||
MentionBlockKeys.includeTime: data.includeTime,
|
||||
MentionBlockKeys.reminderOption: data.reminderOption.name,
|
||||
},
|
||||
};
|
||||
|
||||
// The index is the index of the delta, to get the index of the mention character
|
||||
// in all the text, we need to calculate it based on the deltas before the current delta.
|
||||
int mentionIndex = 0;
|
||||
for (final (i, delta) in node.delta!.indexed) {
|
||||
if (i >= index) {
|
||||
break;
|
||||
}
|
||||
|
||||
mentionIndex += delta.length;
|
||||
}
|
||||
|
||||
// Required to prevent editing the same spot at the same time
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
final transaction = editorState.transaction
|
||||
..formatText(
|
||||
node,
|
||||
mentionIndex,
|
||||
MentionBlockKeys.mentionChar.length,
|
||||
newMentionAttributes,
|
||||
);
|
||||
|
||||
await editorState.apply(
|
||||
transaction,
|
||||
options: const ApplyOptions(recordUndo: false),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A helper class to parse and store the mention date block data
|
||||
class _MentionDateBlockData {
|
||||
_MentionDateBlockData.fromData(Map<String, dynamic> data) {
|
||||
dateString = switch (data[MentionBlockKeys.date]) {
|
||||
final String string when DateTime.tryParse(string) != null => string,
|
||||
_ => "",
|
||||
};
|
||||
includeTime = switch (data[MentionBlockKeys.includeTime]) {
|
||||
final bool flag => flag,
|
||||
_ => false,
|
||||
};
|
||||
reminderOption = switch (data[MentionBlockKeys.reminderOption]) {
|
||||
final String name =>
|
||||
ReminderOption.values.firstWhereOrNull((o) => o.name == name) ??
|
||||
ReminderOption.none,
|
||||
_ => ReminderOption.none,
|
||||
};
|
||||
reminderId = switch (data[MentionBlockKeys.reminderId]) {
|
||||
final String id
|
||||
when id.isNotEmpty && reminderOption != ReminderOption.none =>
|
||||
id,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
late final String dateString;
|
||||
late final bool includeTime;
|
||||
late final String? reminderId;
|
||||
late final ReminderOption reminderOption;
|
||||
}
|
||||
-1
@@ -8,7 +8,6 @@ import 'package:provider/provider.dart';
|
||||
|
||||
enum MentionType {
|
||||
page,
|
||||
reminder,
|
||||
date,
|
||||
childPage;
|
||||
|
||||
|
||||
+11
-6
@@ -8,30 +8,35 @@ class NumberedListIcon extends StatelessWidget {
|
||||
super.key,
|
||||
required this.node,
|
||||
required this.textDirection,
|
||||
this.textStyle,
|
||||
});
|
||||
|
||||
final Node node;
|
||||
final TextDirection textDirection;
|
||||
final TextStyle? textStyle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textStyle =
|
||||
final textStyleConfiguration =
|
||||
context.read<EditorState>().editorStyle.textStyleConfiguration;
|
||||
final fontSize = textStyle.text.fontSize ?? 16.0;
|
||||
final height = textStyle.text.height ?? textStyle.lineHeight;
|
||||
final fontSize = textStyleConfiguration.text.fontSize ?? 16.0;
|
||||
final height =
|
||||
textStyleConfiguration.text.height ?? textStyleConfiguration.lineHeight;
|
||||
final combinedTextStyle = textStyle?.combine(textStyleConfiguration.text) ??
|
||||
textStyleConfiguration.text;
|
||||
final size = fontSize * height;
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
minWidth: size,
|
||||
minHeight: size,
|
||||
),
|
||||
margin: const EdgeInsets.only(right: 8.0),
|
||||
margin: const EdgeInsets.only(top: 0.5, right: 8.0),
|
||||
alignment: Alignment.center,
|
||||
child: Center(
|
||||
child: Text(
|
||||
node.levelString,
|
||||
style: textStyle.text,
|
||||
strutStyle: StrutStyle.fromTextStyle(textStyle.text),
|
||||
style: combinedTextStyle,
|
||||
strutStyle: StrutStyle.fromTextStyle(combinedTextStyle),
|
||||
textDirection: textDirection,
|
||||
),
|
||||
),
|
||||
|
||||
+3
@@ -1,4 +1,7 @@
|
||||
export 'callout_node_parser.dart';
|
||||
export 'custom_image_node_parser.dart';
|
||||
export 'file_block_node_parser.dart';
|
||||
export 'link_preview_node_parser.dart';
|
||||
export 'math_equation_node_parser.dart';
|
||||
export 'simple_table_node_parser.dart';
|
||||
export 'toggle_list_node_parser.dart';
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
|
||||
class FileBlockNodeParser extends NodeParser {
|
||||
const FileBlockNodeParser();
|
||||
|
||||
@override
|
||||
String get id => FileBlockKeys.type;
|
||||
|
||||
@override
|
||||
String transform(Node node, DocumentMarkdownEncoder? encoder) {
|
||||
final name = node.attributes[FileBlockKeys.name];
|
||||
final url = node.attributes[FileBlockKeys.url];
|
||||
if (name == null || url == null) {
|
||||
return '';
|
||||
}
|
||||
return '[$name]($url)\n';
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
||||
|
||||
class LinkPreviewNodeParser extends NodeParser {
|
||||
const LinkPreviewNodeParser();
|
||||
|
||||
@override
|
||||
String get id => LinkPreviewBlockKeys.type;
|
||||
|
||||
@override
|
||||
String transform(Node node, DocumentMarkdownEncoder? encoder) {
|
||||
final href = node.attributes[LinkPreviewBlockKeys.url];
|
||||
if (href == null) {
|
||||
return '';
|
||||
}
|
||||
return '[$href]($href)\n';
|
||||
}
|
||||
}
|
||||
+1
-5
@@ -1,6 +1,2 @@
|
||||
export 'callout_node_parser.dart';
|
||||
export 'custom_image_node_parser.dart';
|
||||
export 'markdown_code_parser.dart';
|
||||
export 'math_equation_node_parser.dart';
|
||||
export 'toggle_list_node_parser.dart';
|
||||
export 'simple_table_parser.dart';
|
||||
export 'markdown_simple_table_parser.dart';
|
||||
Loaded 100 of 350 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user