diff --git a/frontend/appflowy_flutter/integration_test/desktop/board/board_add_row_test.dart b/frontend/appflowy_flutter/integration_test/desktop/board/board_add_row_test.dart index d850115632..84db6a5be0 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/board/board_add_row_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/board/board_add_row_test.dart @@ -1,4 +1,5 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/database/board/presentation/board_page.dart'; import 'package:appflowy/plugins/database/board/presentation/widgets/board_column_header.dart'; import 'package:appflowy/plugins/database/widgets/card/card.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; @@ -82,23 +83,19 @@ void main() { findsOneWidget, ); - await tester.tap( - find - .descendant( - of: find.byType(AppFlowyGroupFooter), - matching: find.byType(FlowySvg), - ) - .at(1), + await tester.tapButton( + find.byType(BoardColumnFooter).at(1), ); const newCardName = 'Card 4'; await tester.enterText( find.descendant( - of: lastCard, + of: find.byType(BoardColumnFooter), matching: find.byType(TextField), ), newCardName, ); + await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(const Duration(milliseconds: 500)); await tester.tap(find.byType(AppFlowyBoard)); diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_database_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_database_test.dart index 915004133f..f401cb1e0b 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_database_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_database_test.dart @@ -56,7 +56,7 @@ void main() { expect( find.descendant( of: find.byType(AppFlowyEditor), - matching: find.byType(BoardPage), + matching: find.byType(DesktopBoardPage), ), findsOneWidget, ); @@ -104,7 +104,7 @@ void main() { expect( find.descendant( of: find.byType(AppFlowyEditor), - matching: find.byType(BoardPage), + matching: find.byType(DesktopBoardPage), ), findsOneWidget, ); diff --git a/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test.dart b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test.dart index d6e5431527..158a8db882 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test.dart @@ -61,7 +61,7 @@ void main() { expect(find.byType(GridPage), findsOneWidget); break; case ViewLayoutPB.Board: - expect(find.byType(BoardPage), findsOneWidget); + expect(find.byType(DesktopBoardPage), findsOneWidget); break; case ViewLayoutPB.Calendar: expect(find.byType(CalendarPage), findsOneWidget); diff --git a/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart b/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart index 6b5b0cc1cf..8697b832c0 100644 --- a/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart +++ b/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart @@ -1463,7 +1463,7 @@ extension AppFlowyDatabaseTest on WidgetTester { void assertCurrentDatabaseTagIs(DatabaseLayoutPB layout) => switch (layout) { DatabaseLayoutPB.Board => - expect(find.byType(BoardPage), findsOneWidget), + expect(find.byType(DesktopBoardPage), findsOneWidget), DatabaseLayoutPB.Calendar => expect(find.byType(CalendarPage), findsOneWidget), DatabaseLayoutPB.Grid => expect(find.byType(GridPage), findsOneWidget), @@ -1521,7 +1521,7 @@ extension AppFlowyDatabaseTest on WidgetTester { } Finder finderForDatabaseLayoutType(DatabaseLayoutPB layout) => switch (layout) { - DatabaseLayoutPB.Board => find.byType(BoardPage), + DatabaseLayoutPB.Board => find.byType(DesktopBoardPage), DatabaseLayoutPB.Calendar => find.byType(CalendarPage), DatabaseLayoutPB.Grid => find.byType(GridPage), _ => throw Exception('Unknown database layout type: $layout'), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/board/board.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/board.dart index 89ae2411e1..642a7ebeae 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/board/board.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/board.dart @@ -1,4 +1,4 @@ export 'mobile_board_screen.dart'; -export 'mobile_board_content.dart'; +export 'mobile_board_page.dart'; export 'widgets/mobile_hidden_groups_column.dart'; export 'widgets/mobile_board_trailing.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/board/mobile_board_content.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/mobile_board_page.dart similarity index 52% rename from frontend/appflowy_flutter/lib/mobile/presentation/database/board/mobile_board_content.dart rename to frontend/appflowy_flutter/lib/mobile/presentation/database/board/mobile_board_page.dart index c01f3ca048..7938f47462 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/board/mobile_board_content.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/mobile_board_page.dart @@ -3,12 +3,16 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/database/board/board.dart'; import 'package:appflowy/mobile/presentation/database/board/widgets/group_card_header.dart'; import 'package:appflowy/mobile/presentation/database/card/card.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/header/field_type_extension.dart'; import 'package:appflowy/plugins/database/widgets/card/card.dart'; import 'package:appflowy/plugins/database/widgets/cell/card_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/cell/card_cell_style_maps/mobile_board_card_cell_style.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_board/appflowy_board.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -16,25 +20,100 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; -class MobileBoardContent extends StatefulWidget { - const MobileBoardContent({ +class MobileBoardPage extends StatefulWidget { + const MobileBoardPage({ super.key, + required this.view, + required this.databaseController, + this.onEditStateChanged, }); + final ViewPB view; + + final DatabaseController databaseController; + + /// Called when edit state changed + final VoidCallback? onEditStateChanged; + @override - State createState() => _MobileBoardContentState(); + State createState() => _MobileBoardPageState(); } -class _MobileBoardContentState extends State { - late final ScrollController scrollController; - late final AppFlowyBoardScrollController scrollManager; +class _MobileBoardPageState extends State { + late final ValueNotifier _didCreateRow; + + @override + void initState() { + super.initState(); + _didCreateRow = ValueNotifier(null)..addListener(_handleDidCreateRow); + } + + @override + void dispose() { + _didCreateRow + ..removeListener(_handleDidCreateRow) + ..dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => BoardBloc( + databaseController: widget.databaseController, + didCreateRow: _didCreateRow, + )..add(const BoardEvent.initial()), + child: BlocBuilder( + builder: (context, state) => state.maybeMap( + loading: (_) => const Center( + child: CircularProgressIndicator.adaptive(), + ), + error: (err) => FlowyMobileStateContainer.error( + emoji: '🛸', + title: LocaleKeys.board_mobile_failedToLoad.tr(), + errorMsg: err.toString(), + ), + ready: (data) => const _BoardContent(), + orElse: () => const SizedBox.shrink(), + ), + ), + ); + } + + void _handleDidCreateRow() { + if (_didCreateRow.value != null) { + final result = _didCreateRow.value!; + switch (result.action) { + case DidCreateRowAction.openAsPage: + context.push( + MobileRowDetailPage.routeName, + extra: { + MobileRowDetailPage.argRowId: result.rowMeta.id, + MobileRowDetailPage.argDatabaseController: + widget.databaseController, + }, + ); + break; + default: + break; + } + } + } +} + +class _BoardContent extends StatefulWidget { + const _BoardContent(); + + @override + State<_BoardContent> createState() => _BoardContentState(); +} + +class _BoardContentState extends State<_BoardContent> { + late final ScrollController scrollController; @override void initState() { super.initState(); - // mobile may not need this - // scroll to bottom when add a new card - scrollManager = AppFlowyBoardScrollController(); scrollController = ScrollController(); } @@ -57,54 +136,48 @@ class _MobileBoardContentState extends State { cardMargin: const EdgeInsets.all(4), ); - return BlocListener( - listenWhen: (previous, current) => - previous.recentAddedRowMeta != current.recentAddedRowMeta, - listener: (context, state) { - context.push( - MobileRowDetailPage.routeName, - extra: { - MobileRowDetailPage.argRowId: state.recentAddedRowMeta!.id, - MobileRowDetailPage.argDatabaseController: - context.read().databaseController, + return BlocBuilder( + builder: (context, state) { + return state.maybeMap( + orElse: () => const SizedBox.shrink(), + ready: (state) { + final showCreateGroupButton = context + .read() + .groupingFieldType + ?.canCreateNewGroup ?? + false; + final showHiddenGroups = state.hiddenGroups.isNotEmpty; + return AppFlowyBoard( + scrollController: scrollController, + controller: context.read().boardController, + groupConstraints: + BoxConstraints.tightFor(width: screenWidth * 0.7), + config: config, + leading: showHiddenGroups + ? MobileHiddenGroupsColumn( + padding: config.groupHeaderPadding, + ) + : const HSpace(16), + trailing: showCreateGroupButton + ? const MobileBoardTrailing() + : const HSpace(16), + headerBuilder: (_, groupData) => BlocProvider.value( + value: context.read(), + child: GroupCardHeader( + groupData: groupData, + ), + ), + footerBuilder: _buildFooter, + cardBuilder: (_, column, columnItem) => _buildCard( + context: context, + afGroupData: column, + afGroupItem: columnItem, + cardMargin: config.cardMargin, + ), + ); }, ); }, - child: BlocBuilder( - builder: (context, state) { - final showCreateGroupButton = - context.read().groupingFieldType.canCreateNewGroup; - final showHiddenGroups = state.hiddenGroups.isNotEmpty; - return AppFlowyBoard( - boardScrollController: scrollManager, - scrollController: scrollController, - controller: context.read().boardController, - groupConstraints: BoxConstraints.tightFor(width: screenWidth * 0.7), - config: config, - leading: showHiddenGroups - ? MobileHiddenGroupsColumn( - padding: config.groupHeaderPadding, - ) - : const HSpace(16), - trailing: showCreateGroupButton - ? const MobileBoardTrailing() - : const HSpace(16), - headerBuilder: (_, groupData) => BlocProvider.value( - value: context.read(), - child: GroupCardHeader( - groupData: groupData, - ), - ), - footerBuilder: _buildFooter, - cardBuilder: (_, column, columnItem) => _buildCard( - context: context, - afGroupData: column, - afGroupItem: columnItem, - cardMargin: config.cardMargin, - ), - ); - }, - ), ); } @@ -129,9 +202,14 @@ class _MobileBoardContentState extends State { color: style.colorScheme.onSurface, ), ), - onPressed: () => context - .read() - .add(BoardEvent.createBottomRow(columnData.id)), + onPressed: () => context.read().add( + BoardEvent.createRow( + columnData.id, + OrderObjectPositionTypePB.End, + null, + null, + ), + ), ), ); } @@ -146,16 +224,9 @@ class _MobileBoardContentState extends State { final groupItem = afGroupItem as GroupItem; final groupData = afGroupData.customData as GroupData; final rowMeta = groupItem.row; - final rowCache = boardBloc.getRowCache(); - - /// Return placeholder widget if the rowCache is null. - if (rowCache == null) return SizedBox.shrink(key: ObjectKey(groupItem)); - final viewId = boardBloc.viewId; final cellBuilder = CardCellBuilder(databaseController: boardBloc.databaseController); - final isEditing = boardBloc.state.isEditingRow && - boardBloc.state.editingRow?.row.id == groupItem.row.id; final groupItemId = groupItem.row.id + groupData.group.groupId; @@ -166,12 +237,12 @@ class _MobileBoardContentState extends State { child: RowCard( fieldController: boardBloc.fieldController, rowMeta: rowMeta, - viewId: viewId, - rowCache: rowCache, + viewId: boardBloc.viewId, + rowCache: boardBloc.rowCache, groupingFieldId: groupItem.fieldInfo.id, - isEditing: isEditing, + isEditing: false, cellBuilder: cellBuilder, - openCard: (context) { + onTap: (context) { context.push( MobileRowDetailPage.routeName, extra: { @@ -181,10 +252,8 @@ class _MobileBoardContentState extends State { }, ); }, - onStartEditing: () => boardBloc - .add(BoardEvent.startEditingRow(groupData.group, groupItem.row)), - onEndEditing: () => - boardBloc.add(BoardEvent.endEditingRow(groupItem.row.id)), + onStartEditing: () {}, + onEndEditing: () {}, styleConfiguration: RowCardStyleConfiguration( cellStyleMap: mobileBoardCardCellStyleMap(context), showAccessory: false, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/group_card_header.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/group_card_header.dart index 5180febd8b..35208c91bd 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/group_card_header.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/group_card_header.dart @@ -1,16 +1,14 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart'; import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/header/field_type_extension.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/group.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_board/appflowy_board.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -73,8 +71,12 @@ class _GroupCardHeaderState extends State { ); } - if (state.isEditingHeader && - state.editingHeaderId == widget.groupData.id) { + final isEditing = state.maybeMap( + ready: (value) => value.editingHeaderId == widget.groupData.id, + orElse: () => false, + ); + + if (isEditing) { title = TextField( controller: _controller, autofocus: true, @@ -135,7 +137,7 @@ class _GroupCardHeaderState extends State { icon: FlowySvgs.hide_s, onTap: () { context.read().add( - BoardEvent.toggleGroupVisibility( + BoardEvent.setGroupVisibility( widget.groupData.customData.group as GroupPB, false, @@ -154,9 +156,16 @@ class _GroupCardHeaderState extends State { color: Theme.of(context).colorScheme.onSurface, ), splashRadius: 5, - onPressed: () => context.read().add( - BoardEvent.createHeaderRow(widget.groupData.id), - ), + onPressed: () { + context.read().add( + BoardEvent.createRow( + widget.groupData.id, + OrderObjectPositionTypePB.Start, + null, + null, + ), + ); + }, ), ], ), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/mobile_hidden_groups_column.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/mobile_hidden_groups_column.dart index 4f873b73b8..2e2367b9bb 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/mobile_hidden_groups_column.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/mobile_hidden_groups_column.dart @@ -23,7 +23,10 @@ class MobileHiddenGroupsColumn extends StatelessWidget { Widget build(BuildContext context) { final databaseController = context.read().databaseController; return BlocSelector( - selector: (state) => state.layoutSettings, + selector: (state) => state.maybeMap( + orElse: () => null, + ready: (value) => value.layoutSettings, + ), builder: (context, layoutSettings) { if (layoutSettings == null) { return const SizedBox.shrink(); @@ -105,29 +108,36 @@ class MobileHiddenGroupList extends StatelessWidget { @override Widget build(BuildContext context) { return BlocBuilder( - builder: (_, state) => ReorderableListView.builder( - itemCount: state.hiddenGroups.length, - itemBuilder: (_, index) => MobileHiddenGroup( - key: ValueKey(state.hiddenGroups[index].groupId), - group: state.hiddenGroups[index], - index: index, - ), - proxyDecorator: (child, index, animation) => BlocProvider.value( - value: context.read(), - child: Material(color: Colors.transparent, child: child), - ), - physics: const ClampingScrollPhysics(), - onReorder: (oldIndex, newIndex) { - if (oldIndex < newIndex) { - newIndex--; - } - final fromGroupId = state.hiddenGroups[oldIndex].groupId; - final toGroupId = state.hiddenGroups[newIndex].groupId; - context - .read() - .add(BoardEvent.reorderGroup(fromGroupId, toGroupId)); - }, - ), + builder: (_, state) { + return state.maybeMap( + orElse: () => const SizedBox.shrink(), + ready: (state) { + return ReorderableListView.builder( + itemCount: state.hiddenGroups.length, + itemBuilder: (_, index) => MobileHiddenGroup( + key: ValueKey(state.hiddenGroups[index].groupId), + group: state.hiddenGroups[index], + index: index, + ), + proxyDecorator: (child, index, animation) => BlocProvider.value( + value: context.read(), + child: Material(color: Colors.transparent, child: child), + ), + physics: const ClampingScrollPhysics(), + onReorder: (oldIndex, newIndex) { + if (oldIndex < newIndex) { + newIndex--; + } + final fromGroupId = state.hiddenGroups[oldIndex].groupId; + final toGroupId = state.hiddenGroups[newIndex].groupId; + context + .read() + .add(BoardEvent.reorderGroup(fromGroupId, toGroupId)); + }, + ); + }, + ); + }, ); } } @@ -148,92 +158,77 @@ class MobileHiddenGroup extends StatelessWidget { final primaryField = databaseController.fieldController.fieldInfos .firstWhereOrNull((element) => element.isPrimary)!; - return BlocBuilder( - builder: (context, state) { - final group = state.hiddenGroups.firstWhereOrNull( - (g) => g.groupId == this.group.groupId, - ); - if (group == null) { - return const SizedBox.shrink(); - } - - final cells = group.rows.map( - (item) { - final cellContext = - databaseController.rowCache.loadCells(item).firstWhere( - (cellContext) => cellContext.fieldId == primaryField.id, - ); - - return TextButton( - style: TextButton.styleFrom( - textStyle: Theme.of(context).textTheme.bodyMedium, - foregroundColor: Theme.of(context).colorScheme.onBackground, - visualDensity: VisualDensity.compact, - ), - child: CardCellBuilder( - databaseController: - context.read().databaseController, - ).build( - cellContext: cellContext, - styleMap: {FieldType.RichText: _titleCellStyle(context)}, - hasNotes: !item.isDocumentEmpty, - ), - onPressed: () { - context.push( - MobileRowDetailPage.routeName, - extra: { - MobileRowDetailPage.argRowId: item.id, - MobileRowDetailPage.argDatabaseController: - context.read().databaseController, - }, + final cells = group.rows.map( + (item) { + final cellContext = + databaseController.rowCache.loadCells(item).firstWhere( + (cellContext) => cellContext.fieldId == primaryField.id, ); + + return TextButton( + style: TextButton.styleFrom( + textStyle: Theme.of(context).textTheme.bodyMedium, + foregroundColor: Theme.of(context).colorScheme.onBackground, + visualDensity: VisualDensity.compact, + ), + child: CardCellBuilder( + databaseController: context.read().databaseController, + ).build( + cellContext: cellContext, + styleMap: {FieldType.RichText: _titleCellStyle(context)}, + hasNotes: !item.isDocumentEmpty, + ), + onPressed: () { + context.push( + MobileRowDetailPage.routeName, + extra: { + MobileRowDetailPage.argRowId: item.id, + MobileRowDetailPage.argDatabaseController: + context.read().databaseController, }, ); }, - ).toList(); - - return ExpansionTile( - tilePadding: EdgeInsets.zero, - childrenPadding: EdgeInsets.zero, - title: Row( - children: [ - Expanded( - child: Text( - context.read().generateGroupNameFromGroup(group), - style: Theme.of(context).textTheme.bodyMedium, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - GestureDetector( - child: const Padding( - padding: EdgeInsets.all(4), - child: FlowySvg( - FlowySvgs.hide_m, - size: Size.square(20), - ), - ), - onTap: () => showFlowyMobileConfirmDialog( - context, - title: FlowyText(LocaleKeys.board_mobile_showGroup.tr()), - content: FlowyText( - LocaleKeys.board_mobile_showGroupContent.tr(), - ), - actionButtonTitle: LocaleKeys.button_yes.tr(), - actionButtonColor: Theme.of(context).colorScheme.primary, - onActionButtonPressed: () => context.read().add( - BoardEvent.toggleGroupVisibility( - group, - true, - ), - ), - ), - ), - ], - ), - children: cells, ); }, + ).toList(); + + return ExpansionTile( + tilePadding: EdgeInsets.zero, + childrenPadding: EdgeInsets.zero, + title: Row( + children: [ + Expanded( + child: Text( + context.read().generateGroupNameFromGroup(group), + style: Theme.of(context).textTheme.bodyMedium, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + GestureDetector( + child: const Padding( + padding: EdgeInsets.all(4), + child: FlowySvg( + FlowySvgs.hide_m, + size: Size.square(20), + ), + ), + onTap: () => showFlowyMobileConfirmDialog( + context, + title: FlowyText(LocaleKeys.board_mobile_showGroup.tr()), + content: FlowyText( + LocaleKeys.board_mobile_showGroupContent.tr(), + ), + actionButtonTitle: LocaleKeys.button_yes.tr(), + actionButtonColor: Theme.of(context).colorScheme.primary, + onActionButtonPressed: () => context + .read() + .add(BoardEvent.setGroupVisibility(group, true)), + ), + ), + ], + ), + children: cells, ); } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart index 5805dc957f..e3cad565ab 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart @@ -162,7 +162,7 @@ class _MobileRowDetailPageState extends State { } deleteRow - ? RowBackendService.deleteRow(viewId, rowId) + ? RowBackendService.deleteRows(viewId, [rowId]) : RowBackendService.duplicateRow(viewId, rowId); context diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_cell_bloc.dart index 28ff1b2f78..70c5e074ab 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_cell_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_cell_bloc.dart @@ -48,7 +48,7 @@ class RelationCellBloc extends Bloc { emit(state.copyWith(rows: const [])); return; } - final payload = RepeatedRowIdPB( + final payload = GetRelatedRowDataPB( databaseId: state.relatedDatabaseMeta!.databaseId, rowIds: cellData.rowIds, ); diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/relation_type_option_cubit.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/relation_type_option_cubit.dart index df8e0d46fb..691b6b7227 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/relation_type_option_cubit.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/relation_type_option_cubit.dart @@ -1,6 +1,7 @@ import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -12,11 +13,9 @@ class RelationDatabaseListCubit extends Cubit { } void _loadDatabaseMetas() async { - final getDatabaseResult = await DatabaseEventGetDatabases().send(); - final metaPBs = getDatabaseResult.fold>( - (s) => s.items, - (f) => [], - ); + final metaPBs = await DatabaseEventGetDatabases() + .send() + .fold>((s) => s.items, (f) => []); final futures = metaPBs.map((meta) { return ViewBackendService.getView(meta.inlineViewId).then( (result) => result.fold( diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_cache.dart b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_cache.dart index 331dd159da..90f20b2fe7 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_cache.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_cache.dart @@ -255,9 +255,7 @@ class RowCache { RowInfo buildGridRow(RowMetaPB rowMetaPB) { return RowInfo( - viewId: viewId, fields: _fieldDelegate.fieldInfos, - rowId: rowMetaPB.id, rowMeta: rowMetaPB, ); } @@ -285,12 +283,13 @@ class RowChangesetNotifier extends ChangeNotifier { @unfreezed class RowInfo with _$RowInfo { + const RowInfo._(); factory RowInfo({ - required String rowId, - required String viewId, required UnmodifiableListView fields, required RowMetaPB rowMeta, }) = _RowInfo; + + String get rowId => rowMeta.id; } typedef InsertedIndexs = List; diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_service.dart b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_service.dart index 1b0d73de8f..1866891336 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_service.dart @@ -96,15 +96,15 @@ class RowBackendService { return DatabaseEventUpdateRowMeta(payload).send(); } - static Future> deleteRow( + static Future> deleteRows( String viewId, - RowId rowId, + List rowIds, ) { - final payload = RowIdPB.create() + final payload = RepeatedRowIdPB.create() ..viewId = viewId - ..rowId = rowId; + ..rowIds.addAll(rowIds); - return DatabaseEventDeleteRow(payload).send(); + return DatabaseEventDeleteRows(payload).send(); } static Future> duplicateRow( diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/application/board_actions_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/board/application/board_actions_bloc.dart new file mode 100644 index 0000000000..99da7d48f0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/board/application/board_actions_bloc.dart @@ -0,0 +1,93 @@ +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'board_actions_bloc.freezed.dart'; + +class BoardActionsCubit extends Cubit { + BoardActionsCubit({ + required this.databaseController, + }) : super(const BoardActionsState.initial()); + + final DatabaseController databaseController; + + void startEditingRow(GroupedRowId groupedRowId) { + emit(BoardActionsState.startEditingRow(groupedRowId: groupedRowId)); + emit(const BoardActionsState.initial()); + } + + void endEditing(GroupedRowId groupedRowId) { + emit(const BoardActionsState.endEditingRow()); + emit(BoardActionsState.setFocus(groupedRowIds: [groupedRowId])); + emit(const BoardActionsState.initial()); + } + + void openCard(RowMetaPB rowMeta) { + emit(BoardActionsState.openCard(rowMeta: rowMeta)); + emit(const BoardActionsState.initial()); + } + + void openCardWithRowId(rowId) { + final rowMeta = databaseController.rowCache.getRow(rowId)!.rowMeta; + openCard(rowMeta); + } + + void setFocus(List groupedRowIds) { + emit(BoardActionsState.setFocus(groupedRowIds: groupedRowIds)); + emit(const BoardActionsState.initial()); + } + + void startCreateBottomRow(String groupId) { + emit(BoardActionsState.startCreateBottomRow(groupId: groupId)); + emit(const BoardActionsState.initial()); + } + + void createRow( + GroupedRowId? groupedRowId, + CreateBoardCardRelativePosition relativePosition, + ) { + emit( + BoardActionsState.createRow( + groupedRowId: groupedRowId, + position: relativePosition, + ), + ); + emit(const BoardActionsState.initial()); + } +} + +@freezed +class BoardActionsState with _$BoardActionsState { + const factory BoardActionsState.initial() = _BoardActionsInitialState; + + const factory BoardActionsState.openCard({ + required RowMetaPB rowMeta, + }) = _BoardActionsOpenCardState; + + const factory BoardActionsState.startEditingRow({ + required GroupedRowId groupedRowId, + }) = _BoardActionsStartEditingRowState; + + const factory BoardActionsState.endEditingRow() = + _BoardActionsEndEditingRowState; + + const factory BoardActionsState.setFocus({ + required List groupedRowIds, + }) = _BoardActionsSetFocusState; + + const factory BoardActionsState.startCreateBottomRow({ + required String groupId, + }) = _BoardActionsStartCreateBottomRowState; + + const factory BoardActionsState.createRow({ + required GroupedRowId? groupedRowId, + required CreateBoardCardRelativePosition position, + }) = _BoardActionCreateRowState; +} + +enum CreateBoardCardRelativePosition { + before, + after, +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/application/board_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/board/application/board_bloc.dart index 7b816bbafc..c25dcd1589 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/board/application/board_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/board/application/board_bloc.dart @@ -8,10 +8,11 @@ import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_board/appflowy_board.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:appflowy_result/appflowy_result.dart'; import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -27,95 +28,108 @@ part 'board_bloc.freezed.dart'; class BoardBloc extends Bloc { BoardBloc({ - required ViewPB view, required this.databaseController, - }) : super(BoardState.initial(view.id)) { + this.didCreateRow, + AppFlowyBoardController? boardController, + }) : super(const BoardState.loading()) { groupBackendSvc = GroupBackendService(viewId); - boardController = AppFlowyBoardController( - onMoveGroup: (fromGroupId, fromIndex, toGroupId, toIndex) => - databaseController.moveGroup( - fromGroupId: fromGroupId, - toGroupId: toGroupId, - ), - onMoveGroupItem: (groupId, fromIndex, toIndex) { - final fromRow = groupControllers[groupId]?.rowAtIndex(fromIndex); - final toRow = groupControllers[groupId]?.rowAtIndex(toIndex); - if (fromRow != null) { - databaseController.moveGroupRow( - fromRow: fromRow, - toRow: toRow, - fromGroupId: groupId, - toGroupId: groupId, - ); - } - }, - onMoveGroupItemToGroup: (fromGroupId, fromIndex, toGroupId, toIndex) { - final fromRow = groupControllers[fromGroupId]?.rowAtIndex(fromIndex); - final toRow = groupControllers[toGroupId]?.rowAtIndex(toIndex); - if (fromRow != null) { - databaseController.moveGroupRow( - fromRow: fromRow, - toRow: toRow, - fromGroupId: fromGroupId, - toGroupId: toGroupId, - ); - } - }, - ); - + _initBoardController(boardController); _dispatch(); } final DatabaseController databaseController; + late final AppFlowyBoardController boardController; final LinkedHashMap groupControllers = LinkedHashMap(); final List groupList = []; - late final AppFlowyBoardController boardController; + final ValueNotifier? didCreateRow; + late final GroupBackendService groupBackendSvc; FieldController get fieldController => databaseController.fieldController; String get viewId => databaseController.viewId; + void _initBoardController(AppFlowyBoardController? controller) { + boardController = controller ?? + AppFlowyBoardController( + onMoveGroup: (fromGroupId, fromIndex, toGroupId, toIndex) => + databaseController.moveGroup( + fromGroupId: fromGroupId, + toGroupId: toGroupId, + ), + onMoveGroupItem: (groupId, fromIndex, toIndex) { + final fromRow = groupControllers[groupId]?.rowAtIndex(fromIndex); + final toRow = groupControllers[groupId]?.rowAtIndex(toIndex); + if (fromRow != null) { + databaseController.moveGroupRow( + fromRow: fromRow, + toRow: toRow, + fromGroupId: groupId, + toGroupId: groupId, + ); + } + }, + onMoveGroupItemToGroup: (fromGroupId, fromIndex, toGroupId, toIndex) { + final fromRow = + groupControllers[fromGroupId]?.rowAtIndex(fromIndex); + final toRow = groupControllers[toGroupId]?.rowAtIndex(toIndex); + if (fromRow != null) { + databaseController.moveGroupRow( + fromRow: fromRow, + toRow: toRow, + fromGroupId: fromGroupId, + toGroupId: toGroupId, + ); + } + }, + ); + } + void _dispatch() { on( (event, emit) async { await event.when( initial: () async { + emit(BoardState.initial(viewId)); _startListening(); - await _openGrid(emit); + await _openDatabase(emit); }, - createHeaderRow: (groupId) async { - final rowId = groupControllers[groupId]?.firstRow()?.id; - final position = rowId == null - ? OrderObjectPositionTypePB.Start - : OrderObjectPositionTypePB.Before; + createRow: (groupId, position, title, targetRowId) async { + final primaryField = databaseController.fieldController.fieldInfos + .firstWhereOrNull((element) => element.isPrimary)!; + final void Function(RowDataBuilder)? cellBuilder = title == null + ? null + : (builder) => builder.insertText(primaryField, title); + final result = await RowBackendService.createRow( viewId: databaseController.viewId, groupId: groupId, position: position, - targetRowId: rowId, + targetRowId: targetRowId, + withCells: cellBuilder, ); - result.fold( - (rowMeta) => emit(state.copyWith(recentAddedRowMeta: rowMeta)), - (err) => Log.error(err), - ); - }, - createBottomRow: (groupId) async { - final rowId = groupControllers[groupId]?.lastRow()?.id; - final position = rowId == null - ? OrderObjectPositionTypePB.End - : OrderObjectPositionTypePB.After; - final result = await RowBackendService.createRow( - viewId: databaseController.viewId, - groupId: groupId, - position: position, - targetRowId: rowId, - ); + final startEditing = position != OrderObjectPositionTypePB.End; + final action = PlatformExtension.isMobile + ? DidCreateRowAction.openAsPage + : startEditing + ? DidCreateRowAction.startEditing + : DidCreateRowAction.none; result.fold( - (rowMeta) => emit(state.copyWith(recentAddedRowMeta: rowMeta)), + (rowMeta) { + state.maybeMap( + ready: (value) { + didCreateRow?.value = DidCreateRowResult( + action: action, + rowMeta: rowMeta, + groupId: groupId, + ); + }, + orElse: () {}, + ); + }, (err) => Log.error(err), ); }, @@ -127,89 +141,62 @@ class BoardBloc extends Bloc { final result = await groupBackendSvc.deleteGroup(groupId: groupId); result.fold((_) {}, (err) => Log.error(err)); }, - didCreateRow: (group, row, int? index) { - emit( - state.copyWith( - isEditingRow: true, - editingRow: BoardEditingRow( - group: group, - row: row, - index: index, - ), - ), - ); - _groupItemStartEditing(group, row, true); - }, - didReceiveGridUpdate: (DatabasePB grid) { - emit(state.copyWith(grid: grid)); - }, - didReceiveError: (FlowyError error) { - emit(state.copyWith(noneOrError: error)); + didReceiveError: (error) { + emit(BoardState.error(error: error)); }, didReceiveGroups: (List groups) { - final hiddenGroups = _filterHiddenGroups(hideUngrouped, groups); - emit( - state.copyWith( - hiddenGroups: hiddenGroups, - groupIds: groups.map((group) => group.groupId).toList(), - ), + state.maybeMap( + ready: (state) { + emit( + state.copyWith( + hiddenGroups: _filterHiddenGroups(hideUngrouped, groups), + groupIds: groups.map((group) => group.groupId).toList(), + ), + ); + }, + orElse: () {}, ); }, didUpdateLayoutSettings: (layoutSettings) { - final hiddenGroups = _filterHiddenGroups(hideUngrouped, groupList); - emit( - state.copyWith( - layoutSettings: layoutSettings, - hiddenGroups: hiddenGroups, - ), + state.maybeMap( + ready: (state) { + emit( + state.copyWith( + layoutSettings: layoutSettings, + hiddenGroups: _filterHiddenGroups(hideUngrouped, groupList), + ), + ); + }, + orElse: () {}, ); }, - toggleGroupVisibility: (GroupPB group, bool isVisible) async { - await _toggleGroupVisibility(group, isVisible); + setGroupVisibility: (GroupPB group, bool isVisible) async { + await _setGroupVisibility(group, isVisible); }, toggleHiddenSectionVisibility: (isVisible) async { - final newLayoutSettings = state.layoutSettings!; - newLayoutSettings.freeze(); + await state.maybeMap( + ready: (state) async { + final newLayoutSettings = state.layoutSettings!; + newLayoutSettings.freeze(); - final newLayoutSetting = newLayoutSettings.rebuild( - (message) => message.collapseHiddenGroups = isVisible, - ); + final newLayoutSetting = newLayoutSettings.rebuild( + (message) => message.collapseHiddenGroups = isVisible, + ); - await databaseController.updateLayoutSetting( - boardLayoutSetting: newLayoutSetting, + await databaseController.updateLayoutSetting( + boardLayoutSetting: newLayoutSetting, + ); + }, + orElse: () {}, ); }, reorderGroup: (fromGroupId, toGroupId) async { _reorderGroup(fromGroupId, toGroupId, emit); }, - startEditingRow: (group, row) { - emit( - state.copyWith( - isEditingRow: true, - editingRow: BoardEditingRow( - group: group, - row: row, - index: null, - ), - ), - ); - _groupItemStartEditing(group, row, true); - }, - endEditingRow: (rowId) { - if (state.editingRow != null && state.isEditingRow) { - assert(state.editingRow!.row.id == rowId); - _groupItemStartEditing( - state.editingRow!.group, - state.editingRow!.row, - false, - ); - - emit(state.copyWith(isEditingRow: false, editingRow: null)); - } - }, startEditingHeader: (String groupId) { - emit( - state.copyWith(isEditingHeader: true, editingHeaderId: groupId), + state.maybeMap( + ready: (state) => emit(state.copyWith(editingHeaderId: groupId)), + orElse: () {}, ); }, endEditingHeader: (String groupId, String? groupName) async { @@ -218,41 +205,79 @@ class BoardBloc extends Bloc { groupId: groupId, name: groupName, ); - emit(state.copyWith(isEditingHeader: false)); + state.maybeMap( + ready: (state) => emit(state.copyWith(editingHeaderId: null)), + orElse: () {}, + ); + }, + deleteCards: (groupedRowIds) async { + final rowIds = groupedRowIds.map((e) => e.rowId).toList(); + await RowBackendService.deleteRows(viewId, rowIds); + }, + moveGroupToAdjacentGroup: (groupedRowId, toPrevious) async { + final fromRow = + databaseController.rowCache.getRow(groupedRowId.rowId)?.rowMeta; + final currentGroupIndex = + boardController.groupIds.indexOf(groupedRowId.groupId); + final toGroupIndex = + toPrevious ? currentGroupIndex - 1 : currentGroupIndex + 1; + if (fromRow != null && + toGroupIndex > -1 && + toGroupIndex < boardController.groupIds.length) { + final toGroupId = boardController.groupDatas[toGroupIndex].id; + final result = await databaseController.moveGroupRow( + fromRow: fromRow, + fromGroupId: groupedRowId.groupId, + toGroupId: toGroupId, + ); + result.fold( + (s) { + final previousState = state; + emit( + BoardState.setFocus( + groupedRowIds: [ + GroupedRowId( + groupId: toGroupId, + rowId: groupedRowId.rowId, + ), + ], + ), + ); + emit(previousState); + }, + (f) {}, + ); + } }, ); }, ); } - void _groupItemStartEditing(GroupPB group, RowMetaPB row, bool isEdit) { - final fieldInfo = fieldController.getField(group.fieldId); - if (fieldInfo == null) { - return Log.warn("fieldInfo should not be null"); - } - - boardController.enableGroupDragging(!isEdit); - } - - Future _toggleGroupVisibility(GroupPB group, bool isVisible) async { + Future _setGroupVisibility(GroupPB group, bool isVisible) async { if (group.isDefault) { - final newLayoutSettings = state.layoutSettings!; - newLayoutSettings.freeze(); + await state.maybeMap( + ready: (state) async { + final newLayoutSettings = state.layoutSettings!; + newLayoutSettings.freeze(); - final newLayoutSetting = newLayoutSettings.rebuild( - (message) => message.hideUngroupedColumn = !isVisible, + final newLayoutSetting = newLayoutSettings.rebuild( + (message) => message.hideUngroupedColumn = !isVisible, + ); + + await databaseController.updateLayoutSetting( + boardLayoutSetting: newLayoutSetting, + ); + }, + orElse: () {}, ); - - return databaseController.updateLayoutSetting( - boardLayoutSetting: newLayoutSetting, + } else { + await groupBackendSvc.updateGroup( + fieldId: groupControllers.values.first.group.fieldId, + groupId: group.groupId, + visible: isVisible, ); } - - await groupBackendSvc.updateGroup( - fieldId: groupControllers.values.first.group.fieldId, - groupId: group.groupId, - visible: isVisible, - ); } void _reorderGroup( @@ -277,6 +302,7 @@ class BoardBloc extends Bloc { for (final controller in groupControllers.values) { await controller.dispose(); } + boardController.dispose(); return super.close(); } @@ -284,11 +310,13 @@ class BoardBloc extends Bloc { databaseController.databaseLayoutSetting?.board.hideUngroupedColumn ?? false; - FieldType get groupingFieldType { - final fieldInfo = - databaseController.fieldController.getField(groupList.first.fieldId)!; - - return fieldInfo.fieldType; + FieldType? get groupingFieldType { + if (groupList.isEmpty) { + return null; + } + return databaseController.fieldController + .getField(groupList.first.fieldId) + ?.fieldType; } void initializeGroups(List groups) { @@ -321,16 +349,9 @@ class BoardBloc extends Bloc { } } - RowCache? getRowCache() => databaseController.rowCache; + RowCache get rowCache => databaseController.rowCache; void _startListening() { - final onDatabaseChanged = DatabaseCallbacks( - onDatabaseChanged: (database) { - if (!isClosed) { - add(BoardEvent.didReceiveGridUpdate(database)); - } - }, - ); final onLayoutSettingsChanged = DatabaseLayoutSettingCallbacks( onLayoutSettingsChanged: (layoutSettings) { if (isClosed) { @@ -433,7 +454,6 @@ class BoardBloc extends Bloc { ); databaseController.addListener( - onDatabaseChanged: onDatabaseChanged, onLayoutSettingsChanged: onLayoutSettingsChanged, onGroupChanged: onGroupChanged, ); @@ -451,31 +471,18 @@ class BoardBloc extends Bloc { return [...items]; } - Future _openGrid(Emitter emit) async { - final result = await databaseController.open(); - result.fold( - (grid) { - databaseController.setIsLoading(false); - emit( - state.copyWith( - loadingState: LoadingState.finish(FlowyResult.success(null)), - ), + Future _openDatabase(Emitter emit) { + return databaseController.open().fold( + (datbasePB) => databaseController.setIsLoading(false), + (err) => emit(BoardState.error(error: err)), ); - }, - (err) => emit( - state.copyWith( - loadingState: LoadingState.finish(FlowyResult.failure(err)), - ), - ), - ); } GroupController _initializeGroupController(GroupPB group) { final delegate = GroupControllerDelegateImpl( controller: boardController, fieldController: fieldController, - onNewColumnItem: (groupId, row, index) => - add(BoardEvent.didCreateRow(group, row, index)), + onNewColumnItem: (groupId, row, index) {}, ); final controller = GroupController( @@ -579,71 +586,77 @@ class BoardBloc extends Bloc { @freezed class BoardEvent with _$BoardEvent { const factory BoardEvent.initial() = _InitialBoard; - const factory BoardEvent.createBottomRow(String groupId) = _CreateBottomRow; - const factory BoardEvent.createHeaderRow(String groupId) = _CreateHeaderRow; + const factory BoardEvent.createRow( + String groupId, + OrderObjectPositionTypePB position, + String? title, + String? targetRowId, + ) = _CreateRow; const factory BoardEvent.createGroup(String name) = _CreateGroup; const factory BoardEvent.startEditingHeader(String groupId) = _StartEditingHeader; const factory BoardEvent.endEditingHeader(String groupId, String? groupName) = _EndEditingHeader; - const factory BoardEvent.didCreateRow( - GroupPB group, - RowMetaPB row, - int? index, - ) = _DidCreateRow; - const factory BoardEvent.startEditingRow( - GroupPB group, - RowMetaPB row, - ) = _StartEditRow; - const factory BoardEvent.endEditingRow(RowId rowId) = _EndEditRow; - const factory BoardEvent.toggleGroupVisibility( + const factory BoardEvent.setGroupVisibility( GroupPB group, bool isVisible, - ) = _ToggleGroupVisibility; + ) = _SetGroupVisibility; const factory BoardEvent.toggleHiddenSectionVisibility(bool isVisible) = _ToggleHiddenSectionVisibility; const factory BoardEvent.deleteGroup(String groupId) = _DeleteGroup; const factory BoardEvent.reorderGroup(String fromGroupId, String toGroupId) = _ReorderGroup; const factory BoardEvent.didReceiveError(FlowyError error) = _DidReceiveError; - const factory BoardEvent.didReceiveGridUpdate( - DatabasePB grid, - ) = _DidReceiveGridUpdate; const factory BoardEvent.didReceiveGroups(List groups) = _DidReceiveGroups; const factory BoardEvent.didUpdateLayoutSettings( BoardLayoutSettingPB layoutSettings, ) = _DidUpdateLayoutSettings; + const factory BoardEvent.deleteCards(List groupedRowIds) = + _DeleteCards; + const factory BoardEvent.moveGroupToAdjacentGroup( + GroupedRowId groupedRowId, + bool toPrevious, + ) = _MoveGroupToAdjacentGroup; } @freezed class BoardState with _$BoardState { - const factory BoardState({ + const BoardState._(); + + const factory BoardState.loading() = _BoardLoadingState; + + const factory BoardState.error({ + required FlowyError error, + }) = _BoardErrorState; + + const factory BoardState.ready({ required String viewId, - required DatabasePB? grid, required List groupIds, - required bool isEditingHeader, - required bool isEditingRow, required LoadingState loadingState, required FlowyError? noneOrError, required BoardLayoutSettingPB? layoutSettings, - String? editingHeaderId, - BoardEditingRow? editingRow, - RowMetaPB? recentAddedRowMeta, required List hiddenGroups, - }) = _BoardState; + String? editingHeaderId, + }) = _BoardReadyState; - factory BoardState.initial(String viewId) => BoardState( - grid: null, + const factory BoardState.setFocus({ + required List groupedRowIds, + }) = _BoardSetFocusState; + + factory BoardState.initial(String viewId) => BoardState.ready( viewId: viewId, groupIds: [], - isEditingHeader: false, - isEditingRow: false, noneOrError: null, loadingState: const LoadingState.loading(), layoutSettings: null, hiddenGroups: [], ); + + bool get isLoading => maybeMap(loading: (_) => true, orElse: () => false); + bool get isError => maybeMap(error: (_) => true, orElse: () => false); + bool get isReady => maybeMap(ready: (_) => true, orElse: () => false); + bool get isSetFocus => maybeMap(setFocus: (_) => true, orElse: () => false); } List _filterHiddenGroups(bool hideUngrouped, List groups) { @@ -658,7 +671,7 @@ class GroupItem extends AppFlowyGroupItem { required this.fieldInfo, bool draggable = true, }) { - super.draggable = draggable; + super.draggable.value = draggable; } final RowMetaPB row; @@ -668,6 +681,23 @@ class GroupItem extends AppFlowyGroupItem { String get id => row.id.toString(); } +/// Identifies a card in a database view that has grouping. To support cases +/// in which a card can belong to more than one group at the same time (e.g. +/// FieldType.Multiselect), we include the card's group id as well. +/// +class GroupedRowId extends Equatable { + const GroupedRowId({ + required this.rowId, + required this.groupId, + }); + + final String rowId; + final String groupId; + + @override + List get props => [rowId, groupId]; +} + class GroupControllerDelegateImpl extends GroupControllerDelegate { GroupControllerDelegateImpl({ required this.controller, @@ -743,18 +773,6 @@ class GroupControllerDelegateImpl extends GroupControllerDelegate { } } -class BoardEditingRow { - BoardEditingRow({ - required this.group, - required this.row, - required this.index, - }); - - GroupPB group; - RowMetaPB row; - int? index; -} - class GroupData { GroupData({ required this.group, @@ -779,3 +797,21 @@ class CheckboxGroup { // pub const CHECK: &str = "Yes"; bool get isCheck => group.groupId == "Yes"; } + +enum DidCreateRowAction { + none, + openAsPage, + startEditing, +} + +class DidCreateRowResult { + DidCreateRowResult({ + required this.action, + required this.rowMeta, + required this.groupId, + }); + + final DidCreateRowAction action; + final RowMetaPB rowMeta; + final String groupId; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart index 352345e2d2..64415ff85b 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart @@ -1,12 +1,11 @@ -import 'dart:collection'; +import 'dart:io'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/presentation/database/board/mobile_board_content.dart'; -import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_state_container.dart'; +import 'package:appflowy/mobile/presentation/database/board/mobile_board_page.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; -import 'package:appflowy/plugins/database/application/row/row_cache.dart'; import 'package:appflowy/plugins/database/application/row/row_controller.dart'; +import 'package:appflowy/plugins/database/board/application/board_actions_bloc.dart'; import 'package:appflowy/plugins/database/board/presentation/widgets/board_column_header.dart'; import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/header/field_type_extension.dart'; @@ -15,7 +14,8 @@ import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy/plugins/database/widgets/card/card_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell/card_cell_style_maps/desktop_board_card_cell_style.dart'; import 'package:appflowy/plugins/database/widgets/row/row_detail.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; +import 'package:appflowy/shared/conditional_listenable_builder.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_board/appflowy_board.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -28,12 +28,13 @@ import 'package:flutter/material.dart' hide Card; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../../workspace/application/view/view_bloc.dart'; import '../../widgets/card/card.dart'; import '../../widgets/cell/card_cell_builder.dart'; import '../application/board_bloc.dart'; import 'toolbar/board_setting_bar.dart'; +import 'widgets/board_focus_scope.dart'; import 'widgets/board_hidden_groups.dart'; +import 'widgets/board_shortcut_container.dart'; class BoardPageTabBarBuilderImpl extends DatabaseTabBarItemBuilder { final _toggleExtension = ToggleExtensionNotifier(); @@ -46,7 +47,17 @@ class BoardPageTabBarBuilderImpl extends DatabaseTabBarItemBuilder { bool shrinkWrap, String? initialRowId, ) => - BoardPage(view: view, databaseController: controller); + PlatformExtension.isDesktop + ? DesktopBoardPage( + key: _makeValueKey(controller), + view: view, + databaseController: controller, + ) + : MobileBoardPage( + key: _makeValueKey(controller), + view: view, + databaseController: controller, + ); @override Widget settingBar(BuildContext context, DatabaseController controller) => @@ -79,12 +90,13 @@ class BoardPageTabBarBuilderImpl extends DatabaseTabBarItemBuilder { ValueKey(controller.viewId); } -class BoardPage extends StatelessWidget { - BoardPage({ +class DesktopBoardPage extends StatefulWidget { + const DesktopBoardPage({ + super.key, required this.view, required this.databaseController, this.onEditStateChanged, - }) : super(key: ValueKey(view.id)); + }); final ViewPB view; @@ -93,54 +105,154 @@ class BoardPage extends StatelessWidget { /// Called when edit state changed final VoidCallback? onEditStateChanged; + @override + State createState() => _DesktopBoardPageState(); +} + +class _DesktopBoardPageState extends State { + late final AppFlowyBoardController _boardController = AppFlowyBoardController( + onMoveGroup: (fromGroupId, fromIndex, toGroupId, toIndex) => + widget.databaseController.moveGroup( + fromGroupId: fromGroupId, + toGroupId: toGroupId, + ), + onMoveGroupItem: (groupId, fromIndex, toIndex) { + final groupControllers = _boardBloc.groupControllers; + final fromRow = groupControllers[groupId]?.rowAtIndex(fromIndex); + final toRow = groupControllers[groupId]?.rowAtIndex(toIndex); + if (fromRow != null) { + widget.databaseController.moveGroupRow( + fromRow: fromRow, + toRow: toRow, + fromGroupId: groupId, + toGroupId: groupId, + ); + } + }, + onMoveGroupItemToGroup: (fromGroupId, fromIndex, toGroupId, toIndex) { + final groupControllers = _boardBloc.groupControllers; + final fromRow = groupControllers[fromGroupId]?.rowAtIndex(fromIndex); + final toRow = groupControllers[toGroupId]?.rowAtIndex(toIndex); + if (fromRow != null) { + widget.databaseController.moveGroupRow( + fromRow: fromRow, + toRow: toRow, + fromGroupId: fromGroupId, + toGroupId: toGroupId, + ); + } + }, + onStartDraggingCard: (groupId, index) { + final groupControllers = _boardBloc.groupControllers; + final toRow = groupControllers[groupId]?.rowAtIndex(index); + if (toRow != null) { + _focusScope.clear(); + } + }, + ); + + late final _focusScope = BoardFocusScope( + boardController: _boardController, + ); + late final BoardBloc _boardBloc; + late final BoardActionsCubit _boardActionsCubit; + late final ValueNotifier _didCreateRow; + + @override + void initState() { + super.initState(); + _didCreateRow = ValueNotifier(null)..addListener(_handleDidCreateRow); + _boardBloc = BoardBloc( + databaseController: widget.databaseController, + didCreateRow: _didCreateRow, + boardController: _boardController, + )..add(const BoardEvent.initial()); + _boardActionsCubit = BoardActionsCubit( + databaseController: widget.databaseController, + ); + } + + @override + void dispose() { + _focusScope.dispose(); + _boardBloc.close(); + _boardActionsCubit.close(); + _didCreateRow + ..removeListener(_handleDidCreateRow) + ..dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return BlocProvider( - create: (context) => BoardBloc( - view: view, - databaseController: databaseController, - )..add(const BoardEvent.initial()), + return MultiBlocProvider( + providers: [ + BlocProvider.value( + value: _boardBloc, + ), + BlocProvider.value( + value: _boardActionsCubit, + ), + ], child: BlocBuilder( - buildWhen: (p, c) => p.loadingState != c.loadingState, - builder: (context, state) => state.loadingState.when( - loading: () => const Center( + builder: (context, state) => state.maybeMap( + loading: (_) => const Center( child: CircularProgressIndicator.adaptive(), ), - idle: () => const SizedBox.shrink(), - finish: (result) => result.fold( - (_) => PlatformExtension.isMobile - ? const MobileBoardContent() - : DesktopBoardContent(onEditStateChanged: onEditStateChanged), - (err) => PlatformExtension.isMobile - ? FlowyMobileStateContainer.error( - emoji: '🛸', - title: LocaleKeys.board_mobile_failedToLoad.tr(), - errorMsg: err.toString(), - ) - : FlowyErrorPage.message( - err.toString(), - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), - ), + error: (err) => FlowyErrorPage.message( + err.toString(), + howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + ), + orElse: () => _BoardContent( + onEditStateChanged: widget.onEditStateChanged, + focusScope: _focusScope, + boardController: _boardController, ), ), ), ); } + + void _handleDidCreateRow() async { + // work around: wait for the new card to be inserted into the board before enabling edit + await Future.delayed(const Duration(milliseconds: 50)); + if (_didCreateRow.value != null) { + final result = _didCreateRow.value!; + switch (result.action) { + case DidCreateRowAction.openAsPage: + _boardActionsCubit.openCard(result.rowMeta); + break; + case DidCreateRowAction.startEditing: + _boardActionsCubit.startEditingRow( + GroupedRowId( + groupId: result.groupId, + rowId: result.rowMeta.id, + ), + ); + break; + default: + break; + } + } + } } -class DesktopBoardContent extends StatefulWidget { - const DesktopBoardContent({ - super.key, +class _BoardContent extends StatefulWidget { + const _BoardContent({ + required this.boardController, + required this.focusScope, this.onEditStateChanged, }); + final AppFlowyBoardController boardController; + final BoardFocusScope focusScope; final VoidCallback? onEditStateChanged; @override - State createState() => _DesktopBoardContentState(); + State<_BoardContent> createState() => _BoardContentState(); } -class _DesktopBoardContentState extends State { +class _BoardContentState extends State<_BoardContent> { final ScrollController scrollController = ScrollController(); final AppFlowyBoardScrollController scrollManager = AppFlowyBoardScrollController(); @@ -148,16 +260,19 @@ class _DesktopBoardContentState extends State { final config = const AppFlowyBoardConfig( groupMargin: EdgeInsets.symmetric(horizontal: 4), groupBodyPadding: EdgeInsets.symmetric(horizontal: 4), - groupFooterPadding: EdgeInsets.fromLTRB(4, 14, 4, 4), + groupFooterPadding: EdgeInsets.fromLTRB(8, 14, 8, 4), groupHeaderPadding: EdgeInsets.symmetric(horizontal: 8), cardMargin: EdgeInsets.symmetric(horizontal: 4, vertical: 3), stretchGroupHeight: false, ); late final cellBuilder = CardCellBuilder( - databaseController: context.read().databaseController, + databaseController: databaseController, ); + DatabaseController get databaseController => + context.read().databaseController; + @override void dispose() { scrollController.dispose(); @@ -166,15 +281,49 @@ class _DesktopBoardContentState extends State { @override Widget build(BuildContext context) { - return BlocListener( - listener: (context, state) { - widget.onEditStateChanged?.call(); - }, - child: BlocBuilder( - builder: (context, state) { - final showCreateGroupButton = - context.read().groupingFieldType.canCreateNewGroup; - return Padding( + return MultiBlocListener( + listeners: [ + BlocListener( + listener: (context, state) { + state.maybeMap( + ready: (value) { + widget.onEditStateChanged?.call(); + }, + orElse: () {}, + ); + }, + ), + BlocListener( + listener: (context, state) { + state.maybeMap( + openCard: (value) { + _openCard( + context: context, + databaseController: + context.read().databaseController, + rowMeta: value.rowMeta, + ); + }, + setFocus: (value) { + widget.focusScope.focusedGroupedRows = value.groupedRowIds; + }, + startEditingRow: (value) { + widget.boardController.enableGroupDragging(false); + widget.focusScope.clear(); + }, + endEditingRow: (value) { + widget.boardController.enableGroupDragging(true); + }, + orElse: () {}, + ); + }, + ), + ], + child: FocusScope( + autofocus: true, + child: BoardShortcutContainer( + focusScope: widget.focusScope, + child: Padding( padding: const EdgeInsets.only(top: 8.0), child: AppFlowyBoard( boardScrollController: scrollManager, @@ -183,7 +332,11 @@ class _DesktopBoardContentState extends State { groupConstraints: const BoxConstraints.tightFor(width: 256), config: config, leading: HiddenGroupsColumn(margin: config.groupHeaderPadding), - trailing: showCreateGroupButton + trailing: context + .read() + .groupingFieldType + ?.canCreateNewGroup ?? + false ? BoardTrailing(scrollController: scrollController) : const HSpace(40), headerBuilder: (_, groupData) => BlocProvider.value( @@ -193,112 +346,369 @@ class _DesktopBoardContentState extends State { margin: config.groupHeaderPadding, ), ), - footerBuilder: _buildFooter, - cardBuilder: (_, column, columnItem) => _buildCard( - context, - column, - columnItem, + footerBuilder: (_, groupData) => MultiBlocProvider( + providers: [ + BlocProvider.value( + value: context.read(), + ), + BlocProvider.value( + value: context.read(), + ), + ], + child: BoardColumnFooter( + columnData: groupData, + boardConfig: config, + scrollManager: scrollManager, + ), + ), + cardBuilder: (_, column, columnItem) => MultiBlocProvider( + key: ValueKey("board_card_${column.id}_${columnItem.id}"), + providers: [ + BlocProvider.value( + value: context.read(), + ), + BlocProvider.value( + value: context.read(), + ), + ], + child: _BoardCard( + afGroupData: column, + groupItem: columnItem as GroupItem, + boardConfig: config, + notifier: widget.focusScope, + cellBuilder: cellBuilder, + ), ), ), - ); + ), + ), + ), + ); + } +} + +@visibleForTesting +class BoardColumnFooter extends StatefulWidget { + const BoardColumnFooter({ + super.key, + required this.columnData, + required this.boardConfig, + required this.scrollManager, + }); + + final AppFlowyGroupData columnData; + final AppFlowyBoardConfig boardConfig; + final AppFlowyBoardScrollController scrollManager; + + @override + State createState() => _BoardColumnFooterState(); +} + +class _BoardColumnFooterState extends State { + final TextEditingController _textController = TextEditingController(); + late final FocusNode _focusNode; + bool _isCreating = false; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode( + onKeyEvent: (node, event) { + if (_focusNode.hasFocus && + event.logicalKey == LogicalKeyboardKey.escape) { + _focusNode.unfocus(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + )..addListener(() { + if (!_focusNode.hasFocus) { + setState(() => _isCreating = false); + } + }); + } + + @override + void dispose() { + _textController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_isCreating) { + _focusNode.requestFocus(); + } + }); + return Padding( + padding: widget.boardConfig.groupFooterPadding, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + child: + _isCreating ? _createCardsTextField() : _startCreatingCardsButton(), + ), + ); + } + + Widget _createCardsTextField() { + const nada = DoNothingAndStopPropagationIntent(); + return Shortcuts( + shortcuts: { + const SingleActivator(LogicalKeyboardKey.arrowUp): nada, + const SingleActivator(LogicalKeyboardKey.arrowDown): nada, + const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): nada, + const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): nada, + const SingleActivator(LogicalKeyboardKey.keyE): nada, + const SingleActivator(LogicalKeyboardKey.keyN): nada, + const SingleActivator(LogicalKeyboardKey.delete): nada, + const SingleActivator(LogicalKeyboardKey.backspace): nada, + const SingleActivator(LogicalKeyboardKey.enter): nada, + const SingleActivator(LogicalKeyboardKey.numpadEnter): nada, + const SingleActivator(LogicalKeyboardKey.comma): nada, + const SingleActivator(LogicalKeyboardKey.period): nada, + SingleActivator( + LogicalKeyboardKey.arrowUp, + shift: true, + meta: Platform.isMacOS, + control: !Platform.isMacOS, + ): nada, + }, + child: FlowyTextField( + hintTextConstraints: const BoxConstraints(maxHeight: 36), + controller: _textController, + focusNode: _focusNode, + onSubmitted: (name) { + context.read().add( + BoardEvent.createRow( + widget.columnData.id, + OrderObjectPositionTypePB.End, + name, + null, + ), + ); + widget.scrollManager.scrollToBottom(widget.columnData.id); + _textController.clear(); + _focusNode.requestFocus(); }, ), ); } - Widget _buildFooter(BuildContext context, AppFlowyGroupData columnData) { - return Padding( - padding: config.groupFooterPadding, + Widget _startCreatingCardsButton() { + return BlocListener( + listener: (context, state) { + state.maybeWhen( + startCreateBottomRow: (groupId) { + if (groupId == widget.columnData.id) { + setState(() => _isCreating = true); + } + }, + orElse: () {}, + ); + }, child: FlowyTooltip( message: LocaleKeys.board_column_addToColumnBottomTooltip.tr(), - child: FlowyHover( - child: AppFlowyGroupFooter( - height: 36, - icon: FlowySvg( + child: SizedBox( + height: 36, + child: FlowyButton( + leftIcon: FlowySvg( FlowySvgs.add_s, color: Theme.of(context).hintColor, ), - title: FlowyText.medium( + text: FlowyText.medium( LocaleKeys.board_column_createNewCard.tr(), color: Theme.of(context).hintColor, ), - onAddButtonClick: () => context - .read() - .add(BoardEvent.createBottomRow(columnData.id)), + onTap: () { + setState(() => _isCreating = true); + }, ), ), ), ); } +} - Widget _buildCard( - BuildContext context, - AppFlowyGroupData afGroupData, - AppFlowyGroupItem afGroupItem, - ) { +class _BoardCard extends StatefulWidget { + const _BoardCard({ + required this.afGroupData, + required this.groupItem, + required this.boardConfig, + required this.cellBuilder, + required this.notifier, + }); + + final AppFlowyGroupData afGroupData; + final GroupItem groupItem; + final AppFlowyBoardConfig boardConfig; + final CardCellBuilder cellBuilder; + final BoardFocusScope notifier; + + @override + State<_BoardCard> createState() => _BoardCardState(); +} + +class _BoardCardState extends State<_BoardCard> { + bool _isEditing = false; + + @override + Widget build(BuildContext context) { final boardBloc = context.read(); - final groupItem = afGroupItem as GroupItem; - final groupData = afGroupData.customData as GroupData; - final rowCache = boardBloc.getRowCache(); - final rowInfo = rowCache?.getRow(groupItem.row.id); - /// Return placeholder widget if the rowCache or rowInfo is null. - if (rowCache == null) { - return SizedBox.shrink(key: ObjectKey(groupItem)); - } + final groupData = widget.afGroupData.customData as GroupData; + final rowCache = boardBloc.rowCache; final databaseController = boardBloc.databaseController; - final viewId = boardBloc.viewId; + final rowMeta = + rowCache.getRow(widget.groupItem.id)?.rowMeta ?? widget.groupItem.row; - final isEditing = boardBloc.state.isEditingRow && - boardBloc.state.editingRow?.row.id == groupItem.row.id; + const nada = DoNothingAndStopPropagationIntent(); - final groupItemId = "${groupData.group.groupId}${groupItem.row.id}"; - final rowMeta = rowInfo?.rowMeta ?? groupItem.row; + return BlocListener( + listener: (context, state) { + state.maybeMap( + startEditingRow: (value) { + if (value.groupedRowId.rowId == widget.groupItem.id && + value.groupedRowId.groupId == groupData.group.groupId) { + setState(() => _isEditing = true); + } + }, + endEditingRow: (_) { + if (_isEditing) { + setState(() => _isEditing = false); + } + }, + createRow: (value) { + if ((_isEditing && value.groupedRowId == null) || + (value.groupedRowId?.rowId == widget.groupItem.id && + value.groupedRowId?.groupId == groupData.group.groupId)) { + context.read().add( + BoardEvent.createRow( + groupData.group.groupId, + value.position == CreateBoardCardRelativePosition.before + ? OrderObjectPositionTypePB.Before + : OrderObjectPositionTypePB.After, + null, + widget.groupItem.row.id, + ), + ); + } + }, + orElse: () {}, + ); + }, + child: Shortcuts( + shortcuts: { + const SingleActivator(LogicalKeyboardKey.arrowUp): nada, + const SingleActivator(LogicalKeyboardKey.arrowDown): nada, + const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): nada, + const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): + nada, + const SingleActivator(LogicalKeyboardKey.keyE): nada, + const SingleActivator(LogicalKeyboardKey.keyN): nada, + const SingleActivator(LogicalKeyboardKey.delete): nada, + const SingleActivator(LogicalKeyboardKey.backspace): nada, + const SingleActivator(LogicalKeyboardKey.enter): nada, + const SingleActivator(LogicalKeyboardKey.numpadEnter): nada, + const SingleActivator(LogicalKeyboardKey.comma): nada, + const SingleActivator(LogicalKeyboardKey.period): nada, + SingleActivator( + LogicalKeyboardKey.arrowUp, + shift: true, + meta: Platform.isMacOS, + control: !Platform.isMacOS, + ): nada, + }, + child: ConditionalListenableBuilder>( + valueListenable: widget.notifier, + buildWhen: (previous, current) { + final focusItem = GroupedRowId( + groupId: groupData.group.groupId, + rowId: rowMeta.id, + ); + final previousContainsFocus = previous.contains(focusItem); + final currentContainsFocus = current.contains(focusItem); - return Container( - key: ValueKey(groupItemId), - margin: config.cardMargin, - decoration: _makeBoxDecoration(context), - child: RowCard( - fieldController: databaseController.fieldController, - rowMeta: rowMeta, - viewId: viewId, - rowCache: rowCache, - groupingFieldId: groupItem.fieldInfo.id, - isEditing: isEditing, - cellBuilder: cellBuilder, - openCard: (context) => _openCard( - context: context, - databaseController: databaseController, - groupId: groupData.group.groupId, - rowMeta: context.read().state.rowMeta, - ), - styleConfiguration: RowCardStyleConfiguration( - cellStyleMap: desktopBoardCardCellStyleMap(context), - hoverStyle: HoverStyle( - hoverColor: Theme.of(context).brightness == Brightness.light - ? const Color(0x0F1F2329) - : const Color(0x0FEFF4FB), - foregroundColorOnHover: Theme.of(context).colorScheme.onBackground, + return previousContainsFocus != currentContainsFocus; + }, + builder: (context, focusedItems, child) => Container( + margin: widget.boardConfig.cardMargin, + decoration: _makeBoxDecoration( + context, + groupData.group.groupId, + widget.groupItem.id, + ), + child: child, + ), + child: RowCard( + fieldController: databaseController.fieldController, + rowMeta: rowMeta, + viewId: boardBloc.viewId, + rowCache: rowCache, + groupingFieldId: widget.groupItem.fieldInfo.id, + isEditing: _isEditing, + cellBuilder: widget.cellBuilder, + onTap: (context) => _openCard( + context: context, + databaseController: databaseController, + rowMeta: context.read().state.rowMeta, + ), + onShiftTap: (_) { + Focus.of(context).requestFocus(); + widget.notifier.toggle( + GroupedRowId( + rowId: widget.groupItem.row.id, + groupId: groupData.group.groupId, + ), + ); + }, + styleConfiguration: RowCardStyleConfiguration( + cellStyleMap: desktopBoardCardCellStyleMap(context), + hoverStyle: HoverStyle( + hoverColor: Theme.of(context).brightness == Brightness.light + ? const Color(0x0F1F2329) + : const Color(0x0FEFF4FB), + foregroundColorOnHover: + Theme.of(context).colorScheme.onBackground, + ), + ), + onStartEditing: () => + context.read().startEditingRow( + GroupedRowId( + groupId: groupData.group.groupId, + rowId: rowMeta.id, + ), + ), + onEndEditing: () => context.read().endEditing( + GroupedRowId( + groupId: groupData.group.groupId, + rowId: rowMeta.id, + ), + ), ), ), - onStartEditing: () => - boardBloc.add(BoardEvent.startEditingRow(groupData.group, rowMeta)), - onEndEditing: () => boardBloc.add(BoardEvent.endEditingRow(rowMeta.id)), ), ); } - BoxDecoration _makeBoxDecoration(BuildContext context) { + BoxDecoration _makeBoxDecoration( + BuildContext context, + String groupId, + String rowId, + ) { return BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: const BorderRadius.all(Radius.circular(6)), border: Border.fromBorderSide( BorderSide( - color: Theme.of(context).brightness == Brightness.light - ? const Color(0xFF1F2329).withOpacity(0.12) - : const Color(0xFF59647A), + color: widget.notifier + .isFocused(GroupedRowId(rowId: rowId, groupId: groupId)) + ? Theme.of(context).colorScheme.primary + : Theme.of(context).brightness == Brightness.light + ? const Color(0xFF1F2329).withOpacity(0.12) + : const Color(0xFF59647A), ), ), boxShadow: [ @@ -314,39 +724,6 @@ class _DesktopBoardContentState extends State { ], ); } - - void _openCard({ - required BuildContext context, - required DatabaseController databaseController, - required String groupId, - required RowMetaPB rowMeta, - }) { - final rowInfo = RowInfo( - viewId: databaseController.viewId, - fields: - UnmodifiableListView(databaseController.fieldController.fieldInfos), - rowMeta: rowMeta, - rowId: rowMeta.id, - ); - - final rowController = RowController( - rowMeta: rowInfo.rowMeta, - viewId: rowInfo.viewId, - rowCache: databaseController.rowCache, - groupId: groupId, - ); - - FlowyOverlay.show( - context: context, - builder: (_) => BlocProvider.value( - value: context.read(), - child: RowDetailPage( - databaseController: databaseController, - rowController: rowController, - ), - ), - ); - } } class BoardTrailing extends StatefulWidget { @@ -458,3 +835,23 @@ class _BoardTrailingState extends State { } } } + +void _openCard({ + required BuildContext context, + required DatabaseController databaseController, + required RowMetaPB rowMeta, +}) { + final rowController = RowController( + rowMeta: rowMeta, + viewId: databaseController.viewId, + rowCache: databaseController.rowCache, + ); + + FlowyOverlay.show( + context: context, + builder: (_) => RowDetailPage( + databaseController: databaseController, + rowController: rowController, + ), + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_column_header.dart b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_column_header.dart index 06eb9be584..ff06319f73 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_column_header.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_column_header.dart @@ -4,8 +4,7 @@ import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/header/field_type_extension.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/group.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_board/appflowy_board.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -65,72 +64,83 @@ class _BoardColumnHeaderState extends State { return BlocBuilder( builder: (context, state) { - if (state.isEditingHeader) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _focusNode.requestFocus(); - }); - } + return state.maybeMap( + orElse: () => const SizedBox.shrink(), + ready: (state) { + if (state.editingHeaderId != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNode.requestFocus(); + }); + } - Widget title = Expanded( - child: FlowyText.medium( - widget.groupData.headerData.groupName, - overflow: TextOverflow.ellipsis, - ), - ); + Widget title = Expanded( + child: FlowyText.medium( + widget.groupData.headerData.groupName, + overflow: TextOverflow.ellipsis, + ), + ); - if (!boardCustomData.group.isDefault && - boardCustomData.fieldType.canEditHeader) { - title = Flexible( - fit: FlexFit.tight, - child: FlowyTooltip( - message: LocaleKeys.board_column_renameGroupTooltip.tr(), - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () => context - .read() - .add(BoardEvent.startEditingHeader(widget.groupData.id)), - child: FlowyText.medium( - widget.groupData.headerData.groupName, - overflow: TextOverflow.ellipsis, + if (!boardCustomData.group.isDefault && + boardCustomData.fieldType.canEditHeader) { + title = Flexible( + fit: FlexFit.tight, + child: FlowyTooltip( + message: LocaleKeys.board_column_renameGroupTooltip.tr(), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => context.read().add( + BoardEvent.startEditingHeader(widget.groupData.id), + ), + child: FlowyText.medium( + widget.groupData.headerData.groupName, + overflow: TextOverflow.ellipsis, + ), + ), ), ), + ); + } + + if (state.editingHeaderId == widget.groupData.id) { + title = _buildTextField(context); + } + + return Padding( + padding: widget.margin, + child: SizedBox( + height: 50, + child: Row( + children: [ + _buildHeaderIcon(boardCustomData), + title, + const HSpace(6), + _groupOptionsButton(context), + const HSpace(4), + FlowyTooltip( + message: + LocaleKeys.board_column_addToColumnTopTooltip.tr(), + preferBelow: false, + child: FlowyIconButton( + width: 20, + icon: const FlowySvg(FlowySvgs.add_s), + iconColorOnHover: + Theme.of(context).colorScheme.onSurface, + onPressed: () => context.read().add( + BoardEvent.createRow( + widget.groupData.id, + OrderObjectPositionTypePB.Start, + null, + null, + ), + ), + ), + ), + ], + ), ), - ), - ); - } - - if (state.isEditingHeader && - state.editingHeaderId == widget.groupData.id) { - title = _buildTextField(context); - } - - return Padding( - padding: widget.margin, - child: SizedBox( - height: 50, - child: Row( - children: [ - _buildHeaderIcon(boardCustomData), - title, - const HSpace(6), - _groupOptionsButton(context), - const HSpace(4), - FlowyTooltip( - message: LocaleKeys.board_column_addToColumnTopTooltip.tr(), - preferBelow: false, - child: FlowyIconButton( - width: 20, - icon: const FlowySvg(FlowySvgs.add_s), - iconColorOnHover: Theme.of(context).colorScheme.onSurface, - onPressed: () => context - .read() - .add(BoardEvent.createHeaderRow(widget.groupData.id)), - ), - ), - ], - ), - ), + ); + }, ); }, ); @@ -257,7 +267,7 @@ enum GroupOptions { case hide: context .read() - .add(BoardEvent.toggleGroupVisibility(group, false)); + .add(BoardEvent.setGroupVisibility(group, false)); break; case delete: NavigatorAlertDialog( diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_focus_scope.dart b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_focus_scope.dart new file mode 100644 index 0000000000..e636ab626d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_focus_scope.dart @@ -0,0 +1,365 @@ +import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; +import 'package:appflowy_board/appflowy_board.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; + +class BoardFocusScope extends ChangeNotifier + implements ValueListenable> { + BoardFocusScope({ + required this.boardController, + }); + + final AppFlowyBoardController boardController; + List _focusedCards = []; + + @override + List get value => _focusedCards; + + UnmodifiableListView get focusedGroupedRows => + UnmodifiableListView(_focusedCards); + + set focusedGroupedRows(List focusedGroupedRows) { + _deepCopy(); + _focusedCards + ..clear() + ..addAll(focusedGroupedRows); + notifyListeners(); + } + + bool isFocused(GroupedRowId groupedRowId) => + _focusedCards.contains(groupedRowId); + + void toggle(GroupedRowId groupedRowId) { + _deepCopy(); + if (_focusedCards.contains(groupedRowId)) { + _focusedCards.remove(groupedRowId); + } else { + _focusedCards.add(groupedRowId); + } + notifyListeners(); + } + + void focusNext() { + _deepCopy(); + + // if no card is focused, focus on the first card in the board + if (_focusedCards.isEmpty) { + _focusFirstCard(); + notifyListeners(); + return; + } + + final lastFocusedCard = _focusedCards.last; + final groupController = boardController.controller(lastFocusedCard.groupId); + final iterable = groupController?.items + .skipWhile((item) => item.id != lastFocusedCard.rowId); + + // if the last-focused card's group cannot be found, or if the last-focused card cannot be found in the group, focus on the first card in the board + if (iterable == null || iterable.isEmpty) { + _focusFirstCard(); + notifyListeners(); + return; + } + + if (iterable.length == 1) { + // focus on the first card in the next group + final group = boardController.groupDatas + .skipWhile((item) => item.id != lastFocusedCard.groupId) + .skip(1) + .firstWhereOrNull((groupData) => groupData.items.isNotEmpty); + if (group != null) { + _focusedCards + ..clear() + ..add( + GroupedRowId( + rowId: group.items.first.id, + groupId: group.id, + ), + ); + } + } else { + // focus on the next card in the same group + _focusedCards + ..clear() + ..add( + GroupedRowId( + rowId: iterable.elementAt(1).id, + groupId: lastFocusedCard.groupId, + ), + ); + } + + notifyListeners(); + } + + void focusPrevious() { + _deepCopy(); + + // if no card is focused, focus on the last card in the board + if (_focusedCards.isEmpty) { + _focusLastCard(); + notifyListeners(); + return; + } + + final lastFocusedCard = _focusedCards.last; + final groupController = boardController.controller(lastFocusedCard.groupId); + final iterable = groupController?.items.reversed + .skipWhile((item) => item.id != lastFocusedCard.rowId); + + // if the last-focused card's group cannot be found or if the last-focused card cannot be found in the group, focus on the last card in the board + if (iterable == null || iterable.isEmpty) { + _focusLastCard(); + notifyListeners(); + return; + } + + if (iterable.length == 1) { + // focus on the last card in the previous group + final group = boardController.groupDatas.reversed + .skipWhile((item) => item.id != lastFocusedCard.groupId) + .skip(1) + .firstWhereOrNull((groupData) => groupData.items.isNotEmpty); + if (group != null) { + _focusedCards + ..clear() + ..add( + GroupedRowId( + rowId: group.items.last.id, + groupId: group.id, + ), + ); + } + } else { + // focus on the next card in the same group + _focusedCards + ..clear() + ..add( + GroupedRowId( + rowId: iterable.elementAt(1).id, + groupId: lastFocusedCard.groupId, + ), + ); + } + + notifyListeners(); + } + + void adjustRangeDown() { + _deepCopy(); + + // if no card is focused, focus on the first card in the board + if (_focusedCards.isEmpty) { + _focusFirstCard(); + notifyListeners(); + return; + } + + final firstFocusedCard = _focusedCards.first; + final lastFocusedCard = _focusedCards.last; + + // determine whether to shrink or expand the selection + bool isExpand = false; + if (_focusedCards.length == 1) { + isExpand = true; + } else { + final firstGroupIndex = boardController.groupDatas + .indexWhere((element) => element.id == firstFocusedCard.groupId); + final lastGroupIndex = boardController.groupDatas + .indexWhere((element) => element.id == lastFocusedCard.groupId); + + if (firstGroupIndex == -1 || lastGroupIndex == -1) { + _focusFirstCard(); + notifyListeners(); + return; + } + + if (firstGroupIndex < lastGroupIndex) { + isExpand = true; + } else if (firstGroupIndex > lastGroupIndex) { + isExpand = false; + } else { + final groupItems = + boardController.groupDatas.elementAt(firstGroupIndex).items; + final firstCardIndex = + groupItems.indexWhere((item) => item.id == firstFocusedCard.rowId); + final lastCardIndex = + groupItems.indexWhere((item) => item.id == lastFocusedCard.rowId); + + if (firstCardIndex == -1 || lastCardIndex == -1) { + _focusFirstCard(); + notifyListeners(); + return; + } + + isExpand = firstCardIndex < lastCardIndex; + } + } + + if (isExpand) { + final groupController = + boardController.controller(lastFocusedCard.groupId); + + if (groupController == null) { + _focusFirstCard(); + notifyListeners(); + return; + } + + final iterable = groupController.items + .skipWhile((item) => item.id != lastFocusedCard.rowId); + + if (iterable.length == 1) { + // focus on the first card in the next group + final group = boardController.groupDatas + .skipWhile((item) => item.id != lastFocusedCard.groupId) + .skip(1) + .firstWhereOrNull((groupData) => groupData.items.isNotEmpty); + if (group != null) { + _focusedCards.add( + GroupedRowId( + rowId: group.items.first.id, + groupId: group.id, + ), + ); + } + } else { + _focusedCards.add( + GroupedRowId( + rowId: iterable.elementAt(1).id, + groupId: lastFocusedCard.groupId, + ), + ); + } + } else { + _focusedCards.removeLast(); + } + + notifyListeners(); + } + + void adjustRangeUp() { + _deepCopy(); + + // if no card is focused, focus on the first card in the board + if (_focusedCards.isEmpty) { + _focusLastCard(); + notifyListeners(); + return; + } + + final firstFocusedCard = _focusedCards.first; + final lastFocusedCard = _focusedCards.last; + + // determine whether to shrink or expand the selection + bool isExpand = false; + if (_focusedCards.length == 1) { + isExpand = true; + } else { + final firstGroupIndex = boardController.groupDatas + .indexWhere((element) => element.id == firstFocusedCard.groupId); + final lastGroupIndex = boardController.groupDatas + .indexWhere((element) => element.id == lastFocusedCard.groupId); + + if (firstGroupIndex == -1 || lastGroupIndex == -1) { + _focusLastCard(); + notifyListeners(); + return; + } + + if (firstGroupIndex < lastGroupIndex) { + isExpand = false; + } else if (firstGroupIndex > lastGroupIndex) { + isExpand = true; + } else { + final groupItems = + boardController.groupDatas.elementAt(firstGroupIndex).items; + final firstCardIndex = + groupItems.indexWhere((item) => item.id == firstFocusedCard.rowId); + final lastCardIndex = + groupItems.indexWhere((item) => item.id == lastFocusedCard.rowId); + + if (firstCardIndex == -1 || lastCardIndex == -1) { + _focusLastCard(); + notifyListeners(); + return; + } + + isExpand = firstCardIndex > lastCardIndex; + } + } + + if (isExpand) { + final groupController = + boardController.controller(lastFocusedCard.groupId); + + if (groupController == null) { + _focusLastCard(); + notifyListeners(); + return; + } + + final iterable = groupController.items.reversed + .skipWhile((item) => item.id != lastFocusedCard.rowId); + + if (iterable.length == 1) { + // focus on the last card in the previous group + final group = boardController.groupDatas.reversed + .skipWhile((item) => item.id != lastFocusedCard.groupId) + .skip(1) + .firstWhereOrNull((groupData) => groupData.items.isNotEmpty); + if (group != null) { + _focusedCards.add( + GroupedRowId( + rowId: group.items.last.id, + groupId: group.id, + ), + ); + } + } else { + _focusedCards.add( + GroupedRowId( + rowId: iterable.elementAt(1).id, + groupId: lastFocusedCard.groupId, + ), + ); + } + } else { + _focusedCards.removeLast(); + } + + notifyListeners(); + } + + void clear() { + _deepCopy(); + _focusedCards.clear(); + notifyListeners(); + } + + void _focusFirstCard() { + _focusedCards.clear(); + final firstGroup = boardController.groupDatas + .firstWhereOrNull((group) => group.items.isNotEmpty); + final firstCard = firstGroup?.items.firstOrNull; + if (firstCard != null) { + _focusedCards + .add(GroupedRowId(rowId: firstCard.id, groupId: firstGroup!.id)); + } + } + + void _focusLastCard() { + _focusedCards.clear(); + final lastGroup = boardController.groupDatas + .lastWhereOrNull((group) => group.items.isNotEmpty); + final lastCard = lastGroup?.items.lastOrNull; + if (lastCard != null) { + _focusedCards + .add(GroupedRowId(rowId: lastCard.id, groupId: lastGroup!.id)); + } + } + + void _deepCopy() { + _focusedCards = [..._focusedCards]; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_hidden_groups.dart b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_hidden_groups.dart index 1e820af120..acc6db2f0f 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_hidden_groups.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_hidden_groups.dart @@ -11,7 +11,6 @@ import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/cell/card_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/text_card_cell.dart'; import 'package:appflowy/plugins/database/widgets/row/row_detail.dart'; -import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:collection/collection.dart'; @@ -23,7 +22,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class HiddenGroupsColumn extends StatelessWidget { - const HiddenGroupsColumn({super.key, required this.margin}); + const HiddenGroupsColumn({ + super.key, + required this.margin, + }); final EdgeInsets margin; @@ -31,7 +33,10 @@ class HiddenGroupsColumn extends StatelessWidget { Widget build(BuildContext context) { final databaseController = context.read().databaseController; return BlocSelector( - selector: (state) => state.layoutSettings, + selector: (state) => state.maybeMap( + orElse: () => null, + ready: (value) => value.layoutSettings, + ), builder: (context, layoutSettings) { if (layoutSettings == null) { return const SizedBox.shrink(); @@ -126,43 +131,48 @@ class HiddenGroupList extends StatelessWidget { @override Widget build(BuildContext context) { return BlocBuilder( - builder: (_, state) => ReorderableListView.builder( - proxyDecorator: (child, index, animation) => Material( - color: Colors.transparent, - child: Stack( - children: [ - child, - MouseRegion( - cursor: Platform.isWindows - ? SystemMouseCursors.click - : SystemMouseCursors.grabbing, - child: const SizedBox.expand(), + builder: (context, state) { + return state.maybeMap( + orElse: () => const SizedBox.shrink(), + ready: (state) => ReorderableListView.builder( + proxyDecorator: (child, index, animation) => Material( + color: Colors.transparent, + child: Stack( + children: [ + child, + MouseRegion( + cursor: Platform.isWindows + ? SystemMouseCursors.click + : SystemMouseCursors.grabbing, + child: const SizedBox.expand(), + ), + ], ), - ], + ), + buildDefaultDragHandles: false, + itemCount: state.hiddenGroups.length, + itemBuilder: (_, index) => Padding( + padding: const EdgeInsets.only(bottom: 4), + key: ValueKey("hiddenGroup${state.hiddenGroups[index].groupId}"), + child: HiddenGroupCard( + group: state.hiddenGroups[index], + index: index, + bloc: context.read(), + ), + ), + onReorder: (oldIndex, newIndex) { + if (oldIndex < newIndex) { + newIndex--; + } + final fromGroupId = state.hiddenGroups[oldIndex].groupId; + final toGroupId = state.hiddenGroups[newIndex].groupId; + context + .read() + .add(BoardEvent.reorderGroup(fromGroupId, toGroupId)); + }, ), - ), - buildDefaultDragHandles: false, - itemCount: state.hiddenGroups.length, - itemBuilder: (_, index) => Padding( - padding: const EdgeInsets.only(bottom: 4), - key: ValueKey("hiddenGroup${state.hiddenGroups[index].groupId}"), - child: HiddenGroupCard( - group: state.hiddenGroups[index], - index: index, - bloc: context.read(), - ), - ), - onReorder: (oldIndex, newIndex) { - if (oldIndex < newIndex) { - newIndex--; - } - final fromGroupId = state.hiddenGroups[oldIndex].groupId; - final toGroupId = state.hiddenGroups[newIndex].groupId; - context - .read() - .add(BoardEvent.reorderGroup(fromGroupId, toGroupId)); - }, - ), + ); + }, ); } } @@ -248,57 +258,63 @@ class HiddenGroupButtonContent extends StatelessWidget { value: bloc, child: BlocBuilder( builder: (context, state) { - final group = state.hiddenGroups.firstWhereOrNull( - (g) => g.groupId == groupId, - ); - if (group == null) { - return const SizedBox.shrink(); - } + return state.maybeMap( + orElse: () => const SizedBox.shrink(), + ready: (state) { + final group = state.hiddenGroups.firstWhereOrNull( + (g) => g.groupId == groupId, + ); + if (group == null) { + return const SizedBox.shrink(); + } - return SizedBox( - height: 32, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 3, - ), - child: Row( - children: [ - HiddenGroupCardActions( - isVisible: isHovering, - index: index, + return SizedBox( + height: 32, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 3, ), - const HSpace(4), - FlowyText.medium( - bloc.generateGroupNameFromGroup(group), - overflow: TextOverflow.ellipsis, - ), - const HSpace(6), - Expanded( - child: FlowyText.medium( - group.rows.length.toString(), - overflow: TextOverflow.ellipsis, - color: Theme.of(context).hintColor, - ), - ), - if (isHovering) ...[ - FlowyIconButton( - width: 20, - icon: FlowySvg( - FlowySvgs.show_m, - color: Theme.of(context).hintColor, + child: Row( + children: [ + HiddenGroupCardActions( + isVisible: isHovering, + index: index, ), - onPressed: () => context.read().add( - BoardEvent.toggleGroupVisibility( - group, - true, - ), + const HSpace(4), + FlowyText.medium( + bloc.generateGroupNameFromGroup(group), + overflow: TextOverflow.ellipsis, + ), + const HSpace(6), + Expanded( + child: FlowyText.medium( + group.rows.length.toString(), + overflow: TextOverflow.ellipsis, + color: Theme.of(context).hintColor, + ), + ), + if (isHovering) ...[ + FlowyIconButton( + width: 20, + icon: FlowySvg( + FlowySvgs.show_m, + color: Theme.of(context).hintColor, ), - ), - ], - ], - ), - ), + onPressed: () => + context.read().add( + BoardEvent.setGroupVisibility( + group, + true, + ), + ), + ), + ], + ], + ), + ), + ); + }, ); }, ), @@ -360,68 +376,71 @@ class HiddenGroupPopupItemList extends StatelessWidget { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - final group = state.hiddenGroups.firstWhereOrNull( - (g) => g.groupId == groupId, - ); - if (group == null) { - return const SizedBox.shrink(); - } - final cells = [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - child: FlowyText.medium( - context.read().generateGroupNameFromGroup(group), - fontSize: 10, - color: Theme.of(context).hintColor, - overflow: TextOverflow.ellipsis, - ), - ), - ...group.rows.map( - (item) { - final rowController = RowController( - rowMeta: item, - viewId: viewId, - rowCache: rowCache, - ); - - final databaseController = - context.read().databaseController; - - return HiddenGroupPopupItem( - cellContext: rowCache.loadCells(item).firstWhere( - (cellContext) => cellContext.fieldId == primaryFieldId, - ), - rowController: rowController, - rowMeta: item, - cellBuilder: CardCellBuilder( - databaseController: databaseController, + return state.maybeMap( + orElse: () => const SizedBox.shrink(), + ready: (state) { + final group = state.hiddenGroups.firstWhereOrNull( + (g) => g.groupId == groupId, + ); + if (group == null) { + return const SizedBox.shrink(); + } + final cells = [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: FlowyText.medium( + context.read().generateGroupNameFromGroup(group), + fontSize: 10, + color: Theme.of(context).hintColor, + overflow: TextOverflow.ellipsis, ), - onPressed: () { - FlowyOverlay.show( - context: context, - builder: (_) { - return BlocProvider.value( - value: context.read(), - child: RowDetailPage( - databaseController: databaseController, - rowController: rowController, + ), + ...group.rows.map( + (item) { + final rowController = RowController( + rowMeta: item, + viewId: viewId, + rowCache: rowCache, + ); + + final databaseController = + context.read().databaseController; + + return HiddenGroupPopupItem( + cellContext: rowCache.loadCells(item).firstWhere( + (cellContext) => + cellContext.fieldId == primaryFieldId, ), + rowController: rowController, + rowMeta: item, + cellBuilder: CardCellBuilder( + databaseController: databaseController, + ), + onPressed: () { + FlowyOverlay.show( + context: context, + builder: (_) { + return RowDetailPage( + databaseController: databaseController, + rowController: rowController, + ); + }, ); + PopoverContainer.of(context).close(); }, ); - PopoverContainer.of(context).close(); }, - ); - }, - ), - ]; + ), + ]; - return ListView.separated( - itemBuilder: (context, index) => cells[index], - itemCount: cells.length, - separatorBuilder: (context, index) => - VSpace(GridSize.typeOptionSeparatorHeight), - shrinkWrap: true, + return ListView.separated( + itemBuilder: (context, index) => cells[index], + itemCount: cells.length, + separatorBuilder: (context, index) => + VSpace(GridSize.typeOptionSeparatorHeight), + shrinkWrap: true, + ); + }, ); }, ); diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_shortcut_container.dart b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_shortcut_container.dart new file mode 100644 index 0000000000..022c25ff18 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_shortcut_container.dart @@ -0,0 +1,146 @@ +import 'dart:io'; + +import 'package:appflowy/plugins/database/board/application/board_actions_bloc.dart'; +import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'board_focus_scope.dart'; + +class BoardShortcutContainer extends StatelessWidget { + const BoardShortcutContainer({ + super.key, + required this.focusScope, + required this.child, + }); + + final BoardFocusScope focusScope; + final Widget child; + + @override + Widget build(BuildContext context) { + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.arrowUp): + focusScope.focusPrevious, + const SingleActivator(LogicalKeyboardKey.arrowDown): + focusScope.focusNext, + const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): + focusScope.adjustRangeUp, + const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): + focusScope.adjustRangeDown, + const SingleActivator(LogicalKeyboardKey.escape): focusScope.clear, + const SingleActivator(LogicalKeyboardKey.keyE): () { + if (focusScope.value.length != 1) { + return; + } + context + .read() + .startEditingRow(focusScope.value.first); + }, + const SingleActivator(LogicalKeyboardKey.keyN): () { + if (focusScope.value.length != 1) { + return; + } + context + .read() + .startCreateBottomRow(focusScope.value.first.groupId); + focusScope.clear(); + }, + const SingleActivator(LogicalKeyboardKey.delete): () => + _removeHandler(context), + const SingleActivator(LogicalKeyboardKey.backspace): () => + _removeHandler(context), + SingleActivator( + LogicalKeyboardKey.arrowUp, + shift: true, + meta: Platform.isMacOS, + control: !Platform.isMacOS, + ): () => _shiftCmdUpHandler(context), + const SingleActivator(LogicalKeyboardKey.enter): () => + _enterHandler(context), + const SingleActivator(LogicalKeyboardKey.numpadEnter): () => + _enterHandler(context), + const SingleActivator(LogicalKeyboardKey.enter, shift: true): () => + _shitEnterHandler(context), + const SingleActivator(LogicalKeyboardKey.comma): () => + _moveGroupToAdjacentGroup(context, true), + const SingleActivator(LogicalKeyboardKey.period): () => + _moveGroupToAdjacentGroup(context, false), + }, + child: FocusScope( + child: Focus( + child: Builder( + builder: (context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + final focusNode = Focus.of(context); + focusNode.requestFocus(); + focusScope.clear(); + }, + child: child, + ); + }, + ), + ), + ), + ); + } + + void _enterHandler(BuildContext context) { + if (focusScope.value.length != 1) { + return; + } + context + .read() + .openCardWithRowId(focusScope.value.first.rowId); + } + + void _shitEnterHandler(BuildContext context) { + if (focusScope.value.isEmpty) { + context + .read() + .createRow(null, CreateBoardCardRelativePosition.after); + } else if (focusScope.value.length == 1) { + context.read().createRow( + focusScope.value.first, + CreateBoardCardRelativePosition.after, + ); + } + } + + void _shiftCmdUpHandler(BuildContext context) { + if (focusScope.value.isEmpty) { + context + .read() + .createRow(null, CreateBoardCardRelativePosition.before); + } else if (focusScope.value.length == 1) { + context.read().createRow( + focusScope.value.first, + CreateBoardCardRelativePosition.before, + ); + } + } + + void _removeHandler(BuildContext context) { + if (focusScope.value.isEmpty) { + return; + } + context.read().add(BoardEvent.deleteCards(focusScope.value)); + } + + void _moveGroupToAdjacentGroup(BuildContext context, bool toPrevious) { + if (focusScope.value.length != 1) { + return; + } + context.read().add( + BoardEvent.moveGroupToAdjacentGroup( + focusScope.value.first, + toPrevious, + ), + ); + focusScope.clear(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_bloc.dart index ecfb4bfff8..8c9e36c2c3 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_bloc.dart @@ -72,7 +72,7 @@ class CalendarBloc extends Bloc { ); }, deleteEvent: (String viewId, String rowId) async { - final result = await RowBackendService.deleteRow(viewId, rowId); + final result = await RowBackendService.deleteRows(viewId, [rowId]); result.fold( (_) => null, (e) => Log.error('Failed to delete event: $e', e), diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_event_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_event_editor_bloc.dart index a0a5ec0efc..303daff87e 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_event_editor_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_event_editor_bloc.dart @@ -46,9 +46,9 @@ class CalendarEventEditorBloc emit(state.copyWith(cells: cells)); }, delete: () async { - final result = await RowBackendService.deleteRow( + final result = await RowBackendService.deleteRows( rowController.viewId, - rowController.rowId, + [rowController.rowId], ); result.fold((l) => null, (err) => Log.error(err)); }, diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_card.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_card.dart index c0d283eeb8..6baf2ecd2f 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_card.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_card.dart @@ -80,7 +80,7 @@ class _EventCardState extends State { rowCache: rowCache, isEditing: false, cellBuilder: cellBuilder, - openCard: (context) { + onTap: (context) { if (PlatformExtension.isMobile) { context.push( MobileRowDetailPage.routeName, diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/grid_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/grid_bloc.dart index 01178d9b3f..0a18a252e3 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/grid_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/grid_bloc.dart @@ -51,7 +51,7 @@ class GridBloc extends Bloc { emit(state.copyWith(createdRow: null, openRowDetail: false)); }, deleteRow: (rowInfo) async { - await RowBackendService.deleteRow(rowInfo.viewId, rowInfo.rowId); + await RowBackendService.deleteRows(viewId, [rowInfo.rowId]); }, moveRow: (int from, int to) { final List rows = [...state.rowInfos]; diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/action.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/action.dart index 0766579627..14c17b40fd 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/action.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/action.dart @@ -102,7 +102,7 @@ enum RowAction { RowBackendService.duplicateRow(viewId, rowId); break; case delete: - RowBackendService.deleteRow(viewId, rowId); + RowBackendService.deleteRows(viewId, [rowId]); break; } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card.dart index 2d3f956f14..b2daf625f6 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card.dart @@ -29,10 +29,11 @@ class RowCard extends StatefulWidget { required this.isEditing, required this.rowCache, required this.cellBuilder, - required this.openCard, + required this.onTap, required this.onStartEditing, required this.onEndEditing, required this.styleConfiguration, + this.onShiftTap, this.groupingFieldId, this.groupId, }); @@ -50,7 +51,9 @@ class RowCard extends StatefulWidget { final CardCellBuilder cellBuilder; /// Called when the user taps on the card. - final void Function(BuildContext) openCard; + final void Function(BuildContext context) onTap; + + final void Function(BuildContext context)? onShiftTap; /// Called when the user starts editing the card. final VoidCallback onStartEditing; @@ -67,12 +70,10 @@ class RowCard extends StatefulWidget { class _RowCardState extends State { final popoverController = PopoverController(); late final CardBloc _cardBloc; - late final EditableRowNotifier rowNotifier; @override void initState() { super.initState(); - rowNotifier = EditableRowNotifier(isEditing: widget.isEditing); _cardBloc = CardBloc( fieldController: widget.fieldController, viewId: widget.viewId, @@ -81,22 +82,18 @@ class _RowCardState extends State { rowMeta: widget.rowMeta, rowCache: widget.rowCache, )..add(const CardEvent.initial()); + } - rowNotifier.isEditing.addListener(() { - if (!mounted) return; - _cardBloc.add(CardEvent.setIsEditing(rowNotifier.isEditing.value)); - - if (rowNotifier.isEditing.value) { - widget.onStartEditing(); - } else { - widget.onEndEditing(); - } - }); + @override + void didUpdateWidget(covariant oldWidget) { + if (widget.isEditing != _cardBloc.state.isEditing) { + _cardBloc.add(CardEvent.setIsEditing(widget.isEditing)); + } + super.didUpdateWidget(oldWidget); } @override void dispose() { - rowNotifier.dispose(); _cardBloc.close(); super.dispose(); } @@ -105,7 +102,14 @@ class _RowCardState extends State { Widget build(BuildContext context) { return BlocProvider.value( value: _cardBloc, - child: BlocBuilder( + child: BlocConsumer( + listenWhen: (previous, current) => + previous.isEditing != current.isEditing, + listener: (context, state) { + if (!state.isEditing) { + widget.onEndEditing(); + } + }, builder: (context, state) => PlatformExtension.isMobile ? _mobile(state) : _desktop(state), ), @@ -114,7 +118,7 @@ class _RowCardState extends State { Widget _mobile(CardState state) { return GestureDetector( - onTap: () => widget.openCard(context), + onTap: () => widget.onTap(context), behavior: HitTestBehavior.opaque, child: MobileCardContent( rowMeta: state.rowMeta, @@ -127,9 +131,9 @@ class _RowCardState extends State { Widget _desktop(CardState state) { final accessories = widget.styleConfiguration.showAccessory - ? [ - EditCardAccessory(rowNotifier: rowNotifier), - const MoreCardOptionsAccessory(), + ? const [ + EditCardAccessory(), + MoreCardOptionsAccessory(), ] : null; return AppFlowyPopover( @@ -148,10 +152,10 @@ class _RowCardState extends State { buildAccessoryWhen: () => state.isEditing == false, accessories: accessories ?? [], openAccessory: _handleOpenAccessory, - openCard: widget.openCard, + onTap: widget.onTap, + onShiftTap: widget.onShiftTap, child: _CardContent( rowMeta: state.rowMeta, - rowNotifier: rowNotifier, cellBuilder: widget.cellBuilder, styleConfiguration: widget.styleConfiguration, cells: state.cells, @@ -163,6 +167,7 @@ class _RowCardState extends State { void _handleOpenAccessory(AccessoryType newAccessoryType) { switch (newAccessoryType) { case AccessoryType.edit: + widget.onStartEditing(); break; case AccessoryType.more: popoverController.show(); @@ -174,14 +179,12 @@ class _RowCardState extends State { class _CardContent extends StatelessWidget { const _CardContent({ required this.rowMeta, - required this.rowNotifier, required this.cellBuilder, required this.cells, required this.styleConfiguration, }); final RowMetaPB rowMeta; - final EditableRowNotifier rowNotifier; final CardCellBuilder cellBuilder; final List cells; final RowCardStyleConfiguration styleConfiguration; @@ -199,7 +202,7 @@ class _CardContent extends StatelessWidget { ? child : FlowyHover( style: styleConfiguration.hoverStyle, - buildWhenOnHover: () => !rowNotifier.isEditing.value, + buildWhenOnHover: () => !context.read().state.isEditing, child: child, ); } @@ -209,16 +212,16 @@ class _CardContent extends StatelessWidget { RowMetaPB rowMeta, List cells, ) { - // Remove all the cell listeners. - rowNotifier.unbind(); - return cells.mapIndexed((int index, CellContext cellContext) { EditableCardNotifier? cellNotifier; if (index == 0) { - cellNotifier = - EditableCardNotifier(isEditing: rowNotifier.isEditing.value); - rowNotifier.bindCell(cellContext, cellNotifier); + final bloc = context.read(); + cellNotifier = EditableCardNotifier(isEditing: bloc.state.isEditing); + cellNotifier.isCellEditing.addListener(() { + final isEditing = cellNotifier!.isCellEditing.value; + bloc.add(CardEvent.setIsEditing(isEditing)); + }); } return cellBuilder.build( @@ -231,6 +234,24 @@ class _CardContent extends StatelessWidget { } } +class EditCardAccessory extends StatelessWidget with CardAccessory { + const EditCardAccessory({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(3.0), + child: FlowySvg( + FlowySvgs.edit_s, + color: Theme.of(context).hintColor, + ), + ); + } + + @override + AccessoryType get type => AccessoryType.edit; +} + class MoreCardOptionsAccessory extends StatelessWidget with CardAccessory { const MoreCardOptionsAccessory({super.key}); @@ -249,29 +270,6 @@ class MoreCardOptionsAccessory extends StatelessWidget with CardAccessory { AccessoryType get type => AccessoryType.more; } -class EditCardAccessory extends StatelessWidget with CardAccessory { - const EditCardAccessory({super.key, required this.rowNotifier}); - - final EditableRowNotifier rowNotifier; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(3.0), - child: FlowySvg( - FlowySvgs.edit_s, - color: Theme.of(context).hintColor, - ), - ); - } - - @override - void onTap(BuildContext context) => rowNotifier.becomeFirstResponder(); - - @override - AccessoryType get type => AccessoryType.edit; -} - class RowCardStyleConfiguration { const RowCardStyleConfiguration({ required this.cellStyleMap, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/card/container/card_container.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/container/card_container.dart index 0cb0988bac..3584f2fce0 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/card/container/card_container.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/container/card_container.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'accessory.dart'; @@ -7,14 +8,16 @@ class RowCardContainer extends StatelessWidget { const RowCardContainer({ super.key, required this.child, - required this.openCard, + required this.onTap, required this.openAccessory, required this.accessories, this.buildAccessoryWhen, + this.onShiftTap, }); final Widget child; - final void Function(BuildContext) openCard; + final void Function(BuildContext) onTap; + final void Function(BuildContext)? onShiftTap; final void Function(AccessoryType) openAccessory; final List accessories; final bool Function()? buildAccessoryWhen; @@ -41,7 +44,13 @@ class RowCardContainer extends StatelessWidget { return GestureDetector( behavior: HitTestBehavior.opaque, - onTap: () => openCard(context), + onTap: () { + if (HardwareKeyboard.instance.isShiftPressed) { + onShiftTap?.call(context); + } else { + onTap(context); + } + }, child: ConstrainedBox( constraints: const BoxConstraints(minHeight: 30), child: container, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/card_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/card_cell.dart index 4351e7dd2c..7b03539252 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/card_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/card_cell.dart @@ -1,4 +1,3 @@ -import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:flutter/material.dart'; abstract class CardCell extends StatefulWidget { @@ -32,61 +31,6 @@ class EditableCardNotifier { } } -class EditableRowNotifier { - EditableRowNotifier({required bool isEditing}) - : isEditing = ValueNotifier(isEditing); - - final Map _cells = {}; - final ValueNotifier isEditing; - - void bindCell( - CellContext cellIdentifier, - EditableCardNotifier notifier, - ) { - assert( - _cells.values.isEmpty, - 'Only one cell can receive the notification', - ); - _cells[cellIdentifier]?.dispose(); - - notifier.isCellEditing.addListener(() { - isEditing.value = notifier.isCellEditing.value; - }); - - _cells[cellIdentifier] = notifier; - } - - void becomeFirstResponder() { - if (_cells.values.isEmpty) return; - assert( - _cells.values.length == 1, - 'Only one cell can receive the notification', - ); - _cells.values.first.isCellEditing.value = true; - } - - void resignFirstResponder() { - if (_cells.values.isEmpty) return; - assert( - _cells.values.length == 1, - 'Only one cell can receive the notification', - ); - _cells.values.first.isCellEditing.value = false; - } - - void unbind() { - for (final notifier in _cells.values) { - notifier.dispose(); - } - _cells.clear(); - } - - void dispose() { - unbind(); - isEditing.dispose(); - } -} - abstract mixin class EditableCell { // Each cell notifier will be bind to the [EditableRowNotifier], which enable // the row notifier receive its cells event. For example: begin editing the diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/text_card_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/text_card_cell.dart index b7aae4dd58..ccfe41f7ee 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/text_card_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/text_card_cell.dart @@ -8,6 +8,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../editable_cell_builder.dart'; @@ -108,18 +109,11 @@ class _TextCellState extends State { return BlocProvider.value( value: cellBloc, child: BlocConsumer( - listenWhen: (previous, current) => - previous.content != current.content && !current.enableEdit, + listenWhen: (previous, current) => previous.content != current.content, listener: (context, state) { - _textEditingController.text = state.content; - }, - buildWhen: (previous, current) { - if (previous.content != current.content && - _textEditingController.text == current.content) { - return false; + if (!state.enableEdit) { + _textEditingController.text = state.content; } - - return previous != current; }, builder: (context, state) { final isTitle = cellBloc.cellController.fieldInfo.isPrimary; @@ -196,31 +190,39 @@ class _TextCellState extends State { widget.style.padding.add(const EdgeInsets.symmetric(vertical: 4.0)); return IgnorePointer( ignoring: !isEditing, - child: TextField( - controller: _textEditingController, - focusNode: focusNode, - onChanged: (_) { - if (_textEditingController.value.composing.isCollapsed) { - cellBloc.add(TextCellEvent.updateText(_textEditingController.text)); - } + child: CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.escape): () => + focusNode.unfocus(), }, - onEditingComplete: () => focusNode.unfocus(), - maxLines: isEditing ? null : 2, - minLines: 1, - textInputAction: TextInputAction.done, - readOnly: !isEditing, - enableInteractiveSelection: isEditing, - style: widget.style.titleTextStyle, - decoration: InputDecoration( - contentPadding: padding, - border: InputBorder.none, - enabledBorder: InputBorder.none, - isDense: true, - isCollapsed: true, - hintText: LocaleKeys.grid_row_titlePlaceholder.tr(), - hintStyle: widget.style.titleTextStyle.copyWith( - color: Theme.of(context).hintColor, + child: TextField( + controller: _textEditingController, + focusNode: focusNode, + onChanged: (_) { + if (_textEditingController.value.composing.isCollapsed) { + cellBloc + .add(TextCellEvent.updateText(_textEditingController.text)); + } + }, + onEditingComplete: () => focusNode.unfocus(), + maxLines: isEditing ? null : 2, + minLines: 1, + textInputAction: TextInputAction.done, + readOnly: !isEditing, + enableInteractiveSelection: isEditing, + style: widget.style.titleTextStyle, + decoration: InputDecoration( + contentPadding: padding, + border: InputBorder.none, + enabledBorder: InputBorder.none, + isDense: true, + isCollapsed: true, + hintText: LocaleKeys.grid_row_titlePlaceholder.tr(), + hintStyle: widget.style.titleTextStyle.copyWith( + color: Theme.of(context).hintColor, + ), ), + onTapOutside: (_) {}, ), ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/text.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/text.dart index 18423ccbd0..2cd318fa64 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/text.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/text.dart @@ -80,7 +80,9 @@ class _TextCellState extends GridEditableTextCell { value: cellBloc, child: BlocListener( listener: (context, state) { - _textEditingController.text = state.content; + if (!focusNode.hasFocus) { + _textEditingController.text = state.content; + } }, child: Builder( builder: (context) { diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_action.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_action.dart index fee92600c6..c9f4a796c0 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_action.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_action.dart @@ -53,7 +53,7 @@ class RowDetailPageDeleteButton extends StatelessWidget { text: FlowyText.regular(LocaleKeys.grid_row_delete.tr()), leftIcon: const FlowySvg(FlowySvgs.trash_m), onTap: () { - RowBackendService.deleteRow(viewId, rowId); + RowBackendService.deleteRows(viewId, [rowId]); FlowyOverlay.pop(context); }, ), diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_banner.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_banner.dart index 358d26bece..dc1b9435e8 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_banner.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_banner.dart @@ -288,7 +288,6 @@ class _TitleSkin extends IEditableTextCellSkin { return TextField( controller: textEditingController, focusNode: focusNode, - maxLines: null, autofocus: true, style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 28), decoration: InputDecoration( diff --git a/frontend/appflowy_flutter/lib/shared/conditional_listenable_builder.dart b/frontend/appflowy_flutter/lib/shared/conditional_listenable_builder.dart new file mode 100644 index 0000000000..661e86dcb7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/conditional_listenable_builder.dart @@ -0,0 +1,87 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class ConditionalListenableBuilder extends StatefulWidget { + const ConditionalListenableBuilder({ + super.key, + required this.valueListenable, + required this.buildWhen, + required this.builder, + this.child, + }); + + /// The [ValueListenable] whose value you depend on in order to build. + /// + /// This widget does not ensure that the [ValueListenable]'s value is not + /// null, therefore your [builder] may need to handle null values. + final ValueListenable valueListenable; + + /// The [buildWhen] function will be called on each value change of the + /// [valueListenable]. If the [buildWhen] function returns true, the [builder] + /// will be called with the new value of the [valueListenable]. + /// + final bool Function(T previous, T current) buildWhen; + + /// A [ValueWidgetBuilder] which builds a widget depending on the + /// [valueListenable]'s value. + /// + /// Can incorporate a [valueListenable] value-independent widget subtree + /// from the [child] parameter into the returned widget tree. + final ValueWidgetBuilder builder; + + /// A [valueListenable]-independent widget which is passed back to the [builder]. + /// + /// This argument is optional and can be null if the entire widget subtree the + /// [builder] builds depends on the value of the [valueListenable]. For + /// example, in the case where the [valueListenable] is a [String] and the + /// [builder] returns a [Text] widget with the current [String] value, there + /// would be no useful [child]. + final Widget? child; + + @override + State createState() => + _ConditionalListenableBuilderState(); +} + +class _ConditionalListenableBuilderState + extends State> { + late T value; + + @override + void initState() { + super.initState(); + value = widget.valueListenable.value; + widget.valueListenable.addListener(_valueChanged); + } + + @override + void didUpdateWidget(ConditionalListenableBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.valueListenable != widget.valueListenable) { + oldWidget.valueListenable.removeListener(_valueChanged); + value = widget.valueListenable.value; + widget.valueListenable.addListener(_valueChanged); + } + } + + @override + void dispose() { + widget.valueListenable.removeListener(_valueChanged); + super.dispose(); + } + + void _valueChanged() { + if (widget.buildWhen(value, widget.valueListenable.value)) { + setState(() { + value = widget.valueListenable.value; + }); + } else { + value = widget.valueListenable.value; + } + } + + @override + Widget build(BuildContext context) { + return widget.builder(context, value, widget.child); + } +} diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index d322aac654..a2c770dc1c 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -44,8 +44,8 @@ packages: dependency: "direct main" description: path: "." - ref: "404262fca4369bc35ff305316e4d59341a732f56" - resolved-ref: "404262fca4369bc35ff305316e4d59341a732f56" + ref: "8a6434ae3d02624b614a010af80f775db11bf22e" + resolved-ref: "8a6434ae3d02624b614a010af80f775db11bf22e" url: "https://github.com/AppFlowy-IO/appflowy-board.git" source: git version: "0.1.2" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 56223b4d48..6673e09dbe 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -44,7 +44,7 @@ dependencies: # path: ../../../appflowy-board git: url: https://github.com/AppFlowy-IO/appflowy-board.git - ref: 404262fca4369bc35ff305316e4d59341a732f56 + ref: 8a6434ae3d02624b614a010af80f775db11bf22e appflowy_result: path: packages/appflowy_result appflowy_editor_plugins: ^0.0.2 diff --git a/frontend/appflowy_flutter/test/bloc_test/board_test/create_card_test.dart b/frontend/appflowy_flutter/test/bloc_test/board_test/create_card_test.dart index 6b6ec926e2..4f855d4fb3 100644 --- a/frontend/appflowy_flutter/test/bloc_test/board_test/create_card_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/board_test/create_card_test.dart @@ -1,5 +1,6 @@ import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flutter_test/flutter_test.dart'; import 'util.dart'; @@ -15,26 +16,42 @@ void main() { final context = await boardTest.createTestBoard(); final databaseController = DatabaseController(view: context.gridView); final boardBloc = BoardBloc( - view: context.gridView, databaseController: databaseController, )..add(const BoardEvent.initial()); await boardResponseFuture(); - final groupId = boardBloc.state.groupIds.last; + List groupIds = boardBloc.state.maybeMap( + orElse: () => const [], + ready: (value) => value.groupIds, + ); + String lastGroupId = groupIds.last; // the group at index 3 is the 'No status' group; - assert(boardBloc.groupControllers[groupId]!.group.rows.isEmpty); + assert(boardBloc.groupControllers[lastGroupId]!.group.rows.isEmpty); assert( - boardBloc.state.groupIds.length == 4, - 'but receive ${boardBloc.state.groupIds.length}', + groupIds.length == 4, + 'but receive ${groupIds.length}', ); - boardBloc.add(BoardEvent.createBottomRow(boardBloc.state.groupIds[3])); + boardBloc.add( + BoardEvent.createRow( + groupIds[3], + OrderObjectPositionTypePB.End, + null, + null, + ), + ); await boardResponseFuture(); + groupIds = boardBloc.state.maybeMap( + orElse: () => [], + ready: (value) => value.groupIds, + ); + lastGroupId = groupIds.last; + assert( - boardBloc.groupControllers[groupId]!.group.rows.length == 1, - 'but receive ${boardBloc.groupControllers[groupId]!.group.rows.length}', + boardBloc.groupControllers[lastGroupId]!.group.rows.length == 1, + 'but receive ${boardBloc.groupControllers[lastGroupId]!.group.rows.length}', ); }); } diff --git a/frontend/appflowy_flutter/test/bloc_test/board_test/create_or_edit_field_test.dart b/frontend/appflowy_flutter/test/bloc_test/board_test/create_or_edit_field_test.dart index 37b9f5f855..752ff272da 100644 --- a/frontend/appflowy_flutter/test/bloc_test/board_test/create_or_edit_field_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/board_test/create_or_edit_field_test.dart @@ -15,7 +15,6 @@ void main() { test('create build-in kanban board test', () async { final context = await boardTest.createTestBoard(); final boardBloc = BoardBloc( - view: context.gridView, databaseController: DatabaseController(view: context.gridView), )..add(const BoardEvent.initial()); await boardResponseFuture(); @@ -27,7 +26,6 @@ void main() { test('edit kanban board field name test', () async { final context = await boardTest.createTestBoard(); final boardBloc = BoardBloc( - view: context.gridView, databaseController: DatabaseController(view: context.gridView), )..add(const BoardEvent.initial()); await boardResponseFuture(); @@ -59,7 +57,6 @@ void main() { test('create a new field in kanban board test', () async { final context = await boardTest.createTestBoard(); final boardBloc = BoardBloc( - view: context.gridView, databaseController: DatabaseController(view: context.gridView), )..add(const BoardEvent.initial()); await boardResponseFuture(); diff --git a/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_checkbox_field_test.dart b/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_checkbox_field_test.dart index a760268dfd..ad4d96f5eb 100644 --- a/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_checkbox_field_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_checkbox_field_test.dart @@ -17,7 +17,6 @@ void main() { test('group by checkbox field test', () async { final context = await boardTest.createTestBoard(); final boardBloc = BoardBloc( - view: context.gridView, databaseController: DatabaseController(view: context.gridView), )..add(const BoardEvent.initial()); await boardResponseFuture(); diff --git a/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_multi_select_field_test.dart b/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_multi_select_field_test.dart index 2385373d14..e2a794b3c4 100644 --- a/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_multi_select_field_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_multi_select_field_test.dart @@ -41,7 +41,6 @@ void main() { // assert only have the 'No status' group final boardBloc = BoardBloc( - view: context.gridView, databaseController: DatabaseController(view: context.gridView), )..add(const BoardEvent.initial()); await boardResponseFuture(); @@ -91,7 +90,6 @@ void main() { // assert there are only three group final boardBloc = BoardBloc( - view: context.gridView, databaseController: DatabaseController(view: context.gridView), )..add(const BoardEvent.initial()); await boardResponseFuture(); diff --git a/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_unsupport_field_test.dart b/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_unsupport_field_test.dart index b4321b5244..c68338b424 100644 --- a/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_unsupport_field_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_unsupport_field_test.dart @@ -38,7 +38,6 @@ void main() { blocTest( 'assert the number of groups is 1', build: () => BoardBloc( - view: context.gridView, databaseController: DatabaseController(view: context.gridView), )..add( const BoardEvent.initial(), diff --git a/frontend/rust-lib/event-integration-test/src/database_event.rs b/frontend/rust-lib/event-integration-test/src/database_event.rs index 30104ab711..1e264e03f8 100644 --- a/frontend/rust-lib/event-integration-test/src/database_event.rs +++ b/frontend/rust-lib/event-integration-test/src/database_event.rs @@ -236,11 +236,10 @@ impl EventIntegrationTest { pub async fn delete_row(&self, view_id: &str, row_id: &str) -> Option { EventBuilder::new(self.clone()) - .event(DatabaseEvent::DeleteRow) - .payload(RowIdPB { + .event(DatabaseEvent::DeleteRows) + .payload(RepeatedRowIdPB { view_id: view_id.to_string(), - row_id: row_id.to_string(), - group_id: None, + row_ids: vec![row_id.to_string()], }) .async_send() .await @@ -523,7 +522,7 @@ impl EventIntegrationTest { ) -> Vec { EventBuilder::new(self.clone()) .event(DatabaseEvent::GetRelatedRowDatas) - .payload(RepeatedRowIdPB { + .payload(GetRelatedRowDataPB { database_id, row_ids, }) diff --git a/frontend/rust-lib/event-integration-test/tests/database/local_test/test.rs b/frontend/rust-lib/event-integration-test/tests/database/local_test/test.rs index f0c78d15b3..ba787877cc 100644 --- a/frontend/rust-lib/event-integration-test/tests/database/local_test/test.rs +++ b/frontend/rust-lib/event-integration-test/tests/database/local_test/test.rs @@ -314,9 +314,12 @@ async fn delete_row_event_with_invalid_row_id_test() { .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) .await; - // delete the row with empty row_id. It should return an error. + // delete the row with empty row_id. It should do nothing let error = test.delete_row(&grid_view.id, "").await; - assert!(error.is_some()); + assert!(error.is_none()); + + let database = test.get_database(&grid_view.id).await; + assert_eq!(database.rows.len(), 3); } #[tokio::test] diff --git a/frontend/rust-lib/flowy-database2/src/entities/row_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/row_entities.rs index 04d1d70729..1071a2630e 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/row_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/row_entities.rs @@ -338,6 +338,15 @@ impl TryInto for RowIdPB { } } +#[derive(Debug, Default, Clone, ProtoBuf)] +pub struct RepeatedRowIdPB { + #[pb(index = 1)] + pub view_id: String, + + #[pb(index = 2)] + pub row_ids: Vec, +} + #[derive(ProtoBuf, Default, Validate)] pub struct CreateRowPayloadPB { #[pb(index = 1)] diff --git a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/relation_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/relation_entities.rs index bebcb6189e..919ca220ae 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/relation_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/relation_entities.rs @@ -78,7 +78,7 @@ pub struct RepeatedRelatedRowDataPB { } #[derive(Debug, Default, Clone, ProtoBuf)] -pub struct RepeatedRowIdPB { +pub struct GetRelatedRowDataPB { #[pb(index = 1)] pub database_id: String, diff --git a/frontend/rust-lib/flowy-database2/src/event_handler.rs b/frontend/rust-lib/flowy-database2/src/event_handler.rs index ebd621eb87..742b0d2fd9 100644 --- a/frontend/rust-lib/flowy-database2/src/event_handler.rs +++ b/frontend/rust-lib/flowy-database2/src/event_handler.rs @@ -385,14 +385,19 @@ pub(crate) async fn update_row_meta_handler( } #[tracing::instrument(level = "debug", skip(data, manager), err)] -pub(crate) async fn delete_row_handler( - data: AFPluginData, +pub(crate) async fn delete_rows_handler( + data: AFPluginData, manager: AFPluginState>, ) -> Result<(), FlowyError> { let manager = upgrade_manager(manager)?; - let params: RowIdParams = data.into_inner().try_into()?; + let params: RepeatedRowIdPB = data.into_inner(); let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; - database_editor.delete_row(¶ms.row_id).await; + let row_ids = params + .row_ids + .into_iter() + .map(RowId::from) + .collect::>(); + database_editor.delete_rows(&row_ids).await; Ok(()) } @@ -1062,11 +1067,11 @@ pub(crate) async fn update_relation_cell_handler( } pub(crate) async fn get_related_row_datas_handler( - data: AFPluginData, + data: AFPluginData, manager: AFPluginState>, ) -> DataResult { let manager = upgrade_manager(manager)?; - let params: RepeatedRowIdPB = data.into_inner(); + let params: GetRelatedRowDataPB = data.into_inner(); let database_editor = manager.get_database(¶ms.database_id).await?; let row_datas = database_editor .get_related_rows(Some(¶ms.row_ids)) diff --git a/frontend/rust-lib/flowy-database2/src/event_map.rs b/frontend/rust-lib/flowy-database2/src/event_map.rs index 97f390771c..7a46332013 100644 --- a/frontend/rust-lib/flowy-database2/src/event_map.rs +++ b/frontend/rust-lib/flowy-database2/src/event_map.rs @@ -37,7 +37,7 @@ pub fn init(database_manager: Weak) -> AFPlugin { .event(DatabaseEvent::GetRow, get_row_handler) .event(DatabaseEvent::GetRowMeta, get_row_meta_handler) .event(DatabaseEvent::UpdateRowMeta, update_row_meta_handler) - .event(DatabaseEvent::DeleteRow, delete_row_handler) + .event(DatabaseEvent::DeleteRows, delete_rows_handler) .event(DatabaseEvent::DuplicateRow, duplicate_row_handler) .event(DatabaseEvent::MoveRow, move_row_handler) // Cell @@ -223,8 +223,8 @@ pub enum DatabaseEvent { #[event(input = "RowIdPB", output = "OptionalRowPB")] GetRow = 51, - #[event(input = "RowIdPB")] - DeleteRow = 52, + #[event(input = "RepeatedRowIdPB")] + DeleteRows = 52, #[event(input = "RowIdPB")] DuplicateRow = 53, @@ -364,7 +364,7 @@ pub enum DatabaseEvent { UpdateRelationCell = 171, /// Get the names of the linked rows in a relation cell. - #[event(input = "RepeatedRowIdPB", output = "RepeatedRelatedRowDataPB")] + #[event(input = "GetRelatedRowDataPB", output = "RepeatedRelatedRowDataPB")] GetRelatedRowDatas = 172, /// Get the names of all the rows in a related database. diff --git a/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs b/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs index 1c62e70b93..01056dfeea 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs @@ -654,9 +654,10 @@ impl DatabaseEditor { } } - pub async fn delete_row(&self, row_id: &RowId) { - let row = self.database.lock().remove_row(row_id); - if let Some(row) = row { + pub async fn delete_rows(&self, row_ids: &[RowId]) { + let rows = self.database.lock().remove_rows(row_ids); + + for row in rows { tracing::trace!("Did delete row:{:?}", row); for view in self.database_views.editors().await { view.v_did_delete_row(&row).await; diff --git a/frontend/rust-lib/flowy-database2/tests/database/group_test/script.rs b/frontend/rust-lib/flowy-database2/tests/database/group_test/script.rs index 035795f88c..1fe883e041 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/group_test/script.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/group_test/script.rs @@ -148,8 +148,8 @@ impl DatabaseGroupTest { row_index, } => { let row = self.row_at_index(group_index, row_index).await; - let row_id = RowId::from(row.id); - self.editor.delete_row(&row_id).await; + let row_ids = vec![RowId::from(row.id)]; + self.editor.delete_rows(&row_ids).await; }, GroupScript::UpdateGroupedCell { from_group_index,