feat: add kanban shortcuts (#5270)

* feat: add kanban shortcuts

* feat: new ux for creating new kanban cards

* chore: fix tests

* fix: open card after creation in mobile board

* chore: adjust code style according to launch review

* chore: update frontend/appflowy_flutter/test/bloc_test/board_test/create_card_test.dart

Co-authored-by: Mathias Mogensen <42929161+Xazin@users.noreply.github.com>

* chore: more review

* chore: implement move card to adjacent group

* chore: reset focus upon card drag start

* feat: N to start creating a row from bottom

* fix: text card update

* feat: shift + enter to create a new card after currently focused card

* fix: row detail title

* feat: shift + cmd + up to create card above

* fix: double dispose and code cleanup

* chore: code cleanup

* fix: widget rebuilds

* fix: build

* chore: update frontend/appflowy_flutter/lib/mobile/presentation/database/board/mobile_board_page.dart

Co-authored-by: Mathias Mogensen <42929161+Xazin@users.noreply.github.com>

* fix: ontapoutside for cards being edited

* fix: correct integration test

* fix: always build

* chore: code cleanup

* fix: mobile build and bugs

* fix: widget rebuilds

* fix: code cleanup and fix mobile open

* fix: disallow dragging when editing

---------

Co-authored-by: Mathias Mogensen <42929161+Xazin@users.noreply.github.com>
This commit is contained in:
Richard Shiue 2024-05-10 10:02:10 +08:00 committed by GitHub
parent 28a27d1b67
commit a490f34a61
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 2192 additions and 990 deletions

View File

@ -1,4 +1,5 @@
import 'package:appflowy/generated/flowy_svgs.g.dart'; 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/board/presentation/widgets/board_column_header.dart';
import 'package:appflowy/plugins/database/widgets/card/card.dart'; import 'package:appflowy/plugins/database/widgets/card/card.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
@ -82,23 +83,19 @@ void main() {
findsOneWidget, findsOneWidget,
); );
await tester.tap( await tester.tapButton(
find find.byType(BoardColumnFooter).at(1),
.descendant(
of: find.byType(AppFlowyGroupFooter),
matching: find.byType(FlowySvg),
)
.at(1),
); );
const newCardName = 'Card 4'; const newCardName = 'Card 4';
await tester.enterText( await tester.enterText(
find.descendant( find.descendant(
of: lastCard, of: find.byType(BoardColumnFooter),
matching: find.byType(TextField), matching: find.byType(TextField),
), ),
newCardName, newCardName,
); );
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle(const Duration(milliseconds: 500)); await tester.pumpAndSettle(const Duration(milliseconds: 500));
await tester.tap(find.byType(AppFlowyBoard)); await tester.tap(find.byType(AppFlowyBoard));

View File

@ -56,7 +56,7 @@ void main() {
expect( expect(
find.descendant( find.descendant(
of: find.byType(AppFlowyEditor), of: find.byType(AppFlowyEditor),
matching: find.byType(BoardPage), matching: find.byType(DesktopBoardPage),
), ),
findsOneWidget, findsOneWidget,
); );
@ -104,7 +104,7 @@ void main() {
expect( expect(
find.descendant( find.descendant(
of: find.byType(AppFlowyEditor), of: find.byType(AppFlowyEditor),
matching: find.byType(BoardPage), matching: find.byType(DesktopBoardPage),
), ),
findsOneWidget, findsOneWidget,
); );

View File

@ -61,7 +61,7 @@ void main() {
expect(find.byType(GridPage), findsOneWidget); expect(find.byType(GridPage), findsOneWidget);
break; break;
case ViewLayoutPB.Board: case ViewLayoutPB.Board:
expect(find.byType(BoardPage), findsOneWidget); expect(find.byType(DesktopBoardPage), findsOneWidget);
break; break;
case ViewLayoutPB.Calendar: case ViewLayoutPB.Calendar:
expect(find.byType(CalendarPage), findsOneWidget); expect(find.byType(CalendarPage), findsOneWidget);

View File

@ -1463,7 +1463,7 @@ extension AppFlowyDatabaseTest on WidgetTester {
void assertCurrentDatabaseTagIs(DatabaseLayoutPB layout) => switch (layout) { void assertCurrentDatabaseTagIs(DatabaseLayoutPB layout) => switch (layout) {
DatabaseLayoutPB.Board => DatabaseLayoutPB.Board =>
expect(find.byType(BoardPage), findsOneWidget), expect(find.byType(DesktopBoardPage), findsOneWidget),
DatabaseLayoutPB.Calendar => DatabaseLayoutPB.Calendar =>
expect(find.byType(CalendarPage), findsOneWidget), expect(find.byType(CalendarPage), findsOneWidget),
DatabaseLayoutPB.Grid => expect(find.byType(GridPage), findsOneWidget), DatabaseLayoutPB.Grid => expect(find.byType(GridPage), findsOneWidget),
@ -1521,7 +1521,7 @@ extension AppFlowyDatabaseTest on WidgetTester {
} }
Finder finderForDatabaseLayoutType(DatabaseLayoutPB layout) => switch (layout) { Finder finderForDatabaseLayoutType(DatabaseLayoutPB layout) => switch (layout) {
DatabaseLayoutPB.Board => find.byType(BoardPage), DatabaseLayoutPB.Board => find.byType(DesktopBoardPage),
DatabaseLayoutPB.Calendar => find.byType(CalendarPage), DatabaseLayoutPB.Calendar => find.byType(CalendarPage),
DatabaseLayoutPB.Grid => find.byType(GridPage), DatabaseLayoutPB.Grid => find.byType(GridPage),
_ => throw Exception('Unknown database layout type: $layout'), _ => throw Exception('Unknown database layout type: $layout'),

View File

@ -1,4 +1,4 @@
export 'mobile_board_screen.dart'; 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_hidden_groups_column.dart';
export 'widgets/mobile_board_trailing.dart'; export 'widgets/mobile_board_trailing.dart';

View File

@ -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/board.dart';
import 'package:appflowy/mobile/presentation/database/board/widgets/group_card_header.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/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/board/application/board_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/header/field_type_extension.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/card/card.dart';
import 'package:appflowy/plugins/database/widgets/cell/card_cell_builder.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/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/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:appflowy_board/appflowy_board.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.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:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
class MobileBoardContent extends StatefulWidget { class MobileBoardPage extends StatefulWidget {
const MobileBoardContent({ const MobileBoardPage({
super.key, 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 @override
State<MobileBoardContent> createState() => _MobileBoardContentState(); State<MobileBoardPage> createState() => _MobileBoardPageState();
} }
class _MobileBoardContentState extends State<MobileBoardContent> { class _MobileBoardPageState extends State<MobileBoardPage> {
late final ScrollController scrollController; late final ValueNotifier<DidCreateRowResult?> _didCreateRow;
late final AppFlowyBoardScrollController scrollManager;
@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<BoardBloc>(
create: (_) => BoardBloc(
databaseController: widget.databaseController,
didCreateRow: _didCreateRow,
)..add(const BoardEvent.initial()),
child: BlocBuilder<BoardBloc, BoardState>(
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 @override
void initState() { void initState() {
super.initState(); super.initState();
// mobile may not need this
// scroll to bottom when add a new card
scrollManager = AppFlowyBoardScrollController();
scrollController = ScrollController(); scrollController = ScrollController();
} }
@ -57,29 +136,22 @@ class _MobileBoardContentState extends State<MobileBoardContent> {
cardMargin: const EdgeInsets.all(4), cardMargin: const EdgeInsets.all(4),
); );
return BlocListener<BoardBloc, BoardState>( return BlocBuilder<BoardBloc, BoardState>(
listenWhen: (previous, current) =>
previous.recentAddedRowMeta != current.recentAddedRowMeta,
listener: (context, state) {
context.push(
MobileRowDetailPage.routeName,
extra: {
MobileRowDetailPage.argRowId: state.recentAddedRowMeta!.id,
MobileRowDetailPage.argDatabaseController:
context.read<BoardBloc>().databaseController,
},
);
},
child: BlocBuilder<BoardBloc, BoardState>(
builder: (context, state) { builder: (context, state) {
final showCreateGroupButton = return state.maybeMap(
context.read<BoardBloc>().groupingFieldType.canCreateNewGroup; orElse: () => const SizedBox.shrink(),
ready: (state) {
final showCreateGroupButton = context
.read<BoardBloc>()
.groupingFieldType
?.canCreateNewGroup ??
false;
final showHiddenGroups = state.hiddenGroups.isNotEmpty; final showHiddenGroups = state.hiddenGroups.isNotEmpty;
return AppFlowyBoard( return AppFlowyBoard(
boardScrollController: scrollManager,
scrollController: scrollController, scrollController: scrollController,
controller: context.read<BoardBloc>().boardController, controller: context.read<BoardBloc>().boardController,
groupConstraints: BoxConstraints.tightFor(width: screenWidth * 0.7), groupConstraints:
BoxConstraints.tightFor(width: screenWidth * 0.7),
config: config, config: config,
leading: showHiddenGroups leading: showHiddenGroups
? MobileHiddenGroupsColumn( ? MobileHiddenGroupsColumn(
@ -104,7 +176,8 @@ class _MobileBoardContentState extends State<MobileBoardContent> {
), ),
); );
}, },
), );
},
); );
} }
@ -129,9 +202,14 @@ class _MobileBoardContentState extends State<MobileBoardContent> {
color: style.colorScheme.onSurface, color: style.colorScheme.onSurface,
), ),
), ),
onPressed: () => context onPressed: () => context.read<BoardBloc>().add(
.read<BoardBloc>() BoardEvent.createRow(
.add(BoardEvent.createBottomRow(columnData.id)), columnData.id,
OrderObjectPositionTypePB.End,
null,
null,
),
),
), ),
); );
} }
@ -146,16 +224,9 @@ class _MobileBoardContentState extends State<MobileBoardContent> {
final groupItem = afGroupItem as GroupItem; final groupItem = afGroupItem as GroupItem;
final groupData = afGroupData.customData as GroupData; final groupData = afGroupData.customData as GroupData;
final rowMeta = groupItem.row; 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 = final cellBuilder =
CardCellBuilder(databaseController: boardBloc.databaseController); 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; final groupItemId = groupItem.row.id + groupData.group.groupId;
@ -166,12 +237,12 @@ class _MobileBoardContentState extends State<MobileBoardContent> {
child: RowCard( child: RowCard(
fieldController: boardBloc.fieldController, fieldController: boardBloc.fieldController,
rowMeta: rowMeta, rowMeta: rowMeta,
viewId: viewId, viewId: boardBloc.viewId,
rowCache: rowCache, rowCache: boardBloc.rowCache,
groupingFieldId: groupItem.fieldInfo.id, groupingFieldId: groupItem.fieldInfo.id,
isEditing: isEditing, isEditing: false,
cellBuilder: cellBuilder, cellBuilder: cellBuilder,
openCard: (context) { onTap: (context) {
context.push( context.push(
MobileRowDetailPage.routeName, MobileRowDetailPage.routeName,
extra: { extra: {
@ -181,10 +252,8 @@ class _MobileBoardContentState extends State<MobileBoardContent> {
}, },
); );
}, },
onStartEditing: () => boardBloc onStartEditing: () {},
.add(BoardEvent.startEditingRow(groupData.group, groupItem.row)), onEndEditing: () {},
onEndEditing: () =>
boardBloc.add(BoardEvent.endEditingRow(groupItem.row.id)),
styleConfiguration: RowCardStyleConfiguration( styleConfiguration: RowCardStyleConfiguration(
cellStyleMap: mobileBoardCardCellStyleMap(context), cellStyleMap: mobileBoardCardCellStyleMap(context),
showAccessory: false, showAccessory: false,

View File

@ -1,16 +1,14 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.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/board/application/board_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/header/field_type_extension.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/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/group.pb.dart';
import 'package:appflowy_board/appflowy_board.dart'; import 'package:appflowy_board/appflowy_board.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@ -73,8 +71,12 @@ class _GroupCardHeaderState extends State<GroupCardHeader> {
); );
} }
if (state.isEditingHeader && final isEditing = state.maybeMap(
state.editingHeaderId == widget.groupData.id) { ready: (value) => value.editingHeaderId == widget.groupData.id,
orElse: () => false,
);
if (isEditing) {
title = TextField( title = TextField(
controller: _controller, controller: _controller,
autofocus: true, autofocus: true,
@ -135,7 +137,7 @@ class _GroupCardHeaderState extends State<GroupCardHeader> {
icon: FlowySvgs.hide_s, icon: FlowySvgs.hide_s,
onTap: () { onTap: () {
context.read<BoardBloc>().add( context.read<BoardBloc>().add(
BoardEvent.toggleGroupVisibility( BoardEvent.setGroupVisibility(
widget.groupData.customData.group widget.groupData.customData.group
as GroupPB, as GroupPB,
false, false,
@ -154,9 +156,16 @@ class _GroupCardHeaderState extends State<GroupCardHeader> {
color: Theme.of(context).colorScheme.onSurface, color: Theme.of(context).colorScheme.onSurface,
), ),
splashRadius: 5, splashRadius: 5,
onPressed: () => context.read<BoardBloc>().add( onPressed: () {
BoardEvent.createHeaderRow(widget.groupData.id), context.read<BoardBloc>().add(
BoardEvent.createRow(
widget.groupData.id,
OrderObjectPositionTypePB.Start,
null,
null,
), ),
);
},
), ),
], ],
), ),

View File

@ -23,7 +23,10 @@ class MobileHiddenGroupsColumn extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final databaseController = context.read<BoardBloc>().databaseController; final databaseController = context.read<BoardBloc>().databaseController;
return BlocSelector<BoardBloc, BoardState, BoardLayoutSettingPB?>( return BlocSelector<BoardBloc, BoardState, BoardLayoutSettingPB?>(
selector: (state) => state.layoutSettings, selector: (state) => state.maybeMap(
orElse: () => null,
ready: (value) => value.layoutSettings,
),
builder: (context, layoutSettings) { builder: (context, layoutSettings) {
if (layoutSettings == null) { if (layoutSettings == null) {
return const SizedBox.shrink(); return const SizedBox.shrink();
@ -105,7 +108,11 @@ class MobileHiddenGroupList extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<BoardBloc, BoardState>( return BlocBuilder<BoardBloc, BoardState>(
builder: (_, state) => ReorderableListView.builder( builder: (_, state) {
return state.maybeMap(
orElse: () => const SizedBox.shrink(),
ready: (state) {
return ReorderableListView.builder(
itemCount: state.hiddenGroups.length, itemCount: state.hiddenGroups.length,
itemBuilder: (_, index) => MobileHiddenGroup( itemBuilder: (_, index) => MobileHiddenGroup(
key: ValueKey(state.hiddenGroups[index].groupId), key: ValueKey(state.hiddenGroups[index].groupId),
@ -127,7 +134,10 @@ class MobileHiddenGroupList extends StatelessWidget {
.read<BoardBloc>() .read<BoardBloc>()
.add(BoardEvent.reorderGroup(fromGroupId, toGroupId)); .add(BoardEvent.reorderGroup(fromGroupId, toGroupId));
}, },
), );
},
);
},
); );
} }
} }
@ -148,15 +158,6 @@ class MobileHiddenGroup extends StatelessWidget {
final primaryField = databaseController.fieldController.fieldInfos final primaryField = databaseController.fieldController.fieldInfos
.firstWhereOrNull((element) => element.isPrimary)!; .firstWhereOrNull((element) => element.isPrimary)!;
return BlocBuilder<BoardBloc, BoardState>(
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( final cells = group.rows.map(
(item) { (item) {
final cellContext = final cellContext =
@ -171,8 +172,7 @@ class MobileHiddenGroup extends StatelessWidget {
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
), ),
child: CardCellBuilder( child: CardCellBuilder(
databaseController: databaseController: context.read<BoardBloc>().databaseController,
context.read<BoardBloc>().databaseController,
).build( ).build(
cellContext: cellContext, cellContext: cellContext,
styleMap: {FieldType.RichText: _titleCellStyle(context)}, styleMap: {FieldType.RichText: _titleCellStyle(context)},
@ -221,20 +221,15 @@ class MobileHiddenGroup extends StatelessWidget {
), ),
actionButtonTitle: LocaleKeys.button_yes.tr(), actionButtonTitle: LocaleKeys.button_yes.tr(),
actionButtonColor: Theme.of(context).colorScheme.primary, actionButtonColor: Theme.of(context).colorScheme.primary,
onActionButtonPressed: () => context.read<BoardBloc>().add( onActionButtonPressed: () => context
BoardEvent.toggleGroupVisibility( .read<BoardBloc>()
group, .add(BoardEvent.setGroupVisibility(group, true)),
true,
),
),
), ),
), ),
], ],
), ),
children: cells, children: cells,
); );
},
);
} }
TextCardCellStyle _titleCellStyle(BuildContext context) { TextCardCellStyle _titleCellStyle(BuildContext context) {

View File

@ -162,7 +162,7 @@ class _MobileRowDetailPageState extends State<MobileRowDetailPage> {
} }
deleteRow deleteRow
? RowBackendService.deleteRow(viewId, rowId) ? RowBackendService.deleteRows(viewId, [rowId])
: RowBackendService.duplicateRow(viewId, rowId); : RowBackendService.duplicateRow(viewId, rowId);
context context

View File

@ -48,7 +48,7 @@ class RelationCellBloc extends Bloc<RelationCellEvent, RelationCellState> {
emit(state.copyWith(rows: const [])); emit(state.copyWith(rows: const []));
return; return;
} }
final payload = RepeatedRowIdPB( final payload = GetRelatedRowDataPB(
databaseId: state.relatedDatabaseMeta!.databaseId, databaseId: state.relatedDatabaseMeta!.databaseId,
rowIds: cellData.rowIds, rowIds: cellData.rowIds,
); );

View File

@ -1,6 +1,7 @@
import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
@ -12,11 +13,9 @@ class RelationDatabaseListCubit extends Cubit<RelationDatabaseListState> {
} }
void _loadDatabaseMetas() async { void _loadDatabaseMetas() async {
final getDatabaseResult = await DatabaseEventGetDatabases().send(); final metaPBs = await DatabaseEventGetDatabases()
final metaPBs = getDatabaseResult.fold<List<DatabaseMetaPB>>( .send()
(s) => s.items, .fold<List<DatabaseMetaPB>>((s) => s.items, (f) => []);
(f) => [],
);
final futures = metaPBs.map((meta) { final futures = metaPBs.map((meta) {
return ViewBackendService.getView(meta.inlineViewId).then( return ViewBackendService.getView(meta.inlineViewId).then(
(result) => result.fold( (result) => result.fold(

View File

@ -255,9 +255,7 @@ class RowCache {
RowInfo buildGridRow(RowMetaPB rowMetaPB) { RowInfo buildGridRow(RowMetaPB rowMetaPB) {
return RowInfo( return RowInfo(
viewId: viewId,
fields: _fieldDelegate.fieldInfos, fields: _fieldDelegate.fieldInfos,
rowId: rowMetaPB.id,
rowMeta: rowMetaPB, rowMeta: rowMetaPB,
); );
} }
@ -285,12 +283,13 @@ class RowChangesetNotifier extends ChangeNotifier {
@unfreezed @unfreezed
class RowInfo with _$RowInfo { class RowInfo with _$RowInfo {
const RowInfo._();
factory RowInfo({ factory RowInfo({
required String rowId,
required String viewId,
required UnmodifiableListView<FieldInfo> fields, required UnmodifiableListView<FieldInfo> fields,
required RowMetaPB rowMeta, required RowMetaPB rowMeta,
}) = _RowInfo; }) = _RowInfo;
String get rowId => rowMeta.id;
} }
typedef InsertedIndexs = List<InsertedIndex>; typedef InsertedIndexs = List<InsertedIndex>;

View File

@ -96,15 +96,15 @@ class RowBackendService {
return DatabaseEventUpdateRowMeta(payload).send(); return DatabaseEventUpdateRowMeta(payload).send();
} }
static Future<FlowyResult<void, FlowyError>> deleteRow( static Future<FlowyResult<void, FlowyError>> deleteRows(
String viewId, String viewId,
RowId rowId, List<RowId> rowIds,
) { ) {
final payload = RowIdPB.create() final payload = RepeatedRowIdPB.create()
..viewId = viewId ..viewId = viewId
..rowId = rowId; ..rowIds.addAll(rowIds);
return DatabaseEventDeleteRow(payload).send(); return DatabaseEventDeleteRows(payload).send();
} }
static Future<FlowyResult<void, FlowyError>> duplicateRow( static Future<FlowyResult<void, FlowyError>> duplicateRow(

View File

@ -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<BoardActionsState> {
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<GroupedRowId> 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<GroupedRowId> 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,
}

View File

@ -8,10 +8,11 @@ import 'package:appflowy/plugins/database/application/row/row_service.dart';
import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.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-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_board/appflowy_board.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:appflowy_result/appflowy_result.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
@ -27,11 +28,31 @@ part 'board_bloc.freezed.dart';
class BoardBloc extends Bloc<BoardEvent, BoardState> { class BoardBloc extends Bloc<BoardEvent, BoardState> {
BoardBloc({ BoardBloc({
required ViewPB view,
required this.databaseController, required this.databaseController,
}) : super(BoardState.initial(view.id)) { this.didCreateRow,
AppFlowyBoardController? boardController,
}) : super(const BoardState.loading()) {
groupBackendSvc = GroupBackendService(viewId); groupBackendSvc = GroupBackendService(viewId);
boardController = AppFlowyBoardController( _initBoardController(boardController);
_dispatch();
}
final DatabaseController databaseController;
late final AppFlowyBoardController boardController;
final LinkedHashMap<String, GroupController> groupControllers =
LinkedHashMap();
final List<GroupPB> groupList = [];
final ValueNotifier<DidCreateRowResult?>? 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) => onMoveGroup: (fromGroupId, fromIndex, toGroupId, toIndex) =>
databaseController.moveGroup( databaseController.moveGroup(
fromGroupId: fromGroupId, fromGroupId: fromGroupId,
@ -50,7 +71,8 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
} }
}, },
onMoveGroupItemToGroup: (fromGroupId, fromIndex, toGroupId, toIndex) { onMoveGroupItemToGroup: (fromGroupId, fromIndex, toGroupId, toIndex) {
final fromRow = groupControllers[fromGroupId]?.rowAtIndex(fromIndex); final fromRow =
groupControllers[fromGroupId]?.rowAtIndex(fromIndex);
final toRow = groupControllers[toGroupId]?.rowAtIndex(toIndex); final toRow = groupControllers[toGroupId]?.rowAtIndex(toIndex);
if (fromRow != null) { if (fromRow != null) {
databaseController.moveGroupRow( databaseController.moveGroupRow(
@ -62,60 +84,52 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
} }
}, },
); );
_dispatch();
} }
final DatabaseController databaseController;
final LinkedHashMap<String, GroupController> groupControllers =
LinkedHashMap();
final List<GroupPB> groupList = [];
late final AppFlowyBoardController boardController;
late final GroupBackendService groupBackendSvc;
FieldController get fieldController => databaseController.fieldController;
String get viewId => databaseController.viewId;
void _dispatch() { void _dispatch() {
on<BoardEvent>( on<BoardEvent>(
(event, emit) async { (event, emit) async {
await event.when( await event.when(
initial: () async { initial: () async {
emit(BoardState.initial(viewId));
_startListening(); _startListening();
await _openGrid(emit); await _openDatabase(emit);
}, },
createHeaderRow: (groupId) async { createRow: (groupId, position, title, targetRowId) async {
final rowId = groupControllers[groupId]?.firstRow()?.id; final primaryField = databaseController.fieldController.fieldInfos
final position = rowId == null .firstWhereOrNull((element) => element.isPrimary)!;
? OrderObjectPositionTypePB.Start final void Function(RowDataBuilder)? cellBuilder = title == null
: OrderObjectPositionTypePB.Before; ? null
: (builder) => builder.insertText(primaryField, title);
final result = await RowBackendService.createRow( final result = await RowBackendService.createRow(
viewId: databaseController.viewId, viewId: databaseController.viewId,
groupId: groupId, groupId: groupId,
position: position, position: position,
targetRowId: rowId, targetRowId: targetRowId,
withCells: cellBuilder,
); );
final startEditing = position != OrderObjectPositionTypePB.End;
final action = PlatformExtension.isMobile
? DidCreateRowAction.openAsPage
: startEditing
? DidCreateRowAction.startEditing
: DidCreateRowAction.none;
result.fold( result.fold(
(rowMeta) => emit(state.copyWith(recentAddedRowMeta: rowMeta)), (rowMeta) {
(err) => Log.error(err), state.maybeMap(
ready: (value) {
didCreateRow?.value = DidCreateRowResult(
action: action,
rowMeta: rowMeta,
groupId: groupId,
); );
}, },
createBottomRow: (groupId) async { orElse: () {},
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,
); );
},
result.fold(
(rowMeta) => emit(state.copyWith(recentAddedRowMeta: rowMeta)),
(err) => Log.error(err), (err) => Log.error(err),
); );
}, },
@ -127,47 +141,41 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
final result = await groupBackendSvc.deleteGroup(groupId: groupId); final result = await groupBackendSvc.deleteGroup(groupId: groupId);
result.fold((_) {}, (err) => Log.error(err)); result.fold((_) {}, (err) => Log.error(err));
}, },
didCreateRow: (group, row, int? index) { didReceiveError: (error) {
emit( emit(BoardState.error(error: error));
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));
}, },
didReceiveGroups: (List<GroupPB> groups) { didReceiveGroups: (List<GroupPB> groups) {
final hiddenGroups = _filterHiddenGroups(hideUngrouped, groups); state.maybeMap(
ready: (state) {
emit( emit(
state.copyWith( state.copyWith(
hiddenGroups: hiddenGroups, hiddenGroups: _filterHiddenGroups(hideUngrouped, groups),
groupIds: groups.map((group) => group.groupId).toList(), groupIds: groups.map((group) => group.groupId).toList(),
), ),
); );
}, },
orElse: () {},
);
},
didUpdateLayoutSettings: (layoutSettings) { didUpdateLayoutSettings: (layoutSettings) {
final hiddenGroups = _filterHiddenGroups(hideUngrouped, groupList); state.maybeMap(
ready: (state) {
emit( emit(
state.copyWith( state.copyWith(
layoutSettings: layoutSettings, layoutSettings: layoutSettings,
hiddenGroups: hiddenGroups, hiddenGroups: _filterHiddenGroups(hideUngrouped, groupList),
), ),
); );
}, },
toggleGroupVisibility: (GroupPB group, bool isVisible) async { orElse: () {},
await _toggleGroupVisibility(group, isVisible); );
},
setGroupVisibility: (GroupPB group, bool isVisible) async {
await _setGroupVisibility(group, isVisible);
}, },
toggleHiddenSectionVisibility: (isVisible) async { toggleHiddenSectionVisibility: (isVisible) async {
await state.maybeMap(
ready: (state) async {
final newLayoutSettings = state.layoutSettings!; final newLayoutSettings = state.layoutSettings!;
newLayoutSettings.freeze(); newLayoutSettings.freeze();
@ -179,37 +187,16 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
boardLayoutSetting: newLayoutSetting, boardLayoutSetting: newLayoutSetting,
); );
}, },
orElse: () {},
);
},
reorderGroup: (fromGroupId, toGroupId) async { reorderGroup: (fromGroupId, toGroupId) async {
_reorderGroup(fromGroupId, toGroupId, emit); _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) { startEditingHeader: (String groupId) {
emit( state.maybeMap(
state.copyWith(isEditingHeader: true, editingHeaderId: groupId), ready: (state) => emit(state.copyWith(editingHeaderId: groupId)),
orElse: () {},
); );
}, },
endEditingHeader: (String groupId, String? groupName) async { endEditingHeader: (String groupId, String? groupName) async {
@ -218,24 +205,59 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
groupId: groupId, groupId: groupId,
name: groupName, 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) { Future<void> _setGroupVisibility(GroupPB group, bool isVisible) async {
final fieldInfo = fieldController.getField(group.fieldId);
if (fieldInfo == null) {
return Log.warn("fieldInfo should not be null");
}
boardController.enableGroupDragging(!isEdit);
}
Future<void> _toggleGroupVisibility(GroupPB group, bool isVisible) async {
if (group.isDefault) { if (group.isDefault) {
await state.maybeMap(
ready: (state) async {
final newLayoutSettings = state.layoutSettings!; final newLayoutSettings = state.layoutSettings!;
newLayoutSettings.freeze(); newLayoutSettings.freeze();
@ -243,17 +265,20 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
(message) => message.hideUngroupedColumn = !isVisible, (message) => message.hideUngroupedColumn = !isVisible,
); );
return databaseController.updateLayoutSetting( await databaseController.updateLayoutSetting(
boardLayoutSetting: newLayoutSetting, boardLayoutSetting: newLayoutSetting,
); );
} },
orElse: () {},
);
} else {
await groupBackendSvc.updateGroup( await groupBackendSvc.updateGroup(
fieldId: groupControllers.values.first.group.fieldId, fieldId: groupControllers.values.first.group.fieldId,
groupId: group.groupId, groupId: group.groupId,
visible: isVisible, visible: isVisible,
); );
} }
}
void _reorderGroup( void _reorderGroup(
String fromGroupId, String fromGroupId,
@ -277,6 +302,7 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
for (final controller in groupControllers.values) { for (final controller in groupControllers.values) {
await controller.dispose(); await controller.dispose();
} }
boardController.dispose();
return super.close(); return super.close();
} }
@ -284,11 +310,13 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
databaseController.databaseLayoutSetting?.board.hideUngroupedColumn ?? databaseController.databaseLayoutSetting?.board.hideUngroupedColumn ??
false; false;
FieldType get groupingFieldType { FieldType? get groupingFieldType {
final fieldInfo = if (groupList.isEmpty) {
databaseController.fieldController.getField(groupList.first.fieldId)!; return null;
}
return fieldInfo.fieldType; return databaseController.fieldController
.getField(groupList.first.fieldId)
?.fieldType;
} }
void initializeGroups(List<GroupPB> groups) { void initializeGroups(List<GroupPB> groups) {
@ -321,16 +349,9 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
} }
} }
RowCache? getRowCache() => databaseController.rowCache; RowCache get rowCache => databaseController.rowCache;
void _startListening() { void _startListening() {
final onDatabaseChanged = DatabaseCallbacks(
onDatabaseChanged: (database) {
if (!isClosed) {
add(BoardEvent.didReceiveGridUpdate(database));
}
},
);
final onLayoutSettingsChanged = DatabaseLayoutSettingCallbacks( final onLayoutSettingsChanged = DatabaseLayoutSettingCallbacks(
onLayoutSettingsChanged: (layoutSettings) { onLayoutSettingsChanged: (layoutSettings) {
if (isClosed) { if (isClosed) {
@ -433,7 +454,6 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
); );
databaseController.addListener( databaseController.addListener(
onDatabaseChanged: onDatabaseChanged,
onLayoutSettingsChanged: onLayoutSettingsChanged, onLayoutSettingsChanged: onLayoutSettingsChanged,
onGroupChanged: onGroupChanged, onGroupChanged: onGroupChanged,
); );
@ -451,22 +471,10 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
return <AppFlowyGroupItem>[...items]; return <AppFlowyGroupItem>[...items];
} }
Future<void> _openGrid(Emitter<BoardState> emit) async { Future<void> _openDatabase(Emitter<BoardState> emit) {
final result = await databaseController.open(); return databaseController.open().fold(
result.fold( (datbasePB) => databaseController.setIsLoading(false),
(grid) { (err) => emit(BoardState.error(error: err)),
databaseController.setIsLoading(false);
emit(
state.copyWith(
loadingState: LoadingState.finish(FlowyResult.success(null)),
),
);
},
(err) => emit(
state.copyWith(
loadingState: LoadingState.finish(FlowyResult.failure(err)),
),
),
); );
} }
@ -474,8 +482,7 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
final delegate = GroupControllerDelegateImpl( final delegate = GroupControllerDelegateImpl(
controller: boardController, controller: boardController,
fieldController: fieldController, fieldController: fieldController,
onNewColumnItem: (groupId, row, index) => onNewColumnItem: (groupId, row, index) {},
add(BoardEvent.didCreateRow(group, row, index)),
); );
final controller = GroupController( final controller = GroupController(
@ -579,71 +586,77 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
@freezed @freezed
class BoardEvent with _$BoardEvent { class BoardEvent with _$BoardEvent {
const factory BoardEvent.initial() = _InitialBoard; const factory BoardEvent.initial() = _InitialBoard;
const factory BoardEvent.createBottomRow(String groupId) = _CreateBottomRow; const factory BoardEvent.createRow(
const factory BoardEvent.createHeaderRow(String groupId) = _CreateHeaderRow; String groupId,
OrderObjectPositionTypePB position,
String? title,
String? targetRowId,
) = _CreateRow;
const factory BoardEvent.createGroup(String name) = _CreateGroup; const factory BoardEvent.createGroup(String name) = _CreateGroup;
const factory BoardEvent.startEditingHeader(String groupId) = const factory BoardEvent.startEditingHeader(String groupId) =
_StartEditingHeader; _StartEditingHeader;
const factory BoardEvent.endEditingHeader(String groupId, String? groupName) = const factory BoardEvent.endEditingHeader(String groupId, String? groupName) =
_EndEditingHeader; _EndEditingHeader;
const factory BoardEvent.didCreateRow( const factory BoardEvent.setGroupVisibility(
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(
GroupPB group, GroupPB group,
bool isVisible, bool isVisible,
) = _ToggleGroupVisibility; ) = _SetGroupVisibility;
const factory BoardEvent.toggleHiddenSectionVisibility(bool isVisible) = const factory BoardEvent.toggleHiddenSectionVisibility(bool isVisible) =
_ToggleHiddenSectionVisibility; _ToggleHiddenSectionVisibility;
const factory BoardEvent.deleteGroup(String groupId) = _DeleteGroup; const factory BoardEvent.deleteGroup(String groupId) = _DeleteGroup;
const factory BoardEvent.reorderGroup(String fromGroupId, String toGroupId) = const factory BoardEvent.reorderGroup(String fromGroupId, String toGroupId) =
_ReorderGroup; _ReorderGroup;
const factory BoardEvent.didReceiveError(FlowyError error) = _DidReceiveError; const factory BoardEvent.didReceiveError(FlowyError error) = _DidReceiveError;
const factory BoardEvent.didReceiveGridUpdate(
DatabasePB grid,
) = _DidReceiveGridUpdate;
const factory BoardEvent.didReceiveGroups(List<GroupPB> groups) = const factory BoardEvent.didReceiveGroups(List<GroupPB> groups) =
_DidReceiveGroups; _DidReceiveGroups;
const factory BoardEvent.didUpdateLayoutSettings( const factory BoardEvent.didUpdateLayoutSettings(
BoardLayoutSettingPB layoutSettings, BoardLayoutSettingPB layoutSettings,
) = _DidUpdateLayoutSettings; ) = _DidUpdateLayoutSettings;
const factory BoardEvent.deleteCards(List<GroupedRowId> groupedRowIds) =
_DeleteCards;
const factory BoardEvent.moveGroupToAdjacentGroup(
GroupedRowId groupedRowId,
bool toPrevious,
) = _MoveGroupToAdjacentGroup;
} }
@freezed @freezed
class BoardState with _$BoardState { 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 String viewId,
required DatabasePB? grid,
required List<String> groupIds, required List<String> groupIds,
required bool isEditingHeader,
required bool isEditingRow,
required LoadingState loadingState, required LoadingState loadingState,
required FlowyError? noneOrError, required FlowyError? noneOrError,
required BoardLayoutSettingPB? layoutSettings, required BoardLayoutSettingPB? layoutSettings,
String? editingHeaderId,
BoardEditingRow? editingRow,
RowMetaPB? recentAddedRowMeta,
required List<GroupPB> hiddenGroups, required List<GroupPB> hiddenGroups,
}) = _BoardState; String? editingHeaderId,
}) = _BoardReadyState;
factory BoardState.initial(String viewId) => BoardState( const factory BoardState.setFocus({
grid: null, required List<GroupedRowId> groupedRowIds,
}) = _BoardSetFocusState;
factory BoardState.initial(String viewId) => BoardState.ready(
viewId: viewId, viewId: viewId,
groupIds: [], groupIds: [],
isEditingHeader: false,
isEditingRow: false,
noneOrError: null, noneOrError: null,
loadingState: const LoadingState.loading(), loadingState: const LoadingState.loading(),
layoutSettings: null, layoutSettings: null,
hiddenGroups: [], 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<GroupPB> _filterHiddenGroups(bool hideUngrouped, List<GroupPB> groups) { List<GroupPB> _filterHiddenGroups(bool hideUngrouped, List<GroupPB> groups) {
@ -658,7 +671,7 @@ class GroupItem extends AppFlowyGroupItem {
required this.fieldInfo, required this.fieldInfo,
bool draggable = true, bool draggable = true,
}) { }) {
super.draggable = draggable; super.draggable.value = draggable;
} }
final RowMetaPB row; final RowMetaPB row;
@ -668,6 +681,23 @@ class GroupItem extends AppFlowyGroupItem {
String get id => row.id.toString(); 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<Object?> get props => [rowId, groupId];
}
class GroupControllerDelegateImpl extends GroupControllerDelegate { class GroupControllerDelegateImpl extends GroupControllerDelegate {
GroupControllerDelegateImpl({ GroupControllerDelegateImpl({
required this.controller, 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 { class GroupData {
GroupData({ GroupData({
required this.group, required this.group,
@ -779,3 +797,21 @@ class CheckboxGroup {
// pub const CHECK: &str = "Yes"; // pub const CHECK: &str = "Yes";
bool get isCheck => group.groupId == "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;
}

View File

@ -1,12 +1,11 @@
import 'dart:collection'; import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.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/database/board/mobile_board_page.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_state_container.dart';
import 'package:appflowy/plugins/database/application/database_controller.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/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/board/presentation/widgets/board_column_header.dart';
import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/header/field_type_extension.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/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/cell/card_cell_style_maps/desktop_board_card_cell_style.dart';
import 'package:appflowy/plugins/database/widgets/row/row_detail.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_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_board/appflowy_board.dart'; import 'package:appflowy_board/appflowy_board.dart';
import 'package:appflowy_editor/appflowy_editor.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/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../workspace/application/view/view_bloc.dart';
import '../../widgets/card/card.dart'; import '../../widgets/card/card.dart';
import '../../widgets/cell/card_cell_builder.dart'; import '../../widgets/cell/card_cell_builder.dart';
import '../application/board_bloc.dart'; import '../application/board_bloc.dart';
import 'toolbar/board_setting_bar.dart'; import 'toolbar/board_setting_bar.dart';
import 'widgets/board_focus_scope.dart';
import 'widgets/board_hidden_groups.dart'; import 'widgets/board_hidden_groups.dart';
import 'widgets/board_shortcut_container.dart';
class BoardPageTabBarBuilderImpl extends DatabaseTabBarItemBuilder { class BoardPageTabBarBuilderImpl extends DatabaseTabBarItemBuilder {
final _toggleExtension = ToggleExtensionNotifier(); final _toggleExtension = ToggleExtensionNotifier();
@ -46,7 +47,17 @@ class BoardPageTabBarBuilderImpl extends DatabaseTabBarItemBuilder {
bool shrinkWrap, bool shrinkWrap,
String? initialRowId, 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 @override
Widget settingBar(BuildContext context, DatabaseController controller) => Widget settingBar(BuildContext context, DatabaseController controller) =>
@ -79,12 +90,13 @@ class BoardPageTabBarBuilderImpl extends DatabaseTabBarItemBuilder {
ValueKey(controller.viewId); ValueKey(controller.viewId);
} }
class BoardPage extends StatelessWidget { class DesktopBoardPage extends StatefulWidget {
BoardPage({ const DesktopBoardPage({
super.key,
required this.view, required this.view,
required this.databaseController, required this.databaseController,
this.onEditStateChanged, this.onEditStateChanged,
}) : super(key: ValueKey(view.id)); });
final ViewPB view; final ViewPB view;
@ -93,54 +105,154 @@ class BoardPage extends StatelessWidget {
/// Called when edit state changed /// Called when edit state changed
final VoidCallback? onEditStateChanged; final VoidCallback? onEditStateChanged;
@override
State<DesktopBoardPage> createState() => _DesktopBoardPageState();
}
class _DesktopBoardPageState extends State<DesktopBoardPage> {
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<DidCreateRowResult?> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider<BoardBloc>( return MultiBlocProvider(
create: (context) => BoardBloc( providers: [
view: view, BlocProvider<BoardBloc>.value(
databaseController: databaseController, value: _boardBloc,
)..add(const BoardEvent.initial()), ),
BlocProvider.value(
value: _boardActionsCubit,
),
],
child: BlocBuilder<BoardBloc, BoardState>( child: BlocBuilder<BoardBloc, BoardState>(
buildWhen: (p, c) => p.loadingState != c.loadingState, builder: (context, state) => state.maybeMap(
builder: (context, state) => state.loadingState.when( loading: (_) => const Center(
loading: () => const Center(
child: CircularProgressIndicator.adaptive(), child: CircularProgressIndicator.adaptive(),
), ),
idle: () => const SizedBox.shrink(), error: (err) => FlowyErrorPage.message(
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(), err.toString(),
howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), 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 { class _BoardContent extends StatefulWidget {
const DesktopBoardContent({ const _BoardContent({
super.key, required this.boardController,
required this.focusScope,
this.onEditStateChanged, this.onEditStateChanged,
}); });
final AppFlowyBoardController boardController;
final BoardFocusScope focusScope;
final VoidCallback? onEditStateChanged; final VoidCallback? onEditStateChanged;
@override @override
State<DesktopBoardContent> createState() => _DesktopBoardContentState(); State<_BoardContent> createState() => _BoardContentState();
} }
class _DesktopBoardContentState extends State<DesktopBoardContent> { class _BoardContentState extends State<_BoardContent> {
final ScrollController scrollController = ScrollController(); final ScrollController scrollController = ScrollController();
final AppFlowyBoardScrollController scrollManager = final AppFlowyBoardScrollController scrollManager =
AppFlowyBoardScrollController(); AppFlowyBoardScrollController();
@ -148,16 +260,19 @@ class _DesktopBoardContentState extends State<DesktopBoardContent> {
final config = const AppFlowyBoardConfig( final config = const AppFlowyBoardConfig(
groupMargin: EdgeInsets.symmetric(horizontal: 4), groupMargin: EdgeInsets.symmetric(horizontal: 4),
groupBodyPadding: 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), groupHeaderPadding: EdgeInsets.symmetric(horizontal: 8),
cardMargin: EdgeInsets.symmetric(horizontal: 4, vertical: 3), cardMargin: EdgeInsets.symmetric(horizontal: 4, vertical: 3),
stretchGroupHeight: false, stretchGroupHeight: false,
); );
late final cellBuilder = CardCellBuilder( late final cellBuilder = CardCellBuilder(
databaseController: context.read<BoardBloc>().databaseController, databaseController: databaseController,
); );
DatabaseController get databaseController =>
context.read<BoardBloc>().databaseController;
@override @override
void dispose() { void dispose() {
scrollController.dispose(); scrollController.dispose();
@ -166,15 +281,49 @@ class _DesktopBoardContentState extends State<DesktopBoardContent> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocListener<BoardBloc, BoardState>( return MultiBlocListener(
listeners: [
BlocListener<BoardBloc, BoardState>(
listener: (context, state) { listener: (context, state) {
state.maybeMap(
ready: (value) {
widget.onEditStateChanged?.call(); widget.onEditStateChanged?.call();
}, },
child: BlocBuilder<BoardBloc, BoardState>( orElse: () {},
builder: (context, state) { );
final showCreateGroupButton = },
context.read<BoardBloc>().groupingFieldType.canCreateNewGroup; ),
return Padding( BlocListener<BoardActionsCubit, BoardActionsState>(
listener: (context, state) {
state.maybeMap(
openCard: (value) {
_openCard(
context: context,
databaseController:
context.read<BoardBloc>().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), padding: const EdgeInsets.only(top: 8.0),
child: AppFlowyBoard( child: AppFlowyBoard(
boardScrollController: scrollManager, boardScrollController: scrollManager,
@ -183,7 +332,11 @@ class _DesktopBoardContentState extends State<DesktopBoardContent> {
groupConstraints: const BoxConstraints.tightFor(width: 256), groupConstraints: const BoxConstraints.tightFor(width: 256),
config: config, config: config,
leading: HiddenGroupsColumn(margin: config.groupHeaderPadding), leading: HiddenGroupsColumn(margin: config.groupHeaderPadding),
trailing: showCreateGroupButton trailing: context
.read<BoardBloc>()
.groupingFieldType
?.canCreateNewGroup ??
false
? BoardTrailing(scrollController: scrollController) ? BoardTrailing(scrollController: scrollController)
: const HSpace(40), : const HSpace(40),
headerBuilder: (_, groupData) => BlocProvider<BoardBloc>.value( headerBuilder: (_, groupData) => BlocProvider<BoardBloc>.value(
@ -193,110 +346,367 @@ class _DesktopBoardContentState extends State<DesktopBoardContent> {
margin: config.groupHeaderPadding, margin: config.groupHeaderPadding,
), ),
), ),
footerBuilder: _buildFooter, footerBuilder: (_, groupData) => MultiBlocProvider(
cardBuilder: (_, column, columnItem) => _buildCard( providers: [
context, BlocProvider.value(
column, value: context.read<BoardBloc>(),
columnItem, ),
BlocProvider.value(
value: context.read<BoardActionsCubit>(),
),
],
child: BoardColumnFooter(
columnData: groupData,
boardConfig: config,
scrollManager: scrollManager,
),
),
cardBuilder: (_, column, columnItem) => MultiBlocProvider(
key: ValueKey("board_card_${column.id}_${columnItem.id}"),
providers: [
BlocProvider<BoardBloc>.value(
value: context.read<BoardBloc>(),
),
BlocProvider.value(
value: context.read<BoardActionsCubit>(),
),
],
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<BoardColumnFooter> createState() => _BoardColumnFooterState();
}
class _BoardColumnFooterState extends State<BoardColumnFooter> {
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<BoardBloc>().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) { Widget _startCreatingCardsButton() {
return Padding( return BlocListener<BoardActionsCubit, BoardActionsState>(
padding: config.groupFooterPadding, listener: (context, state) {
state.maybeWhen(
startCreateBottomRow: (groupId) {
if (groupId == widget.columnData.id) {
setState(() => _isCreating = true);
}
},
orElse: () {},
);
},
child: FlowyTooltip( child: FlowyTooltip(
message: LocaleKeys.board_column_addToColumnBottomTooltip.tr(), message: LocaleKeys.board_column_addToColumnBottomTooltip.tr(),
child: FlowyHover( child: SizedBox(
child: AppFlowyGroupFooter(
height: 36, height: 36,
icon: FlowySvg( child: FlowyButton(
leftIcon: FlowySvg(
FlowySvgs.add_s, FlowySvgs.add_s,
color: Theme.of(context).hintColor, color: Theme.of(context).hintColor,
), ),
title: FlowyText.medium( text: FlowyText.medium(
LocaleKeys.board_column_createNewCard.tr(), LocaleKeys.board_column_createNewCard.tr(),
color: Theme.of(context).hintColor, color: Theme.of(context).hintColor,
), ),
onAddButtonClick: () => context onTap: () {
.read<BoardBloc>() setState(() => _isCreating = true);
.add(BoardEvent.createBottomRow(columnData.id)), },
), ),
), ),
), ),
); );
} }
}
Widget _buildCard( class _BoardCard extends StatefulWidget {
BuildContext context, const _BoardCard({
AppFlowyGroupData afGroupData, required this.afGroupData,
AppFlowyGroupItem afGroupItem, 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<BoardBloc>(); final boardBloc = context.read<BoardBloc>();
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. final groupData = widget.afGroupData.customData as GroupData;
if (rowCache == null) { final rowCache = boardBloc.rowCache;
return SizedBox.shrink(key: ObjectKey(groupItem));
}
final databaseController = boardBloc.databaseController; final databaseController = boardBloc.databaseController;
final viewId = boardBloc.viewId; final rowMeta =
rowCache.getRow(widget.groupItem.id)?.rowMeta ?? widget.groupItem.row;
final isEditing = boardBloc.state.isEditingRow && const nada = DoNothingAndStopPropagationIntent();
boardBloc.state.editingRow?.row.id == groupItem.row.id;
final groupItemId = "${groupData.group.groupId}${groupItem.row.id}"; return BlocListener<BoardActionsCubit, BoardActionsState>(
final rowMeta = rowInfo?.rowMeta ?? groupItem.row; 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<BoardBloc>().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<List<GroupedRowId>>(
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( return previousContainsFocus != currentContainsFocus;
key: ValueKey(groupItemId), },
margin: config.cardMargin, builder: (context, focusedItems, child) => Container(
decoration: _makeBoxDecoration(context), margin: widget.boardConfig.cardMargin,
decoration: _makeBoxDecoration(
context,
groupData.group.groupId,
widget.groupItem.id,
),
child: child,
),
child: RowCard( child: RowCard(
fieldController: databaseController.fieldController, fieldController: databaseController.fieldController,
rowMeta: rowMeta, rowMeta: rowMeta,
viewId: viewId, viewId: boardBloc.viewId,
rowCache: rowCache, rowCache: rowCache,
groupingFieldId: groupItem.fieldInfo.id, groupingFieldId: widget.groupItem.fieldInfo.id,
isEditing: isEditing, isEditing: _isEditing,
cellBuilder: cellBuilder, cellBuilder: widget.cellBuilder,
openCard: (context) => _openCard( onTap: (context) => _openCard(
context: context, context: context,
databaseController: databaseController, databaseController: databaseController,
groupId: groupData.group.groupId,
rowMeta: context.read<CardBloc>().state.rowMeta, rowMeta: context.read<CardBloc>().state.rowMeta,
), ),
onShiftTap: (_) {
Focus.of(context).requestFocus();
widget.notifier.toggle(
GroupedRowId(
rowId: widget.groupItem.row.id,
groupId: groupData.group.groupId,
),
);
},
styleConfiguration: RowCardStyleConfiguration( styleConfiguration: RowCardStyleConfiguration(
cellStyleMap: desktopBoardCardCellStyleMap(context), cellStyleMap: desktopBoardCardCellStyleMap(context),
hoverStyle: HoverStyle( hoverStyle: HoverStyle(
hoverColor: Theme.of(context).brightness == Brightness.light hoverColor: Theme.of(context).brightness == Brightness.light
? const Color(0x0F1F2329) ? const Color(0x0F1F2329)
: const Color(0x0FEFF4FB), : const Color(0x0FEFF4FB),
foregroundColorOnHover: Theme.of(context).colorScheme.onBackground, foregroundColorOnHover:
Theme.of(context).colorScheme.onBackground,
), ),
), ),
onStartEditing: () => onStartEditing: () =>
boardBloc.add(BoardEvent.startEditingRow(groupData.group, rowMeta)), context.read<BoardActionsCubit>().startEditingRow(
onEndEditing: () => boardBloc.add(BoardEvent.endEditingRow(rowMeta.id)), GroupedRowId(
groupId: groupData.group.groupId,
rowId: rowMeta.id,
),
),
onEndEditing: () => context.read<BoardActionsCubit>().endEditing(
GroupedRowId(
groupId: groupData.group.groupId,
rowId: rowMeta.id,
),
),
),
),
), ),
); );
} }
BoxDecoration _makeBoxDecoration(BuildContext context) { BoxDecoration _makeBoxDecoration(
BuildContext context,
String groupId,
String rowId,
) {
return BoxDecoration( return BoxDecoration(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.all(Radius.circular(6)), borderRadius: const BorderRadius.all(Radius.circular(6)),
border: Border.fromBorderSide( border: Border.fromBorderSide(
BorderSide( BorderSide(
color: Theme.of(context).brightness == Brightness.light 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(0xFF1F2329).withOpacity(0.12)
: const Color(0xFF59647A), : const Color(0xFF59647A),
), ),
@ -314,39 +724,6 @@ class _DesktopBoardContentState extends State<DesktopBoardContent> {
], ],
); );
} }
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<ViewBloc>(),
child: RowDetailPage(
databaseController: databaseController,
rowController: rowController,
),
),
);
}
} }
class BoardTrailing extends StatefulWidget { class BoardTrailing extends StatefulWidget {
@ -458,3 +835,23 @@ class _BoardTrailingState extends State<BoardTrailing> {
} }
} }
} }
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,
),
);
}

View File

@ -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/layout/sizes.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/header/field_type_extension.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/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/group.pb.dart';
import 'package:appflowy_board/appflowy_board.dart'; import 'package:appflowy_board/appflowy_board.dart';
import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
@ -65,7 +64,10 @@ class _BoardColumnHeaderState extends State<BoardColumnHeader> {
return BlocBuilder<BoardBloc, BoardState>( return BlocBuilder<BoardBloc, BoardState>(
builder: (context, state) { builder: (context, state) {
if (state.isEditingHeader) { return state.maybeMap(
orElse: () => const SizedBox.shrink(),
ready: (state) {
if (state.editingHeaderId != null) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus(); _focusNode.requestFocus();
}); });
@ -87,9 +89,9 @@ class _BoardColumnHeaderState extends State<BoardColumnHeader> {
child: MouseRegion( child: MouseRegion(
cursor: SystemMouseCursors.click, cursor: SystemMouseCursors.click,
child: GestureDetector( child: GestureDetector(
onTap: () => context onTap: () => context.read<BoardBloc>().add(
.read<BoardBloc>() BoardEvent.startEditingHeader(widget.groupData.id),
.add(BoardEvent.startEditingHeader(widget.groupData.id)), ),
child: FlowyText.medium( child: FlowyText.medium(
widget.groupData.headerData.groupName, widget.groupData.headerData.groupName,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@ -100,8 +102,7 @@ class _BoardColumnHeaderState extends State<BoardColumnHeader> {
); );
} }
if (state.isEditingHeader && if (state.editingHeaderId == widget.groupData.id) {
state.editingHeaderId == widget.groupData.id) {
title = _buildTextField(context); title = _buildTextField(context);
} }
@ -117,15 +118,22 @@ class _BoardColumnHeaderState extends State<BoardColumnHeader> {
_groupOptionsButton(context), _groupOptionsButton(context),
const HSpace(4), const HSpace(4),
FlowyTooltip( FlowyTooltip(
message: LocaleKeys.board_column_addToColumnTopTooltip.tr(), message:
LocaleKeys.board_column_addToColumnTopTooltip.tr(),
preferBelow: false, preferBelow: false,
child: FlowyIconButton( child: FlowyIconButton(
width: 20, width: 20,
icon: const FlowySvg(FlowySvgs.add_s), icon: const FlowySvg(FlowySvgs.add_s),
iconColorOnHover: Theme.of(context).colorScheme.onSurface, iconColorOnHover:
onPressed: () => context Theme.of(context).colorScheme.onSurface,
.read<BoardBloc>() onPressed: () => context.read<BoardBloc>().add(
.add(BoardEvent.createHeaderRow(widget.groupData.id)), BoardEvent.createRow(
widget.groupData.id,
OrderObjectPositionTypePB.Start,
null,
null,
),
),
), ),
), ),
], ],
@ -134,6 +142,8 @@ class _BoardColumnHeaderState extends State<BoardColumnHeader> {
); );
}, },
); );
},
);
} }
Widget _buildTextField(BuildContext context) { Widget _buildTextField(BuildContext context) {
@ -257,7 +267,7 @@ enum GroupOptions {
case hide: case hide:
context context
.read<BoardBloc>() .read<BoardBloc>()
.add(BoardEvent.toggleGroupVisibility(group, false)); .add(BoardEvent.setGroupVisibility(group, false));
break; break;
case delete: case delete:
NavigatorAlertDialog( NavigatorAlertDialog(

View File

@ -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<List<GroupedRowId>> {
BoardFocusScope({
required this.boardController,
});
final AppFlowyBoardController boardController;
List<GroupedRowId> _focusedCards = [];
@override
List<GroupedRowId> get value => _focusedCards;
UnmodifiableListView<GroupedRowId> get focusedGroupedRows =>
UnmodifiableListView(_focusedCards);
set focusedGroupedRows(List<GroupedRowId> 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];
}
}

View File

@ -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_builder.dart';
import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/text_card_cell.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/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_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -23,7 +22,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
class HiddenGroupsColumn extends StatelessWidget { class HiddenGroupsColumn extends StatelessWidget {
const HiddenGroupsColumn({super.key, required this.margin}); const HiddenGroupsColumn({
super.key,
required this.margin,
});
final EdgeInsets margin; final EdgeInsets margin;
@ -31,7 +33,10 @@ class HiddenGroupsColumn extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final databaseController = context.read<BoardBloc>().databaseController; final databaseController = context.read<BoardBloc>().databaseController;
return BlocSelector<BoardBloc, BoardState, BoardLayoutSettingPB?>( return BlocSelector<BoardBloc, BoardState, BoardLayoutSettingPB?>(
selector: (state) => state.layoutSettings, selector: (state) => state.maybeMap(
orElse: () => null,
ready: (value) => value.layoutSettings,
),
builder: (context, layoutSettings) { builder: (context, layoutSettings) {
if (layoutSettings == null) { if (layoutSettings == null) {
return const SizedBox.shrink(); return const SizedBox.shrink();
@ -126,7 +131,10 @@ class HiddenGroupList extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<BoardBloc, BoardState>( return BlocBuilder<BoardBloc, BoardState>(
builder: (_, state) => ReorderableListView.builder( builder: (context, state) {
return state.maybeMap(
orElse: () => const SizedBox.shrink(),
ready: (state) => ReorderableListView.builder(
proxyDecorator: (child, index, animation) => Material( proxyDecorator: (child, index, animation) => Material(
color: Colors.transparent, color: Colors.transparent,
child: Stack( child: Stack(
@ -164,6 +172,8 @@ class HiddenGroupList extends StatelessWidget {
}, },
), ),
); );
},
);
} }
} }
@ -248,6 +258,9 @@ class HiddenGroupButtonContent extends StatelessWidget {
value: bloc, value: bloc,
child: BlocBuilder<BoardBloc, BoardState>( child: BlocBuilder<BoardBloc, BoardState>(
builder: (context, state) { builder: (context, state) {
return state.maybeMap(
orElse: () => const SizedBox.shrink(),
ready: (state) {
final group = state.hiddenGroups.firstWhereOrNull( final group = state.hiddenGroups.firstWhereOrNull(
(g) => g.groupId == groupId, (g) => g.groupId == groupId,
); );
@ -288,8 +301,9 @@ class HiddenGroupButtonContent extends StatelessWidget {
FlowySvgs.show_m, FlowySvgs.show_m,
color: Theme.of(context).hintColor, color: Theme.of(context).hintColor,
), ),
onPressed: () => context.read<BoardBloc>().add( onPressed: () =>
BoardEvent.toggleGroupVisibility( context.read<BoardBloc>().add(
BoardEvent.setGroupVisibility(
group, group,
true, true,
), ),
@ -301,6 +315,8 @@ class HiddenGroupButtonContent extends StatelessWidget {
), ),
); );
}, },
);
},
), ),
); );
}, },
@ -360,6 +376,9 @@ class HiddenGroupPopupItemList extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<BoardBloc, BoardState>( return BlocBuilder<BoardBloc, BoardState>(
builder: (context, state) { builder: (context, state) {
return state.maybeMap(
orElse: () => const SizedBox.shrink(),
ready: (state) {
final group = state.hiddenGroups.firstWhereOrNull( final group = state.hiddenGroups.firstWhereOrNull(
(g) => g.groupId == groupId, (g) => g.groupId == groupId,
); );
@ -389,7 +408,8 @@ class HiddenGroupPopupItemList extends StatelessWidget {
return HiddenGroupPopupItem( return HiddenGroupPopupItem(
cellContext: rowCache.loadCells(item).firstWhere( cellContext: rowCache.loadCells(item).firstWhere(
(cellContext) => cellContext.fieldId == primaryFieldId, (cellContext) =>
cellContext.fieldId == primaryFieldId,
), ),
rowController: rowController, rowController: rowController,
rowMeta: item, rowMeta: item,
@ -400,12 +420,9 @@ class HiddenGroupPopupItemList extends StatelessWidget {
FlowyOverlay.show( FlowyOverlay.show(
context: context, context: context,
builder: (_) { builder: (_) {
return BlocProvider.value( return RowDetailPage(
value: context.read<ViewBloc>(),
child: RowDetailPage(
databaseController: databaseController, databaseController: databaseController,
rowController: rowController, rowController: rowController,
),
); );
}, },
); );
@ -425,6 +442,8 @@ class HiddenGroupPopupItemList extends StatelessWidget {
); );
}, },
); );
},
);
} }
} }

View File

@ -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<BoardActionsCubit>()
.startEditingRow(focusScope.value.first);
},
const SingleActivator(LogicalKeyboardKey.keyN): () {
if (focusScope.value.length != 1) {
return;
}
context
.read<BoardActionsCubit>()
.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<BoardActionsCubit>()
.openCardWithRowId(focusScope.value.first.rowId);
}
void _shitEnterHandler(BuildContext context) {
if (focusScope.value.isEmpty) {
context
.read<BoardActionsCubit>()
.createRow(null, CreateBoardCardRelativePosition.after);
} else if (focusScope.value.length == 1) {
context.read<BoardActionsCubit>().createRow(
focusScope.value.first,
CreateBoardCardRelativePosition.after,
);
}
}
void _shiftCmdUpHandler(BuildContext context) {
if (focusScope.value.isEmpty) {
context
.read<BoardActionsCubit>()
.createRow(null, CreateBoardCardRelativePosition.before);
} else if (focusScope.value.length == 1) {
context.read<BoardActionsCubit>().createRow(
focusScope.value.first,
CreateBoardCardRelativePosition.before,
);
}
}
void _removeHandler(BuildContext context) {
if (focusScope.value.isEmpty) {
return;
}
context.read<BoardBloc>().add(BoardEvent.deleteCards(focusScope.value));
}
void _moveGroupToAdjacentGroup(BuildContext context, bool toPrevious) {
if (focusScope.value.length != 1) {
return;
}
context.read<BoardBloc>().add(
BoardEvent.moveGroupToAdjacentGroup(
focusScope.value.first,
toPrevious,
),
);
focusScope.clear();
}
}

View File

@ -72,7 +72,7 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
); );
}, },
deleteEvent: (String viewId, String rowId) async { deleteEvent: (String viewId, String rowId) async {
final result = await RowBackendService.deleteRow(viewId, rowId); final result = await RowBackendService.deleteRows(viewId, [rowId]);
result.fold( result.fold(
(_) => null, (_) => null,
(e) => Log.error('Failed to delete event: $e', e), (e) => Log.error('Failed to delete event: $e', e),

View File

@ -46,9 +46,9 @@ class CalendarEventEditorBloc
emit(state.copyWith(cells: cells)); emit(state.copyWith(cells: cells));
}, },
delete: () async { delete: () async {
final result = await RowBackendService.deleteRow( final result = await RowBackendService.deleteRows(
rowController.viewId, rowController.viewId,
rowController.rowId, [rowController.rowId],
); );
result.fold((l) => null, (err) => Log.error(err)); result.fold((l) => null, (err) => Log.error(err));
}, },

View File

@ -80,7 +80,7 @@ class _EventCardState extends State<EventCard> {
rowCache: rowCache, rowCache: rowCache,
isEditing: false, isEditing: false,
cellBuilder: cellBuilder, cellBuilder: cellBuilder,
openCard: (context) { onTap: (context) {
if (PlatformExtension.isMobile) { if (PlatformExtension.isMobile) {
context.push( context.push(
MobileRowDetailPage.routeName, MobileRowDetailPage.routeName,

View File

@ -51,7 +51,7 @@ class GridBloc extends Bloc<GridEvent, GridState> {
emit(state.copyWith(createdRow: null, openRowDetail: false)); emit(state.copyWith(createdRow: null, openRowDetail: false));
}, },
deleteRow: (rowInfo) async { deleteRow: (rowInfo) async {
await RowBackendService.deleteRow(rowInfo.viewId, rowInfo.rowId); await RowBackendService.deleteRows(viewId, [rowInfo.rowId]);
}, },
moveRow: (int from, int to) { moveRow: (int from, int to) {
final List<RowInfo> rows = [...state.rowInfos]; final List<RowInfo> rows = [...state.rowInfos];

View File

@ -102,7 +102,7 @@ enum RowAction {
RowBackendService.duplicateRow(viewId, rowId); RowBackendService.duplicateRow(viewId, rowId);
break; break;
case delete: case delete:
RowBackendService.deleteRow(viewId, rowId); RowBackendService.deleteRows(viewId, [rowId]);
break; break;
} }
} }

View File

@ -29,10 +29,11 @@ class RowCard extends StatefulWidget {
required this.isEditing, required this.isEditing,
required this.rowCache, required this.rowCache,
required this.cellBuilder, required this.cellBuilder,
required this.openCard, required this.onTap,
required this.onStartEditing, required this.onStartEditing,
required this.onEndEditing, required this.onEndEditing,
required this.styleConfiguration, required this.styleConfiguration,
this.onShiftTap,
this.groupingFieldId, this.groupingFieldId,
this.groupId, this.groupId,
}); });
@ -50,7 +51,9 @@ class RowCard extends StatefulWidget {
final CardCellBuilder cellBuilder; final CardCellBuilder cellBuilder;
/// Called when the user taps on the card. /// 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. /// Called when the user starts editing the card.
final VoidCallback onStartEditing; final VoidCallback onStartEditing;
@ -67,12 +70,10 @@ class RowCard extends StatefulWidget {
class _RowCardState extends State<RowCard> { class _RowCardState extends State<RowCard> {
final popoverController = PopoverController(); final popoverController = PopoverController();
late final CardBloc _cardBloc; late final CardBloc _cardBloc;
late final EditableRowNotifier rowNotifier;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
rowNotifier = EditableRowNotifier(isEditing: widget.isEditing);
_cardBloc = CardBloc( _cardBloc = CardBloc(
fieldController: widget.fieldController, fieldController: widget.fieldController,
viewId: widget.viewId, viewId: widget.viewId,
@ -81,22 +82,18 @@ class _RowCardState extends State<RowCard> {
rowMeta: widget.rowMeta, rowMeta: widget.rowMeta,
rowCache: widget.rowCache, rowCache: widget.rowCache,
)..add(const CardEvent.initial()); )..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 @override
void dispose() { void dispose() {
rowNotifier.dispose();
_cardBloc.close(); _cardBloc.close();
super.dispose(); super.dispose();
} }
@ -105,7 +102,14 @@ class _RowCardState extends State<RowCard> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider.value( return BlocProvider.value(
value: _cardBloc, value: _cardBloc,
child: BlocBuilder<CardBloc, CardState>( child: BlocConsumer<CardBloc, CardState>(
listenWhen: (previous, current) =>
previous.isEditing != current.isEditing,
listener: (context, state) {
if (!state.isEditing) {
widget.onEndEditing();
}
},
builder: (context, state) => builder: (context, state) =>
PlatformExtension.isMobile ? _mobile(state) : _desktop(state), PlatformExtension.isMobile ? _mobile(state) : _desktop(state),
), ),
@ -114,7 +118,7 @@ class _RowCardState extends State<RowCard> {
Widget _mobile(CardState state) { Widget _mobile(CardState state) {
return GestureDetector( return GestureDetector(
onTap: () => widget.openCard(context), onTap: () => widget.onTap(context),
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
child: MobileCardContent( child: MobileCardContent(
rowMeta: state.rowMeta, rowMeta: state.rowMeta,
@ -127,9 +131,9 @@ class _RowCardState extends State<RowCard> {
Widget _desktop(CardState state) { Widget _desktop(CardState state) {
final accessories = widget.styleConfiguration.showAccessory final accessories = widget.styleConfiguration.showAccessory
? <CardAccessory>[ ? const <CardAccessory>[
EditCardAccessory(rowNotifier: rowNotifier), EditCardAccessory(),
const MoreCardOptionsAccessory(), MoreCardOptionsAccessory(),
] ]
: null; : null;
return AppFlowyPopover( return AppFlowyPopover(
@ -148,10 +152,10 @@ class _RowCardState extends State<RowCard> {
buildAccessoryWhen: () => state.isEditing == false, buildAccessoryWhen: () => state.isEditing == false,
accessories: accessories ?? [], accessories: accessories ?? [],
openAccessory: _handleOpenAccessory, openAccessory: _handleOpenAccessory,
openCard: widget.openCard, onTap: widget.onTap,
onShiftTap: widget.onShiftTap,
child: _CardContent( child: _CardContent(
rowMeta: state.rowMeta, rowMeta: state.rowMeta,
rowNotifier: rowNotifier,
cellBuilder: widget.cellBuilder, cellBuilder: widget.cellBuilder,
styleConfiguration: widget.styleConfiguration, styleConfiguration: widget.styleConfiguration,
cells: state.cells, cells: state.cells,
@ -163,6 +167,7 @@ class _RowCardState extends State<RowCard> {
void _handleOpenAccessory(AccessoryType newAccessoryType) { void _handleOpenAccessory(AccessoryType newAccessoryType) {
switch (newAccessoryType) { switch (newAccessoryType) {
case AccessoryType.edit: case AccessoryType.edit:
widget.onStartEditing();
break; break;
case AccessoryType.more: case AccessoryType.more:
popoverController.show(); popoverController.show();
@ -174,14 +179,12 @@ class _RowCardState extends State<RowCard> {
class _CardContent extends StatelessWidget { class _CardContent extends StatelessWidget {
const _CardContent({ const _CardContent({
required this.rowMeta, required this.rowMeta,
required this.rowNotifier,
required this.cellBuilder, required this.cellBuilder,
required this.cells, required this.cells,
required this.styleConfiguration, required this.styleConfiguration,
}); });
final RowMetaPB rowMeta; final RowMetaPB rowMeta;
final EditableRowNotifier rowNotifier;
final CardCellBuilder cellBuilder; final CardCellBuilder cellBuilder;
final List<CellContext> cells; final List<CellContext> cells;
final RowCardStyleConfiguration styleConfiguration; final RowCardStyleConfiguration styleConfiguration;
@ -199,7 +202,7 @@ class _CardContent extends StatelessWidget {
? child ? child
: FlowyHover( : FlowyHover(
style: styleConfiguration.hoverStyle, style: styleConfiguration.hoverStyle,
buildWhenOnHover: () => !rowNotifier.isEditing.value, buildWhenOnHover: () => !context.read<CardBloc>().state.isEditing,
child: child, child: child,
); );
} }
@ -209,16 +212,16 @@ class _CardContent extends StatelessWidget {
RowMetaPB rowMeta, RowMetaPB rowMeta,
List<CellContext> cells, List<CellContext> cells,
) { ) {
// Remove all the cell listeners.
rowNotifier.unbind();
return cells.mapIndexed((int index, CellContext cellContext) { return cells.mapIndexed((int index, CellContext cellContext) {
EditableCardNotifier? cellNotifier; EditableCardNotifier? cellNotifier;
if (index == 0) { if (index == 0) {
cellNotifier = final bloc = context.read<CardBloc>();
EditableCardNotifier(isEditing: rowNotifier.isEditing.value); cellNotifier = EditableCardNotifier(isEditing: bloc.state.isEditing);
rowNotifier.bindCell(cellContext, cellNotifier); cellNotifier.isCellEditing.addListener(() {
final isEditing = cellNotifier!.isCellEditing.value;
bloc.add(CardEvent.setIsEditing(isEditing));
});
} }
return cellBuilder.build( 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 { class MoreCardOptionsAccessory extends StatelessWidget with CardAccessory {
const MoreCardOptionsAccessory({super.key}); const MoreCardOptionsAccessory({super.key});
@ -249,29 +270,6 @@ class MoreCardOptionsAccessory extends StatelessWidget with CardAccessory {
AccessoryType get type => AccessoryType.more; 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 { class RowCardStyleConfiguration {
const RowCardStyleConfiguration({ const RowCardStyleConfiguration({
required this.cellStyleMap, required this.cellStyleMap,

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'accessory.dart'; import 'accessory.dart';
@ -7,14 +8,16 @@ class RowCardContainer extends StatelessWidget {
const RowCardContainer({ const RowCardContainer({
super.key, super.key,
required this.child, required this.child,
required this.openCard, required this.onTap,
required this.openAccessory, required this.openAccessory,
required this.accessories, required this.accessories,
this.buildAccessoryWhen, this.buildAccessoryWhen,
this.onShiftTap,
}); });
final Widget child; final Widget child;
final void Function(BuildContext) openCard; final void Function(BuildContext) onTap;
final void Function(BuildContext)? onShiftTap;
final void Function(AccessoryType) openAccessory; final void Function(AccessoryType) openAccessory;
final List<CardAccessory> accessories; final List<CardAccessory> accessories;
final bool Function()? buildAccessoryWhen; final bool Function()? buildAccessoryWhen;
@ -41,7 +44,13 @@ class RowCardContainer extends StatelessWidget {
return GestureDetector( return GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: () => openCard(context), onTap: () {
if (HardwareKeyboard.instance.isShiftPressed) {
onShiftTap?.call(context);
} else {
onTap(context);
}
},
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 30), constraints: const BoxConstraints(minHeight: 30),
child: container, child: container,

View File

@ -1,4 +1,3 @@
import 'package:appflowy/plugins/database/application/cell/cell_controller.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
abstract class CardCell<T extends CardCellStyle> extends StatefulWidget { abstract class CardCell<T extends CardCellStyle> extends StatefulWidget {
@ -32,61 +31,6 @@ class EditableCardNotifier {
} }
} }
class EditableRowNotifier {
EditableRowNotifier({required bool isEditing})
: isEditing = ValueNotifier(isEditing);
final Map<CellContext, EditableCardNotifier> _cells = {};
final ValueNotifier<bool> 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 { abstract mixin class EditableCell {
// Each cell notifier will be bind to the [EditableRowNotifier], which enable // Each cell notifier will be bind to the [EditableRowNotifier], which enable
// the row notifier receive its cells event. For example: begin editing the // the row notifier receive its cells event. For example: begin editing the

View File

@ -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/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../editable_cell_builder.dart'; import '../editable_cell_builder.dart';
@ -108,18 +109,11 @@ class _TextCellState extends State<TextCardCell> {
return BlocProvider.value( return BlocProvider.value(
value: cellBloc, value: cellBloc,
child: BlocConsumer<TextCellBloc, TextCellState>( child: BlocConsumer<TextCellBloc, TextCellState>(
listenWhen: (previous, current) => listenWhen: (previous, current) => previous.content != current.content,
previous.content != current.content && !current.enableEdit,
listener: (context, state) { listener: (context, state) {
if (!state.enableEdit) {
_textEditingController.text = state.content; _textEditingController.text = state.content;
},
buildWhen: (previous, current) {
if (previous.content != current.content &&
_textEditingController.text == current.content) {
return false;
} }
return previous != current;
}, },
builder: (context, state) { builder: (context, state) {
final isTitle = cellBloc.cellController.fieldInfo.isPrimary; final isTitle = cellBloc.cellController.fieldInfo.isPrimary;
@ -196,12 +190,18 @@ class _TextCellState extends State<TextCardCell> {
widget.style.padding.add(const EdgeInsets.symmetric(vertical: 4.0)); widget.style.padding.add(const EdgeInsets.symmetric(vertical: 4.0));
return IgnorePointer( return IgnorePointer(
ignoring: !isEditing, ignoring: !isEditing,
child: CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.escape): () =>
focusNode.unfocus(),
},
child: TextField( child: TextField(
controller: _textEditingController, controller: _textEditingController,
focusNode: focusNode, focusNode: focusNode,
onChanged: (_) { onChanged: (_) {
if (_textEditingController.value.composing.isCollapsed) { if (_textEditingController.value.composing.isCollapsed) {
cellBloc.add(TextCellEvent.updateText(_textEditingController.text)); cellBloc
.add(TextCellEvent.updateText(_textEditingController.text));
} }
}, },
onEditingComplete: () => focusNode.unfocus(), onEditingComplete: () => focusNode.unfocus(),
@ -222,6 +222,8 @@ class _TextCellState extends State<TextCardCell> {
color: Theme.of(context).hintColor, color: Theme.of(context).hintColor,
), ),
), ),
onTapOutside: (_) {},
),
), ),
); );
} }

View File

@ -80,7 +80,9 @@ class _TextCellState extends GridEditableTextCell<EditableTextCell> {
value: cellBloc, value: cellBloc,
child: BlocListener<TextCellBloc, TextCellState>( child: BlocListener<TextCellBloc, TextCellState>(
listener: (context, state) { listener: (context, state) {
if (!focusNode.hasFocus) {
_textEditingController.text = state.content; _textEditingController.text = state.content;
}
}, },
child: Builder( child: Builder(
builder: (context) { builder: (context) {

View File

@ -53,7 +53,7 @@ class RowDetailPageDeleteButton extends StatelessWidget {
text: FlowyText.regular(LocaleKeys.grid_row_delete.tr()), text: FlowyText.regular(LocaleKeys.grid_row_delete.tr()),
leftIcon: const FlowySvg(FlowySvgs.trash_m), leftIcon: const FlowySvg(FlowySvgs.trash_m),
onTap: () { onTap: () {
RowBackendService.deleteRow(viewId, rowId); RowBackendService.deleteRows(viewId, [rowId]);
FlowyOverlay.pop(context); FlowyOverlay.pop(context);
}, },
), ),

View File

@ -288,7 +288,6 @@ class _TitleSkin extends IEditableTextCellSkin {
return TextField( return TextField(
controller: textEditingController, controller: textEditingController,
focusNode: focusNode, focusNode: focusNode,
maxLines: null,
autofocus: true, autofocus: true,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 28), style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 28),
decoration: InputDecoration( decoration: InputDecoration(

View File

@ -0,0 +1,87 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class ConditionalListenableBuilder<T> 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<T> 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<T> 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<ConditionalListenableBuilder> createState() =>
_ConditionalListenableBuilderState<T>();
}
class _ConditionalListenableBuilderState<T>
extends State<ConditionalListenableBuilder<T>> {
late T value;
@override
void initState() {
super.initState();
value = widget.valueListenable.value;
widget.valueListenable.addListener(_valueChanged);
}
@override
void didUpdateWidget(ConditionalListenableBuilder<T> 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);
}
}

View File

@ -44,8 +44,8 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." path: "."
ref: "404262fca4369bc35ff305316e4d59341a732f56" ref: "8a6434ae3d02624b614a010af80f775db11bf22e"
resolved-ref: "404262fca4369bc35ff305316e4d59341a732f56" resolved-ref: "8a6434ae3d02624b614a010af80f775db11bf22e"
url: "https://github.com/AppFlowy-IO/appflowy-board.git" url: "https://github.com/AppFlowy-IO/appflowy-board.git"
source: git source: git
version: "0.1.2" version: "0.1.2"

View File

@ -44,7 +44,7 @@ dependencies:
# path: ../../../appflowy-board # path: ../../../appflowy-board
git: git:
url: https://github.com/AppFlowy-IO/appflowy-board.git url: https://github.com/AppFlowy-IO/appflowy-board.git
ref: 404262fca4369bc35ff305316e4d59341a732f56 ref: 8a6434ae3d02624b614a010af80f775db11bf22e
appflowy_result: appflowy_result:
path: packages/appflowy_result path: packages/appflowy_result
appflowy_editor_plugins: ^0.0.2 appflowy_editor_plugins: ^0.0.2

View File

@ -1,5 +1,6 @@
import 'package:appflowy/plugins/database/application/database_controller.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/board/application/board_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'util.dart'; import 'util.dart';
@ -15,26 +16,42 @@ void main() {
final context = await boardTest.createTestBoard(); final context = await boardTest.createTestBoard();
final databaseController = DatabaseController(view: context.gridView); final databaseController = DatabaseController(view: context.gridView);
final boardBloc = BoardBloc( final boardBloc = BoardBloc(
view: context.gridView,
databaseController: databaseController, databaseController: databaseController,
)..add(const BoardEvent.initial()); )..add(const BoardEvent.initial());
await boardResponseFuture(); await boardResponseFuture();
final groupId = boardBloc.state.groupIds.last; List<String> groupIds = boardBloc.state.maybeMap(
orElse: () => const [],
ready: (value) => value.groupIds,
);
String lastGroupId = groupIds.last;
// the group at index 3 is the 'No status' group; // 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( assert(
boardBloc.state.groupIds.length == 4, groupIds.length == 4,
'but receive ${boardBloc.state.groupIds.length}', 'but receive ${groupIds.length}',
); );
boardBloc.add(BoardEvent.createBottomRow(boardBloc.state.groupIds[3])); boardBloc.add(
BoardEvent.createRow(
groupIds[3],
OrderObjectPositionTypePB.End,
null,
null,
),
);
await boardResponseFuture(); await boardResponseFuture();
groupIds = boardBloc.state.maybeMap(
orElse: () => [],
ready: (value) => value.groupIds,
);
lastGroupId = groupIds.last;
assert( assert(
boardBloc.groupControllers[groupId]!.group.rows.length == 1, boardBloc.groupControllers[lastGroupId]!.group.rows.length == 1,
'but receive ${boardBloc.groupControllers[groupId]!.group.rows.length}', 'but receive ${boardBloc.groupControllers[lastGroupId]!.group.rows.length}',
); );
}); });
} }

View File

@ -15,7 +15,6 @@ void main() {
test('create build-in kanban board test', () async { test('create build-in kanban board test', () async {
final context = await boardTest.createTestBoard(); final context = await boardTest.createTestBoard();
final boardBloc = BoardBloc( final boardBloc = BoardBloc(
view: context.gridView,
databaseController: DatabaseController(view: context.gridView), databaseController: DatabaseController(view: context.gridView),
)..add(const BoardEvent.initial()); )..add(const BoardEvent.initial());
await boardResponseFuture(); await boardResponseFuture();
@ -27,7 +26,6 @@ void main() {
test('edit kanban board field name test', () async { test('edit kanban board field name test', () async {
final context = await boardTest.createTestBoard(); final context = await boardTest.createTestBoard();
final boardBloc = BoardBloc( final boardBloc = BoardBloc(
view: context.gridView,
databaseController: DatabaseController(view: context.gridView), databaseController: DatabaseController(view: context.gridView),
)..add(const BoardEvent.initial()); )..add(const BoardEvent.initial());
await boardResponseFuture(); await boardResponseFuture();
@ -59,7 +57,6 @@ void main() {
test('create a new field in kanban board test', () async { test('create a new field in kanban board test', () async {
final context = await boardTest.createTestBoard(); final context = await boardTest.createTestBoard();
final boardBloc = BoardBloc( final boardBloc = BoardBloc(
view: context.gridView,
databaseController: DatabaseController(view: context.gridView), databaseController: DatabaseController(view: context.gridView),
)..add(const BoardEvent.initial()); )..add(const BoardEvent.initial());
await boardResponseFuture(); await boardResponseFuture();

View File

@ -17,7 +17,6 @@ void main() {
test('group by checkbox field test', () async { test('group by checkbox field test', () async {
final context = await boardTest.createTestBoard(); final context = await boardTest.createTestBoard();
final boardBloc = BoardBloc( final boardBloc = BoardBloc(
view: context.gridView,
databaseController: DatabaseController(view: context.gridView), databaseController: DatabaseController(view: context.gridView),
)..add(const BoardEvent.initial()); )..add(const BoardEvent.initial());
await boardResponseFuture(); await boardResponseFuture();

View File

@ -41,7 +41,6 @@ void main() {
// assert only have the 'No status' group // assert only have the 'No status' group
final boardBloc = BoardBloc( final boardBloc = BoardBloc(
view: context.gridView,
databaseController: DatabaseController(view: context.gridView), databaseController: DatabaseController(view: context.gridView),
)..add(const BoardEvent.initial()); )..add(const BoardEvent.initial());
await boardResponseFuture(); await boardResponseFuture();
@ -91,7 +90,6 @@ void main() {
// assert there are only three group // assert there are only three group
final boardBloc = BoardBloc( final boardBloc = BoardBloc(
view: context.gridView,
databaseController: DatabaseController(view: context.gridView), databaseController: DatabaseController(view: context.gridView),
)..add(const BoardEvent.initial()); )..add(const BoardEvent.initial());
await boardResponseFuture(); await boardResponseFuture();

View File

@ -38,7 +38,6 @@ void main() {
blocTest<BoardBloc, BoardState>( blocTest<BoardBloc, BoardState>(
'assert the number of groups is 1', 'assert the number of groups is 1',
build: () => BoardBloc( build: () => BoardBloc(
view: context.gridView,
databaseController: DatabaseController(view: context.gridView), databaseController: DatabaseController(view: context.gridView),
)..add( )..add(
const BoardEvent.initial(), const BoardEvent.initial(),

View File

@ -236,11 +236,10 @@ impl EventIntegrationTest {
pub async fn delete_row(&self, view_id: &str, row_id: &str) -> Option<FlowyError> { pub async fn delete_row(&self, view_id: &str, row_id: &str) -> Option<FlowyError> {
EventBuilder::new(self.clone()) EventBuilder::new(self.clone())
.event(DatabaseEvent::DeleteRow) .event(DatabaseEvent::DeleteRows)
.payload(RowIdPB { .payload(RepeatedRowIdPB {
view_id: view_id.to_string(), view_id: view_id.to_string(),
row_id: row_id.to_string(), row_ids: vec![row_id.to_string()],
group_id: None,
}) })
.async_send() .async_send()
.await .await
@ -523,7 +522,7 @@ impl EventIntegrationTest {
) -> Vec<RelatedRowDataPB> { ) -> Vec<RelatedRowDataPB> {
EventBuilder::new(self.clone()) EventBuilder::new(self.clone())
.event(DatabaseEvent::GetRelatedRowDatas) .event(DatabaseEvent::GetRelatedRowDatas)
.payload(RepeatedRowIdPB { .payload(GetRelatedRowDataPB {
database_id, database_id,
row_ids, row_ids,
}) })

View File

@ -314,9 +314,12 @@ async fn delete_row_event_with_invalid_row_id_test() {
.create_grid(&current_workspace.id, "my grid view".to_owned(), vec![]) .create_grid(&current_workspace.id, "my grid view".to_owned(), vec![])
.await; .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; 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] #[tokio::test]

View File

@ -338,6 +338,15 @@ impl TryInto<RowIdParams> for RowIdPB {
} }
} }
#[derive(Debug, Default, Clone, ProtoBuf)]
pub struct RepeatedRowIdPB {
#[pb(index = 1)]
pub view_id: String,
#[pb(index = 2)]
pub row_ids: Vec<String>,
}
#[derive(ProtoBuf, Default, Validate)] #[derive(ProtoBuf, Default, Validate)]
pub struct CreateRowPayloadPB { pub struct CreateRowPayloadPB {
#[pb(index = 1)] #[pb(index = 1)]

View File

@ -78,7 +78,7 @@ pub struct RepeatedRelatedRowDataPB {
} }
#[derive(Debug, Default, Clone, ProtoBuf)] #[derive(Debug, Default, Clone, ProtoBuf)]
pub struct RepeatedRowIdPB { pub struct GetRelatedRowDataPB {
#[pb(index = 1)] #[pb(index = 1)]
pub database_id: String, pub database_id: String,

View File

@ -385,14 +385,19 @@ pub(crate) async fn update_row_meta_handler(
} }
#[tracing::instrument(level = "debug", skip(data, manager), err)] #[tracing::instrument(level = "debug", skip(data, manager), err)]
pub(crate) async fn delete_row_handler( pub(crate) async fn delete_rows_handler(
data: AFPluginData<RowIdPB>, data: AFPluginData<RepeatedRowIdPB>,
manager: AFPluginState<Weak<DatabaseManager>>, manager: AFPluginState<Weak<DatabaseManager>>,
) -> Result<(), FlowyError> { ) -> Result<(), FlowyError> {
let manager = upgrade_manager(manager)?; 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(&params.view_id).await?; let database_editor = manager.get_database_with_view_id(&params.view_id).await?;
database_editor.delete_row(&params.row_id).await; let row_ids = params
.row_ids
.into_iter()
.map(RowId::from)
.collect::<Vec<_>>();
database_editor.delete_rows(&row_ids).await;
Ok(()) Ok(())
} }
@ -1062,11 +1067,11 @@ pub(crate) async fn update_relation_cell_handler(
} }
pub(crate) async fn get_related_row_datas_handler( pub(crate) async fn get_related_row_datas_handler(
data: AFPluginData<RepeatedRowIdPB>, data: AFPluginData<GetRelatedRowDataPB>,
manager: AFPluginState<Weak<DatabaseManager>>, manager: AFPluginState<Weak<DatabaseManager>>,
) -> DataResult<RepeatedRelatedRowDataPB, FlowyError> { ) -> DataResult<RepeatedRelatedRowDataPB, FlowyError> {
let manager = upgrade_manager(manager)?; let manager = upgrade_manager(manager)?;
let params: RepeatedRowIdPB = data.into_inner(); let params: GetRelatedRowDataPB = data.into_inner();
let database_editor = manager.get_database(&params.database_id).await?; let database_editor = manager.get_database(&params.database_id).await?;
let row_datas = database_editor let row_datas = database_editor
.get_related_rows(Some(&params.row_ids)) .get_related_rows(Some(&params.row_ids))

View File

@ -37,7 +37,7 @@ pub fn init(database_manager: Weak<DatabaseManager>) -> AFPlugin {
.event(DatabaseEvent::GetRow, get_row_handler) .event(DatabaseEvent::GetRow, get_row_handler)
.event(DatabaseEvent::GetRowMeta, get_row_meta_handler) .event(DatabaseEvent::GetRowMeta, get_row_meta_handler)
.event(DatabaseEvent::UpdateRowMeta, update_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::DuplicateRow, duplicate_row_handler)
.event(DatabaseEvent::MoveRow, move_row_handler) .event(DatabaseEvent::MoveRow, move_row_handler)
// Cell // Cell
@ -223,8 +223,8 @@ pub enum DatabaseEvent {
#[event(input = "RowIdPB", output = "OptionalRowPB")] #[event(input = "RowIdPB", output = "OptionalRowPB")]
GetRow = 51, GetRow = 51,
#[event(input = "RowIdPB")] #[event(input = "RepeatedRowIdPB")]
DeleteRow = 52, DeleteRows = 52,
#[event(input = "RowIdPB")] #[event(input = "RowIdPB")]
DuplicateRow = 53, DuplicateRow = 53,
@ -364,7 +364,7 @@ pub enum DatabaseEvent {
UpdateRelationCell = 171, UpdateRelationCell = 171,
/// Get the names of the linked rows in a relation cell. /// Get the names of the linked rows in a relation cell.
#[event(input = "RepeatedRowIdPB", output = "RepeatedRelatedRowDataPB")] #[event(input = "GetRelatedRowDataPB", output = "RepeatedRelatedRowDataPB")]
GetRelatedRowDatas = 172, GetRelatedRowDatas = 172,
/// Get the names of all the rows in a related database. /// Get the names of all the rows in a related database.

View File

@ -654,9 +654,10 @@ impl DatabaseEditor {
} }
} }
pub async fn delete_row(&self, row_id: &RowId) { pub async fn delete_rows(&self, row_ids: &[RowId]) {
let row = self.database.lock().remove_row(row_id); let rows = self.database.lock().remove_rows(row_ids);
if let Some(row) = row {
for row in rows {
tracing::trace!("Did delete row:{:?}", row); tracing::trace!("Did delete row:{:?}", row);
for view in self.database_views.editors().await { for view in self.database_views.editors().await {
view.v_did_delete_row(&row).await; view.v_did_delete_row(&row).await;

View File

@ -148,8 +148,8 @@ impl DatabaseGroupTest {
row_index, row_index,
} => { } => {
let row = self.row_at_index(group_index, row_index).await; let row = self.row_at_index(group_index, row_index).await;
let row_id = RowId::from(row.id); let row_ids = vec![RowId::from(row.id)];
self.editor.delete_row(&row_id).await; self.editor.delete_rows(&row_ids).await;
}, },
GroupScript::UpdateGroupedCell { GroupScript::UpdateGroupedCell {
from_group_index, from_group_index,