Compare commits

...
11 Commits
Author SHA1 Message Date
Lucas.Xu 430325c731 chore: bump version 0.2.2 (#2804) 2023-06-15 18:55:21 +08:00
Lucas.Xu d5884ad2b5 fix: unable insert a reference database (#2798)
* fix: unable insert a reference database

* test: add reference database tests

* feat: set min height for document inside database
2023-06-15 16:33:44 +08:00
Kilu.He 95f8b2e9a4 chore: create redo/undo bridge (#2760)
* chore: create redo/undo bridge

* chore: update test

* chore: review update

* chore: review update

* chore: react redo/undo

* chore: review update

* chore: add test

* chore: review update

* chore: generate document id

* chore: update undo/redo

* chore: update cargo lock
2023-06-15 10:37:51 +08:00
Nathan.foooandLucas.Xu 27dd719aa8 feat: row document (#2792)
* chore: create orphan view handler

* feat: save icon url and cover url in view

* feat: implement emoji picker UI

* chore: config ui

* chore: config ui again

* chore: replace RowPB with RowMetaPB to exposing more row information

* fix: compile error

* feat: show emoji in row

* chore: update

* test: insert emoji test

* test: add update emoji test

* test: add remove emoji test

* test: add create field tests

* test: add create row and delete row integration tests

* test: add create row from row menu

* test: document in row detail page

* test: delete, duplicate row in row detail page

* test: check the row count displayed in grid page

* test: rename existing field in grid page

* test: update field type of exisiting field in grid page

* test: delete field test

* test: add duplicate field test

* test: add hide field test

* test: add edit text cell test

* test: add insert text to text cell test

* test: add edit number cell test

* test: add edit multiple number cells

* test: add edit checkbox cell test

* feat: integrate editor into database row

* test: add edit create time and last edit time cell test

* test: add edit date cell by selecting a date test

* chore: remove unused code

* chore: update checklist bg color

* test: add update database layout test

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2023-06-14 22:16:33 +08:00
Yijing HuangandLucas.Xu b8983e4466 feat: language selector on welcome page (#2796)
* feat: add language selector on welcome page

* feat: add hover effect and refactor layout

* test: add basic languge selector testing

* chore: increate place holder width

* fix: add catch error for setLocale and finish the testing

* chore: update comment

* feat: refactor the skip login in page and add tests

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2023-06-14 19:14:41 +08:00
Kilu.HeandLucas.Xu 00c0934df6 Support text block add link (#2730)
* feat: support text block href attribute

* fix: double click didn't select range

* fix: link update

* chore: ts lint

* chore: add new line

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>

* chore: update get word indices function

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2023-06-13 15:59:18 +08:00
Lucas.Xu aca3c90eb3 chore: optimize the integration test UI 2023-06-12 21:53:39 +08:00
Lucas.Xu 8b4d1868e5 test: add integration test 2023-06-12 20:13:25 +08:00
Lucas.Xu 5bcf48a4f9 fix: the markdown file name doesn't update after renaming the document (#2777)
* fix: the markdown file name doesn't update after renaming the document

* test: add integration test
2023-06-12 14:29:09 +08:00
Lucas.Xu 335861706e feat: support importing multiple md files all at once (#2778) 2023-06-12 14:27:38 +08:00
Nathan.foooandLucas.Xu 4f5672c2af refactor: create view interface (#2772)
* refactor: create view interface

* chore: update doc

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2023-06-12 12:57:01 +08:00
249 changed files with 7391 additions and 2276 deletions

No files matched your search

+9 -1
View File
@@ -1,10 +1,18 @@
# Release Notes
## Version 0.2.2 - 06/15/2023
### New Features
- Added support for embedding a document in the database's row detail page
- Added support for inserting an emoji in the database's row detail page
### Other Updates
- Added language selector on the welcome page
- Added support for importing multiple markdown files all at once
## Version 0.2.1 - 06/11/2023
### New Features
- Added support for creating or referencing a calendar in the document
- Added `+` icon in grid's add field
-
### Other Updates
- Added vertical padding for progress bar
+1 -1
View File
@@ -23,7 +23,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
CARGO_MAKE_CRATE_FS_NAME = "dart_ffi"
CARGO_MAKE_CRATE_NAME = "dart-ffi"
LIB_NAME = "dart_ffi"
CURRENT_APP_VERSION = "0.2.1"
CURRENT_APP_VERSION = "0.2.2"
FLUTTER_DESKTOP_FEATURES = "dart,rev-sqlite"
PRODUCT_NAME = "AppFlowy"
# CRATE_TYPE: https://doc.rust-lang.org/reference/linkage.html
@@ -0,0 +1,5 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.99999 15.8333C13.2217 15.8333 15.8333 13.2216 15.8333 9.99996C15.8333 6.7783 13.2217 4.16663 9.99999 4.16663C6.77833 4.16663 4.16666 6.7783 4.16666 9.99996C4.16666 13.2216 6.77833 15.8333 9.99999 15.8333Z" stroke="#333333" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.16666 10H15.8333" stroke="#333333" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.0002 4.16663C11.4593 5.764 12.2884 7.83698 12.3335 9.99996C12.2884 12.1629 11.4593 14.2359 10.0002 15.8333C8.54109 14.2359 7.7119 12.1629 7.66684 9.99996C7.7119 7.83698 8.54109 5.764 10.0002 4.16663V4.16663Z" stroke="#333333" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 831 B

@@ -0,0 +1,22 @@
# Welcome to AppFlowy!
## Here are the basics
- [ ] Click anywhere and just start typing.
- [ ] Highlight any text, and use the editing menu to _style_ **your** <u>writing</u> `however` you ~~like.~~
- [ ] As soon as you type /a menu will pop up. Select different types of content blocks you can add.
- [ ] Type `/` followed by `/bullet` or `/num` to create a list.
- [x] Click `+ New Page `button at the bottom of your sidebar to add a new page.
- [ ] Click `+` next to any page title in the sidebar to quickly add a new subpage, `Document`, `Grid`, or `Kanban Board`.
---
## Keyboard shortcuts, markdown, and code block
1. Keyboard shortcuts [guide](https://appflowy.gitbook.io/docs/essential-documentation/shortcuts)
1. Markdown [reference](https://appflowy.gitbook.io/docs/essential-documentation/markdown)
1. Type `/code` to insert a code block
## Have a question❓
> Click `?` at the bottom right for help and support.
@@ -0,0 +1 @@
# test
@@ -51,6 +51,12 @@
"import": "Import",
"moreOptions": "More options"
},
"importPanel": {
"textAndMarkdown": "Text & Markdown",
"documentFromV010": "Document from v0.1.0",
"databaseFromV010": "Database from v0.1.0",
"csv": "CSV"
},
"disclosureAction": {
"rename": "Rename",
"delete": "Delete",
@@ -28,8 +28,9 @@ void main() {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.hoverOnCoverPluginAddButton();
await tester.expectToSeePluginAddCoverAndIconButton();
await tester.editor.hoverOnCoverPluginAddButton();
tester.expectToSeePluginAddCoverAndIconButton();
});
});
}
@@ -0,0 +1,185 @@
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'util/database_test_op.dart';
import 'util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('grid cell', () {
const location = 'appflowy';
setUp(() async {
await TestFolder.cleanTestLocation(location);
await TestFolder.setTestLocation(location);
});
tearDown(() async {
await TestFolder.cleanTestLocation(location);
});
tearDownAll(() async {
await TestFolder.cleanTestLocation(null);
});
testWidgets('edit text cell', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
await tester.editCell(
rowIndex: 0,
fieldType: FieldType.RichText,
input: 'hello world',
);
await tester.assertCellContent(
rowIndex: 0,
fieldType: FieldType.RichText,
content: 'hello world',
);
await tester.pumpAndSettle();
});
testWidgets('edit number cell', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
const fieldType = FieldType.Number;
// Create a number field
await tester.createField(fieldType, fieldType.name);
await tester.editCell(
rowIndex: 0,
fieldType: fieldType,
input: '-1',
);
// edit the next cell to force the previous cell at row 0 to lose focus
await tester.editCell(
rowIndex: 1,
fieldType: fieldType,
input: '0.2',
);
// -1 -> -1
await tester.assertCellContent(
rowIndex: 0,
fieldType: fieldType,
content: '-1',
);
// edit the next cell to force the previous cell at row 1 to lose focus
await tester.editCell(
rowIndex: 2,
fieldType: fieldType,
input: '.1',
);
// 0.2 -> 0.2
await tester.assertCellContent(
rowIndex: 1,
fieldType: fieldType,
content: '0.2',
);
// edit the next cell to force the previous cell at row 2 to lose focus
await tester.editCell(
rowIndex: 0,
fieldType: fieldType,
input: '',
);
// .1 -> 0.1
await tester.assertCellContent(
rowIndex: 2,
fieldType: fieldType,
content: '0.1',
);
await tester.pumpAndSettle();
});
testWidgets('edit checkbox cell', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
await tester.assertCheckboxCell(rowIndex: 0, isSelected: false);
await tester.tapCheckboxCellInGrid(rowIndex: 0);
await tester.assertCheckboxCell(rowIndex: 0, isSelected: true);
await tester.tapCheckboxCellInGrid(rowIndex: 1);
await tester.tapCheckboxCellInGrid(rowIndex: 2);
await tester.assertCheckboxCell(rowIndex: 1, isSelected: true);
await tester.assertCheckboxCell(rowIndex: 2, isSelected: true);
await tester.pumpAndSettle();
});
testWidgets('edit create time cell', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
const fieldType = FieldType.CreatedTime;
// Create a create time field
// The create time field is not editable
await tester.createField(fieldType, fieldType.name);
await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType);
await tester.findDateEditor(findsNothing);
await tester.pumpAndSettle();
});
testWidgets('edit last time cell', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
const fieldType = FieldType.LastEditedTime;
// Create a last time field
// The last time field is not editable
await tester.createField(fieldType, fieldType.name);
await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType);
await tester.findDateEditor(findsNothing);
await tester.pumpAndSettle();
});
testWidgets('edit time cell', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
const fieldType = FieldType.DateTime;
await tester.createField(fieldType, fieldType.name);
// Tap the cell to invoke the field editor
await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType);
await tester.findDateEditor(findsOneWidget);
// Select the date
await tester.selectDay(content: 3);
await tester.pumpAndSettle();
});
});
}
@@ -0,0 +1,202 @@
import 'package:appflowy/plugins/database_view/grid/presentation/grid_page.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'util/database_test_op.dart';
import 'util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('grid page', () {
const location = 'appflowy';
setUp(() async {
await TestFolder.cleanTestLocation(location);
await TestFolder.setTestLocation(location);
});
tearDown(() async {
await TestFolder.cleanTestLocation(location);
});
tearDownAll(() async {
await TestFolder.cleanTestLocation(null);
});
testWidgets('rename existing field', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
// Invoke the field editor
await tester.tapGridFieldWithName('Name');
await tester.tapEditPropertyButton();
await tester.renameField('hello world');
await tester.dismissFieldEditor();
await tester.tapGridFieldWithName('hello world');
await tester.pumpAndSettle();
});
testWidgets('update field type of existing field', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
// Invoke the field editor
await tester.tapGridFieldWithName('Type');
await tester.tapEditPropertyButton();
await tester.tapTypeOptionButton();
await tester.selectFieldType(FieldType.Checkbox);
await tester.dismissFieldEditor();
await tester.assertFieldTypeWithFieldName(
'Type',
FieldType.Checkbox,
);
await tester.pumpAndSettle();
});
testWidgets('create a field and rename it', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// create a new grid
await tester.tapAddButton();
await tester.tapCreateGridButton();
// create a field
await tester.createField(FieldType.Checklist, 'checklist');
// check the field is created successfully
await tester.findFieldWithName('checklist');
await tester.pumpAndSettle();
});
testWidgets('delete field', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
// create a field
await tester.createField(FieldType.Checkbox, 'New field 1');
// Delete the field
await tester.tapGridFieldWithName('New field 1');
await tester.tapDeletePropertyButton();
// confirm delete
await tester.tapDialogOkButton();
await tester.noFieldWithName('New field 1');
await tester.pumpAndSettle();
});
testWidgets('duplicate field', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
// create a field
await tester.scrollToRight(find.byType(GridPage));
await tester.tapNewPropertyButton();
await tester.renameField('New field 1');
await tester.dismissFieldEditor();
await tester.createField(FieldType.RichText, 'New field 1');
// Delete the field
await tester.tapGridFieldWithName('New field 1');
await tester.tapDuplicatePropertyButton();
await tester.findFieldWithName('New field 1 (copy)');
await tester.pumpAndSettle();
});
testWidgets('hide field', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
// create a field
await tester.scrollToRight(find.byType(GridPage));
await tester.tapNewPropertyButton();
await tester.renameField('New field 1');
await tester.dismissFieldEditor();
// Delete the field
await tester.tapGridFieldWithName('New field 1');
await tester.tapHidePropertyButton();
await tester.noFieldWithName('New field 1');
await tester.pumpAndSettle();
});
testWidgets('create checklist field ', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
await tester.scrollToRight(find.byType(GridPage));
await tester.tapNewPropertyButton();
// Open the type option menu
await tester.tapTypeOptionButton();
await tester.selectFieldType(FieldType.Checklist);
// After update the field type, the cells should be updated
await tester.findCellByFieldType(FieldType.Checklist);
await tester.pumpAndSettle();
});
testWidgets('create list of fields', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
for (final fieldType in [
FieldType.Checklist,
FieldType.DateTime,
FieldType.Number,
FieldType.URL,
FieldType.MultiSelect,
FieldType.LastEditedTime,
FieldType.CreatedTime,
FieldType.Checkbox,
]) {
await tester.scrollToRight(find.byType(GridPage));
await tester.tapNewPropertyButton();
await tester.renameField(fieldType.name);
// Open the type option menu
await tester.tapTypeOptionButton();
await tester.selectFieldType(fieldType);
await tester.dismissFieldEditor();
// After update the field type, the cells should be updated
await tester.findCellByFieldType(fieldType);
await tester.pumpAndSettle();
}
});
});
}
@@ -0,0 +1,241 @@
import 'package:appflowy/plugins/database_view/widgets/row/row_banner.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'util/database_test_op.dart';
import 'util/ime.dart';
import 'util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('grid', () {
const location = 'appflowy';
setUp(() async {
await TestFolder.cleanTestLocation(location);
await TestFolder.setTestLocation(location);
});
tearDown(() async {
await TestFolder.cleanTestLocation(location);
});
tearDownAll(() async {
await TestFolder.cleanTestLocation(null);
});
testWidgets('open first row of the grid', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// Create a new grid
await tester.tapAddButton();
await tester.tapCreateGridButton();
// Hover first row and then open the row page
await tester.openFirstRowDetailPage();
});
testWidgets('insert emoji in the row detail page', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// Create a new grid
await tester.tapAddButton();
await tester.tapCreateGridButton();
// Hover first row and then open the row page
await tester.openFirstRowDetailPage();
await tester.hoverRowBanner();
await tester.openEmojiPicker();
await tester.switchToEmojiList();
await tester.tapEmoji('😀');
// After select the emoji, the EmojiButton will show up
await tester.tapButton(find.byType(EmojiButton));
});
testWidgets('update emoji in the row detail page', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// Create a new grid
await tester.tapAddButton();
await tester.tapCreateGridButton();
// Hover first row and then open the row page
await tester.openFirstRowDetailPage();
await tester.hoverRowBanner();
await tester.openEmojiPicker();
await tester.switchToEmojiList();
await tester.tapEmoji('😀');
// Update existing selected emoji
await tester.tapButton(find.byType(EmojiButton));
await tester.switchToEmojiList();
await tester.tapEmoji('😅');
// The emoji already displayed in the row banner
final emojiText = find.byWidgetPredicate(
(widget) => widget is FlowyText && widget.title == '😅',
);
// The number of emoji should be two. One in the row displayed in the grid
// one in the row detail page.
expect(emojiText, findsNWidgets(2));
});
testWidgets('remove emoji in the row detail page', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// Create a new grid
await tester.tapAddButton();
await tester.tapCreateGridButton();
// Hover first row and then open the row page
await tester.openFirstRowDetailPage();
await tester.hoverRowBanner();
await tester.openEmojiPicker();
await tester.switchToEmojiList();
await tester.tapEmoji('😀');
// Remove the emoji
await tester.tapButton(find.byType(RemoveEmojiButton));
final emojiText = find.byWidgetPredicate(
(widget) => widget is FlowyText && widget.title == '😀',
);
expect(emojiText, findsNothing);
});
testWidgets('create list of fields in row detail page', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// Create a new grid
await tester.tapAddButton();
await tester.tapCreateGridButton();
// Hover first row and then open the row page
await tester.openFirstRowDetailPage();
for (final fieldType in [
FieldType.Checklist,
FieldType.DateTime,
FieldType.Number,
FieldType.URL,
FieldType.MultiSelect,
FieldType.LastEditedTime,
FieldType.CreatedTime,
FieldType.Checkbox,
]) {
await tester.tapRowDetailPageCreatePropertyButton();
await tester.renameField(fieldType.name);
// Open the type option menu
await tester.tapTypeOptionButton();
await tester.selectFieldType(fieldType);
await tester.dismissFieldEditor();
// After update the field type, the cells should be updated
await tester.findCellByFieldType(fieldType);
await tester.scrollRowDetailByOffset(const Offset(0, -50));
}
});
testWidgets('check document is exist in row detail page', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// Create a new grid
await tester.tapAddButton();
await tester.tapCreateGridButton();
// Hover first row and then open the row page
await tester.openFirstRowDetailPage();
// Each row detail page should have a document
await tester.assertDocumentExistInRowDetailPage();
});
testWidgets('update the content of the document and re-open it',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// Create a new grid
await tester.tapAddButton();
await tester.tapCreateGridButton();
// Hover first row and then open the row page
await tester.openFirstRowDetailPage();
// Wait for the document to be loaded
await tester.wait(500);
// Focus on the editor
final textBlock = find.byType(TextBlockComponentWidget);
await tester.tapAt(tester.getCenter(textBlock));
// Input some text
const inputText = 'Hello world';
await tester.ime.insertText(inputText);
expect(
find.textContaining(inputText, findRichText: true),
findsOneWidget,
);
// Tap outside to dismiss the field
await tester.tapAt(Offset.zero);
await tester.pumpAndSettle();
// Re-open the document
await tester.openFirstRowDetailPage();
expect(
find.textContaining(inputText, findRichText: true),
findsOneWidget,
);
});
testWidgets('delete row in row detail page', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// Create a new grid
await tester.tapAddButton();
await tester.tapCreateGridButton();
// Hover first row and then open the row page
await tester.openFirstRowDetailPage();
await tester.tapRowDetailPageDeleteRowButton();
await tester.tapEscButton();
await tester.assertNumberOfRowsInGridPage(2);
});
testWidgets('duplicate row in row detail page', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// Create a new grid
await tester.tapAddButton();
await tester.tapCreateGridButton();
// Hover first row and then open the row page
await tester.openFirstRowDetailPage();
await tester.tapRowDetailPageDuplicateRowButton();
await tester.tapEscButton();
await tester.assertNumberOfRowsInGridPage(4);
});
});
}
@@ -0,0 +1,85 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'util/database_test_op.dart';
import 'util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('grid', () {
const location = 'appflowy';
setUp(() async {
await TestFolder.cleanTestLocation(location);
await TestFolder.setTestLocation(location);
});
tearDown(() async {
await TestFolder.cleanTestLocation(location);
});
tearDownAll(() async {
await TestFolder.cleanTestLocation(null);
});
testWidgets('create row of the grid', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
await tester.tapCreateRowButtonInGrid();
// The initial number of rows is 3
await tester.assertNumberOfRowsInGridPage(4);
await tester.pumpAndSettle();
});
testWidgets('create row from row menu of the grid', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
await tester.hoverOnFirstRowOfGrid();
await tester.tapCreateRowButtonInRowMenuOfGrid();
// The initial number of rows is 3
await tester.assertNumberOfRowsInGridPage(4);
await tester.assertRowCountInGridPage(4);
await tester.pumpAndSettle();
});
testWidgets('delete row of the grid', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
await tester.hoverOnFirstRowOfGrid();
// Open the row menu and then click the delete
await tester.tapRowMenuButtonInGrid();
await tester.tapDeleteOnRowMenu();
// The initial number of rows is 3
await tester.assertNumberOfRowsInGridPage(2);
await tester.assertRowCountInGridPage(2);
await tester.pumpAndSettle();
});
testWidgets('check number of row indicator in the initial grid',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
await tester.assertRowCountInGridPage(3);
await tester.pumpAndSettle();
});
});
}
@@ -0,0 +1,66 @@
import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'util/database_test_op.dart';
import 'util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('grid', () {
const location = 'appflowy';
setUp(() async {
await TestFolder.cleanTestLocation(location);
await TestFolder.setTestLocation(location);
});
tearDown(() async {
await TestFolder.cleanTestLocation(location);
});
tearDownAll(() async {
await TestFolder.cleanTestLocation(null);
});
testWidgets('update layout', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
// open setting
await tester.tapDatabaseSettingButton();
// select the layout
await tester.tapDatabaseLayoutButton();
// select layout by board
await tester.selectDatabaseLayoutType(DatabaseLayoutPB.Board);
await tester.assertCurrentDatabaseLayoutType(DatabaseLayoutPB.Board);
await tester.pumpAndSettle();
});
testWidgets('update layout multiple times', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
// open setting
await tester.tapDatabaseSettingButton();
await tester.tapDatabaseLayoutButton();
await tester.selectDatabaseLayoutType(DatabaseLayoutPB.Board);
await tester.assertCurrentDatabaseLayoutType(DatabaseLayoutPB.Board);
await tester.tapDatabaseSettingButton();
await tester.tapDatabaseLayoutButton();
await tester.selectDatabaseLayoutType(DatabaseLayoutPB.Calendar);
await tester.assertCurrentDatabaseLayoutType(DatabaseLayoutPB.Calendar);
await tester.pumpAndSettle();
});
});
}
@@ -0,0 +1,110 @@
import 'package:appflowy/plugins/database_view/board/presentation/board_page.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/grid_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('database view in document', () {
const location = 'database_view';
setUp(() async {
await TestFolder.cleanTestLocation(location);
await TestFolder.setTestLocation(location);
});
tearDown(() async {
await TestFolder.cleanTestLocation(null);
});
testWidgets('insert a referenced grid', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await insertReferenceDatabase(tester, ViewLayoutPB.Grid);
// validate the referenced grid is inserted
expect(
find.descendant(
of: find.byType(AppFlowyEditor),
matching: find.byType(GridPage),
),
findsOneWidget,
);
});
testWidgets('insert a referenced board', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await insertReferenceDatabase(tester, ViewLayoutPB.Board);
// validate the referenced board is inserted
expect(
find.descendant(
of: find.byType(AppFlowyEditor),
matching: find.byType(BoardPage),
),
findsOneWidget,
);
});
// testWidgets('insert a referenced calendar', (tester) async {
// await tester.initializeAppFlowy();
// await tester.tapGoButton();
// await insertReferenceDatabase(tester, ViewLayoutPB.Calendar);
// // validate the referenced grid is inserted
// expect(
// find.descendant(
// of: find.byType(AppFlowyEditor),
// matching: find.byType(CalendarPage),
// ),
// findsOneWidget,
// );
// });
});
}
/// Insert a referenced database of [layout] into the document
Future<void> insertReferenceDatabase(
WidgetTester tester,
ViewLayoutPB layout,
) async {
// create a new grid
final id = uuid();
final name = '${layout.name}_$id';
await tester.createNewPageWithName(
layout,
name,
);
// create a new document
await tester.createNewPageWithName(
ViewLayoutPB.Document,
'insert_a_reference_${layout.name}',
);
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
// insert a referenced grid
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
layout.referencedMenuName,
);
final linkToPageMenu = find.byType(LinkToPageMenu);
expect(linkToPageMenu, findsOneWidget);
final referencedDatabase = find.descendant(
of: linkToPageMenu,
matching: find.findTextInFlowyText(name),
);
expect(referencedDatabase, findsOneWidget);
await tester.tapButton(referencedDatabase);
}
@@ -0,0 +1,60 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'util/mock/mock_file_picker.dart';
import 'util/util.dart';
import 'package:path/path.dart' as p;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('import files', () {
const location = 'import_files';
setUp(() async {
await TestFolder.cleanTestLocation(location);
await TestFolder.setTestLocation(location);
});
tearDown(() async {
await TestFolder.cleanTestLocation(location);
});
tearDownAll(() async {
await TestFolder.cleanTestLocation(null);
});
testWidgets('import multiple markdown files', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// expect to see a readme page
tester.expectToSeePageName(readme);
await tester.tapAddButton();
await tester.tapImportButton();
final testFileNames = ['test1.md', 'test2.md'];
final fileLocation = await tester.currentFileLocation();
for (final fileName in testFileNames) {
final str = await rootBundle.loadString(
p.join(
'assets/test/workspaces/markdowns',
fileName,
),
);
File(p.join(fileLocation, fileName)).writeAsStringSync(str);
}
// mock get files
await mockPickFilePaths(testFileNames, name: location);
await tester.tapTextAndMarkdownButton();
tester.expectToSeePageName('test1');
tester.expectToSeePageName('test2');
});
});
}
@@ -0,0 +1,45 @@
import 'package:appflowy/workspace/presentation/settings/widgets/settings_language_view.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('document', () {
const location = 'appflowy';
setUpAll(() async {
await TestFolder.setTestLocation(location);
});
tearDownAll(() async {
await TestFolder.cleanTestLocation(null);
});
testWidgets(
'change the language successfully when launching the app for the first time',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapLanguageSelectorOnWelcomePage();
expect(find.byType(LanguageItemsListView), findsOneWidget);
await tester.tapLanguageItem(languageCode: 'zh', countryCode: 'CN');
tester.expectToSeeText('开始');
await tester.tapLanguageItem(languageCode: 'en', scrollDelta: -100);
tester.expectToSeeText('Quick Start');
await tester.tapLanguageItem(languageCode: 'it', countryCode: 'IT');
tester.expectToSeeText('Andiamo');
});
/// Make sure this test is executed after the test above.
testWidgets('check the language after relaunching the app', (tester) async {
await tester.initializeAppFlowy();
tester.expectToSeeText('Andiamo');
});
});
}
@@ -3,6 +3,9 @@ import 'package:integration_test/integration_test.dart';
import 'switch_folder_test.dart' as switch_folder_test;
import 'document_test.dart' as document_test;
import 'cover_image_test.dart' as cover_image_test;
import 'share_markdown_test.dart' as share_markdown_test;
import 'import_files_test.dart' as import_files_test;
import 'document_with_database_test.dart' as document_with_database_test;
/// The main task runner for all integration tests in AppFlowy.
///
@@ -16,6 +19,9 @@ void main() {
switch_folder_test.main();
cover_image_test.main();
document_test.main();
share_markdown_test.main();
import_files_test.main();
document_with_database_test.main();
// board_test.main();
// empty_document_test.main();
// smart_menu_test.main();
@@ -1,5 +1,6 @@
import 'dart:io';
import 'package:appflowy/plugins/document/presentation/share/share_button.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -47,6 +48,38 @@ void main() {
final markdown = file.readAsStringSync();
expect(markdown, expectedMarkdown);
});
testWidgets(
'share the markdown after renaming the document name',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// expect to see a readme page
tester.expectToSeePageName(readme);
// rename the document
await tester.hoverOnPageName(readme);
await tester.renamePage('example');
final shareButton = find.byType(ShareActionList);
final shareButtonState =
tester.state(shareButton) as ShareActionListState;
final path =
await mockSaveFilePath(location, '${shareButtonState.name}.md');
// click the share button and select markdown
await tester.tapShareButton();
await tester.tapMarkdownButton();
// expect to see the success dialog
tester.expectToExportSuccess();
final file = File(path);
final isExist = file.existsSync();
expect(isExist, true);
},
);
});
}
@@ -35,7 +35,7 @@ void main() {
await tester.initializeAppFlowy();
await tester.tapGoButton();
tester.expectToSeeWelcomePage();
tester.expectToSeeHomePage();
// switch to user B
{
@@ -51,7 +51,7 @@ void main() {
await mockGetDirectoryPath(userB);
await tester.tapCustomLocationButton();
await tester.pumpAndSettle();
tester.expectToSeeWelcomePage();
tester.expectToSeeHomePage();
// set user name to userB
await tester.openSettings();
@@ -69,7 +69,7 @@ void main() {
await tester.tapCustomLocationButton();
await tester.pumpAndSettle();
tester.expectToSeeWelcomePage();
tester.expectToSeeHomePage();
tester.expectToSeeUserName(userA);
}
@@ -84,7 +84,7 @@ void main() {
await tester.tapCustomLocationButton();
await tester.pumpAndSettle();
tester.expectToSeeWelcomePage();
tester.expectToSeeHomePage();
tester.expectToSeeUserName(userB);
}
@@ -98,7 +98,7 @@ void main() {
await tester.tapGoButton();
// home and readme document
tester.expectToSeeWelcomePage();
tester.expectToSeeHomePage();
// open settings and restore the location
await tester.openSettings();
@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/main.dart' as app;
import 'package:appflowy/startup/tasks/prelude.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -91,7 +92,16 @@ extension AppFlowyTestBase on WidgetTester {
String tr, {
int milliseconds = 500,
}) async {
final button = find.text(tr, findRichText: true);
Finder button = find.text(
tr,
findRichText: true,
skipOffstage: false,
);
if (button.evaluate().isEmpty) {
button = find.byWidgetPredicate(
(widget) => widget is FlowyText && widget.title == tr,
);
}
await tapButton(
button,
milliseconds: milliseconds,
@@ -116,3 +126,11 @@ extension AppFlowyTestBase on WidgetTester {
return;
}
}
extension AppFlowyFinderTestBase on CommonFinders {
Finder findTextInFlowyText(String text) {
return find.byWidgetPredicate(
(widget) => widget is FlowyText && widget.title == text,
);
}
}
@@ -1,92 +1,184 @@
import 'dart:ui';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/banner.dart';
import 'package:appflowy/plugins/document/presentation/share/share_button.dart';
import 'package:appflowy/user/presentation/skip_log_in_screen.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy/workspace/presentation/home/menu/app/header/add_button.dart';
import 'package:appflowy/workspace/presentation/home/menu/app/section/item.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/settings_language_view.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/buttons/primary_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'base.dart';
const String readme = 'Read me';
import 'util.dart';
extension CommonOperations on WidgetTester {
/// Get current file location of AppFlowy.
Future<String> currentFileLocation() async {
return TestFolder.currentLocation();
}
/// Tap the GetStart button on the launch page.
Future<void> tapGoButton() async {
final goButton = find.byType(GoButton);
await tapButton(goButton);
}
Future<void> tapCreateButton() async {
await tapButtonWithName(LocaleKeys.settings_files_create.tr());
}
void expectToSeeWelcomePage() {
expect(find.byType(HomeStack), findsOneWidget);
expect(find.textContaining('Read me'), findsOneWidget);
}
/// Tap the + button on the home page.
Future<void> tapAddButton() async {
final addButton = find.byType(AddButton);
await tapButton(addButton);
}
/// Tap the create document button.
///
/// Must call [tapAddButton] first.
Future<void> tapCreateDocumentButton() async {
await tapButtonWithName(LocaleKeys.document_menuName.tr());
}
Finder findPageName(String name) {
return find.byWidgetPredicate(
(widget) => widget is ViewSectionItem && widget.view.name == name,
);
/// Tap the create grid button.
///
/// Must call [tapAddButton] first.
Future<void> tapCreateGridButton() async {
await tapButtonWithName(LocaleKeys.grid_menuName.tr());
}
void expectToSeePageName(String name) {
final pageName = findPageName(name);
expect(pageName, findsOneWidget);
/// Tap the import button.
///
/// Must call [tapAddButton] first.
Future<void> tapImportButton() async {
await tapButtonWithName(LocaleKeys.moreAction_import.tr());
}
void expectNotToSeePageName(String name) {
final pageName = findPageName(name);
expect(pageName, findsNothing);
/// Tap the import from text & markdown button.
///
/// Must call [tapImportButton] first.
Future<void> tapTextAndMarkdownButton() async {
await tapButtonWithName(LocaleKeys.importPanel_textAndMarkdown.tr());
}
Future<void> hoverOnPageName(String name) async {
final pageName = find.byWidgetPredicate(
(widget) => widget is ViewSectionItem && widget.view.name == name,
/// Tap the LanguageSelectorOnWelcomePage widget on the launch page.
Future<void> tapLanguageSelectorOnWelcomePage() async {
final languageSelector = find.byType(LanguageSelectorOnWelcomePage);
await tapButton(languageSelector);
}
/// Tap languageItem on LanguageItemsListView.
///
/// [scrollDelta] is the distance to scroll the ListView.
/// Default value is 100
///
/// If it is positive -> scroll down.
///
/// If it is negative -> scroll up.
Future<void> tapLanguageItem({
required String languageCode,
String? countryCode,
double? scrollDelta,
}) async {
final languageItemsListView = find.descendant(
of: find.byType(ListView),
matching: find.byType(Scrollable),
);
final gesture = await createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: Offset.zero);
addTearDown(gesture.removePointer);
await pump();
await gesture.moveTo(getCenter(pageName));
await pumpAndSettle();
final languageItem = find.byWidgetPredicate(
(widget) =>
widget is LanguageItem &&
widget.locale.languageCode == languageCode &&
widget.locale.countryCode == countryCode,
);
// scroll the ListView until zHCNLanguageItem shows on the screen.
await scrollUntilVisible(
languageItem,
scrollDelta ?? 100,
scrollable: languageItemsListView,
// maxHeight of LanguageItemsListView
maxScrolls: 400,
);
try {
await tapButton(languageItem);
} on FlutterError catch (e) {
Log.warn('tapLanguageItem error: $e');
}
}
/// Hover on the widget.
Future<void> hoverOnWidget(
Finder finder, {
Offset? offset,
Future<void> Function()? onHover,
}) async {
try {
final gesture = await createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: Offset.zero);
await pump();
await gesture.moveTo(offset ?? getCenter(finder));
await pumpAndSettle();
await onHover?.call();
await gesture.removePointer();
} catch (err) {
Log.error('hoverOnWidget error: $err');
}
}
/// Hover on the page name.
Future<void> hoverOnPageName(
String name, {
Future<void> Function()? onHover,
bool useLast = true,
}) async {
if (useLast) {
await hoverOnWidget(findPageName(name).last, onHover: onHover);
} else {
await hoverOnWidget(findPageName(name).first, onHover: onHover);
}
}
/// Tap the ... button beside the page name.
///
/// Must call [hoverOnPageName] first.
Future<void> tapPageOptionButton() async {
final optionButton = find.byType(ViewDisclosureButton);
await tapButton(optionButton);
}
/// Tap the delete page button.
Future<void> tapDeletePageButton() async {
await tapPageOptionButton();
await tapButtonWithName(ViewDisclosureAction.delete.name);
}
void expectToSeeDocumentBanner() {
expect(find.byType(DocumentBanner), findsOneWidget);
/// Tap the rename page button.
Future<void> tapRenamePageButton() async {
await tapPageOptionButton();
await tapButtonWithName(ViewDisclosureAction.rename.name);
}
void expectNotToSeeDocumentBanner() {
expect(find.byType(DocumentBanner), findsNothing);
/// Rename the page.
Future<void> renamePage(String name) async {
await tapRenamePageButton();
await enterText(find.byType(TextFormField), name);
await tapOKButton();
}
Future<void> tapOKButton() async {
final okButton = find.byWidgetPredicate(
(widget) =>
widget is PrimaryTextButton &&
widget.label == LocaleKeys.button_OK.tr(),
);
await tapButton(okButton);
}
/// Tap the restore button.
///
/// the restore button will show after the current page is deleted.
Future<void> tapRestoreButton() async {
final restoreButton = find.textContaining(
LocaleKeys.deletePagePrompt_restore.tr(),
@@ -94,6 +186,9 @@ extension CommonOperations on WidgetTester {
await tapButton(restoreButton);
}
/// Tap the delete permanently button.
///
/// the restore button will show after the current page is deleted.
Future<void> tapDeletePermanentlyButton() async {
final restoreButton = find.textContaining(
LocaleKeys.deletePagePrompt_deletePermanent.tr(),
@@ -101,6 +196,7 @@ extension CommonOperations on WidgetTester {
await tapButton(restoreButton);
}
/// Tap the share button above the document page.
Future<void> tapShareButton() async {
final shareButton = find.byWidgetPredicate(
(widget) => widget is DocumentShareButton,
@@ -108,6 +204,9 @@ extension CommonOperations on WidgetTester {
await tapButton(shareButton);
}
/// Tap the export markdown button
///
/// Must call [tapShareButton] first.
Future<void> tapMarkdownButton() async {
final markdownButton = find.textContaining(
LocaleKeys.shareAction_markdown.tr(),
@@ -115,44 +214,50 @@ extension CommonOperations on WidgetTester {
await tapButton(markdownButton);
}
void expectToExportSuccess() {
final exportSuccess = find.byWidgetPredicate(
(widget) =>
widget is FlowyText &&
widget.title == LocaleKeys.settings_files_exportFileSuccess.tr(),
);
expect(exportSuccess, findsOneWidget);
}
Future<void> createNewPageWithName(ViewLayoutPB layout, String name) async {
// create a new page
await tapAddButton();
await tapButtonWithName(layout.menuName);
await pumpAndSettle();
Future<void> hoverOnCoverPluginAddButton() async {
final editor = find.byWidgetPredicate(
(widget) => widget is AppFlowyEditor,
// hover on it and change it's name
await hoverOnPageName(
LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
onHover: () async {
await renamePage(name);
await pumpAndSettle();
},
);
final gesture = await createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: Offset.zero);
addTearDown(gesture.removePointer);
await pump();
final topLeft = getTopLeft(editor).translate(20, 20);
await gesture.moveTo(topLeft);
await pumpAndSettle();
}
}
Future<void> expectToSeePluginAddCoverAndIconButton() async {
final addCover = find.textContaining(
LocaleKeys.document_plugins_cover_addCover.tr(),
);
final addIcon = find.textContaining(
LocaleKeys.document_plugins_cover_addIcon.tr(),
);
expect(addCover, findsOneWidget);
expect(addIcon, findsOneWidget);
extension ViewLayoutPBTest on ViewLayoutPB {
String get menuName {
switch (this) {
case ViewLayoutPB.Grid:
return LocaleKeys.grid_menuName.tr();
case ViewLayoutPB.Board:
return LocaleKeys.board_menuName.tr();
case ViewLayoutPB.Document:
return LocaleKeys.document_menuName.tr();
case ViewLayoutPB.Calendar:
return LocaleKeys.calendar_menuName.tr();
default:
throw UnsupportedError('Unsupported layout: $this');
}
}
void expectToSeeUserName(String name) {
final userName = find.byWidgetPredicate(
(widget) => widget is FlowyText && widget.title == name,
);
expect(userName, findsOneWidget);
String get referencedMenuName {
switch (this) {
case ViewLayoutPB.Grid:
return LocaleKeys.document_plugins_referencedGrid.tr();
case ViewLayoutPB.Board:
return LocaleKeys.document_plugins_referencedBoard.tr();
case ViewLayoutPB.Calendar:
return LocaleKeys.document_plugins_referencedCalendar.tr();
default:
throw UnsupportedError('Unsupported layout: $this');
}
}
}
@@ -0,0 +1,481 @@
import 'dart:ui';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/application/setting/setting_bloc.dart';
import 'package:appflowy/plugins/database_view/board/presentation/board_page.dart';
import 'package:appflowy/plugins/database_view/calendar/presentation/calendar_page.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_cell_action_sheet.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_type_option_editor.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/toolbar/grid_layout.dart';
import 'package:appflowy/plugins/database_view/widgets/row/row_document.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/date_cell/date_editor.dart';
import 'package:appflowy/plugins/database_view/widgets/setting/database_setting.dart';
import 'package:appflowy/plugins/database_view/widgets/setting/setting_button.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flowy_infra_ui/widget/buttons/primary_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/grid_page.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/footer/grid_footer.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_cell.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_editor.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_type_extension.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/row/row.dart';
import 'package:appflowy/plugins/database_view/widgets/row/accessory/cell_accessory.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/cells.dart';
import 'package:appflowy/plugins/database_view/widgets/row/row_action.dart';
import 'package:appflowy/plugins/database_view/widgets/row/row_banner.dart';
import 'package:appflowy/plugins/database_view/widgets/row/row_detail.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/emoji_picker/emoji_menu_item.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:table_calendar/table_calendar.dart';
import 'base.dart';
import 'common_operations.dart';
extension AppFlowyDatabaseTest on WidgetTester {
Future<void> hoverOnFirstRowOfGrid() async {
final findRow = find.byType(GridRow);
expect(findRow, findsWidgets);
final firstRow = findRow.first;
await hoverOnWidget(firstRow);
}
Future<void> editCell({
required int rowIndex,
required FieldType fieldType,
required String input,
}) async {
final findRow = find.byType(GridRow);
final findCell = finderForFieldType(fieldType);
final cell = find.descendant(
of: findRow.at(rowIndex),
matching: findCell,
);
expect(cell, findsOneWidget);
await enterText(cell, input);
await pumpAndSettle();
}
Future<void> tapCheckboxCellInGrid({
required int rowIndex,
}) async {
final findRow = find.byType(GridRow);
final findCell = finderForFieldType(FieldType.Checkbox);
final cell = find.descendant(
of: findRow.at(rowIndex),
matching: findCell,
);
final button = find.descendant(
of: cell,
matching: find.byType(FlowyIconButton),
);
expect(cell, findsOneWidget);
await tapButton(button);
}
Future<void> assertCheckboxCell({
required int rowIndex,
required bool isSelected,
}) async {
final findRow = find.byType(GridRow);
final findCell = finderForFieldType(FieldType.Checkbox);
final cell = find.descendant(
of: findRow.at(rowIndex),
matching: findCell,
);
var finder = find.byType(CheckboxCellUncheck);
if (isSelected) {
finder = find.byType(CheckboxCellCheck);
}
expect(
find.descendant(
of: cell,
matching: finder,
),
findsOneWidget,
);
}
Future<void> tapCellInGrid({
required int rowIndex,
required FieldType fieldType,
}) async {
final findRow = find.byType(GridRow);
final findCell = finderForFieldType(fieldType);
final cell = find.descendant(
of: findRow.at(rowIndex),
matching: findCell,
);
expect(cell, findsOneWidget);
await tapButton(cell);
}
Future<void> assertCellContent({
required int rowIndex,
required FieldType fieldType,
required String content,
}) async {
final findRow = find.byType(GridRow);
final findCell = finderForFieldType(fieldType);
final cell = find.descendant(
of: findRow.at(rowIndex),
matching: findCell,
);
final findContent = find.descendant(
of: cell,
matching: find.text(content),
);
expect(findContent, findsOneWidget);
}
Future<void> selectDay({
required int content,
}) async {
final findCalendar = find.byType(TableCalendar);
final findDay = find.text(content.toString());
final finder = find.descendant(
of: findCalendar,
matching: findDay,
);
await tapButton(finder);
}
Future<void> openFirstRowDetailPage() async {
await hoverOnFirstRowOfGrid();
final expandButton = find.byType(PrimaryCellAccessory);
expect(expandButton, findsOneWidget);
await tapButton(expandButton);
}
Future<void> hoverRowBanner() async {
final banner = find.byType(RowBanner);
expect(banner, findsOneWidget);
await startGesture(
getTopLeft(banner),
kind: PointerDeviceKind.mouse,
);
await pumpAndSettle();
}
Future<void> openEmojiPicker() async {
await tapButton(find.byType(EmojiPickerButton));
await tapButton(find.byType(EmojiSelectionMenu));
}
/// Must call [openEmojiPicker] first
Future<void> switchToEmojiList() async {
final icon = find.byIcon(Icons.tag_faces);
await tapButton(icon);
}
Future<void> tapEmoji(String emoji) async {
final emojiWidget = find.text(emoji);
await tapButton(emojiWidget);
}
Future<void> scrollGridByOffset(Offset offset) async {
await drag(find.byType(GridPage), offset);
await pumpAndSettle();
}
Future<void> scrollRowDetailByOffset(Offset offset) async {
await drag(find.byType(RowDetailPage), offset);
await pumpAndSettle();
}
Future<void> scrollToRight(Finder find) async {
final size = getSize(find);
await drag(find, Offset(-size.width, 0));
await pumpAndSettle(const Duration(milliseconds: 500));
}
Future<void> tapNewPropertyButton() async {
await tapButtonWithName(LocaleKeys.grid_field_newProperty.tr());
await pumpAndSettle();
}
Future<void> tapGridFieldWithName(String name) async {
final field = find.byWidgetPredicate(
(widget) => widget is FieldCellButton && widget.field.name == name,
);
await tapButton(field);
await pumpAndSettle();
}
/// Should call [tapGridFieldWithName] first.
Future<void> tapEditPropertyButton() async {
await tapButtonWithName(LocaleKeys.grid_field_editProperty.tr());
await pumpAndSettle(const Duration(milliseconds: 200));
}
/// Should call [tapGridFieldWithName] first.
Future<void> tapDeletePropertyButton() async {
final field = find.byWidgetPredicate(
(widget) =>
widget is FieldActionCell && widget.action == FieldAction.delete,
);
await tapButton(field);
}
/// Should call [tapGridFieldWithName] first.
Future<void> tapDialogOkButton() async {
final field = find.byWidgetPredicate(
(widget) =>
widget is PrimaryTextButton &&
widget.label == LocaleKeys.button_OK.tr(),
);
await tapButton(field);
}
/// Should call [tapGridFieldWithName] first.
Future<void> tapDuplicatePropertyButton() async {
final field = find.byWidgetPredicate(
(widget) =>
widget is FieldActionCell && widget.action == FieldAction.duplicate,
);
await tapButton(field);
}
/// Should call [tapGridFieldWithName] first.
Future<void> tapHidePropertyButton() async {
final field = find.byWidgetPredicate(
(widget) =>
widget is FieldActionCell && widget.action == FieldAction.hide,
);
await tapButton(field);
}
Future<void> tapRowDetailPageCreatePropertyButton() async {
await tapButton(find.byType(CreateRowFieldButton));
}
Future<void> tapRowDetailPageDeleteRowButton() async {
await tapButton(find.byType(RowDetailPageDeleteButton));
}
Future<void> tapRowDetailPageDuplicateRowButton() async {
await tapButton(find.byType(RowDetailPageDuplicateButton));
}
Future<void> tapTypeOptionButton() async {
await tapButton(find.byType(SwitchFieldButton));
}
Future<void> tapEscButton() async {
await sendKeyEvent(LogicalKeyboardKey.escape);
}
/// Must call [tapTypeOptionButton] first.
Future<void> selectFieldType(FieldType fieldType) async {
final fieldTypeButton = find.byWidgetPredicate(
(widget) => widget is FlowyText && widget.title == fieldType.title(),
);
await tapButton(fieldTypeButton);
}
/// Each field has its own cell, so we can find the corresponding cell by
/// the field type after create a new field.
Future<void> findCellByFieldType(FieldType fieldType) async {
final finder = finderForFieldType(fieldType);
expect(finder, findsWidgets);
}
Future<void> assertNumberOfFieldsInGridPage(int num) async {
expect(find.byType(GridFieldCell), findsNWidgets(num));
}
Future<void> assertNumberOfRowsInGridPage(int num) async {
expect(find.byType(GridRow), findsNWidgets(num));
}
Future<void> assertDocumentExistInRowDetailPage() async {
expect(find.byType(RowDocument), findsOneWidget);
}
/// Check the field type of the [FieldCellButton] is the same as the name.
Future<void> assertFieldTypeWithFieldName(
String name,
FieldType fieldType,
) async {
final field = find.byWidgetPredicate(
(widget) =>
widget is FieldCellButton &&
widget.field.fieldType == fieldType &&
widget.field.name == name,
);
expect(field, findsOneWidget);
}
Future<void> findFieldWithName(String name) async {
final field = find.byWidgetPredicate(
(widget) => widget is FieldCellButton && widget.field.name == name,
);
expect(field, findsOneWidget);
}
Future<void> noFieldWithName(String name) async {
final field = find.byWidgetPredicate(
(widget) => widget is FieldCellButton && widget.field.name == name,
);
expect(field, findsNothing);
}
Future<void> renameField(String newName) async {
final textField = find.byType(FieldNameTextField);
expect(textField, findsOneWidget);
await enterText(textField, newName);
await pumpAndSettle();
}
Future<void> dismissFieldEditor() async {
await sendKeyEvent(LogicalKeyboardKey.escape);
await sendKeyEvent(LogicalKeyboardKey.escape);
await sendKeyEvent(LogicalKeyboardKey.escape);
await pumpAndSettle();
}
Future<void> findFieldEditor(dynamic matcher) async {
final finder = find.byType(FieldEditor);
expect(finder, matcher);
}
Future<void> findDateEditor(dynamic matcher) async {
final finder = find.byType(DateCellEditor);
expect(finder, matcher);
}
Future<void> tapCreateRowButtonInGrid() async {
await tapButton(find.byType(GridAddRowButton));
}
Future<void> tapCreateRowButtonInRowMenuOfGrid() async {
await tapButton(find.byType(InsertRowButton));
}
Future<void> tapRowMenuButtonInGrid() async {
await tapButton(find.byType(RowMenuButton));
}
/// Should call [tapRowMenuButtonInGrid] first.
Future<void> tapDeleteOnRowMenu() async {
await tapButtonWithName(LocaleKeys.grid_row_delete.tr());
}
Future<void> assertRowCountInGridPage(int num) async {
final text = find.byWidgetPredicate(
(widget) => widget is FlowyText && widget.title == rowCountString(num),
);
expect(text, findsOneWidget);
}
Future<void> createField(FieldType fieldType, String name) async {
await scrollToRight(find.byType(GridPage));
await tapNewPropertyButton();
await renameField(name);
await tapTypeOptionButton();
await selectFieldType(fieldType);
await dismissFieldEditor();
}
Future<void> tapDatabaseSettingButton() async {
await tapButton(find.byType(SettingButton));
}
/// Should call [tapDatabaseSettingButton] first.
Future<void> tapDatabaseLayoutButton() async {
final findSettingItem = find.byType(DatabaseSettingItem);
final findLayoutButton = find.byWidgetPredicate(
(widget) =>
widget is FlowyText &&
widget.title == DatabaseSettingAction.showLayout.title(),
);
final button = find.descendant(
of: findSettingItem,
matching: findLayoutButton,
);
await tapButton(button);
}
Future<void> selectDatabaseLayoutType(DatabaseLayoutPB layout) async {
final findLayoutCell = find.byType(DatabaseViewLayoutCell);
final findText = find.byWidgetPredicate(
(widget) => widget is FlowyText && widget.title == layout.layoutName(),
);
final button = find.descendant(
of: findLayoutCell,
matching: findText,
);
await tapButton(button);
}
Future<void> assertCurrentDatabaseLayoutType(DatabaseLayoutPB layout) async {
expect(finderForDatabaseLayoutType(layout), findsOneWidget);
}
}
Finder finderForDatabaseLayoutType(DatabaseLayoutPB layout) {
switch (layout) {
case DatabaseLayoutPB.Board:
return find.byType(BoardPage);
case DatabaseLayoutPB.Calendar:
return find.byType(CalendarPage);
case DatabaseLayoutPB.Grid:
return find.byType(GridPage);
default:
throw Exception('Unknown database layout type: $layout');
}
}
Finder finderForFieldType(FieldType fieldType) {
switch (fieldType) {
case FieldType.Checkbox:
return find.byType(GridCheckboxCell);
case FieldType.DateTime:
return find.byType(GridDateCell);
case FieldType.LastEditedTime:
case FieldType.CreatedTime:
return find.byType(GridDateCell);
case FieldType.SingleSelect:
return find.byType(GridSingleSelectCell);
case FieldType.MultiSelect:
return find.byType(GridMultiSelectCell);
case FieldType.Checklist:
return find.byType(GridChecklistCell);
case FieldType.Number:
return find.byType(GridNumberCell);
case FieldType.RichText:
return find.byType(GridTextCell);
case FieldType.URL:
return find.byType(GridURLCell);
default:
throw Exception('Unknown field type: $fieldType');
}
}
@@ -0,0 +1,45 @@
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:flutter_test/flutter_test.dart';
import 'ime.dart';
import 'util.dart';
extension EditorWidgetTester on WidgetTester {
EditorOperations get editor => EditorOperations(this);
}
class EditorOperations {
const EditorOperations(this.tester);
final WidgetTester tester;
/// Tap the line of editor at [index]
Future<void> tapLineOfEditorAt(int index) async {
final textBlocks = find.byType(TextBlockComponentWidget);
await tester.tapAt(tester.getTopRight(textBlocks.at(index)));
}
/// Hover on cover plugin button above the document
Future<void> hoverOnCoverPluginAddButton() async {
final editor = find.byWidgetPredicate(
(widget) => widget is AppFlowyEditor,
);
await tester.hoverOnWidget(
editor,
offset: tester.getTopLeft(editor).translate(20, 20),
);
}
/// trigger the slash command (selection menu)
Future<void> showSlashMenu() async {
await tester.ime.insertCharacter('/');
}
/// Tap the slash menu item with [name]
///
/// Must call [showSlashMenu] first.
Future<void> tapSlashMenuItemWithName(String name) async {
final slashMenuItem = find.text(name, findRichText: true);
await tester.tapButton(slashMenuItem);
}
}
@@ -0,0 +1,87 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/banner.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy/workspace/presentation/home/menu/app/section/item.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter_test/flutter_test.dart';
const String readme = 'Read me';
extension Expectation on WidgetTester {
/// Expect to see the home page and with a default read me page.
void expectToSeeHomePage() {
expect(find.byType(HomeStack), findsOneWidget);
expect(find.textContaining(readme), findsOneWidget);
}
/// Expect to see the page name on the home page.
void expectToSeePageName(String name) {
final pageName = findPageName(name);
expect(pageName, findsOneWidget);
}
/// Expect not to see the page name on the home page.
void expectNotToSeePageName(String name) {
final pageName = findPageName(name);
expect(pageName, findsNothing);
}
/// Expect to see the document banner.
void expectToSeeDocumentBanner() {
expect(find.byType(DocumentBanner), findsOneWidget);
}
/// Expect not to see the document banner.
void expectNotToSeeDocumentBanner() {
expect(find.byType(DocumentBanner), findsNothing);
}
/// Expect to the markdown file export success dialog.
void expectToExportSuccess() {
final exportSuccess = find.byWidgetPredicate(
(widget) =>
widget is FlowyText &&
widget.title == LocaleKeys.settings_files_exportFileSuccess.tr(),
);
expect(exportSuccess, findsOneWidget);
}
/// Expect to see the add button and icon button inside the document.
void expectToSeePluginAddCoverAndIconButton() {
final addCover = find.textContaining(
LocaleKeys.document_plugins_cover_addCover.tr(),
);
final addIcon = find.textContaining(
LocaleKeys.document_plugins_cover_addIcon.tr(),
);
expect(addCover, findsOneWidget);
expect(addIcon, findsOneWidget);
}
/// Expect to see the user name on the home page
void expectToSeeUserName(String name) {
final userName = find.byWidgetPredicate(
(widget) => widget is FlowyText && widget.title == name,
);
expect(userName, findsOneWidget);
}
/// Expect to see a text
void expectToSeeText(String text) {
Finder textWidget = find.textContaining(text, findRichText: true);
if (textWidget.evaluate().isEmpty) {
textWidget = find.byWidgetPredicate(
(widget) => widget is FlowyText && widget.title == text,
);
}
expect(textWidget, findsOneWidget);
}
/// Find the page name on the home page.
Finder findPageName(String name) {
return find.byWidgetPredicate(
(widget) => widget is ViewSectionItem && widget.view.name == name,
);
}
}
@@ -0,0 +1,54 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
extension IME on WidgetTester {
IMESimulator get ime => IMESimulator(this);
}
class IMESimulator {
IMESimulator(this.tester) {
client = findDeltaTextInputClient();
}
final WidgetTester tester;
late final DeltaTextInputClient client;
Future<void> insertText(String text) async {
for (final c in text.characters) {
await insertCharacter(c);
}
}
Future<void> insertCharacter(String character) async {
final value = client.currentTextEditingValue;
if (value == null) {
assert(false);
return;
}
final deltas = [
TextEditingDeltaInsertion(
textInserted: character,
oldText: value.text.replaceRange(
value.selection.start,
value.selection.end,
'',
),
insertionOffset: value.selection.baseOffset,
selection: TextSelection.collapsed(
offset: value.selection.baseOffset + 1,
),
composing: TextRange.empty,
),
];
client.updateEditingValueWithDeltas(deltas);
await tester.pumpAndSettle();
}
DeltaTextInputClient findDeltaTextInputClient() {
final finder = find.byType(KeyboardServiceWidget);
final KeyboardServiceWidgetState state = tester.state(finder);
return state.textInputService as DeltaTextInputClient;
}
}
@@ -1,15 +1,19 @@
import 'dart:io';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/file_picker/file_picker_service.dart';
import 'package:file_picker/src/file_picker.dart' as fp;
import 'package:file_picker/file_picker.dart' as fp;
import 'package:path/path.dart' as p;
import '../util.dart';
class MockFilePicker implements FilePickerService {
MockFilePicker({
required this.mockPath,
this.mockPath = '',
this.mockPaths = const [],
});
final String mockPath;
final List<String> mockPaths;
@override
Future<String?> getDirectoryPath({String? title}) {
@@ -41,7 +45,14 @@ class MockFilePicker implements FilePickerService {
bool withReadStream = false,
bool lockParentWindow = false,
}) {
throw UnimplementedError();
final platformFiles = mockPaths
.map((e) => fp.PlatformFile(path: e, name: '', size: 0))
.toList();
return Future.value(
FilePickerResult(
platformFiles,
),
);
}
}
@@ -67,3 +78,24 @@ Future<String> mockSaveFilePath(String? name, String fileName) async {
);
return path;
}
Future<List<String>> mockPickFilePaths(
List<String> fileNames, {
String? name,
String? customPath,
}) async {
late final Directory dir;
if (customPath != null) {
dir = Directory(customPath);
} else {
dir = await TestFolder.testLocation(name);
}
final paths = fileNames.map((e) => p.join(dir.path, e)).toList();
getIt.unregister<FilePickerService>();
getIt.registerFactory<FilePickerService>(
() => MockFilePicker(
mockPaths: paths,
),
);
return paths;
}
@@ -2,3 +2,5 @@ export 'base.dart';
export 'common_operations.dart';
export 'settings.dart';
export 'data.dart';
export 'expectation.dart';
export 'editor_test_operations.dart';
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:appflowy/plugins/database_view/application/field/field_listener.dart';
import 'package:appflowy/plugins/database_view/application/row/row_meta_listener.dart';
import 'package:appflowy/plugins/database_view/application/row/row_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
@@ -22,39 +23,45 @@ import 'cell_service.dart';
///
// ignore: must_be_immutable
class CellController<T, D> extends Equatable {
final DatabaseCellContext cellContext;
DatabaseCellContext _cellContext;
final CellCache _cellCache;
final CellCacheKey _cacheKey;
final FieldBackendService _fieldBackendSvc;
final SingleFieldListener _fieldListener;
final CellDataLoader<T> _cellDataLoader;
final CellDataPersistence<D> _cellDataPersistence;
CellListener? _cellListener;
RowMetaListener? _rowMetaListener;
SingleFieldListener? _fieldListener;
CellDataNotifier<T?>? _cellDataNotifier;
VoidCallback? _onCellFieldChanged;
VoidCallback? _onRowMetaChanged;
Timer? _loadDataOperation;
Timer? _saveDataOperation;
String get viewId => cellContext.viewId;
String get viewId => _cellContext.viewId;
RowId get rowId => cellContext.rowId;
RowId get rowId => _cellContext.rowId;
String get fieldId => cellContext.fieldInfo.id;
String get fieldId => _cellContext.fieldInfo.id;
FieldInfo get fieldInfo => cellContext.fieldInfo;
FieldInfo get fieldInfo => _cellContext.fieldInfo;
FieldType get fieldType => cellContext.fieldInfo.fieldType;
FieldType get fieldType => _cellContext.fieldInfo.fieldType;
String? get emoji => _cellContext.emoji;
CellController({
required this.cellContext,
required DatabaseCellContext cellContext,
required CellCache cellCache,
required CellDataLoader<T> cellDataLoader,
required CellDataPersistence<D> cellDataPersistence,
}) : _cellCache = cellCache,
}) : _cellContext = cellContext,
_cellCache = cellCache,
_cellDataLoader = cellDataLoader,
_cellDataPersistence = cellDataPersistence,
_rowMetaListener = RowMetaListener(cellContext.rowId),
_fieldListener = SingleFieldListener(fieldId: cellContext.fieldId),
_fieldBackendSvc = FieldBackendService(
viewId: cellContext.viewId,
@@ -84,20 +91,22 @@ class CellController<T, D> extends Equatable {
);
/// 2.Listen on the field event and load the cell data if needed.
_fieldListener.start(
onFieldChanged: (result) {
result.fold(
(fieldPB) {
/// reloadOnFieldChanged should be true if you need to load the data when the corresponding field is changed
/// For example:
/// ¥12 -> $12
if (_cellDataLoader.reloadOnFieldChanged) {
_loadData();
}
_onCellFieldChanged?.call();
},
(err) => Log.error(err),
);
_fieldListener?.start(
onFieldChanged: (fieldPB) {
/// reloadOnFieldChanged should be true if you need to load the data when the corresponding field is changed
/// For example:
/// ¥12 -> $12
if (_cellDataLoader.reloadOnFieldChanged) {
_loadData();
}
_onCellFieldChanged?.call();
},
);
_rowMetaListener?.start(
callback: (newRowMeta) {
_cellContext = _cellContext.copyWith(rowMeta: newRowMeta);
_onRowMetaChanged?.call();
},
);
}
@@ -105,9 +114,11 @@ class CellController<T, D> extends Equatable {
/// Listen on the cell content or field changes
VoidCallback? startListening({
required void Function(T?) onCellChanged,
VoidCallback? onRowMetaChanged,
VoidCallback? onCellFieldChanged,
}) {
_onCellFieldChanged = onCellFieldChanged;
_onRowMetaChanged = onRowMetaChanged;
/// Notify the listener, the cell data was changed.
onCellChangedFn() => onCellChanged(_cellDataNotifier?.value);
@@ -186,18 +197,26 @@ class CellController<T, D> extends Equatable {
}
Future<void> dispose() async {
await _rowMetaListener?.stop();
_rowMetaListener = null;
await _cellListener?.stop();
_cellListener = null;
await _fieldListener?.stop();
_fieldListener = null;
_loadDataOperation?.cancel();
_saveDataOperation?.cancel();
_cellDataNotifier?.dispose();
await _fieldListener.stop();
_cellDataNotifier = null;
_onRowMetaChanged = null;
}
@override
List<Object> get props => [
_cellCache.get(_cacheKey) ?? "",
cellContext.rowId + cellContext.fieldInfo.id
_cellContext.rowId + _cellContext.fieldInfo.id
];
}
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:collection';
import 'package:appflowy_backend/protobuf/flowy-database2/checklist_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/url_entities.pb.dart';
import 'package:dartz/dartz.dart';
@@ -52,18 +53,23 @@ class CellBackendService {
class DatabaseCellContext with _$DatabaseCellContext {
const factory DatabaseCellContext({
required String viewId,
required RowId rowId,
required RowMetaPB rowMeta,
required FieldInfo fieldInfo,
}) = _DatabaseCellContext;
// ignore: unused_element
const DatabaseCellContext._();
String get rowId => rowMeta.id;
String get fieldId => fieldInfo.id;
FieldType get fieldType => fieldInfo.fieldType;
ValueKey key() {
return ValueKey("$rowId$fieldId${fieldInfo.fieldType}");
return ValueKey("${rowMeta.id}$fieldId${fieldInfo.fieldType}");
}
/// Only the primary field can have an emoji.
String? get emoji => fieldInfo.isPrimary ? rowMeta.icon : null;
}
@@ -1,4 +1,3 @@
import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/cell_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/checklist_entities.pb.dart';
@@ -7,17 +6,23 @@ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:dartz/dartz.dart';
class ChecklistCellBackendService {
final DatabaseCellContext cellContext;
final String viewId;
final String fieldId;
final String rowId;
ChecklistCellBackendService({required this.cellContext});
ChecklistCellBackendService({
required this.viewId,
required this.fieldId,
required this.rowId,
});
Future<Either<Unit, FlowyError>> create({
required String name,
}) {
final payload = ChecklistCellDataChangesetPB.create()
..viewId = cellContext.viewId
..fieldId = cellContext.fieldInfo.id
..rowId = cellContext.rowId
..viewId = viewId
..fieldId = fieldId
..rowId = rowId
..insertOptions.add(name);
return DatabaseEventUpdateChecklistCell(payload).send();
@@ -27,9 +32,9 @@ class ChecklistCellBackendService {
required List<String> optionIds,
}) {
final payload = ChecklistCellDataChangesetPB.create()
..viewId = cellContext.viewId
..fieldId = cellContext.fieldInfo.id
..rowId = cellContext.rowId
..viewId = viewId
..fieldId = fieldId
..rowId = rowId
..deleteOptionIds.addAll(optionIds);
return DatabaseEventUpdateChecklistCell(payload).send();
@@ -39,9 +44,9 @@ class ChecklistCellBackendService {
required String optionId,
}) {
final payload = ChecklistCellDataChangesetPB.create()
..viewId = cellContext.viewId
..fieldId = cellContext.fieldInfo.id
..rowId = cellContext.rowId
..viewId = viewId
..fieldId = fieldId
..rowId = rowId
..selectedOptionIds.add(optionId);
return DatabaseEventUpdateChecklistCell(payload).send();
@@ -51,9 +56,9 @@ class ChecklistCellBackendService {
required SelectOptionPB option,
}) {
final payload = ChecklistCellDataChangesetPB.create()
..viewId = cellContext.viewId
..fieldId = cellContext.fieldInfo.id
..rowId = cellContext.rowId
..viewId = viewId
..fieldId = fieldId
..rowId = rowId
..updateOptions.add(option);
return DatabaseEventUpdateChecklistCell(payload).send();
@@ -61,10 +66,9 @@ class ChecklistCellBackendService {
Future<Either<ChecklistCellDataPB, FlowyError>> getCellData() {
final payload = CellIdPB.create()
..fieldId = cellContext.fieldInfo.id
..viewId = cellContext.viewId
..rowId = cellContext.rowId
..rowId = cellContext.rowId;
..viewId = viewId
..fieldId = fieldId
..rowId = rowId;
return DatabaseEventGetChecklistCellData(payload).send();
}
@@ -1,6 +1,4 @@
import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart';
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_service.dart';
import 'package:appflowy/plugins/database_view/application/row/row_service.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option.pb.dart';
import 'package:dartz/dartz.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
@@ -8,12 +6,15 @@ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/cell_entities.pb.dart';
class SelectOptionCellBackendService {
final DatabaseCellContext cellContext;
SelectOptionCellBackendService({required this.cellContext});
final String viewId;
final String fieldId;
final String rowId;
String get viewId => cellContext.viewId;
String get fieldId => cellContext.fieldInfo.id;
RowId get rowId => cellContext.rowId;
SelectOptionCellBackendService({
required this.viewId,
required this.fieldId,
required this.rowId,
});
Future<Either<Unit, FlowyError>> create({
required String name,
@@ -160,7 +160,7 @@ class DatabaseController {
});
}
Future<Either<RowPB, FlowyError>> createRow({
Future<Either<RowMetaPB, FlowyError>> createRow({
RowId? startRowId,
String? groupId,
void Function(RowDataBuilder builder)? withCells,
@@ -181,9 +181,9 @@ class DatabaseController {
}
Future<Either<Unit, FlowyError>> moveGroupRow({
required RowPB fromRow,
required RowMetaPB fromRow,
required String groupId,
RowPB? toRow,
RowMetaPB? toRow,
}) {
return _databaseViewBackendSvc.moveGroupRow(
fromRowId: fromRow.id,
@@ -193,12 +193,12 @@ class DatabaseController {
}
Future<Either<Unit, FlowyError>> moveRow({
required RowPB fromRow,
required RowPB toRow,
required String fromRowId,
required String toRowId,
}) {
return _databaseViewBackendSvc.moveRow(
fromRowId: fromRow.id,
toRowId: toRow.id,
fromRowId: fromRowId,
toRowId: toRowId,
);
}
@@ -269,8 +269,8 @@ class DatabaseController {
onRowsDeleted: (ids) {
_databaseCallbacks?.onRowsDeleted?.call(ids);
},
onRowsUpdated: (ids) {
_databaseCallbacks?.onRowsUpdated?.call(ids);
onRowsUpdated: (ids, reason) {
_databaseCallbacks?.onRowsUpdated?.call(ids, reason);
},
onRowsCreated: (ids) {
_databaseCallbacks?.onRowsCreated?.call(ids);
@@ -30,7 +30,7 @@ class DatabaseViewBackendService {
return DatabaseEventGetDatabase(payload).send();
}
Future<Either<RowPB, FlowyError>> createRow({
Future<Either<RowMetaPB, FlowyError>> createRow({
RowId? startRowId,
String? groupId,
Map<String, String>? cellDataByFieldId,
@@ -13,7 +13,10 @@ typedef OnFiltersChanged = void Function(List<FilterInfo>);
typedef OnDatabaseChanged = void Function(DatabasePB);
typedef OnRowsCreated = void Function(List<RowId> ids);
typedef OnRowsUpdated = void Function(List<RowId> ids);
typedef OnRowsUpdated = void Function(
List<RowId> ids,
RowsChangedReason reason,
);
typedef OnRowsDeleted = void Function(List<RowId> ids);
typedef OnNumOfRowsChanged = void Function(
UnmodifiableListView<RowInfo> rows,
@@ -1,4 +1,3 @@
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@@ -52,14 +51,11 @@ class FieldCellBloc extends Bloc<FieldCellEvent, FieldCellState> {
void _startListening() {
_fieldListener.start(
onFieldChanged: (result) {
onFieldChanged: (updatedField) {
if (isClosed) {
return;
}
result.fold(
(field) => add(FieldCellEvent.didReceiveFieldUpdate(field)),
(err) => Log.error(err),
);
add(FieldCellEvent.didReceiveFieldUpdate(updatedField));
},
);
}
@@ -1,4 +1,5 @@
import 'package:appflowy/core/notification/grid_notification.dart';
import 'package:appflowy_backend/log.dart';
import 'package:dartz/dartz.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/notification.pb.dart';
@@ -7,12 +8,11 @@ import 'dart:async';
import 'dart:typed_data';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
typedef UpdateFieldNotifiedValue = Either<FieldPB, FlowyError>;
typedef UpdateFieldNotifiedValue = FieldPB;
class SingleFieldListener {
final String fieldId;
PublishNotifier<UpdateFieldNotifiedValue>? _updateFieldNotifier =
PublishNotifier();
void Function(UpdateFieldNotifiedValue)? _updateFieldNotifier;
DatabaseNotificationListener? _listener;
SingleFieldListener({required this.fieldId});
@@ -20,7 +20,7 @@ class SingleFieldListener {
void start({
required void Function(UpdateFieldNotifiedValue) onFieldChanged,
}) {
_updateFieldNotifier?.addPublishListener(onFieldChanged);
_updateFieldNotifier = onFieldChanged;
_listener = DatabaseNotificationListener(
objectId: fieldId,
handler: _handler,
@@ -34,9 +34,8 @@ class SingleFieldListener {
switch (ty) {
case DatabaseNotification.DidUpdateField:
result.fold(
(payload) =>
_updateFieldNotifier?.value = left(FieldPB.fromBuffer(payload)),
(error) => _updateFieldNotifier?.value = right(error),
(payload) => _updateFieldNotifier?.call(FieldPB.fromBuffer(payload)),
(error) => Log.error(error),
);
break;
default:
@@ -46,7 +45,6 @@ class SingleFieldListener {
Future<void> stop() async {
await _listener?.stop();
_updateFieldNotifier?.dispose();
_updateFieldNotifier = null;
}
}
@@ -99,6 +99,14 @@ class FieldBackendService {
);
});
}
/// Returns the primary field of the view.
static Future<Either<FieldPB, FlowyError>> getPrimaryField({
required String viewId,
}) {
final payload = DatabaseViewIdPB.create()..value = viewId;
return DatabaseEventGetPrimaryField(payload).send();
}
}
@freezed
@@ -0,0 +1,163 @@
import 'package:appflowy/plugins/database_view/application/field/field_listener.dart';
import 'package:appflowy/plugins/database_view/application/field/field_service.dart';
import 'package:appflowy/plugins/database_view/application/row/row_service.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'row_meta_listener.dart';
part 'row_banner_bloc.freezed.dart';
class RowBannerBloc extends Bloc<RowBannerEvent, RowBannerState> {
final String viewId;
final RowBackendService _rowBackendSvc;
final RowMetaListener _metaListener;
SingleFieldListener? _fieldListener;
RowBannerBloc({
required this.viewId,
required RowMetaPB rowMeta,
}) : _rowBackendSvc = RowBackendService(viewId: viewId),
_metaListener = RowMetaListener(rowMeta.id),
super(RowBannerState.initial(rowMeta)) {
on<RowBannerEvent>(
(event, emit) async {
event.when(
initial: () async {
_loadPrimaryField();
await _listenRowMeteChanged();
},
didReceiveRowMeta: (RowMetaPB rowMeta) {
emit(
state.copyWith(
rowMeta: rowMeta,
),
);
},
setCover: (String coverURL) {
_updateMeta(coverURL: coverURL);
},
setIcon: (String iconURL) {
_updateMeta(iconURL: iconURL);
},
didReceiveFieldUpdate: (updatedField) {
emit(
state.copyWith(
primaryField: updatedField,
loadingState: const LoadingState.finish(),
),
);
},
);
},
);
}
@override
Future<void> close() async {
await _metaListener.stop();
await _fieldListener?.stop();
_fieldListener = null;
return super.close();
}
Future<void> _loadPrimaryField() async {
final fieldOrError =
await FieldBackendService.getPrimaryField(viewId: viewId);
fieldOrError.fold(
(primaryField) {
if (!isClosed) {
_fieldListener = SingleFieldListener(fieldId: primaryField.id);
_fieldListener?.start(
onFieldChanged: (updatedField) {
if (!isClosed) {
add(RowBannerEvent.didReceiveFieldUpdate(updatedField));
}
},
);
add(RowBannerEvent.didReceiveFieldUpdate(primaryField));
}
},
(r) => Log.error(r),
);
}
/// Listen the changes of the row meta and then update the banner
Future<void> _listenRowMeteChanged() async {
_metaListener.start(
callback: (rowMeta) {
add(RowBannerEvent.didReceiveRowMeta(rowMeta));
},
);
}
/// Update the meta of the row and the view
Future<void> _updateMeta({
String? iconURL,
String? coverURL,
}) async {
// Most of the time, the result is success, so we don't need to handle it.
await _rowBackendSvc
.updateMeta(
iconURL: iconURL,
coverURL: coverURL,
rowId: state.rowMeta.id,
)
.then((result) {
result.fold(
(l) => null,
(err) => Log.error(err),
);
});
// Set the icon and cover of the view
ViewBackendService.updateView(
viewId: viewId,
iconURL: iconURL,
coverURL: coverURL,
).then((result) {
result.fold(
(l) => null,
(err) => Log.error(err),
);
});
}
}
@freezed
class RowBannerEvent with _$RowBannerEvent {
const factory RowBannerEvent.initial() = _Initial;
const factory RowBannerEvent.didReceiveRowMeta(RowMetaPB rowMeta) =
_DidReceiveRowMeta;
const factory RowBannerEvent.didReceiveFieldUpdate(FieldPB field) =
_DidReceiveFieldUdate;
const factory RowBannerEvent.setIcon(String iconURL) = _SetIcon;
const factory RowBannerEvent.setCover(String coverURL) = _SetCover;
}
@freezed
class RowBannerState with _$RowBannerState {
const factory RowBannerState({
ViewPB? view,
FieldPB? primaryField,
required RowMetaPB rowMeta,
required LoadingState loadingState,
}) = _RowBannerState;
factory RowBannerState.initial(RowMetaPB rowMetaPB) => RowBannerState(
rowMeta: rowMetaPB,
loadingState: const LoadingState.loading(),
);
}
@freezed
class LoadingState with _$LoadingState {
const factory LoadingState.loading() = _Loading;
const factory LoadingState.finish() = _Finish;
}
@@ -38,17 +38,21 @@ class RowCache {
final RowCacheDelegate _delegate;
final RowChangesetNotifier _rowChangeReasonNotifier;
/// Returns a unmodifiable list of RowInfo
UnmodifiableListView<RowInfo> get rowInfos {
final visibleRows = [..._rowList.rows];
return UnmodifiableListView(visibleRows);
}
/// Returns a unmodifiable map of rowId to RowInfo
UnmodifiableMapView<RowId, RowInfo> get rowByRowId {
return UnmodifiableMapView(_rowList.rowInfoByRowId);
}
CellCache get cellCache => _cellCache;
RowsChangedReason get changeReason => _rowChangeReasonNotifier.reason;
RowCache({
required this.viewId,
required RowFieldsDelegate fieldsDelegate,
@@ -70,7 +74,7 @@ class RowCache {
return _rowList.get(rowId);
}
void setInitialRows(List<RowPB> rows) {
void setInitialRows(List<RowMetaPB> rows) {
for (final row in rows) {
final rowInfo = buildGridRow(row);
_rowList.add(rowInfo);
@@ -128,7 +132,7 @@ class RowCache {
void _insertRows(List<InsertedRowPB> insertRows) {
for (final insertedRow in insertRows) {
final insertedIndex =
_rowList.insert(insertedRow.index, buildGridRow(insertedRow.row));
_rowList.insert(insertedRow.index, buildGridRow(insertedRow.rowMeta));
if (insertedIndex != null) {
_rowChangeReasonNotifier
.receive(RowsChangedReason.insert(insertedIndex));
@@ -138,20 +142,23 @@ class RowCache {
void _updateRows(List<UpdatedRowPB> updatedRows) {
if (updatedRows.isEmpty) return;
final List<RowPB> rowPBs = [];
final List<RowMetaPB> updatedList = [];
for (final updatedRow in updatedRows) {
for (final fieldId in updatedRow.fieldIds) {
final key = CellCacheKey(
fieldId: fieldId,
rowId: updatedRow.row.id,
rowId: updatedRow.rowId,
);
_cellCache.remove(key);
}
rowPBs.add(updatedRow.row);
if (updatedRow.hasRowMeta()) {
updatedList.add(updatedRow.rowMeta);
}
}
final updatedIndexs =
_rowList.updateRows(rowPBs, (rowPB) => buildGridRow(rowPB));
_rowList.updateRows(updatedList, (rowId) => buildGridRow(rowId));
if (updatedIndexs.isNotEmpty) {
_rowChangeReasonNotifier.receive(RowsChangedReason.update(updatedIndexs));
}
@@ -169,7 +176,7 @@ class RowCache {
void _showRows(List<InsertedRowPB> visibleRows) {
for (final insertedRow in visibleRows) {
final insertedIndex =
_rowList.insert(insertedRow.index, buildGridRow(insertedRow.row));
_rowList.insert(insertedRow.index, buildGridRow(insertedRow.rowMeta));
if (insertedIndex != null) {
_rowChangeReasonNotifier
.receive(RowsChangedReason.insert(insertedIndex));
@@ -197,8 +204,9 @@ class RowCache {
if (onCellUpdated != null) {
final rowInfo = _rowList.get(rowId);
if (rowInfo != null) {
final CellContextByFieldId cellDataMap =
_makeGridCells(rowId, rowInfo.rowPB);
final CellContextByFieldId cellDataMap = _makeGridCells(
rowInfo.rowMeta,
);
onCellUpdated(cellDataMap, _rowChangeReasonNotifier.reason);
}
}
@@ -220,12 +228,12 @@ class RowCache {
_rowChangeReasonNotifier.removeListener(callback);
}
CellContextByFieldId loadGridCells(RowId rowId) {
final RowPB? data = _rowList.get(rowId)?.rowPB;
if (data == null) {
_loadRow(rowId);
CellContextByFieldId loadGridCells(RowMetaPB rowMeta) {
final rowInfo = _rowList.get(rowMeta.id);
if (rowInfo == null) {
_loadRow(rowMeta.id);
}
return _makeGridCells(rowId, data);
return _makeGridCells(rowMeta);
}
Future<void> _loadRow(RowId rowId) async {
@@ -233,57 +241,51 @@ class RowCache {
..viewId = viewId
..rowId = rowId;
final result = await DatabaseEventGetRow(payload).send();
final result = await DatabaseEventGetRowMeta(payload).send();
result.fold(
(optionRow) => _refreshRow(optionRow),
(rowMetaPB) {
final rowInfo = _rowList.get(rowMetaPB.id);
final rowIndex = _rowList.indexOfRow(rowMetaPB.id);
if (rowInfo != null && rowIndex != null) {
final updatedRowInfo = rowInfo.copyWith(rowMeta: rowMetaPB);
_rowList.remove(rowMetaPB.id);
_rowList.insert(rowIndex, updatedRowInfo);
final UpdatedIndexMap updatedIndexs = UpdatedIndexMap();
updatedIndexs[rowMetaPB.id] = UpdatedIndex(
index: rowIndex,
rowId: rowMetaPB.id,
);
_rowChangeReasonNotifier
.receive(RowsChangedReason.update(updatedIndexs));
}
},
(err) => Log.error(err),
);
}
CellContextByFieldId _makeGridCells(RowId rowId, RowPB? row) {
CellContextByFieldId _makeGridCells(RowMetaPB rowMeta) {
// ignore: prefer_collection_literals
final cellDataMap = CellContextByFieldId();
final cellContextMap = CellContextByFieldId();
for (final field in _delegate.fields) {
if (field.visibility) {
cellDataMap[field.id] = DatabaseCellContext(
rowId: rowId,
cellContextMap[field.id] = DatabaseCellContext(
rowMeta: rowMeta,
viewId: viewId,
fieldInfo: field,
);
}
}
return cellDataMap;
return cellContextMap;
}
void _refreshRow(OptionalRowPB optionRow) {
if (!optionRow.hasRow()) {
return;
}
final updatedRow = optionRow.row;
updatedRow.freeze();
final rowInfo = _rowList.get(updatedRow.id);
final rowIndex = _rowList.indexOfRow(updatedRow.id);
if (rowInfo != null && rowIndex != null) {
final updatedRowInfo = rowInfo.copyWith(rowPB: updatedRow);
_rowList.remove(updatedRow.id);
_rowList.insert(rowIndex, updatedRowInfo);
final UpdatedIndexMap updatedIndexs = UpdatedIndexMap();
updatedIndexs[rowInfo.rowPB.id] = UpdatedIndex(
index: rowIndex,
rowId: updatedRowInfo.rowPB.id,
);
_rowChangeReasonNotifier.receive(RowsChangedReason.update(updatedIndexs));
}
}
RowInfo buildGridRow(RowPB rowPB) {
RowInfo buildGridRow(RowMetaPB rowMetaPB) {
return RowInfo(
viewId: viewId,
fields: _delegate.fields,
rowPB: rowPB,
rowId: rowMetaPB.id,
rowMeta: rowMetaPB,
);
}
}
@@ -310,9 +312,10 @@ class RowChangesetNotifier extends ChangeNotifier {
@unfreezed
class RowInfo with _$RowInfo {
factory RowInfo({
required String rowId,
required String viewId,
required UnmodifiableListView<FieldInfo> fields,
required RowPB rowPB,
required RowMetaPB rowMeta,
}) = _RowInfo;
}
@@ -1,12 +1,12 @@
import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart';
import 'package:flutter/material.dart';
import '../cell/cell_service.dart';
import 'row_cache.dart';
import 'row_service.dart';
typedef OnRowChanged = void Function(CellContextByFieldId, RowsChangedReason);
class RowController {
final RowId rowId;
final RowMetaPB rowMeta;
final String? groupId;
final String viewId;
final List<VoidCallback> _onRowChangedListeners = [];
@@ -14,24 +14,27 @@ class RowController {
get cellCache => _rowCache.cellCache;
get rowId => rowMeta.id;
RowController({
required this.rowId,
required this.rowMeta,
required this.viewId,
required RowCache rowCache,
this.groupId,
}) : _rowCache = rowCache;
CellContextByFieldId loadData() {
return _rowCache.loadGridCells(rowId);
return _rowCache.loadGridCells(rowMeta);
}
void addListener({OnRowChanged? onRowChanged}) {
_onRowChangedListeners.add(
_rowCache.addListener(
rowId: rowId,
onCellUpdated: onRowChanged,
),
final fn = _rowCache.addListener(
rowId: rowMeta.id,
onCellUpdated: onRowChanged,
);
// Add the listener to the list so that we can remove it later.
_onRowChangedListeners.add(fn);
}
void dispose() {
@@ -25,10 +25,9 @@ class RowList {
}
void add(RowInfo rowInfo) {
final rowId = rowInfo.rowPB.id;
final rowId = rowInfo.rowId;
if (contains(rowId)) {
final index =
_rowInfos.indexWhere((element) => element.rowPB.id == rowId);
final index = _rowInfos.indexWhere((element) => element.rowId == rowId);
_rowInfos.removeAt(index);
_rowInfos.insert(index, rowInfo);
} else {
@@ -38,7 +37,7 @@ class RowList {
}
InsertedIndex? insert(int index, RowInfo rowInfo) {
final rowId = rowInfo.rowPB.id;
final rowId = rowInfo.rowId;
var insertedIndex = index;
if (_rowInfos.length <= insertedIndex) {
insertedIndex = _rowInfos.length;
@@ -62,7 +61,7 @@ class RowList {
if (rowInfo != null) {
final index = _rowInfos.indexOf(rowInfo);
if (index != -1) {
rowInfoByRowId.remove(rowInfo.rowPB.id);
rowInfoByRowId.remove(rowInfo.rowId);
_rowInfos.remove(rowInfo);
}
return DeletedIndex(index: index, rowInfo: rowInfo);
@@ -73,23 +72,23 @@ class RowList {
InsertedIndexs insertRows(
List<InsertedRowPB> insertedRows,
RowInfo Function(RowPB) builder,
RowInfo Function(RowMetaPB) builder,
) {
final InsertedIndexs insertIndexs = [];
for (final insertRow in insertedRows) {
final isContains = contains(insertRow.row.id);
final isContains = contains(insertRow.rowMeta.id);
var index = insertRow.index;
if (_rowInfos.length < index) {
index = _rowInfos.length;
}
insert(index, builder(insertRow.row));
insert(index, builder(insertRow.rowMeta));
if (!isContains) {
insertIndexs.add(
InsertedIndex(
index: index,
rowId: insertRow.row.id,
rowId: insertRow.rowMeta.id,
),
);
}
@@ -105,10 +104,10 @@ class RowList {
};
_rowInfos.asMap().forEach((index, RowInfo rowInfo) {
if (deletedRowByRowId[rowInfo.rowPB.id] == null) {
if (deletedRowByRowId[rowInfo.rowId] == null) {
newRows.add(rowInfo);
} else {
rowInfoByRowId.remove(rowInfo.rowPB.id);
rowInfoByRowId.remove(rowInfo.rowId);
deletedIndex.add(DeletedIndex(index: index, rowInfo: rowInfo));
}
});
@@ -117,19 +116,21 @@ class RowList {
}
UpdatedIndexMap updateRows(
List<RowPB> updatedRows,
RowInfo Function(RowPB) builder,
List<RowMetaPB> rowMetas,
RowInfo Function(RowMetaPB) builder,
) {
final UpdatedIndexMap updatedIndexs = UpdatedIndexMap();
for (final RowPB updatedRow in updatedRows) {
final rowId = updatedRow.id;
for (final rowMeta in rowMetas) {
final index = _rowInfos.indexWhere(
(rowInfo) => rowInfo.rowPB.id == rowId,
(rowInfo) => rowInfo.rowId == rowMeta.id,
);
if (index != -1) {
final rowInfo = builder(updatedRow);
final rowInfo = builder(rowMeta);
insert(index, rowInfo);
updatedIndexs[rowId] = UpdatedIndex(index: index, rowId: rowId);
updatedIndexs[rowMeta.id] = UpdatedIndex(
index: index,
rowId: rowMeta.id,
);
}
}
return updatedIndexs;
@@ -148,7 +149,7 @@ class RowList {
void moveRow(RowId rowId, int oldIndex, int newIndex) {
final index = _rowInfos.indexWhere(
(rowInfo) => rowInfo.rowPB.id == rowId,
(rowInfo) => rowInfo.rowId == rowId,
);
if (index != -1) {
final rowInfo = remove(rowId)!.rowInfo;
@@ -0,0 +1,49 @@
import 'dart:typed_data';
import 'package:appflowy/core/notification/grid_notification.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:dartz/dartz.dart';
typedef RowMetaCallback = void Function(RowMetaPB);
class RowMetaListener {
final String rowId;
RowMetaCallback? _callback;
DatabaseNotificationListener? _listener;
RowMetaListener(this.rowId);
void start({required RowMetaCallback callback}) {
_callback = callback;
_listener = DatabaseNotificationListener(
objectId: rowId,
handler: _handler,
);
}
void _handler(
DatabaseNotification ty,
Either<Uint8List, FlowyError> result,
) {
switch (ty) {
case DatabaseNotification.DidUpdateRowMeta:
result.fold(
(payload) {
if (_callback != null) {
_callback!(RowMetaPB.fromBuffer(payload));
}
},
(error) => Log.error(error),
);
break;
default:
break;
}
}
Future<void> stop() async {
await _listener?.stop();
_callback = null;
}
}
@@ -12,7 +12,7 @@ class RowBackendService {
required this.viewId,
});
Future<Either<RowPB, FlowyError>> createRow(RowId rowId) {
Future<Either<RowMetaPB, FlowyError>> createRowAfterRow(RowId rowId) {
final payload = CreateRowPayloadPB.create()
..viewId = viewId
..startRowId = rowId;
@@ -28,6 +28,33 @@ class RowBackendService {
return DatabaseEventGetRow(payload).send();
}
Future<Either<RowMetaPB, FlowyError>> getRowMeta(RowId rowId) {
final payload = RowIdPB.create()
..viewId = viewId
..rowId = rowId;
return DatabaseEventGetRowMeta(payload).send();
}
Future<Either<Unit, FlowyError>> updateMeta({
required String rowId,
String? iconURL,
String? coverURL,
}) {
final payload = UpdateRowMetaChangesetPB.create()
..viewId = viewId
..id = rowId;
if (iconURL != null) {
payload.iconUrl = iconURL;
}
if (coverURL != null) {
payload.coverUrl = coverURL;
}
return DatabaseEventUpdateRowMeta(payload).send();
}
Future<Either<Unit, FlowyError>> deleteRow(RowId rowId) {
final payload = RowIdPB.create()
..viewId = viewId
@@ -65,14 +65,16 @@ class DatabaseViewCache {
}
if (changeset.updatedRows.isNotEmpty) {
_callbacks?.onRowsUpdated
?.call(changeset.updatedRows.map((e) => e.row.id).toList());
_callbacks?.onRowsUpdated?.call(
changeset.updatedRows.map((e) => e.rowId).toList(),
_rowCache.changeReason,
);
}
if (changeset.insertedRows.isNotEmpty) {
_callbacks?.onRowsCreated?.call(
changeset.insertedRows
.map((insertedRow) => insertedRow.row.id)
.map((insertedRow) => insertedRow.rowMeta.id)
.toList(),
);
}
@@ -156,7 +156,7 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
);
}
void _groupItemStartEditing(GroupPB group, RowPB row, bool isEdit) {
void _groupItemStartEditing(GroupPB group, RowMetaPB row, bool isEdit) {
final fieldInfo = fieldController.getField(group.fieldId);
if (fieldInfo == null) {
Log.warn("fieldInfo should not be null");
@@ -302,12 +302,12 @@ class BoardEvent with _$BoardEvent {
const factory BoardEvent.createHeaderRow(String groupId) = _CreateHeaderRow;
const factory BoardEvent.didCreateRow(
GroupPB group,
RowPB row,
RowMetaPB row,
int? index,
) = _DidCreateRow;
const factory BoardEvent.startEditingRow(
GroupPB group,
RowPB row,
RowMetaPB row,
) = _StartEditRow;
const factory BoardEvent.endEditingRow(RowId rowId) = _EndEditRow;
const factory BoardEvent.didReceiveError(FlowyError error) = _DidReceiveError;
@@ -371,7 +371,7 @@ class GridFieldEquatable extends Equatable {
}
class GroupItem extends AppFlowyGroupItem {
final RowPB row;
final RowMetaPB row;
final FieldInfo fieldInfo;
GroupItem({
@@ -389,7 +389,7 @@ class GroupItem extends AppFlowyGroupItem {
class GroupControllerDelegateImpl extends GroupControllerDelegate {
final FieldController fieldController;
final AppFlowyBoardController controller;
final void Function(String, RowPB, int?) onNewColumnItem;
final void Function(String, RowMetaPB, int?) onNewColumnItem;
GroupControllerDelegateImpl({
required this.controller,
@@ -398,7 +398,7 @@ class GroupControllerDelegateImpl extends GroupControllerDelegate {
});
@override
void insertRow(GroupPB group, RowPB row, int? index) {
void insertRow(GroupPB group, RowMetaPB row, int? index) {
final fieldInfo = fieldController.getField(group.fieldId);
if (fieldInfo == null) {
Log.warn("fieldInfo should not be null");
@@ -426,7 +426,7 @@ class GroupControllerDelegateImpl extends GroupControllerDelegate {
}
@override
void updateRow(GroupPB group, RowPB row) {
void updateRow(GroupPB group, RowMetaPB row) {
final fieldInfo = fieldController.getField(group.fieldId);
if (fieldInfo == null) {
Log.warn("fieldInfo should not be null");
@@ -442,7 +442,7 @@ class GroupControllerDelegateImpl extends GroupControllerDelegate {
}
@override
void addNewRow(GroupPB group, RowPB row, int? index) {
void addNewRow(GroupPB group, RowMetaPB row, int? index) {
final fieldInfo = fieldController.getField(group.fieldId);
if (fieldInfo == null) {
Log.warn("fieldInfo should not be null");
@@ -465,7 +465,7 @@ class GroupControllerDelegateImpl extends GroupControllerDelegate {
class BoardEditingRow {
GroupPB group;
RowPB row;
RowMetaPB row;
int? index;
BoardEditingRow({
@@ -12,9 +12,9 @@ typedef OnGroupError = void Function(FlowyError);
abstract class GroupControllerDelegate {
void removeRow(GroupPB group, RowId rowId);
void insertRow(GroupPB group, RowPB row, int? index);
void updateRow(GroupPB group, RowPB row);
void addNewRow(GroupPB group, RowPB row, int? index);
void insertRow(GroupPB group, RowMetaPB row, int? index);
void updateRow(GroupPB group, RowMetaPB row);
void addNewRow(GroupPB group, RowMetaPB row, int? index);
}
class GroupController {
@@ -28,7 +28,7 @@ class GroupController {
required this.delegate,
}) : _listener = SingleGroupListener(group);
RowPB? rowAtIndex(int index) {
RowMetaPB? rowAtIndex(int index) {
if (index < group.rows.length) {
return group.rows[index];
} else {
@@ -36,7 +36,7 @@ class GroupController {
}
}
RowPB? lastRow() {
RowMetaPB? lastRow() {
if (group.rows.isEmpty) return null;
return group.rows.last;
}
@@ -55,15 +55,15 @@ class GroupController {
final index = insertedRow.hasIndex() ? insertedRow.index : null;
if (insertedRow.hasIndex() &&
group.rows.length > insertedRow.index) {
group.rows.insert(insertedRow.index, insertedRow.row);
group.rows.insert(insertedRow.index, insertedRow.rowMeta);
} else {
group.rows.add(insertedRow.row);
group.rows.add(insertedRow.rowMeta);
}
if (insertedRow.isNew) {
delegate.addNewRow(group, insertedRow.row, index);
delegate.addNewRow(group, insertedRow.rowMeta, index);
} else {
delegate.insertRow(group, insertedRow.row, index);
delegate.insertRow(group, insertedRow.rowMeta, index);
}
}
@@ -45,8 +45,12 @@ class BoardPlugin extends Plugin {
BoardPlugin({
required ViewPB view,
required PluginType pluginType,
bool listenOnViewChanged = false,
}) : _pluginType = pluginType,
notifier = ViewPluginNotifier(view: view);
notifier = ViewPluginNotifier(
view: view,
listenOnViewChanged: listenOnViewChanged,
);
@override
PluginWidgetBuilder get widgetBuilder =>
@@ -231,7 +231,7 @@ class _BoardContentState extends State<BoardContent> {
) {
final groupItem = afGroupItem as GroupItem;
final groupData = afGroupData.customData as GroupData;
final rowPB = groupItem.row;
final rowMeta = groupItem.row;
final rowCache = context.read<BoardBloc>().getRowCache();
/// Return placeholder widget if the rowCache is null.
@@ -255,7 +255,7 @@ class _BoardContentState extends State<BoardContent> {
margin: config.cardPadding,
decoration: _makeBoxDecoration(context),
child: RowCard<String>(
row: rowPB,
rowMeta: rowMeta,
viewId: viewId,
rowCache: rowCache,
cardData: groupData.group.groupId,
@@ -267,7 +267,7 @@ class _BoardContentState extends State<BoardContent> {
viewId,
groupData.group.groupId,
fieldController,
rowPB,
rowMeta,
rowCache,
context,
),
@@ -305,18 +305,19 @@ class _BoardContentState extends State<BoardContent> {
String viewId,
String groupId,
FieldController fieldController,
RowPB rowPB,
RowMetaPB rowMetaPB,
RowCache rowCache,
BuildContext context,
) {
final rowInfo = RowInfo(
viewId: viewId,
fields: UnmodifiableListView(fieldController.fieldInfos),
rowPB: rowPB,
rowMeta: rowMetaPB,
rowId: rowMetaPB.id,
);
final dataController = RowController(
rowId: rowInfo.rowPB.id,
rowMeta: rowInfo.rowMeta,
viewId: rowInfo.viewId,
rowCache: rowCache,
groupId: groupId,
@@ -268,7 +268,7 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
final eventData = CalendarDayEvent(
event: eventPB,
eventId: eventPB.rowId,
eventId: eventPB.rowMeta.id,
dateFieldId: eventPB.dateFieldId,
date: date,
);
@@ -310,7 +310,7 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
}
add(CalendarEvent.didDeleteEvents(rowIds));
},
onRowsUpdated: (rowIds) async {
onRowsUpdated: (rowIds, reason) async {
if (isClosed) {
return;
}
@@ -45,8 +45,12 @@ class CalendarPlugin extends Plugin {
CalendarPlugin({
required ViewPB view,
required PluginType pluginType,
bool listenOnViewChanged = false,
}) : _pluginType = pluginType,
notifier = ViewPluginNotifier(view: view);
notifier = ViewPluginNotifier(
view: view,
listenOnViewChanged: listenOnViewChanged,
);
@override
PluginWidgetBuilder get widgetBuilder =>
@@ -301,7 +301,7 @@ class _EventCard extends StatelessWidget {
// Add the key here to make sure the card is rebuilt when the cells
// in this row are updated.
key: ValueKey(event.eventId),
row: rowInfo!.rowPB,
rowMeta: rowInfo!.rowMeta,
viewId: viewId,
rowCache: rowCache,
cardData: event.dateFieldId,
@@ -243,7 +243,7 @@ void showEventDetails({
required RowCache rowCache,
}) {
final dataController = RowController(
rowId: event.eventId,
rowMeta: event.event.rowMeta,
viewId: viewId,
rowCache: rowCache,
);
@@ -1,61 +0,0 @@
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_listener.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import '../../workspace/presentation/home/home_stack.dart';
/// [DatabaseViewPlugin] is used to build the grid, calendar, and board.
/// It is a wrapper of the [Plugin] class. The underlying [Plugin] is
/// determined by the [ViewPB.pluginType] field.
///
class DatabaseViewPlugin extends Plugin {
final ViewListener _viewListener;
ViewPB _view;
Plugin _innerPlugin;
DatabaseViewPlugin({
required ViewPB view,
}) : _view = view,
_innerPlugin = _makeInnerPlugin(view),
_viewListener = ViewListener(view: view) {
_listenOnLayoutChanged();
}
@override
PluginId get id => _innerPlugin.id;
@override
PluginType get pluginType => _innerPlugin.pluginType;
@override
PluginWidgetBuilder get widgetBuilder => _innerPlugin.widgetBuilder;
@override
void dispose() {
_viewListener.stop();
super.dispose();
}
void _listenOnLayoutChanged() {
_viewListener.start(
onViewUpdated: (result) {
result.fold(
(updatedView) {
if (_view.layout != updatedView.layout) {
_innerPlugin = _makeInnerPlugin(updatedView);
getIt<HomeStackManager>().setPlugin(_innerPlugin);
}
_view = updatedView;
},
(r) => null,
);
},
);
}
}
Plugin _makeInnerPlugin(ViewPB view) {
return makePlugin(pluginType: view.pluginType, data: view);
}
@@ -33,18 +33,18 @@ class GridBloc extends Bloc<GridEvent, GridState> {
final rowService = RowBackendService(
viewId: rowInfo.viewId,
);
await rowService.deleteRow(rowInfo.rowPB.id);
await rowService.deleteRow(rowInfo.rowId);
},
moveRow: (int from, int to) {
final List<RowInfo> rows = [...state.rowInfos];
final fromRow = rows[from].rowPB;
final toRow = rows[to].rowPB;
final fromRow = rows[from].rowId;
final toRow = rows[to].rowId;
rows.insert(to, rows.removeAt(from));
emit(state.copyWith(rowInfos: rows));
databaseController.moveRow(fromRow: fromRow, toRow: toRow);
databaseController.moveRow(fromRowId: fromRow, toRowId: toRow);
},
didReceiveGridUpdate: (grid) {
emit(state.copyWith(grid: Some(grid)));
@@ -56,7 +56,7 @@ class GridBloc extends Bloc<GridEvent, GridState> {
),
);
},
didReceiveRowUpdate: (newRowInfos, reason) {
didLoadRows: (newRowInfos, reason) {
emit(
state.copyWith(
rowInfos: newRowInfos,
@@ -76,7 +76,7 @@ class GridBloc extends Bloc<GridEvent, GridState> {
return super.close();
}
RowCache? getRowCache(RowId rowId) {
RowCache getRowCache(RowId rowId) {
return databaseController.rowCache;
}
@@ -89,9 +89,14 @@ class GridBloc extends Bloc<GridEvent, GridState> {
},
onNumOfRowsChanged: (rowInfos, _, reason) {
if (!isClosed) {
add(GridEvent.didReceiveRowUpdate(rowInfos, reason));
add(GridEvent.didLoadRows(rowInfos, reason));
}
},
onRowsUpdated: (rows, reason) {
add(
GridEvent.didLoadRows(databaseController.rowCache.rowInfos, reason),
);
},
onFieldsChanged: (fields) {
if (!isClosed) {
add(GridEvent.didReceiveFieldUpdate(fields));
@@ -122,9 +127,9 @@ class GridEvent with _$GridEvent {
const factory GridEvent.createRow() = _CreateRow;
const factory GridEvent.deleteRow(RowInfo rowInfo) = _DeleteRow;
const factory GridEvent.moveRow(int from, int to) = _MoveRow;
const factory GridEvent.didReceiveRowUpdate(
const factory GridEvent.didLoadRows(
List<RowInfo> rows,
RowsChangedReason listState,
RowsChangedReason reason,
) = _DidReceiveRowUpdate;
const factory GridEvent.didReceiveFieldUpdate(
List<FieldInfo> fields,
@@ -4,7 +4,6 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:dartz/dartz.dart';
import '../../../application/row/row_cache.dart';
import '../../../application/row/row_service.dart';
part 'row_action_sheet_bloc.freezed.dart';
@@ -13,19 +12,20 @@ class RowActionSheetBloc
extends Bloc<RowActionSheetEvent, RowActionSheetState> {
final RowBackendService _rowService;
RowActionSheetBloc({required RowInfo rowInfo})
: _rowService = RowBackendService(viewId: rowInfo.viewId),
super(RowActionSheetState.initial(rowInfo)) {
RowActionSheetBloc({
required String viewId,
required RowId rowId,
}) : _rowService = RowBackendService(viewId: viewId),
super(RowActionSheetState.initial(rowId)) {
on<RowActionSheetEvent>(
(event, emit) async {
await event.when(
deleteRow: () async {
final result = await _rowService.deleteRow(state.rowData.rowPB.id);
final result = await _rowService.deleteRow(state.rowId);
logResult(result);
},
duplicateRow: () async {
final result =
await _rowService.duplicateRow(rowId: state.rowData.rowPB.id);
final result = await _rowService.duplicateRow(rowId: state.rowId);
logResult(result);
},
);
@@ -47,10 +47,10 @@ class RowActionSheetEvent with _$RowActionSheetEvent {
@freezed
class RowActionSheetState with _$RowActionSheetState {
const factory RowActionSheetState({
required RowInfo rowData,
required RowId rowId,
}) = _RowActionSheetState;
factory RowActionSheetState.initial(RowInfo rowData) => RowActionSheetState(
rowData: rowData,
factory RowActionSheetState.initial(RowId rowId) => RowActionSheetState(
rowId: rowId,
);
}
@@ -15,13 +15,16 @@ part 'row_bloc.freezed.dart';
class RowBloc extends Bloc<RowEvent, RowState> {
final RowBackendService _rowBackendSvc;
final RowController _dataController;
final String viewId;
final String rowId;
RowBloc({
required RowInfo rowInfo,
required this.rowId,
required this.viewId,
required RowController dataController,
}) : _rowBackendSvc = RowBackendService(viewId: rowInfo.viewId),
}) : _rowBackendSvc = RowBackendService(viewId: viewId),
_dataController = dataController,
super(RowState.initial(rowInfo, dataController.loadData())) {
super(RowState.initial(dataController.loadData())) {
on<RowEvent>(
(event, emit) async {
await event.when(
@@ -29,7 +32,7 @@ class RowBloc extends Bloc<RowEvent, RowState> {
await _startListening();
},
createRow: () {
_rowBackendSvc.createRow(rowInfo.rowPB.id);
_rowBackendSvc.createRowAfterRow(rowId);
},
didReceiveCells: (cellByFieldId, reason) async {
final cells = cellByFieldId.values
@@ -78,18 +81,15 @@ class RowEvent with _$RowEvent {
@freezed
class RowState with _$RowState {
const factory RowState({
required RowInfo rowInfo,
required CellContextByFieldId cellByFieldId,
required UnmodifiableListView<GridCellEquatable> cells,
RowsChangedReason? changeReason,
}) = _RowState;
factory RowState.initial(
RowInfo rowInfo,
CellContextByFieldId cellByFieldId,
) =>
RowState(
rowInfo: rowInfo,
cellByFieldId: cellByFieldId,
cells: UnmodifiableListView(
cellByFieldId.values
@@ -27,7 +27,7 @@ class RowDetailBloc extends Bloc<RowDetailEvent, RowDetailState> {
}
},
didReceiveCellDatas: (cells) {
emit(state.copyWith(gridCells: cells));
emit(state.copyWith(cells: cells));
},
deleteField: (fieldId) {
_fieldBackendService(fieldId).deleteField();
@@ -95,10 +95,10 @@ class RowDetailEvent with _$RowDetailEvent {
@freezed
class RowDetailState with _$RowDetailState {
const factory RowDetailState({
required List<DatabaseCellContext> gridCells,
required List<DatabaseCellContext> cells,
}) = _RowDetailState;
factory RowDetailState.initial() => RowDetailState(
gridCells: List.empty(),
cells: List.empty(),
);
}
@@ -0,0 +1,126 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import '../../../application/row/row_service.dart';
part 'row_document_bloc.freezed.dart';
class RowDocumentBloc extends Bloc<RowDocumentEvent, RowDocumentState> {
final String rowId;
final RowBackendService _rowBackendSvc;
RowDocumentBloc({
required this.rowId,
required String viewId,
}) : _rowBackendSvc = RowBackendService(viewId: viewId),
super(RowDocumentState.initial()) {
on<RowDocumentEvent>(
(event, emit) async {
await event.when(
initial: () async {
_getRowDocumentView();
},
didReceiveRowDocument: (view) {
emit(
state.copyWith(
viewPB: view,
loadingState: const LoadingState.finish(),
),
);
},
didReceiveError: (FlowyError error) {
emit(
state.copyWith(
loadingState: LoadingState.error(error),
),
);
},
);
},
);
}
Future<void> _getRowDocumentView() async {
final rowDetailOrError = await _rowBackendSvc.getRowMeta(rowId);
rowDetailOrError.fold(
(RowMetaPB rowMeta) async {
final viewsOrError =
await ViewBackendService.getView(rowMeta.documentId);
if (isClosed) {
return;
}
viewsOrError.fold(
(view) => add(RowDocumentEvent.didReceiveRowDocument(view)),
(error) async {
if (error.code == ErrorCode.RecordNotFound.value) {
// By default, the document of the row is not exist. So creating a
// new document for the given document id of the row.
final documentView =
await _createRowDocumentView(rowMeta.documentId);
if (documentView != null) {
add(RowDocumentEvent.didReceiveRowDocument(documentView));
}
} else {
add(RowDocumentEvent.didReceiveError(error));
}
},
);
},
(err) => Log.error('Failed to get row detail: $err'),
);
}
Future<ViewPB?> _createRowDocumentView(String viewId) async {
final result = await ViewBackendService.createOrphanView(
viewId: viewId,
name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
desc: '',
layoutType: ViewLayoutPB.Document,
);
return result.fold(
(view) => view,
(error) {
Log.error(error);
return null;
},
);
}
}
@freezed
class RowDocumentEvent with _$RowDocumentEvent {
const factory RowDocumentEvent.initial() = _InitialRow;
const factory RowDocumentEvent.didReceiveRowDocument(ViewPB view) =
_DidReceiveRowDocument;
const factory RowDocumentEvent.didReceiveError(FlowyError error) =
_DidReceiveError;
}
@freezed
class RowDocumentState with _$RowDocumentState {
const factory RowDocumentState({
ViewPB? viewPB,
required LoadingState loadingState,
}) = _RowDocumentState;
factory RowDocumentState.initial() => const RowDocumentState(
loadingState: LoadingState.loading(),
);
}
@freezed
class LoadingState with _$LoadingState {
const factory LoadingState.loading() = _Loading;
const factory LoadingState.error(FlowyError error) = _Error;
const factory LoadingState.finish() = _Finish;
}
@@ -45,8 +45,12 @@ class GridPlugin extends Plugin {
GridPlugin({
required ViewPB view,
required PluginType pluginType,
bool listenOnViewChanged = false,
}) : _pluginType = pluginType,
notifier = ViewPluginNotifier(view: view);
notifier = ViewPluginNotifier(
view: view,
listenOnViewChanged: listenOnViewChanged,
);
@override
PluginWidgetBuilder get widgetBuilder =>
@@ -1,5 +1,7 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/application/row/row_service.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cell_builder.dart';
import 'package:appflowy_backend/log.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui_web.dart';
import 'package:flowy_infra_ui/style_widget/scrolling/styled_scroll_bar.dart';
@@ -78,7 +80,11 @@ class _GridPageState extends State<GridPage> {
loading: (_) =>
const Center(child: CircularProgressIndicator.adaptive()),
finish: (result) => result.successOrFail.fold(
(_) => const GridShortcuts(child: FlowyGrid()),
(_) => GridShortcuts(
child: FlowyGrid(
viewId: widget.view.id,
),
),
(err) => FlowyErrorPage(err.toString()),
),
);
@@ -89,7 +95,9 @@ class _GridPageState extends State<GridPage> {
}
class FlowyGrid extends StatefulWidget {
final String viewId;
const FlowyGrid({
required this.viewId,
super.key,
});
@@ -125,6 +133,7 @@ class _FlowyGridState extends State<FlowyGrid> {
scrollController: _scrollController,
contentWidth: contentWidth,
child: _GridRows(
viewId: widget.viewId,
verticalScrollController: _scrollController.verticalController,
),
);
@@ -155,7 +164,9 @@ class _FlowyGridState extends State<FlowyGrid> {
}
class _GridRows extends StatelessWidget {
final String viewId;
const _GridRows({
required this.viewId,
required this.verticalScrollController,
});
@@ -207,7 +218,7 @@ class _GridRows extends StatelessWidget {
final rowInfo = rowInfos[index];
return _renderRow(
context,
rowInfo,
rowInfo.rowId,
index: index,
isSortEnabled: sortState.sortInfos.isNotEmpty,
isFilterEnabled: filterState.filters.isNotEmpty,
@@ -223,38 +234,38 @@ class _GridRows extends StatelessWidget {
Widget _renderRow(
BuildContext context,
RowInfo rowInfo, {
RowId rowId, {
int? index,
bool isSortEnabled = false,
bool isFilterEnabled = false,
Animation<double>? animation,
}) {
final rowCache = context.read<GridBloc>().getRowCache(
rowInfo.rowPB.id,
);
final rowCache = context.read<GridBloc>().getRowCache(rowId);
final rowMeta = rowCache.getRow(rowId)?.rowMeta;
/// Return placeholder widget if the rowCache is null.
if (rowCache == null) return const SizedBox.shrink();
/// Return placeholder widget if the rowMeta is null.
if (rowMeta == null) return const SizedBox.shrink();
final fieldController =
context.read<GridBloc>().databaseController.fieldController;
final dataController = RowController(
rowId: rowInfo.rowPB.id,
viewId: rowInfo.viewId,
viewId: viewId,
rowMeta: rowMeta,
rowCache: rowCache,
);
final child = GridRow(
key: ValueKey(rowInfo.rowPB.id),
key: ValueKey(rowMeta.id),
rowId: rowId,
viewId: viewId,
index: index,
isDraggable: !isSortEnabled && !isFilterEnabled,
rowInfo: rowInfo,
dataController: dataController,
cellBuilder: GridCellBuilder(cellCache: dataController.cellCache),
openDetailPage: (context, cellBuilder) {
_openRowDetailPage(
context,
rowInfo,
rowId,
fieldController,
rowCache,
cellBuilder,
@@ -274,26 +285,32 @@ class _GridRows extends StatelessWidget {
void _openRowDetailPage(
BuildContext context,
RowInfo rowInfo,
RowId rowId,
FieldController fieldController,
RowCache rowCache,
GridCellBuilder cellBuilder,
) {
final dataController = RowController(
viewId: rowInfo.viewId,
rowId: rowInfo.rowPB.id,
rowCache: rowCache,
);
final rowMeta = rowCache.getRow(rowId)?.rowMeta;
// Most of the cases, the rowMeta should not be null.
if (rowMeta != null) {
final dataController = RowController(
viewId: viewId,
rowMeta: rowMeta,
rowCache: rowCache,
);
FlowyOverlay.show(
context: context,
builder: (BuildContext context) {
return RowDetailPage(
cellBuilder: cellBuilder,
rowController: dataController,
);
},
);
FlowyOverlay.show(
context: context,
builder: (BuildContext context) {
return RowDetailPage(
cellBuilder: cellBuilder,
rowController: dataController,
);
},
);
} else {
Log.warn('RowMeta is null for rowId: $rowId');
}
}
}
@@ -357,10 +374,9 @@ class _RowCountBadge extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.start,
children: [
FlowyText.medium(
'${LocaleKeys.grid_row_count.tr()} : ',
rowCountString(rowCount),
color: Theme.of(context).hintColor,
),
FlowyText.medium(rowCount.toString()),
],
),
);
@@ -368,3 +384,7 @@ class _RowCountBadge extends StatelessWidget {
);
}
}
String rowCountString(int count) {
return '${LocaleKeys.grid_row_count.tr()} : $count';
}
@@ -52,11 +52,11 @@ class _FieldEditorState extends State<FieldEditor> {
@override
Widget build(BuildContext context) {
final List<Widget> children = [
_FieldNameTextField(popoverMutex: popoverMutex),
FieldNameTextField(popoverMutex: popoverMutex),
if (widget.onDeleted != null) _addDeleteFieldButton(),
if (widget.onHidden != null) _addHideFieldButton(),
if (!widget.typeOptionLoader.field.isPrimary)
_FieldTypeOptionCell(popoverMutex: popoverMutex),
FieldTypeOptionCell(popoverMutex: popoverMutex),
];
return BlocProvider(
create: (context) {
@@ -116,10 +116,10 @@ class _FieldEditorState extends State<FieldEditor> {
}
}
class _FieldTypeOptionCell extends StatelessWidget {
class FieldTypeOptionCell extends StatelessWidget {
final PopoverMutex popoverMutex;
const _FieldTypeOptionCell({
const FieldTypeOptionCell({
Key? key,
required this.popoverMutex,
}) : super(key: key);
@@ -130,7 +130,7 @@ class _FieldTypeOptionCell extends StatelessWidget {
buildWhen: (p, c) => p.field != c.field,
builder: (context, state) {
return state.field.fold(
() => const SizedBox(),
() => const SizedBox.shrink(),
(fieldInfo) {
final dataController =
context.read<FieldEditorBloc>().dataController;
@@ -145,18 +145,18 @@ class _FieldTypeOptionCell extends StatelessWidget {
}
}
class _FieldNameTextField extends StatefulWidget {
class FieldNameTextField extends StatefulWidget {
final PopoverMutex popoverMutex;
const _FieldNameTextField({
const FieldNameTextField({
required this.popoverMutex,
Key? key,
}) : super(key: key);
@override
State<_FieldNameTextField> createState() => _FieldNameTextFieldState();
State<FieldNameTextField> createState() => _FieldNameTextFieldState();
}
class _FieldNameTextFieldState extends State<_FieldNameTextField> {
class _FieldNameTextFieldState extends State<FieldNameTextField> {
final textController = TextEditingController();
FocusNode focusNode = FocusNode();
@@ -48,7 +48,7 @@ class FieldTypeOptionEditor extends StatelessWidget {
);
final List<Widget> children = [
_SwitchFieldButton(popoverMutex: popoverMutex),
SwitchFieldButton(popoverMutex: popoverMutex),
if (typeOptionWidget != null) typeOptionWidget
];
@@ -73,9 +73,9 @@ class FieldTypeOptionEditor extends StatelessWidget {
}
}
class _SwitchFieldButton extends StatelessWidget {
class SwitchFieldButton extends StatelessWidget {
final PopoverMutex popoverMutex;
const _SwitchFieldButton({
const SwitchFieldButton({
required this.popoverMutex,
Key? key,
}) : super(key: key);
@@ -1,4 +1,4 @@
import 'package:appflowy/plugins/database_view/application/row/row_cache.dart';
import 'package:appflowy/plugins/database_view/application/row/row_service.dart';
import 'package:appflowy/plugins/database_view/grid/application/row/row_action_sheet_bloc.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
@@ -14,13 +14,18 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../layout/sizes.dart';
class RowActions extends StatelessWidget {
final RowInfo rowData;
const RowActions({required this.rowData, Key? key}) : super(key: key);
final String viewId;
final RowId rowId;
const RowActions({
required this.viewId,
required this.rowId,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => RowActionSheetBloc(rowInfo: rowData),
create: (context) => RowActionSheetBloc(viewId: viewId, rowId: rowId),
child: BlocBuilder<RowActionSheetBloc, RowActionSheetState>(
builder: (context, state) {
final cells = _RowAction.values
@@ -1,6 +1,6 @@
import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart';
import 'package:appflowy/plugins/database_view/application/row/row_cache.dart';
import 'package:appflowy/plugins/database_view/application/row/row_data_controller.dart';
import 'package:appflowy/plugins/database_view/application/row/row_service.dart';
import 'package:appflowy/plugins/database_view/grid/application/row/row_bloc.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cell_builder.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
@@ -20,7 +20,8 @@ import "package:appflowy/generated/locale_keys.g.dart";
import 'package:easy_localization/easy_localization.dart';
class GridRow extends StatefulWidget {
final RowInfo rowInfo;
final RowId viewId;
final RowId rowId;
final RowController dataController;
final GridCellBuilder cellBuilder;
final void Function(BuildContext, GridCellBuilder) openDetailPage;
@@ -30,7 +31,8 @@ class GridRow extends StatefulWidget {
const GridRow({
super.key,
required this.rowInfo,
required this.viewId,
required this.rowId,
required this.dataController,
required this.cellBuilder,
required this.openDetailPage,
@@ -49,8 +51,9 @@ class _GridRowState extends State<GridRow> {
void initState() {
super.initState();
_rowBloc = RowBloc(
rowInfo: widget.rowInfo,
rowId: widget.rowId,
dataController: widget.dataController,
viewId: widget.viewId,
);
_rowBloc.add(const RowEvent.initial());
}
@@ -61,7 +64,8 @@ class _GridRowState extends State<GridRow> {
value: _rowBloc,
child: _RowEnterRegion(
child: BlocBuilder<RowBloc, RowState>(
buildWhen: (p, c) => p.rowInfo.rowPB.height != c.rowInfo.rowPB.height,
// The row need to rebuild when the cell count changes.
buildWhen: (p, c) => p.cellByFieldId.length != c.cellByFieldId.length,
builder: (context, state) {
final content = Expanded(
child: RowContent(
@@ -126,7 +130,11 @@ class _RowLeadingState extends State<_RowLeading> {
direction: PopoverDirection.rightWithCenterAligned,
margin: const EdgeInsets.all(6),
popupBuilder: (BuildContext popoverContext) {
return RowActions(rowData: context.read<RowBloc>().state.rowInfo);
final bloc = context.read<RowBloc>();
return RowActions(
viewId: bloc.viewId,
rowId: bloc.rowId,
);
},
child: Consumer<RegionStateNotifier>(
builder: (context, state, _) {
@@ -143,11 +151,11 @@ class _RowLeadingState extends State<_RowLeading> {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const _InsertButton(),
const InsertRowButton(),
if (isDraggable) ...[
ReorderableDragStartListener(
index: widget.index!,
child: _MenuButton(
child: RowMenuButton(
isDragEnabled: isDraggable,
openMenu: () {
popoverController.show();
@@ -155,7 +163,7 @@ class _RowLeadingState extends State<_RowLeading> {
),
),
] else ...[
_MenuButton(
RowMenuButton(
openMenu: () {
popoverController.show();
},
@@ -168,8 +176,8 @@ class _RowLeadingState extends State<_RowLeading> {
bool get isDraggable => widget.index != null && widget.isDraggable;
}
class _InsertButton extends StatelessWidget {
const _InsertButton({Key? key}) : super(key: key);
class InsertRowButton extends StatelessWidget {
const InsertRowButton({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
@@ -188,20 +196,21 @@ class _InsertButton extends StatelessWidget {
}
}
class _MenuButton extends StatefulWidget {
class RowMenuButton extends StatefulWidget {
final VoidCallback openMenu;
final bool isDragEnabled;
const _MenuButton({
const RowMenuButton({
required this.openMenu,
this.isDragEnabled = false,
super.key,
});
@override
State<_MenuButton> createState() => _MenuButtonState();
State<RowMenuButton> createState() => _RowMenuButtonState();
}
class _MenuButtonState extends State<_MenuButton> {
class _RowMenuButtonState extends State<RowMenuButton> {
@override
Widget build(BuildContext context) {
return FlowyIconButton(
@@ -17,7 +17,7 @@ import 'container/card_container.dart';
/// Edit a database row with card style widget
class RowCard<CustomCardData> extends StatefulWidget {
final RowPB row;
final RowMetaPB rowMeta;
final String viewId;
final String? groupingFieldId;
@@ -46,7 +46,7 @@ class RowCard<CustomCardData> extends StatefulWidget {
final RowCardStyleConfiguration styleConfiguration;
const RowCard({
required this.row,
required this.rowMeta,
required this.viewId,
this.groupingFieldId,
required this.isEditing,
@@ -81,7 +81,7 @@ class _RowCardState<T> extends State<RowCard<T>> {
viewId: widget.viewId,
groupFieldId: widget.groupingFieldId,
isEditing: widget.isEditing,
row: widget.row,
rowMeta: widget.rowMeta,
rowCache: widget.rowCache,
)..add(const RowCardEvent.initial());
@@ -178,7 +178,8 @@ class _RowCardState<T> extends State<RowCard<T>> {
throw UnimplementedError();
case AccessoryType.more:
return RowActions(
rowData: context.read<CardBloc>().rowInfo(),
viewId: context.read<CardBloc>().viewId,
rowId: context.read<CardBloc>().rowMeta.id,
);
}
}
@@ -12,24 +12,24 @@ import '../../application/row/row_service.dart';
part 'card_bloc.freezed.dart';
class CardBloc extends Bloc<RowCardEvent, RowCardState> {
final RowPB row;
final RowMetaPB rowMeta;
final String? groupFieldId;
final RowBackendService _rowBackendSvc;
final RowCache _rowCache;
VoidCallback? _rowCallback;
final String viewId;
CardBloc({
required this.row,
required this.rowMeta,
required this.groupFieldId,
required String viewId,
required this.viewId,
required RowCache rowCache,
required bool isEditing,
}) : _rowBackendSvc = RowBackendService(viewId: viewId),
_rowCache = rowCache,
super(
RowCardState.initial(
row,
_makeCells(groupFieldId, rowCache.loadGridCells(row.id)),
_makeCells(groupFieldId, rowCache.loadGridCells(rowMeta)),
isEditing,
),
) {
@@ -70,13 +70,14 @@ class CardBloc extends Bloc<RowCardEvent, RowCardState> {
fields: UnmodifiableListView(
state.cells.map((cell) => cell.fieldInfo).toList(),
),
rowPB: state.rowPB,
rowId: rowMeta.id,
rowMeta: rowMeta,
);
}
Future<void> _startListening() async {
_rowCallback = _rowCache.addListener(
rowId: row.id,
rowId: rowMeta.id,
onCellUpdated: (cellMap, reason) {
if (!isClosed) {
final cells = _makeCells(groupFieldId, cellMap);
@@ -118,19 +119,16 @@ class RowCardEvent with _$RowCardEvent {
@freezed
class RowCardState with _$RowCardState {
const factory RowCardState({
required RowPB rowPB,
required List<DatabaseCellContext> cells,
required bool isEditing,
RowsChangedReason? changeReason,
}) = _RowCardState;
factory RowCardState.initial(
RowPB rowPB,
List<DatabaseCellContext> cells,
bool isEditing,
) =>
RowCardState(
rowPB: rowPB,
cells: cells,
isEditing: isEditing,
);
@@ -1,4 +1,3 @@
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_listener.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
@@ -47,26 +46,19 @@ class _DatabaseViewWidgetState extends State<DatabaseViewWidget> {
return ValueListenableBuilder<ViewLayoutPB>(
valueListenable: _layoutTypeChangeNotifier,
builder: (_, __, ___) {
return makePlugin(pluginType: view.pluginType, data: view)
.widgetBuilder
.buildWidget();
return view.plugin().widgetBuilder.buildWidget();
},
);
}
void _listenOnViewUpdated() {
_listener = ViewListener(view: widget.view)
_listener = ViewListener(viewId: widget.view.id)
..start(
onViewUpdated: (result) {
result.fold(
(updatedView) {
if (mounted) {
view = updatedView;
_layoutTypeChangeNotifier.value = view.layout;
}
},
(r) => null,
);
onViewUpdated: (updatedView) {
if (mounted) {
view = updatedView;
_layoutTypeChangeNotifier.value = view.layout;
}
},
);
@@ -14,6 +14,7 @@ import 'cells/select_option_cell/select_option_cell.dart';
import 'cells/text_cell/text_cell.dart';
import 'cells/url_cell/url_cell.dart';
/// Build the cell widget in Grid style.
class GridCellBuilder {
final CellCache cellCache;
GridCellBuilder({
@@ -0,0 +1,7 @@
export 'checkbox_cell/checkbox_cell.dart';
export 'checklist_cell/checklist_cell.dart';
export 'date_cell/date_cell.dart';
export 'number_cell/number_cell.dart';
export 'select_option_cell/select_option_cell.dart';
export 'text_cell/text_cell.dart';
export 'url_cell/url_cell.dart';
@@ -40,8 +40,8 @@ class _CheckboxCellState extends GridCellState<GridCheckboxCell> {
child: BlocBuilder<CheckboxCellBloc, CheckboxCellState>(
builder: (context, state) {
final icon = state.isSelected
? svgWidget('editor/editor_check')
: svgWidget('editor/editor_uncheck');
? const CheckboxCellCheck()
: const CheckboxCellUncheck();
return Align(
alignment: Alignment.centerLeft,
child: Padding(
@@ -82,3 +82,21 @@ class _CheckboxCellState extends GridCellState<GridCheckboxCell> {
}
}
}
class CheckboxCellCheck extends StatelessWidget {
const CheckboxCellCheck({super.key});
@override
Widget build(BuildContext context) {
return svgWidget('editor/editor_check');
}
}
class CheckboxCellUncheck extends StatelessWidget {
const CheckboxCellUncheck({super.key});
@override
Widget build(BuildContext context) {
return svgWidget('editor/editor_uncheck');
}
}
@@ -16,7 +16,9 @@ class ChecklistCardCellBloc
ChecklistCardCellBloc({
required this.cellController,
}) : _checklistCellSvc = ChecklistCellBackendService(
cellContext: cellController.cellContext,
viewId: cellController.viewId,
fieldId: cellController.fieldId,
rowId: cellController.rowId,
),
super(ChecklistCellState.initial(cellController)) {
on<ChecklistCellEvent>(
@@ -19,7 +19,9 @@ class ChecklistCellEditorBloc
ChecklistCellEditorBloc({
required this.cellController,
}) : _checklistCellService = ChecklistCellBackendService(
cellContext: cellController.cellContext,
viewId: cellController.viewId,
fieldId: cellController.fieldId,
rowId: cellController.rowId,
),
super(ChecklistCellEditorState.initial(cellController)) {
on<ChecklistCellEditorEvent>(
@@ -22,7 +22,7 @@ class ChecklistProgressBar extends StatelessWidget {
percent: percent,
padding: EdgeInsets.zero,
progressColor: percent < 1.0
? SelectOptionColorPB.Blue.toColor(context)
? SelectOptionColorPB.Purple.toColor(context)
: SelectOptionColorPB.Green.toColor(context),
backgroundColor: AFThemeExtension.of(context).progressBarBGColor,
barRadius: const Radius.circular(5),
@@ -17,7 +17,9 @@ class SelectOptionCellEditorBloc
SelectOptionCellEditorBloc({
required this.cellController,
}) : _selectOptionService = SelectOptionCellBackendService(
cellContext: cellController.cellContext,
viewId: cellController.viewId,
fieldId: cellController.fieldId,
rowId: cellController.rowId,
),
super(SelectOptionEditorState.initial(cellController)) {
on<SelectOptionEditorEvent>(
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/text_cell/text_cell_bloc.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../grid/presentation/layout/sizes.dart';
@@ -10,17 +11,23 @@ class GridTextCellStyle extends GridCellStyle {
String? placeholder;
TextStyle? textStyle;
bool? autofocus;
double emojiFontSize;
double emojiHPadding;
bool showEmoji;
GridTextCellStyle({
this.placeholder,
this.textStyle,
this.autofocus,
this.showEmoji = true,
this.emojiFontSize = 16,
this.emojiHPadding = 0,
});
}
class GridTextCell extends GridCellWidget {
final CellControllerBuilder cellControllerBuilder;
late final GridTextCellStyle? cellStyle;
late final GridTextCellStyle cellStyle;
GridTextCell({
required this.cellControllerBuilder,
GridCellStyle? style,
@@ -29,7 +36,7 @@ class GridTextCell extends GridCellWidget {
if (style != null) {
cellStyle = (style as GridTextCellStyle);
} else {
cellStyle = null;
cellStyle = GridTextCellStyle();
}
}
@@ -66,22 +73,40 @@ class _GridTextCellState extends GridFocusNodeCellState<GridTextCell> {
left: GridSize.cellContentInsets.left,
right: GridSize.cellContentInsets.right,
),
child: TextField(
controller: _controller,
focusNode: focusNode,
maxLines: null,
style: widget.cellStyle?.textStyle ??
Theme.of(context).textTheme.bodyMedium,
autofocus: widget.cellStyle?.autofocus ?? false,
decoration: InputDecoration(
contentPadding: EdgeInsets.only(
top: GridSize.cellContentInsets.top,
bottom: GridSize.cellContentInsets.bottom,
),
border: InputBorder.none,
hintText: widget.cellStyle?.placeholder,
isDense: true,
),
child: Row(
children: [
if (widget.cellStyle.showEmoji)
// Only build the emoji when it changes
BlocBuilder<TextCellBloc, TextCellState>(
buildWhen: (p, c) => p.emoji != c.emoji,
builder: (context, state) => Center(
child: FlowyText(
state.emoji,
fontSize: widget.cellStyle.emojiFontSize,
),
),
),
HSpace(widget.cellStyle.emojiHPadding),
Expanded(
child: TextField(
controller: _controller,
focusNode: focusNode,
maxLines: null,
style: widget.cellStyle.textStyle ??
Theme.of(context).textTheme.bodyMedium,
autofocus: widget.cellStyle.autofocus ?? false,
decoration: InputDecoration(
contentPadding: EdgeInsets.only(
top: GridSize.cellContentInsets.top,
bottom: GridSize.cellContentInsets.bottom,
),
border: InputBorder.none,
hintText: widget.cellStyle.placeholder,
isDense: true,
),
),
)
],
),
),
),
@@ -26,6 +26,9 @@ class TextCellBloc extends Bloc<TextCellEvent, TextCellState> {
didReceiveCellUpdate: (content) {
emit(state.copyWith(content: content));
},
didUpdateEmoji: (String emoji) {
emit(state.copyWith(emoji: emoji));
},
);
},
);
@@ -48,6 +51,11 @@ class TextCellBloc extends Bloc<TextCellEvent, TextCellState> {
add(TextCellEvent.didReceiveCellUpdate(cellContent ?? ""));
}
}),
onRowMetaChanged: () {
if (!isClosed) {
add(TextCellEvent.didUpdateEmoji(cellController.emoji ?? ""));
}
},
);
}
}
@@ -58,15 +66,18 @@ class TextCellEvent with _$TextCellEvent {
const factory TextCellEvent.didReceiveCellUpdate(String cellContent) =
_DidReceiveCellUpdate;
const factory TextCellEvent.updateText(String text) = _UpdateText;
const factory TextCellEvent.didUpdateEmoji(String emoji) = _UpdateEmoji;
}
@freezed
class TextCellState with _$TextCellState {
const factory TextCellState({
required String content,
required String emoji,
}) = _TextCellState;
factory TextCellState.initial(TextCellController context) => TextCellState(
content: context.getCellData() ?? "",
emoji: context.emoji ?? "",
);
}
@@ -0,0 +1,174 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart';
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_service.dart';
import 'package:appflowy/plugins/database_view/application/row/row_data_controller.dart';
import 'package:appflowy/plugins/database_view/grid/application/row/row_detail_bloc.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_editor.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/image.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class RowActionList extends StatelessWidget {
final RowController rowController;
const RowActionList({
required String viewId,
required this.rowController,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(left: 10),
child: FlowyText(LocaleKeys.grid_row_action.tr()),
),
const VSpace(15),
RowDetailPageDeleteButton(rowId: rowController.rowId),
RowDetailPageDuplicateButton(
rowId: rowController.rowId,
groupId: rowController.groupId,
),
],
);
}
}
class RowDetailPageDeleteButton extends StatelessWidget {
final String rowId;
const RowDetailPageDeleteButton({required this.rowId, Key? key})
: super(key: key);
@override
Widget build(BuildContext context) {
return SizedBox(
height: GridSize.popoverItemHeight,
child: FlowyButton(
text: FlowyText.regular(LocaleKeys.grid_row_delete.tr()),
leftIcon: const FlowySvg(name: "home/trash"),
onTap: () {
context.read<RowDetailBloc>().add(RowDetailEvent.deleteRow(rowId));
FlowyOverlay.pop(context);
},
),
);
}
}
class RowDetailPageDuplicateButton extends StatelessWidget {
final String rowId;
final String? groupId;
const RowDetailPageDuplicateButton({
required this.rowId,
this.groupId,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return SizedBox(
height: GridSize.popoverItemHeight,
child: FlowyButton(
text: FlowyText.regular(LocaleKeys.grid_row_duplicate.tr()),
leftIcon: const FlowySvg(name: "grid/duplicate"),
onTap: () {
context
.read<RowDetailBloc>()
.add(RowDetailEvent.duplicateRow(rowId, groupId));
FlowyOverlay.pop(context);
},
),
);
}
}
class CreateRowFieldButton extends StatefulWidget {
final String viewId;
const CreateRowFieldButton({
required this.viewId,
Key? key,
}) : super(key: key);
@override
State<CreateRowFieldButton> createState() => _CreateRowFieldButtonState();
}
class _CreateRowFieldButtonState extends State<CreateRowFieldButton> {
late PopoverController popoverController;
late TypeOptionPB typeOption;
@override
void initState() {
popoverController = PopoverController();
super.initState();
}
@override
Widget build(BuildContext context) {
return AppFlowyPopover(
constraints: BoxConstraints.loose(const Size(240, 200)),
controller: popoverController,
direction: PopoverDirection.topWithLeftAligned,
triggerActions: PopoverTriggerFlags.none,
margin: EdgeInsets.zero,
child: SizedBox(
height: 40,
child: FlowyButton(
text: FlowyText.medium(
LocaleKeys.grid_field_newProperty.tr(),
color: AFThemeExtension.of(context).textColor,
),
hoverColor: AFThemeExtension.of(context).lightGreyHover,
onTap: () async {
final result = await TypeOptionBackendService.createFieldTypeOption(
viewId: widget.viewId,
);
result.fold(
(l) {
typeOption = l;
popoverController.show();
},
(r) => Log.error("Failed to create field type option: $r"),
);
},
leftIcon: svgWidget(
"home/add",
color: AFThemeExtension.of(context).textColor,
),
),
),
popupBuilder: (BuildContext popOverContext) {
return FieldEditor(
viewId: widget.viewId,
typeOptionLoader: FieldTypeOptionLoader(
viewId: widget.viewId,
field: typeOption.field_2,
),
onDeleted: (fieldId) {
popoverController.close();
NavigatorAlertDialog(
title: LocaleKeys.grid_field_deleteFieldPromptMessage.tr(),
confirm: () {
context
.read<RowDetailBloc>()
.add(RowDetailEvent.deleteField(fieldId));
},
).show(context);
},
);
},
);
}
}
@@ -0,0 +1,268 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/application/row/row_banner_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/emoji_picker/emoji_picker.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart';
import 'package:appflowy_popover/appflowy_popover.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';
typedef RowBannerCellBuilder = Widget Function(String fieldId);
class RowBanner extends StatefulWidget {
final String viewId;
final RowMetaPB rowMeta;
final RowBannerCellBuilder cellBuilder;
const RowBanner({
required this.viewId,
required this.rowMeta,
required this.cellBuilder,
super.key,
});
@override
State<RowBanner> createState() => _RowBannerState();
}
class _RowBannerState extends State<RowBanner> {
final _isHovering = ValueNotifier(false);
final popoverController = PopoverController();
@override
Widget build(BuildContext context) {
return BlocProvider<RowBannerBloc>(
create: (context) => RowBannerBloc(
viewId: widget.viewId,
rowMeta: widget.rowMeta,
)..add(const RowBannerEvent.initial()),
child: MouseRegion(
onEnter: (event) => _isHovering.value = true,
onExit: (event) => _isHovering.value = false,
child: SizedBox(
height: 80,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 30,
child: _BannerAction(
isHovering: _isHovering,
popoverController: popoverController,
),
),
_BannerTitle(
cellBuilder: widget.cellBuilder,
popoverController: popoverController,
),
],
),
),
),
);
}
}
class _BannerAction extends StatelessWidget {
final ValueNotifier<bool> isHovering;
final PopoverController popoverController;
const _BannerAction({
required this.isHovering,
required this.popoverController,
});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: isHovering,
builder: (BuildContext context, bool value, Widget? child) {
if (value) {
return BlocBuilder<RowBannerBloc, RowBannerState>(
builder: (context, state) {
final children = <Widget>[];
final rowMeta = state.rowMeta;
if (rowMeta.icon.isEmpty) {
children.add(
EmojiPickerButton(
showEmojiPicker: () => popoverController.show(),
),
);
} else {
children.add(
RemoveEmojiButton(
onRemoved: () {
context
.read<RowBannerBloc>()
.add(const RowBannerEvent.setIcon(''));
},
),
);
}
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
);
},
);
} else {
return const SizedBox(height: _kBannerActionHeight);
}
},
);
}
}
class _BannerTitle extends StatefulWidget {
final RowBannerCellBuilder cellBuilder;
final PopoverController popoverController;
const _BannerTitle({
required this.cellBuilder,
required this.popoverController,
});
@override
State<_BannerTitle> createState() => _BannerTitleState();
}
class _BannerTitleState extends State<_BannerTitle> {
@override
Widget build(BuildContext context) {
return BlocBuilder<RowBannerBloc, RowBannerState>(
builder: (context, state) {
final children = <Widget>[];
if (state.rowMeta.icon.isNotEmpty) {
children.add(
EmojiButton(
emoji: state.rowMeta.icon,
showEmojiPicker: () => widget.popoverController.show(),
),
);
}
if (state.primaryField != null) {
children.add(
Expanded(
child: widget.cellBuilder(state.primaryField!.id),
),
);
}
return AppFlowyPopover(
controller: widget.popoverController,
triggerActions: PopoverTriggerFlags.none,
direction: PopoverDirection.bottomWithLeftAligned,
popupBuilder: (popoverContext) => _buildEmojiPicker((emoji) {
context
.read<RowBannerBloc>()
.add(RowBannerEvent.setIcon(emoji.emoji));
widget.popoverController.close();
}),
child: Row(children: children),
);
},
);
}
}
typedef OnSubmittedEmoji = void Function(Emoji emoji);
const _kBannerActionHeight = 40.0;
class EmojiButton extends StatelessWidget {
final String emoji;
final VoidCallback showEmojiPicker;
const EmojiButton({
required this.emoji,
required this.showEmojiPicker,
super.key,
});
@override
Widget build(BuildContext context) {
return SizedBox(
height: _kBannerActionHeight,
width: _kBannerActionHeight,
child: FlowyButton(
margin: const EdgeInsets.all(4),
text: FlowyText.medium(
emoji,
fontSize: 30,
textAlign: TextAlign.center,
),
onTap: showEmojiPicker,
),
);
}
}
class EmojiPickerButton extends StatefulWidget {
final VoidCallback showEmojiPicker;
const EmojiPickerButton({
super.key,
required this.showEmojiPicker,
});
@override
State<EmojiPickerButton> createState() => _EmojiPickerButtonState();
}
class _EmojiPickerButtonState extends State<EmojiPickerButton> {
@override
Widget build(BuildContext context) {
return SizedBox(
height: 26,
width: 160,
child: FlowyButton(
text: FlowyText.medium(
LocaleKeys.document_plugins_cover_addIcon.tr(),
),
leftIcon: const Icon(
Icons.emoji_emotions,
size: 16,
),
onTap: widget.showEmojiPicker,
),
);
}
}
class RemoveEmojiButton extends StatelessWidget {
final VoidCallback onRemoved;
RemoveEmojiButton({
super.key,
required this.onRemoved,
});
final popoverController = PopoverController();
@override
Widget build(BuildContext context) {
return SizedBox(
height: 26,
width: 160,
child: FlowyButton(
text: FlowyText.medium(
LocaleKeys.document_plugins_cover_removeIcon.tr(),
),
leftIcon: const Icon(
Icons.emoji_emotions,
size: 16,
),
onTap: onRemoved,
),
);
}
}
Widget _buildEmojiPicker(OnSubmittedEmoji onSubmitted) {
return SizedBox(
height: 250,
child: EmojiSelectionMenu(
onSubmitted: onSubmitted,
onExit: () {},
),
);
}
@@ -1,31 +1,20 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart';
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart';
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_service.dart';
import 'package:appflowy/plugins/database_view/application/row/row_data_controller.dart';
import 'package:appflowy/plugins/database_view/grid/application/row/row_detail_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy/plugins/database_view/widgets/row/row_document.dart';
import 'package:collection/collection.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra/image.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import '../../grid/presentation/layout/sizes.dart';
import 'accessory/cell_accessory.dart';
import 'cell_builder.dart';
import 'cells/date_cell/date_cell.dart';
import 'cells/select_option_cell/select_option_cell.dart';
import 'cells/text_cell/text_cell.dart';
import 'cells/url_cell/url_cell.dart';
import '../../grid/presentation/widgets/header/field_cell.dart';
import '../../grid/presentation/widgets/header/field_editor.dart';
import 'row_action.dart';
import 'row_banner.dart';
import 'row_property.dart';
class RowDetailPage extends StatefulWidget with FlowyOverlayDelegate {
final RowController rowController;
@@ -46,6 +35,14 @@ class RowDetailPage extends StatefulWidget with FlowyOverlayDelegate {
}
class _RowDetailPageState extends State<RowDetailPage> {
final scrollController = ScrollController();
@override
void dispose() {
scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FlowyDialog(
@@ -55,43 +52,91 @@ class _RowDetailPageState extends State<RowDetailPage> {
..add(const RowDetailEvent.initial());
},
child: ListView(
controller: scrollController,
children: [
// using ListView here for future expansion:
// - header and cover image
// - lower rich text area
_rowBanner(),
IntrinsicHeight(child: _responsiveRowInfo()),
const Divider(height: 1.0),
const SizedBox(height: 10),
const VSpace(10),
RowDocument(
viewId: widget.rowController.viewId,
rowId: widget.rowController.rowId,
scrollController: scrollController,
),
],
),
),
);
}
Widget _rowBanner() {
return BlocBuilder<RowDetailBloc, RowDetailState>(
builder: (context, state) {
final paddingOffset = getHorizontalPadding(context);
return Padding(
padding: EdgeInsets.only(
left: paddingOffset,
right: paddingOffset,
top: 20,
),
child: RowBanner(
rowMeta: widget.rowController.rowMeta,
viewId: widget.rowController.viewId,
cellBuilder: (fieldId) {
final fieldInfo = state.cells
.firstWhereOrNull(
(e) => e.fieldInfo.field.id == fieldId,
)
?.fieldInfo;
if (fieldInfo != null) {
final style = GridTextCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
textStyle: Theme.of(context).textTheme.titleLarge,
showEmoji: false,
autofocus: true,
);
final cellContext = DatabaseCellContext(
viewId: widget.rowController.viewId,
rowMeta: widget.rowController.rowMeta,
fieldInfo: fieldInfo,
);
return widget.cellBuilder.build(cellContext, style: style);
} else {
return const SizedBox.shrink();
}
},
),
);
},
);
}
Widget _responsiveRowInfo() {
final rowDataColumn = _PropertyColumn(
final rowDataColumn = RowPropertyList(
cellBuilder: widget.cellBuilder,
viewId: widget.rowController.viewId,
);
final rowOptionColumn = _RowOptionColumn(
final rowOptionColumn = RowActionList(
viewId: widget.rowController.viewId,
rowController: widget.rowController,
);
final paddingOffset = getHorizontalPadding(context);
if (MediaQuery.of(context).size.width > 800) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
flex: 4,
flex: 3,
child: Padding(
padding: const EdgeInsets.fromLTRB(50, 50, 20, 20),
padding: EdgeInsets.fromLTRB(paddingOffset, 0, 20, 20),
child: rowDataColumn,
),
),
const VerticalDivider(width: 1.0),
Flexible(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 50, 20, 20),
padding: EdgeInsets.fromLTRB(20, 0, paddingOffset, 0),
child: rowOptionColumn,
),
),
@@ -103,12 +148,12 @@ class _RowDetailPageState extends State<RowDetailPage> {
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 50, 20, 20),
padding: EdgeInsets.fromLTRB(paddingOffset, 0, 20, 20),
child: rowDataColumn,
),
const Divider(height: 1.0),
Padding(
padding: const EdgeInsets.all(20),
padding: EdgeInsets.symmetric(horizontal: paddingOffset),
child: rowOptionColumn,
)
],
@@ -117,352 +162,10 @@ class _RowDetailPageState extends State<RowDetailPage> {
}
}
class _PropertyColumn extends StatelessWidget {
final String viewId;
final GridCellBuilder cellBuilder;
const _PropertyColumn({
required this.viewId,
required this.cellBuilder,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return BlocBuilder<RowDetailBloc, RowDetailState>(
buildWhen: (previous, current) => previous.gridCells != current.gridCells,
builder: (context, state) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_RowTitle(
cellContext: state.gridCells
.firstWhereOrNull((e) => e.fieldInfo.isPrimary),
cellBuilder: cellBuilder,
),
const VSpace(20),
...state.gridCells
.where((element) => !element.fieldInfo.isPrimary)
.map(
(cell) => Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: _PropertyCell(
cellContext: cell,
cellBuilder: cellBuilder,
),
),
)
.toList(),
const VSpace(20),
_CreatePropertyButton(viewId: viewId),
],
);
},
);
}
}
class _RowTitle extends StatelessWidget {
final DatabaseCellContext? cellContext;
final GridCellBuilder cellBuilder;
const _RowTitle({this.cellContext, required this.cellBuilder, Key? key})
: super(key: key);
@override
Widget build(BuildContext context) {
if (cellContext == null) {
return const SizedBox();
}
final style = GridTextCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
textStyle: Theme.of(context).textTheme.titleLarge,
autofocus: true,
);
return cellBuilder.build(cellContext!, style: style);
}
}
class _CreatePropertyButton extends StatefulWidget {
final String viewId;
const _CreatePropertyButton({
required this.viewId,
Key? key,
}) : super(key: key);
@override
State<_CreatePropertyButton> createState() => _CreatePropertyButtonState();
}
class _CreatePropertyButtonState extends State<_CreatePropertyButton> {
late PopoverController popoverController;
late TypeOptionPB typeOption;
@override
void initState() {
popoverController = PopoverController();
super.initState();
}
@override
Widget build(BuildContext context) {
return AppFlowyPopover(
constraints: BoxConstraints.loose(const Size(240, 200)),
controller: popoverController,
direction: PopoverDirection.topWithLeftAligned,
triggerActions: PopoverTriggerFlags.none,
margin: EdgeInsets.zero,
child: SizedBox(
height: 40,
child: FlowyButton(
text: FlowyText.medium(
LocaleKeys.grid_field_newProperty.tr(),
color: AFThemeExtension.of(context).textColor,
),
hoverColor: AFThemeExtension.of(context).lightGreyHover,
onTap: () async {
final result = await TypeOptionBackendService.createFieldTypeOption(
viewId: widget.viewId,
);
result.fold(
(l) {
typeOption = l;
popoverController.show();
},
(r) => Log.error("Failed to create field type option: $r"),
);
},
leftIcon: svgWidget(
"home/add",
color: AFThemeExtension.of(context).textColor,
),
),
),
popupBuilder: (BuildContext popOverContext) {
return FieldEditor(
viewId: widget.viewId,
typeOptionLoader: FieldTypeOptionLoader(
viewId: widget.viewId,
field: typeOption.field_2,
),
onDeleted: (fieldId) {
popoverController.close();
NavigatorAlertDialog(
title: LocaleKeys.grid_field_deleteFieldPromptMessage.tr(),
confirm: () {
context
.read<RowDetailBloc>()
.add(RowDetailEvent.deleteField(fieldId));
},
).show(context);
},
);
},
);
}
}
class _PropertyCell extends StatefulWidget {
final DatabaseCellContext cellContext;
final GridCellBuilder cellBuilder;
const _PropertyCell({
required this.cellContext,
required this.cellBuilder,
Key? key,
}) : super(key: key);
@override
State<StatefulWidget> createState() => _PropertyCellState();
}
class _PropertyCellState extends State<_PropertyCell> {
final PopoverController popover = PopoverController();
@override
Widget build(BuildContext context) {
final style = _customCellStyle(widget.cellContext.fieldType);
final cell = widget.cellBuilder.build(widget.cellContext, style: style);
final gesture = GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => cell.beginFocus.notify(),
child: AccessoryHover(
contentPadding: const EdgeInsets.symmetric(horizontal: 3, vertical: 3),
child: cell,
),
);
return IntrinsicHeight(
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 30),
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: [
AppFlowyPopover(
controller: popover,
constraints: BoxConstraints.loose(const Size(240, 600)),
margin: EdgeInsets.zero,
triggerActions: PopoverTriggerFlags.none,
popupBuilder: (popoverContext) => buildFieldEditor(),
child: SizedBox(
width: 150,
child: FieldCellButton(
field: widget.cellContext.fieldInfo.field,
onTap: () => popover.show(),
radius: BorderRadius.circular(6),
),
),
),
const HSpace(10),
Expanded(child: gesture),
],
),
),
);
}
Widget buildFieldEditor() {
return FieldEditor(
viewId: widget.cellContext.viewId,
isGroupingField: widget.cellContext.fieldInfo.isGroupField,
typeOptionLoader: FieldTypeOptionLoader(
viewId: widget.cellContext.viewId,
field: widget.cellContext.fieldInfo.field,
),
onHidden: (fieldId) {
popover.close();
context.read<RowDetailBloc>().add(RowDetailEvent.hideField(fieldId));
},
onDeleted: (fieldId) {
popover.close();
NavigatorAlertDialog(
title: LocaleKeys.grid_field_deleteFieldPromptMessage.tr(),
confirm: () {
context
.read<RowDetailBloc>()
.add(RowDetailEvent.deleteField(fieldId));
},
).show(context);
},
);
}
}
GridCellStyle? _customCellStyle(FieldType fieldType) {
switch (fieldType) {
case FieldType.Checkbox:
return null;
case FieldType.DateTime:
case FieldType.LastEditedTime:
case FieldType.CreatedTime:
return DateCellStyle(
alignment: Alignment.centerLeft,
);
case FieldType.MultiSelect:
return SelectOptionCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
);
case FieldType.Checklist:
return SelectOptionCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
);
case FieldType.Number:
return null;
case FieldType.RichText:
return GridTextCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
);
case FieldType.SingleSelect:
return SelectOptionCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
);
case FieldType.URL:
return GridURLCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
accessoryTypes: [
GridURLCellAccessoryType.copyURL,
GridURLCellAccessoryType.visitURL,
],
);
}
throw UnimplementedError;
}
class _RowOptionColumn extends StatelessWidget {
final RowController rowController;
const _RowOptionColumn({
required String viewId,
required this.rowController,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(left: 10),
child: FlowyText(LocaleKeys.grid_row_action.tr()),
),
const VSpace(15),
_DeleteButton(rowId: rowController.rowId),
_DuplicateButton(
rowId: rowController.rowId,
groupId: rowController.groupId,
),
],
);
}
}
class _DeleteButton extends StatelessWidget {
final String rowId;
const _DeleteButton({required this.rowId, Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return SizedBox(
height: GridSize.popoverItemHeight,
child: FlowyButton(
text: FlowyText.regular(LocaleKeys.grid_row_delete.tr()),
leftIcon: const FlowySvg(name: "home/trash"),
onTap: () {
context.read<RowDetailBloc>().add(RowDetailEvent.deleteRow(rowId));
FlowyOverlay.pop(context);
},
),
);
}
}
class _DuplicateButton extends StatelessWidget {
final String rowId;
final String? groupId;
const _DuplicateButton({
required this.rowId,
this.groupId,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return SizedBox(
height: GridSize.popoverItemHeight,
child: FlowyButton(
text: FlowyText.regular(LocaleKeys.grid_row_duplicate.tr()),
leftIcon: const FlowySvg(name: "grid/duplicate"),
onTap: () {
context
.read<RowDetailBloc>()
.add(RowDetailEvent.duplicateRow(rowId, groupId));
FlowyOverlay.pop(context);
},
),
);
double getHorizontalPadding(BuildContext context) {
if (MediaQuery.of(context).size.width > 800) {
return 50;
} else {
return 20;
}
}
@@ -0,0 +1,128 @@
import 'package:appflowy/plugins/database_view/grid/application/row/row_document_bloc.dart';
import 'package:appflowy/plugins/document/application/doc_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:flowy_infra_ui/widget/error_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class RowDocument extends StatelessWidget {
const RowDocument({
super.key,
required this.viewId,
required this.rowId,
required this.scrollController,
});
final String viewId;
final String rowId;
final ScrollController scrollController;
@override
Widget build(BuildContext context) {
return BlocProvider<RowDocumentBloc>(
create: (context) => RowDocumentBloc(
viewId: viewId,
rowId: rowId,
)..add(
const RowDocumentEvent.initial(),
),
child: BlocBuilder<RowDocumentBloc, RowDocumentState>(
builder: (context, state) {
return state.loadingState.when(
loading: () => const Center(
child: CircularProgressIndicator.adaptive(),
),
error: (error) => FlowyErrorPage(
error.toString(),
),
finish: () => RowEditor(
viewPB: state.viewPB!,
scrollController: scrollController,
),
);
},
),
);
}
}
class RowEditor extends StatefulWidget {
const RowEditor({
super.key,
required this.viewPB,
required this.scrollController,
});
final ViewPB viewPB;
final ScrollController scrollController;
@override
State<RowEditor> createState() => _RowEditorState();
}
class _RowEditorState extends State<RowEditor> {
late final DocumentBloc documentBloc;
@override
void initState() {
super.initState();
documentBloc = DocumentBloc(view: widget.viewPB)
..add(const DocumentEvent.initial());
}
@override
dispose() {
documentBloc.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider(create: (_) => DocumentAppearanceCubit()),
BlocProvider.value(value: documentBloc),
],
child: BlocBuilder<DocumentBloc, DocumentState>(
builder: (context, state) {
return state.loadingState.when(
loading: () => const Center(
child: CircularProgressIndicator.adaptive(),
),
finish: (result) {
return result.fold(
(error) => FlowyErrorPage(
error.toString(),
),
(_) {
final editorState = documentBloc.editorState;
if (editorState == null) {
return const SizedBox.shrink();
}
return IntrinsicHeight(
child: Container(
constraints: const BoxConstraints(minHeight: 300),
child: AppFlowyEditorPage(
shrinkWrap: true,
autoFocus: false,
editorState: editorState,
scrollController: widget.scrollController,
styleCustomizer: EditorStyleCustomizer(
context: context,
padding: const EdgeInsets.symmetric(horizontal: 10),
),
),
),
);
},
);
},
);
},
),
);
}
}
@@ -0,0 +1,192 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart';
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart';
import 'package:appflowy/plugins/database_view/grid/application/row/row_detail_bloc.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_cell.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_editor.dart';
import 'package:appflowy/plugins/database_view/widgets/row/row_action.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'accessory/cell_accessory.dart';
import 'cell_builder.dart';
import 'cells/date_cell/date_cell.dart';
import 'cells/select_option_cell/select_option_cell.dart';
import 'cells/text_cell/text_cell.dart';
import 'cells/url_cell/url_cell.dart';
/// Display the row properties in a list. Only use this widget in the
/// [RowDetailPage].
///
class RowPropertyList extends StatelessWidget {
final String viewId;
final GridCellBuilder cellBuilder;
const RowPropertyList({
required this.viewId,
required this.cellBuilder,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return BlocBuilder<RowDetailBloc, RowDetailState>(
buildWhen: (previous, current) => previous.cells != current.cells,
builder: (context, state) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// The rest of the fields are displayed in the order of the field
// list
...state.cells
.where((element) => !element.fieldInfo.isPrimary)
.map(
(cell) => _PropertyCell(
cellContext: cell,
cellBuilder: cellBuilder,
),
)
.toList(),
const VSpace(20),
// Create a new property(field) button
CreateRowFieldButton(viewId: viewId),
],
);
},
);
}
}
class _PropertyCell extends StatefulWidget {
final DatabaseCellContext cellContext;
final GridCellBuilder cellBuilder;
const _PropertyCell({
required this.cellContext,
required this.cellBuilder,
Key? key,
}) : super(key: key);
@override
State<StatefulWidget> createState() => _PropertyCellState();
}
class _PropertyCellState extends State<_PropertyCell> {
final PopoverController popover = PopoverController();
@override
Widget build(BuildContext context) {
final style = _customCellStyle(widget.cellContext.fieldType);
final cell = widget.cellBuilder.build(widget.cellContext, style: style);
final gesture = GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => cell.beginFocus.notify(),
child: AccessoryHover(
contentPadding: const EdgeInsets.symmetric(horizontal: 3, vertical: 3),
child: cell,
),
);
return IntrinsicHeight(
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 30),
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: [
AppFlowyPopover(
controller: popover,
constraints: BoxConstraints.loose(const Size(240, 600)),
margin: EdgeInsets.zero,
triggerActions: PopoverTriggerFlags.none,
popupBuilder: (popoverContext) => buildFieldEditor(),
child: SizedBox(
width: 150,
child: FieldCellButton(
field: widget.cellContext.fieldInfo.field,
onTap: () => popover.show(),
radius: BorderRadius.circular(6),
),
),
),
Expanded(child: gesture),
],
),
),
);
}
Widget buildFieldEditor() {
return FieldEditor(
viewId: widget.cellContext.viewId,
isGroupingField: widget.cellContext.fieldInfo.isGroupField,
typeOptionLoader: FieldTypeOptionLoader(
viewId: widget.cellContext.viewId,
field: widget.cellContext.fieldInfo.field,
),
onHidden: (fieldId) {
popover.close();
context.read<RowDetailBloc>().add(RowDetailEvent.hideField(fieldId));
},
onDeleted: (fieldId) {
popover.close();
NavigatorAlertDialog(
title: LocaleKeys.grid_field_deleteFieldPromptMessage.tr(),
confirm: () {
context
.read<RowDetailBloc>()
.add(RowDetailEvent.deleteField(fieldId));
},
).show(context);
},
);
}
}
GridCellStyle? _customCellStyle(FieldType fieldType) {
switch (fieldType) {
case FieldType.Checkbox:
return null;
case FieldType.DateTime:
case FieldType.LastEditedTime:
case FieldType.CreatedTime:
return DateCellStyle(
alignment: Alignment.centerLeft,
);
case FieldType.MultiSelect:
return SelectOptionCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
);
case FieldType.Checklist:
return SelectOptionCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
);
case FieldType.Number:
return null;
case FieldType.RichText:
return GridTextCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
);
case FieldType.SingleSelect:
return SelectOptionCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
);
case FieldType.URL:
return GridURLCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
accessoryTypes: [
GridURLCellAccessoryType.copyURL,
GridURLCellAccessoryType.visitURL,
],
);
}
throw UnimplementedError;
}
@@ -23,7 +23,7 @@ class DatabaseSettingList extends StatelessWidget {
Widget build(BuildContext context) {
final cells = actionsForDatabaseLayout(databaseContoller.databaseLayout)
.map((action) {
return _SettingItem(
return DatabaseSettingItem(
action: action,
onAction: (action) => onAction(action, databaseContoller),
);
@@ -44,11 +44,11 @@ class DatabaseSettingList extends StatelessWidget {
}
}
class _SettingItem extends StatelessWidget {
class DatabaseSettingItem extends StatelessWidget {
final DatabaseSettingAction action;
final Function(DatabaseSettingAction) onAction;
const _SettingItem({
const DatabaseSettingItem({
required this.action,
required this.onAction,
Key? key,
@@ -23,7 +23,7 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
DocumentBloc({
required this.view,
}) : _documentListener = DocumentListener(id: view.id),
_viewListener = ViewListener(view: view),
_viewListener = ViewListener(viewId: view.id),
_documentService = DocumentService(),
_trashService = TrashService(),
super(DocumentState.initial()) {
@@ -48,8 +48,12 @@ class DocumentPlugin extends Plugin<int> {
DocumentPlugin({
required PluginType pluginType,
required ViewPB view,
bool listenOnViewChanged = false,
Key? key,
}) : notifier = ViewPluginNotifier(view: view) {
}) : notifier = ViewPluginNotifier(
view: view,
listenOnViewChanged: listenOnViewChanged,
) {
_pluginType = pluginType;
_documentAppearanceCubit.fetch();
}
@@ -5,6 +5,7 @@ import 'package:appflowy/plugins/document/application/doc_bloc.dart';
import 'package:appflowy/plugins/document/presentation/banner.dart';
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/plugins/document/presentation/export_page_widget.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/base64_string.dart';
@@ -90,6 +91,10 @@ class _DocumentPageState extends State<DocumentPage> {
Widget _buildEditorPage(BuildContext context, DocumentState state) {
final appflowyEditorPage = AppFlowyEditorPage(
editorState: editorState!,
styleCustomizer: EditorStyleCustomizer(
context: context,
padding: const EdgeInsets.symmetric(horizontal: 50),
),
header: _buildCoverAndIcon(context),
);
return Column(
@@ -14,17 +14,25 @@ class AppFlowyEditorPage extends StatefulWidget {
super.key,
required this.editorState,
this.header,
this.shrinkWrap = false,
this.scrollController,
this.autoFocus,
required this.styleCustomizer,
});
final EditorState editorState;
final Widget? header;
final EditorState editorState;
final ScrollController? scrollController;
final bool shrinkWrap;
final bool? autoFocus;
final EditorStyleCustomizer styleCustomizer;
@override
State<AppFlowyEditorPage> createState() => _AppFlowyEditorPageState();
}
class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
final scrollController = ScrollController();
late final ScrollController effectiveScrollController;
final List<CommandShortcutEvent> commandShortcutEvents = [
...codeBlockCommands,
@@ -85,11 +93,23 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
style: styleCustomizer.selectionMenuStyleBuilder(),
).handler;
EditorStyleCustomizer get styleCustomizer => EditorStyleCustomizer(
context: context,
);
EditorStyleCustomizer get styleCustomizer => widget.styleCustomizer;
DocumentBloc get documentBloc => context.read<DocumentBloc>();
@override
void initState() {
super.initState();
effectiveScrollController = widget.scrollController ?? ScrollController();
}
@override
void dispose() {
if (widget.scrollController == null) {
effectiveScrollController.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final (bool autoFocus, Selection? selection) =
@@ -98,9 +118,10 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
final editor = AppFlowyEditor.custom(
editorState: widget.editorState,
editable: true,
scrollController: scrollController,
shrinkWrap: widget.shrinkWrap,
scrollController: effectiveScrollController,
// setup the auto focus parameters
autoFocus: autoFocus,
autoFocus: widget.autoFocus ?? autoFocus,
focusedSelection: selection,
// setup the theme
editorStyle: styleCustomizer.style(),
@@ -122,7 +143,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
style: styleCustomizer.floatingToolbarStyleBuilder(),
items: toolbarItems,
editorState: widget.editorState,
scrollController: scrollController,
scrollController: effectiveScrollController,
child: editor,
),
),
@@ -61,6 +61,7 @@ extension InsertDatabase on EditorState {
).then((value) => value.swap().toOption().toNullable());
// TODO(a-wallen): Show error dialog here.
// Maybe extend the FlowyErrorPage.
if (ref == null) {
return;
}
@@ -23,11 +23,13 @@ void showLinkToPageMenu(
final top = alignment == Alignment.bottomLeft ? offset.dy : null;
final bottom = alignment == Alignment.topLeft ? offset.dy : null;
keepEditorFocusNotifier.value += 1;
late OverlayEntry linkToPageMenuEntry;
linkToPageMenuEntry = FullScreenOverlayEntry(
top: top,
bottom: bottom,
left: offset.dx,
dismissCallback: () => keepEditorFocusNotifier.value -= 1,
builder: (context) => Material(
color: Colors.transparent,
child: LinkToPageMenu(
@@ -4,7 +4,6 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/change_cover_popover.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/emoji_popover.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/emoji_icon_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/workspace/presentation/widgets/emoji_picker/emoji_picker.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide FlowySvg;
import 'package:appflowy_popover/appflowy_popover.dart';
@@ -164,8 +163,8 @@ class _AddCoverButtonState extends State<_AddCoverButton> {
height: widget.hasIcon ? 180 : 50.0,
alignment: Alignment.bottomLeft,
width: double.infinity,
padding: EdgeInsets.only(
left: EditorStyleCustomizer.horizontalPadding + 30,
padding: const EdgeInsets.only(
left: 80,
top: 20,
bottom: 5,
),
@@ -333,7 +332,7 @@ class _CoverImageState extends State<_CoverImage> {
),
hasIcon
? Positioned(
left: EditorStyleCustomizer.horizontalPadding + 30,
left: 80,
bottom: !hasCover ? 30 : 40,
child: AppFlowyPopover(
offset: const Offset(100, 0),
@@ -416,7 +415,7 @@ class _CoverImageState extends State<_CoverImage> {
Widget _buildCoverOverlayButtons(BuildContext context) {
return Positioned(
bottom: 20,
right: EditorStyleCustomizer.horizontalPadding,
right: 50,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -35,10 +35,12 @@ void showEmojiPickerMenu(
final top = alignment == Alignment.bottomLeft ? offset.dy : null;
final bottom = alignment == Alignment.topLeft ? offset.dy : null;
keepEditorFocusNotifier.value += 1;
final emojiPickerMenuEntry = FullScreenOverlayEntry(
top: top,
bottom: bottom,
left: offset.dx,
dismissCallback: () => keepEditorFocusNotifier.value -= 1,
builder: (context) => Material(
child: Container(
width: 300,
@@ -8,12 +8,11 @@ import 'package:google_fonts/google_fonts.dart';
class EditorStyleCustomizer {
EditorStyleCustomizer({
required this.context,
required this.padding,
});
static double get horizontalPadding =>
PlatformExtension.isDesktop ? 50.0 : 10.0;
final BuildContext context;
final EdgeInsets padding;
EditorStyle style() {
if (PlatformExtension.isDesktopOrWeb) {
@@ -28,7 +27,7 @@ class EditorStyleCustomizer {
final theme = Theme.of(context);
final fontSize = context.read<DocumentAppearanceCubit>().state.fontSize;
return EditorStyle.desktop(
padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
padding: padding,
backgroundColor: theme.colorScheme.surface,
cursorColor: theme.colorScheme.primary,
textStyleConfiguration: TextStyleConfiguration(
@@ -65,7 +64,7 @@ class EditorStyleCustomizer {
final theme = Theme.of(context);
final fontSize = context.read<DocumentAppearanceCubit>().state.fontSize;
return EditorStyle.desktop(
padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
padding: padding,
backgroundColor: theme.colorScheme.surface,
cursorColor: theme.colorScheme.primary,
textStyleConfiguration: TextStyleConfiguration(
@@ -2,6 +2,7 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/plugins/document/application/share_bloc.dart';
import 'package:appflowy/util/file_picker/file_picker_service.dart';
import 'package:appflowy/workspace/application/view/view_listener.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_backend/protobuf/flowy-document2/entities.pb.dart';
@@ -68,7 +69,7 @@ class DocumentShareButton extends StatelessWidget {
}
}
class ShareActionList extends StatelessWidget {
class ShareActionList extends StatefulWidget {
const ShareActionList({
super.key,
required this.view,
@@ -76,6 +77,27 @@ class ShareActionList extends StatelessWidget {
final ViewPB view;
@override
State<ShareActionList> createState() => ShareActionListState();
}
@visibleForTesting
class ShareActionListState extends State<ShareActionList> {
late String name;
late final ViewListener viewListener = ViewListener(viewId: widget.view.id);
@override
void initState() {
super.initState();
listenOnViewUpdated();
}
@override
void dispose() {
viewListener.stop();
super.dispose();
}
@override
Widget build(BuildContext context) {
final docShareBloc = context.read<DocShareBloc>();
@@ -96,7 +118,7 @@ class ShareActionList extends StatelessWidget {
case ShareAction.markdown:
final exportPath = await getIt<FilePickerService>().saveFile(
dialogTitle: '',
fileName: '${view.name}.md',
fileName: '$name.md',
);
if (exportPath != null) {
docShareBloc.add(DocShareEvent.shareMarkdown(exportPath));
@@ -107,6 +129,15 @@ class ShareActionList extends StatelessWidget {
},
);
}
void listenOnViewUpdated() {
name = widget.view.name;
viewListener.start(
onViewUpdated: (view) {
name = view.name;
},
);
}
}
enum ShareAction {
+27 -21
View File
@@ -1,10 +1,14 @@
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_listener.dart';
import 'package:dartz/dartz.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:flutter/material.dart';
import '../workspace/presentation/home/home_stack.dart';
class ViewPluginNotifier extends PluginNotifier<Option<DeletedViewPB>> {
final ViewListener? _viewListener;
ViewPB view;
@@ -12,35 +16,37 @@ class ViewPluginNotifier extends PluginNotifier<Option<DeletedViewPB>> {
@override
final ValueNotifier<Option<DeletedViewPB>> isDeleted = ValueNotifier(none());
@override
final ValueNotifier<int> isDisplayChanged = ValueNotifier(0);
ViewPluginNotifier({
required this.view,
}) : _viewListener = ViewListener(view: view) {
_viewListener?.start(
onViewUpdated: (result) {
result.fold(
(updatedView) {
required bool listenOnViewChanged,
}) : _viewListener = ViewListener(viewId: view.id) {
if (listenOnViewChanged) {
_viewListener?.start(
onViewUpdated: (updatedView) {
// If the layout is changed, we need to create a new plugin for it.
if (view.layout != updatedView.layout) {
getIt<HomeStackManager>().setPlugin(
updatedView.plugin(
listenOnViewChanged: listenOnViewChanged,
),
);
} else {
view = updatedView;
isDisplayChanged.value = updatedView.hashCode;
},
(err) => Log.error(err),
);
},
onViewMoveToTrash: (result) {
result.fold(
(deletedView) => isDeleted.value = some(deletedView),
(err) => Log.error(err),
);
},
);
}
},
onViewMoveToTrash: (result) {
result.fold(
(deletedView) => isDeleted.value = some(deletedView),
(err) => Log.error(err),
);
},
);
}
}
@override
void dispose() {
isDeleted.dispose();
isDisplayChanged.dispose();
_viewListener?.stop();
}
}
@@ -122,11 +122,6 @@ void _resolveFolderDeps(GetIt getIt) {
WorkspaceListener(user: user, workspaceId: workspaceId),
);
// ViewPB
getIt.registerFactoryParam<ViewListener, ViewPB, void>(
(view, _) => ViewListener(view: view),
);
getIt.registerFactoryParam<ViewBloc, ViewPB, void>(
(view, _) => ViewBloc(
view: view,
@@ -37,9 +37,6 @@ abstract class PluginNotifier<T> {
/// Notify if the plugin get deleted
ValueNotifier<T> get isDeleted;
/// Notify if the [PluginWidgetBuilder]'s content was changed
ValueNotifier<int> get isDisplayChanged;
void dispose() {}
}
@@ -37,7 +37,7 @@ class InitAppWindowTask extends LaunchTask with WindowListener {
title: title,
);
await windowManager.waitUntilReadyToShow(windowOptions, () async {
windowManager.waitUntilReadyToShow(windowOptions, () async {
await windowManager.show();
await windowManager.focus();
});
@@ -3,8 +3,13 @@ import 'package:appflowy/startup/entry_point.dart';
import 'package:appflowy/startup/launch_configuration.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/workspace/application/appearance.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/settings_language_view.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:dartz/dartz.dart' as dartz;
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/image.dart';
import 'package:flowy_infra/language.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
@@ -13,6 +18,7 @@ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -49,6 +55,7 @@ class _SkipLogInScreenState extends State<SkipLogInScreen> {
}
Widget _renderBody(BuildContext context) {
final size = MediaQuery.of(context).size;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
@@ -70,7 +77,7 @@ class _SkipLogInScreenState extends State<SkipLogInScreen> {
),
const VSpace(32),
SizedBox(
width: MediaQuery.of(context).size.width * 0.5,
width: size.width * 0.5,
child: FolderWidget(
createFolderCallback: () async {
_didCustomizeFolder = true;
@@ -79,55 +86,12 @@ class _SkipLogInScreenState extends State<SkipLogInScreen> {
),
const Spacer(),
const VSpace(48),
_buildSubscribeButtons(context),
const VSpace(24),
const SkipLoginPageFooter(),
const VSpace(20),
],
);
}
Widget _buildSubscribeButtons(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FlowyText.regular(
LocaleKeys.youCanAlso.tr(),
fontSize: FontSizes.s12,
),
FlowyTextButton(
LocaleKeys.githubStarText.tr(),
fontWeight: FontWeight.w500,
fontColor: Theme.of(context).colorScheme.primary,
hoverColor: Colors.transparent,
fillColor: Colors.transparent,
onPressed: () => _launchURL(
'https://github.com/AppFlowy-IO/appflowy',
),
),
FlowyText.regular(
LocaleKeys.and.tr(),
fontSize: FontSizes.s12,
),
FlowyTextButton(
LocaleKeys.subscribeNewsletterText.tr(),
fontWeight: FontWeight.w500,
fontColor: Theme.of(context).colorScheme.primary,
hoverColor: Colors.transparent,
fillColor: Colors.transparent,
onPressed: () => _launchURL('https://www.appflowy.io/blog'),
),
],
);
}
Future<void> _launchURL(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
} else {
throw 'Could not launch $url';
}
}
Future<void> _autoRegister(BuildContext context) async {
final result = await widget.authService.signUpAsGuest();
result.fold(
@@ -168,6 +132,140 @@ class _SkipLogInScreenState extends State<SkipLogInScreen> {
}
}
class SkipLoginPageFooter extends StatelessWidget {
const SkipLoginPageFooter({
super.key,
});
@override
Widget build(BuildContext context) {
// The placeholderWidth should be greater than the longest width of the LanguageSelectorOnWelcomePage
const double placeholderWidth = 180;
return const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
HSpace(placeholderWidth),
Expanded(child: SubscribeButtons()),
SizedBox(
width: placeholderWidth,
height: 28,
child: Row(
children: [
Spacer(),
LanguageSelectorOnWelcomePage(),
],
),
),
],
),
);
}
}
class SubscribeButtons extends StatelessWidget {
const SubscribeButtons({
super.key,
});
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
FlowyText.regular(
LocaleKeys.youCanAlso.tr(),
fontSize: FontSizes.s12,
),
FlowyTextButton(
LocaleKeys.githubStarText.tr(),
fontWeight: FontWeight.w500,
fontColor: Theme.of(context).colorScheme.primary,
hoverColor: Colors.transparent,
fillColor: Colors.transparent,
onPressed: () => _launchURL(
'https://github.com/AppFlowy-IO/appflowy',
),
),
FlowyText.regular(
LocaleKeys.and.tr(),
fontSize: FontSizes.s12,
),
FlowyTextButton(
LocaleKeys.subscribeNewsletterText.tr(),
overflow: TextOverflow.ellipsis,
fontWeight: FontWeight.w500,
fontColor: Theme.of(context).colorScheme.primary,
hoverColor: Colors.transparent,
fillColor: Colors.transparent,
onPressed: () => _launchURL('https://www.appflowy.io/blog'),
),
],
);
}
Future<void> _launchURL(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
} else {
throw 'Could not launch $url';
}
}
}
class LanguageSelectorOnWelcomePage extends StatelessWidget {
const LanguageSelectorOnWelcomePage({
super.key,
});
@override
Widget build(BuildContext context) {
return BlocBuilder<AppearanceSettingsCubit, AppearanceSettingsState>(
builder: (context, state) {
return AppFlowyPopover(
offset: const Offset(0, -450),
direction: PopoverDirection.bottomWithRightAligned,
child: FlowyButton(
useIntrinsicWidth: true,
text: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [
const FlowySvg(
name: 'login/language',
size: Size.square(20),
),
const HSpace(4),
FlowyText(
languageFromLocale(state.locale),
),
// const HSpace(4),
const FlowySvg(
name: 'home/drop_down_hide',
size: Size.square(20),
),
],
),
),
popupBuilder: (BuildContext context) {
final easyLocalization = EasyLocalization.of(context);
if (easyLocalization == null) {
return const SizedBox.shrink();
}
final allLocales = easyLocalization.supportedLocales;
return LanguageItemsListView(
allLocales: allLocales,
);
},
);
},
);
}
}
class GoButton extends StatelessWidget {
final VoidCallback onPressed;
@@ -113,6 +113,7 @@ class AppBloc extends Bloc<AppEvent, AppState> {
layoutType: value.pluginBuilder.layoutType!,
initialDataBytes: value.initialDataBytes,
ext: value.ext ?? {},
openAfterCreate: true,
);
result.fold(
(view) => emit(
@@ -57,13 +57,14 @@ class AppearanceSettingsCubit extends Cubit<AppearanceSettingsState> {
newLocale = const Locale('en');
}
if (state.locale != newLocale) {
context.setLocale(newLocale);
context.setLocale(newLocale).catchError((e) {
Log.warn('Catch error in setLocale: $e}');
});
if (state.locale != newLocale) {
_setting.locale.languageCode = newLocale.languageCode;
_setting.locale.countryCode = newLocale.countryCode ?? "";
_saveAppearanceSettings();
emit(state.copyWith(locale: newLocale));
}
}
@@ -16,14 +16,14 @@ class ViewBloc extends Bloc<ViewEvent, ViewState> {
ViewBloc({
required this.view,
}) : viewBackendSvc = ViewBackendService(),
listener = ViewListener(view: view),
listener = ViewListener(viewId: view.id),
super(ViewState.init(view)) {
on<ViewEvent>((event, emit) async {
await event.map(
initial: (e) {
listener.start(
onViewUpdated: (result) {
add(ViewEvent.viewDidUpdate(result));
add(ViewEvent.viewDidUpdate(left(result)));
},
);
emit(state);
@@ -1,4 +1,7 @@
import 'package:appflowy/plugins/database_view/database_view.dart';
import 'package:appflowy/plugins/database_view/board/board.dart';
import 'package:appflowy/plugins/database_view/calendar/calendar.dart';
import 'package:appflowy/plugins/database_view/grid/grid.dart';
import 'package:appflowy/plugins/document/document.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:flowy_infra/image.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
@@ -56,14 +59,32 @@ extension ViewExtension on ViewPB {
throw UnimplementedError;
}
Plugin plugin() {
Plugin plugin({bool listenOnViewChanged = false}) {
switch (layout) {
case ViewLayoutPB.Board:
return BoardPlugin(
view: this,
pluginType: pluginType,
listenOnViewChanged: listenOnViewChanged,
);
case ViewLayoutPB.Calendar:
return CalendarPlugin(
view: this,
pluginType: pluginType,
listenOnViewChanged: listenOnViewChanged,
);
case ViewLayoutPB.Grid:
return DatabaseViewPlugin(view: this);
return GridPlugin(
view: this,
pluginType: pluginType,
listenOnViewChanged: listenOnViewChanged,
);
case ViewLayoutPB.Document:
return makePlugin(pluginType: pluginType, data: this);
return DocumentPlugin(
view: this,
pluginType: pluginType,
listenOnViewChanged: listenOnViewChanged,
);
}
throw UnimplementedError;
}
Loaded 100 of 249 files, more files were not shown because too many files have changed in this diff. Show more