mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: implement draggable folder (#3083)
This commit is contained in:
parent
eb77346e5a
commit
266209caeb
@ -14,8 +14,7 @@ void main() {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateCalendarButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Calendar);
|
||||
|
||||
// open setting
|
||||
await tester.tapDatabaseSettingButton();
|
||||
@ -36,7 +35,11 @@ void main() {
|
||||
await tester.tapGoButton();
|
||||
|
||||
// Create calendar view
|
||||
await tester.createNewPageWithName(ViewLayoutPB.Calendar, 'calendar');
|
||||
const name = 'calendar';
|
||||
await tester.createNewPageWithName(
|
||||
name: name,
|
||||
layout: ViewLayoutPB.Calendar,
|
||||
);
|
||||
|
||||
// Open setting
|
||||
await tester.tapDatabaseSettingButton();
|
||||
@ -47,9 +50,9 @@ void main() {
|
||||
await tester.tapFirstDayOfWeekStartFromMonday();
|
||||
|
||||
// Open the other page and open the new calendar page again
|
||||
await tester.openPage(readme);
|
||||
await tester.openPage(gettingStated);
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 300));
|
||||
await tester.openPage('calendar');
|
||||
await tester.openPage(name, layout: ViewLayoutPB.Calendar);
|
||||
|
||||
// Open setting again and check the start from Monday is selected
|
||||
await tester.tapDatabaseSettingButton();
|
||||
@ -65,8 +68,7 @@ void main() {
|
||||
await tester.tapGoButton();
|
||||
|
||||
// Create the calendar view
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateCalendarButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Calendar);
|
||||
|
||||
// Scroll until today's date cell is visible
|
||||
await tester.scrollToToday();
|
||||
@ -135,8 +137,7 @@ void main() {
|
||||
await tester.tapGoButton();
|
||||
|
||||
// Create the calendar view
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateCalendarButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Calendar);
|
||||
|
||||
// Create a new event on the first of this month
|
||||
final today = DateTime.now();
|
||||
|
@ -15,8 +15,7 @@ void main() {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
await tester.editCell(
|
||||
rowIndex: 0,
|
||||
@ -38,7 +37,10 @@ void main() {
|
||||
testWidgets('edit multiple text cells', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
await tester.createNewPageWithName(ViewLayoutPB.Grid, 'my grid');
|
||||
await tester.createNewPageWithName(
|
||||
name: 'my grid',
|
||||
layout: ViewLayoutPB.Grid,
|
||||
);
|
||||
await tester.createField(FieldType.RichText, 'description');
|
||||
|
||||
await tester.editCell(
|
||||
@ -75,8 +77,7 @@ void main() {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
const fieldType = FieldType.Number;
|
||||
|
||||
@ -134,8 +135,7 @@ void main() {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
await tester.assertCheckboxCell(rowIndex: 0, isSelected: false);
|
||||
await tester.tapCheckboxCellInGrid(rowIndex: 0);
|
||||
@ -153,8 +153,7 @@ void main() {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
const fieldType = FieldType.CreatedTime;
|
||||
// Create a create time field
|
||||
@ -172,8 +171,7 @@ void main() {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
const fieldType = FieldType.LastEditedTime;
|
||||
// Create a last time field
|
||||
@ -191,8 +189,7 @@ void main() {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
const fieldType = FieldType.DateTime;
|
||||
await tester.createField(fieldType, fieldType.name);
|
||||
@ -288,9 +285,9 @@ void main() {
|
||||
await tester.tapGoButton();
|
||||
|
||||
const fieldType = FieldType.SingleSelect;
|
||||
await tester.tapAddButton();
|
||||
|
||||
// When create a grid, it will create a single select field by default
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
// Tap the cell to invoke the selection option editor
|
||||
await tester.tapSelectOptionCellInGrid(rowIndex: 0, fieldType: fieldType);
|
||||
@ -366,8 +363,7 @@ void main() {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
const fieldType = FieldType.MultiSelect;
|
||||
await tester.createField(fieldType, fieldType.name);
|
||||
|
@ -17,8 +17,7 @@ void main() {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
// Invoke the field editor
|
||||
await tester.tapGridFieldWithName('Name');
|
||||
@ -35,8 +34,7 @@ void main() {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
// Invoke the field editor
|
||||
await tester.tapGridFieldWithName('Type');
|
||||
@ -58,8 +56,7 @@ void main() {
|
||||
await tester.tapGoButton();
|
||||
|
||||
// create a new grid
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
// create a field
|
||||
await tester.createField(FieldType.Checklist, 'checklist');
|
||||
@ -73,8 +70,7 @@ void main() {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
// create a field
|
||||
await tester.createField(FieldType.Checkbox, 'New field 1');
|
||||
@ -94,8 +90,7 @@ void main() {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
// create a field
|
||||
await tester.scrollToRight(find.byType(GridPage));
|
||||
@ -115,8 +110,7 @@ void main() {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
// create a field
|
||||
await tester.scrollToRight(find.byType(GridPage));
|
||||
@ -136,8 +130,7 @@ void main() {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
await tester.scrollToRight(find.byType(GridPage));
|
||||
await tester.tapNewPropertyButton();
|
||||
@ -157,8 +150,7 @@ void main() {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
for (final fieldType in [
|
||||
FieldType.Checklist,
|
||||
@ -190,7 +182,9 @@ void main() {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
await tester.createNewPageWithName(ViewLayoutPB.Grid);
|
||||
await tester.createNewPageWithName(
|
||||
layout: ViewLayoutPB.Grid,
|
||||
);
|
||||
|
||||
// Invoke the field editor
|
||||
await tester.tapGridFieldWithName('Type');
|
||||
|
@ -9,7 +9,7 @@ import 'util/database_test_op.dart';
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('grid', () {
|
||||
group('database filter', () {
|
||||
testWidgets('add text filter', (tester) async {
|
||||
await tester.openV020database();
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
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_backend/protobuf/flowy-folder2/view.pb.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@ -19,8 +20,7 @@ void main() {
|
||||
await tester.tapGoButton();
|
||||
|
||||
// Create a new grid
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
// Hover first row and then open the row page
|
||||
await tester.openFirstRowDetailPage();
|
||||
@ -34,8 +34,7 @@ void main() {
|
||||
await tester.tapGoButton();
|
||||
|
||||
// Create a new grid
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
// Hover first row and then open the row page
|
||||
await tester.openFirstRowDetailPage();
|
||||
@ -55,8 +54,7 @@ void main() {
|
||||
await tester.tapGoButton();
|
||||
|
||||
// Create a new grid
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
// Hover first row and then open the row page
|
||||
await tester.openFirstRowDetailPage();
|
||||
@ -85,8 +83,7 @@ void main() {
|
||||
await tester.tapGoButton();
|
||||
|
||||
// Create a new grid
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
// Hover first row and then open the row page
|
||||
await tester.openFirstRowDetailPage();
|
||||
@ -108,8 +105,7 @@ void main() {
|
||||
await tester.tapGoButton();
|
||||
|
||||
// Create a new grid
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
// Hover first row and then open the row page
|
||||
await tester.openFirstRowDetailPage();
|
||||
@ -144,8 +140,7 @@ void main() {
|
||||
await tester.tapGoButton();
|
||||
|
||||
// Create a new grid
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
// Hover first row and then open the row page
|
||||
await tester.openFirstRowDetailPage();
|
||||
@ -160,8 +155,7 @@ void main() {
|
||||
await tester.tapGoButton();
|
||||
|
||||
// Create a new grid
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
// Hover first row and then open the row page
|
||||
await tester.openFirstRowDetailPage();
|
||||
@ -201,8 +195,7 @@ void main() {
|
||||
await tester.tapGoButton();
|
||||
|
||||
// Create a new grid
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
// Hover first row and then open the row page
|
||||
await tester.openFirstRowDetailPage();
|
||||
@ -241,8 +234,7 @@ void main() {
|
||||
await tester.tapGoButton();
|
||||
|
||||
// Create a new grid
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
// Hover first row and then open the row page
|
||||
await tester.openFirstRowDetailPage();
|
||||
@ -258,8 +250,7 @@ void main() {
|
||||
await tester.tapGoButton();
|
||||
|
||||
// Create a new grid
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
// Hover first row and then open the row page
|
||||
await tester.openFirstRowDetailPage();
|
||||
|
@ -1,3 +1,4 @@
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
@ -12,8 +13,7 @@ void main() {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
await tester.tapCreateRowButtonInGrid();
|
||||
|
||||
// The initial number of rows is 3
|
||||
@ -25,8 +25,8 @@ void main() {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
await tester.hoverOnFirstRowOfGrid();
|
||||
|
||||
await tester.tapCreateRowButtonInRowMenuOfGrid();
|
||||
@ -41,8 +41,7 @@ void main() {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
await tester.hoverOnFirstRowOfGrid();
|
||||
|
||||
// Open the row menu and then click the delete
|
||||
@ -60,8 +59,7 @@ void main() {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
await tester.assertRowCountInGridPage(3);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
@ -13,8 +14,7 @@ void main() {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
// open setting
|
||||
await tester.tapDatabaseSettingButton();
|
||||
@ -31,8 +31,7 @@ void main() {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
// open setting
|
||||
await tester.tapDatabaseSettingButton();
|
||||
|
@ -15,8 +15,7 @@ void main() {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
// Create board view
|
||||
await tester.tapCreateLinkedDatabaseViewButton(AddButtonAction.board);
|
||||
@ -37,8 +36,7 @@ void main() {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
// Create board view
|
||||
await tester.tapCreateLinkedDatabaseViewButton(AddButtonAction.board);
|
||||
@ -63,8 +61,7 @@ void main() {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateGridButton();
|
||||
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
|
||||
|
||||
// Create board view
|
||||
await tester.tapCreateLinkedDatabaseViewButton(AddButtonAction.board);
|
||||
|
@ -17,9 +17,7 @@ void main() {
|
||||
await tester.tapGoButton();
|
||||
|
||||
// create a new document
|
||||
await tester.tapAddButton();
|
||||
await tester.tapCreateDocumentButton();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.createNewPageWithName();
|
||||
|
||||
// expect to see a new document
|
||||
tester.expectToSeePageName(
|
||||
@ -35,19 +33,21 @@ void main() {
|
||||
await tester.tapGoButton();
|
||||
|
||||
// delete the readme page
|
||||
await tester.hoverOnPageName(readme);
|
||||
await tester.tapDeletePageButton();
|
||||
await tester.hoverOnPageName(
|
||||
gettingStated,
|
||||
onHover: () async => await tester.tapDeletePageButton(),
|
||||
);
|
||||
|
||||
// the banner should show up and the readme page should be gone
|
||||
tester.expectToSeeDocumentBanner();
|
||||
tester.expectNotToSeePageName(readme);
|
||||
tester.expectNotToSeePageName(gettingStated);
|
||||
|
||||
// restore the readme page
|
||||
await tester.tapRestoreButton();
|
||||
|
||||
// the banner should be gone and the readme page should be back
|
||||
tester.expectNotToSeeDocumentBanner();
|
||||
tester.expectToSeePageName(readme);
|
||||
tester.expectToSeePageName(gettingStated);
|
||||
});
|
||||
|
||||
testWidgets('delete the readme page and delete it permanently',
|
||||
@ -57,19 +57,21 @@ void main() {
|
||||
await tester.tapGoButton();
|
||||
|
||||
// delete the readme page
|
||||
await tester.hoverOnPageName(readme);
|
||||
await tester.tapDeletePageButton();
|
||||
await tester.hoverOnPageName(
|
||||
gettingStated,
|
||||
onHover: () async => await tester.tapDeletePageButton(),
|
||||
);
|
||||
|
||||
// the banner should show up and the readme page should be gone
|
||||
tester.expectToSeeDocumentBanner();
|
||||
tester.expectNotToSeePageName(readme);
|
||||
tester.expectNotToSeePageName(gettingStated);
|
||||
|
||||
// delete the page permanently
|
||||
await tester.tapDeletePermanentlyButton();
|
||||
|
||||
// the banner should be gone and the readme page should be gone
|
||||
tester.expectNotToSeeDocumentBanner();
|
||||
tester.expectNotToSeePageName(readme);
|
||||
tester.expectNotToSeePageName(gettingStated);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -73,13 +73,13 @@ Future<void> insertReferenceDatabase(
|
||||
final id = uuid();
|
||||
final name = '${layout.name}_$id';
|
||||
await tester.createNewPageWithName(
|
||||
layout,
|
||||
name,
|
||||
name: name,
|
||||
layout: layout,
|
||||
);
|
||||
// create a new document
|
||||
await tester.createNewPageWithName(
|
||||
ViewLayoutPB.Document,
|
||||
'insert_a_reference_${layout.name}',
|
||||
name: 'insert_a_reference_${layout.name}',
|
||||
layout: ViewLayoutPB.Document,
|
||||
);
|
||||
// tap the first line of the document
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
|
@ -21,8 +21,8 @@ void main() {
|
||||
|
||||
// create a new document
|
||||
await tester.createNewPageWithName(
|
||||
ViewLayoutPB.Document,
|
||||
LocaleKeys.document_plugins_createInlineMathEquation.tr(),
|
||||
name: LocaleKeys.document_plugins_createInlineMathEquation.tr(),
|
||||
layout: ViewLayoutPB.Document,
|
||||
);
|
||||
|
||||
// tap the first line of the document
|
||||
@ -67,8 +67,8 @@ void main() {
|
||||
|
||||
// create a new document
|
||||
await tester.createNewPageWithName(
|
||||
ViewLayoutPB.Document,
|
||||
LocaleKeys.document_plugins_createInlineMathEquation.tr(),
|
||||
name: LocaleKeys.document_plugins_createInlineMathEquation.tr(),
|
||||
layout: ViewLayoutPB.Document,
|
||||
);
|
||||
|
||||
// tap the first line of the document
|
||||
|
@ -62,9 +62,11 @@ void main() {
|
||||
final pageName = await insertingInlinePage(tester, ViewLayoutPB.Document);
|
||||
|
||||
// rename
|
||||
await tester.hoverOnPageName(pageName);
|
||||
const newName = 'RenameToNewPageName';
|
||||
await tester.renamePage(newName);
|
||||
await tester.hoverOnPageName(
|
||||
pageName,
|
||||
onHover: () async => await tester.renamePage(newName),
|
||||
);
|
||||
final finder = find.descendant(
|
||||
of: find.byType(MentionPageBlock),
|
||||
matching: find.findTextInFlowyText(newName),
|
||||
@ -79,8 +81,11 @@ void main() {
|
||||
final pageName = await insertingInlinePage(tester, ViewLayoutPB.Grid);
|
||||
|
||||
// rename
|
||||
await tester.hoverOnPageName(pageName);
|
||||
await tester.tapDeletePageButton();
|
||||
await tester.hoverOnPageName(
|
||||
pageName,
|
||||
layout: ViewLayoutPB.Grid,
|
||||
onHover: () async => await tester.tapDeletePageButton(),
|
||||
);
|
||||
final finder = find.descendant(
|
||||
of: find.byType(MentionPageBlock),
|
||||
matching: find.findTextInFlowyText(pageName),
|
||||
@ -101,13 +106,14 @@ Future<String> insertingInlinePage(
|
||||
final id = uuid();
|
||||
final name = '${layout.name}_$id';
|
||||
await tester.createNewPageWithName(
|
||||
layout,
|
||||
name,
|
||||
name: name,
|
||||
layout: layout,
|
||||
openAfterCreated: false,
|
||||
);
|
||||
// create a new document
|
||||
await tester.createNewPageWithName(
|
||||
ViewLayoutPB.Document,
|
||||
'insert_a_inline_page_${layout.name}',
|
||||
name: 'insert_a_inline_page_${layout.name}',
|
||||
layout: ViewLayoutPB.Document,
|
||||
);
|
||||
// tap the first line of the document
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
|
@ -25,7 +25,7 @@ void main() {
|
||||
|
||||
// create a new document
|
||||
await tester.createNewPageWithName(
|
||||
ViewLayoutPB.Document,
|
||||
layout: ViewLayoutPB.Document,
|
||||
);
|
||||
|
||||
// tap the first line of the document
|
||||
|
@ -16,8 +16,8 @@ void main() {
|
||||
await tester.tapGoButton();
|
||||
|
||||
await tester.createNewPageWithName(
|
||||
ViewLayoutPB.Document,
|
||||
'outline_test',
|
||||
name: 'outline_test',
|
||||
layout: ViewLayoutPB.Document,
|
||||
);
|
||||
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
@ -33,8 +33,8 @@ void main() {
|
||||
await tester.tapGoButton();
|
||||
|
||||
await tester.createNewPageWithName(
|
||||
ViewLayoutPB.Document,
|
||||
'outline_test',
|
||||
name: 'outline_test',
|
||||
layout: ViewLayoutPB.Document,
|
||||
);
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
|
||||
|
@ -32,7 +32,7 @@ void main() {
|
||||
|
||||
// create a new document
|
||||
await tester.createNewPageWithName(
|
||||
ViewLayoutPB.Document,
|
||||
layout: ViewLayoutPB.Document,
|
||||
);
|
||||
|
||||
// tap the first line of the document
|
||||
@ -78,7 +78,7 @@ void main() {
|
||||
|
||||
// create a new document
|
||||
await tester.createNewPageWithName(
|
||||
ViewLayoutPB.Document,
|
||||
layout: ViewLayoutPB.Document,
|
||||
);
|
||||
|
||||
// tap the first line of the document
|
||||
@ -118,7 +118,7 @@ void main() {
|
||||
|
||||
// create a new document
|
||||
await tester.createNewPageWithName(
|
||||
ViewLayoutPB.Document,
|
||||
layout: ViewLayoutPB.Document,
|
||||
);
|
||||
|
||||
// tap the first line of the document
|
||||
@ -156,7 +156,7 @@ void main() {
|
||||
|
||||
// create a new document
|
||||
await tester.createNewPageWithName(
|
||||
ViewLayoutPB.Document,
|
||||
layout: ViewLayoutPB.Document,
|
||||
);
|
||||
|
||||
// tap the first line of the document
|
||||
@ -191,7 +191,7 @@ void main() {
|
||||
|
||||
// create a new document
|
||||
await tester.createNewPageWithName(
|
||||
ViewLayoutPB.Document,
|
||||
layout: ViewLayoutPB.Document,
|
||||
);
|
||||
|
||||
// tap the first line of the document
|
||||
|
@ -18,7 +18,10 @@ void main() {
|
||||
|
||||
// create a new document called Sample
|
||||
const pageName = 'Sample';
|
||||
await tester.createNewPageWithName(ViewLayoutPB.Document, pageName);
|
||||
await tester.createNewPageWithName(
|
||||
name: pageName,
|
||||
layout: ViewLayoutPB.Document,
|
||||
);
|
||||
|
||||
// focus on the editor
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
@ -56,7 +59,7 @@ void main() {
|
||||
);
|
||||
|
||||
// switch to other page and switch back
|
||||
await tester.openPage(readme);
|
||||
await tester.openPage(gettingStated);
|
||||
await tester.openPage(pageName);
|
||||
|
||||
// the numbered list should be kept
|
||||
@ -72,7 +75,10 @@ void main() {
|
||||
|
||||
// create a new document called Sample
|
||||
const pageName = 'Sample';
|
||||
await tester.createNewPageWithName(ViewLayoutPB.Document, pageName);
|
||||
await tester.createNewPageWithName(
|
||||
name: pageName,
|
||||
layout: ViewLayoutPB.Document,
|
||||
);
|
||||
|
||||
// focus on the editor
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
@ -85,7 +91,7 @@ void main() {
|
||||
}
|
||||
|
||||
// switch to other page and switch back
|
||||
await tester.openPage(readme);
|
||||
await tester.openPage(gettingStated);
|
||||
await tester.openPage(pageName);
|
||||
|
||||
// this screenshots are different on different platform, so comment it out temporarily.
|
||||
|
@ -16,10 +16,10 @@ void main() {
|
||||
final context = await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
// expect to see a readme page
|
||||
tester.expectToSeePageName(readme);
|
||||
// expect to see a getting started page
|
||||
tester.expectToSeePageName(gettingStated);
|
||||
|
||||
await tester.tapAddButton();
|
||||
await tester.tapAddViewButton();
|
||||
await tester.tapImportButton();
|
||||
|
||||
final testFileNames = ['test1.md', 'test2.md'];
|
||||
|
@ -14,6 +14,7 @@ import 'document/document_test_runner.dart' as document_test_runner;
|
||||
import 'import_files_test.dart' as import_files_test;
|
||||
import 'share_markdown_test.dart' as share_markdown_test;
|
||||
import 'switch_folder_test.dart' as switch_folder_test;
|
||||
import 'sidebar/sidebar_test_runner.dart' as sidebar_test_runner;
|
||||
|
||||
/// The main task runner for all integration tests in AppFlowy.
|
||||
///
|
||||
@ -31,6 +32,9 @@ void main() {
|
||||
// Document integration tests
|
||||
document_test_runner.startTesting();
|
||||
|
||||
// Sidebar integration tests
|
||||
sidebar_test_runner.startTesting();
|
||||
|
||||
// Database integration tests
|
||||
database_cell_test.main();
|
||||
database_field_test.main();
|
||||
|
@ -16,7 +16,7 @@ void main() {
|
||||
await tester.tapGoButton();
|
||||
|
||||
// expect to see a readme page
|
||||
tester.expectToSeePageName(readme);
|
||||
tester.expectToSeePageName(gettingStated);
|
||||
|
||||
// mock the file picker
|
||||
final path = await mockSaveFilePath(
|
||||
@ -42,12 +42,16 @@ void main() {
|
||||
final context = await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
// expect to see a readme page
|
||||
tester.expectToSeePageName(readme);
|
||||
// expect to see a getting started page
|
||||
tester.expectToSeePageName(gettingStated);
|
||||
|
||||
// rename the document
|
||||
await tester.hoverOnPageName(readme);
|
||||
await tester.renamePage('example');
|
||||
await tester.hoverOnPageName(
|
||||
gettingStated,
|
||||
onHover: () async {
|
||||
await tester.renamePage('example');
|
||||
},
|
||||
);
|
||||
|
||||
final shareButton = find.byType(ShareActionList);
|
||||
final shareButtonState =
|
||||
|
@ -0,0 +1,142 @@
|
||||
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/grid_page.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/draggable_view_item.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import '../util/util.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('sidebar test', () {
|
||||
testWidgets('create a new page', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
// create a new page
|
||||
const name = 'Hello AppFlowy';
|
||||
await tester.tapNewPageButton();
|
||||
await tester.enterText(find.byType(TextFormField), name);
|
||||
await tester.tapOKButton();
|
||||
|
||||
// expect to see a new document
|
||||
tester.expectToSeePageName(
|
||||
name,
|
||||
);
|
||||
// and with one paragraph block
|
||||
expect(find.byType(TextBlockComponentWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('create a new document, grid, board and calendar',
|
||||
(tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
for (final layout in ViewLayoutPB.values) {
|
||||
// create a new page
|
||||
final name = 'AppFlowy_$layout';
|
||||
await tester.createNewPageWithName(
|
||||
name: name,
|
||||
layout: layout,
|
||||
);
|
||||
|
||||
// expect to see a new page
|
||||
tester.expectToSeePageName(
|
||||
name,
|
||||
layout: layout,
|
||||
);
|
||||
|
||||
switch (layout) {
|
||||
case ViewLayoutPB.Document:
|
||||
// and with one paragraph block
|
||||
expect(find.byType(TextBlockComponentWidget), findsOneWidget);
|
||||
break;
|
||||
case ViewLayoutPB.Grid:
|
||||
expect(find.byType(GridPage), findsOneWidget);
|
||||
break;
|
||||
case ViewLayoutPB.Board:
|
||||
expect(find.byType(BoardPage), findsOneWidget);
|
||||
break;
|
||||
case ViewLayoutPB.Calendar:
|
||||
expect(find.byType(CalendarPage), findsOneWidget);
|
||||
break;
|
||||
}
|
||||
|
||||
await tester.openPage(gettingStated);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('create some nested pages, and move them', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
final names = [1, 2, 3, 4].map((e) => 'document_$e').toList();
|
||||
for (var i = 0; i < names.length; i++) {
|
||||
final parentName = i == 0 ? gettingStated : names[i - 1];
|
||||
await tester.createNewPageWithName(
|
||||
name: names[i],
|
||||
parentName: parentName,
|
||||
layout: ViewLayoutPB.Document,
|
||||
);
|
||||
tester.expectToSeePageName(names[i], parentName: parentName);
|
||||
}
|
||||
|
||||
// move the document_3 to the getting started page
|
||||
await tester.movePageToOtherPage(
|
||||
name: names[3],
|
||||
parentName: gettingStated,
|
||||
layout: ViewLayoutPB.Document,
|
||||
parentLayout: ViewLayoutPB.Document,
|
||||
);
|
||||
final fromId = tester
|
||||
.widget<SingleInnerViewItem>(tester.findPageName(names[3]))
|
||||
.view
|
||||
.parentViewId;
|
||||
final toId = tester
|
||||
.widget<SingleInnerViewItem>(tester.findPageName(gettingStated))
|
||||
.view
|
||||
.id;
|
||||
expect(fromId, toId);
|
||||
|
||||
// move the document_2 before document_1
|
||||
await tester.movePageToOtherPage(
|
||||
name: names[2],
|
||||
parentName: gettingStated,
|
||||
layout: ViewLayoutPB.Document,
|
||||
parentLayout: ViewLayoutPB.Document,
|
||||
position: DraggableHoverPosition.bottom,
|
||||
);
|
||||
final childViews = tester
|
||||
.widget<SingleInnerViewItem>(tester.findPageName(gettingStated))
|
||||
.view
|
||||
.childViews;
|
||||
expect(
|
||||
childViews[0].id,
|
||||
tester
|
||||
.widget<SingleInnerViewItem>(tester.findPageName(names[2]))
|
||||
.view
|
||||
.id,
|
||||
);
|
||||
expect(
|
||||
childViews[1].id,
|
||||
tester
|
||||
.widget<SingleInnerViewItem>(tester.findPageName(names[0]))
|
||||
.view
|
||||
.id,
|
||||
);
|
||||
expect(
|
||||
childViews[2].id,
|
||||
tester
|
||||
.widget<SingleInnerViewItem>(tester.findPageName(names[3]))
|
||||
.view
|
||||
.id,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import 'sidebar_test.dart' as sidebar_test;
|
||||
|
||||
void startTesting() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Sidebar integration tests
|
||||
sidebar_test.main();
|
||||
}
|
@ -29,8 +29,14 @@ void main() {
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
await tester.createNewPageWithName(ViewLayoutPB.Calendar, _calendarName);
|
||||
await tester.createNewPageWithName(ViewLayoutPB.Document, _documentName);
|
||||
await tester.createNewPageWithName(
|
||||
name: _calendarName,
|
||||
layout: ViewLayoutPB.Calendar,
|
||||
);
|
||||
await tester.createNewPageWithName(
|
||||
name: _documentName,
|
||||
layout: ViewLayoutPB.Document,
|
||||
);
|
||||
|
||||
// Navigate current view to "Read me" document again
|
||||
await tester.tapButtonWithName(_readmeName);
|
||||
|
@ -91,7 +91,6 @@ extension AppFlowyTestBase on WidgetTester {
|
||||
warnIfMissed: warnIfMissed,
|
||||
);
|
||||
await pumpAndSettle(Duration(milliseconds: milliseconds));
|
||||
return;
|
||||
}
|
||||
|
||||
Future<void> tapButtonWithName(
|
||||
|
@ -1,10 +1,14 @@
|
||||
import 'dart:ui';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_new_page_button.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/draggable_view_item.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_add_button.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_more_action_button.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.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/menu/app/header/add_button.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/app/section/item.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/settings_language_view.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
|
||||
@ -24,35 +28,48 @@ extension CommonOperations on WidgetTester {
|
||||
}
|
||||
|
||||
/// Tap the + button on the home page.
|
||||
Future<void> tapAddButton() async {
|
||||
final addButton = find.byType(AddButton);
|
||||
await tapButton(addButton);
|
||||
Future<void> tapAddViewButton({
|
||||
String name = gettingStated,
|
||||
}) async {
|
||||
await hoverOnPageName(
|
||||
name,
|
||||
onHover: () async {
|
||||
final addButton = find.byType(ViewAddButton);
|
||||
await tapButton(addButton);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Tap the 'New Page' Button on the sidebar.
|
||||
Future<void> tapNewPageButton() async {
|
||||
final newPageButton = find.byType(SidebarNewPageButton);
|
||||
await tapButton(newPageButton);
|
||||
}
|
||||
|
||||
/// Tap the create document button.
|
||||
///
|
||||
/// Must call [tapAddButton] first.
|
||||
/// Must call [tapAddViewButton] first.
|
||||
Future<void> tapCreateDocumentButton() async {
|
||||
await tapButtonWithName(LocaleKeys.document_menuName.tr());
|
||||
}
|
||||
|
||||
/// Tap the create grid button.
|
||||
///
|
||||
/// Must call [tapAddButton] first.
|
||||
/// Must call [tapAddViewButton] first.
|
||||
Future<void> tapCreateGridButton() async {
|
||||
await tapButtonWithName(LocaleKeys.grid_menuName.tr());
|
||||
}
|
||||
|
||||
/// Tap the create grid button.
|
||||
///
|
||||
/// Must call [tapAddButton] first.
|
||||
/// Must call [tapAddViewButton] first.
|
||||
Future<void> tapCreateCalendarButton() async {
|
||||
await tapButtonWithName(LocaleKeys.calendar_menuName.tr());
|
||||
}
|
||||
|
||||
/// Tap the import button.
|
||||
///
|
||||
/// Must call [tapAddButton] first.
|
||||
/// Must call [tapAddViewButton] first.
|
||||
Future<void> tapImportButton() async {
|
||||
await tapButtonWithName(LocaleKeys.moreAction_import.tr());
|
||||
}
|
||||
@ -116,6 +133,7 @@ extension CommonOperations on WidgetTester {
|
||||
Finder finder, {
|
||||
Offset? offset,
|
||||
Future<void> Function()? onHover,
|
||||
bool removePointer = true,
|
||||
}) async {
|
||||
try {
|
||||
final gesture = await createGesture(kind: PointerDeviceKind.mouse);
|
||||
@ -133,19 +151,30 @@ extension CommonOperations on WidgetTester {
|
||||
/// Hover on the page name.
|
||||
Future<void> hoverOnPageName(
|
||||
String name, {
|
||||
ViewLayoutPB layout = ViewLayoutPB.Document,
|
||||
Future<void> Function()? onHover,
|
||||
bool useLast = true,
|
||||
}) async {
|
||||
final pageNames = findPageName(name, layout: layout);
|
||||
if (useLast) {
|
||||
await hoverOnWidget(findPageName(name).last, onHover: onHover);
|
||||
await hoverOnWidget(
|
||||
pageNames.last,
|
||||
onHover: onHover,
|
||||
);
|
||||
} else {
|
||||
await hoverOnWidget(findPageName(name).first, onHover: onHover);
|
||||
await hoverOnWidget(
|
||||
pageNames.first,
|
||||
onHover: onHover,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// open the page with given name.
|
||||
Future<void> openPage(String name) async {
|
||||
final finder = findPageName(name);
|
||||
Future<void> openPage(
|
||||
String name, {
|
||||
ViewLayoutPB layout = ViewLayoutPB.Document,
|
||||
}) async {
|
||||
final finder = findPageName(name, layout: layout);
|
||||
expect(finder, findsOneWidget);
|
||||
await tapButton(finder);
|
||||
}
|
||||
@ -154,20 +183,20 @@ extension CommonOperations on WidgetTester {
|
||||
///
|
||||
/// Must call [hoverOnPageName] first.
|
||||
Future<void> tapPageOptionButton() async {
|
||||
final optionButton = find.byType(ViewDisclosureButton);
|
||||
final optionButton = find.byType(ViewMoreActionButton);
|
||||
await tapButton(optionButton);
|
||||
}
|
||||
|
||||
/// Tap the delete page button.
|
||||
Future<void> tapDeletePageButton() async {
|
||||
await tapPageOptionButton();
|
||||
await tapButtonWithName(ViewDisclosureAction.delete.name);
|
||||
await tapButtonWithName(ViewMoreActionType.delete.name);
|
||||
}
|
||||
|
||||
/// Tap the rename page button.
|
||||
Future<void> tapRenamePageButton() async {
|
||||
await tapPageOptionButton();
|
||||
await tapButtonWithName(ViewDisclosureAction.rename.name);
|
||||
await tapButtonWithName(ViewMoreActionType.rename.name);
|
||||
}
|
||||
|
||||
/// Rename the page.
|
||||
@ -224,12 +253,14 @@ extension CommonOperations on WidgetTester {
|
||||
await tapButton(markdownButton);
|
||||
}
|
||||
|
||||
Future<void> createNewPageWithName(
|
||||
ViewLayoutPB layout, [
|
||||
Future<void> createNewPageWithName({
|
||||
String? name,
|
||||
]) async {
|
||||
ViewLayoutPB layout = ViewLayoutPB.Document,
|
||||
String? parentName,
|
||||
bool openAfterCreated = true,
|
||||
}) async {
|
||||
// create a new page
|
||||
await tapAddButton();
|
||||
await tapAddViewButton(name: parentName ?? gettingStated);
|
||||
await tapButtonWithName(layout.menuName);
|
||||
await pumpAndSettle();
|
||||
|
||||
@ -237,6 +268,7 @@ extension CommonOperations on WidgetTester {
|
||||
if (name != null) {
|
||||
await hoverOnPageName(
|
||||
LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
|
||||
layout: layout,
|
||||
onHover: () async {
|
||||
await renamePage(name);
|
||||
await pumpAndSettle();
|
||||
@ -244,6 +276,16 @@ extension CommonOperations on WidgetTester {
|
||||
);
|
||||
await pumpAndSettle();
|
||||
}
|
||||
|
||||
// open the page after created
|
||||
if (openAfterCreated) {
|
||||
await openPage(
|
||||
// if the name is null, use the default name
|
||||
name ?? LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
|
||||
layout: layout,
|
||||
);
|
||||
await pumpAndSettle();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> simulateKeyEvent(
|
||||
@ -289,6 +331,34 @@ extension CommonOperations on WidgetTester {
|
||||
await tap(find.text(LocaleKeys.disclosureAction_openNewTab.tr()));
|
||||
await pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> movePageToOtherPage({
|
||||
required String name,
|
||||
required String parentName,
|
||||
required ViewLayoutPB layout,
|
||||
required ViewLayoutPB parentLayout,
|
||||
DraggableHoverPosition position = DraggableHoverPosition.center,
|
||||
}) async {
|
||||
final from = findPageName(name, layout: layout);
|
||||
final to = findPageName(parentName, layout: parentLayout);
|
||||
final gesture = await startGesture(getCenter(from));
|
||||
Offset offset = Offset.zero;
|
||||
switch (position) {
|
||||
case DraggableHoverPosition.center:
|
||||
offset = getCenter(to);
|
||||
break;
|
||||
case DraggableHoverPosition.top:
|
||||
offset = getTopLeft(to);
|
||||
break;
|
||||
case DraggableHoverPosition.bottom:
|
||||
offset = getBottomLeft(to);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
await gesture.moveTo(offset);
|
||||
await gesture.up();
|
||||
await pumpAndSettle();
|
||||
}
|
||||
}
|
||||
|
||||
extension ViewLayoutPBTest on ViewLayoutPB {
|
||||
|
@ -76,9 +76,9 @@ extension AppFlowyDatabaseTest on WidgetTester {
|
||||
await tapGoButton();
|
||||
|
||||
// expect to see a readme page
|
||||
expectToSeePageName(readme);
|
||||
expectToSeePageName(gettingStated);
|
||||
|
||||
await tapAddButton();
|
||||
await tapAddViewButton();
|
||||
await tapImportButton();
|
||||
|
||||
final testFileNames = ['v020.afdb'];
|
||||
@ -102,7 +102,8 @@ extension AppFlowyDatabaseTest on WidgetTester {
|
||||
paths: paths,
|
||||
);
|
||||
await tapDatabaseRawDataButton();
|
||||
await openPage('v020');
|
||||
await pumpAndSettle();
|
||||
await openPage('v020', layout: ViewLayoutPB.Grid);
|
||||
}
|
||||
|
||||
Future<void> hoverOnFirstRowOfGrid() async {
|
||||
|
@ -3,29 +3,51 @@ import 'package:appflowy/plugins/document/presentation/banner.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/app/section/item.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.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:flutter_test/flutter_test.dart';
|
||||
|
||||
const String readme = 'Read me';
|
||||
// const String readme = 'Read me';
|
||||
const String gettingStated = '⭐️ Getting started';
|
||||
|
||||
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), findsWidgets);
|
||||
expect(find.textContaining(gettingStated), findsWidgets);
|
||||
}
|
||||
|
||||
/// Expect to see the page name on the home page.
|
||||
void expectToSeePageName(String name) {
|
||||
final pageName = findPageName(name);
|
||||
void expectToSeePageName(
|
||||
String name, {
|
||||
String? parentName,
|
||||
ViewLayoutPB layout = ViewLayoutPB.Document,
|
||||
ViewLayoutPB parentLayout = ViewLayoutPB.Document,
|
||||
}) {
|
||||
final pageName = findPageName(
|
||||
name,
|
||||
layout: layout,
|
||||
parentName: parentName,
|
||||
parentLayout: parentLayout,
|
||||
);
|
||||
expect(pageName, findsOneWidget);
|
||||
}
|
||||
|
||||
/// Expect not to see the page name on the home page.
|
||||
void expectNotToSeePageName(String name) {
|
||||
final pageName = findPageName(name);
|
||||
void expectNotToSeePageName(
|
||||
String name, {
|
||||
String? parentName,
|
||||
ViewLayoutPB layout = ViewLayoutPB.Document,
|
||||
ViewLayoutPB parentLayout = ViewLayoutPB.Document,
|
||||
}) {
|
||||
final pageName = findPageName(
|
||||
name,
|
||||
layout: layout,
|
||||
parentName: parentName,
|
||||
parentLayout: parentLayout,
|
||||
);
|
||||
expect(pageName, findsNothing);
|
||||
}
|
||||
|
||||
@ -126,10 +148,31 @@ extension Expectation on WidgetTester {
|
||||
}
|
||||
|
||||
/// Find the page name on the home page.
|
||||
Finder findPageName(String name) {
|
||||
return find.byWidgetPredicate(
|
||||
(widget) => widget is ViewSectionItem && widget.view.name == name,
|
||||
skipOffstage: false,
|
||||
Finder findPageName(
|
||||
String name, {
|
||||
ViewLayoutPB layout = ViewLayoutPB.Document,
|
||||
String? parentName,
|
||||
ViewLayoutPB parentLayout = ViewLayoutPB.Document,
|
||||
}) {
|
||||
if (parentName == null) {
|
||||
return find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is SingleInnerViewItem &&
|
||||
widget.view.name == name &&
|
||||
widget.view.layout == layout,
|
||||
skipOffstage: false,
|
||||
);
|
||||
}
|
||||
|
||||
return find.descendant(
|
||||
of: find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is ViewItem &&
|
||||
widget.view.name == name &&
|
||||
widget.view.layout == layout,
|
||||
skipOffstage: false,
|
||||
),
|
||||
matching: findPageName(name),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -29,4 +29,10 @@ class KVKeys {
|
||||
'kDocumentAppearanceFontSize';
|
||||
static const String kDocumentAppearanceFontFamily =
|
||||
'kDocumentAppearanceFontFamily';
|
||||
|
||||
/// The key for saving the expanded views
|
||||
///
|
||||
/// The value is a json string with the following format:
|
||||
/// {'viewId': true, 'viewId2': false}
|
||||
static const String expandedViews = 'expandedViews';
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
|
||||
import 'package:appflowy/plugins/trash/application/trash_service.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/prelude.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/menu.dart';
|
||||
@ -109,6 +110,11 @@ class _MentionPageBlockState extends State<MentionPageBlock> {
|
||||
return;
|
||||
}
|
||||
getIt<MenuSharedState>().latestOpenView = view;
|
||||
getIt<TabsBloc>().add(
|
||||
TabsEvent.openPlugin(
|
||||
plugin: view.plugin(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<ViewPB?> fetchView(String pageId) async {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'package:appflowy/startup/plugin/plugin.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/application/workspace/workspace_listener.dart';
|
||||
import 'package:appflowy/workspace/application/workspace/workspace_service.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
|
||||
@ -43,7 +44,7 @@ class MenuBloc extends Bloc<MenuEvent, MenuState> {
|
||||
desc: event.desc ?? "",
|
||||
);
|
||||
result.fold(
|
||||
(app) => {},
|
||||
(app) => emit(state.copyWith(plugin: app.plugin())),
|
||||
(error) {
|
||||
Log.error(error);
|
||||
emit(state.copyWith(successOrFailure: right(error)));
|
||||
|
@ -1,3 +1,8 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:appflowy/core/config/kv.dart';
|
||||
import 'package:appflowy/core/config/kv_keys.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_listener.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_service.dart';
|
||||
import 'package:dartz/dartz.dart';
|
||||
@ -20,21 +25,34 @@ class ViewBloc extends Bloc<ViewEvent, ViewState> {
|
||||
super(ViewState.init(view)) {
|
||||
on<ViewEvent>((event, emit) async {
|
||||
await event.map(
|
||||
initial: (e) {
|
||||
initial: (e) async {
|
||||
listener.start(
|
||||
onViewUpdated: (result) {
|
||||
add(ViewEvent.viewDidUpdate(left(result)));
|
||||
},
|
||||
);
|
||||
emit(state);
|
||||
final isExpanded = await _getViewIsExpanded(view);
|
||||
await _loadViewsWhenExpanded(emit, isExpanded);
|
||||
},
|
||||
setIsEditing: (e) {
|
||||
emit(state.copyWith(isEditing: e.isEditing));
|
||||
},
|
||||
setIsExpanded: (e) async {
|
||||
if (e.isExpanded) {
|
||||
await _loadViewsWhenExpanded(emit, true);
|
||||
} else {
|
||||
emit(state.copyWith(isExpanded: e.isExpanded));
|
||||
}
|
||||
await _setViewIsExpanded(view, e.isExpanded);
|
||||
},
|
||||
viewDidUpdate: (e) {
|
||||
e.result.fold(
|
||||
(view) => emit(
|
||||
state.copyWith(view: view, successOrFailure: left(unit)),
|
||||
state.copyWith(
|
||||
view: view,
|
||||
childViews: view.childViews,
|
||||
successOrFailure: left(unit),
|
||||
),
|
||||
),
|
||||
(error) => emit(
|
||||
state.copyWith(successOrFailure: right(error)),
|
||||
@ -71,6 +89,36 @@ class ViewBloc extends Bloc<ViewEvent, ViewState> {
|
||||
),
|
||||
);
|
||||
},
|
||||
move: (value) async {
|
||||
final result = await ViewBackendService.moveViewV2(
|
||||
viewId: value.from.id,
|
||||
newParentId: value.newParentId,
|
||||
prevViewId: value.prevId,
|
||||
);
|
||||
emit(
|
||||
result.fold(
|
||||
(l) => state.copyWith(successOrFailure: left(unit)),
|
||||
(error) => state.copyWith(successOrFailure: right(error)),
|
||||
),
|
||||
);
|
||||
},
|
||||
createView: (e) async {
|
||||
final result = await ViewBackendService.createView(
|
||||
parentViewId: view.id,
|
||||
name: e.name,
|
||||
desc: '',
|
||||
layoutType: e.layoutType,
|
||||
initialDataBytes: null,
|
||||
ext: {},
|
||||
openAfterCreate: e.openAfterCreated,
|
||||
);
|
||||
emit(
|
||||
result.fold(
|
||||
(l) => state.copyWith(successOrFailure: left(unit)),
|
||||
(error) => state.copyWith(successOrFailure: right(error)),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -80,15 +128,84 @@ class ViewBloc extends Bloc<ViewEvent, ViewState> {
|
||||
await listener.stop();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
Future<void> _loadViewsWhenExpanded(
|
||||
Emitter<ViewState> emit,
|
||||
bool isExpanded,
|
||||
) async {
|
||||
if (!isExpanded) {
|
||||
return;
|
||||
}
|
||||
if (state.childViews.isNotEmpty) {
|
||||
// notify the old child views
|
||||
emit(
|
||||
state.copyWith(
|
||||
childViews: state.childViews,
|
||||
isExpanded: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
final viewsOrFailed =
|
||||
await ViewBackendService.getChildViews(viewId: state.view.id);
|
||||
viewsOrFailed.fold(
|
||||
(childViews) => emit(
|
||||
state.copyWith(
|
||||
childViews: childViews,
|
||||
isExpanded: true,
|
||||
),
|
||||
),
|
||||
(error) => emit(
|
||||
state.copyWith(
|
||||
successOrFailure: right(error),
|
||||
isExpanded: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _setViewIsExpanded(ViewPB view, bool isExpanded) async {
|
||||
final result = await getIt<KeyValueStorage>().get(KVKeys.expandedViews);
|
||||
final map = result.fold(
|
||||
(l) => {},
|
||||
(r) => jsonDecode(r),
|
||||
);
|
||||
if (isExpanded) {
|
||||
map[view.id] = true;
|
||||
} else {
|
||||
map.remove(view.id);
|
||||
}
|
||||
await getIt<KeyValueStorage>().set(KVKeys.expandedViews, jsonEncode(map));
|
||||
}
|
||||
|
||||
Future<bool> _getViewIsExpanded(ViewPB view) {
|
||||
return getIt<KeyValueStorage>().get(KVKeys.expandedViews).then((result) {
|
||||
return result.fold((l) => false, (r) {
|
||||
final map = jsonDecode(r);
|
||||
return map[view.id] ?? false;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ViewEvent with _$ViewEvent {
|
||||
const factory ViewEvent.initial() = Initial;
|
||||
const factory ViewEvent.setIsEditing(bool isEditing) = SetEditing;
|
||||
const factory ViewEvent.setIsExpanded(bool isExpanded) = SetIsExpanded;
|
||||
const factory ViewEvent.rename(String newName) = Rename;
|
||||
const factory ViewEvent.delete() = Delete;
|
||||
const factory ViewEvent.duplicate() = Duplicate;
|
||||
const factory ViewEvent.move(
|
||||
ViewPB from,
|
||||
String newParentId,
|
||||
String? prevId,
|
||||
) = Move;
|
||||
const factory ViewEvent.createView(
|
||||
String name,
|
||||
ViewLayoutPB layoutType, {
|
||||
/// open the view after created
|
||||
@Default(true) bool openAfterCreated,
|
||||
}) = CreateView;
|
||||
const factory ViewEvent.viewDidUpdate(Either<ViewPB, FlowyError> result) =
|
||||
ViewDidUpdate;
|
||||
}
|
||||
@ -97,12 +214,16 @@ class ViewEvent with _$ViewEvent {
|
||||
class ViewState with _$ViewState {
|
||||
const factory ViewState({
|
||||
required ViewPB view,
|
||||
required List<ViewPB> childViews,
|
||||
required bool isEditing,
|
||||
required bool isExpanded,
|
||||
required Either<Unit, FlowyError> successOrFailure,
|
||||
}) = _ViewState;
|
||||
|
||||
factory ViewState.init(ViewPB view) => ViewState(
|
||||
view: view,
|
||||
childViews: view.childViews,
|
||||
isExpanded: false,
|
||||
isEditing: false,
|
||||
successOrFailure: left(unit),
|
||||
);
|
||||
|
@ -40,11 +40,23 @@ extension FlowyPluginExtension on FlowyPlugin {
|
||||
extension ViewExtension on ViewPB {
|
||||
Widget renderThumbnail({Color? iconColor}) {
|
||||
const String thumbnail = "file_icon";
|
||||
|
||||
const Widget widget = FlowySvg(name: thumbnail);
|
||||
return widget;
|
||||
}
|
||||
|
||||
Widget icon() {
|
||||
final iconName = switch (layout) {
|
||||
ViewLayoutPB.Board => 'editor/board',
|
||||
ViewLayoutPB.Calendar => 'editor/calendar',
|
||||
ViewLayoutPB.Grid => 'editor/grid',
|
||||
ViewLayoutPB.Document => 'editor/documents',
|
||||
_ => 'file_icon',
|
||||
};
|
||||
return FlowySvg(
|
||||
name: iconName,
|
||||
);
|
||||
}
|
||||
|
||||
PluginType get pluginType {
|
||||
switch (layout) {
|
||||
case ViewLayoutPB.Board:
|
||||
|
@ -141,6 +141,7 @@ class ViewBackendService {
|
||||
return FolderEventUpdateView(payload).send();
|
||||
}
|
||||
|
||||
// deprecated
|
||||
static Future<Either<Unit, FlowyError>> moveView({
|
||||
required String viewId,
|
||||
required int fromIndex,
|
||||
@ -154,6 +155,24 @@ class ViewBackendService {
|
||||
return FolderEventMoveView(payload).send();
|
||||
}
|
||||
|
||||
/// Move the view to the new parent view.
|
||||
///
|
||||
/// supports nested view
|
||||
/// if the [prevViewId] is null, the view will be moved to the beginning of the list
|
||||
static Future<Either<Unit, FlowyError>> moveViewV2({
|
||||
required String viewId,
|
||||
required String newParentId,
|
||||
required String? prevViewId,
|
||||
}) {
|
||||
final payload = MoveNestedViewPayloadPB(
|
||||
viewId: viewId,
|
||||
newParentId: newParentId,
|
||||
prevViewId: prevViewId,
|
||||
);
|
||||
|
||||
return FolderEventMoveNestedView(payload).send();
|
||||
}
|
||||
|
||||
Future<List<(ViewPB, List<ViewPB>)>> fetchViewsWithLayoutType(
|
||||
ViewLayoutPB? layoutType,
|
||||
) async {
|
||||
|
@ -8,6 +8,7 @@ import 'package:appflowy/workspace/application/home/home_setting_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/hotkeys.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/edit_panel/panel_animation.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/float_bubble/question_bubble.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
@ -23,7 +24,6 @@ import 'package:styled_widget/styled_widget.dart';
|
||||
import '../widgets/edit_panel/edit_panel.dart';
|
||||
import 'home_layout.dart';
|
||||
import 'home_stack.dart';
|
||||
import 'menu/menu.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
final UserProfilePB user;
|
||||
@ -118,7 +118,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
buildContext: context,
|
||||
),
|
||||
);
|
||||
final menu = _buildHomeMenu(
|
||||
final menu = _buildHomeSidebar(
|
||||
layout: layout,
|
||||
context: context,
|
||||
);
|
||||
@ -140,16 +140,15 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHomeMenu({
|
||||
Widget _buildHomeSidebar({
|
||||
required HomeLayout layout,
|
||||
required BuildContext context,
|
||||
}) {
|
||||
final workspaceSetting = widget.workspaceSetting;
|
||||
final homeMenu = HomeMenu(
|
||||
final homeMenu = HomeSideBar(
|
||||
user: widget.user,
|
||||
workspaceSetting: workspaceSetting,
|
||||
);
|
||||
|
||||
return FocusTraversalGroup(child: RepaintBoundary(child: homeMenu));
|
||||
}
|
||||
|
||||
|
@ -83,6 +83,7 @@ class ImportPanel extends StatelessWidget {
|
||||
e.toString(),
|
||||
fontSize: 15,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: Theme.of(context).colorScheme.tertiary,
|
||||
),
|
||||
onTap: () async {
|
||||
await _importFile(parentViewId, e);
|
||||
@ -157,6 +158,8 @@ class ImportPanel extends StatelessWidget {
|
||||
assert(false, 'Unsupported Type $importType');
|
||||
}
|
||||
}
|
||||
|
||||
importCallback(importType, '', null);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -43,6 +43,7 @@ enum ImportType {
|
||||
}
|
||||
return FlowySvg(
|
||||
name: name,
|
||||
color: Theme.of(context).colorScheme.tertiary,
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -3,13 +3,12 @@ import 'package:appflowy/workspace/application/app/app_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/menu/menu_view_section_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/app/section/item.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/menu.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:reorderables/reorderables.dart';
|
||||
|
||||
import 'item.dart';
|
||||
|
||||
class ViewSection extends StatelessWidget {
|
||||
final ViewDataContext appViewData;
|
||||
const ViewSection({Key? key, required this.appViewData}) : super(key: key);
|
||||
|
@ -0,0 +1,114 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/menu/menu_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/menu.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/image.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class PersonalFolder extends StatefulWidget {
|
||||
const PersonalFolder({
|
||||
super.key,
|
||||
required this.views,
|
||||
});
|
||||
|
||||
final List<ViewPB> views;
|
||||
|
||||
@override
|
||||
State<PersonalFolder> createState() => _PersonalFolderState();
|
||||
}
|
||||
|
||||
class _PersonalFolderState extends State<PersonalFolder> {
|
||||
bool isExpanded = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
PersonalFolderHeader(
|
||||
onPressed: () => setState(
|
||||
() => isExpanded = !isExpanded,
|
||||
),
|
||||
onAdded: () => setState(() => isExpanded = true),
|
||||
),
|
||||
if (isExpanded)
|
||||
...widget.views.map(
|
||||
(view) => ViewItem(
|
||||
key: ValueKey(view.id),
|
||||
isFirstChild: view.id == widget.views.first.id,
|
||||
view: view,
|
||||
level: 0,
|
||||
onSelected: (view) {
|
||||
getIt<MenuSharedState>().latestOpenView = view;
|
||||
context.read<MenuBloc>().add(MenuEvent.openPage(view.plugin()));
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PersonalFolderHeader extends StatefulWidget {
|
||||
const PersonalFolderHeader({
|
||||
super.key,
|
||||
required this.onPressed,
|
||||
required this.onAdded,
|
||||
});
|
||||
|
||||
final VoidCallback onPressed;
|
||||
final VoidCallback onAdded;
|
||||
|
||||
@override
|
||||
State<PersonalFolderHeader> createState() => _PersonalFolderHeaderState();
|
||||
}
|
||||
|
||||
class _PersonalFolderHeaderState extends State<PersonalFolderHeader> {
|
||||
bool onHover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const iconSize = 26.0;
|
||||
return MouseRegion(
|
||||
onEnter: (event) => setState(() => onHover = true),
|
||||
onExit: (event) => setState(() => onHover = false),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
FlowyTextButton(
|
||||
LocaleKeys.sideBar_personal.tr(),
|
||||
tooltip: LocaleKeys.sideBar_clickToHidePersonal.tr(),
|
||||
constraints: const BoxConstraints(maxHeight: iconSize),
|
||||
padding: const EdgeInsets.all(4),
|
||||
fillColor: Colors.transparent,
|
||||
onPressed: widget.onPressed,
|
||||
),
|
||||
if (onHover) ...[
|
||||
const Spacer(),
|
||||
FlowyIconButton(
|
||||
tooltipText: LocaleKeys.sideBar_addAPage.tr(),
|
||||
hoverColor: Theme.of(context).colorScheme.secondaryContainer,
|
||||
iconPadding: const EdgeInsets.all(2),
|
||||
height: iconSize,
|
||||
width: iconSize,
|
||||
icon: const FlowySvg(name: 'editor/add'),
|
||||
onPressed: () {
|
||||
context.read<MenuBloc>().add(
|
||||
MenuEvent.createApp(
|
||||
LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
|
||||
),
|
||||
);
|
||||
widget.onAdded();
|
||||
},
|
||||
),
|
||||
]
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,94 @@
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/menu/menu_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_folder.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_new_page_button.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_top_menu.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_trash.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_user.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/workspace.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'
|
||||
show UserProfilePB;
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
/// Home Sidebar is the left side bar of the home page.
|
||||
///
|
||||
/// in the sidebar, we have:
|
||||
/// - user icon, user name
|
||||
/// - settings
|
||||
/// - scrollable document list
|
||||
/// - trash
|
||||
class HomeSideBar extends StatelessWidget {
|
||||
const HomeSideBar({
|
||||
super.key,
|
||||
required this.user,
|
||||
required this.workspaceSetting,
|
||||
});
|
||||
|
||||
final UserProfilePB user;
|
||||
|
||||
final WorkspaceSettingPB workspaceSetting;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) => MenuBloc(
|
||||
user: user,
|
||||
workspace: workspaceSetting.workspace,
|
||||
)..add(const MenuEvent.initial()),
|
||||
child: BlocConsumer<MenuBloc, MenuState>(
|
||||
builder: (context, state) => _buildSidebar(context, state),
|
||||
listenWhen: (p, c) => p.plugin.id != c.plugin.id,
|
||||
listener: (context, state) => getIt<TabsBloc>().add(
|
||||
TabsEvent.openPlugin(plugin: state.plugin),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSidebar(BuildContext context, MenuState state) {
|
||||
final views = state.views;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant,
|
||||
border: Border(
|
||||
right: BorderSide(color: Theme.of(context).dividerColor),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
// top menu
|
||||
const SidebarTopMenu(),
|
||||
// user, setting
|
||||
SidebarUser(user: user),
|
||||
// Favorite, Not supported yet
|
||||
const VSpace(20),
|
||||
// scrollable document list
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: SidebarFolder(
|
||||
views: views,
|
||||
),
|
||||
),
|
||||
),
|
||||
const VSpace(10),
|
||||
// trash
|
||||
const SidebarTrashButton(),
|
||||
const VSpace(10),
|
||||
// new page button
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 6.0),
|
||||
child: SidebarNewPageButton(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/folder/personal_folder.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SidebarFolder extends StatelessWidget {
|
||||
const SidebarFolder({
|
||||
super.key,
|
||||
required this.views,
|
||||
});
|
||||
|
||||
final List<ViewPB> views;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
// personal
|
||||
PersonalFolder(views: views),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,56 @@
|
||||
import 'package:appflowy/workspace/application/menu/menu_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/image.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/button.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/extension.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class SidebarNewPageButton extends StatelessWidget {
|
||||
const SidebarNewPageButton({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final child = FlowyTextButton(
|
||||
LocaleKeys.newPageText.tr(),
|
||||
fillColor: Colors.transparent,
|
||||
hoverColor: Colors.transparent,
|
||||
fontColor: Theme.of(context).colorScheme.tertiary,
|
||||
onPressed: () async => await _showCreatePageDialog(context),
|
||||
heading: Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
),
|
||||
child: svgWidget('home/new_app'),
|
||||
),
|
||||
padding: const EdgeInsets.all(0),
|
||||
);
|
||||
|
||||
return SizedBox(
|
||||
height: 60,
|
||||
child: TopBorder(
|
||||
color: Theme.of(context).dividerColor,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showCreatePageDialog(BuildContext context) async {
|
||||
return NavigatorTextFieldDialog(
|
||||
title: LocaleKeys.newPageText.tr(),
|
||||
value: '',
|
||||
confirm: (value) {
|
||||
if (value.isNotEmpty) {
|
||||
context.read<MenuBloc>().add(MenuEvent.createApp(value, desc: ''));
|
||||
}
|
||||
},
|
||||
).show(context);
|
||||
}
|
||||
}
|
@ -0,0 +1,85 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:appflowy/core/frameless_window.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/workspace/application/home/home_setting_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/menu/menu_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/image.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
/// Sidebar top menu is the top bar of the sidebar.
|
||||
///
|
||||
/// in the top menu, we have:
|
||||
/// - appflowy icon (Windows or Linux)
|
||||
/// - close / expand sidebar button
|
||||
class SidebarTopMenu extends StatelessWidget {
|
||||
const SidebarTopMenu({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<MenuBloc, MenuState>(
|
||||
builder: (context, state) {
|
||||
return SizedBox(
|
||||
height: HomeSizes.topBarHeight,
|
||||
child: MoveWindowDetector(
|
||||
child: Row(
|
||||
children: [
|
||||
_buildLogoIcon(context),
|
||||
const Spacer(),
|
||||
_buildCollapseMenuButton(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLogoIcon(BuildContext context) {
|
||||
if (Platform.isMacOS) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final name = Theme.of(context).brightness == Brightness.dark
|
||||
? 'flowy_logo_dark_mode'
|
||||
: 'flowy_logo_with_text';
|
||||
return svgWidget(
|
||||
name,
|
||||
size: const Size(92, 17),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCollapseMenuButton(BuildContext context) {
|
||||
final textSpan = TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '${LocaleKeys.sideBar_closeSidebar.tr()}\n',
|
||||
),
|
||||
TextSpan(
|
||||
// TODO(Lucas.Xu): it doesn't work on macOS.
|
||||
text: Platform.isMacOS ? '⌘+\\' : 'Ctrl+\\',
|
||||
),
|
||||
],
|
||||
);
|
||||
return Tooltip(
|
||||
richMessage: textSpan,
|
||||
child: FlowyIconButton(
|
||||
width: 28,
|
||||
hoverColor: Colors.transparent,
|
||||
onPressed: () => context
|
||||
.read<HomeSettingBloc>()
|
||||
.add(const HomeSettingEvent.collapseMenu()),
|
||||
iconPadding: const EdgeInsets.fromLTRB(4, 4, 4, 4),
|
||||
icon: const FlowySvg(
|
||||
name: 'home/hide_menu',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,63 @@
|
||||
import 'package:appflowy/startup/plugin/plugin.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/menu.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra/image.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
|
||||
class SidebarTrashButton extends StatelessWidget {
|
||||
const SidebarTrashButton({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: getIt<MenuSharedState>().notifier,
|
||||
builder: (context, value, child) {
|
||||
return FlowyHover(
|
||||
style: HoverStyle(
|
||||
hoverColor: AFThemeExtension.of(context).greySelect,
|
||||
),
|
||||
isSelected: () => getIt<MenuSharedState>().latestOpenView == null,
|
||||
child: SizedBox(
|
||||
height: 26,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
getIt<MenuSharedState>().latestOpenView = null;
|
||||
getIt<TabsBloc>().add(
|
||||
TabsEvent.openPlugin(
|
||||
plugin: makePlugin(pluginType: PluginType.trash),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: _buildTextButton(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextButton(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
const HSpace(6),
|
||||
const FlowySvg(
|
||||
size: Size(16, 16),
|
||||
name: 'home/trash',
|
||||
),
|
||||
const HSpace(6),
|
||||
FlowyText.medium(
|
||||
LocaleKeys.trash_text.tr(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,149 @@
|
||||
import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
|
||||
import 'package:appflowy/startup/entry_point.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/util/color_generator/color_generator.dart';
|
||||
import 'package:appflowy/workspace/application/menu/menu_user_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/settings_dialog.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/settings_user_view.dart';
|
||||
import 'package:flowy_infra/image.dart';
|
||||
import 'package:flowy_infra/size.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'
|
||||
show UserProfilePB;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
|
||||
class SidebarUser extends StatelessWidget {
|
||||
const SidebarUser({
|
||||
super.key,
|
||||
required this.user,
|
||||
});
|
||||
|
||||
final UserProfilePB user;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<MenuUserBloc>(
|
||||
create: (context) => getIt<MenuUserBloc>(param1: user)
|
||||
..add(
|
||||
const MenuUserEvent.initial(),
|
||||
),
|
||||
child: BlocBuilder<MenuUserBloc, MenuUserState>(
|
||||
builder: (context, state) => Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
_buildAvatar(context),
|
||||
const HSpace(10),
|
||||
Expanded(
|
||||
child: _buildUserName(context),
|
||||
),
|
||||
_buildSettingsButton(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatar(BuildContext context) {
|
||||
String iconUrl = context.read<MenuUserBloc>().state.userProfile.iconUrl;
|
||||
if (iconUrl.isEmpty) {
|
||||
iconUrl = defaultUserAvatar;
|
||||
final String name = _userName(
|
||||
context.read<MenuUserBloc>().state.userProfile,
|
||||
);
|
||||
final Color color = ColorGenerator().generateColorFromString(name);
|
||||
const initialsCount = 2;
|
||||
// Taking the first letters of the name components and limiting to 2 elements
|
||||
final nameInitials = name
|
||||
.split(' ')
|
||||
.where((element) => element.isNotEmpty)
|
||||
.take(initialsCount)
|
||||
.map((element) => element[0].toUpperCase())
|
||||
.join('');
|
||||
return Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: FlowyText.semibold(
|
||||
nameInitials,
|
||||
color: Colors.white,
|
||||
fontSize: nameInitials.length == initialsCount ? 12 : 14,
|
||||
),
|
||||
);
|
||||
}
|
||||
return SizedBox.square(
|
||||
dimension: 25,
|
||||
child: ClipRRect(
|
||||
borderRadius: Corners.s5Border,
|
||||
child: CircleAvatar(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: svgWidget('emoji/$iconUrl'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUserName(BuildContext context) {
|
||||
final String name = _userName(
|
||||
context.read<MenuUserBloc>().state.userProfile,
|
||||
);
|
||||
return FlowyText.medium(
|
||||
name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: Theme.of(context).colorScheme.tertiary,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSettingsButton(BuildContext context) {
|
||||
final userProfile = context.read<MenuUserBloc>().state.userProfile;
|
||||
return Tooltip(
|
||||
message: LocaleKeys.settings_menu_open.tr(),
|
||||
child: IconButton(
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return BlocProvider<DocumentAppearanceCubit>.value(
|
||||
value: BlocProvider.of<DocumentAppearanceCubit>(context),
|
||||
child: SettingsDialog(
|
||||
userProfile,
|
||||
didLogout: () async {
|
||||
Navigator.of(context).pop();
|
||||
await FlowyRunner.run(
|
||||
FlowyApp(),
|
||||
integrationEnv(),
|
||||
);
|
||||
},
|
||||
dismissDialog: () => Navigator.of(context).pop(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
icon: SizedBox.square(
|
||||
dimension: 20,
|
||||
child: svgWidget(
|
||||
'home/settings',
|
||||
color: Theme.of(context).colorScheme.tertiary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Return the user name, if the user name is empty, return the default user name.
|
||||
String _userName(UserProfilePB userProfile) {
|
||||
String name = userProfile.name;
|
||||
if (name.isEmpty) {
|
||||
name = LocaleKeys.defaultUsername.tr();
|
||||
}
|
||||
return name;
|
||||
}
|
||||
}
|
@ -0,0 +1,172 @@
|
||||
import 'package:appflowy/workspace/application/view/view_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/draggable_item/draggable_item.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
enum DraggableHoverPosition {
|
||||
none,
|
||||
top,
|
||||
center,
|
||||
bottom,
|
||||
}
|
||||
|
||||
class DraggableViewItem extends StatefulWidget {
|
||||
const DraggableViewItem({
|
||||
super.key,
|
||||
required this.view,
|
||||
this.feedback,
|
||||
required this.child,
|
||||
this.isFirstChild = false,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final WidgetBuilder? feedback;
|
||||
final ViewPB view;
|
||||
final bool isFirstChild;
|
||||
|
||||
@override
|
||||
State<DraggableViewItem> createState() => _DraggableViewItemState();
|
||||
}
|
||||
|
||||
class _DraggableViewItemState extends State<DraggableViewItem> {
|
||||
DraggableHoverPosition position = DraggableHoverPosition.none;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// add top border if the draggable item is on the top of the list
|
||||
// highlight the draggable item if the draggable item is on the center
|
||||
// add bottom border if the draggable item is on the bottom of the list
|
||||
final child = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// only show the top border when the draggable item is the first child
|
||||
if (widget.isFirstChild)
|
||||
Divider(
|
||||
height: 2,
|
||||
thickness: 2,
|
||||
color: position == DraggableHoverPosition.top
|
||||
? Theme.of(context).colorScheme.secondary
|
||||
: Colors.transparent,
|
||||
),
|
||||
Container(
|
||||
color: position == DraggableHoverPosition.center
|
||||
? Theme.of(context).colorScheme.secondary.withOpacity(0.5)
|
||||
: Colors.transparent,
|
||||
child: widget.child,
|
||||
),
|
||||
Divider(
|
||||
height: 2,
|
||||
thickness: 2,
|
||||
color: position == DraggableHoverPosition.bottom
|
||||
? Theme.of(context).colorScheme.secondary
|
||||
: Colors.transparent,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return DraggableItem<ViewPB>(
|
||||
data: widget.view,
|
||||
onWillAccept: (data) => true,
|
||||
onMove: (data) {
|
||||
if (!_shouldAccept(data.data)) {
|
||||
return;
|
||||
}
|
||||
final renderBox = context.findRenderObject() as RenderBox;
|
||||
final offset = renderBox.globalToLocal(data.offset);
|
||||
setState(() {
|
||||
position = _computeHoverPosition(offset, renderBox.size);
|
||||
Log.debug(
|
||||
'offset: $offset, position: $position, size: ${renderBox.size}',
|
||||
);
|
||||
});
|
||||
},
|
||||
onLeave: (_) => setState(
|
||||
() => position = DraggableHoverPosition.none,
|
||||
),
|
||||
onAccept: (data) {
|
||||
_move(data, widget.view);
|
||||
setState(
|
||||
() => position = DraggableHoverPosition.none,
|
||||
);
|
||||
},
|
||||
feedback: IntrinsicWidth(
|
||||
child: Opacity(
|
||||
opacity: 0.5,
|
||||
child: widget.feedback?.call(context) ?? child,
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
void _move(ViewPB from, ViewPB to) {
|
||||
switch (position) {
|
||||
case DraggableHoverPosition.top:
|
||||
context.read<ViewBloc>().add(
|
||||
ViewEvent.move(
|
||||
from,
|
||||
to.parentViewId,
|
||||
null,
|
||||
),
|
||||
);
|
||||
break;
|
||||
case DraggableHoverPosition.bottom:
|
||||
context.read<ViewBloc>().add(
|
||||
ViewEvent.move(
|
||||
from,
|
||||
to.parentViewId,
|
||||
to.id,
|
||||
),
|
||||
);
|
||||
break;
|
||||
case DraggableHoverPosition.center:
|
||||
context.read<ViewBloc>().add(
|
||||
ViewEvent.move(
|
||||
from,
|
||||
to.id,
|
||||
to.childViews.lastOrNull?.id,
|
||||
),
|
||||
);
|
||||
break;
|
||||
case DraggableHoverPosition.none:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
DraggableHoverPosition _computeHoverPosition(Offset offset, Size size) {
|
||||
final threshold = size.height / 4.0;
|
||||
if (widget.isFirstChild && offset.dy < -5.0) {
|
||||
return DraggableHoverPosition.top;
|
||||
}
|
||||
if (offset.dy > threshold) {
|
||||
return DraggableHoverPosition.bottom;
|
||||
}
|
||||
return DraggableHoverPosition.center;
|
||||
}
|
||||
|
||||
bool _shouldAccept(ViewPB data) {
|
||||
// ignore moving the view to itself
|
||||
if (data.id == widget.view.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ignore moving the view to its child view
|
||||
if (data.containsView(widget.view)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
extension on ViewPB {
|
||||
bool containsView(ViewPB view) {
|
||||
if (id == view.id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return childViews.any((v) => v.containsView(view));
|
||||
}
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum ViewMoreActionType {
|
||||
delete,
|
||||
addToFavorites, // not supported yet.
|
||||
duplicate,
|
||||
copyLink, // not supported yet.
|
||||
rename,
|
||||
moveTo, // not supported yet.
|
||||
openInNewTab,
|
||||
}
|
||||
|
||||
extension ViewMoreActionTypeExtension on ViewMoreActionType {
|
||||
String get name {
|
||||
switch (this) {
|
||||
case ViewMoreActionType.delete:
|
||||
return LocaleKeys.disclosureAction_delete.tr();
|
||||
case ViewMoreActionType.addToFavorites:
|
||||
return LocaleKeys.disclosureAction_addToFavorites.tr();
|
||||
case ViewMoreActionType.duplicate:
|
||||
return LocaleKeys.disclosureAction_duplicate.tr();
|
||||
case ViewMoreActionType.copyLink:
|
||||
return LocaleKeys.disclosureAction_copyLink.tr();
|
||||
case ViewMoreActionType.rename:
|
||||
return LocaleKeys.disclosureAction_rename.tr();
|
||||
case ViewMoreActionType.moveTo:
|
||||
return LocaleKeys.disclosureAction_moveTo.tr();
|
||||
case ViewMoreActionType.openInNewTab:
|
||||
return LocaleKeys.disclosureAction_openNewTab.tr();
|
||||
}
|
||||
}
|
||||
|
||||
Widget icon(Color iconColor) {
|
||||
switch (this) {
|
||||
case ViewMoreActionType.delete:
|
||||
return const FlowySvg(name: 'editor/delete');
|
||||
case ViewMoreActionType.addToFavorites:
|
||||
return const Icon(Icons.favorite);
|
||||
case ViewMoreActionType.duplicate:
|
||||
return const FlowySvg(name: 'editor/copy');
|
||||
case ViewMoreActionType.copyLink:
|
||||
return const Icon(Icons.copy);
|
||||
case ViewMoreActionType.rename:
|
||||
return const FlowySvg(name: 'editor/edit');
|
||||
case ViewMoreActionType.moveTo:
|
||||
return const Icon(Icons.move_to_inbox);
|
||||
case ViewMoreActionType.openInNewTab:
|
||||
return const FlowySvg(name: 'grid/expander');
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,130 @@
|
||||
import 'package:appflowy/plugins/document/document.dart';
|
||||
import 'package:appflowy/startup/plugin/plugin.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/app/header/import/import_panel.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:flowy_infra/image.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
|
||||
class ViewAddButton extends StatelessWidget {
|
||||
const ViewAddButton({
|
||||
super.key,
|
||||
required this.parentViewId,
|
||||
required this.onEditing,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final String parentViewId;
|
||||
final void Function(bool value) onEditing;
|
||||
final Function(
|
||||
PluginBuilder,
|
||||
String? name,
|
||||
List<int>? initialDataBytes,
|
||||
bool openAfterCreated,
|
||||
bool createNewView,
|
||||
) onSelected;
|
||||
|
||||
List<PopoverAction> get _actions {
|
||||
return [
|
||||
// document, grid, kanban, calendar
|
||||
...pluginBuilders().map(
|
||||
(pluginBuilder) => ViewAddButtonActionWrapper(
|
||||
pluginBuilder: pluginBuilder,
|
||||
),
|
||||
),
|
||||
// import from ...
|
||||
...getIt<PluginSandbox>().builders.whereType<DocumentPluginBuilder>().map(
|
||||
(pluginBuilder) => ViewImportActionWrapper(
|
||||
pluginBuilder: pluginBuilder,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopoverActionList<PopoverAction>(
|
||||
direction: PopoverDirection.bottomWithLeftAligned,
|
||||
actions: _actions,
|
||||
offset: const Offset(0, 8),
|
||||
buildChild: (popover) {
|
||||
return FlowyIconButton(
|
||||
hoverColor: Colors.transparent,
|
||||
iconPadding: const EdgeInsets.all(2),
|
||||
width: 26,
|
||||
icon: const FlowySvg(name: 'editor/add'),
|
||||
onPressed: () {
|
||||
onEditing(true);
|
||||
popover.show();
|
||||
},
|
||||
);
|
||||
},
|
||||
onSelected: (action, popover) {
|
||||
onEditing(false);
|
||||
if (action is ViewAddButtonActionWrapper) {
|
||||
_showViewAddButtonActions(context, action);
|
||||
} else if (action is ViewImportActionWrapper) {
|
||||
_showViewImportAction(context, action);
|
||||
}
|
||||
popover.close();
|
||||
},
|
||||
onClosed: () {
|
||||
onEditing(false);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showViewAddButtonActions(
|
||||
BuildContext context,
|
||||
ViewAddButtonActionWrapper action,
|
||||
) {
|
||||
onSelected(action.pluginBuilder, null, null, true, true);
|
||||
}
|
||||
|
||||
void _showViewImportAction(
|
||||
BuildContext context,
|
||||
ViewImportActionWrapper action,
|
||||
) {
|
||||
showImportPanel(
|
||||
parentViewId,
|
||||
context,
|
||||
(type, name, initialDataBytes) {
|
||||
onSelected(action.pluginBuilder, null, null, true, false);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ViewAddButtonActionWrapper extends ActionCell {
|
||||
ViewAddButtonActionWrapper({
|
||||
required this.pluginBuilder,
|
||||
});
|
||||
|
||||
final PluginBuilder pluginBuilder;
|
||||
|
||||
@override
|
||||
Widget? leftIcon(Color iconColor) => FlowySvg(name: pluginBuilder.menuIcon);
|
||||
|
||||
@override
|
||||
String get name => pluginBuilder.menuName;
|
||||
|
||||
PluginType get pluginType => pluginBuilder.pluginType;
|
||||
}
|
||||
|
||||
class ViewImportActionWrapper extends ActionCell {
|
||||
ViewImportActionWrapper({
|
||||
required this.pluginBuilder,
|
||||
});
|
||||
|
||||
final DocumentPluginBuilder pluginBuilder;
|
||||
|
||||
@override
|
||||
Widget? leftIcon(Color iconColor) => const FlowySvg(name: 'editor/import');
|
||||
|
||||
@override
|
||||
String get name => LocaleKeys.moreAction_import.tr();
|
||||
}
|
@ -0,0 +1,322 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/menu.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/draggable_view_item.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_add_button.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_more_action_button.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/image.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class ViewItem extends StatelessWidget {
|
||||
const ViewItem({
|
||||
super.key,
|
||||
required this.view,
|
||||
required this.level,
|
||||
this.leftPadding = 10,
|
||||
required this.onSelected,
|
||||
this.isFirstChild = false,
|
||||
this.isDraggable = true,
|
||||
});
|
||||
|
||||
final ViewPB view;
|
||||
|
||||
// indicate the level of the view item
|
||||
// used to calculate the left padding
|
||||
final int level;
|
||||
|
||||
// the left padding of the view item for each level
|
||||
// the left padding of the each level = level * leftPadding
|
||||
final double leftPadding;
|
||||
|
||||
final void Function(ViewPB) onSelected;
|
||||
|
||||
// used for indicating the first child of the parent view, so that we can
|
||||
// add top border to the first child
|
||||
final bool isFirstChild;
|
||||
|
||||
// it should be false when it's rendered as feedback widget inside DraggableItem
|
||||
final bool isDraggable;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) => ViewBloc(view: view)..add(const ViewEvent.initial()),
|
||||
child: BlocBuilder<ViewBloc, ViewState>(
|
||||
builder: (context, state) {
|
||||
view.childViews
|
||||
..clear()
|
||||
..addAll(state.childViews);
|
||||
return InnerViewItem(
|
||||
view: view,
|
||||
level: level,
|
||||
leftPadding: leftPadding,
|
||||
showActions: state.isEditing,
|
||||
isExpanded: state.isExpanded,
|
||||
onSelected: onSelected,
|
||||
isFirstChild: isFirstChild,
|
||||
isDraggable: isDraggable,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class InnerViewItem extends StatelessWidget {
|
||||
const InnerViewItem({
|
||||
super.key,
|
||||
required this.view,
|
||||
this.isDraggable = true,
|
||||
this.isExpanded = true,
|
||||
required this.level,
|
||||
this.leftPadding = 10,
|
||||
required this.showActions,
|
||||
required this.onSelected,
|
||||
this.isFirstChild = false,
|
||||
});
|
||||
|
||||
final ViewPB view;
|
||||
|
||||
final bool isDraggable;
|
||||
final bool isExpanded;
|
||||
final bool isFirstChild;
|
||||
|
||||
final int level;
|
||||
final double leftPadding;
|
||||
|
||||
final bool showActions;
|
||||
final void Function(ViewPB) onSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget child = SingleInnerViewItem(
|
||||
view: view,
|
||||
level: level,
|
||||
showActions: showActions,
|
||||
onSelected: onSelected,
|
||||
isExpanded: isExpanded,
|
||||
);
|
||||
|
||||
// if the view is expanded and has child views, render its child views
|
||||
final childViews = view.childViews;
|
||||
if (isExpanded && childViews.isNotEmpty) {
|
||||
final children = childViews.map((childView) {
|
||||
return ViewItem(
|
||||
key: ValueKey(childView.id),
|
||||
isFirstChild: childView.id == childViews.first.id,
|
||||
view: childView,
|
||||
level: level + 1,
|
||||
onSelected: onSelected,
|
||||
isDraggable: isDraggable,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
child = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
child,
|
||||
...children,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// wrap the child with DraggableItem if isDraggable is true
|
||||
if (isDraggable) {
|
||||
child = DraggableViewItem(
|
||||
isFirstChild: isFirstChild,
|
||||
view: view,
|
||||
child: child,
|
||||
feedback: (context) {
|
||||
return ViewItem(
|
||||
view: view,
|
||||
level: level,
|
||||
onSelected: onSelected,
|
||||
isDraggable: false,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
class SingleInnerViewItem extends StatefulWidget {
|
||||
const SingleInnerViewItem({
|
||||
super.key,
|
||||
required this.view,
|
||||
required this.isExpanded,
|
||||
required this.level,
|
||||
this.leftPadding = 10,
|
||||
required this.showActions,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final ViewPB view;
|
||||
final bool isExpanded;
|
||||
|
||||
final int level;
|
||||
final double leftPadding;
|
||||
|
||||
final bool showActions;
|
||||
final void Function(ViewPB) onSelected;
|
||||
|
||||
@override
|
||||
State<SingleInnerViewItem> createState() => _SingleInnerViewItemState();
|
||||
}
|
||||
|
||||
class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyHover(
|
||||
style: HoverStyle(
|
||||
hoverColor: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
buildWhenOnHover: () => !widget.showActions,
|
||||
builder: (_, onHover) => _buildViewItem(onHover),
|
||||
isSelected: () =>
|
||||
widget.showActions ||
|
||||
getIt<MenuSharedState>().latestOpenView?.id == widget.view.id,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildViewItem(bool onHover) {
|
||||
final children = [
|
||||
// expand icon
|
||||
_buildExpandedIcon(),
|
||||
const HSpace(7),
|
||||
// icon
|
||||
SizedBox.square(
|
||||
dimension: 16,
|
||||
child: widget.view.icon(),
|
||||
),
|
||||
const HSpace(5),
|
||||
// title
|
||||
Expanded(
|
||||
child: FlowyText.regular(
|
||||
widget.view.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
)
|
||||
];
|
||||
|
||||
// hover action
|
||||
if (widget.showActions || onHover) {
|
||||
// ··· more action button
|
||||
children.add(_buildViewMoreActionButton(context));
|
||||
// + button
|
||||
children.add(_buildViewAddButton(context));
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => widget.onSelected(widget.view),
|
||||
child: SizedBox(
|
||||
height: 26,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(left: widget.level * widget.leftPadding),
|
||||
child: Row(
|
||||
children: children,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// > button
|
||||
Widget _buildExpandedIcon() {
|
||||
final name =
|
||||
widget.isExpanded ? 'home/drop_down_show' : 'home/drop_down_hide';
|
||||
return GestureDetector(
|
||||
child: FlowySvg(
|
||||
name: name,
|
||||
size: const Size.square(16.0),
|
||||
),
|
||||
onTap: () => context
|
||||
.read<ViewBloc>()
|
||||
.add(ViewEvent.setIsExpanded(!widget.isExpanded)),
|
||||
);
|
||||
}
|
||||
|
||||
// + button
|
||||
Widget _buildViewAddButton(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: LocaleKeys.menuAppHeader_addPageTooltip.tr(),
|
||||
child: ViewAddButton(
|
||||
parentViewId: widget.view.id,
|
||||
onEditing: (value) =>
|
||||
context.read<ViewBloc>().add(ViewEvent.setIsEditing(value)),
|
||||
onSelected: (
|
||||
pluginBuilder,
|
||||
name,
|
||||
initialDataBytes,
|
||||
openAfterCreated,
|
||||
createNewView,
|
||||
) {
|
||||
if (createNewView) {
|
||||
context.read<ViewBloc>().add(
|
||||
ViewEvent.createView(
|
||||
name ?? LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
|
||||
pluginBuilder.layoutType!,
|
||||
openAfterCreated: openAfterCreated,
|
||||
),
|
||||
);
|
||||
}
|
||||
context.read<ViewBloc>().add(
|
||||
const ViewEvent.setIsExpanded(true),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ··· more action button
|
||||
Widget _buildViewMoreActionButton(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: LocaleKeys.menuAppHeader_moreButtonToolTip.tr(),
|
||||
child: ViewMoreActionButton(
|
||||
onEditing: (value) =>
|
||||
context.read<ViewBloc>().add(ViewEvent.setIsEditing(value)),
|
||||
onAction: (action) {
|
||||
switch (action) {
|
||||
case ViewMoreActionType.rename:
|
||||
NavigatorTextFieldDialog(
|
||||
title: LocaleKeys.disclosureAction_rename.tr(),
|
||||
autoSelectAllText: true,
|
||||
value: widget.view.name,
|
||||
confirm: (newValue) {
|
||||
context.read<ViewBloc>().add(ViewEvent.rename(newValue));
|
||||
},
|
||||
).show(context);
|
||||
break;
|
||||
case ViewMoreActionType.delete:
|
||||
context.read<ViewBloc>().add(const ViewEvent.delete());
|
||||
break;
|
||||
case ViewMoreActionType.duplicate:
|
||||
context.read<ViewBloc>().add(const ViewEvent.duplicate());
|
||||
break;
|
||||
case ViewMoreActionType.openInNewTab:
|
||||
context.read<TabsBloc>().add(
|
||||
TabsEvent.openTab(
|
||||
plugin: widget.view.plugin(),
|
||||
view: widget.view,
|
||||
),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
throw UnsupportedError('$action is not supported');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,67 @@
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flowy_infra/image.dart';
|
||||
|
||||
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
|
||||
|
||||
const supportedActionTypes = [
|
||||
ViewMoreActionType.rename,
|
||||
ViewMoreActionType.delete,
|
||||
ViewMoreActionType.duplicate,
|
||||
ViewMoreActionType.openInNewTab,
|
||||
];
|
||||
|
||||
/// ··· button beside the view name
|
||||
class ViewMoreActionButton extends StatelessWidget {
|
||||
const ViewMoreActionButton({
|
||||
super.key,
|
||||
required this.onEditing,
|
||||
required this.onAction,
|
||||
});
|
||||
|
||||
final void Function(bool value) onEditing;
|
||||
final void Function(ViewMoreActionType) onAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopoverActionList<ViewMoreActionTypeWrapper>(
|
||||
direction: PopoverDirection.bottomWithCenterAligned,
|
||||
offset: const Offset(0, 8),
|
||||
actions: supportedActionTypes
|
||||
.map((e) => ViewMoreActionTypeWrapper(e))
|
||||
.toList(),
|
||||
buildChild: (popover) {
|
||||
return FlowyIconButton(
|
||||
hoverColor: Colors.transparent,
|
||||
iconPadding: const EdgeInsets.all(2),
|
||||
width: 26,
|
||||
icon: const FlowySvg(name: 'editor/details'),
|
||||
onPressed: () {
|
||||
onEditing(true);
|
||||
popover.show();
|
||||
},
|
||||
);
|
||||
},
|
||||
onSelected: (action, popover) {
|
||||
onEditing(false);
|
||||
onAction(action.inner);
|
||||
popover.close();
|
||||
},
|
||||
onClosed: () => onEditing(false),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ViewMoreActionTypeWrapper extends ActionCell {
|
||||
ViewMoreActionTypeWrapper(this.inner);
|
||||
|
||||
final ViewMoreActionType inner;
|
||||
|
||||
@override
|
||||
Widget? leftIcon(Color iconColor) => inner.icon(iconColor);
|
||||
|
||||
@override
|
||||
String get name => inner.name;
|
||||
}
|
@ -0,0 +1,107 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DraggableItem<T extends Object> extends StatefulWidget {
|
||||
const DraggableItem({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.data,
|
||||
this.feedback,
|
||||
this.childWhenDragging,
|
||||
this.onAccept,
|
||||
this.onWillAccept,
|
||||
this.onMove,
|
||||
this.onLeave,
|
||||
this.enableAutoScroll = true,
|
||||
this.hitTestSize = const Size(100, 100),
|
||||
});
|
||||
|
||||
final T data;
|
||||
|
||||
final Widget child;
|
||||
final Widget? feedback;
|
||||
final Widget? childWhenDragging;
|
||||
|
||||
final DragTargetAccept<T>? onAccept;
|
||||
final DragTargetWillAccept<T>? onWillAccept;
|
||||
final DragTargetMove<T>? onMove;
|
||||
final DragTargetLeave<T>? onLeave;
|
||||
|
||||
/// Whether to enable auto scroll when dragging.
|
||||
///
|
||||
/// If true, the draggable item must be wrapped inside a [Scrollable] widget.
|
||||
final bool enableAutoScroll;
|
||||
final Size hitTestSize;
|
||||
|
||||
@override
|
||||
State<DraggableItem<T>> createState() => _DraggableItemState<T>();
|
||||
}
|
||||
|
||||
class _DraggableItemState<T extends Object> extends State<DraggableItem<T>> {
|
||||
ScrollableState? scrollable;
|
||||
EdgeDraggingAutoScroller? autoScroller;
|
||||
Rect? dragTarget;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
|
||||
initAutoScrollerIfNeeded(context);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
initAutoScrollerIfNeeded(context);
|
||||
|
||||
return DragTarget(
|
||||
onAccept: widget.onAccept,
|
||||
onWillAccept: widget.onWillAccept,
|
||||
onMove: widget.onMove,
|
||||
onLeave: widget.onLeave,
|
||||
builder: (_, __, ___) => Draggable<T>(
|
||||
data: widget.data,
|
||||
feedback: widget.feedback ?? widget.child,
|
||||
childWhenDragging: widget.childWhenDragging ?? widget.child,
|
||||
child: widget.child,
|
||||
onDragUpdate: (details) {
|
||||
if (widget.enableAutoScroll) {
|
||||
dragTarget = details.globalPosition & widget.hitTestSize;
|
||||
autoScroller?.startAutoScrollIfNecessary(dragTarget!);
|
||||
}
|
||||
},
|
||||
onDragEnd: (details) {
|
||||
autoScroller?.stopAutoScroll();
|
||||
dragTarget = null;
|
||||
},
|
||||
onDraggableCanceled: (_, __) {
|
||||
autoScroller?.stopAutoScroll();
|
||||
dragTarget = null;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void initAutoScrollerIfNeeded(BuildContext context) {
|
||||
if (!widget.enableAutoScroll) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollable = Scrollable.of(context);
|
||||
if (scrollable == null) {
|
||||
throw FlutterError(
|
||||
'DraggableItem must be wrapped inside a Scrollable widget '
|
||||
'when enableAutoScroll is true.',
|
||||
);
|
||||
}
|
||||
|
||||
autoScroller?.stopAutoScroll();
|
||||
autoScroller = EdgeDraggingAutoScroller(
|
||||
scrollable!,
|
||||
onScrollViewScrolled: () {
|
||||
if (dragTarget != null) {
|
||||
autoScroller!.startAutoScrollIfNecessary(dragTarget!);
|
||||
}
|
||||
},
|
||||
velocityScalar: 20,
|
||||
);
|
||||
}
|
||||
}
|
@ -24,3 +24,28 @@ extension FlowyStyledWidget on Widget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TopBorder extends StatelessWidget {
|
||||
const TopBorder({
|
||||
super.key,
|
||||
this.width = 1.0,
|
||||
this.color = Colors.grey,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final double width;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(width: width, color: color),
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -69,4 +69,32 @@ void main() {
|
||||
|
||||
assert(appBloc.state.views.isEmpty);
|
||||
});
|
||||
|
||||
test('create nested view test', () async {
|
||||
final app = await testContext.createTestApp();
|
||||
|
||||
final appBloc = AppBloc(view: app);
|
||||
appBloc
|
||||
..add(
|
||||
const AppEvent.initial(),
|
||||
)
|
||||
..add(
|
||||
const AppEvent.createView('Document 1', ViewLayoutPB.Document),
|
||||
);
|
||||
await blocResponseFuture();
|
||||
|
||||
// create a nested view
|
||||
const name = 'Document 1 - 1';
|
||||
final viewBloc = ViewBloc(view: appBloc.state.views.first);
|
||||
viewBloc
|
||||
..add(
|
||||
const ViewEvent.initial(),
|
||||
)
|
||||
..add(
|
||||
const ViewEvent.createView(name, ViewLayoutPB.Document),
|
||||
);
|
||||
await blocResponseFuture();
|
||||
|
||||
assert(viewBloc.state.childViews.first.name == name);
|
||||
});
|
||||
}
|
||||
|
@ -70,7 +70,10 @@
|
||||
"rename": "Rename",
|
||||
"delete": "Delete",
|
||||
"duplicate": "Duplicate",
|
||||
"openNewTab": "Open in a new tab"
|
||||
"openNewTab": "Open in a new tab",
|
||||
"moveTo": "Move to",
|
||||
"addToFavorites": "Add to Favorites",
|
||||
"copyLink": "Copy Link"
|
||||
},
|
||||
"blankPageTitle": "Blank page",
|
||||
"newPageText": "New page",
|
||||
@ -111,6 +114,7 @@
|
||||
"feedback": "Feedback"
|
||||
},
|
||||
"menuAppHeader": {
|
||||
"moreButtonToolTip": "Remove, rename, and more...",
|
||||
"addPageTooltip": "Quickly add a page inside",
|
||||
"defaultNewPageName": "Untitled",
|
||||
"renameDialog": "Rename"
|
||||
@ -146,7 +150,11 @@
|
||||
},
|
||||
"sideBar": {
|
||||
"closeSidebar": "Close side bar",
|
||||
"openSidebar": "Open side bar"
|
||||
"openSidebar": "Open side bar",
|
||||
"personal": "Personal",
|
||||
"favorites": "Favorites",
|
||||
"clickToHidePersonal": "Click to hide personal section",
|
||||
"addAPage": "Add a page"
|
||||
},
|
||||
"notifications": {
|
||||
"export": {
|
||||
|
92
frontend/rust-lib/Cargo.lock
generated
92
frontend/rust-lib/Cargo.lock
generated
@ -178,9 +178,9 @@ checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
|
||||
|
||||
[[package]]
|
||||
name = "aws-config"
|
||||
version = "0.55.3"
|
||||
version = "0.55.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bcdcf0d683fe9c23d32cf5b53c9918ea0a500375a9fb20109802552658e576c9"
|
||||
checksum = "fc00553f5f3c06ffd4510a9d576f92143618706c45ea6ff81e84ad9be9588abd"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-http",
|
||||
@ -208,9 +208,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-credential-types"
|
||||
version = "0.55.3"
|
||||
version = "0.55.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fcdb2f7acbc076ff5ad05e7864bdb191ca70a6fd07668dc3a1a8bcd051de5ae"
|
||||
checksum = "4cb57ac6088805821f78d282c0ba8aec809f11cbee10dda19a97b03ab040ccc2"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-types",
|
||||
@ -222,9 +222,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-endpoint"
|
||||
version = "0.55.3"
|
||||
version = "0.55.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8cce1c41a6cfaa726adee9ebb9a56fcd2bbfd8be49fd8a04c5e20fd968330b04"
|
||||
checksum = "9c5f6f84a4f46f95a9bb71d9300b73cd67eb868bc43ae84f66ad34752299f4ac"
|
||||
dependencies = [
|
||||
"aws-smithy-http",
|
||||
"aws-smithy-types",
|
||||
@ -236,9 +236,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-http"
|
||||
version = "0.55.3"
|
||||
version = "0.55.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aadbc44e7a8f3e71c8b374e03ecd972869eb91dd2bc89ed018954a52ba84bc44"
|
||||
checksum = "a754683c322f7dc5167484266489fdebdcd04d26e53c162cad1f3f949f2c5671"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-smithy-http",
|
||||
@ -281,9 +281,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-sso"
|
||||
version = "0.28.0"
|
||||
version = "0.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8b812340d86d4a766b2ca73f740dfd47a97c2dff0c06c8517a16d88241957e4"
|
||||
checksum = "babfd626348836a31785775e3c08a4c345a5ab4c6e06dfd9167f2bee0e6295d6"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-endpoint",
|
||||
@ -306,9 +306,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-sts"
|
||||
version = "0.28.0"
|
||||
version = "0.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "265fac131fbfc188e5c3d96652ea90ecc676a934e3174eaaee523c6cec040b3b"
|
||||
checksum = "2d0fbe3c2c342bc8dfea4bb43937405a8ec06f99140a0dcb9c7b59e54dfa93a1"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-endpoint",
|
||||
@ -332,9 +332,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sig-auth"
|
||||
version = "0.55.3"
|
||||
version = "0.55.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b94acb10af0c879ecd5c7bdf51cda6679a0a4f4643ce630905a77673bfa3c61"
|
||||
checksum = "84dc92a63ede3c2cbe43529cb87ffa58763520c96c6a46ca1ced80417afba845"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-sigv4",
|
||||
@ -346,9 +346,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sigv4"
|
||||
version = "0.55.3"
|
||||
version = "0.55.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d2ce6f507be68e968a33485ced670111d1cbad161ddbbab1e313c03d37d8f4c"
|
||||
checksum = "392fefab9d6fcbd76d518eb3b1c040b84728ab50f58df0c3c53ada4bea9d327e"
|
||||
dependencies = [
|
||||
"aws-smithy-http",
|
||||
"form_urlencoded",
|
||||
@ -365,9 +365,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-async"
|
||||
version = "0.55.3"
|
||||
version = "0.55.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13bda3996044c202d75b91afeb11a9afae9db9a721c6a7a427410018e286b880"
|
||||
checksum = "ae23b9fe7a07d0919000116c4c5c0578303fbce6fc8d32efca1f7759d4c20faf"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"pin-project-lite",
|
||||
@ -377,9 +377,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-client"
|
||||
version = "0.55.3"
|
||||
version = "0.55.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0a86aa6e21e86c4252ad6a0e3e74da9617295d8d6e374d552be7d3059c41cedd"
|
||||
checksum = "5230d25d244a51339273b8870f0f77874cd4449fb4f8f629b21188ae10cfc0ba"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http",
|
||||
@ -401,9 +401,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-http"
|
||||
version = "0.55.3"
|
||||
version = "0.55.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b3b693869133551f135e1f2c77cb0b8277d9e3e17feaf2213f735857c4f0d28"
|
||||
checksum = "b60e2133beb9fe6ffe0b70deca57aaeff0a35ad24a9c6fab2fd3b4f45b99fdb5"
|
||||
dependencies = [
|
||||
"aws-smithy-types",
|
||||
"bytes",
|
||||
@ -423,9 +423,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-http-tower"
|
||||
version = "0.55.3"
|
||||
version = "0.55.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ae4f6c5798a247fac98a867698197d9ac22643596dc3777f0c76b91917616b9"
|
||||
checksum = "3a4d94f556c86a0dd916a5d7c39747157ea8cb909ca469703e20fee33e448b67"
|
||||
dependencies = [
|
||||
"aws-smithy-http",
|
||||
"aws-smithy-types",
|
||||
@ -439,18 +439,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-json"
|
||||
version = "0.55.3"
|
||||
version = "0.55.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23f9f42fbfa96d095194a632fbac19f60077748eba536eb0b9fecc28659807f8"
|
||||
checksum = "5ce3d6e6ebb00b2cce379f079ad5ec508f9bcc3a9510d9b9c1840ed1d6f8af39"
|
||||
dependencies = [
|
||||
"aws-smithy-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-query"
|
||||
version = "0.55.3"
|
||||
version = "0.55.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98819eb0b04020a1c791903533b638534ae6c12e2aceda3e6e6fba015608d51d"
|
||||
checksum = "d58edfca32ef9bfbc1ca394599e17ea329cb52d6a07359827be74235b64b3298"
|
||||
dependencies = [
|
||||
"aws-smithy-types",
|
||||
"urlencoding",
|
||||
@ -458,9 +458,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-types"
|
||||
version = "0.55.3"
|
||||
version = "0.55.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a3d0bf4f324f4ef9793b86a1701d9700fbcdbd12a846da45eed104c634c6e8"
|
||||
checksum = "58db46fc1f4f26be01ebdb821751b4e2482cd43aa2b64a0348fb89762defaffa"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"itoa",
|
||||
@ -471,18 +471,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-xml"
|
||||
version = "0.55.3"
|
||||
version = "0.55.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1b9d12875731bd07e767be7baad95700c3137b56730ec9ddeedb52a5e5ca63b"
|
||||
checksum = "fb557fe4995bd9ec87fb244bbb254666a971dc902a783e9da8b7711610e9664c"
|
||||
dependencies = [
|
||||
"xmlparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-types"
|
||||
version = "0.55.3"
|
||||
version = "0.55.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dd209616cc8d7bfb82f87811a5c655dc97537f592689b18743bddf5dc5c4829"
|
||||
checksum = "de0869598bfe46ec44ffe17e063ed33336e59df90356ca8ff0e8da6f7c1d994b"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-smithy-async",
|
||||
@ -3372,9 +3372,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "postgrest"
|
||||
version = "1.6.0"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a966c650b47a064e7082170b4be74fca08c088d893244fc4b70123e3c1f3ee7"
|
||||
checksum = "e66400cb23a379592bc8c8bdc9adda652eef4a969b74ab78454a8e8c11330c2b"
|
||||
dependencies = [
|
||||
"reqwest",
|
||||
]
|
||||
@ -4030,9 +4030,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.6.3"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00"
|
||||
checksum = "0167bac7a9f490495f3c33013e7722b53cb087ecbe082fb0c6387c96f634ea50"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pemfile",
|
||||
@ -4151,15 +4151,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.18"
|
||||
version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0293b4b29daaf487284529cc2f5675b8e57c61f70167ba415a463651fd6a918"
|
||||
checksum = "bebd363326d05ec3e2f532ab7660680f3b02130d780c299bca73469d521bc0ed"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.175"
|
||||
version = "1.0.178"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d25439cd7397d044e2748a6fe2432b5e85db703d6d097bd014b3c0ad1ebff0b"
|
||||
checksum = "60363bdd39a7be0266a520dab25fdc9241d2f987b08a01e01f0ec6d06a981348"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
@ -4177,9 +4177,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.175"
|
||||
version = "1.0.178"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b23f7ade6f110613c0d63858ddb8b94c1041f550eab58a16b371bdf2c9c80ab4"
|
||||
checksum = "f28482318d6641454cb273da158647922d1be6b5a2fcc6165cd89ebdd7ed576b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -5085,9 +5085,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
checksum = "e8db7427f936968176eaa7cdf81b7f98b980b18495ec28f1b5791ac3bfe3eea9"
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
|
@ -100,23 +100,18 @@ impl FolderOperationHandler for DocumentFolderOperation {
|
||||
FutureResult::new(async move {
|
||||
let mut write_guard = workspace_view_builder.write().await;
|
||||
|
||||
// Create a parent view named "⭐️ Getting started". and a child view named "Read me".
|
||||
// Create a view named "⭐️ Getting started" with built-in README data.
|
||||
// Don't modify this code unless you know what you are doing.
|
||||
write_guard
|
||||
.with_view_builder(|view_builder| async {
|
||||
view_builder
|
||||
.with_name("⭐️ Getting started")
|
||||
.with_child_view_builder(|child_view_builder| async {
|
||||
let view = child_view_builder.with_name("Read me").build();
|
||||
let json_str = include_str!("../../assets/read_me.json");
|
||||
let document_pb = JsonToDocumentParser::json_str_to_document(json_str).unwrap();
|
||||
manager
|
||||
.create_document(&view.parent_view.id, Some(document_pb.into()))
|
||||
.unwrap();
|
||||
view
|
||||
})
|
||||
.await
|
||||
.build()
|
||||
let view = view_builder.with_name("⭐️ Getting started").build();
|
||||
// create a empty document
|
||||
let json_str = include_str!("../../assets/read_me.json");
|
||||
let document_pb = JsonToDocumentParser::json_str_to_document(json_str).unwrap();
|
||||
manager
|
||||
.create_document(&view.parent_view.id, Some(document_pb.into()))
|
||||
.unwrap();
|
||||
view
|
||||
})
|
||||
.await;
|
||||
Ok(())
|
||||
|
@ -27,14 +27,7 @@ impl DefaultFolderBuilder {
|
||||
|
||||
let views = workspace_view_builder.write().await.build();
|
||||
// Safe to unwrap because we have at least one view. check out the DocumentFolderOperation.
|
||||
let first_view = views
|
||||
.first()
|
||||
.unwrap()
|
||||
.child_views
|
||||
.first()
|
||||
.unwrap()
|
||||
.parent_view
|
||||
.clone();
|
||||
let first_view = views.first().unwrap().parent_view.clone();
|
||||
|
||||
let first_level_views = views
|
||||
.iter()
|
||||
|
Loading…
Reference in New Issue
Block a user