Compare commits

...
53 Commits
Author SHA1 Message Date
Lucas 251b16cb62 chore: update changelog (#6596) 2024-10-22 10:22:25 +08:00
Mathias Mogensen da80c599bf fix: show default view name (#6571) 2024-10-22 09:31:14 +08:00
Lucas ca6a900874 fix: display paragraph mark only if the block content is empty (#6594) 2024-10-21 20:25:55 +08:00
Lucas 5e09e96f7b fix: callout emoji size should follow the document font size (#6590) 2024-10-21 18:00:32 +08:00
Mathias MogensenandLucas.Xu 068500df84 feat: inline sub page mention (#6567)
* feat: inline sub page mention

* fix: disable editing documents in trash

* fix: duplicate block behavior

* refactor: clean up code

* feat: use formatText function instead of modify delta manually

* fix: paste behavior format mention

* fix: default icon for mentioned pages

* fix: view new parent turn into page reference

* test: add base test

* chore: add feature flag

* chore: default flag to on

* fix: minor fixes to behavior

* fix: review and code cleanup

* fix: dart linter

* fix: content is required

* test: use doc title to rename page

* test: add test coverage

* test: fix wrong expect

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2024-10-21 16:34:30 +08:00
Murat Sarıbaş 15949a7e21 chore: update translations with Fink 🐦 (#6565) 2024-10-21 09:45:52 +08:00
Mathias Mogensen e635fe3378 fix: trash breadcrumb line height (#6588) 2024-10-21 09:42:48 +08:00
Mathias Mogensen 8276c5db57 fix: drag and drop row document full page (#6589) 2024-10-21 09:42:15 +08:00
Mathias Mogensen 1d9876a6eb fix: notifier disposed but in use (#6586) 2024-10-21 00:21:25 +02:00
Lucas 6340174927 fix: share link and publish issues on mobile (#6579)
* fix: cache publish info to avoid repeated requests

* chore: 'Copy Link' to 'Copy link'

* chore: enable ios ci

* fix: ios ci

* chore: replace mention block icon

* fix: ios ci tests

* fix: ios ci tests

* fix: ai wirte replace assertion

* fix: windows ci
2024-10-20 10:13:06 +08:00
Lucas 7b031b228e fix: turn into issues (#6576)
* fix: cover title issues

* fix: the selection should be cleared if selecting child node

* fix: exclude the blocks that are not supported in the 'turn into' types

* fix: add logs

* fix: floating toolbar ai status

* test: selecting the parent should deselect all the child nodes as well

* chore: 'Copy Link' to 'Copy link'

* fix: select all and turn into block doesn't work on Windows

* test: calculate turn into selection test

* fix: option button tests
2024-10-18 17:13:38 +08:00
Richard Shiue ec408940e4 fix(flutter_desktop): bring back the board scrollbar (#6580) 2024-10-18 15:52:32 +08:00
Richard Shiue bd46fc11f4 fix(flutter-desktop): launch review issues for 0.7.2 (#6577)
* test: add test case for calendar filter

* fix: select option filter logic

* fix: calendar filters not applying if switched repeatedly

* fix: checklist cell editor improvements

* fix: nested scrolling in checklist

* fix: make filter logic match notion

* test: fix checklist cell test

* test: single select filter tests

* test: fix flutter test

* test: fix rust tests

* chore: fix clippy
2024-10-18 13:48:50 +08:00
nathan 42e55fe248 chore: update import string 2024-10-17 16:03:22 +08:00
nathan 3a9011b3cb chore: remove timezone for self-hosted runner 2024-10-17 15:42:01 +08:00
Lucas e3bf0442e8 fix: space icon color assertion (#6570)
* fix: space icon color assertion

* chore: bump version 0.7.2
2024-10-17 13:43:14 +08:00
Lucas 0413100e2b feat: observe mention block change and support block navigation (#6568)
* feat: observe mentioned block changes and navigate to block

* test: add delete mentioned block test

* chore: update editor version

* feat: navigate block in same page

* fix: sometimes turn into menu doesn't work

* test: add test

* fix: integration test
2024-10-17 13:29:34 +08:00
nathan 7cad04bbf4 chore: bump collab which include fixing windows build 2024-10-17 13:27:05 +08:00
Mathias Mogensen c9f0bbf26a feat: right click to open view more actions (#6529)
* feat: right click to open view more actions

* test: fix tests

* test: fix failing test

* fix: hide in move to page menu

* test: add simple test

* test: add negative case in cloud test

* chore: check code related to sidebar spaces

* fix: view item

* fix: clean up
2024-10-17 06:58:01 +02:00
Nathan.fooo d91639172a chore: show import error (#6569) 2024-10-17 10:32:51 +08:00
Nathan.fooo 5f0cc1becd chore: bump client api and collab (#6563)
* chore: bump client api and collab

* chore: fix windows build
2024-10-17 09:45:45 +08:00
Lucas a8bcab7770 feat: turn into multiple lines (#6558)
* feat: select multiple lines with block selection style

* feat: multiple nodes conversion

* fix: exclude children for the block can't contain children

* chore: update editor version

* fix: unit test

* test: convert nested list to heading/quote/callout

* test: transform nodes at the same level into another block type

* test: add undo redo for turn into

* test: add multi lines integration test

* chore: remove debug logs

* fix: integration test
2024-10-16 22:19:18 +08:00
Kilu.He b1682e4f54 fix: modified import function (#6562) 2024-10-16 21:40:32 +08:00
Richard Shiue 2447b34107 fix: text button's text style (#6559) 2024-10-16 17:06:14 +08:00
Richard Shiue 8126d19682 fix(flutter): wrong time zone when filtering by date field (#6543)
* fix: wrong time zone when filtering by date field

* test: set time zone
2024-10-16 16:44:06 +08:00
Richard Shiue 6cc8c81e3d chore: adjust Toggle widget's onChanged callback (#6556) 2024-10-16 14:39:46 +08:00
Nathan.fooo e86d812ee6 chore: add tooltip when import notion file (#6557) 2024-10-16 13:55:21 +08:00
Nathan.fooo c6f042830f fix: import document that contains refs (#6551)
* fix: import document that contains refs

* chore: clippy
2024-10-15 23:55:27 +08:00
Nathan.fooo 2378c0c441 chore: add calculation rust test (#6547)
* chore: add test

* chore: clippy
2024-10-15 17:20:53 +08:00
Lucas 7bc53d7bc6 feat: support 'share link' and 'publish' on mobile (#6548)
* feat: support publish on mobile

* chore: replace icons

* feat: support publish and copy link on mobile

* feat: support copy link

* feat: support copy share link

* chore: update icons

* fix: flutter analyze

* fix: ignore opening same page on mobile

* test: add copy link test on mobile
2024-10-15 15:12:59 +08:00
Nathan.fooo 7918d6c0d1 chore: update logs (#6550) 2024-10-15 15:05:22 +08:00
Kilu.He 073e8b897b feat: support import notion (#6549)
* feat: support import notion

* feat: support import page
2024-10-15 11:49:38 +08:00
Nathan.fooo 46a3006f4d refactor: database test (#6544)
* chore: remove script

* chore: remove script

* chore: refactor test

* chore: clippy

* chore: fix test

* chore: fix test

* chore: fmt
2024-10-15 09:32:06 +08:00
Deep Isane 5d578ab9e6 chore: update README.md fixed a typo (#6546)
Corrected "Acknowledgements" to "Acknowledgments" in [README.md].

This pull request addresses a minor typo found in the repository. The typo has been corrected to improve clarity and maintain the quality of the documentation.

This change is purely cosmetic and does not affect functionality.
2024-10-14 22:11:34 +02:00
Lucas f3fe729900 fix: potentially missing field in todo list (#6540)
* fix: potentially missing field in todo list

* chore: upgrade appflowy_editor_plugins

* fix: potentially missing field in todo list
2024-10-14 20:54:13 +08:00
Ahad PatelandLucas.Xu a04c157770 fix: date issue when enbling time feild (#6447)
* fix: date issue when enbling time field

* chore: add integration test for date or reminder field

* chore: add test file to document_test_runner.dart

* fix: flutter analyze

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2024-10-14 20:54:03 +08:00
Nathan.fooo 320ffcb434 Import notion zip (#6528)
* chore: upgrade client api

* chore: import notion zip

* chore: bump client api

* chore: bump collab

* chore: upgrade cloud version

* chore: fix test

* chore: fix test
2024-10-14 20:38:04 +08:00
Lucas 37a2fb17b5 feat: copy link to block (#6523)
* feat: copy link to block

* chore: add comment

* feat: using mention bloc to manage the mention bloc status

* feat: use mention page bloc to manage mentioned page status

* feat: observe mention block content changes

* feat: sync the block content

* fix: integration test

* fix: mentioned block display name contains unnessary -

* fix: handle block was deleted case

* chore: move the get doc block and get mentioned page status to service

* feat: support copy link to page

* test: add copy link to block tests

* test: add copy link to block(same pge) tests

* test: open the mentioned page

* fix: integration test

* Revert "fix: integration test"

This reverts commit f4466b22d8.

* fix: integration test
2024-10-14 14:04:42 +08:00
Kilu.He b5936cec54 fix: supported incremental updates (#6531) 2024-10-11 11:29:29 +08:00
Richard Shiue ea61c81cce feat(flutter_desktop): open created filtered rows (#6522)
* fix: revert previous implementation

* feat: open created but hidden rows as page

* fix: cargo clippy

* test: add integration tests

* fix: typo

* fix: flutter analyzer

* chore: clean up code

* chore: code cleanup

* chore: code cleanup
2024-10-11 11:04:03 +08:00
Kilu.He 64d71d0d20 fix: add sync manager (#6517)
* fix: add sync manager

* fix: add toolbar actions

* fix: add import entry

* fix: modified approve page
2024-10-10 19:48:30 +08:00
Mathias Mogensen af08b4c004 fix: hide breadcrumbs for deleted pages and show trash (#6512)
* fix: hide breadcrumbs for deleted pages and show trash

* fix: copy for banner and trash deletion

* test: add base test

* test: expand on test

* test: fix test

* test: fix failing test
2024-10-10 11:57:42 +02:00
ea670b6ae6 fix: grid new row getting cut (#6475)
* fix: Grid new row getting cut

* chore: add test case for testing text in veiwport

* refac: clean up code

---------

Co-authored-by: Mathias Mogensen <mathiasrieckm@gmail.com>
Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2024-10-10 11:02:05 +02:00
Lucas f19e354418 feat: support 'turn into' in doc (#6516)
* feat: customize animation for popover

* chore: code refactor

* feat: using popover direction calculate the popover animation translate direction

* feat: integrate the animated popover in appflowy_popover and popover_action

* fix: close popover assertion

* chore: format code

* chore: code refactor

* feat: optimize the popover listener

* feat: clear popover when hot-reloading

* chore: refactor code

* feat: integrate animated popover in block action button

* fix: integration test

* feat: add turn into entry

* fix: popover asBarrier issue

* feat: move biz logic from widget to cubit

* feat: add turn into menu

* chore: remove unused code

* feat: support h1-h3

* feat: add block conversions

* fix: integration test

* feat: implement block conversions

* fix: outline test

* test: add turn into tests

* test: add turn into nested list test

* test: add turn into nested list test

* fix: flutter analyze

* chore: replace turninto icon

* feat: integrate animated popover in color option button

* chore: split the block option action into separate files

* test: add integration test

* fix: outline block test

* fix: integration test

* fix: shortcut test
2024-10-10 14:40:38 +08:00
Richard Shiue b54e3dd243 feat: checklist cell improvements (#6515)
* feat: reorder checklist tasks

* feat: quickly create checklist tasks

* feat: reorder checklist on mobile

* test: add integration test

* test: add bloc test

* fix: flutter analyzer

* fix: cargo clippy
2024-10-10 11:38:17 +08:00
Richard Shiue 9798e39e49 feat: calendar filters (#6519)
* feat: calendar filters

* test: add integration tests
2024-10-10 11:00:23 +08:00
Richard Shiue 66fd0c19da test: fix flaky sort editor bloc test (#6520) 2024-10-10 11:00:09 +08:00
Mathias Mogensen 8d6c67206c feat: enable drag and drop in row document (#6513)
* feat: enable drag and drop in row document

* refactor: cleanup drop handling to one widget

* fix: clean up code
2024-10-09 15:23:42 +02:00
Lucas 8cf683eb50 feat: customize animation for popover (#6507)
* feat: customize animation for popover

* chore: code refactor

* feat: using popover direction calculate the popover animation translate direction

* feat: integrate the animated popover in appflowy_popover and popover_action

* fix: close popover assertion

* chore: format code

* chore: code refactor

* feat: optimize the popover listener

* feat: clear popover when hot-reloading

* chore: refactor code

* fix: integration test

* fix: icon test
2024-10-09 15:10:05 +08:00
Kilu.He 580a23f3f5 feat: support pasted on web (#6510)
* fix: add some test cases

* fix: with pasted plugin
2024-10-09 11:50:33 +08:00
Richard Shiue 29dc46a993 fix: dont edit every view while handling database view row change (#6506)
* fix: don't process on every view while only handling one view

* test: add test
2024-10-09 11:01:09 +08:00
Richard Shiue 93885bad7b fix: get all calendar events not fetching empty ones (#6508) 2024-10-09 11:00:33 +08:00
Richard Shiue 6e0619c21e chore: bump validator crate version (#6494) 2024-10-08 21:38:58 +08:00
527 changed files with 21099 additions and 9267 deletions

No files matched your search

+6 -5
View File
@@ -28,6 +28,7 @@ env:
FLUTTER_VERSION: "3.22.2"
RUST_TOOLCHAIN: "1.80.1"
CARGO_MAKE_VERSION: "0.37.18"
CLOUD_VERSION: 0.6.34-amd64
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -73,9 +74,9 @@ jobs:
strategy:
fail-fast: true
matrix:
os: [windows-2019]
os: [windows-latest]
include:
- os: windows-2019
- os: windows-latest
flutter_profile: development-windows-x86
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.os }}
@@ -247,9 +248,9 @@ jobs:
- name: Run Docker-Compose
working-directory: AppFlowy-Cloud
env:
APPFLOWY_CLOUD_VERSION: 0.6.29-amd64
APPFLOWY_HISTORY_VERSION: 0.6.29-amd64
APPFLOWY_WORKER_VERSION: 0.6.29-amd64
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
+9 -9
View File
@@ -22,6 +22,7 @@ on:
env:
FLUTTER_VERSION: "3.22.0"
RUST_TOOLCHAIN: "1.80.1"
CLOUD_VERSION: 0.6.34-amd64
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -48,13 +49,13 @@ jobs:
model: "iPhone 15"
shutdown_after_job: false
build-macos:
integration-tests:
if: github.event.pull_request.head.repo.full_name != github.repository
runs-on: macos-13
runs-on: macos-latest
steps:
- name: Checkout source code
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: actions-rs/toolchain@v1
@@ -96,11 +97,11 @@ jobs:
cargo make --profile development-ios-arm64-sim appflowy-core-dev-ios
cargo make --profile development-ios-arm64-sim code_generation
- uses: futureware-tech/simulator-action@v3
id: simulator-action
with:
model: "iPhone 15"
shutdown_after_job: false
# - uses: futureware-tech/simulator-action@v3
# id: simulator-action
# with:
# model: "iPhone 15"
# shutdown_after_job: false
# - name: Run AppFlowy on simulator
# working-directory: frontend/appflowy_flutter
@@ -111,7 +112,6 @@ jobs:
# kill $pid
# continue-on-error: true
# enable it again if the 12 mins timeout is fixed
# - name: Run integration tests
# working-directory: frontend/appflowy_flutter
# run: flutter test integration_test/runner.dart -d ${{ steps.simulator-action.outputs.udid }}
+12 -6
View File
@@ -20,6 +20,7 @@ on:
env:
CARGO_TERM_COLOR: always
CLOUD_VERSION: 0.6.34-amd64
RUST_TOOLCHAIN: "1.77.2"
jobs:
@@ -46,9 +47,9 @@ jobs:
- name: Ensure AppFlowy-Cloud is Running with Correct Version
working-directory: AppFlowy-Cloud
env:
APPFLOWY_CLOUD_VERSION: 0.6.29-amd64
APPFLOWY_HISTORY_VERSION: 0.6.29-amd64
APPFLOWY_WORKER_VERSION: 0.6.29-amd64
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
@@ -93,6 +94,11 @@ jobs:
if: github.event.pull_request.head.repo.full_name != github.repository
runs-on: ubuntu-latest
steps:
- name: Set timezone for action
uses: szenius/set-timezone@v2.0
with:
timezoneLinux: "US/Pacific"
- name: Maximize build space
run: |
sudo rm -rf /usr/share/dotnet
@@ -135,9 +141,9 @@ jobs:
- name: Ensure AppFlowy-Cloud is Running with Correct Version
working-directory: AppFlowy-Cloud
env:
APPFLOWY_CLOUD_VERSION: 0.6.29-amd64
APPFLOWY_HISTORY_VERSION: 0.6.29-amd64
APPFLOWY_WORKER_VERSION: 0.6.29-amd64
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
+16 -1
View File
@@ -1,4 +1,19 @@
# Release Notes
## Version 0.7.2 - 22/10/2024
### New Features
- Copy link to block
- Support turn into in document
- Enable sharing links and publishing pages on mobile
- Enable drag and drop in row documents
- Right-click on page in sidebar to open more actions
- Create new subpage in document using `+` character
- Allow reordering checklist item
### Bug Fixes
- Fixed issue with inability to cancel inline code format in French IME
- Fixed delete with Shift or Ctrl shortcuts not working in documents
- Fixed the issues with incorrect time zone being used in filters.
## Version 0.7.1 - 07/10/2024
### New Features
- Copy link to share and open it in a browser
@@ -60,7 +75,7 @@
- Fixed the inability to edit group names on Kanban boards
- Made error codes more user-friendly
- Added leading zeros to day and month in date format
## Version 0.6.8 - 22/08/2024
### New Features
- Enabled viewing data inside a database record on mobile.
+1 -1
View File
@@ -119,7 +119,7 @@ We do not claim to outperform Notion in terms of functionality and design, at le
Distributed under the AGPLv3 License. See [`LICENSE.md`](https://github.com/AppFlowy-IO/AppFlowy/blob/main/LICENSE) for more information.
## Acknowledgements
## Acknowledgments
Special thanks to these amazing projects which help power AppFlowy.IO:
+1 -1
View File
@@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
CARGO_MAKE_CRATE_FS_NAME = "dart_ffi"
CARGO_MAKE_CRATE_NAME = "dart-ffi"
LIB_NAME = "dart_ffi"
APPFLOWY_VERSION = "0.7.1"
APPFLOWY_VERSION = "0.7.2"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"
@@ -1,13 +1,3 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
analyzer:
@@ -16,17 +6,6 @@ analyzer:
- "**/*.freezed.dart"
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at
# https://dart-lang.github.io/linter/lints/index.html.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
- require_trailing_commas
@@ -51,8 +30,5 @@ linter:
- sort_constructors_first
- unawaited_futures
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
errors:
invalid_annotation_target: ignore
@@ -0,0 +1,275 @@
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/document_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:appflowy/shared/patterns/common_patterns.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../../shared/constants.dart';
import '../../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
// copy link to block
group('copy link to block:', () {
testWidgets('copy link to check if the clipboard has the correct content',
(tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
// open getting started page
await tester.openPage(Constants.gettingStartedPageName);
await tester.editor.copyLinkToBlock([0]);
await tester.pumpAndSettle(Durations.short1);
// check the clipboard
final content = await Clipboard.getData(Clipboard.kTextPlain);
expect(
content?.text,
matches(appflowySharePageLinkPattern),
);
});
testWidgets('copy link to block(another page) and paste it in doc',
(tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
// open getting started page
await tester.openPage(Constants.gettingStartedPageName);
await tester.editor.copyLinkToBlock([0]);
// create a new page and paste it
const pageName = 'copy link to block';
await tester.createNewPageInSpace(
spaceName: Constants.generalSpaceName,
layout: ViewLayoutPB.Document,
pageName: pageName,
);
// paste the link to the new page
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.paste();
await tester.pumpAndSettle();
// check the content of the block
final node = tester.editor.getNodeAtPath([0]);
final delta = node.delta!;
final insert = (delta.first as TextInsert).text;
final attributes = delta.first.attributes;
expect(insert, MentionBlockKeys.mentionChar);
final mention =
attributes?[MentionBlockKeys.mention] as Map<String, dynamic>;
expect(mention[MentionBlockKeys.type], MentionType.page.name);
expect(mention[MentionBlockKeys.blockId], isNotNull);
expect(mention[MentionBlockKeys.pageId], isNotNull);
expect(
find.descendant(
of: find.byType(AppFlowyEditor),
matching: find.textContaining(
Constants.gettingStartedPageName,
findRichText: true,
),
),
findsOneWidget,
);
expect(
find.descendant(
of: find.byType(AppFlowyEditor),
matching: find.textContaining(
// the pasted block content is 'Welcome to AppFlowy'
'Welcome to AppFlowy',
findRichText: true,
),
),
findsOneWidget,
);
// tap the mention block to jump to the page
await tester.tapButton(find.byType(MentionPageBlock));
await tester.pumpAndSettle();
// expect to go to the getting started page
final documentPage = find.byType(DocumentPage);
expect(documentPage, findsOneWidget);
expect(
tester.widget<DocumentPage>(documentPage).view.name,
Constants.gettingStartedPageName,
);
// and the block is selected
expect(
tester.widget<DocumentPage>(documentPage).initialBlockId,
mention[MentionBlockKeys.blockId],
);
expect(
tester.editor.getCurrentEditorState().selection,
Selection.collapsed(
Position(
path: [0],
),
),
);
});
testWidgets('copy link to block(same page) and paste it in doc',
(tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
// create a new page and paste it
const pageName = 'copy link to block';
await tester.createNewPageInSpace(
spaceName: Constants.generalSpaceName,
layout: ViewLayoutPB.Document,
pageName: pageName,
);
// copy the link to block from the first line
const inputText = 'Hello World';
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText(inputText);
await tester.ime.insertCharacter('\n');
await tester.pumpAndSettle();
await tester.editor.copyLinkToBlock([0]);
// paste the link to the second line
await tester.editor.tapLineOfEditorAt(1);
await tester.editor.paste();
await tester.pumpAndSettle();
// check the content of the block
final node = tester.editor.getNodeAtPath([1]);
final delta = node.delta!;
final insert = (delta.first as TextInsert).text;
final attributes = delta.first.attributes;
expect(insert, MentionBlockKeys.mentionChar);
final mention =
attributes?[MentionBlockKeys.mention] as Map<String, dynamic>;
expect(mention[MentionBlockKeys.type], MentionType.page.name);
expect(mention[MentionBlockKeys.blockId], isNotNull);
expect(mention[MentionBlockKeys.pageId], isNotNull);
expect(
find.descendant(
of: find.byType(AppFlowyEditor),
matching: find.textContaining(
inputText,
findRichText: true,
),
),
findsNWidgets(2),
);
// edit the pasted block
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText('!');
await tester.pumpAndSettle();
// check the content of the block
expect(
find.descendant(
of: find.byType(AppFlowyEditor),
matching: find.textContaining(
'$inputText!',
findRichText: true,
),
),
findsNWidgets(2),
);
// tap the mention block
await tester.tapButton(find.byType(MentionPageBlock));
expect(
tester.editor.getCurrentEditorState().selection,
Selection.collapsed(
Position(
path: [0],
),
),
);
});
testWidgets('''1. copy link to block from another page
2. paste the link to the new page
3. delete the original page
4. check the content of the block, it should be no access to the page
''', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
// open getting started page
await tester.openPage(Constants.gettingStartedPageName);
await tester.editor.copyLinkToBlock([0]);
// create a new page and paste it
const pageName = 'copy link to block';
await tester.createNewPageInSpace(
spaceName: Constants.generalSpaceName,
layout: ViewLayoutPB.Document,
pageName: pageName,
);
// paste the link to the new page
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.paste();
await tester.pumpAndSettle();
// tap the mention block to jump to the page
await tester.tapButton(find.byType(MentionPageBlock));
await tester.pumpAndSettle();
// expect to go to the getting started page
final documentPage = find.byType(DocumentPage);
expect(documentPage, findsOneWidget);
expect(
tester.widget<DocumentPage>(documentPage).view.name,
Constants.gettingStartedPageName,
);
// delete the getting started page
await tester.hoverOnPageName(
Constants.gettingStartedPageName,
onHover: () async => tester.tapDeletePageButton(),
);
tester.expectToSeeDocumentBanner();
tester.expectNotToSeePageName(gettingStarted);
// delete the page permanently
await tester.tapDeletePermanentlyButton();
// go back the page
await tester.openPage(pageName);
await tester.pumpAndSettle();
// check the content of the block
// it should be no access to the page
expect(
find.descendant(
of: find.byType(AppFlowyEditor),
matching: find.findTextInFlowyText(
LocaleKeys.document_mention_noAccess.tr(),
),
),
findsOneWidget,
);
});
});
}
@@ -68,38 +68,6 @@ void main() {
expect(afterMoveBlock.delta, beforeMoveBlock.delta);
});
// Copy link to block feature is disable temporarily, enable this test when the feature is ready.
// testWidgets('copy block link', (tester) async {
// await tester.initializeAppFlowy(
// cloudType: AuthenticatorType.appflowyCloudSelfHost,
// );
// await tester.tapGoogleLoginInButton();
// await tester.expectToSeeHomePageWithGetStartedPage();
// // open getting started page
// await tester.openPage(Constants.gettingStartedPageName);
// // hover and click on the option menu button beside the block component.
// await tester.editor.hoverAndClickOptionMenuButton([0]);
// // click the copy link to block option
// await tester.tap(
// find.findTextInFlowyText(
// LocaleKeys.document_plugins_optionAction_copyLinkToBlock.tr(),
// ),
// );
// await tester.pumpAndSettle(Durations.short1);
// // check the clipboard
// final content = await Clipboard.getData(Clipboard.kTextPlain);
// expect(
// content?.text,
// matches(
// r'^https:\/\/appflowy\.com\/app\/[a-f0-9-]{36}\/[a-f0-9-]{36}\?blockId=[A-Za-z0-9_-]+$',
// ),
// );
// });
testWidgets('hover on the block and delete it', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
@@ -1,9 +1,12 @@
import 'package:integration_test/integration_test.dart';
import 'document_copy_link_to_block_test.dart'
as document_copy_link_to_block_test;
import 'document_option_actions_test.dart' as document_option_actions_test;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
document_option_actions_test.main();
document_copy_link_to_block_test.main();
}
@@ -18,6 +18,7 @@ import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -92,6 +93,14 @@ void main() {
await tester.pumpAndSettle();
}
// Attempt right-click on the page name and expect not to see
await tester.tap(gettingStarted.last, buttons: kSecondaryButton);
await tester.pumpAndSettle();
expect(
find.text(LocaleKeys.disclosureAction_moveTo.tr()),
findsOneWidget,
);
// move the current page to Getting started
await tester.tapButton(
gettingStarted.last,
@@ -1,5 +1,5 @@
import 'package:appflowy/plugins/database/calendar/presentation/calendar_event_editor.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -277,5 +277,72 @@ void main() {
tester.assertRowDetailPageOpened();
});
testWidgets('filter calendar events', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// Create the calendar view
await tester.createNewPageWithNameUnderParent(
layout: ViewLayoutPB.Calendar,
);
// Create a new event on the first of this month
final today = DateTime.now();
final firstOfThisMonth = DateTime(today.year, today.month);
await tester.doubleClickCalendarCell(firstOfThisMonth);
await tester.dismissEventEditor();
tester.assertNumberOfEventsInCalendar(1);
await tester.openCalendarEvent(index: 0, date: firstOfThisMonth);
await tester.tapButton(finderForFieldType(FieldType.MultiSelect));
await tester.createOption(name: "asdf");
await tester.createOption(name: "qwer");
await tester.selectOption(name: "asdf");
await tester.dismissCellEditor();
await tester.tapDatabaseFilterButton();
await tester.tapCreateFilterByFieldType(FieldType.MultiSelect, "Tags");
await tester.tapFilterButtonInGrid('Tags');
await tester.tapOptionFilterWithName('asdf');
await tester.dismissCellEditor();
tester.assertNumberOfEventsInCalendar(0);
await tester.tapFilterButtonInGrid('Tags');
await tester.tapOptionFilterWithName('asdf');
await tester.dismissCellEditor();
tester.assertNumberOfEventsInCalendar(1);
await tester.tapFilterButtonInGrid('Tags');
await tester.tapOptionFilterWithName('asdf');
await tester.dismissCellEditor();
tester.assertNumberOfEventsInCalendar(0);
final secondOfThisMonth = DateTime(today.year, today.month, 2);
await tester.doubleClickCalendarCell(secondOfThisMonth);
await tester.dismissEventEditor();
tester.assertNumberOfEventsInCalendar(1);
await tester.openCalendarEvent(index: 0, date: secondOfThisMonth);
await tester.tapButton(finderForFieldType(FieldType.MultiSelect));
await tester.selectOption(name: "asdf");
await tester.dismissCellEditor();
tester.assertNumberOfEventsInCalendar(0);
await tester.tapFilterButtonInGrid('Tags');
await tester.changeSelectFilterCondition(
SelectOptionFilterConditionPB.OptionIsEmpty,
);
await tester.dismissCellEditor();
tester.assertNumberOfEventsInCalendar(1);
tester.assertNumberOfEventsOnSpecificDay(1, secondOfThisMonth);
});
});
}
@@ -1,11 +1,12 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart';
import 'package:appflowy/plugins/database/widgets/field/type_option_editor/select/select_option.dart';
import 'package:appflowy/util/field_type_extension.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -316,6 +317,30 @@ void main() {
);
});
testWidgets('text in viewport while typing', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
await tester.changeCalculateAtIndex(0, CalculationType.Count);
// add very large text with 200 lines
final largeText = List.generate(
200,
(index) => 'Line ${index + 1}',
).join('\n');
await tester.editCell(
rowIndex: 2,
fieldType: FieldType.RichText,
input: largeText,
);
// checks if last line is in view port
tester.expectToSeeText('Line 200');
});
// Disable this test because it fails on CI randomly
// testWidgets('last modified and created at field type options',
// (tester) async {
@@ -1,7 +1,7 @@
import 'package:appflowy/plugins/database/application/field/filter_entities.dart';
import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checkbox.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/text.dart';
import 'package:appflowy/plugins/database/widgets/row/row_detail.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -108,7 +108,7 @@ void main() {
await tester.tapOptionFilterWithName('s4');
// The row with 's4' should be shown.
tester.assertNumberOfRowsInGridPage(1);
tester.assertNumberOfRowsInGridPage(2);
await tester.pumpAndSettle();
});
@@ -154,12 +154,10 @@ void main() {
tester.assertNumberOfRowsInGridPage(0);
await tester.tapFilterButtonInGrid('date');
await tester.tapDateFilterButtonInGrid();
await tester.tapDateFilterCondition(DateTimeFilterCondition.before);
await tester.changeDateFilterCondition(DateTimeFilterCondition.before);
tester.assertNumberOfRowsInGridPage(7);
await tester.tapDateFilterButtonInGrid();
await tester.tapDateFilterCondition(DateTimeFilterCondition.isEmpty);
await tester.changeDateFilterCondition(DateTimeFilterCondition.isEmpty);
tester.assertNumberOfRowsInGridPage(3);
await tester.pumpAndSettle();
@@ -171,8 +169,6 @@ void main() {
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
await tester.scrollToRight(find.byType(GridPage));
await tester.createField(
FieldType.CreatedTime,
name: 'Created at',
@@ -189,11 +185,40 @@ void main() {
tester.assertNumberOfRowsInGridPage(3);
await tester.tapFilterButtonInGrid('Created at');
await tester.tapDateFilterButtonInGrid();
await tester.tapDateFilterCondition(DateTimeFilterCondition.before);
await tester.changeDateFilterCondition(DateTimeFilterCondition.before);
tester.assertNumberOfRowsInGridPage(0);
await tester.pumpAndSettle();
});
testWidgets('create new row when filters don\'t autofill', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
// create a filter
await tester.tapDatabaseFilterButton();
await tester.tapCreateFilterByFieldType(
FieldType.RichText,
'Name',
);
tester.assertNumberOfRowsInGridPage(3);
await tester.tapCreateRowButtonInGrid();
tester.assertNumberOfRowsInGridPage(4);
await tester.tapFilterButtonInGrid('Name');
await tester
.changeTextFilterCondition(TextFilterConditionPB.TextIsNotEmpty);
await tester.dismissCellEditor();
tester.assertNumberOfRowsInGridPage(0);
await tester.tapCreateRowButtonInGrid();
tester.assertNumberOfRowsInGridPage(0);
expect(find.byType(RowDetailPage), findsOneWidget);
await tester.pumpAndSettle();
});
});
}
@@ -1,3 +1,6 @@
import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_cell_editor.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
@@ -9,7 +12,7 @@ import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -335,5 +338,139 @@ void main() {
tester.assertNumberOfRowsInGridPage(4);
});
testWidgets('edit checklist cell', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
const fieldType = FieldType.Checklist;
await tester.createField(fieldType);
await tester.openFirstRowDetailPage();
await tester.hoverOnWidget(
find.byType(ChecklistRowDetailCell),
onHover: () async {
await tester.tapButton(find.byType(ChecklistItemControl));
},
);
tester.assertPhantomChecklistItemAtIndex(index: 0);
await tester.enterText(find.byType(PhantomChecklistItem), 'task 1');
await tester.pumpAndSettle();
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle(const Duration(milliseconds: 500));
tester.assertChecklistTaskInEditor(
index: 0,
name: "task 1",
isChecked: false,
);
tester.assertPhantomChecklistItemAtIndex(index: 1);
await tester.enterText(find.byType(PhantomChecklistItem), 'task 2');
await tester.pumpAndSettle();
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle(const Duration(milliseconds: 500));
tester.assertChecklistTaskInEditor(
index: 1,
name: "task 2",
isChecked: false,
);
tester.assertPhantomChecklistItemAtIndex(index: 2);
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(find.byType(PhantomChecklistItem), findsNothing);
await tester.renameChecklistTask(index: 0, name: "task -1", enter: false);
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
tester.assertChecklistTaskInEditor(
index: 0,
name: "task -1",
isChecked: false,
);
tester.assertPhantomChecklistItemAtIndex(index: 1);
await tester.enterText(find.byType(PhantomChecklistItem), 'task 0');
await tester.pumpAndSettle();
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle(const Duration(milliseconds: 500));
tester.assertPhantomChecklistItemAtIndex(index: 2);
await tester.checkChecklistTask(index: 1);
expect(find.byType(PhantomChecklistItem), findsNothing);
expect(find.byType(ChecklistItem), findsNWidgets(3));
tester.assertChecklistTaskInEditor(
index: 0,
name: "task -1",
isChecked: false,
);
tester.assertChecklistTaskInEditor(
index: 1,
name: "task 0",
isChecked: true,
);
tester.assertChecklistTaskInEditor(
index: 2,
name: "task 2",
isChecked: false,
);
await tester.tapButton(
find.descendant(
of: find.byType(ProgressAndHideCompleteButton),
matching: find.byType(FlowyIconButton),
),
);
expect(find.byType(ChecklistItem), findsNWidgets(2));
tester.assertChecklistTaskInEditor(
index: 0,
name: "task -1",
isChecked: false,
);
tester.assertChecklistTaskInEditor(
index: 1,
name: "task 2",
isChecked: false,
);
await tester.renameChecklistTask(index: 1, name: "task 3", enter: false);
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
await tester.renameChecklistTask(index: 0, name: "task 1", enter: false);
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
await tester.enterText(find.byType(PhantomChecklistItem), 'task 2');
await tester.pumpAndSettle();
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle(const Duration(milliseconds: 500));
tester.assertChecklistTaskInEditor(
index: 0,
name: "task 1",
isChecked: false,
);
tester.assertChecklistTaskInEditor(
index: 1,
name: "task 2",
isChecked: false,
);
tester.assertChecklistTaskInEditor(
index: 2,
name: "task 3",
isChecked: false,
);
tester.assertPhantomChecklistItemAtIndex(index: 2);
await tester.checkChecklistTask(index: 1);
expect(find.byType(ChecklistItem), findsNWidgets(2));
});
});
}
@@ -0,0 +1,84 @@
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/widgets/inline_actions_handler.dart';
import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/util.dart';
import 'document_inline_page_reference_test.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Document deletion', () {
testWidgets('Trash breadcrumb', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// This test shares behavior with the inline page reference test, thus
// we utilize the same helper functions there.
final name = await createDocumentToReference(tester);
await tester.editor.tapLineOfEditorAt(0);
await tester.pumpAndSettle();
await triggerReferenceDocumentBySlashMenu(tester);
// Search for prefix of document
await enterDocumentText(tester);
// Select result
final optionFinder = find.descendant(
of: find.byType(InlineActionsHandler),
matching: find.text(name),
);
await tester.tap(optionFinder);
await tester.pumpAndSettle();
final mentionBlock = find.byType(MentionPageBlock);
expect(mentionBlock, findsOneWidget);
// Delete the page
await tester.hoverOnPageName(
name,
onHover: () async => tester.tapDeletePageButton(),
);
await tester.pumpAndSettle();
// Navigate to the deleted page from the inline mention
await tester.tap(mentionBlock);
await tester.pumpUntilFound(find.byType(TrashBreadcrumb));
expect(find.byType(TrashBreadcrumb), findsOneWidget);
// Navigate using the trash breadcrumb
await tester.tap(
find.descendant(
of: find.byType(TrashBreadcrumb),
matching: find.text(
LocaleKeys.trash_text.tr(),
),
),
);
await tester.pumpUntilFound(find.text(LocaleKeys.trash_restoreAll.tr()));
// Restore all
await tester.tap(find.text(LocaleKeys.trash_restoreAll.tr()));
await tester.pumpAndSettle();
await tester.tap(find.text(LocaleKeys.trash_restore.tr()));
await tester.pumpAndSettle();
// Navigate back to the document
await tester.openPage('Getting started');
await tester.pumpAndSettle();
await tester.tap(mentionBlock);
await tester.pumpAndSettle();
expect(find.byType(TrashBreadcrumb), findsNothing);
});
});
}
@@ -0,0 +1,378 @@
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_page_block.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.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';
import '../../shared/keyboard.dart';
import '../../shared/util.dart';
import '../board/board_hide_groups_test.dart';
const _firstDocName = "Inline Sub Page Mention";
const _createdPageName = "hi world";
// Test cases that are covered in this file:
// - [x] Insert sub page mention from action menu (+)
// - [x] Delete sub page mention from editor
// - [x] Delete page from sidebar
// - [x] Delete page from sidebar and then trash
// - [x] Undo delete sub page mention
// - [x] Cut+paste in same document
// - [x] Cut+paste in different document
// - [x] Cut+paste in same document and then paste again in same document
// - [x] Turn paragraph with sub page mention into a heading
// - [x] Turn heading with sub page mention into a paragraph
// - [x] Duplicate a Block containing two sub page mentions
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('document inline sub-page mention tests:', () {
testWidgets('Insert (& delete) a sub page mention from action menu',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createOpenRenameDocumentUnderParent(name: _firstDocName);
await tester.insertInlineSubPageFromPlusMenu();
await tester.expandOrCollapsePage(
pageName: _firstDocName,
layout: ViewLayoutPB.Document,
);
await tester.pumpAndSettle();
expect(find.text(_createdPageName), findsNWidgets(2));
expect(find.byType(MentionSubPageBlock), findsOneWidget);
expect(find.byFlowySvg(FlowySvgs.child_page_s), findsOneWidget);
// Delete from editor
await tester.editor.updateSelection(
Selection.collapsed(Position(path: [0], offset: 1)),
);
await tester.simulateKeyEvent(LogicalKeyboardKey.backspace);
await tester.pumpAndSettle();
expect(find.text(_createdPageName), findsNothing);
expect(find.byType(MentionSubPageBlock), findsNothing);
// Undo
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyZ,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.text(_createdPageName), findsNWidgets(2));
expect(find.byType(MentionSubPageBlock), findsOneWidget);
// Move to trash (delete from sidebar)
await tester.rightClickOnPageName(_createdPageName);
await tester.tapButtonWithName(ViewMoreActionType.delete.name);
await tester.pumpAndSettle();
expect(find.text(_createdPageName), findsOneWidget);
expect(find.byType(MentionSubPageBlock), findsOneWidget);
expect(
find.text(LocaleKeys.document_mention_trashHint.tr()),
findsOneWidget,
);
// Delete from trash
await tester.tapTrashButton();
await tester.pumpAndSettle();
await tester.tap(find.text(LocaleKeys.trash_deleteAll.tr()));
await tester.pumpAndSettle();
await tester.tap(find.text(LocaleKeys.button_delete.tr()));
await tester.pumpAndSettle();
await tester.openPage(_firstDocName);
await tester.pumpAndSettle();
expect(find.text(_createdPageName), findsNothing);
expect(find.byType(MentionSubPageBlock), findsOneWidget);
expect(
find.text(LocaleKeys.document_mention_deletedPage.tr()),
findsOneWidget,
);
});
testWidgets(
'Cut+paste in same document and cut+paste in different document',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createOpenRenameDocumentUnderParent(name: _firstDocName);
await tester.insertInlineSubPageFromPlusMenu();
await tester.expandOrCollapsePage(
pageName: _firstDocName,
layout: ViewLayoutPB.Document,
);
await tester.pumpAndSettle();
expect(find.text(_createdPageName), findsNWidgets(2));
expect(find.byType(MentionSubPageBlock), findsOneWidget);
expect(find.byFlowySvg(FlowySvgs.child_page_s), findsOneWidget);
// Cut from editor
await tester.editor.updateSelection(
Selection.collapsed(Position(path: [0], offset: 1)),
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyX,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.text(_createdPageName), findsNothing);
expect(find.byType(MentionSubPageBlock), findsNothing);
// Paste in same document
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.text(_createdPageName), findsNWidgets(2));
expect(find.byType(MentionSubPageBlock), findsOneWidget);
expect(find.byFlowySvg(FlowySvgs.child_page_s), findsOneWidget);
// Cut again
await tester.editor.updateSelection(
Selection.collapsed(Position(path: [0], offset: 1)),
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyX,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
// Create another document
const anotherDocName = "Another Document";
await tester.createOpenRenameDocumentUnderParent(
name: anotherDocName,
);
await tester.pumpAndSettle();
expect(find.text(_createdPageName), findsNothing);
expect(find.byType(MentionSubPageBlock), findsNothing);
await tester.editor.tapLineOfEditorAt(0);
await tester.pumpAndSettle();
// Paste in document
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpUntilFound(find.byType(MentionSubPageBlock));
await tester.pumpAndSettle();
expect(find.text(_createdPageName), findsOneWidget);
await tester.expandOrCollapsePage(
pageName: anotherDocName,
layout: ViewLayoutPB.Document,
);
await tester.pumpAndSettle();
expect(find.text(_createdPageName), findsNWidgets(2));
expect(find.byFlowySvg(FlowySvgs.child_page_s), findsOneWidget);
});
testWidgets(
'Cut+paste in same docuemnt and then paste again in same document',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createOpenRenameDocumentUnderParent(name: _firstDocName);
await tester.insertInlineSubPageFromPlusMenu();
await tester.expandOrCollapsePage(
pageName: _firstDocName,
layout: ViewLayoutPB.Document,
);
await tester.pumpAndSettle();
expect(find.text(_createdPageName), findsNWidgets(2));
expect(find.byType(MentionSubPageBlock), findsOneWidget);
expect(find.byFlowySvg(FlowySvgs.child_page_s), findsOneWidget);
// Cut from editor
await tester.editor.updateSelection(
Selection.collapsed(Position(path: [0], offset: 1)),
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyX,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.text(_createdPageName), findsNothing);
expect(find.byType(MentionSubPageBlock), findsNothing);
// Paste in same document
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.text(_createdPageName), findsNWidgets(2));
expect(find.byType(MentionSubPageBlock), findsOneWidget);
expect(find.byFlowySvg(FlowySvgs.child_page_s), findsOneWidget);
// Paste again
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle(const Duration(seconds: 2));
expect(find.text(_createdPageName), findsNWidgets(2));
expect(find.byType(MentionSubPageBlock), findsNWidgets(2));
expect(find.byFlowySvg(FlowySvgs.child_page_s), findsNWidgets(2));
expect(find.text('$_createdPageName (copy)'), findsNWidgets(2));
});
testWidgets('Turn into w/ sub page mentions', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createOpenRenameDocumentUnderParent(name: _firstDocName);
await tester.insertInlineSubPageFromPlusMenu();
await tester.expandOrCollapsePage(
pageName: _firstDocName,
layout: ViewLayoutPB.Document,
);
await tester.pumpAndSettle();
expect(find.text(_createdPageName), findsNWidgets(2));
expect(find.byType(MentionSubPageBlock), findsOneWidget);
expect(find.byFlowySvg(FlowySvgs.child_page_s), findsOneWidget);
final headingText = LocaleKeys.document_slashMenu_name_heading1.tr();
final paragraphText = LocaleKeys.document_slashMenu_name_text.tr();
// Turn into heading
await tester.editor.openTurnIntoMenu([0]);
await tester.tapButton(find.findTextInFlowyText(headingText));
await tester.pumpAndSettle();
expect(find.text(_createdPageName), findsNWidgets(2));
expect(find.byType(MentionSubPageBlock), findsOneWidget);
expect(find.byFlowySvg(FlowySvgs.child_page_s), findsOneWidget);
// Turn into paragraph
await tester.editor.openTurnIntoMenu([0]);
await tester.tapButton(find.findTextInFlowyText(paragraphText));
await tester.pumpAndSettle();
expect(find.text(_createdPageName), findsNWidgets(2));
expect(find.byType(MentionSubPageBlock), findsOneWidget);
expect(find.byFlowySvg(FlowySvgs.child_page_s), findsOneWidget);
});
testWidgets('Duplicate a block containing two sub page mentions',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createOpenRenameDocumentUnderParent(name: _firstDocName);
await tester.insertInlineSubPageFromPlusMenu();
// Copy paste it
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();
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.text(_createdPageName), findsOneWidget);
expect(find.text("$_createdPageName (copy)"), findsOneWidget);
expect(find.byType(MentionSubPageBlock), findsNWidgets(2));
expect(find.byFlowySvg(FlowySvgs.child_page_s), findsNWidgets(2));
// Duplicate node from block action menu
await tester.editor.hoverAndClickOptionMenuButton([0]);
await tester.tapButtonWithName(LocaleKeys.button_duplicate.tr());
await tester.pumpAndSettle();
expect(find.text(_createdPageName), findsOneWidget);
expect(find.text("$_createdPageName (copy)"), findsNWidgets(2));
expect(find.text("$_createdPageName (copy) (copy)"), findsOneWidget);
});
});
}
extension _InlineSubPageTestHelper on WidgetTester {
Future<void> insertInlineSubPageFromPlusMenu() async {
await editor.tapLineOfEditorAt(0);
await editor.showPlusMenu();
// Workaround to allow typing a document name
await FlowyTestKeyboard.simulateKeyDownEvent(
tester: this,
withKeyUp: true,
[
LogicalKeyboardKey.keyH,
LogicalKeyboardKey.keyI,
LogicalKeyboardKey.space,
LogicalKeyboardKey.keyW,
LogicalKeyboardKey.keyO,
LogicalKeyboardKey.keyR,
LogicalKeyboardKey.keyL,
LogicalKeyboardKey.keyD,
],
);
await FlowyTestKeyboard.simulateKeyDownEvent(
tester: this,
withKeyUp: true,
[LogicalKeyboardKey.enter],
);
await pumpUntilFound(find.byType(MentionSubPageBlock));
}
}
@@ -1,3 +1,8 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -7,9 +12,23 @@ void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
// +, ... button beside the block component.
group('document with option action button', () {
testWidgets(
'click + to add a block after current selection, and click + and option key to add a block before current selection',
group('block option action:', () {
Future<void> turnIntoBlock(
WidgetTester tester,
Path path, {
required String menuText,
required String afterType,
}) async {
await tester.editor.openTurnIntoMenu(path);
await tester.tapButton(
find.findTextInFlowyText(menuText),
);
final node = tester.editor.getCurrentEditorState().getNodeAtPath(path);
expect(node?.type, afterType);
}
testWidgets('''click + to add a block after current selection,
and click + and option key to add a block before current selection''',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
@@ -40,5 +59,122 @@ void main() {
expect(editorState.getNodeAtPath([0])?.delta?.toPlainText(), isEmpty);
expect(editorState.getNodeAtPath([2])?.delta?.toPlainText(), isEmpty);
});
testWidgets('turn into - single line', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
const name = 'Test Document';
await tester.createNewPageWithNameUnderParent(name: name);
await tester.openPage(name);
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText('turn into');
// click the block option button to convert it to another blocks
final values = {
LocaleKeys.document_slashMenu_name_heading1.tr(): HeadingBlockKeys.type,
LocaleKeys.document_slashMenu_name_heading2.tr(): HeadingBlockKeys.type,
LocaleKeys.document_slashMenu_name_heading3.tr(): HeadingBlockKeys.type,
LocaleKeys.document_slashMenu_name_bulletedList.tr():
BulletedListBlockKeys.type,
LocaleKeys.document_slashMenu_name_numberedList.tr():
NumberedListBlockKeys.type,
LocaleKeys.document_slashMenu_name_quote.tr(): QuoteBlockKeys.type,
LocaleKeys.document_slashMenu_name_todoList.tr():
TodoListBlockKeys.type,
LocaleKeys.document_slashMenu_name_callout.tr(): CalloutBlockKeys.type,
LocaleKeys.document_slashMenu_name_text.tr(): ParagraphBlockKeys.type,
};
for (final value in values.entries) {
final menuText = value.key;
final afterType = value.value;
await turnIntoBlock(
tester,
[0],
menuText: menuText,
afterType: afterType,
);
}
});
testWidgets('turn into - multi lines', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
const name = 'Test Document';
await tester.createNewPageWithNameUnderParent(name: name);
await tester.openPage(name);
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText('turn into 1');
await tester.ime.insertCharacter('\n');
await tester.ime.insertText('turn into 2');
// click the block option button to convert it to another blocks
final values = {
LocaleKeys.document_slashMenu_name_heading1.tr(): HeadingBlockKeys.type,
LocaleKeys.document_slashMenu_name_heading2.tr(): HeadingBlockKeys.type,
LocaleKeys.document_slashMenu_name_heading3.tr(): HeadingBlockKeys.type,
LocaleKeys.document_slashMenu_name_bulletedList.tr():
BulletedListBlockKeys.type,
LocaleKeys.document_slashMenu_name_numberedList.tr():
NumberedListBlockKeys.type,
LocaleKeys.document_slashMenu_name_quote.tr(): QuoteBlockKeys.type,
LocaleKeys.document_slashMenu_name_todoList.tr():
TodoListBlockKeys.type,
LocaleKeys.document_slashMenu_name_callout.tr(): CalloutBlockKeys.type,
LocaleKeys.document_slashMenu_name_text.tr(): ParagraphBlockKeys.type,
};
for (final value in values.entries) {
final editorState = tester.editor.getCurrentEditorState();
editorState.selection = Selection(
start: Position(path: [0]),
end: Position(path: [1], offset: 2),
);
final menuText = value.key;
final afterType = value.value;
await turnIntoBlock(
tester,
[0],
menuText: menuText,
afterType: afterType,
);
}
});
testWidgets(
'selecting the parent should deselect all the child nodes as well',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
const name = 'Test Document';
await tester.createNewPageWithNameUnderParent(name: name);
await tester.openPage(name);
// create a nested list
// Item 1
// Nested Item 1
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText('Item 1');
await tester.ime.insertCharacter('\n');
await tester.simulateKeyEvent(LogicalKeyboardKey.tab);
await tester.ime.insertText('Nested Item 1');
// select the 'Nested Item 1' and then tap the option button of the 'Item 1'
final editorState = tester.editor.getCurrentEditorState();
final selection = Selection.collapsed(
Position(path: [0, 0], offset: 1),
);
editorState.selection = selection;
await tester.pumpAndSettle();
expect(editorState.selection, selection);
await tester.editor.hoverAndClickOptionMenuButton([0]);
expect(editorState.selection, Selection.collapsed(Position(path: [0])));
},
);
});
}
@@ -8,7 +8,6 @@ import 'document_create_and_delete_test.dart'
import 'document_inline_page_reference_test.dart'
as document_inline_page_reference_test;
import 'document_more_actions_test.dart' as document_more_actions_test;
import 'document_option_action_test.dart' as document_option_action_test;
import 'document_shortcuts_test.dart' as document_shortcuts_test;
import 'document_text_direction_test.dart' as document_text_direction_test;
import 'document_with_cover_image_test.dart' as document_with_cover_image_test;
@@ -40,13 +39,11 @@ void main() {
document_codeblock_paste_test.main();
document_alignment_test.main();
document_text_direction_test.main();
document_option_action_test.main();
document_with_image_block_test.main();
document_with_multi_image_block_test.main();
document_inline_page_reference_test.main();
document_more_actions_test.main();
document_with_file_test.main();
document_shortcuts_test.main();
// Don't add new tests here. Add them to document_test_runner_2.dart
}
@@ -1,7 +1,12 @@
import 'package:integration_test/integration_test.dart';
import 'document_app_lifecycle_test.dart' as document_app_lifecycle_test;
import 'document_deletion_test.dart' as document_deletion_test;
import 'document_option_action_test.dart' as document_option_action_test;
import 'document_title_test.dart' as document_title_test;
import 'document_with_date_reminder_test.dart'
as document_with_date_reminder_test;
import 'document_inline_sub_page_test.dart' as document_inline_sub_page_test;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
@@ -11,4 +16,8 @@ void main() {
// Disable subPage test temporarily, enable it in version 0.7.2
// document_sub_page_test.main();
document_app_lifecycle_test.main();
document_with_date_reminder_test.main();
document_deletion_test.main();
document_option_action_test.main();
document_inline_sub_page_test.main();
}
@@ -0,0 +1,125 @@
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/workspace/application/settings/date_time/date_format_ext.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/util.dart';
import '../board/board_hide_groups_test.dart';
void main() {
setUp(() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
TestWidgetsFlutterBinding.ensureInitialized();
});
group('date or reminder block in document', () {
testWidgets("insert date with time block", (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a new document
await tester.createNewPageWithNameUnderParent(
name: 'Date with time test',
);
// 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(),
offset: 100,
);
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);
// tap on date field
await tester.tap(find.byType(MentionDateBlock));
await tester.pumpAndSettle();
// tap the toggle of include time
await tester.tap(find.byType(Toggle));
await tester.pumpAndSettle();
// add time 11:12
final currentTime = DateFormat('HH:mm').format(DateTime.now());
final textField = find.byWidgetPredicate(
(widget) =>
widget is TextField && widget.controller!.text == currentTime,
);
await tester.enterText(textField, "11:12");
await tester.editor.tapLineOfEditorAt(0);
await tester.pumpAndSettle();
// we will get field with current date and 11:12 as time
expect(find.byType(MentionDateBlock), findsOneWidget);
expect(find.text('@$formattedDate 11:12'), findsOneWidget);
});
testWidgets("insert date with reminder block", (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a new document
await tester.createNewPageWithNameUnderParent(
name: 'Date with reminder test',
);
// 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(),
offset: 100,
);
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);
// tap on date field
await tester.tap(find.byType(MentionDateBlock));
await tester.pumpAndSettle();
// tap reminder and set reminder to 1 day before
await tester.tap(find.text(LocaleKeys.datePicker_reminderLabel.tr()));
await tester.pumpAndSettle();
await tester.tap(
find.textContaining(
LocaleKeys.datePicker_reminderOptions_oneDayBefore.tr(),
),
);
await tester.editor.tapLineOfEditorAt(0);
await tester.pumpAndSettle();
// we will get field with current date reminder_clock.svg icon
expect(find.byType(MentionDateBlock), findsOneWidget);
expect(find.text('@$formattedDate'), findsOneWidget);
expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsOneWidget);
});
});
}
@@ -1,5 +1,5 @@
import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:appflowy/shared/flowy_error_page.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -92,7 +92,7 @@ void main() {
);
expect(finder, findsOneWidget);
await tester.tapButton(finder);
expect(find.byType(AppFlowyErrorPage), findsOneWidget);
expect(find.byType(GridPage), findsOneWidget);
});
});
}
@@ -1,9 +1,6 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -182,13 +179,9 @@ Future<void> hoverAndClickDepthOptionAction(
List<int> path,
int level,
) async {
await tester.editor.hoverAndClickOptionMenuButton([3]);
await tester.tap(find.byType(AppFlowyPopover).hitTestable().last);
await tester.pumpAndSettle();
// Find a total of 4 HoverButtons under the [BlockOptionButton],
// in addition to 3 HoverButtons under the [DepthOptionAction] - (child of BlockOptionButton)
await tester.tap(find.byType(HoverButton).hitTestable().at(3 + level));
await tester.editor.openDepthMenu(path);
final type = OptionDepthType.fromLevel(level);
await tester.tapButton(find.findTextInFlowyText(type.description));
await tester.pumpAndSettle();
}
@@ -189,15 +189,46 @@ void main() {
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
await tester.hoverOnFirstRowOfGrid(() async {
// Open the row menu and then click the delete
// Open the row menu and click the delete button
await tester.tapRowMenuButtonInGrid();
await tester.pumpAndSettle();
await tester.tapDeleteOnRowMenu();
await tester.pumpAndSettle();
// 3 initial rows - 1 deleted
tester.assertNumberOfRowsInGridPage(2);
});
expect(find.byType(ConfirmPopup), findsOneWidget);
await tester.tapButtonWithName(LocaleKeys.button_delete.tr());
tester.assertNumberOfRowsInGridPage(2);
});
testWidgets('delete row in two views', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
await tester.renameLinkedView(
tester.findTabBarLinkViewByViewLayout(ViewLayoutPB.Grid),
'grid 1',
);
tester.assertNumberOfRowsInGridPage(3);
await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Grid);
await tester.renameLinkedView(
tester.findTabBarLinkViewByViewLayout(ViewLayoutPB.Grid).at(1),
'grid 2',
);
tester.assertNumberOfRowsInGridPage(3);
await tester.hoverOnFirstRowOfGrid(() async {
// Open the row menu and click the delete button
await tester.tapRowMenuButtonInGrid();
await tester.tapDeleteOnRowMenu();
});
expect(find.byType(ConfirmPopup), findsOneWidget);
await tester.tapButtonWithName(LocaleKeys.button_delete.tr());
// 3 initial rows - 1 deleted
tester.assertNumberOfRowsInGridPage(2);
await tester.tapTabBarLinkedViewByViewName('grid 1');
tester.assertNumberOfRowsInGridPage(2);
});
});
}
@@ -11,7 +11,7 @@ void main() {
const emoji = '😁';
group('Icon', () {
group('Icon:', () {
testWidgets('Update page icon in sidebar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
@@ -52,6 +52,7 @@ void main() {
if (value == ViewLayoutPB.Chat) {
continue;
}
await tester.createNewPageWithNameUnderParent(
name: value.name,
parentName: gettingStarted,
@@ -198,7 +198,7 @@ void main() {
layout: ViewLayoutPB.Grid,
onHover: () async {
expect(find.byType(ViewAddButton), findsNothing);
expect(find.byType(ViewMoreActionButton), findsOneWidget);
expect(find.byType(ViewMoreActionPopover), findsOneWidget);
},
);
});
@@ -3,6 +3,7 @@ import 'package:integration_test/integration_test.dart';
import 'sidebar_favorites_test.dart' as sidebar_favorite_test;
import 'sidebar_icon_test.dart' as sidebar_icon_test;
import 'sidebar_test.dart' as sidebar_test;
import 'sidebar_view_item_test.dart' as sidebar_view_item_test;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
@@ -12,4 +13,5 @@ void main() {
// sidebar_expanded_test.main();
sidebar_favorite_test.main();
sidebar_icon_test.main();
sidebar_view_item_test.main();
}
@@ -0,0 +1,39 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/emoji.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Sidebar view item tests', () {
testWidgets('Access view item context menu by right click', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// Right click on the view item and change icon
await tester.tap(find.byType(ViewItem), buttons: kSecondaryButton);
await tester.pumpAndSettle();
// Change icon
final changeIconButton =
find.text(LocaleKeys.document_plugins_cover_changeIcon.tr());
await tester.tapButton(changeIconButton);
await tester.pumpUntilFound(find.byType(FlowyEmojiPicker));
const emoji = '😁';
await tester.tapEmoji(emoji);
await tester.pumpAndSettle();
tester.expectViewHasIcon(gettingStarted, ViewLayoutPB.Document, emoji);
});
});
}
@@ -1,5 +1,9 @@
import 'document/publish_test.dart' as publish_test;
import 'document/share_link_test.dart' as share_link_test;
import 'workspace/workspace_operations_test.dart' as workspace_operations_test;
Future<void> main() async {
workspace_operations_test.main();
share_link_test.main();
publish_test.main();
}
@@ -0,0 +1,89 @@
// ignore_for_file: unused_import
import 'dart:io';
import 'dart:math';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart';
import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart';
import 'package:appflowy/mobile/presentation/home/home.dart';
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_layout.dart';
import 'package:appflowy/shared/patterns/common_patterns.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
import '../../../shared/constants.dart';
import '../../../shared/dir.dart';
import '../../../shared/mock/mock_file_picker.dart';
import '../../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('publish:', () {
testWidgets('publish document', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
await tester.openPage(Constants.gettingStartedPageName);
await tester.editor.openMoreActionMenuOnMobile();
// click the publish button
await tester.editor.clickMoreActionItemOnMobile(
LocaleKeys.shareAction_publish.tr(),
);
// wait the notification dismiss
final publishSuccessText = find.findTextInFlowyText(
LocaleKeys.publish_publishSuccessfully.tr(),
);
expect(publishSuccessText, findsOneWidget);
await tester.pumpUntilNotFound(publishSuccessText);
// open the menu again, to check the publish status
await tester.editor.openMoreActionMenuOnMobile();
// expect to see the unpublish button and the visit site button
expect(
find.text(LocaleKeys.shareAction_unPublish.tr()),
findsOneWidget,
);
expect(
find.text(LocaleKeys.shareAction_visitSite.tr()),
findsOneWidget,
);
// unpublish the document
await tester.editor.clickMoreActionItemOnMobile(
LocaleKeys.shareAction_unPublish.tr(),
);
final unPublishSuccessText = find.findTextInFlowyText(
LocaleKeys.publish_unpublishSuccessfully.tr(),
);
expect(unPublishSuccessText, findsOneWidget);
await tester.pumpUntilNotFound(unPublishSuccessText);
});
});
}
@@ -0,0 +1,69 @@
// ignore_for_file: unused_import
import 'dart:io';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart';
import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart';
import 'package:appflowy/mobile/presentation/home/home.dart';
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_layout.dart';
import 'package:appflowy/shared/patterns/common_patterns.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
import '../../../shared/constants.dart';
import '../../../shared/dir.dart';
import '../../../shared/mock/mock_file_picker.dart';
import '../../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('share link:', () {
testWidgets('copy share link and paste it on doc', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
// open the getting started page and paste the link
await tester.openPage(Constants.gettingStartedPageName);
// open the more action menu
await tester.editor.openMoreActionMenuOnMobile();
// click the share link item
await tester.editor.clickMoreActionItemOnMobile(
LocaleKeys.shareAction_copyLink.tr(),
);
// check the clipboard
final content = await Clipboard.getData(Clipboard.kTextPlain);
expect(
content?.text,
matches(appflowySharePageLinkPattern),
);
});
});
}
@@ -57,10 +57,11 @@ void main() {
.single
.widget as AppFlowyEditorPage;
return editorPage.styleCustomizer
.style()
.textStyleConfiguration
.text
.height!;
.style()
.textStyleConfiguration
.text
.height ??
PageStyleLineHeightLayout.normal.lineHeight;
}
testWidgets('change font size in page style settings', (tester) async {
@@ -126,7 +127,6 @@ void main() {
// click done button to exit the page style settings
await tester.tapButton(find.byType(BottomSheetDoneButton).first);
await tester.tapButton(find.byType(BottomSheetDoneButton).first);
// check the cover
final builtInCover = find.descendant(
@@ -3,10 +3,11 @@
import 'dart:io';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/editor/mobile_editor_screen.dart';
import 'package:appflowy/mobile/presentation/home/home.dart';
import 'package:appflowy/mobile/presentation/home/section_folder/mobile_home_section_folder_header.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/plugins/document/document_page.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
@@ -34,13 +35,12 @@ void main() {
cloudType: AuthenticatorType.local,
);
// click the anonymousSignInButton
final anonymousSignInButton = find.byType(SignInAnonymousButtonV2);
expect(anonymousSignInButton, findsOneWidget);
await tester.tapButton(anonymousSignInButton);
// tap the create page button
final createPageButton = find.byKey(mobileCreateNewPageButtonKey);
final createPageButton = find.byWidgetPredicate(
(widget) =>
widget is FlowySvg &&
widget.svg.path == FlowySvgs.m_home_unselected_m.path,
);
await tester.tapButton(createPageButton);
expect(find.byType(MobileDocumentScreen), findsOneWidget);
});
@@ -29,11 +29,6 @@ void main() {
testWidgets('anon user and then sign in', (tester) async {
await tester.initializeAppFlowy();
// click the anonymousSignInButton
final anonymousSignInButton = find.byType(SignInAnonymousButtonV2);
expect(anonymousSignInButton, findsOneWidget);
await tester.tapButton(anonymousSignInButton);
// expect to see the home page
expect(find.byType(MobileHomeScreen), findsOneWidget);
});
@@ -1,3 +1,4 @@
import 'package:appflowy_backend/log.dart';
import 'package:integration_test/integration_test.dart';
import 'mobile/document/page_style_test.dart' as page_style_test;
@@ -5,7 +6,9 @@ import 'mobile/home_page/create_new_page_test.dart' as create_new_page_test;
import 'mobile/sign_in/anonymous_sign_in_test.dart' as anonymous_sign_in_test;
Future<void> runIntegrationOnMobile() async {
Log.shared.disableLog = true;
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
anonymous_sign_in_test.main();
create_new_page_test.main();
page_style_test.main();
@@ -17,6 +17,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:universal_platform/universal_platform.dart';
class FlowyTestContext {
FlowyTestContext({required this.applicationDataDirectory});
@@ -105,7 +106,7 @@ extension AppFlowyTestBase on WidgetTester {
}
Future<void> waitUntilSignInPageShow() async {
if (isAuthEnabled) {
if (isAuthEnabled || UniversalPlatform.isMobile) {
final finder = find.byType(SignInAnonymousButtonV2);
await pumpUntilFound(finder, timeout: const Duration(seconds: 30));
expect(finder, findsOneWidget);
@@ -158,17 +159,12 @@ extension AppFlowyTestBase on WidgetTester {
Future<void> tapButton(
Finder finder, {
int? pointer,
int buttons = kPrimaryButton,
bool warnIfMissed = false,
int milliseconds = 500,
bool pumpAndSettle = true,
}) async {
await tap(
finder,
buttons: buttons,
warnIfMissed: warnIfMissed,
);
await tap(finder, buttons: buttons, warnIfMissed: warnIfMissed);
if (pumpAndSettle) {
await this.pumpAndSettle(
@@ -8,6 +8,7 @@ import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart';
import 'package:appflowy/plugins/shared/share/share_button.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/shared/text_field/text_filed_with_metric_lines.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/presentation/screens/screens.dart';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
@@ -42,6 +43,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../desktop/board/board_hide_groups_test.dart';
import 'emoji.dart';
import 'util.dart';
@@ -185,6 +187,16 @@ extension CommonOperations on WidgetTester {
}
}
/// Right click on the page name.
Future<void> rightClickOnPageName(
String name, {
ViewLayoutPB layout = ViewLayoutPB.Document,
}) async {
final page = findPageName(name, layout: layout);
await tap(page, buttons: kSecondaryMouseButton);
await pumpAndSettle();
}
/// open the page with given name.
Future<void> openPage(
String name, {
@@ -199,8 +211,11 @@ extension CommonOperations on WidgetTester {
///
/// Must call [hoverOnPageName] first.
Future<void> tapPageOptionButton() async {
final optionButton = find.byType(ViewMoreActionButton);
await tapButton(optionButton);
final optionButton = find.descendant(
of: find.byType(ViewMoreActionPopover),
matching: find.byFlowySvg(FlowySvgs.workspace_three_dots_s),
);
await tapButton(optionButton, warnIfMissed: true);
}
/// Tap the delete page button.
@@ -279,12 +294,14 @@ extension CommonOperations on WidgetTester {
/// Tap the delete permanently button.
///
/// the restore button will show after the current page is deleted.
/// the delete permanently button will show after the current page is deleted.
Future<void> tapDeletePermanentlyButton() async {
final restoreButton = find.textContaining(
final deleteButton = find.textContaining(
LocaleKeys.deletePagePrompt_deletePermanent.tr(),
);
await tapButton(restoreButton);
await tapButton(deleteButton);
await tap(find.text(LocaleKeys.button_delete.tr()));
await pumpAndSettle();
}
/// Tap the share button above the document page.
@@ -348,6 +365,32 @@ extension CommonOperations on WidgetTester {
}
}
Future<void> createOpenRenameDocumentUnderParent({
required String name,
String? parentName,
}) async {
// create a new page
await tapAddViewButton(name: parentName ?? gettingStarted);
await tapButtonWithName(ViewLayoutPB.Document.menuName);
final settingsOrFailure = await getIt<KeyValueStorage>().getWithFormat(
KVKeys.showRenameDialogWhenCreatingNewFile,
(value) => bool.parse(value),
);
final showRenameDialog = settingsOrFailure ?? false;
if (showRenameDialog) {
await tapOKButton();
}
await pumpAndSettle();
// open the page after created
await openPage(ViewLayoutPB.Document.defaultName);
await pumpAndSettle();
// Enter new name in the document title
await enterText(find.byType(TextFieldWithMetricLines), name);
await pumpAndSettle();
}
/// Create a new page in the space
Future<void> createNewPageInSpace({
required String spaceName,
@@ -1,6 +1,8 @@
import 'dart:io';
import 'package:appflowy/plugins/database/application/field/filter_entities.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/condition_list.dart';
import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:flutter/gestures.dart';
@@ -411,8 +413,11 @@ extension AppFlowyDatabaseTest on WidgetTester {
}
Future<void> selectOption({required String name}) async {
final option = find.byWidgetPredicate(
(widget) => widget is SelectOptionTagCell && widget.option.name == name,
final option = find.descendant(
of: find.byType(SelectOptionCellEditor),
matching: find.byWidgetPredicate(
(widget) => widget is SelectOptionTagCell && widget.option.name == name,
),
);
await tapButton(option);
@@ -504,6 +509,7 @@ extension AppFlowyDatabaseTest on WidgetTester {
Future<void> renameChecklistTask({
required int index,
required String name,
bool enter = true,
}) async {
final textField = find
.descendant(
@@ -513,7 +519,9 @@ extension AppFlowyDatabaseTest on WidgetTester {
.at(index);
await enterText(textField, name);
await testTextInput.receiveAction(TextInputAction.done);
if (enter) {
await testTextInput.receiveAction(TextInputAction.done);
}
await pumpAndSettle();
}
@@ -531,14 +539,32 @@ extension AppFlowyDatabaseTest on WidgetTester {
Future<void> deleteChecklistTask({required int index}) async {
final task = find.byType(ChecklistItem).at(index);
await startGesture(getCenter(task), kind: PointerDeviceKind.mouse);
await pumpAndSettle();
final button = find.byWidgetPredicate(
(widget) => widget is FlowySvg && widget.svg == FlowySvgs.delete_s,
await hoverOnWidget(
task,
onHover: () async {
final button = find.byWidgetPredicate(
(widget) => widget is FlowySvg && widget.svg == FlowySvgs.delete_s,
);
await tapButton(button);
},
);
}
await tapButton(button);
void assertPhantomChecklistItemAtIndex({required int index}) {
final paddings = find.descendant(
of: find.descendant(
of: find.byType(ChecklistRowDetailCell),
matching: find.byType(ReorderableListView),
),
matching: find.byWidgetPredicate(
(widget) =>
widget is Padding &&
(widget.child is ChecklistItem ||
widget.child is PhantomChecklistItem),
),
);
final phantom = widget<Padding>(paddings.at(index)).child!;
expect(phantom is PhantomChecklistItem, true);
}
Future<void> openFirstRowDetailPage() async {
@@ -1157,10 +1183,6 @@ extension AppFlowyDatabaseTest on WidgetTester {
await tapButton(find.byType(ChecklistFilterConditionList));
}
Future<void> tapDateFilterButtonInGrid() async {
await tapButton(find.byType(DateFilterConditionList));
}
/// The [SelectOptionFilterList] must show up first.
Future<void> tapOptionFilterWithName(String name) async {
final findCell = find.descendant(
@@ -1194,7 +1216,36 @@ extension AppFlowyDatabaseTest on WidgetTester {
await tapButton(button);
}
Future<void> tapDateFilterCondition(DateTimeFilterCondition condition) async {
Future<void> changeTextFilterCondition(
TextFilterConditionPB condition,
) async {
await tapButton(find.byType(TextFilterConditionList));
final button = find.descendant(
of: find.byType(HoverButton),
matching: find.text(
condition.filterName,
),
);
await tapButton(button);
}
Future<void> changeSelectFilterCondition(
SelectOptionFilterConditionPB condition,
) async {
await tapButton(find.byType(SelectOptionFilterConditionList));
final button = find.descendant(
of: find.byType(HoverButton),
matching: find.text(condition.i18n),
);
await tapButton(button);
}
Future<void> changeDateFilterCondition(
DateTimeFilterCondition condition,
) async {
await tapButton(find.byType(DateFilterConditionList));
final button = find.descendant(
of: find.byType(HoverButton),
matching: find.text(condition.filterName),
@@ -2,10 +2,13 @@ import 'dart:async';
import 'dart:ui';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart';
import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_add_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option/option_actions.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_title.dart';
@@ -15,6 +18,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/image/uplo
import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart';
import 'package:appflowy/shared/icon_emoji_picker/emoji_skin_tone.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@@ -71,6 +75,20 @@ class EditorOperations {
expect(find.byType(FlowyEmojiPicker), findsOneWidget);
}
Future<void> paste() async {
if (UniversalPlatform.isMacOS) {
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isMetaPressed: true,
);
} else {
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed: true,
);
}
}
Future<void> tapGettingStartedIcon() async {
await tester.tapButton(
find.descendant(
@@ -179,6 +197,11 @@ class EditorOperations {
await tester.ime.insertCharacter('@');
}
/// trigger the plus action menu (+) command
Future<void> showPlusMenu() async {
await tester.ime.insertCharacter('+');
}
/// Tap the slash menu item with [name]
///
/// Must call [showSlashMenu] first.
@@ -275,10 +298,42 @@ class EditorOperations {
widget.blockComponentContext.node.path.equals(path),
),
);
await tester.pumpUntilFound(find.byType(PopoverActionList));
},
);
}
/// open the turn into menu
Future<void> openTurnIntoMenu(Path path) async {
await hoverAndClickOptionMenuButton(path);
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_optionAction_turnInto.tr(),
),
);
await tester.pumpUntilFound(find.byType(TurnIntoOptionMenu));
}
/// copy link to block
Future<void> copyLinkToBlock(Path path) async {
await hoverAndClickOptionMenuButton(path);
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_optionAction_copyLinkToBlock.tr(),
),
);
}
Future<void> openDepthMenu(Path path) async {
await hoverAndClickOptionMenuButton(path);
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_optionAction_depth.tr(),
),
);
await tester.pumpUntilFound(find.byType(DepthOptionMenu));
}
/// Drag block
///
/// [offset] is the offset to move the block.
@@ -360,4 +415,23 @@ class EditorOperations {
),
);
}
/// open the more action menu on mobile
Future<void> openMoreActionMenuOnMobile() async {
final moreActionButton = find.byType(MobileViewPageMoreButton);
await tester.tapButton(moreActionButton);
await tester.pumpAndSettle();
}
/// click the more action item on mobile
///
/// rename, add collaborator, publish, delete, etc.
Future<void> clickMoreActionItemOnMobile(String name) async {
final moreActionItem = find.descendant(
of: find.byType(MobileQuickActionButton),
matching: find.findTextInFlowyText(name),
);
await tester.tapButton(moreActionItem);
await tester.pumpAndSettle();
}
}
+3 -1
View File
@@ -3,7 +3,9 @@ import 'package:scaled_app/scaled_app.dart';
import 'startup/startup.dart';
Future<void> main() async {
ScaledWidgetsFlutterBinding.ensureInitialized(scaleFactor: (_) => 1.0);
ScaledWidgetsFlutterBinding.ensureInitialized(
scaleFactor: (_) => 1.0,
);
await runAppFlowy();
}
@@ -56,7 +56,7 @@ class NotificationReminderBloc
emit(
NotificationReminderState(
createdAt: createdAt,
pageTitle: view.name,
pageTitle: view.nameOrDefault,
view: view,
reminderContent: node.delta?.toPlainText() ?? '',
nodes: [node],
@@ -68,7 +68,7 @@ class NotificationReminderBloc
emit(
NotificationReminderState(
createdAt: createdAt,
pageTitle: view.name,
pageTitle: view.nameOrDefault,
view: view,
reminderContent: reminder.message,
status: NotificationReminderStatus.loaded,
@@ -5,6 +5,7 @@ import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart';
import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_state_container.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/plugins/document/presentation/document_collaborators.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
@@ -94,6 +95,10 @@ class _MobileViewPageState extends State<MobileViewPage> {
BlocProvider.value(
value: getIt<ReminderBloc>(),
),
BlocProvider(
create: (_) =>
ShareBloc(view: view)..add(const ShareEvent.initial()),
),
if (view.layout.isDocumentView)
BlocProvider(
create: (_) => DocumentPageStyleBloc(view: view)
@@ -8,6 +8,7 @@ import 'package:appflowy/mobile/presentation/base/view_page/more_bottom_sheet.da
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/plugins/document/presentation/editor_notification.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/page_style_bottom_sheet.dart';
import 'package:appflowy/plugins/shared/share/share_bloc.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
@@ -98,6 +99,8 @@ class MobileViewPageMoreButton extends StatelessWidget {
providers: [
BlocProvider.value(value: context.read<ViewBloc>()),
BlocProvider.value(value: context.read<FavoriteBloc>()),
BlocProvider.value(value: context.read<MobileViewPageBloc>()),
BlocProvider.value(value: context.read<ShareBloc>()),
],
child: MobileViewPageMoreBottomSheet(view: view),
),
@@ -1,11 +1,24 @@
import 'dart:async';
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/plugins/document/presentation/editor_notification.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/shared/share/constants.dart';
import 'package:appflowy/plugins/shared/share/publish_name_generator.dart';
import 'package:appflowy/plugins/shared/share/share_bloc.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/navigator_context_exntesion.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
class MobileViewPageMoreBottomSheet extends StatelessWidget {
const MobileViewPageMoreBottomSheet({super.key, required this.view});
@@ -16,29 +29,20 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
Widget build(BuildContext context) {
return ViewPageBottomSheet(
view: view,
onAction: (action) {
onAction: (action) async {
switch (action) {
case MobileViewBottomSheetBodyAction.duplicate:
context.pop();
context.read<ViewBloc>().add(const ViewEvent.duplicate());
// show toast
break;
case MobileViewBottomSheetBodyAction.share:
// unimplemented
context.pop();
break;
case MobileViewBottomSheetBodyAction.delete:
// pop to home page
context
..pop()
..pop();
context.read<ViewBloc>().add(const ViewEvent.delete());
context.popToHome();
break;
case MobileViewBottomSheetBodyAction.addToFavorites:
case MobileViewBottomSheetBodyAction.removeFromFavorites:
context.pop();
context.read<FavoriteBloc>().add(FavoriteEvent.toggle(view));
context.pop();
break;
case MobileViewBottomSheetBodyAction.undo:
EditorNotification.undo().post();
@@ -52,17 +56,125 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
// unimplemented
context.pop();
break;
case MobileViewBottomSheetBodyAction.publish:
await _publish(context);
if (context.mounted) {
context.pop();
}
break;
case MobileViewBottomSheetBodyAction.unpublish:
_unpublish(context);
context.pop();
break;
case MobileViewBottomSheetBodyAction.copyPublishLink:
_copyPublishLink(context);
context.pop();
break;
case MobileViewBottomSheetBodyAction.visitSite:
_visitPublishedSite(context);
context.pop();
break;
case MobileViewBottomSheetBodyAction.copyShareLink:
_copyShareLink(context);
context.pop();
break;
case MobileViewBottomSheetBodyAction.rename:
// no need to implement, rename is handled by the onRename callback.
throw UnimplementedError();
}
},
onRename: (name) {
if (name != view.name) {
context.read<ViewBloc>().add(ViewEvent.rename(name));
}
_onRename(context, name);
context.pop();
},
);
}
Future<void> _publish(BuildContext context) async {
final id = context.read<ShareBloc>().view.id;
final publishName = await generatePublishName(
id,
view.name,
);
if (context.mounted) {
context.read<ShareBloc>().add(
ShareEvent.publish(
'',
publishName,
[view.id],
),
);
showToastNotification(
context,
message: LocaleKeys.publish_publishSuccessfully.tr(),
);
}
}
void _unpublish(BuildContext context) {
context.read<ShareBloc>().add(const ShareEvent.unPublish());
showToastNotification(
context,
message: LocaleKeys.publish_unpublishSuccessfully.tr(),
);
}
void _copyPublishLink(BuildContext context) {
final url = context.read<ShareBloc>().state.url;
if (url.isNotEmpty) {
unawaited(
getIt<ClipboardService>().setData(
ClipboardServiceData(plainText: url),
),
);
showToastNotification(
context,
message: LocaleKeys.grid_url_copy.tr(),
);
}
}
void _visitPublishedSite(BuildContext context) {
final url = context.read<ShareBloc>().state.url;
if (url.isNotEmpty) {
unawaited(
afLaunchUrl(
Uri.parse(url),
mode: LaunchMode.externalApplication,
),
);
}
}
void _copyShareLink(BuildContext context) {
final workspaceId = context.read<ShareBloc>().state.workspaceId;
final viewId = context.read<ShareBloc>().state.viewId;
final url = ShareConstants.buildShareUrl(
workspaceId: workspaceId,
viewId: viewId,
);
if (url.isNotEmpty) {
unawaited(
getIt<ClipboardService>().setData(
ClipboardServiceData(plainText: url),
),
);
showToastNotification(
context,
message: LocaleKeys.shareAction_copyLinkSuccess.tr(),
);
} else {
showToastNotification(
context,
message: LocaleKeys.shareAction_copyLinkToBlockFailed.tr(),
type: ToastificationType.error,
);
}
}
void _onRename(BuildContext context, String name) {
if (name != view.name) {
context.read<ViewBloc>().add(ViewEvent.rename(name));
}
}
}
@@ -1,21 +1,29 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/base/mobile_view_page_bloc.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart';
import 'package:appflowy/plugins/shared/share/share_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
enum MobileViewBottomSheetBodyAction {
undo,
redo,
share,
rename,
duplicate,
delete,
addToFavorites,
removeFromFavorites,
helpCenter;
helpCenter,
publish,
unpublish,
copyPublishLink,
visitSite,
copyShareLink,
}
typedef MobileViewBottomSheetBodyActionCallback = void Function(
@@ -90,6 +98,7 @@ class MobileViewBottomSheetBody extends StatelessWidget {
MobileQuickActionButton(
text: LocaleKeys.button_rename.tr(),
icon: FlowySvgs.view_item_rename_s,
iconSize: const Size.square(18),
onTap: () => onAction(
MobileViewBottomSheetBodyAction.rename,
),
@@ -100,6 +109,7 @@ class MobileViewBottomSheetBody extends StatelessWidget {
? LocaleKeys.button_removeFromFavorites.tr()
: LocaleKeys.button_addToFavorites.tr(),
icon: isFavorite ? FlowySvgs.unfavorite_s : FlowySvgs.favorite_s,
iconSize: const Size.square(18),
onTap: () => onAction(
isFavorite
? MobileViewBottomSheetBodyAction.removeFromFavorites
@@ -110,16 +120,30 @@ class MobileViewBottomSheetBody extends StatelessWidget {
MobileQuickActionButton(
text: LocaleKeys.button_duplicate.tr(),
icon: FlowySvgs.duplicate_s,
iconSize: const Size.square(18),
onTap: () => onAction(
MobileViewBottomSheetBodyAction.duplicate,
),
),
// copy link
_divider(),
MobileQuickActionButton(
text: LocaleKeys.shareAction_copyLink.tr(),
icon: FlowySvgs.m_copy_link_s,
iconSize: const Size.square(18),
onTap: () => onAction(
MobileViewBottomSheetBodyAction.copyShareLink,
),
),
_divider(),
..._buildPublishActions(context),
_divider(),
MobileQuickActionButton(
text: LocaleKeys.button_delete.tr(),
textColor: Theme.of(context).colorScheme.error,
icon: FlowySvgs.trash_s,
iconColor: Theme.of(context).colorScheme.error,
iconSize: const Size.square(18),
onTap: () => onAction(
MobileViewBottomSheetBodyAction.delete,
),
@@ -129,6 +153,48 @@ class MobileViewBottomSheetBody extends StatelessWidget {
);
}
List<Widget> _buildPublishActions(BuildContext context) {
final userProfile = context.read<MobileViewPageBloc>().state.userProfilePB;
// the publish feature is only available for AppFlowy Cloud
if (userProfile == null ||
userProfile.authenticator != AuthenticatorPB.AppFlowyCloud) {
return [];
}
final isPublished = context.watch<ShareBloc>().state.isPublished;
if (isPublished) {
return [
MobileQuickActionButton(
text: LocaleKeys.shareAction_visitSite.tr(),
icon: FlowySvgs.m_visit_site_s,
iconSize: const Size.square(18),
onTap: () => onAction(
MobileViewBottomSheetBodyAction.visitSite,
),
),
_divider(),
MobileQuickActionButton(
text: LocaleKeys.shareAction_unPublish.tr(),
icon: FlowySvgs.m_unpublish_s,
iconSize: const Size.square(18),
onTap: () => onAction(
MobileViewBottomSheetBodyAction.unpublish,
),
),
];
} else {
return [
MobileQuickActionButton(
text: LocaleKeys.shareAction_publish.tr(),
icon: FlowySvgs.m_publish_s,
onTap: () => onAction(
MobileViewBottomSheetBodyAction.publish,
),
),
];
}
}
Widget _divider() => const Divider(
height: 8.5,
thickness: 0.5,
@@ -1003,7 +1003,7 @@ class _SelectOptionFilterContentEditorState
isSelected,
);
},
indicator: _getIndicator(),
indicator: MobileSelectedOptionIndicator.multi,
showMoreOptionsButton: false,
);
},
@@ -1027,35 +1027,19 @@ class _SelectOptionFilterContentEditorState
}
}
MobileSelectedOptionIndicator _getIndicator() {
return (widget.filter.condition == SelectOptionFilterConditionPB.OptionIs ||
widget.filter.condition ==
SelectOptionFilterConditionPB.OptionIsNot) &&
widget.field.fieldType == FieldType.SingleSelect
? MobileSelectedOptionIndicator.single
: MobileSelectedOptionIndicator.multi;
}
void _onTapHandler(
BuildContext context,
List<SelectOptionPB> options,
SelectOptionPB option,
bool isSelected,
) {
final selectedOptionIds = Set<String>.from(widget.filter.optionIds);
if (isSelected) {
final selectedOptionIds = Set<String>.from(widget.filter.optionIds)
..remove(option.id);
_updateSelectOptions(context, options, selectedOptionIds);
selectedOptionIds.remove(option.id);
} else {
final selectedOptionIds = widget.delegate.selectOption(
widget.filter.optionIds,
option.id,
widget.filter.condition,
);
_updateSelectOptions(context, options, selectedOptionIds);
selectedOptionIds.add(option.id);
}
_updateSelectOptions(context, options, selectedOptionIds);
}
void _updateSelectOptions(
@@ -93,8 +93,6 @@ final class SingleSelectOptionFilterCondition
return [
SelectOptionFilterConditionPB.OptionIs,
SelectOptionFilterConditionPB.OptionIsNot,
SelectOptionFilterConditionPB.OptionContains,
SelectOptionFilterConditionPB.OptionDoesNotContain,
SelectOptionFilterConditionPB.OptionIsEmpty,
SelectOptionFilterConditionPB.OptionIsNotEmpty,
].map((e) => (e, e.i18n)).toList();
@@ -109,8 +107,6 @@ final class MultiSelectOptionFilterCondition
return [
SelectOptionFilterConditionPB.OptionContains,
SelectOptionFilterConditionPB.OptionDoesNotContain,
SelectOptionFilterConditionPB.OptionIs,
SelectOptionFilterConditionPB.OptionIsNot,
SelectOptionFilterConditionPB.OptionIsEmpty,
SelectOptionFilterConditionPB.OptionIsNotEmpty,
].map((e) => (e, e.i18n)).toList();
@@ -26,7 +26,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import 'package:sentry/sentry.dart';
import 'package:toastification/toastification.dart';
class MobileHomeScreen extends StatelessWidget {
const MobileHomeScreen({super.key});
@@ -258,7 +258,7 @@ class NotificationDocumentContent extends StatelessWidget {
),
);
final blockBuilders = getEditorBuilderMap(
final blockBuilders = buildBlockComponentBuilders(
context: context,
editorState: editorState,
styleCustomizer: styleCustomizer,
@@ -1,7 +1,6 @@
import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/mobile_router.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
@@ -9,7 +8,6 @@ import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/draggable_view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -262,9 +260,7 @@ class _SingleMobileInnerViewItemState extends State<SingleMobileInnerViewItem> {
// title
Expanded(
child: FlowyText.regular(
widget.view.name.isEmpty
? LocaleKeys.menuAppHeader_defaultNewPageName.tr()
: widget.view.name,
widget.view.nameOrDefault,
fontSize: 16.0,
figmaLineHeight: 20.0,
overflow: TextOverflow.ellipsis,
@@ -14,7 +14,6 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:string_validator/string_validator.dart';
import 'package:toastification/toastification.dart';
import 'member_list.dart';
@@ -10,6 +10,7 @@ class MobileQuickActionButton extends StatelessWidget {
required this.text,
this.textColor,
this.iconColor,
this.iconSize,
this.enable = true,
});
@@ -18,10 +19,12 @@ class MobileQuickActionButton extends StatelessWidget {
final String text;
final Color? textColor;
final Color? iconColor;
final Size? iconSize;
final bool enable;
@override
Widget build(BuildContext context) {
final iconSize = this.iconSize ?? const Size.square(18);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: InkWell(
@@ -37,10 +40,10 @@ class MobileQuickActionButton extends StatelessWidget {
children: [
FlowySvg(
icon,
size: const Size.square(18),
size: iconSize,
color: enable ? iconColor : Theme.of(context).disabledColor,
),
const HSpace(12),
HSpace(30 - iconSize.width),
Expanded(
child: FlowyText.regular(
text,
@@ -108,7 +108,7 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
cursorColor: Colors.transparent,
cursorWidth: 0,
);
final blockBuilders = getEditorBuilderMap(
final blockBuilders = buildBlockComponentBuilders(
context: context,
editorState: editorState,
styleCustomizer: styleCustomizer,
@@ -33,6 +33,8 @@ class ChecklistCellBloc extends Bloc<ChecklistCellEvent, ChecklistCellState> {
final ChecklistCellBackendService _checklistCellService;
void Function()? _onCellChangedFn;
int? nextPhantomIndex;
@override
Future<void> close() async {
if (_onCellChangedFn != null) {
@@ -52,17 +54,23 @@ class ChecklistCellBloc extends Bloc<ChecklistCellEvent, ChecklistCellState> {
const ChecklistCellState(
tasks: [],
percent: 0,
newTask: false,
showIncompleteOnly: false,
phantomIndex: null,
),
);
return;
}
final phantomIndex = state.phantomIndex != null
? nextPhantomIndex ?? state.phantomIndex
: null;
emit(
state.copyWith(
tasks: _makeChecklistSelectOptions(data),
percent: data.percentage,
phantomIndex: phantomIndex,
),
);
nextPhantomIndex = null;
},
updateTaskName: (option, name) {
_updateOption(option, name);
@@ -70,16 +78,28 @@ class ChecklistCellBloc extends Bloc<ChecklistCellEvent, ChecklistCellState> {
selectTask: (id) async {
await _checklistCellService.select(optionId: id);
},
createNewTask: (name) async {
final result = await _checklistCellService.create(name: name);
result.fold(
(l) => emit(state.copyWith(newTask: true)),
(err) => Log.error(err),
);
createNewTask: (name, index) async {
await _createTask(name, index);
},
deleteTask: (id) async {
await _deleteOption([id]);
},
reorderTask: (fromIndex, toIndex) async {
await _reorderTask(fromIndex, toIndex, emit);
},
toggleShowIncompleteOnly: () {
emit(state.copyWith(showIncompleteOnly: !state.showIncompleteOnly));
},
updatePhantomIndex: (index) {
emit(
ChecklistCellState(
tasks: state.tasks,
percent: state.percent,
showIncompleteOnly: state.showIncompleteOnly,
phantomIndex: index,
),
);
},
);
},
);
@@ -95,6 +115,31 @@ class ChecklistCellBloc extends Bloc<ChecklistCellEvent, ChecklistCellState> {
);
}
Future<void> _createTask(String name, int? index) async {
nextPhantomIndex = index == null ? state.tasks.length + 1 : index + 1;
int? actualIndex = index;
if (index != null && state.showIncompleteOnly) {
int notSelectedTaskCount = 0;
for (int i = 0; i < state.tasks.length; i++) {
if (!state.tasks[i].isSelected) {
notSelectedTaskCount++;
}
if (notSelectedTaskCount == index) {
actualIndex = i + 1;
break;
}
}
}
final result = await _checklistCellService.create(
name: name,
index: actualIndex,
);
result.fold((l) {}, (err) => Log.error(err));
}
void _updateOption(SelectOptionPB option, String name) async {
final result =
await _checklistCellService.updateName(option: option, name: name);
@@ -105,6 +150,32 @@ class ChecklistCellBloc extends Bloc<ChecklistCellEvent, ChecklistCellState> {
final result = await _checklistCellService.delete(optionIds: options);
result.fold((l) => null, (err) => Log.error(err));
}
Future<void> _reorderTask(
int fromIndex,
int toIndex,
Emitter<ChecklistCellState> emit,
) async {
if (fromIndex < toIndex) {
toIndex--;
}
final tasks = state.showIncompleteOnly
? state.tasks.where((task) => !task.isSelected).toList()
: state.tasks;
final fromId = tasks[fromIndex].data.id;
final toId = tasks[toIndex].data.id;
final newTasks = [...state.tasks];
newTasks.insert(toIndex, newTasks.removeAt(fromIndex));
emit(state.copyWith(tasks: newTasks));
final result = await _checklistCellService.reorder(
fromTaskId: fromId,
toTaskId: toId,
);
result.fold((l) => null, (err) => Log.error(err));
}
}
@freezed
@@ -117,9 +188,17 @@ class ChecklistCellEvent with _$ChecklistCellEvent {
String name,
) = _UpdateTaskName;
const factory ChecklistCellEvent.selectTask(String taskId) = _SelectTask;
const factory ChecklistCellEvent.createNewTask(String description) =
_CreateNewTask;
const factory ChecklistCellEvent.createNewTask(
String description, {
int? index,
}) = _CreateNewTask;
const factory ChecklistCellEvent.deleteTask(String taskId) = _DeleteTask;
const factory ChecklistCellEvent.reorderTask(int fromIndex, int toIndex) =
_ReorderTask;
const factory ChecklistCellEvent.toggleShowIncompleteOnly() = _IncompleteOnly;
const factory ChecklistCellEvent.updatePhantomIndex(int? index) =
_UpdatePhantomIndex;
}
@freezed
@@ -127,7 +206,8 @@ class ChecklistCellState with _$ChecklistCellState {
const factory ChecklistCellState({
required List<ChecklistSelectOption> tasks,
required double percent,
required bool newTask,
required bool showIncompleteOnly,
required int? phantomIndex,
}) = _ChecklistCellState;
factory ChecklistCellState.initial(ChecklistCellController cellController) {
@@ -136,7 +216,8 @@ class ChecklistCellState with _$ChecklistCellState {
return ChecklistCellState(
tasks: _makeChecklistSelectOptions(cellData),
percent: cellData?.percentage ?? 0,
newTask: false,
showIncompleteOnly: false,
phantomIndex: null,
);
}
}
@@ -1,7 +1,7 @@
import 'dart:collection';
import 'package:appflowy/plugins/database/application/field/filter_entities.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/database_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@@ -18,7 +18,7 @@ typedef OnFiltersChanged = void Function(List<DatabaseFilter>);
typedef OnSortsChanged = void Function(List<DatabaseSort>);
typedef OnDatabaseChanged = void Function(DatabasePB);
typedef OnRowsCreated = void Function(List<RowId> rowIds);
typedef OnRowsCreated = void Function(List<InsertedRowPB> rows);
typedef OnRowsUpdated = void Function(
List<RowId> rowIds,
ChangedReason reason,
@@ -29,6 +29,9 @@ typedef OnNumOfRowsChanged = void Function(
UnmodifiableMapView<RowId, RowInfo> rowById,
ChangedReason reason,
);
typedef OnRowsVisibilityChanged = void Function(
List<(RowId, bool)> rowVisibilityChanges,
);
@freezed
class LoadingState with _$LoadingState {
@@ -353,14 +353,7 @@ final class SelectOptionFilter extends DatabaseFilter {
required List<String> optionIds,
}) {
if (canAttachContent) {
if (fieldType == FieldType.SingleSelect &&
(condition == SelectOptionFilterConditionPB.OptionIs ||
condition == SelectOptionFilterConditionPB.OptionIsNot) &&
optionIds.isNotEmpty) {
this.optionIds.add(optionIds.first);
} else {
this.optionIds.addAll(optionIds);
}
this.optionIds.addAll(optionIds);
}
}
@@ -377,7 +370,7 @@ final class SelectOptionFilter extends DatabaseFilter {
@override
String getContentDescription(FieldInfo field) {
if (!canAttachContent) {
if (!canAttachContent || optionIds.isEmpty) {
return condition.i18n;
}
@@ -436,13 +429,6 @@ final class SelectOptionFilter extends DatabaseFilter {
SelectOptionFilterConditionPB? condition,
List<String>? optionIds,
}) {
final options = optionIds ?? this.optionIds;
if (fieldType == FieldType.SingleSelect &&
(condition == SelectOptionFilterConditionPB.OptionIs ||
condition == SelectOptionFilterConditionPB.OptionIsNot) &&
options.length > 1) {
options.removeRange(1, options.length);
}
return SelectOptionFilter(
filterId: filterId,
fieldId: fieldId,
@@ -93,12 +93,9 @@ class RowCache {
}
void setRowMeta(RowMetaPB rowMeta) {
var rowInfo = _rowList.get(rowMeta.id);
final rowInfo = _rowList.get(rowMeta.id);
if (rowInfo != null) {
rowInfo.updateRowMeta(rowMeta);
} else {
rowInfo = buildGridRow(rowMeta);
_rowList.add(rowInfo);
}
_changedNotifier?.receive(const ChangedReason.didFetchRow());
@@ -122,6 +119,9 @@ class RowCache {
if (_isInitialRows) {
_hideRows(changeset.invisibleRows);
_showRows(changeset.visibleRows);
_changedNotifier?.receive(
ChangedReason.updateRowsVisibility(changeset),
);
} else {
_pendingVisibilityChanges.add(changeset);
}
@@ -159,13 +159,19 @@ class RowCache {
}
void _insertRows(List<InsertedRowPB> insertRows) {
final InsertedIndexs insertedIndices = [];
for (final insertedRow in insertRows) {
final insertedIndex =
_rowList.insert(insertedRow.index, buildGridRow(insertedRow.rowMeta));
if (insertedIndex != null) {
_changedNotifier?.receive(ChangedReason.insert(insertedIndex));
if (insertedRow.hasIndex()) {
final index = _rowList.insert(
insertedRow.index,
buildGridRow(insertedRow.rowMeta),
);
if (index != null) {
insertedIndices.add(index);
}
}
}
_changedNotifier?.receive(ChangedReason.insert(insertedIndices));
}
void _updateRows(List<UpdatedRowPB> updatedRows) {
@@ -208,7 +214,7 @@ class RowCache {
final insertedIndex =
_rowList.insert(insertedRow.index, buildGridRow(insertedRow.rowMeta));
if (insertedIndex != null) {
_changedNotifier?.receive(ChangedReason.insert(insertedIndex));
_changedNotifier?.receive(ChangedReason.insert([insertedIndex]));
}
}
}
@@ -310,6 +316,7 @@ class RowChangesetNotifier extends ChangeNotifier {
initial: (_) {},
reorderRows: (_) => notifyListeners(),
reorderSingleRow: (_) => notifyListeners(),
updateRowsVisibility: (_) => notifyListeners(),
setInitialRows: (_) => notifyListeners(),
didFetchRow: (_) => notifyListeners(),
);
@@ -361,7 +368,7 @@ typedef UpdatedIndexMap = LinkedHashMap<RowId, UpdatedIndex>;
@freezed
class ChangedReason with _$ChangedReason {
const factory ChangedReason.insert(InsertedIndex item) = _Insert;
const factory ChangedReason.insert(InsertedIndexs items) = _Insert;
const factory ChangedReason.delete(DeletedIndex item) = _Delete;
const factory ChangedReason.update(UpdatedIndexMap indexs) = _Update;
const factory ChangedReason.fieldDidChange() = _FieldDidChange;
@@ -372,6 +379,9 @@ class ChangedReason with _$ChangedReason {
ReorderSingleRowPB reorderRow,
RowInfo rowInfo,
) = _ReorderSingleRow;
const factory ChangedReason.updateRowsVisibility(
RowsVisibilityChangePB changeset,
) = _UpdateRowsVisibility;
const factory ChangedReason.setInitialRows() = _SetInitialRows;
}
@@ -69,11 +69,7 @@ class DatabaseViewCache {
if (changeset.insertedRows.isNotEmpty) {
for (final callback in _callbacks) {
callback.onRowsCreated?.call(
changeset.insertedRows
.map((insertedRow) => insertedRow.rowMeta.id)
.toList(),
);
callback.onRowsCreated?.call(changeset.insertedRows);
}
}
},
@@ -273,6 +273,11 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
);
}
},
openRowDetail: (rowMeta) {
final copyState = state;
emit(BoardState.openRowDetail(rowMeta: rowMeta));
emit(copyState);
},
);
},
);
@@ -477,8 +482,18 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
add(BoardEvent.didReceiveGroups(groupList));
},
);
final onDatabaseChanged = DatabaseCallbacks(
onRowsCreated: (rows) {
for (final row in rows) {
if (!isClosed && row.isHiddenInView) {
add(BoardEvent.openRowDetail(row.rowMeta));
}
}
},
);
databaseController.addListener(
onDatabaseChanged: onDatabaseChanged,
onLayoutSettingsChanged: onLayoutSettingsChanged,
onGroupChanged: onGroupChanged,
);
@@ -581,6 +596,7 @@ class BoardEvent with _$BoardEvent {
GroupedRowId groupedRowId,
bool toPrevious,
) = _MoveGroupToAdjacentGroup;
const factory BoardEvent.openRowDetail(RowMetaPB rowMeta) = _OpenRowDetail;
}
@freezed
@@ -607,6 +623,10 @@ class BoardState with _$BoardState {
required List<GroupedRowId> groupedRowIds,
}) = _BoardSetFocusState;
const factory BoardState.openRowDetail({
required RowMetaPB rowMeta,
}) = _BoardOpenRowDetailState;
factory BoardState.initial(String viewId) => BoardState.ready(
viewId: viewId,
groupIds: [],
@@ -292,6 +292,14 @@ class _BoardContentState extends State<_BoardContent> {
ready: (value) {
widget.onEditStateChanged?.call();
},
openRowDetail: (value) {
_openCard(
context: context,
databaseController:
context.read<BoardBloc>().databaseController,
rowMeta: value.rowMeta,
);
},
orElse: () {},
);
},
@@ -122,6 +122,7 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
deleteEventIds: deletedRowIds,
),
);
emit(state.copyWith(deleteEventIds: const []));
},
didReceiveEvent: (CalendarEventData<CalendarDayEvent> event) {
emit(
@@ -130,6 +131,11 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
newEvent: event,
),
);
emit(state.copyWith(newEvent: null));
},
openRowDetail: (row) {
emit(state.copyWith(openRow: row));
emit(state.copyWith(openRow: null));
},
);
},
@@ -231,15 +237,13 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
Future<CalendarEventData<CalendarDayEvent>?> _loadEvent(RowId rowId) async {
final payload = DatabaseViewRowIdPB(viewId: viewId, rowId: rowId);
return DatabaseEventGetCalendarEvent(payload).send().then((result) {
return result.fold(
(eventPB) => _calendarEventDataFromEventPB(eventPB),
(r) {
Log.error(r);
return null;
},
);
});
return DatabaseEventGetCalendarEvent(payload).send().fold(
(eventPB) => _calendarEventDataFromEventPB(eventPB),
(r) {
Log.error(r);
return null;
},
);
}
void _loadAllEvents() async {
@@ -308,14 +312,18 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
for (final fieldInfo in fieldInfos) fieldInfo.field.id: fieldInfo,
};
},
onRowsCreated: (rowIds) async {
onRowsCreated: (rows) async {
if (isClosed) {
return;
}
for (final id in rowIds) {
final event = await _loadEvent(id);
if (event != null && !isClosed) {
add(CalendarEvent.didReceiveEvent(event));
for (final row in rows) {
if (row.isHiddenInView) {
add(CalendarEvent.openRowDetail(row.rowMeta));
} else {
final event = await _loadEvent(row.rowMeta.id);
if (event != null) {
add(CalendarEvent.didReceiveEvent(event));
}
}
}
},
@@ -341,6 +349,30 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
}
}
},
onNumOfRowsChanged: (rows, rowById, reason) {
reason.maybeWhen(
updateRowsVisibility: (changeset) async {
if (isClosed) {
return;
}
for (final id in changeset.invisibleRows) {
if (_containsEvent(id)) {
add(CalendarEvent.didDeleteEvents([id]));
}
}
for (final row in changeset.visibleRows) {
final id = row.rowMeta.id;
if (!_containsEvent(id)) {
final event = await _loadEvent(id);
if (event != null) {
add(CalendarEvent.didReceiveEvent(event));
}
}
}
},
orElse: () {},
);
},
);
final onLayoutSettingsChanged = DatabaseLayoutSettingCallbacks(
@@ -372,6 +404,10 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
return state.allEvents[index].date.day != event.date.day;
}
bool _containsEvent(String rowId) {
return state.allEvents.any((element) => element.event!.eventId == rowId);
}
Int64 _eventTimestamp(CalendarDayEvent event, DateTime date) {
final time =
event.date.hour * 3600 + event.date.minute * 60 + event.date.second;
@@ -437,6 +473,8 @@ class CalendarEvent with _$CalendarEvent {
const factory CalendarEvent.deleteEvent(String viewId, String rowId) =
_DeleteEvent;
const factory CalendarEvent.openRowDetail(RowMetaPB row) = _OpenRowDetail;
}
@freezed
@@ -451,6 +489,7 @@ class CalendarState with _$CalendarState {
CalendarEventData<CalendarDayEvent>? updateEvent,
required List<String> deleteEventIds,
required CalendarLayoutSettingPB? settings,
required RowMetaPB? openRow,
required LoadingState loadingState,
required FlowyError? noneOrError,
}) = _CalendarState;
@@ -461,6 +500,7 @@ class CalendarState with _$CalendarState {
initialEvents: [],
deleteEventIds: [],
settings: null,
openRow: null,
noneOrError: null,
loadingState: LoadingState.loading(),
);
@@ -42,7 +42,7 @@ class UnscheduleEventsBloc
state.copyWith(
allEvents: events,
unscheduleEvents:
events.where((element) => !element.isScheduled).toList(),
events.where((element) => !element.hasTimestamp()).toList(),
),
);
},
@@ -55,7 +55,7 @@ class UnscheduleEventsBloc
state.copyWith(
allEvents: events,
unscheduleEvents:
events.where((element) => !element.isScheduled).toList(),
events.where((element) => !element.hasTimestamp()).toList(),
),
);
},
@@ -65,7 +65,7 @@ class UnscheduleEventsBloc
state.copyWith(
allEvents: events,
unscheduleEvents:
events.where((element) => !element.isScheduled).toList(),
events.where((element) => !element.hasTimestamp()).toList(),
),
);
},
@@ -104,12 +104,12 @@ class UnscheduleEventsBloc
void _startListening() {
final onDatabaseChanged = DatabaseCallbacks(
onRowsCreated: (rowIds) async {
onRowsCreated: (rows) async {
if (isClosed) {
return;
}
for (final id in rowIds) {
final event = await _loadEvent(id);
for (final row in rows) {
final event = await _loadEvent(row.rowMeta.id);
if (event != null && !isClosed) {
add(UnscheduleEventsEvent.didReceiveEvent(event));
}
@@ -1,3 +1,6 @@
import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart';
import 'package:appflowy/plugins/database/tab_bar/desktop/setting_menu.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
@@ -11,7 +14,6 @@ import 'package:appflowy/plugins/database/calendar/application/unschedule_event_
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/calendar_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:calendar_view/calendar_view.dart';
@@ -31,6 +33,8 @@ import 'layout/sizes.dart';
import 'toolbar/calendar_setting_bar.dart';
class CalendarPageTabBarBuilderImpl extends DatabaseTabBarItemBuilder {
final _toggleExtension = ToggleExtensionNotifier();
@override
Widget content(
BuildContext context,
@@ -52,6 +56,7 @@ class CalendarPageTabBarBuilderImpl extends DatabaseTabBarItemBuilder {
return CalendarSettingBar(
key: _makeValueKey(controller),
databaseController: controller,
toggleExtension: _toggleExtension,
);
}
@@ -60,7 +65,18 @@ class CalendarPageTabBarBuilderImpl extends DatabaseTabBarItemBuilder {
BuildContext context,
DatabaseController controller,
) {
return SizedBox.fromSize();
return DatabaseViewSettingExtension(
key: _makeValueKey(controller),
viewId: controller.viewId,
databaseController: controller,
toggleExtension: _toggleExtension,
);
}
@override
void dispose() {
_toggleExtension.dispose();
super.dispose();
}
ValueKey _makeValueKey(DatabaseController controller) {
@@ -152,6 +168,18 @@ class _CalendarPageState extends State<CalendarPage> {
}
},
),
BlocListener<CalendarBloc, CalendarState>(
listenWhen: (p, c) => p.openRow != c.openRow,
listener: (context, state) {
if (state.openRow != null) {
showEventDetails(
context: context,
databaseController: _calendarBloc.databaseController,
rowMeta: state.openRow!,
);
}
},
),
],
child: BlocBuilder<CalendarBloc, CalendarState>(
builder: (context, state) {
@@ -351,10 +379,10 @@ class _CalendarPageState extends State<CalendarPage> {
void showEventDetails({
required BuildContext context,
required DatabaseController databaseController,
required CalendarEventPB event,
required RowMetaPB rowMeta,
}) {
final rowController = RowController(
rowMeta: event.rowMeta,
rowMeta: rowMeta,
viewId: databaseController.viewId,
rowCache: databaseController.rowCache,
);
@@ -505,7 +533,7 @@ class UnscheduleEventsList extends StatelessWidget {
} else {
showEventDetails(
context: context,
event: event,
rowMeta: event.rowMeta,
databaseController: databaseController,
);
PopoverContainer.of(context).close();
@@ -335,7 +335,7 @@ Widget _toggleItem({
const Spacer(),
Toggle(
value: value,
onChanged: (value) => onToggle(!value),
onChanged: (value) => onToggle(value),
padding: EdgeInsets.zero,
),
],
@@ -1,26 +1,51 @@
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/filter_button.dart';
import 'package:appflowy/plugins/database/widgets/setting/setting_button.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class CalendarSettingBar extends StatelessWidget {
const CalendarSettingBar({
super.key,
required this.databaseController,
required this.toggleExtension,
});
final DatabaseController databaseController;
final ToggleExtensionNotifier toggleExtension;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 20,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
SettingButton(
databaseController: databaseController,
),
],
return BlocProvider(
create: (context) => FilterEditorBloc(
viewId: databaseController.viewId,
fieldController: databaseController.fieldController,
),
child: ValueListenableBuilder<bool>(
valueListenable: databaseController.isLoading,
builder: (context, value, child) {
if (value) {
return const SizedBox.shrink();
}
return SizedBox(
height: 20,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FilterButton(
toggleExtension: toggleExtension,
),
const HSpace(2),
SettingButton(
databaseController: databaseController,
),
],
),
);
},
),
);
}
@@ -1,6 +1,5 @@
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/checklist_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:protobuf/protobuf.dart';
@@ -18,12 +17,16 @@ class ChecklistCellBackendService {
Future<FlowyResult<void, FlowyError>> create({
required String name,
int? index,
}) {
final payload = ChecklistCellDataChangesetPB.create()
..viewId = viewId
..fieldId = fieldId
..rowId = rowId
..insertOptions.add(name);
final insert = ChecklistCellInsertPB()..name = name;
if (index != null) {
insert.index = index;
}
final payload = ChecklistCellDataChangesetPB()
..cellId = _makdeCellId()
..insertTask.add(insert);
return DatabaseEventUpdateChecklistCell(payload).send();
}
@@ -31,11 +34,9 @@ class ChecklistCellBackendService {
Future<FlowyResult<void, FlowyError>> delete({
required List<String> optionIds,
}) {
final payload = ChecklistCellDataChangesetPB.create()
..viewId = viewId
..fieldId = fieldId
..rowId = rowId
..deleteOptionIds.addAll(optionIds);
final payload = ChecklistCellDataChangesetPB()
..cellId = _makdeCellId()
..deleteTasks.addAll(optionIds);
return DatabaseEventUpdateChecklistCell(payload).send();
}
@@ -43,11 +44,9 @@ class ChecklistCellBackendService {
Future<FlowyResult<void, FlowyError>> select({
required String optionId,
}) {
final payload = ChecklistCellDataChangesetPB.create()
..viewId = viewId
..fieldId = fieldId
..rowId = rowId
..selectedOptionIds.add(optionId);
final payload = ChecklistCellDataChangesetPB()
..cellId = _makdeCellId()
..completedTasks.add(optionId);
return DatabaseEventUpdateChecklistCell(payload).send();
}
@@ -60,12 +59,28 @@ class ChecklistCellBackendService {
final newOption = option.rebuild((option) {
option.name = name;
});
final payload = ChecklistCellDataChangesetPB.create()
..viewId = viewId
..fieldId = fieldId
..rowId = rowId
..updateOptions.add(newOption);
final payload = ChecklistCellDataChangesetPB()
..cellId = _makdeCellId()
..updateTasks.add(newOption);
return DatabaseEventUpdateChecklistCell(payload).send();
}
Future<FlowyResult<void, FlowyError>> reorder({
required fromTaskId,
required toTaskId,
}) {
final payload = ChecklistCellDataChangesetPB()
..cellId = _makdeCellId()
..reorder = "$fromTaskId $toTaskId";
return DatabaseEventUpdateChecklistCell(payload).send();
}
CellIdPB _makdeCellId() {
return CellIdPB()
..viewId = viewId
..fieldId = fieldId
..rowId = rowId;
}
}
@@ -117,7 +117,10 @@ class FilterEditorBloc extends Bloc<FilterEditorEvent, FilterEditorState> {
case FieldType.DateTime:
case FieldType.LastEditedTime:
case FieldType.CreatedTime:
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final now = DateTime.now();
final timestamp =
DateTime(now.year, now.month, now.day).millisecondsSinceEpoch ~/
1000;
return _filterBackendSvc.insertDateFilter(
filterId: filterId,
fieldId: fieldId,
@@ -6,12 +6,6 @@ abstract class SelectOptionFilterDelegate {
const SelectOptionFilterDelegate();
List<SelectOptionPB> getOptions(FieldInfo fieldInfo);
Set<String> selectOption(
List<String> currentOptionIds,
String optionId,
SelectOptionFilterConditionPB condition,
);
}
class SingleSelectOptionFilterDelegateImpl
@@ -23,37 +17,6 @@ class SingleSelectOptionFilterDelegateImpl
final parser = SingleSelectTypeOptionDataParser();
return parser.fromBuffer(fieldInfo.field.typeOptionData).options;
}
@override
Set<String> selectOption(
List<String> currentOptionIds,
String optionId,
SelectOptionFilterConditionPB condition,
) {
final selectOptionIds = Set<String>.from(currentOptionIds);
switch (condition) {
case SelectOptionFilterConditionPB.OptionIs:
case SelectOptionFilterConditionPB.OptionIsNot:
if (selectOptionIds.isNotEmpty) {
selectOptionIds.clear();
}
selectOptionIds.add(optionId);
break;
case SelectOptionFilterConditionPB.OptionContains:
case SelectOptionFilterConditionPB.OptionDoesNotContain:
selectOptionIds.add(optionId);
break;
case SelectOptionFilterConditionPB.OptionIsEmpty ||
SelectOptionFilterConditionPB.OptionIsNotEmpty:
selectOptionIds.clear();
break;
default:
throw UnimplementedError();
}
return selectOptionIds;
}
}
class MultiSelectOptionFilterDelegateImpl
@@ -66,12 +29,4 @@ class MultiSelectOptionFilterDelegateImpl
.fromBuffer(fieldInfo.field.typeOptionData)
.options;
}
@override
Set<String> selectOption(
List<String> currentOptionIds,
String optionId,
SelectOptionFilterConditionPB condition,
) =>
Set<String>.from(currentOptionIds)..add(optionId);
}
@@ -46,6 +46,14 @@ class GridBloc extends Bloc<GridEvent, GridState> {
_startListening();
await _openGrid(emit);
},
openRowDetail: (row) {
emit(
state.copyWith(
createdRow: row,
openRowDetail: true,
),
);
},
createRow: (openRowDetail) async {
final result = await RowBackendService.createRow(viewId: viewId);
result.fold(
@@ -75,9 +83,6 @@ class GridBloc extends Bloc<GridEvent, GridState> {
databaseController.moveRow(fromRowId: fromRow, toRowId: toRow);
},
didReceiveGridUpdate: (grid) {
emit(state.copyWith(grid: grid));
},
didReceiveFieldUpdate: (fields) {
emit(
state.copyWith(
@@ -112,20 +117,22 @@ class GridBloc extends Bloc<GridEvent, GridState> {
);
}
RowCache getRowCache(RowId rowId) => databaseController.rowCache;
RowCache get rowCache => databaseController.rowCache;
void _startListening() {
final onDatabaseChanged = DatabaseCallbacks(
onDatabaseChanged: (database) {
if (!isClosed) {
add(GridEvent.didReceiveGridUpdate(database));
}
},
onNumOfRowsChanged: (rowInfos, _, reason) {
if (!isClosed) {
add(GridEvent.didLoadRows(rowInfos, reason));
}
},
onRowsCreated: (rows) {
for (final row in rows) {
if (!isClosed && row.isHiddenInView) {
add(GridEvent.openRowDetail(row.rowMeta));
}
}
},
onRowsUpdated: (rows, reason) {
// TODO(nathan): separate different reasons
if (!isClosed) {
@@ -176,6 +183,7 @@ class GridBloc extends Bloc<GridEvent, GridState> {
@freezed
class GridEvent with _$GridEvent {
const factory GridEvent.initial() = InitialGrid;
const factory GridEvent.openRowDetail(RowMetaPB row) = _OpenRowDetail;
const factory GridEvent.createRow({bool? openRowDetail}) = _CreateRow;
const factory GridEvent.resetCreatedRow() = _ResetCreatedRow;
const factory GridEvent.deleteRow(RowInfo rowInfo) = _DeleteRow;
@@ -188,10 +196,6 @@ class GridEvent with _$GridEvent {
List<FieldInfo> fields,
) = _DidReceiveFieldUpdate;
const factory GridEvent.didReceiveGridUpdate(
DatabasePB grid,
) = _DidReceiveGridUpdate;
const factory GridEvent.didReceveFilters(List<DatabaseFilter> filters) =
_DidReceiveFilters;
const factory GridEvent.didReceveSorts(List<DatabaseSort> sorts) =
@@ -202,7 +206,6 @@ class GridEvent with _$GridEvent {
class GridState with _$GridState {
const factory GridState({
required String viewId,
required DatabasePB? grid,
required List<FieldInfo> fields,
required List<RowInfo> rowInfos,
required int rowCount,
@@ -220,7 +223,6 @@ class GridState with _$GridState {
rowInfos: [],
rowCount: 0,
createdRow: null,
grid: null,
viewId: viewId,
reorderable: true,
loadingState: const LoadingState.loading(),
@@ -1,10 +1,11 @@
import 'dart:async';
import 'dart:math';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
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';
@@ -18,6 +19,7 @@ import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/scrolling/styled_scrollview.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -143,18 +145,7 @@ class _GridPageState extends State<GridPage> {
}
},
child: BlocConsumer<GridBloc, GridState>(
listener: (context, state) => state.loadingState.whenOrNull(
// If initial row id is defined, open row details overlay
finish: (_) {
if (widget.initialRowId != null && !_didOpenInitialRow) {
_didOpenInitialRow = true;
_openRow(context, widget.initialRowId!);
}
return;
},
),
listener: listener,
builder: (context, state) => state.loadingState.map(
loading: (_) => const Center(
child: CircularProgressIndicator.adaptive(),
@@ -185,7 +176,7 @@ class _GridPageState extends State<GridPage> {
) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final gridBloc = context.read<GridBloc>();
final rowCache = gridBloc.getRowCache(rowId);
final rowCache = gridBloc.rowCache;
final rowMeta = rowCache.getRow(rowId)?.rowMeta;
if (rowMeta == null) {
return;
@@ -210,6 +201,48 @@ class _GridPageState extends State<GridPage> {
);
});
}
void listener(BuildContext context, GridState state) {
state.loadingState.whenOrNull(
// If initial row id is defined, open row details overlay
finish: (_) async {
if (widget.initialRowId != null && !_didOpenInitialRow) {
_didOpenInitialRow = true;
_openRow(context, widget.initialRowId!);
return;
}
final bloc = context.read<DatabaseTabBarBloc>();
final isCurrentView =
bloc.state.tabBars[bloc.state.selectedIndex].viewId ==
widget.view.id;
if (state.openRowDetail && state.createdRow != null && isCurrentView) {
final rowController = RowController(
viewId: widget.view.id,
rowMeta: state.createdRow!,
rowCache: context.read<GridBloc>().rowCache,
);
unawaited(
FlowyOverlay.show(
context: context,
builder: (_) => BlocProvider.value(
value: context.read<ViewBloc>(),
child: RowDetailPage(
databaseController:
context.read<GridBloc>().databaseController,
rowController: rowController,
userProfile: context.read<GridBloc>().userProfile,
),
),
),
);
context.read<GridBloc>().add(const GridEvent.resetCreatedRow());
}
},
);
}
}
class GridPageContent extends StatefulWidget {
@@ -292,21 +325,41 @@ class _GridRows extends StatefulWidget {
class _GridRowsState extends State<_GridRows> {
bool showFloatingCalculations = false;
bool isAtBottom = false;
@override
void initState() {
super.initState();
_evaluateFloatingCalculations();
widget.scrollController.verticalController.addListener(_onScrollChanged);
}
void _onScrollChanged() {
final controller = widget.scrollController.verticalController;
final isAtBottom = controller.position.atEdge && controller.offset > 0 ||
controller.offset >= controller.position.maxScrollExtent - 1;
if (isAtBottom != this.isAtBottom) {
setState(() => this.isAtBottom = isAtBottom);
}
}
@override
void dispose() {
widget.scrollController.verticalController.removeListener(_onScrollChanged);
super.dispose();
}
void _evaluateFloatingCalculations() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
final verticalController = widget.scrollController.verticalController;
// maxScrollExtent is 0.0 if scrolling is not possible
showFloatingCalculations = widget.scrollController.verticalController
.position.maxScrollExtent >
0;
showFloatingCalculations =
verticalController.position.maxScrollExtent > 0;
isAtBottom = verticalController.position.atEdge &&
verticalController.offset > 0;
});
}
});
@@ -356,11 +409,11 @@ class _GridRowsState extends State<_GridRows> {
) {
// 1. GridRowBottomBar
// 2. GridCalculationsRow
// 3. Footer Padding
final itemCount = state.rowInfos.length + 3;
return Stack(
final itemCount =
state.rowInfos.length + (showFloatingCalculations ? 1 : 2);
return Column(
children: [
Positioned.fill(
Expanded(
child: ReorderableListView.builder(
/// This is a workaround related to
/// https://github.com/flutter/flutter/issues/25652
@@ -408,38 +461,31 @@ class _GridRowsState extends State<_GridRows> {
},
itemCount: itemCount,
itemBuilder: (context, index) {
if (index < state.rowInfos.length) {
return _renderRow(
context,
state.rowInfos[index].rowId,
index: index,
);
}
if (index == state.rowInfos.length) {
return const GridRowBottomBar(key: Key('grid_footer'));
}
if (index == state.rowInfos.length + 1) {
if (showFloatingCalculations) {
return const SizedBox(
key: Key('calculations_bottom_padding'),
height: 36,
);
} else {
return GridCalculationsRow(
key: const Key('grid_calculations'),
viewId: widget.viewId,
);
}
if (index == state.rowInfos.length + 1 &&
!showFloatingCalculations) {
return GridCalculationsRow(
key: const Key('grid_calculations'),
viewId: widget.viewId,
);
}
return const SizedBox(key: Key('footer_padding'), height: 10);
return _renderRow(
context,
state.rowInfos[index].rowId,
index: index,
);
},
),
),
if (showFloatingCalculations) ...[
_PositionedCalculationsRow(viewId: widget.viewId),
_PositionedCalculationsRow(
viewId: widget.viewId,
isAtBottom: isAtBottom,
),
],
],
);
@@ -462,7 +508,7 @@ class _GridRowsState extends State<_GridRows> {
}
final child = GridRow(
key: ValueKey(rowId),
key: ValueKey("grid_row_$rowId"),
fieldController: databaseController.fieldController,
rowId: rowId,
viewId: viewId,
@@ -542,10 +588,16 @@ class _WrapScrollView extends StatelessWidget {
class _PositionedCalculationsRow extends StatefulWidget {
const _PositionedCalculationsRow({
required this.viewId,
this.isAtBottom = false,
});
final String viewId;
/// We don't need to show the top border if the scroll offset
/// is at the bottom of the ScrollView.
///
final bool isAtBottom;
@override
State<_PositionedCalculationsRow> createState() =>
_PositionedCalculationsRowState();
@@ -555,32 +607,28 @@ class _PositionedCalculationsRowState
extends State<_PositionedCalculationsRow> {
@override
Widget build(BuildContext context) {
return Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
margin: EdgeInsets.only(
left:
context.read<DatabasePluginWidgetBuilderSize>().horizontalPadding,
),
padding: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: Theme.of(context).canvasColor,
border: Border(
top: BorderSide(
color: AFThemeExtension.of(context).borderColor,
),
),
),
child: SizedBox(
height: 36,
width: double.infinity,
child: GridCalculationsRow(
key: const Key('floating_grid_calculations'),
viewId: widget.viewId,
includeDefaultInsets: false,
),
return Container(
margin: EdgeInsets.only(
left: context.read<DatabasePluginWidgetBuilderSize>().horizontalPadding,
),
padding: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: Theme.of(context).canvasColor,
border: widget.isAtBottom
? null
: Border(
top: BorderSide(
color: AFThemeExtension.of(context).borderColor,
),
),
),
child: SizedBox(
height: 36,
width: double.infinity,
child: GridCalculationsRow(
key: const Key('floating_grid_calculations'),
viewId: widget.viewId,
includeDefaultInsets: false,
),
),
);
@@ -25,12 +25,14 @@ class SelectOptionFilterConditionList extends StatelessWidget {
@override
Widget build(BuildContext context) {
final conditions = (fieldType == FieldType.SingleSelect
? SingleSelectOptionFilterCondition().conditions
: MultiSelectOptionFilterCondition().conditions);
return PopoverActionList<ConditionWrapper>(
asBarrier: true,
mutex: popoverMutex,
direction: PopoverDirection.bottomWithCenterAligned,
actions: SingleSelectOptionFilterCondition()
.conditions
actions: conditions
.map(
(action) => ConditionWrapper(
action.$1,
@@ -2,7 +2,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/database/application/field/field_info.dart';
import 'package:appflowy/plugins/database/application/field/filter_entities.dart';
import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart';
import 'package:appflowy/plugins/database/grid/application/filter/select_option_loader.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/select_option_cell_editor.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart';
@@ -17,14 +16,12 @@ class SelectOptionFilterList extends StatelessWidget {
super.key,
required this.filter,
required this.field,
required this.delegate,
required this.options,
required this.onTap,
});
final SelectOptionFilter filter;
final FieldInfo field;
final SelectOptionFilterDelegate delegate;
final List<SelectOptionPB> options;
final VoidCallback onTap;
@@ -53,20 +50,13 @@ class SelectOptionFilterList extends StatelessWidget {
SelectOptionPB option,
bool isSelected,
) {
final selectedOptionIds = Set<String>.from(filter.optionIds);
if (isSelected) {
final selectedOptionIds = Set<String>.from(filter.optionIds)
..remove(option.id);
_updateSelectOptions(context, filter, selectedOptionIds);
selectedOptionIds.remove(option.id);
} else {
final selectedOptionIds = delegate.selectOption(
filter.optionIds,
option.id,
filter.condition,
);
_updateSelectOptions(context, filter, selectedOptionIds);
selectedOptionIds.add(option.id);
}
_updateSelectOptions(context, filter, selectedOptionIds);
onTap();
}
@@ -1,7 +1,6 @@
import 'package:appflowy/plugins/database/application/field/field_info.dart';
import 'package:appflowy/plugins/database/application/field/filter_entities.dart';
import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -76,11 +75,7 @@ class _SelectOptionFilterEditorState extends State<SelectOptionFilterEditor> {
SliverToBoxAdapter(child: _buildFilterPanel(filter, field)),
];
if (![
SelectOptionFilterConditionPB.OptionIsEmpty,
SelectOptionFilterConditionPB.OptionIsNotEmpty,
].contains(filter.condition)) {
final delegate = filter.makeDelegate(field);
if (filter.canAttachContent) {
slivers
..add(const SliverToBoxAdapter(child: VSpace(4)))
..add(
@@ -88,8 +83,7 @@ class _SelectOptionFilterEditorState extends State<SelectOptionFilterEditor> {
child: SelectOptionFilterList(
filter: filter,
field: field,
delegate: delegate,
options: delegate.getOptions(field),
options: filter.makeDelegate(field).getOptions(field),
onTap: () => popoverMutex.close(),
),
),
@@ -1,3 +1,5 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart';
@@ -7,7 +9,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class GridAddRowButton extends StatelessWidget {
@@ -44,7 +44,9 @@ class MobileTabBarHeader extends StatelessWidget {
controller: state
.tabBarControllerByViewId[currentView.viewId]!.controller,
features: switch (currentView.layout) {
ViewLayoutPB.Board => [MobileDatabaseControlFeatures.filter],
ViewLayoutPB.Board || ViewLayoutPB.Calendar => [
MobileDatabaseControlFeatures.filter,
],
ViewLayoutPB.Grid => [
MobileDatabaseControlFeatures.sort,
MobileDatabaseControlFeatures.filter,
@@ -17,7 +17,6 @@ class DesktopGridChecklistCellSkin extends IEditableChecklistCellSkin {
BuildContext context,
CellContainerNotifier cellContainerNotifier,
ChecklistCellBloc bloc,
ChecklistCellState state,
PopoverController popoverController,
) {
return AppFlowyPopover(
@@ -27,7 +26,7 @@ class DesktopGridChecklistCellSkin extends IEditableChecklistCellSkin {
direction: PopoverDirection.bottomWithLeftAligned,
triggerActions: PopoverTriggerFlags.none,
skipTraversal: true,
popupBuilder: (BuildContext popoverContext) {
popupBuilder: (popoverContext) {
WidgetsBinding.instance.addPostFrameCallback((_) {
cellContainerNotifier.isFocus = true;
});
@@ -39,15 +38,19 @@ class DesktopGridChecklistCellSkin extends IEditableChecklistCellSkin {
);
},
onClose: () => cellContainerNotifier.isFocus = false,
child: Container(
alignment: AlignmentDirectional.centerStart,
padding: GridSize.cellContentInsets,
child: state.tasks.isEmpty
? const SizedBox.shrink()
: ChecklistProgressBar(
tasks: state.tasks,
percent: state.percent,
),
child: BlocBuilder<ChecklistCellBloc, ChecklistCellState>(
builder: (context, state) {
return Container(
alignment: AlignmentDirectional.centerStart,
padding: GridSize.cellContentInsets,
child: state.tasks.isEmpty
? const SizedBox.shrink()
: ChecklistProgressBar(
tasks: state.tasks,
percent: state.percent,
),
);
},
),
);
}
@@ -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/database/application/cell/bloc/checklist_cell_bloc.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_cell_editor.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_cell_textfield.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_progress_bar.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:collection/collection.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';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import '../editable_cell_skeleton/checklist.dart';
@@ -19,150 +26,359 @@ class DesktopRowDetailChecklistCellSkin extends IEditableChecklistCellSkin {
BuildContext context,
CellContainerNotifier cellContainerNotifier,
ChecklistCellBloc bloc,
ChecklistCellState state,
PopoverController popoverController,
) {
return ChecklistItems(
return ChecklistRowDetailCell(
context: context,
cellContainerNotifier: cellContainerNotifier,
bloc: bloc,
state: state,
popoverController: popoverController,
);
}
}
class ChecklistItems extends StatefulWidget {
const ChecklistItems({
class ChecklistRowDetailCell extends StatefulWidget {
const ChecklistRowDetailCell({
super.key,
required this.context,
required this.cellContainerNotifier,
required this.bloc,
required this.state,
required this.popoverController,
});
final BuildContext context;
final CellContainerNotifier cellContainerNotifier;
final ChecklistCellBloc bloc;
final ChecklistCellState state;
final PopoverController popoverController;
@override
State<ChecklistItems> createState() => _ChecklistItemsState();
State<ChecklistRowDetailCell> createState() => _ChecklistRowDetailCellState();
}
class _ChecklistItemsState extends State<ChecklistItems> {
bool showIncompleteOnly = false;
class _ChecklistRowDetailCellState extends State<ChecklistRowDetailCell> {
@override
Widget build(BuildContext context) {
final tasks = [...widget.state.tasks];
if (showIncompleteOnly) {
tasks.removeWhere((task) => task.isSelected);
}
// final children = tasks
// .mapIndexed(
// (index, task) => Padding(
// padding: const EdgeInsets.symmetric(vertical: 2.0),
// child: ChecklistItem(
// key: ValueKey('${task.data.id}$index'),
// task: task,
// autofocus: widget.state.newTask && index == tasks.length - 1,
// onSubmitted: () {
// if (index == tasks.length - 1) {
// // create a new task under the last task if the users press enter
// widget.bloc.add(const ChecklistCellEvent.createNewTask(''));
// }
// },
// ),
// ),
// )
// .toList();
return Align(
alignment: AlignmentDirectional.centerStart,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: ChecklistProgressBar(
tasks: widget.state.tasks,
percent: widget.state.percent,
),
),
const HSpace(6.0),
FlowyIconButton(
tooltipText: showIncompleteOnly
? LocaleKeys.grid_checklist_showComplete.tr()
: LocaleKeys.grid_checklist_hideComplete.tr(),
width: 32,
iconColorOnHover: Theme.of(context).colorScheme.onSurface,
icon: FlowySvg(
showIncompleteOnly ? FlowySvgs.show_m : FlowySvgs.hide_m,
size: const Size.square(16),
),
onPressed: () {
setState(
() => showIncompleteOnly = !showIncompleteOnly,
);
},
),
],
),
ProgressAndHideCompleteButton(
onToggleHideComplete: () => context
.read<ChecklistCellBloc>()
.add(const ChecklistCellEvent.toggleShowIncompleteOnly()),
),
const VSpace(2.0),
_ChecklistCellEditors(tasks: tasks),
ChecklistItemControl(cellNotifer: widget.cellContainerNotifier),
_ChecklistItems(
onStartCreatingTaskAfter: (index) {
context
.read<ChecklistCellBloc>()
.add(ChecklistCellEvent.updatePhantomIndex(index + 1));
},
),
ChecklistItemControl(
cellNotifer: widget.cellContainerNotifier,
onTap: () {
final bloc = context.read<ChecklistCellBloc>();
if (bloc.state.phantomIndex == null) {
bloc.add(
ChecklistCellEvent.updatePhantomIndex(
bloc.state.showIncompleteOnly
? bloc.state.tasks
.where((task) => !task.isSelected)
.length
: bloc.state.tasks.length,
),
);
}
},
),
],
),
);
}
}
class _ChecklistCellEditors extends StatelessWidget {
const _ChecklistCellEditors({
required this.tasks,
@visibleForTesting
class ProgressAndHideCompleteButton extends StatelessWidget {
const ProgressAndHideCompleteButton({
super.key,
required this.onToggleHideComplete,
});
final List<ChecklistSelectOption> tasks;
final VoidCallback onToggleHideComplete;
@override
Widget build(BuildContext context) {
final bloc = context.read<ChecklistCellBloc>();
final state = bloc.state;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
...tasks.mapIndexed(
(index, task) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: ChecklistItem(
key: ValueKey('${task.data.id}$index'),
task: task,
autofocus: state.newTask && index == tasks.length - 1,
onSubmitted: () {
if (index == tasks.length - 1) {
// create a new task under the last task if the users press enter
bloc.add(const ChecklistCellEvent.createNewTask(''));
}
},
),
return BlocBuilder<ChecklistCellBloc, ChecklistCellState>(
buildWhen: (previous, current) =>
previous.showIncompleteOnly != current.showIncompleteOnly,
builder: (context, state) {
return Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: BlocBuilder<ChecklistCellBloc, ChecklistCellState>(
builder: (context, state) {
return ChecklistProgressBar(
tasks: state.tasks,
percent: state.percent,
);
},
),
),
const HSpace(6.0),
FlowyIconButton(
tooltipText: state.showIncompleteOnly
? LocaleKeys.grid_checklist_showComplete.tr()
: LocaleKeys.grid_checklist_hideComplete.tr(),
width: 32,
iconColorOnHover: Theme.of(context).colorScheme.onSurface,
icon: FlowySvg(
state.showIncompleteOnly
? FlowySvgs.show_m
: FlowySvgs.hide_m,
size: const Size.square(16),
),
onPressed: onToggleHideComplete,
),
],
),
),
],
);
},
);
}
}
class _ChecklistItems extends StatelessWidget {
const _ChecklistItems({
required this.onStartCreatingTaskAfter,
});
final void Function(int index) onStartCreatingTaskAfter;
@override
Widget build(BuildContext context) {
return Actions(
actions: {
_CancelCreatingFromPhantomIntent:
CallbackAction<_CancelCreatingFromPhantomIntent>(
onInvoke: (_CancelCreatingFromPhantomIntent intent) {
context
.read<ChecklistCellBloc>()
.add(const ChecklistCellEvent.updatePhantomIndex(null));
return;
},
),
},
child: BlocBuilder<ChecklistCellBloc, ChecklistCellState>(
builder: (context, state) {
final children = _makeChildren(context, state);
return ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
proxyDecorator: (child, index, _) => Material(
color: Colors.transparent,
child: Stack(
children: [
BlocProvider.value(
value: context.read<ChecklistCellBloc>(),
child: child,
),
MouseRegion(
cursor: Platform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grabbing,
child: const SizedBox.expand(),
),
],
),
),
buildDefaultDragHandles: false,
itemCount: children.length,
itemBuilder: (_, index) => children[index],
onReorder: (from, to) {
context
.read<ChecklistCellBloc>()
.add(ChecklistCellEvent.reorderTask(from, to));
},
);
},
),
);
}
List<Widget> _makeChildren(BuildContext context, ChecklistCellState state) {
final children = <Widget>[];
final tasks = [...state.tasks];
if (state.showIncompleteOnly) {
tasks.removeWhere((task) => task.isSelected);
}
children.addAll(
tasks.mapIndexed(
(index, task) => Padding(
key: ValueKey('checklist_row_detail_cell_task_${task.data.id}'),
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: ChecklistItem(
task: task,
index: index,
onSubmitted: () {
onStartCreatingTaskAfter(index);
},
),
),
),
);
if (state.phantomIndex != null) {
children.insert(
state.phantomIndex!,
Padding(
key: const ValueKey('new_checklist_cell_task'),
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: PhantomChecklistItem(index: state.phantomIndex!),
),
);
}
return children;
}
}
class _CancelCreatingFromPhantomIntent extends Intent {
const _CancelCreatingFromPhantomIntent();
}
class _SubmitPhantomTaskIntent extends Intent {
const _SubmitPhantomTaskIntent({
required this.taskDescription,
required this.index,
});
final String taskDescription;
final int index;
}
@visibleForTesting
class PhantomChecklistItem extends StatefulWidget {
const PhantomChecklistItem({
super.key,
required this.index,
});
final int index;
@override
State<PhantomChecklistItem> createState() => _PhantomChecklistItemState();
}
class _PhantomChecklistItemState extends State<PhantomChecklistItem> {
TextEditingController textController = TextEditingController();
final focusNode = FocusNode();
bool isComposing = false;
@override
void initState() {
super.initState();
textController.addListener(() {
setState(() {
isComposing = !textController.value.composing.isCollapsed;
});
});
focusNode.addListener(() {
if (!focusNode.hasFocus) {
textController.clear();
Actions.maybeInvoke(
context,
const _CancelCreatingFromPhantomIntent(),
);
}
});
WidgetsBinding.instance.addPostFrameCallback((_) {
focusNode.requestFocus();
});
}
@override
void dispose() {
textController.dispose();
focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Actions(
actions: {
_SubmitPhantomTaskIntent: CallbackAction<_SubmitPhantomTaskIntent>(
onInvoke: (_SubmitPhantomTaskIntent intent) {
if (intent.taskDescription.isNotEmpty) {
context.read<ChecklistCellBloc>().add(
ChecklistCellEvent.createNewTask(
intent.taskDescription,
index: intent.index,
),
);
}
textController.clear();
return;
},
),
},
child: Shortcuts(
shortcuts: _buildShortcuts(),
child: Container(
constraints: const BoxConstraints(minHeight: 32),
decoration: BoxDecoration(
color: AFThemeExtension.of(context).lightGreyHover,
borderRadius: Corners.s6Border,
),
child: Center(
child: ChecklistCellTextfield(
textController: textController,
focusNode: focusNode,
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 10,
),
),
),
),
),
);
}
Map<ShortcutActivator, Intent> _buildShortcuts() {
return isComposing
? const {}
: {
const SingleActivator(LogicalKeyboardKey.enter):
_SubmitPhantomTaskIntent(
taskDescription: textController.text,
index: widget.index,
),
const SingleActivator(LogicalKeyboardKey.escape):
const _CancelCreatingFromPhantomIntent(),
};
}
}
@visibleForTesting
class ChecklistItemControl extends StatelessWidget {
const ChecklistItemControl({super.key, required this.cellNotifer});
const ChecklistItemControl({
super.key,
required this.cellNotifer,
required this.onTap,
});
final CellContainerNotifier cellNotifer;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
@@ -171,9 +387,7 @@ class ChecklistItemControl extends StatelessWidget {
child: Consumer<CellContainerNotifier>(
builder: (buildContext, notifier, _) => GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => context
.read<ChecklistCellBloc>()
.add(const ChecklistCellEvent.createNewTask("")),
onTap: onTap,
child: Container(
margin: const EdgeInsets.fromLTRB(8.0, 2.0, 8.0, 0),
height: 12,
@@ -192,11 +406,7 @@ class ChecklistItemControl extends StatelessWidget {
maximumSize: const Size.square(12),
padding: EdgeInsets.zero,
),
onPressed: () => context
.read<ChecklistCellBloc>()
.add(
const ChecklistCellEvent.createNewTask(""),
),
onPressed: onTap,
child: FlowySvg(
FlowySvgs.add_s,
color: Theme.of(context).colorScheme.onPrimary,
@@ -29,7 +29,6 @@ abstract class IEditableChecklistCellSkin {
BuildContext context,
CellContainerNotifier cellContainerNotifier,
ChecklistCellBloc bloc,
ChecklistCellState state,
PopoverController popoverController,
);
}
@@ -70,16 +69,11 @@ class GridChecklistCellState extends GridCellState<EditableChecklistCell> {
Widget build(BuildContext context) {
return BlocProvider.value(
value: cellBloc,
child: BlocBuilder<ChecklistCellBloc, ChecklistCellState>(
builder: (context, state) {
return widget.skin.build(
context,
widget.cellContainerNotifier,
cellBloc,
state,
_popover,
);
},
child: widget.skin.build(
context,
widget.cellContainerNotifier,
cellBloc,
_popover,
),
);
}
@@ -17,35 +17,38 @@ class MobileGridChecklistCellSkin extends IEditableChecklistCellSkin {
BuildContext context,
CellContainerNotifier cellContainerNotifier,
ChecklistCellBloc bloc,
ChecklistCellState state,
PopoverController popoverController,
) {
return FlowyButton(
radius: BorderRadius.zero,
hoverColor: Colors.transparent,
text: Container(
alignment: Alignment.centerLeft,
padding: GridSize.cellContentInsets,
child: state.tasks.isEmpty
? const SizedBox.shrink()
: ChecklistProgressBar(
tasks: state.tasks,
percent: state.percent,
textStyle: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(fontSize: 15),
),
),
onTap: () => showMobileBottomSheet(
context,
builder: (context) {
return BlocProvider.value(
value: bloc,
child: const MobileChecklistCellEditScreen(),
);
},
),
return BlocBuilder<ChecklistCellBloc, ChecklistCellState>(
builder: (context, state) {
return FlowyButton(
radius: BorderRadius.zero,
hoverColor: Colors.transparent,
text: Container(
alignment: Alignment.centerLeft,
padding: GridSize.cellContentInsets,
child: state.tasks.isEmpty
? const SizedBox.shrink()
: ChecklistProgressBar(
tasks: state.tasks,
percent: state.percent,
textStyle: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(fontSize: 15),
),
),
onTap: () => showMobileBottomSheet(
context,
builder: (context) {
return BlocProvider.value(
value: bloc,
child: const MobileChecklistCellEditScreen(),
);
},
),
);
},
);
}
}
@@ -19,49 +19,52 @@ class MobileRowDetailChecklistCellSkin extends IEditableChecklistCellSkin {
BuildContext context,
CellContainerNotifier cellContainerNotifier,
ChecklistCellBloc bloc,
ChecklistCellState state,
PopoverController popoverController,
) {
return InkWell(
borderRadius: const BorderRadius.all(Radius.circular(14)),
onTap: () => showMobileBottomSheet(
context,
backgroundColor: AFThemeExtension.of(context).background,
builder: (context) {
return BlocProvider.value(
value: bloc,
child: const MobileChecklistCellEditScreen(),
);
},
),
child: Container(
constraints: const BoxConstraints(
minHeight: 48,
minWidth: double.infinity,
),
decoration: BoxDecoration(
border: Border.fromBorderSide(
BorderSide(color: Theme.of(context).colorScheme.outline),
),
return BlocBuilder<ChecklistCellBloc, ChecklistCellState>(
builder: (context, state) {
return InkWell(
borderRadius: const BorderRadius.all(Radius.circular(14)),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 13),
alignment: AlignmentDirectional.centerStart,
child: state.tasks.isEmpty
? FlowyText(
LocaleKeys.grid_row_textPlaceholder.tr(),
fontSize: 15,
color: Theme.of(context).hintColor,
)
: ChecklistProgressBar(
tasks: state.tasks,
percent: state.percent,
textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontSize: 15,
color: Theme.of(context).hintColor,
),
onTap: () => showMobileBottomSheet(
context,
backgroundColor: AFThemeExtension.of(context).background,
builder: (context) {
return BlocProvider.value(
value: bloc,
child: const MobileChecklistCellEditScreen(),
);
},
),
child: Container(
constraints: const BoxConstraints(
minHeight: 48,
minWidth: double.infinity,
),
decoration: BoxDecoration(
border: Border.fromBorderSide(
BorderSide(color: Theme.of(context).colorScheme.outline),
),
),
borderRadius: const BorderRadius.all(Radius.circular(14)),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 13),
alignment: AlignmentDirectional.centerStart,
child: state.tasks.isEmpty
? FlowyText(
LocaleKeys.grid_row_textPlaceholder.tr(),
fontSize: 15,
color: Theme.of(context).hintColor,
)
: ChecklistProgressBar(
tasks: state.tasks,
percent: state.percent,
textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontSize: 15,
color: Theme.of(context).hintColor,
),
),
),
);
},
);
}
}
@@ -1,5 +1,6 @@
import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
@@ -108,23 +109,46 @@ class ChecklistItemList extends StatelessWidget {
final itemList = options
.mapIndexed(
(index, option) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0),
key: ValueKey(option.data.id),
child: ChecklistItem(
task: option,
index: index,
onSubmitted: index == options.length - 1 ? onUpdateTask : null,
key: ValueKey(option.data.id),
),
),
)
.toList();
return Flexible(
child: ListView.separated(
itemBuilder: (context, index) => itemList[index],
separatorBuilder: (context, index) => const VSpace(4),
itemCount: itemList.length,
child: ReorderableListView.builder(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 8.0),
proxyDecorator: (child, index, _) => Material(
color: Colors.transparent,
child: Stack(
children: [
BlocProvider.value(
value: context.read<ChecklistCellBloc>(),
child: child,
),
MouseRegion(
cursor: Platform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grabbing,
child: const SizedBox.expand(),
),
],
),
),
buildDefaultDragHandles: false,
itemBuilder: (context, index) => itemList[index],
itemCount: itemList.length,
padding: const EdgeInsets.symmetric(vertical: 6.0),
onReorder: (from, to) {
context
.read<ChecklistCellBloc>()
.add(ChecklistCellEvent.reorderTask(from, to));
},
),
);
}
@@ -142,17 +166,17 @@ class _UpdateTaskDescriptionIntent extends Intent {
const _UpdateTaskDescriptionIntent();
}
/// Represents an existing task
@visibleForTesting
class ChecklistItem extends StatefulWidget {
const ChecklistItem({
super.key,
required this.task,
required this.index,
this.onSubmitted,
this.autofocus = false,
});
final ChecklistSelectOption task;
final int index;
final VoidCallback? onSubmitted;
final bool autofocus;
@@ -167,27 +191,21 @@ class _ChecklistItemState extends State<ChecklistItem> {
bool isHovered = false;
bool isFocused = false;
bool isComposing = false;
final _debounceOnChanged = Debounce(
duration: const Duration(milliseconds: 300),
);
final selectTaskShortcut = {
SingleActivator(
LogicalKeyboardKey.enter,
meta: Platform.isMacOS,
control: !Platform.isMacOS,
): const _SelectTaskIntent(),
const SingleActivator(LogicalKeyboardKey.enter):
const _UpdateTaskDescriptionIntent(),
const SingleActivator(LogicalKeyboardKey.escape):
const _EndEditingTaskIntent(),
};
@override
void initState() {
super.initState();
textController.text = widget.task.data.name;
textController.addListener(() {
setState(() {
isComposing = !textController.value.composing.isCollapsed;
});
});
if (widget.autofocus) {
WidgetsBinding.instance.addPostFrameCallback((_) {
focusNode.requestFocus();
@@ -196,6 +214,15 @@ class _ChecklistItemState extends State<ChecklistItem> {
}
}
@override
void didUpdateWidget(covariant oldWidget) {
if (!focusNode.hasFocus &&
oldWidget.task.data.name != widget.task.data.name) {
textController.text = widget.task.data.name;
}
super.didUpdateWidget(oldWidget);
}
@override
void dispose() {
_debounceOnChanged.dispose();
@@ -208,9 +235,8 @@ class _ChecklistItemState extends State<ChecklistItem> {
@override
Widget build(BuildContext context) {
final isFocusedOrHovered =
isHovered || isFocused || textFieldFocusNode.hasFocus;
final color = isFocusedOrHovered
final isFocusedOrHovered = isHovered || isFocused;
final color = isFocusedOrHovered || textFieldFocusNode.hasFocus
? AFThemeExtension.of(context).lightGreyHover
: Colors.transparent;
return FocusableActionDetector(
@@ -222,7 +248,7 @@ class _ChecklistItemState extends State<ChecklistItem> {
isFocused = value;
}),
actions: _buildActions(),
shortcuts: selectTaskShortcut,
shortcuts: _buildShortcuts(),
child: Container(
constraints: BoxConstraints(
minHeight: GridSize.popoverItemHeight,
@@ -232,25 +258,46 @@ class _ChecklistItemState extends State<ChecklistItem> {
borderRadius: Corners.s6Border,
),
child: _buildChild(
context,
isFocusedOrHovered,
isFocusedOrHovered && !textFieldFocusNode.hasFocus,
),
),
);
}
Widget _buildChild(BuildContext context, bool isFocusedOrHovered) {
Widget _buildChild(bool showTrash) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ChecklistCellCheckIcon(task: widget.task),
ReorderableDragStartListener(
index: widget.index,
child: MouseRegion(
cursor: Platform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grab,
child: SizedBox(
width: 20,
height: 32,
child: Align(
alignment: AlignmentDirectional.centerEnd,
child: FlowySvg(
FlowySvgs.drag_element_s,
size: const Size.square(14),
color: AFThemeExtension.of(context).onBackground,
),
),
),
),
),
ChecklistCellCheckIcon(
task: widget.task,
),
Expanded(
child: ChecklistCellTextfield(
textController: textController,
focusNode: textFieldFocusNode,
autofocus: widget.autofocus,
onChanged: () {
_debounceOnChanged.call(() {
if (textController.selection.isCollapsed) {
if (!isComposing) {
_submitUpdateTaskDescription(textController.text);
}
});
@@ -266,7 +313,7 @@ class _ChecklistItemState extends State<ChecklistItem> {
},
),
),
if (isFocusedOrHovered)
if (showTrash)
ChecklistCellDeleteButton(
onPressed: () => context.read<ChecklistCellBloc>().add(
ChecklistCellEvent.deleteTask(widget.task.data.id),
@@ -276,6 +323,22 @@ class _ChecklistItemState extends State<ChecklistItem> {
);
}
Map<ShortcutActivator, Intent> _buildShortcuts() {
return {
SingleActivator(
LogicalKeyboardKey.enter,
meta: Platform.isMacOS,
control: !Platform.isMacOS,
): const _SelectTaskIntent(),
if (!isComposing)
const SingleActivator(LogicalKeyboardKey.enter):
const _UpdateTaskDescriptionIntent(),
if (!isComposing)
const SingleActivator(LogicalKeyboardKey.escape):
const _EndEditingTaskIntent(),
};
}
Map<Type, Action<Intent>> _buildActions() {
return {
_SelectTaskIntent: CallbackAction<_SelectTaskIntent>(
@@ -326,12 +389,19 @@ class NewTaskItem extends StatefulWidget {
}
class _NewTaskItemState extends State<NewTaskItem> {
final _textEditingController = TextEditingController();
bool _isCreateButtonEnabled = false;
final textController = TextEditingController();
bool isCreateButtonEnabled = false;
bool isComposing = false;
@override
void initState() {
super.initState();
textController.addListener(() {
setState(() {
isComposing = !textController.value.composing.isCollapsed;
});
});
if (widget.focusNode.canRequestFocus) {
widget.focusNode.requestFocus();
}
@@ -339,7 +409,7 @@ class _NewTaskItemState extends State<NewTaskItem> {
@override
void dispose() {
_textEditingController.dispose();
textController.dispose();
super.dispose();
}
@@ -353,13 +423,15 @@ class _NewTaskItemState extends State<NewTaskItem> {
const HSpace(8),
Expanded(
child: CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.enter): () =>
_createNewTask(context),
},
bindings: isComposing
? const {}
: {
const SingleActivator(LogicalKeyboardKey.enter): () =>
_createNewTask(context),
},
child: TextField(
focusNode: widget.focusNode,
controller: _textEditingController,
controller: textController,
style: Theme.of(context).textTheme.bodyMedium,
maxLines: null,
decoration: InputDecoration(
@@ -373,8 +445,7 @@ class _NewTaskItemState extends State<NewTaskItem> {
),
onSubmitted: (_) => _createNewTask(context),
onChanged: (value) => setState(
() => _isCreateButtonEnabled =
_textEditingController.text.isNotEmpty,
() => isCreateButtonEnabled = textController.text.isNotEmpty,
),
),
),
@@ -382,23 +453,23 @@ class _NewTaskItemState extends State<NewTaskItem> {
FlowyTextButton(
LocaleKeys.grid_checklist_submitNewTask.tr(),
fontSize: 11,
fillColor: _isCreateButtonEnabled
fillColor: isCreateButtonEnabled
? Theme.of(context).colorScheme.primary
: Theme.of(context).disabledColor,
hoverColor: _isCreateButtonEnabled
hoverColor: isCreateButtonEnabled
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).disabledColor,
fontColor: Theme.of(context).colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
onPressed: _isCreateButtonEnabled
onPressed: isCreateButtonEnabled
? () {
context.read<ChecklistCellBloc>().add(
ChecklistCellEvent.createNewTask(
_textEditingController.text,
textController.text,
),
);
widget.focusNode.requestFocus();
_textEditingController.clear();
textController.clear();
}
: null,
),
@@ -408,12 +479,12 @@ class _NewTaskItemState extends State<NewTaskItem> {
}
void _createNewTask(BuildContext context) {
final taskDescription = _textEditingController.text;
final taskDescription = textController.text;
if (taskDescription.isNotEmpty) {
context
.read<ChecklistCellBloc>()
.add(ChecklistCellEvent.createNewTask(taskDescription));
_textEditingController.clear();
textController.clear();
}
widget.focusNode.requestFocus();
}
@@ -40,23 +40,22 @@ class ChecklistCellTextfield extends StatelessWidget {
super.key,
required this.textController,
required this.focusNode,
required this.autofocus,
required this.onChanged,
this.onChanged,
this.contentPadding = const EdgeInsets.symmetric(
vertical: 10,
horizontal: 2,
),
this.onSubmitted,
});
final TextEditingController textController;
final FocusNode focusNode;
final bool autofocus;
final EdgeInsetsGeometry contentPadding;
final VoidCallback? onSubmitted;
final VoidCallback onChanged;
final VoidCallback? onChanged;
@override
Widget build(BuildContext context) {
const contentPadding = EdgeInsets.symmetric(
vertical: 6.0,
horizontal: 2.0,
);
return TextField(
controller: textController,
focusNode: focusNode,
@@ -65,11 +64,12 @@ class ChecklistCellTextfield extends StatelessWidget {
decoration: InputDecoration(
border: InputBorder.none,
isCollapsed: true,
isDense: true,
contentPadding: contentPadding,
hintText: LocaleKeys.grid_checklist_taskHint.tr(),
),
textInputAction: onSubmitted == null ? TextInputAction.next : null,
onChanged: (_) => onChanged(),
onChanged: (_) => onChanged?.call(),
onSubmitted: (_) => onSubmitted?.call(),
);
}
@@ -52,12 +52,12 @@ class _DateCellEditor extends State<DateCellEditor> {
includeTime: state.includeTime,
rebuildOnDaySelected: false,
onIncludeTimeChanged: (value) =>
dateCellBloc.add(DateCellEditorEvent.setIncludeTime(!value)),
dateCellBloc.add(DateCellEditorEvent.setIncludeTime(value)),
isRange: state.isRange,
startDay: state.isRange ? state.startDay : null,
endDay: state.isRange ? state.endDay : null,
onIsRangeChanged: (value) =>
dateCellBloc.add(DateCellEditorEvent.setIsRange(!value)),
dateCellBloc.add(DateCellEditorEvent.setIsRange(value)),
dateFormat: state.dateTypeOptionPB.dateFormat,
timeFormat: state.dateTypeOptionPB.timeFormat,
selectedDay: state.dateTime,
@@ -27,21 +27,17 @@ class _MobileChecklistCellEditScreenState
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: const BoxConstraints.tightFor(height: 420),
child: BlocBuilder<ChecklistCellBloc, ChecklistCellState>(
builder: (context, state) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const DragHandle(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: _buildHeader(context),
),
const Divider(),
const Expanded(child: _TaskList()),
],
);
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const DragHandle(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: _buildHeader(context),
),
const Divider(),
const Expanded(child: _TaskList()),
],
),
);
}
@@ -75,19 +71,42 @@ class _TaskList extends StatelessWidget {
state.tasks
.mapIndexed(
(index, task) => _ChecklistItem(
key: ValueKey('mobile_checklist_task_${task.data.id}'),
task: task,
autofocus: state.newTask && index == state.tasks.length - 1,
index: index,
autofocus: state.phantomIndex != null &&
index == state.tasks.length - 1,
onAutofocus: () {
context
.read<ChecklistCellBloc>()
.add(const ChecklistCellEvent.updatePhantomIndex(null));
},
),
)
.toList(),
);
cells.add(const _NewTaskButton());
cells.add(
const _NewTaskButton(key: ValueKey('mobile_checklist_new_task')),
);
return ListView.separated(
return ReorderableListView.builder(
shrinkWrap: true,
proxyDecorator: (child, index, _) => Material(
color: Colors.transparent,
child: BlocProvider.value(
value: context.read<ChecklistCellBloc>(),
child: child,
),
),
buildDefaultDragHandles: false,
itemCount: cells.length,
separatorBuilder: (_, __) => const VSpace(8),
itemBuilder: (_, int index) => cells[index],
itemBuilder: (_, index) => cells[index],
padding: const EdgeInsets.only(bottom: 12.0),
onReorder: (from, to) {
context
.read<ChecklistCellBloc>()
.add(ChecklistCellEvent.reorderTask(from, to));
},
);
},
);
@@ -95,26 +114,37 @@ class _TaskList extends StatelessWidget {
}
class _ChecklistItem extends StatefulWidget {
const _ChecklistItem({required this.task, required this.autofocus});
const _ChecklistItem({
super.key,
required this.task,
required this.index,
required this.autofocus,
this.onAutofocus,
});
final ChecklistSelectOption task;
final int index;
final bool autofocus;
final VoidCallback? onAutofocus;
@override
State<_ChecklistItem> createState() => _ChecklistItemState();
}
class _ChecklistItemState extends State<_ChecklistItem> {
late final TextEditingController _textController;
final FocusNode _focusNode = FocusNode();
late final TextEditingController textController;
final FocusNode focusNode = FocusNode();
Timer? _debounceOnChanged;
@override
void initState() {
super.initState();
_textController = TextEditingController(text: widget.task.data.name);
textController = TextEditingController(text: widget.task.data.name);
if (widget.autofocus) {
_focusNode.requestFocus();
WidgetsBinding.instance.addPostFrameCallback((_) {
focusNode.requestFocus();
widget.onAutofocus?.call();
});
}
}
@@ -122,47 +152,50 @@ class _ChecklistItemState extends State<_ChecklistItem> {
void didUpdateWidget(covariant oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.task.data.name != oldWidget.task.data.name &&
!_focusNode.hasFocus) {
_textController.text = widget.task.data.name;
!focusNode.hasFocus) {
textController.text = widget.task.data.name;
}
}
@override
void dispose() {
_textController.dispose();
_focusNode.dispose();
textController.dispose();
focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5),
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 4),
constraints: const BoxConstraints(minHeight: 44),
child: Row(
children: [
InkWell(
borderRadius: BorderRadius.circular(22),
onTap: () => context
.read<ChecklistCellBloc>()
.add(ChecklistCellEvent.selectTask(widget.task.data.id)),
child: SizedBox.square(
dimension: 44,
child: Center(
child: FlowySvg(
widget.task.isSelected
? FlowySvgs.check_filled_s
: FlowySvgs.uncheck_s,
size: const Size.square(20.0),
blendMode: BlendMode.dst,
ReorderableDelayedDragStartListener(
index: widget.index,
child: InkWell(
borderRadius: BorderRadius.circular(22),
onTap: () => context
.read<ChecklistCellBloc>()
.add(ChecklistCellEvent.selectTask(widget.task.data.id)),
child: SizedBox.square(
dimension: 44,
child: Center(
child: FlowySvg(
widget.task.isSelected
? FlowySvgs.check_filled_s
: FlowySvgs.uncheck_s,
size: const Size.square(20.0),
blendMode: BlendMode.dst,
),
),
),
),
),
Expanded(
child: TextField(
controller: _textController,
focusNode: _focusNode,
controller: textController,
focusNode: focusNode,
style: Theme.of(context).textTheme.bodyMedium,
keyboardType: TextInputType.multiline,
maxLines: null,
@@ -261,7 +294,7 @@ class _ChecklistItemState extends State<_ChecklistItem> {
}
class _NewTaskButton extends StatelessWidget {
const _NewTaskButton();
const _NewTaskButton({super.key});
@override
Widget build(BuildContext context) {
@@ -270,6 +303,9 @@ class _NewTaskButton extends StatelessWidget {
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () {
context
.read<ChecklistCellBloc>()
.add(const ChecklistCellEvent.updatePhantomIndex(-1));
context
.read<ChecklistCellBloc>()
.add(const ChecklistCellEvent.createNewTask(""));
@@ -629,23 +629,23 @@ class FieldNameTextField extends StatefulWidget {
}
class _FieldNameTextFieldState extends State<FieldNameTextField> {
FocusNode focusNode = FocusNode();
final focusNode = FocusNode();
@override
void initState() {
super.initState();
focusNode.addListener(() {
if (focusNode.hasFocus) {
widget.popoverMutex?.close();
}
});
focusNode.addListener(_onFocusChanged);
widget.popoverMutex?.addPopoverListener(_onPopoverChanged);
}
widget.popoverMutex?.listenOnPopoverChanged(() {
if (focusNode.hasFocus) {
focusNode.unfocus();
}
});
@override
void dispose() {
widget.popoverMutex?.removePopoverListener(_onPopoverChanged);
focusNode.removeListener(_onFocusChanged);
focusNode.dispose();
super.dispose();
}
@override
@@ -662,15 +662,16 @@ class _FieldNameTextFieldState extends State<FieldNameTextField> {
);
}
@override
void dispose() {
focusNode.removeListener(() {
if (focusNode.hasFocus) {
widget.popoverMutex?.close();
}
});
focusNode.dispose();
super.dispose();
void _onFocusChanged() {
if (focusNode.hasFocus) {
widget.popoverMutex?.close();
}
}
void _onPopoverChanged() {
if (focusNode.hasFocus) {
focusNode.unfocus();
}
}
}
@@ -41,7 +41,7 @@ class MediaTypeOptionEditorFactory implements TypeOptionEditorFactory {
value: typeOption.hideFileNames,
onChanged: (value) {
onTypeOptionUpdated(
_toggleHideFiles(typeOption, !value).writeToBuffer(),
_toggleHideFiles(typeOption, value).writeToBuffer(),
);
},
padding: EdgeInsets.zero,
@@ -206,22 +206,23 @@ class CreateOptionTextField extends StatefulWidget {
}
class _CreateOptionTextFieldState extends State<CreateOptionTextField> {
late final FocusNode _focusNode;
final focusNode = FocusNode();
@override
void initState() {
super.initState();
_focusNode = FocusNode()
..addListener(() {
if (_focusNode.hasFocus) {
widget.popoverMutex?.close();
}
});
widget.popoverMutex?.listenOnPopoverChanged(() {
if (_focusNode.hasFocus) {
_focusNode.unfocus();
}
});
focusNode.addListener(_onFocusChanged);
widget.popoverMutex?.addPopoverListener(_onPopoverChanged);
}
@override
void dispose() {
widget.popoverMutex?.removePopoverListener(_onPopoverChanged);
focusNode.removeListener(_onFocusChanged);
focusNode.dispose();
super.dispose();
}
@override
@@ -234,7 +235,7 @@ class _CreateOptionTextFieldState extends State<CreateOptionTextField> {
child: FlowyTextField(
autoClearWhenDone: true,
text: text,
focusNode: _focusNode,
focusNode: focusNode,
onCanceled: () {
context
.read<SelectOptionTypeOptionBloc>()
@@ -252,15 +253,16 @@ class _CreateOptionTextFieldState extends State<CreateOptionTextField> {
);
}
@override
void dispose() {
_focusNode.removeListener(() {
if (_focusNode.hasFocus) {
widget.popoverMutex?.close();
}
});
_focusNode.dispose();
super.dispose();
void _onFocusChanged() {
if (focusNode.hasFocus) {
widget.popoverMutex?.close();
}
}
void _onPopoverChanged() {
if (focusNode.hasFocus) {
focusNode.unfocus();
}
}
}
@@ -34,7 +34,7 @@ class TimestampTypeOptionEditorFactory implements TypeOptionEditorFactory {
onChanged: (value) {
final newTypeOption = _updateTypeOption(
typeOption: typeOption,
includeTime: !value,
includeTime: value,
);
onTypeOptionUpdated(newTypeOption.writeToBuffer());
},
@@ -72,7 +72,7 @@ class DatabaseGroupList extends StatelessWidget {
Toggle(
value: !state.layoutSettings.hideUngroupedColumn,
onChanged: (value) =>
_updateLayoutSettings(state.layoutSettings, value),
_updateLayoutSettings(state.layoutSettings, !value),
padding: EdgeInsets.zero,
),
],
@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/grid/application/row/row_document_bloc.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart';
import 'package:appflowy/plugins/document/presentation/editor_drop_handler.dart';
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/shared/flowy_error_page.dart';
@@ -12,7 +12,6 @@ import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
class RowDocument extends StatelessWidget {
const RowDocument({
@@ -45,7 +44,7 @@ class RowDocument extends StatelessWidget {
),
),
finish: () => _RowEditor(
viewPB: state.viewPB!,
view: state.viewPB!,
onIsEmptyChanged: (isEmpty) => context
.read<RowDocumentBloc>()
.add(RowDocumentEvent.updateIsEmpty(isEmpty)),
@@ -59,84 +58,72 @@ class RowDocument extends StatelessWidget {
class _RowEditor extends StatelessWidget {
const _RowEditor({
required this.viewPB,
required this.view,
this.onIsEmptyChanged,
});
final ViewPB viewPB;
final ViewPB view;
final void Function(bool)? onIsEmptyChanged;
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
// Due to how DropTarget works, there is no way to differentiate if an overlay is
// blocking the target visibly, so when we have an overlay with a drop target,
// we should disable the drop target for the Editor, until it is closed.
//
// See FileBlockComponent for sample use.
//
// Relates to:
// - https://github.com/MixinNetwork/flutter-plugins/issues/2
// - https://github.com/MixinNetwork/flutter-plugins/issues/331
//
create: (_) => EditorDropManagerState(),
child: BlocProvider(
create: (context) => DocumentBloc(documentId: viewPB.id)
..add(const DocumentEvent.initial()),
child: BlocConsumer<DocumentBloc, DocumentState>(
listenWhen: (previous, current) =>
previous.isDocumentEmpty != current.isDocumentEmpty,
listener: (_, state) {
if (state.isDocumentEmpty != null) {
onIsEmptyChanged?.call(state.isDocumentEmpty!);
}
if (state.error != null) {
Log.error('RowEditor error: ${state.error}');
}
if (state.editorState == null) {
Log.error('RowEditor unable to get editorState');
}
},
builder: (context, state) {
if (state.isLoading) {
return const Center(
child: CircularProgressIndicator.adaptive(),
);
}
return BlocProvider(
create: (_) =>
DocumentBloc(documentId: view.id)..add(const DocumentEvent.initial()),
child: BlocConsumer<DocumentBloc, DocumentState>(
listenWhen: (previous, current) =>
previous.isDocumentEmpty != current.isDocumentEmpty,
listener: (_, state) {
if (state.isDocumentEmpty != null) {
onIsEmptyChanged?.call(state.isDocumentEmpty!);
}
if (state.error != null) {
Log.error('RowEditor error: ${state.error}');
}
if (state.editorState == null) {
Log.error('RowEditor unable to get editorState');
}
},
builder: (context, state) {
if (state.isLoading) {
return const Center(child: CircularProgressIndicator.adaptive());
}
final editorState = state.editorState;
final error = state.error;
if (error != null || editorState == null) {
return Center(
child: AppFlowyErrorPage(error: error),
);
}
final editorState = state.editorState;
final error = state.error;
if (error != null || editorState == null) {
return Center(
child: AppFlowyErrorPage(error: error),
);
}
return Consumer<EditorDropManagerState>(
builder: (_, dropState, __) => BlocProvider<ViewInfoBloc>(
create: (context) => ViewInfoBloc(view: viewPB),
child: IntrinsicHeight(
child: Container(
constraints: const BoxConstraints(minHeight: 300),
child: AppFlowyEditorPage(
shrinkWrap: true,
autoFocus: false,
editorState: editorState,
styleCustomizer: EditorStyleCustomizer(
context: context,
padding: const EdgeInsets.only(left: 16, right: 54),
),
showParagraphPlaceholder: (editorState, node) =>
editorState.document.isEmpty,
placeholderText: (node) =>
LocaleKeys.cardDetails_notesPlaceholder.tr(),
return BlocProvider<ViewInfoBloc>(
create: (context) => ViewInfoBloc(view: view),
child: IntrinsicHeight(
child: Container(
constraints: const BoxConstraints(minHeight: 300),
child: EditorDropHandler(
viewId: view.id,
editorState: editorState,
isLocalMode: context.read<DocumentBloc>().isLocalMode,
child: AppFlowyEditorPage(
shrinkWrap: true,
autoFocus: false,
editorState: editorState,
styleCustomizer: EditorStyleCustomizer(
context: context,
padding: const EdgeInsets.only(left: 16, right: 54),
),
showParagraphPlaceholder: (editorState, _) =>
editorState.document.isEmpty,
placeholderText: (_) =>
LocaleKeys.cardDetails_notesPlaceholder.tr(),
),
),
),
);
},
),
),
);
},
),
);
}
@@ -1,3 +1,4 @@
import 'package:appflowy/plugins/document/presentation/editor_drop_handler.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/database/application/row/related_row_detail_bloc.dart';
@@ -105,15 +106,20 @@ class _DatabaseDocumentPageState extends State<DatabaseDocumentPage> {
}
Widget _buildEditorPage(BuildContext context, DocumentState state) {
final appflowyEditorPage = AppFlowyEditorPage(
final appflowyEditorPage = EditorDropHandler(
viewId: widget.view.id,
editorState: state.editorState!,
styleCustomizer: EditorStyleCustomizer(
context: context,
padding: EditorStyleCustomizer.documentPadding,
isLocalMode: context.read<DocumentBloc>().isLocalMode,
child: AppFlowyEditorPage(
editorState: state.editorState!,
styleCustomizer: EditorStyleCustomizer(
context: context,
padding: EditorStyleCustomizer.documentPadding,
),
header: _buildDatabaseDataContent(context, state.editorState!),
initialSelection: widget.initialSelection,
useViewInfoBloc: false,
),
header: _buildDatabaseDataContent(context, state.editorState!),
initialSelection: widget.initialSelection,
useViewInfoBloc: false,
);
return Column(
@@ -182,6 +188,7 @@ class _DatabaseDocumentPageState extends State<DatabaseDocumentPage> {
Widget _buildBanner(BuildContext context) {
return DocumentBanner(
viewName: widget.view.name,
onRestore: () => context.read<DocumentBloc>().add(
const DocumentEvent.restorePage(),
),
@@ -1,4 +1,6 @@
import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
@@ -36,6 +38,41 @@ class DocumentService {
return result;
}
Future<FlowyResult<(DocumentDataPB, BlockPB, Node), FlowyError>>
getDocumentNode({
required String documentId,
required String blockId,
}) async {
final documentResult = await getDocument(documentId: documentId);
final document = documentResult.fold((l) => l, (f) => null);
if (document == null) {
Log.error('unable to get the document for page $documentId');
return FlowyResult.failure(FlowyError(msg: 'Document not found'));
}
final blockResult = await getBlockFromDocument(
document: document,
blockId: blockId,
);
final block = blockResult.fold((l) => l, (f) => null);
if (block == null) {
Log.error(
'unable to get the block $blockId from the document $documentId',
);
return FlowyResult.failure(FlowyError(msg: 'Block not found'));
}
final node = document.buildNode(blockId);
if (node == null) {
Log.error(
'unable to get the node for block $blockId in document $documentId',
);
return FlowyResult.failure(FlowyError(msg: 'Node not found'));
}
return FlowyResult.success((document, block, node));
}
Future<FlowyResult<BlockPB, FlowyError>> getBlockFromDocument({
required DocumentDataPB document,
required String blockId,
@@ -281,8 +281,8 @@ extension on UpdateOperation {
assert(parentId.isNotEmpty);
// create the external text if the node contains the delta in its data.
final prevDelta = oldAttributes['delta'];
final delta = attributes['delta'];
final prevDelta = oldAttributes[blockComponentDelta];
final delta = attributes[blockComponentDelta];
final diff = prevDelta != null && delta != null
? Delta.fromJson(prevDelta).diff(
Delta.fromJson(delta),
@@ -290,6 +290,7 @@ extension on UpdateOperation {
: null;
final composedAttributes = composeAttributes(oldAttributes, attributes);
final composedDelta = composedAttributes?[blockComponentDelta];
composedAttributes?.remove(blockComponentDelta);
final payload = BlockActionPayloadPB()
@@ -306,12 +307,13 @@ extension on UpdateOperation {
if (textId == null || textId.isEmpty) {
// to be compatible with the old version, we create a new text id if the text id is empty.
final textId = nanoid(6);
final textDeltaPayloadPB = delta == null
final textDelta = composedDelta ?? delta ?? prevDelta;
final textDeltaPayloadPB = textDelta == null
? null
: TextDeltaPayloadPB(
documentId: documentId,
textId: textId,
delta: jsonEncode(delta),
delta: jsonEncode(textDelta),
);
node.externalValues = ExternalValues(
@@ -51,6 +51,7 @@ class DocumentPlugin extends Plugin {
required ViewPB view,
required PluginType pluginType,
this.initialSelection,
this.initialBlockId,
}) : notifier = ViewPluginNotifier(view: view) {
_pluginType = pluginType;
}
@@ -61,13 +62,18 @@ class DocumentPlugin extends Plugin {
@override
final ViewPluginNotifier notifier;
// the initial selection of the document
final Selection? initialSelection;
// the initial block id of the document
final String? initialBlockId;
@override
PluginWidgetBuilder get widgetBuilder => DocumentPluginWidgetBuilder(
bloc: _viewInfoBloc,
notifier: notifier,
initialSelection: initialSelection,
initialBlockId: initialBlockId,
);
@override
@@ -95,6 +101,7 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
required this.bloc,
required this.notifier,
this.initialSelection,
this.initialBlockId,
});
final ViewInfoBloc bloc;
@@ -102,6 +109,7 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
ViewPB get view => notifier.view;
int? deletedViewIndex;
final Selection? initialSelection;
final String? initialBlockId;
@override
EdgeInsets get contentPadding => EdgeInsets.zero;
@@ -129,6 +137,7 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
view: view,
onDeleted: () => context.onDeleted?.call(view, deletedViewIndex),
initialSelection: initialSelection,
initialBlockId: initialBlockId,
fixedTitle: fixedTitle,
),
),
@@ -1,55 +1,42 @@
import 'dart:async';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/banner.dart';
import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart';
import 'package:appflowy/plugins/document/presentation/editor_notification.dart';
import 'package:appflowy/plugins/document/presentation/editor_drop_handler.dart';
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_file.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/shared/flowy_error_page.dart';
import 'package:appflowy/shared/patterns/file_type_patterns.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart';
import 'package:appflowy/workspace/application/action_navigation/navigation_action.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_editor/appflowy_editor.dart';
import 'package:cross_file/cross_file.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import 'package:universal_platform/universal_platform.dart';
const _excludeFromDropTarget = [
ImageBlockKeys.type,
CustomImageBlockKeys.type,
MultiImageBlockKeys.type,
FileBlockKeys.type,
];
class DocumentPage extends StatefulWidget {
const DocumentPage({
super.key,
required this.view,
required this.onDeleted,
this.initialSelection,
this.initialBlockId,
this.fixedTitle,
});
final ViewPB view;
final VoidCallback onDeleted;
final Selection? initialSelection;
final String? initialBlockId;
final String? fixedTitle;
@override
@@ -62,25 +49,16 @@ class _DocumentPageState extends State<DocumentPage>
late final documentBloc = DocumentBloc(documentId: widget.view.id)
..add(const DocumentEvent.initial());
StreamSubscription<(TransactionTime, Transaction)>? transactionSubscription;
bool isUndoRedo = false;
bool isPaste = false;
bool isDraggingNode = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
EditorNotification.addListener(onEditorNotification);
}
@override
void dispose() {
EditorNotification.removeListener(onEditorNotification);
WidgetsBinding.instance.removeObserver(this);
documentBloc.close();
transactionSubscription?.cancel();
super.dispose();
}
@@ -96,55 +74,37 @@ class _DocumentPageState extends State<DocumentPage>
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
// Due to how DropTarget works, there is no way to differentiate if an overlay is
// blocking the target visibly, so when we have an overlay with a drop target,
// we should disable the drop target for the Editor, until it is closed.
//
// See FileBlockComponent for sample use.
//
// Relates to:
// - https://github.com/MixinNetwork/flutter-plugins/issues/2
// - https://github.com/MixinNetwork/flutter-plugins/issues/331
//
create: (_) => EditorDropManagerState(),
child: MultiBlocProvider(
providers: [
BlocProvider.value(value: getIt<ActionNavigationBloc>()),
BlocProvider.value(value: documentBloc),
],
child: BlocBuilder<DocumentBloc, DocumentState>(
buildWhen: shouldRebuildDocument,
builder: (context, state) {
if (state.isLoading) {
return const Center(child: CircularProgressIndicator.adaptive());
}
return MultiBlocProvider(
providers: [
BlocProvider.value(value: getIt<ActionNavigationBloc>()),
BlocProvider.value(value: documentBloc),
],
child: BlocBuilder<DocumentBloc, DocumentState>(
buildWhen: shouldRebuildDocument,
builder: (context, state) {
if (state.isLoading) {
return const Center(child: CircularProgressIndicator.adaptive());
}
final editorState = state.editorState;
this.editorState = editorState;
final error = state.error;
if (error != null || editorState == null) {
Log.error(error);
return Center(child: AppFlowyErrorPage(error: error));
}
final editorState = state.editorState;
this.editorState = editorState;
final error = state.error;
if (error != null || editorState == null) {
Log.error(error);
return Center(child: AppFlowyErrorPage(error: error));
}
if (state.forceClose) {
widget.onDeleted();
return const SizedBox.shrink();
}
if (state.forceClose) {
widget.onDeleted();
return const SizedBox.shrink();
}
editorState.transactionStream.listen(onEditorTransaction);
return BlocListener<ActionNavigationBloc, ActionNavigationState>(
listenWhen: (_, curr) => curr.action != null,
listener: onNotificationAction,
child: Consumer<EditorDropManagerState>(
builder: (context, dropState, _) =>
buildEditorPage(context, state, dropState),
),
);
},
),
return BlocListener<ActionNavigationBloc, ActionNavigationState>(
listenWhen: (_, curr) => curr.action != null,
listener: onNotificationAction,
child: buildEditorPage(context, state),
);
},
),
);
}
@@ -152,15 +112,19 @@ class _DocumentPageState extends State<DocumentPage>
Widget buildEditorPage(
BuildContext context,
DocumentState state,
EditorDropManagerState dropState,
) {
final editorState = state.editorState;
if (editorState == null) {
return const SizedBox.shrink();
}
final width = context.read<DocumentAppearanceCubit>().state.width;
final Widget child;
if (UniversalPlatform.isMobile) {
child = BlocBuilder<DocumentPageStyleBloc, DocumentPageStyleState>(
builder: (context, styleState) => AppFlowyEditorPage(
editorState: state.editorState!,
editorState: editorState,
// if the view's name is empty, focus on the title
autoFocus: widget.view.name.isEmpty ? false : null,
styleCustomizer: EditorStyleCustomizer(
@@ -173,85 +137,12 @@ class _DocumentPageState extends State<DocumentPage>
),
);
} else {
child = DropTarget(
enable: dropState.isDropEnabled,
onDragExited: (_) =>
state.editorState!.selectionService.removeDropTarget(),
onDragUpdated: (details) {
final data = state.editorState!.selectionService
.getDropTargetRenderData(details.globalPosition);
if (data != null &&
data.dropPath != null &&
// We implement custom Drop logic for image blocks, this is
// how we can exclude them from the Drop Target
!_excludeFromDropTarget.contains(data.cursorNode?.type)) {
// Render the drop target
state.editorState!.selectionService
.renderDropTargetForOffset(details.globalPosition);
} else {
state.editorState!.selectionService.removeDropTarget();
}
},
onDragDone: (details) async {
final editorState = state.editorState;
if (editorState == null) {
return;
}
editorState.selectionService.removeDropTarget();
final data = editorState.selectionService
.getDropTargetRenderData(details.globalPosition);
if (data != null) {
final cursorNode = data.cursorNode;
final dropPath = data.dropPath;
if (cursorNode != null && dropPath != null) {
if (_excludeFromDropTarget.contains(cursorNode.type)) {
return;
}
final node = editorState.getNodeAtPath(dropPath);
if (node == null) {
return;
}
final isLocalMode = context.read<DocumentBloc>().isLocalMode;
final List<XFile> imageFiles = [];
final List<XFile> otherFiles = [];
for (final file in details.files) {
final fileName = file.name.toLowerCase();
if (file.mimeType?.startsWith('image/') ??
false || imgExtensionRegex.hasMatch(fileName)) {
imageFiles.add(file);
} else {
otherFiles.add(file);
}
}
await editorState.dropImages(
node,
imageFiles,
widget.view.id,
isLocalMode,
);
await editorState.dropFiles(
node,
otherFiles,
widget.view.id,
isLocalMode,
);
}
}
},
child = EditorDropHandler(
viewId: widget.view.id,
editorState: editorState,
isLocalMode: context.read<DocumentBloc>().isLocalMode,
child: AppFlowyEditorPage(
editorState: state.editorState!,
editorState: editorState,
// if the view's name is empty, focus on the title
autoFocus: widget.view.name.isEmpty ? false : null,
styleCustomizer: EditorStyleCustomizer(
@@ -260,24 +151,29 @@ class _DocumentPageState extends State<DocumentPage>
padding: EditorStyleCustomizer.documentPadding,
),
header: buildCoverAndIcon(context, state),
initialSelection: widget.initialSelection,
initialSelection: _calculateInitialSelection(editorState),
),
);
}
return Provider(
create: (_) => SharedEditorContext(),
child: Column(
children: [
if (state.isDeleted) buildBanner(context),
Expanded(child: child),
],
child: EditorTransactionService(
viewId: widget.view.id,
editorState: state.editorState!,
child: Column(
children: [
if (state.isDeleted) buildBanner(context),
Expanded(child: child),
],
),
),
);
}
Widget buildBanner(BuildContext context) {
return DocumentBanner(
viewName: widget.view.nameOrDefault,
onRestore: () =>
context.read<DocumentBloc>().add(const DocumentEvent.restorePage()),
onDelete: () => context
@@ -313,47 +209,58 @@ class _DocumentPageState extends State<DocumentPage>
);
}
void onEditorNotification(EditorNotificationType type) {
final editorState = this.editorState;
if (editorState == null) {
return;
}
if ([EditorNotificationType.undo, EditorNotificationType.redo]
.contains(type)) {
isUndoRedo = true;
} else if (type == EditorNotificationType.paste) {
isPaste = true;
} else if (type == EditorNotificationType.dragStart) {
isDraggingNode = true;
} else if (type == EditorNotificationType.dragEnd) {
isDraggingNode = false;
}
if (type == EditorNotificationType.undo) {
undoCommand.execute(editorState);
} else if (type == EditorNotificationType.redo) {
redoCommand.execute(editorState);
} else if (type == EditorNotificationType.exitEditing &&
editorState.selection != null) {
editorState.selection = null;
}
}
void onNotificationAction(
BuildContext context,
ActionNavigationState state,
) {
if (state.action != null && state.action!.type == ActionType.jumpToBlock) {
final path = state.action?.arguments?[ActionArgumentKeys.nodePath];
final action = state.action;
if (action == null ||
action.type != ActionType.jumpToBlock ||
action.objectId != widget.view.id) {
return;
}
final editorState = context.read<DocumentBloc>().state.editorState;
if (editorState != null && widget.view.id == state.action?.objectId) {
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: [path])),
);
final editorState = context.read<DocumentBloc>().state.editorState;
if (editorState == null) {
return;
}
final Path? path = _getPathFromAction(action, editorState);
if (path != null) {
debugPrint('jump to block: $path');
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: path)),
);
}
}
Path? _getPathFromAction(NavigationAction action, EditorState editorState) {
Path? path = action.arguments?[ActionArgumentKeys.nodePath];
if (path == null || path.isEmpty) {
final blockId = action.arguments?[ActionArgumentKeys.blockId];
if (blockId != null) {
path = _findNodePathByBlockId(editorState, blockId);
}
}
return path;
}
Path? _findNodePathByBlockId(EditorState editorState, String blockId) {
final document = editorState.document;
final startNode = document.root.children.firstOrNull;
if (startNode == null) {
return null;
}
final nodeIterator = NodeIterator(document: document, startNode: startNode);
while (nodeIterator.moveNext()) {
final node = nodeIterator.current;
if (node.id == blockId) {
return node.path;
}
}
return null;
}
bool shouldRebuildDocument(DocumentState previous, DocumentState current) {
@@ -383,85 +290,23 @@ class _DocumentPageState extends State<DocumentPage>
return false;
}
List<Node> collectMatchingNodes(Node node, String type) {
final List<Node> matchingNodes = [];
if (node.type == type) {
matchingNodes.add(node);
Selection? _calculateInitialSelection(EditorState editorState) {
if (widget.initialSelection != null) {
return widget.initialSelection;
}
for (final child in node.children) {
matchingNodes.addAll(collectMatchingNodes(child, type));
}
return matchingNodes;
}
void onEditorTransaction((TransactionTime, Transaction) event) {
if (editorState == null || event.$1 == TransactionTime.before) {
return;
}
final Map<String, List<Node>> addedNodes = {
for (final handler in SharedEditorContext.transactionHandlers)
handler.blockType: [],
};
final Map<String, List<Node>> removedNodes = {
for (final handler in SharedEditorContext.transactionHandlers)
handler.blockType: [],
};
final transactionHandlerTypes = SharedEditorContext.transactionHandlers
.map((h) => h.blockType)
.toList();
// Collect all matching nodes in a performant way for each handler type.
for (final op in event.$2.operations) {
if (op is InsertOperation) {
for (final n in op.nodes) {
for (final handlerType in transactionHandlerTypes) {
if (n.type == handlerType) {
addedNodes[handlerType]!
.addAll(collectMatchingNodes(n, handlerType));
}
}
}
} else if (op is DeleteOperation) {
for (final n in op.nodes) {
for (final handlerType in transactionHandlerTypes) {
if (n.type == handlerType) {
removedNodes[handlerType]!
.addAll(collectMatchingNodes(n, handlerType));
}
}
}
if (widget.initialBlockId != null) {
final path = _findNodePathByBlockId(editorState, widget.initialBlockId!);
if (path != null) {
editorState.selectionType = SelectionType.block;
return Selection.collapsed(
Position(
path: path,
),
);
}
}
if (removedNodes.isEmpty && addedNodes.isEmpty) {
return;
}
for (final handler in SharedEditorContext.transactionHandlers) {
final added = addedNodes[handler.blockType] ?? [];
final removed = removedNodes[handler.blockType] ?? [];
if (added.isEmpty && removed.isEmpty) {
continue;
}
handler.onTransaction(
context,
editorState!,
added,
removed,
isUndoRedo: isUndoRedo,
isPaste: isPaste,
isDraggingNode: isDraggingNode,
parentViewId: widget.view.id,
);
isUndoRedo = false;
isPaste = false;
}
return null;
}
}
@@ -1,18 +1,21 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/buttons/base_styled_button.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
class DocumentBanner extends StatelessWidget {
const DocumentBanner({
super.key,
required this.viewName,
required this.onRestore,
required this.onDelete,
});
final String viewName;
final void Function() onRestore;
final void Function() onDelete;
@@ -58,7 +61,16 @@ class DocumentBanner extends StatelessWidget {
highlightColor: Theme.of(context).colorScheme.error,
outlineColor: colorScheme.tertiaryContainer,
borderRadius: Corners.s8Border,
onPressed: onDelete,
onPressed: () => showConfirmDeletionDialog(
context: context,
name: viewName.trim().isEmpty
? LocaleKeys.menuAppHeader_defaultNewPageName.tr()
: viewName,
description: LocaleKeys
.deletePagePrompt_deletePermanentDescription
.tr(),
onConfirm: onDelete,
),
child: FlowyText.medium(
LocaleKeys.deletePagePrompt_deletePermanent.tr(),
color: colorScheme.tertiary,
@@ -18,7 +18,17 @@ import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:universal_platform/universal_platform.dart';
Map<String, BlockComponentBuilder> getEditorBuilderMap({
/// Build the block component builders.
///
/// Every block type should have a corresponding builder in the map.
/// Otherwise, the errorBlockComponentBuilder will be rendered.
///
/// Additional, you can define the block render options in the builder
/// - customize the block option actions. (... button and + button)
/// - customize the block component configuration. (padding, placeholder, etc.)
/// - customize the block icon. (bulleted list, numbered list, todo list)
/// - customize the hover menu. (show the menu at the top-right corner of the block)
Map<String, BlockComponentBuilder> buildBlockComponentBuilders({
required BuildContext context,
required EditorState editorState,
required EditorStyleCustomizer styleCustomizer,
@@ -28,18 +38,32 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
String Function(Node)? placeholderText,
EdgeInsets? customHeadingPadding,
}) {
final standardActions = [
OptionAction.delete,
OptionAction.duplicate,
// Copy link to block feature is disable temporarily, enable this test when the feature is ready.
// filter out the copy link to block option if in local mode
// if (context.read<DocumentBloc?>()?.isLocalMode != true)
// OptionAction.copyLinkToBlock,
];
final configuration = _buildDefaultConfiguration(context);
final builders = _buildBlockComponentBuilderMap(
context,
configuration: configuration,
editorState: editorState,
styleCustomizer: styleCustomizer,
showParagraphPlaceholder: showParagraphPlaceholder,
placeholderText: placeholderText,
);
final calloutBGColor = AFThemeExtension.of(context).calloutBGColor;
// customize the action builder. actually, we can customize them in their own builder. Put them here just for convenience.
if (editable) {
_customBlockOptionActions(
context,
builders: builders,
editorState: editorState,
styleCustomizer: styleCustomizer,
slashMenuItems: slashMenuItems,
);
}
return builders;
}
BlockComponentConfiguration _buildDefaultConfiguration(BuildContext context) {
final configuration = BlockComponentConfiguration(
// use EdgeInsets.zero to remove the default padding.
padding: (_) {
if (UniversalPlatform.isMobile) {
final pageStyle = context.read<DocumentPageStyleBloc>().state;
@@ -54,218 +78,206 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
? const EdgeInsets.only(left: 26.0)
: const EdgeInsets.only(right: 26.0),
);
return configuration;
}
/// Build the option actions for the block component.
///
/// Notes: different block type may have different option actions.
/// All the block types have the delete and duplicate options.
List<OptionAction> _buildOptionActions(BuildContext context, String type) {
final standardActions = [
OptionAction.delete,
OptionAction.duplicate,
];
// filter out the copy link to block option if in local mode
if (context.read<DocumentBloc?>()?.isLocalMode != true) {
standardActions.add(OptionAction.copyLinkToBlock);
}
if (EditorOptionActionType.turnInto.supportTypes.contains(type)) {
standardActions.add(OptionAction.turnInto);
}
if (EditorOptionActionType.color.supportTypes.contains(type)) {
standardActions.addAll([OptionAction.divider, OptionAction.color]);
}
if (EditorOptionActionType.align.supportTypes.contains(type)) {
standardActions.addAll([OptionAction.divider, OptionAction.align]);
}
if (EditorOptionActionType.depth.supportTypes.contains(type)) {
standardActions.addAll([OptionAction.divider, OptionAction.depth]);
}
return standardActions;
}
void _customBlockOptionActions(
BuildContext context, {
required Map<String, BlockComponentBuilder> builders,
required EditorState editorState,
required EditorStyleCustomizer styleCustomizer,
List<SelectionMenuItem>? slashMenuItems,
}) {
for (final entry in builders.entries) {
if (entry.key == PageBlockKeys.type) {
continue;
}
final builder = entry.value;
final actions = _buildOptionActions(context, entry.key);
if (UniversalPlatform.isDesktop) {
builder.showActions =
(node) => node.parent?.type != TableCellBlockKeys.type;
builder.configuration = builder.configuration.copyWith(
blockSelectionAreaMargin: (_) => const EdgeInsets.symmetric(
vertical: 1,
),
);
builder.actionBuilder = (context, state) {
final top = builder.configuration.padding(context.node).top;
final padding = context.node.type == HeadingBlockKeys.type
? EdgeInsets.only(top: top + 8.0)
: EdgeInsets.only(top: top + 2.0);
return Padding(
padding: padding,
child: BlockActionList(
blockComponentContext: context,
blockComponentState: state,
editorState: editorState,
blockComponentBuilder: builders,
actions: actions,
showSlashMenu: slashMenuItems != null
? () => customSlashCommand(
slashMenuItems,
shouldInsertSlash: false,
style: styleCustomizer.selectionMenuStyleBuilder(),
).handler.call(editorState)
: () {},
),
);
};
}
}
}
Map<String, BlockComponentBuilder> _buildBlockComponentBuilderMap(
BuildContext context, {
required BlockComponentConfiguration configuration,
required EditorState editorState,
required EditorStyleCustomizer styleCustomizer,
ShowPlaceholder? showParagraphPlaceholder,
String Function(Node)? placeholderText,
EdgeInsets? customHeadingPadding,
}) {
final customBlockComponentBuilderMap = {
PageBlockKeys.type: PageBlockComponentBuilder(),
ParagraphBlockKeys.type: ParagraphBlockComponentBuilder(
configuration: configuration.copyWith(placeholderText: placeholderText),
showPlaceholder: showParagraphPlaceholder,
ParagraphBlockKeys.type: _buildParagraphBlockComponentBuilder(
context,
configuration,
showParagraphPlaceholder,
placeholderText,
),
TodoListBlockKeys.type: TodoListBlockComponentBuilder(
configuration: configuration.copyWith(
placeholderText: (_) => LocaleKeys.blockPlaceholders_todoList.tr(),
),
iconBuilder: (_, node, onCheck) =>
TodoListIcon(node: node, onCheck: onCheck),
toggleChildrenTriggers: [
LogicalKeyboardKey.shift,
LogicalKeyboardKey.shiftLeft,
LogicalKeyboardKey.shiftRight,
],
TodoListBlockKeys.type: _buildTodoListBlockComponentBuilder(
context,
configuration,
),
BulletedListBlockKeys.type: BulletedListBlockComponentBuilder(
configuration: configuration.copyWith(
placeholderText: (_) => LocaleKeys.blockPlaceholders_bulletList.tr(),
),
iconBuilder: (_, node) => BulletedListIcon(node: node),
BulletedListBlockKeys.type: _buildBulletedListBlockComponentBuilder(
context,
configuration,
),
NumberedListBlockKeys.type: NumberedListBlockComponentBuilder(
configuration: configuration.copyWith(
placeholderText: (_) => LocaleKeys.blockPlaceholders_numberList.tr(),
),
iconBuilder: (_, node, textDirection) =>
NumberedListIcon(node: node, textDirection: textDirection),
NumberedListBlockKeys.type: _buildNumberedListBlockComponentBuilder(
context,
configuration,
),
QuoteBlockKeys.type: QuoteBlockComponentBuilder(
configuration: configuration.copyWith(
placeholderText: (_) => LocaleKeys.blockPlaceholders_quote.tr(),
),
QuoteBlockKeys.type: _buildQuoteBlockComponentBuilder(
context,
configuration,
),
HeadingBlockKeys.type: HeadingBlockComponentBuilder(
configuration: configuration.copyWith(
padding: (node) {
if (customHeadingPadding != null) {
return customHeadingPadding;
}
if (UniversalPlatform.isMobile) {
final pageStyle = context.read<DocumentPageStyleBloc>().state;
final factor = pageStyle.fontLayout.factor;
final headingPaddings = pageStyle.lineHeightLayout.headingPaddings
.map((e) => e * factor);
int level = node.attributes[HeadingBlockKeys.level] ?? 6;
level = level.clamp(1, 6);
return EdgeInsets.only(top: headingPaddings.elementAt(level - 1));
}
return const EdgeInsets.only(top: 12.0, bottom: 4.0);
},
placeholderText: (node) {
int level = node.attributes[HeadingBlockKeys.level] ?? 6;
level = level.clamp(1, 6);
return LocaleKeys.blockPlaceholders_heading.tr(
args: [level.toString()],
);
},
),
textStyleBuilder: (level) => styleCustomizer.headingStyleBuilder(level),
HeadingBlockKeys.type: _buildHeadingBlockComponentBuilder(
context,
configuration,
styleCustomizer,
customHeadingPadding,
),
ImageBlockKeys.type: CustomImageBlockComponentBuilder(
configuration: configuration,
showMenu: true,
menuBuilder: (node, state) => Positioned(
top: 10,
right: 10,
child: ImageMenu(node: node, state: state),
),
ImageBlockKeys.type: _buildCustomImageBlockComponentBuilder(
context,
configuration,
),
MultiImageBlockKeys.type: MultiImageBlockComponentBuilder(
configuration: configuration,
showMenu: true,
menuBuilder: (
Node node,
MultiImageBlockComponentState state,
ValueNotifier<int> indexNotifier,
VoidCallback onImageDeleted,
) =>
Positioned(
top: 10,
right: 10,
child: MultiImageMenu(
node: node,
state: state,
indexNotifier: indexNotifier,
isLocalMode: context.read<DocumentBloc>().isLocalMode,
onImageDeleted: onImageDeleted,
),
),
MultiImageBlockKeys.type: _buildMultiImageBlockComponentBuilder(
context,
configuration,
),
TableBlockKeys.type: TableBlockComponentBuilder(
menuBuilder: (node, editorState, position, dir, onBuild, onClose) =>
TableMenu(
node: node,
editorState: editorState,
position: position,
dir: dir,
onBuild: onBuild,
onClose: onClose,
),
TableBlockKeys.type: _buildTableBlockComponentBuilder(
context,
configuration,
),
TableCellBlockKeys.type: TableCellBlockComponentBuilder(
colorBuilder: (context, node) {
final String colorString =
node.attributes[TableCellBlockKeys.colBackgroundColor] ??
node.attributes[TableCellBlockKeys.rowBackgroundColor] ??
'';
if (colorString.isEmpty) {
return null;
}
return buildEditorCustomizedColor(context, node, colorString);
},
menuBuilder: (node, editorState, position, dir, onBuild, onClose) =>
TableMenu(
node: node,
editorState: editorState,
position: position,
dir: dir,
onBuild: onBuild,
onClose: onClose,
),
TableCellBlockKeys.type: _buildTableCellBlockComponentBuilder(
context,
configuration,
),
DatabaseBlockKeys.gridType: DatabaseViewBlockComponentBuilder(
configuration: configuration.copyWith(
padding: (_) => const EdgeInsets.symmetric(vertical: 10),
),
DatabaseBlockKeys.gridType: _buildDatabaseViewBlockComponentBuilder(
context,
configuration,
),
DatabaseBlockKeys.boardType: DatabaseViewBlockComponentBuilder(
configuration: configuration.copyWith(
padding: (_) => const EdgeInsets.symmetric(vertical: 10),
),
DatabaseBlockKeys.boardType: _buildDatabaseViewBlockComponentBuilder(
context,
configuration,
),
DatabaseBlockKeys.calendarType: DatabaseViewBlockComponentBuilder(
configuration: configuration.copyWith(
padding: (_) => const EdgeInsets.symmetric(vertical: 10),
),
DatabaseBlockKeys.calendarType: _buildDatabaseViewBlockComponentBuilder(
context,
configuration,
),
CalloutBlockKeys.type: CalloutBlockComponentBuilder(
configuration: configuration.copyWith(
padding: (node) => const EdgeInsets.symmetric(vertical: 10),
),
inlinePadding: const EdgeInsets.symmetric(vertical: 8.0),
defaultColor: calloutBGColor,
CalloutBlockKeys.type: _buildCalloutBlockComponentBuilder(
context,
configuration,
),
DividerBlockKeys.type: DividerBlockComponentBuilder(
configuration: configuration,
height: 28.0,
wrapper: (_, node, child) => MobileBlockActionButtons(
showThreeDots: false,
node: node,
editorState: editorState,
child: child,
),
DividerBlockKeys.type: _buildDividerBlockComponentBuilder(
context,
configuration,
editorState,
),
MathEquationBlockKeys.type: MathEquationBlockComponentBuilder(
configuration: configuration,
MathEquationBlockKeys.type: _buildMathEquationBlockComponentBuilder(
context,
configuration,
),
CodeBlockKeys.type: CodeBlockComponentBuilder(
configuration: configuration.copyWith(
textStyle: (_) => styleCustomizer.codeBlockStyleBuilder(),
placeholderTextStyle: (_) => styleCustomizer.codeBlockStyleBuilder(),
),
styleBuilder: () => CodeBlockStyle(
backgroundColor: AFThemeExtension.of(context).calloutBGColor,
foregroundColor: AFThemeExtension.of(context).textColor.withAlpha(155),
),
padding: const EdgeInsets.only(left: 20, right: 30, bottom: 34),
languagePickerBuilder: codeBlockLanguagePickerBuilder,
copyButtonBuilder: codeBlockCopyBuilder,
showLineNumbers: false,
CodeBlockKeys.type: _buildCodeBlockComponentBuilder(
context,
configuration,
styleCustomizer,
),
AutoCompletionBlockKeys.type: AutoCompletionBlockComponentBuilder(),
SmartEditBlockKeys.type: SmartEditBlockComponentBuilder(),
ToggleListBlockKeys.type: ToggleListBlockComponentBuilder(
configuration: configuration,
AutoCompletionBlockKeys.type: _buildAutoCompletionBlockComponentBuilder(
context,
configuration,
),
OutlineBlockKeys.type: OutlineBlockComponentBuilder(
configuration: configuration.copyWith(
placeholderTextStyle: (_) =>
styleCustomizer.outlineBlockPlaceholderStyleBuilder(),
padding: (_) => const EdgeInsets.only(top: 12.0, bottom: 4.0),
),
SmartEditBlockKeys.type: _buildSmartEditBlockComponentBuilder(
context,
configuration,
),
LinkPreviewBlockKeys.type: LinkPreviewBlockComponentBuilder(
configuration: configuration.copyWith(
padding: (_) => const EdgeInsets.symmetric(vertical: 10),
),
cache: LinkPreviewDataCache(),
showMenu: true,
menuBuilder: (context, node, state) => Positioned(
top: 10,
right: 0,
child: LinkPreviewMenu(node: node, state: state),
),
builder: (_, node, url, title, description, imageUrl) =>
CustomLinkPreviewWidget(
node: node,
url: url,
title: title,
description: description,
imageUrl: imageUrl,
),
ToggleListBlockKeys.type: _buildToggleListBlockComponentBuilder(
context,
configuration,
),
FileBlockKeys.type: FileBlockComponentBuilder(configuration: configuration),
SubPageBlockKeys.type: SubPageBlockComponentBuilder(
configuration: configuration,
OutlineBlockKeys.type: _buildOutlineBlockComponentBuilder(
context,
configuration,
styleCustomizer,
),
LinkPreviewBlockKeys.type: _buildLinkPreviewBlockComponentBuilder(
context,
configuration,
),
FileBlockKeys.type: _buildFileBlockComponentBuilder(
context,
configuration,
),
SubPageBlockKeys.type: _buildSubPageBlockComponentBuilder(
context,
configuration,
),
errorBlockComponentBuilderKey: ErrorBlockComponentBuilder(
configuration: configuration,
@@ -277,70 +289,347 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
...customBlockComponentBuilderMap,
};
if (editable) {
// customize the action builder. actually, we can customize them in their own builder. Put them here just for convenience.
for (final entry in builders.entries) {
if (entry.key == PageBlockKeys.type) {
continue;
}
final builder = entry.value;
// customize the action builder.
final supportColorBuilderTypes = [
ParagraphBlockKeys.type,
HeadingBlockKeys.type,
BulletedListBlockKeys.type,
NumberedListBlockKeys.type,
QuoteBlockKeys.type,
TodoListBlockKeys.type,
CalloutBlockKeys.type,
OutlineBlockKeys.type,
ToggleListBlockKeys.type,
];
final supportAlignBuilderType = [ImageBlockKeys.type];
final supportDepthBuilderType = [OutlineBlockKeys.type];
final colorAction = [OptionAction.divider, OptionAction.color];
final alignAction = [OptionAction.divider, OptionAction.align];
final depthAction = [OptionAction.depth];
final List<OptionAction> actions = [
...standardActions,
if (supportColorBuilderTypes.contains(entry.key)) ...colorAction,
if (supportAlignBuilderType.contains(entry.key)) ...alignAction,
if (supportDepthBuilderType.contains(entry.key)) ...depthAction,
];
if (UniversalPlatform.isDesktop) {
builder.showActions =
(node) => node.parent?.type != TableCellBlockKeys.type;
builder.actionBuilder = (context, state) {
final top = builder.configuration.padding(context.node).top;
final padding = context.node.type == HeadingBlockKeys.type
? EdgeInsets.only(top: top + 8.0)
: EdgeInsets.only(top: top + 2.0);
return Padding(
padding: padding,
child: BlockActionList(
blockComponentContext: context,
blockComponentState: state,
editorState: editorState,
blockComponentBuilder: builders,
actions: actions,
showSlashMenu: slashMenuItems != null
? () => customSlashCommand(
slashMenuItems,
shouldInsertSlash: false,
style: styleCustomizer.selectionMenuStyleBuilder(),
).handler.call(editorState)
: () {},
),
);
};
}
}
}
return builders;
}
ParagraphBlockComponentBuilder _buildParagraphBlockComponentBuilder(
BuildContext context,
BlockComponentConfiguration configuration,
ShowPlaceholder? showParagraphPlaceholder,
String Function(Node)? placeholderText,
) {
return ParagraphBlockComponentBuilder(
configuration: configuration.copyWith(placeholderText: placeholderText),
showPlaceholder: showParagraphPlaceholder,
);
}
TodoListBlockComponentBuilder _buildTodoListBlockComponentBuilder(
BuildContext context,
BlockComponentConfiguration configuration,
) {
return TodoListBlockComponentBuilder(
configuration: configuration.copyWith(
placeholderText: (_) => LocaleKeys.blockPlaceholders_todoList.tr(),
),
iconBuilder: (_, node, onCheck) => TodoListIcon(
node: node,
onCheck: onCheck,
),
toggleChildrenTriggers: [
LogicalKeyboardKey.shift,
LogicalKeyboardKey.shiftLeft,
LogicalKeyboardKey.shiftRight,
],
);
}
BulletedListBlockComponentBuilder _buildBulletedListBlockComponentBuilder(
BuildContext context,
BlockComponentConfiguration configuration,
) {
return BulletedListBlockComponentBuilder(
configuration: configuration.copyWith(
placeholderText: (_) => LocaleKeys.blockPlaceholders_bulletList.tr(),
),
iconBuilder: (_, node) => BulletedListIcon(node: node),
);
}
NumberedListBlockComponentBuilder _buildNumberedListBlockComponentBuilder(
BuildContext context,
BlockComponentConfiguration configuration,
) {
return NumberedListBlockComponentBuilder(
configuration: configuration.copyWith(
placeholderText: (_) => LocaleKeys.blockPlaceholders_numberList.tr(),
),
iconBuilder: (_, node, textDirection) => NumberedListIcon(
node: node,
textDirection: textDirection,
),
);
}
QuoteBlockComponentBuilder _buildQuoteBlockComponentBuilder(
BuildContext context,
BlockComponentConfiguration configuration,
) {
return QuoteBlockComponentBuilder(
configuration: configuration.copyWith(
placeholderText: (_) => LocaleKeys.blockPlaceholders_quote.tr(),
),
);
}
HeadingBlockComponentBuilder _buildHeadingBlockComponentBuilder(
BuildContext context,
BlockComponentConfiguration configuration,
EditorStyleCustomizer styleCustomizer,
EdgeInsets? customHeadingPadding,
) {
return HeadingBlockComponentBuilder(
configuration: configuration.copyWith(
padding: (node) {
if (customHeadingPadding != null) {
return customHeadingPadding;
}
if (UniversalPlatform.isMobile) {
final pageStyle = context.read<DocumentPageStyleBloc>().state;
final factor = pageStyle.fontLayout.factor;
final headingPaddings =
pageStyle.lineHeightLayout.headingPaddings.map((e) => e * factor);
int level = node.attributes[HeadingBlockKeys.level] ?? 6;
level = level.clamp(1, 6);
return EdgeInsets.only(top: headingPaddings.elementAt(level - 1));
}
return const EdgeInsets.only(top: 12.0, bottom: 4.0);
},
placeholderText: (node) {
int level = node.attributes[HeadingBlockKeys.level] ?? 6;
level = level.clamp(1, 6);
return LocaleKeys.blockPlaceholders_heading.tr(
args: [level.toString()],
);
},
),
textStyleBuilder: (level) => styleCustomizer.headingStyleBuilder(level),
);
}
CustomImageBlockComponentBuilder _buildCustomImageBlockComponentBuilder(
BuildContext context,
BlockComponentConfiguration configuration,
) {
return CustomImageBlockComponentBuilder(
configuration: configuration,
showMenu: true,
menuBuilder: (node, state) => Positioned(
top: 10,
right: 10,
child: ImageMenu(node: node, state: state),
),
);
}
MultiImageBlockComponentBuilder _buildMultiImageBlockComponentBuilder(
BuildContext context,
BlockComponentConfiguration configuration,
) {
return MultiImageBlockComponentBuilder(
configuration: configuration,
showMenu: true,
menuBuilder: (
Node node,
MultiImageBlockComponentState state,
ValueNotifier<int> indexNotifier,
VoidCallback onImageDeleted,
) =>
Positioned(
top: 10,
right: 10,
child: MultiImageMenu(
node: node,
state: state,
indexNotifier: indexNotifier,
isLocalMode: context.read<DocumentBloc>().isLocalMode,
onImageDeleted: onImageDeleted,
),
),
);
}
TableBlockComponentBuilder _buildTableBlockComponentBuilder(
BuildContext context,
BlockComponentConfiguration configuration,
) {
return TableBlockComponentBuilder(
menuBuilder: (node, editorState, position, dir, onBuild, onClose) =>
TableMenu(
node: node,
editorState: editorState,
position: position,
dir: dir,
onBuild: onBuild,
onClose: onClose,
),
);
}
TableCellBlockComponentBuilder _buildTableCellBlockComponentBuilder(
BuildContext context,
BlockComponentConfiguration configuration,
) {
return TableCellBlockComponentBuilder(
colorBuilder: (context, node) {
final String colorString =
node.attributes[TableCellBlockKeys.colBackgroundColor] ??
node.attributes[TableCellBlockKeys.rowBackgroundColor] ??
'';
if (colorString.isEmpty) {
return null;
}
return buildEditorCustomizedColor(context, node, colorString);
},
menuBuilder: (node, editorState, position, dir, onBuild, onClose) =>
TableMenu(
node: node,
editorState: editorState,
position: position,
dir: dir,
onBuild: onBuild,
onClose: onClose,
),
);
}
DatabaseViewBlockComponentBuilder _buildDatabaseViewBlockComponentBuilder(
BuildContext context,
BlockComponentConfiguration configuration,
) {
return DatabaseViewBlockComponentBuilder(
configuration: configuration.copyWith(
padding: (_) => const EdgeInsets.symmetric(vertical: 10),
),
);
}
CalloutBlockComponentBuilder _buildCalloutBlockComponentBuilder(
BuildContext context,
BlockComponentConfiguration configuration,
) {
final calloutBGColor = AFThemeExtension.of(context).calloutBGColor;
return CalloutBlockComponentBuilder(
configuration: configuration.copyWith(
padding: (node) => const EdgeInsets.symmetric(vertical: 10),
),
inlinePadding: const EdgeInsets.symmetric(vertical: 8.0),
defaultColor: calloutBGColor,
);
}
DividerBlockComponentBuilder _buildDividerBlockComponentBuilder(
BuildContext context,
BlockComponentConfiguration configuration,
EditorState editorState,
) {
return DividerBlockComponentBuilder(
configuration: configuration,
height: 28.0,
wrapper: (_, node, child) => MobileBlockActionButtons(
showThreeDots: false,
node: node,
editorState: editorState,
child: child,
),
);
}
MathEquationBlockComponentBuilder _buildMathEquationBlockComponentBuilder(
BuildContext context,
BlockComponentConfiguration configuration,
) {
return MathEquationBlockComponentBuilder(
configuration: configuration,
);
}
CodeBlockComponentBuilder _buildCodeBlockComponentBuilder(
BuildContext context,
BlockComponentConfiguration configuration,
EditorStyleCustomizer styleCustomizer,
) {
return CodeBlockComponentBuilder(
configuration: configuration.copyWith(
textStyle: (_) => styleCustomizer.codeBlockStyleBuilder(),
placeholderTextStyle: (_) => styleCustomizer.codeBlockStyleBuilder(),
),
styleBuilder: () => CodeBlockStyle(
backgroundColor: AFThemeExtension.of(context).calloutBGColor,
foregroundColor: AFThemeExtension.of(context).textColor.withAlpha(155),
),
padding: const EdgeInsets.only(left: 20, right: 30, bottom: 34),
languagePickerBuilder: codeBlockLanguagePickerBuilder,
copyButtonBuilder: codeBlockCopyBuilder,
showLineNumbers: false,
);
}
AutoCompletionBlockComponentBuilder _buildAutoCompletionBlockComponentBuilder(
BuildContext context,
BlockComponentConfiguration configuration,
) {
return AutoCompletionBlockComponentBuilder();
}
SmartEditBlockComponentBuilder _buildSmartEditBlockComponentBuilder(
BuildContext context,
BlockComponentConfiguration configuration,
) {
return SmartEditBlockComponentBuilder();
}
ToggleListBlockComponentBuilder _buildToggleListBlockComponentBuilder(
BuildContext context,
BlockComponentConfiguration configuration,
) {
return ToggleListBlockComponentBuilder(
configuration: configuration,
);
}
OutlineBlockComponentBuilder _buildOutlineBlockComponentBuilder(
BuildContext context,
BlockComponentConfiguration configuration,
EditorStyleCustomizer styleCustomizer,
) {
return OutlineBlockComponentBuilder(
configuration: configuration.copyWith(
placeholderTextStyle: (_) =>
styleCustomizer.outlineBlockPlaceholderStyleBuilder(),
padding: (_) => const EdgeInsets.only(top: 12.0, bottom: 4.0),
),
);
}
LinkPreviewBlockComponentBuilder _buildLinkPreviewBlockComponentBuilder(
BuildContext context,
BlockComponentConfiguration configuration,
) {
return LinkPreviewBlockComponentBuilder(
configuration: configuration.copyWith(
padding: (_) => const EdgeInsets.symmetric(vertical: 10),
),
cache: LinkPreviewDataCache(),
showMenu: true,
menuBuilder: (context, node, state) => Positioned(
top: 10,
right: 0,
child: LinkPreviewMenu(node: node, state: state),
),
builder: (_, node, url, title, description, imageUrl) =>
CustomLinkPreviewWidget(
node: node,
url: url,
title: title,
description: description,
imageUrl: imageUrl,
),
);
}
FileBlockComponentBuilder _buildFileBlockComponentBuilder(
BuildContext context,
BlockComponentConfiguration configuration,
) {
return FileBlockComponentBuilder(configuration: configuration);
}
SubPageBlockComponentBuilder _buildSubPageBlockComponentBuilder(
BuildContext context,
BlockComponentConfiguration configuration,
) {
return SubPageBlockComponentBuilder(configuration: configuration);
}
@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_file.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart';
import 'package:appflowy/shared/patterns/file_type_patterns.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:provider/provider.dart';
const _excludeFromDropTarget = [
ImageBlockKeys.type,
CustomImageBlockKeys.type,
MultiImageBlockKeys.type,
FileBlockKeys.type,
];
class EditorDropHandler extends StatelessWidget {
const EditorDropHandler({
super.key,
required this.viewId,
required this.editorState,
required this.isLocalMode,
required this.child,
});
final String viewId;
final EditorState editorState;
final bool isLocalMode;
final Widget child;
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
// Due to how DropTarget works, there is no way to differentiate if an overlay is
// blocking the target visibly, so when we have an overlay with a drop target,
// we should disable the drop target for the Editor, until it is closed.
//
// See FileBlockComponent for sample use.
//
// Relates to:
// - https://github.com/MixinNetwork/flutter-plugins/issues/2
// - https://github.com/MixinNetwork/flutter-plugins/issues/331
//
create: (_) => EditorDropManagerState(),
child: Consumer<EditorDropManagerState>(
builder: (context, dropState, _) => DropTarget(
enable: dropState.isDropEnabled,
onDragExited: (_) => editorState.selectionService.removeDropTarget(),
onDragUpdated: _onDragUpdated,
onDragDone: _onDragDone,
child: child,
),
),
);
}
void _onDragUpdated(DropEventDetails details) {
final data = editorState.selectionService
.getDropTargetRenderData(details.globalPosition);
if (data != null &&
data.dropPath != null &&
// We implement custom Drop logic for image blocks, this is
// how we can exclude them from the Drop Target
!_excludeFromDropTarget.contains(data.cursorNode?.type)) {
// Render the drop target
editorState.selectionService
.renderDropTargetForOffset(details.globalPosition);
} else {
editorState.selectionService.removeDropTarget();
}
}
Future<void> _onDragDone(DropDoneDetails details) async {
editorState.selectionService.removeDropTarget();
final data = editorState.selectionService
.getDropTargetRenderData(details.globalPosition);
if (data != null) {
final cursorNode = data.cursorNode;
final dropPath = data.dropPath;
if (cursorNode != null && dropPath != null) {
if (_excludeFromDropTarget.contains(cursorNode.type)) {
return;
}
final node = editorState.getNodeAtPath(dropPath);
if (node == null) {
return;
}
for (final file in details.files) {
final fileName = file.name.toLowerCase();
if (file.mimeType?.startsWith('image/') ??
false || imgExtensionRegex.hasMatch(fileName)) {
await editorState.dropImages(node, [file], viewId, isLocalMode);
} else {
await editorState.dropFiles(node, [file], viewId, isLocalMode);
}
}
}
}
}
}
@@ -6,10 +6,12 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/background
import 'package:appflowy/plugins/document/presentation/editor_plugins/i18n/editor_i18n.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/plugins/inline_actions/handlers/child_page.dart';
import 'package:appflowy/plugins/inline_actions/handlers/date_reference.dart';
import 'package:appflowy/plugins/inline_actions/handlers/inline_page_reference.dart';
import 'package:appflowy/plugins/inline_actions/handlers/reminder_reference.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart';
import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart';
@@ -64,6 +66,8 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
late final InlineActionsService inlineActionsService = InlineActionsService(
context: context,
handlers: [
if (FeatureFlag.inlineSubPageMention.isOn)
InlineChildPageService(currentViewId: documentBloc.documentId),
InlinePageReferenceService(currentViewId: documentBloc.documentId),
DateReferenceService(context),
ReminderReferenceService(context),
@@ -177,8 +181,14 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
focusManager = AFFocusManager.maybeOf(context);
focusManager?.loseFocusNotifier.addListener(_loseFocus);
if (widget.initialSelection != null) {
widget.editorState.updateSelectionWithReason(widget.initialSelection);
final initialSelection = widget.initialSelection;
final path = initialSelection?.start.path;
if (initialSelection != null && path != null && path.isNotEmpty) {
editorScrollController.itemScrollController.jumpTo(
index: path.first,
alignment: 0.5,
);
widget.editorState.updateSelectionWithReason(initialSelection);
}
widget.editorState.service.keyboardService?.registerInterceptor(
@@ -270,10 +280,12 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
context.read<AppearanceSettingsCubit>().state.enableRtlToolbarItems,
);
final isViewDeleted = context.read<DocumentBloc>().state.isDeleted;
final editor = Directionality(
textDirection: textDirection,
child: AppFlowyEditor(
editorState: widget.editorState,
editable: !isViewDeleted,
editorScrollController: editorScrollController,
// setup the auto focus parameters
autoFocus: widget.autoFocus ?? autoFocus,
@@ -281,7 +293,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
// setup the theme
editorStyle: styleCustomizer.style(),
// customize the block builders
blockComponentBuilders: getEditorBuilderMap(
blockComponentBuilders: buildBlockComponentBuilders(
slashMenuItems: slashMenuItems,
context: context,
editorState: widget.editorState,
@@ -311,6 +323,10 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
),
);
if (isViewDeleted) {
return editor;
}
final editorState = widget.editorState;
if (UniversalPlatform.isMobile) {
@@ -12,12 +12,14 @@ class BlockActionButton extends StatelessWidget {
required this.richMessage,
required this.onTap,
this.showTooltip = true,
this.onPointerDown,
});
final FlowySvgData svg;
final bool showTooltip;
final InlineSpan richMessage;
final VoidCallback onTap;
final VoidCallback? onPointerDown;
@override
Widget build(BuildContext context) {
@@ -26,6 +28,7 @@ class BlockActionButton extends StatelessWidget {
? SystemMouseCursors.click
: SystemMouseCursors.grab,
child: IgnoreParentGestureWidget(
onPress: onPointerDown,
child: GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.deferToChild,
Loaded 100 of 527 files, more files were not shown because too many files have changed in this diff. Show more