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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
export 'mobile_board_screen.dart';
export 'mobile_board_content.dart';
export 'mobile_board_page.dart';
export 'widgets/mobile_hidden_groups_column.dart';
export 'widgets/mobile_board_trailing.dart';

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/widgets/group_card_header.dart';
import 'package:appflowy/mobile/presentation/database/card/card.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/board/application/board_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/header/field_type_extension.dart';
import 'package:appflowy/plugins/database/widgets/card/card.dart';
import 'package:appflowy/plugins/database/widgets/cell/card_cell_builder.dart';
import 'package:appflowy/plugins/database/widgets/cell/card_cell_style_maps/mobile_board_card_cell_style.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_board/appflowy_board.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@ -16,25 +20,100 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
class MobileBoardContent extends StatefulWidget {
const MobileBoardContent({
class MobileBoardPage extends StatefulWidget {
const MobileBoardPage({
super.key,
required this.view,
required this.databaseController,
this.onEditStateChanged,
});
final ViewPB view;
final DatabaseController databaseController;
/// Called when edit state changed
final VoidCallback? onEditStateChanged;
@override
State<MobileBoardContent> createState() => _MobileBoardContentState();
State<MobileBoardPage> createState() => _MobileBoardPageState();
}
class _MobileBoardContentState extends State<MobileBoardContent> {
late final ScrollController scrollController;
late final AppFlowyBoardScrollController scrollManager;
class _MobileBoardPageState extends State<MobileBoardPage> {
late final ValueNotifier<DidCreateRowResult?> _didCreateRow;
@override
void initState() {
super.initState();
_didCreateRow = ValueNotifier(null)..addListener(_handleDidCreateRow);
}
@override
void dispose() {
_didCreateRow
..removeListener(_handleDidCreateRow)
..dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider<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
void initState() {
super.initState();
// mobile may not need this
// scroll to bottom when add a new card
scrollManager = AppFlowyBoardScrollController();
scrollController = ScrollController();
}
@ -57,29 +136,22 @@ class _MobileBoardContentState extends State<MobileBoardContent> {
cardMargin: const EdgeInsets.all(4),
);
return BlocListener<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>(
return BlocBuilder<BoardBloc, BoardState>(
builder: (context, state) {
final showCreateGroupButton =
context.read<BoardBloc>().groupingFieldType.canCreateNewGroup;
return state.maybeMap(
orElse: () => const SizedBox.shrink(),
ready: (state) {
final showCreateGroupButton = context
.read<BoardBloc>()
.groupingFieldType
?.canCreateNewGroup ??
false;
final showHiddenGroups = state.hiddenGroups.isNotEmpty;
return AppFlowyBoard(
boardScrollController: scrollManager,
scrollController: scrollController,
controller: context.read<BoardBloc>().boardController,
groupConstraints: BoxConstraints.tightFor(width: screenWidth * 0.7),
groupConstraints:
BoxConstraints.tightFor(width: screenWidth * 0.7),
config: config,
leading: showHiddenGroups
? MobileHiddenGroupsColumn(
@ -104,7 +176,8 @@ class _MobileBoardContentState extends State<MobileBoardContent> {
),
);
},
),
);
},
);
}
@ -129,9 +202,14 @@ class _MobileBoardContentState extends State<MobileBoardContent> {
color: style.colorScheme.onSurface,
),
),
onPressed: () => context
.read<BoardBloc>()
.add(BoardEvent.createBottomRow(columnData.id)),
onPressed: () => context.read<BoardBloc>().add(
BoardEvent.createRow(
columnData.id,
OrderObjectPositionTypePB.End,
null,
null,
),
),
),
);
}
@ -146,16 +224,9 @@ class _MobileBoardContentState extends State<MobileBoardContent> {
final groupItem = afGroupItem as GroupItem;
final groupData = afGroupData.customData as GroupData;
final rowMeta = groupItem.row;
final rowCache = boardBloc.getRowCache();
/// Return placeholder widget if the rowCache is null.
if (rowCache == null) return SizedBox.shrink(key: ObjectKey(groupItem));
final viewId = boardBloc.viewId;
final cellBuilder =
CardCellBuilder(databaseController: boardBloc.databaseController);
final isEditing = boardBloc.state.isEditingRow &&
boardBloc.state.editingRow?.row.id == groupItem.row.id;
final groupItemId = groupItem.row.id + groupData.group.groupId;
@ -166,12 +237,12 @@ class _MobileBoardContentState extends State<MobileBoardContent> {
child: RowCard(
fieldController: boardBloc.fieldController,
rowMeta: rowMeta,
viewId: viewId,
rowCache: rowCache,
viewId: boardBloc.viewId,
rowCache: boardBloc.rowCache,
groupingFieldId: groupItem.fieldInfo.id,
isEditing: isEditing,
isEditing: false,
cellBuilder: cellBuilder,
openCard: (context) {
onTap: (context) {
context.push(
MobileRowDetailPage.routeName,
extra: {
@ -181,10 +252,8 @@ class _MobileBoardContentState extends State<MobileBoardContent> {
},
);
},
onStartEditing: () => boardBloc
.add(BoardEvent.startEditingRow(groupData.group, groupItem.row)),
onEndEditing: () =>
boardBloc.add(BoardEvent.endEditingRow(groupItem.row.id)),
onStartEditing: () {},
onEndEditing: () {},
styleConfiguration: RowCardStyleConfiguration(
cellStyleMap: mobileBoardCardCellStyleMap(context),
showAccessory: false,

View File

@ -1,16 +1,14 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart';
import 'package:appflowy/plugins/database/board/application/board_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/header/field_type_extension.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/group.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_board/appflowy_board.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
@ -73,8 +71,12 @@ class _GroupCardHeaderState extends State<GroupCardHeader> {
);
}
if (state.isEditingHeader &&
state.editingHeaderId == widget.groupData.id) {
final isEditing = state.maybeMap(
ready: (value) => value.editingHeaderId == widget.groupData.id,
orElse: () => false,
);
if (isEditing) {
title = TextField(
controller: _controller,
autofocus: true,
@ -135,7 +137,7 @@ class _GroupCardHeaderState extends State<GroupCardHeader> {
icon: FlowySvgs.hide_s,
onTap: () {
context.read<BoardBloc>().add(
BoardEvent.toggleGroupVisibility(
BoardEvent.setGroupVisibility(
widget.groupData.customData.group
as GroupPB,
false,
@ -154,9 +156,16 @@ class _GroupCardHeaderState extends State<GroupCardHeader> {
color: Theme.of(context).colorScheme.onSurface,
),
splashRadius: 5,
onPressed: () => context.read<BoardBloc>().add(
BoardEvent.createHeaderRow(widget.groupData.id),
onPressed: () {
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) {
final databaseController = context.read<BoardBloc>().databaseController;
return BlocSelector<BoardBloc, BoardState, BoardLayoutSettingPB?>(
selector: (state) => state.layoutSettings,
selector: (state) => state.maybeMap(
orElse: () => null,
ready: (value) => value.layoutSettings,
),
builder: (context, layoutSettings) {
if (layoutSettings == null) {
return const SizedBox.shrink();
@ -105,7 +108,11 @@ class MobileHiddenGroupList extends StatelessWidget {
@override
Widget build(BuildContext context) {
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,
itemBuilder: (_, index) => MobileHiddenGroup(
key: ValueKey(state.hiddenGroups[index].groupId),
@ -127,7 +134,10 @@ class MobileHiddenGroupList extends StatelessWidget {
.read<BoardBloc>()
.add(BoardEvent.reorderGroup(fromGroupId, toGroupId));
},
),
);
},
);
},
);
}
}
@ -148,15 +158,6 @@ class MobileHiddenGroup extends StatelessWidget {
final primaryField = databaseController.fieldController.fieldInfos
.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(
(item) {
final cellContext =
@ -171,8 +172,7 @@ class MobileHiddenGroup extends StatelessWidget {
visualDensity: VisualDensity.compact,
),
child: CardCellBuilder(
databaseController:
context.read<BoardBloc>().databaseController,
databaseController: context.read<BoardBloc>().databaseController,
).build(
cellContext: cellContext,
styleMap: {FieldType.RichText: _titleCellStyle(context)},
@ -221,20 +221,15 @@ class MobileHiddenGroup extends StatelessWidget {
),
actionButtonTitle: LocaleKeys.button_yes.tr(),
actionButtonColor: Theme.of(context).colorScheme.primary,
onActionButtonPressed: () => context.read<BoardBloc>().add(
BoardEvent.toggleGroupVisibility(
group,
true,
),
),
onActionButtonPressed: () => context
.read<BoardBloc>()
.add(BoardEvent.setGroupVisibility(group, true)),
),
),
],
),
children: cells,
);
},
);
}
TextCardCellStyle _titleCellStyle(BuildContext context) {

View File

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

View File

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

View File

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

View File

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

View File

@ -96,15 +96,15 @@ class RowBackendService {
return DatabaseEventUpdateRowMeta(payload).send();
}
static Future<FlowyResult<void, FlowyError>> deleteRow(
static Future<FlowyResult<void, FlowyError>> deleteRows(
String viewId,
RowId rowId,
List<RowId> rowIds,
) {
final payload = RowIdPB.create()
final payload = RepeatedRowIdPB.create()
..viewId = viewId
..rowId = rowId;
..rowIds.addAll(rowIds);
return DatabaseEventDeleteRow(payload).send();
return DatabaseEventDeleteRows(payload).send();
}
static Future<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/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_board/appflowy_board.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:appflowy_result/appflowy_result.dart';
import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@ -27,11 +28,31 @@ part 'board_bloc.freezed.dart';
class BoardBloc extends Bloc<BoardEvent, BoardState> {
BoardBloc({
required ViewPB view,
required this.databaseController,
}) : super(BoardState.initial(view.id)) {
this.didCreateRow,
AppFlowyBoardController? boardController,
}) : super(const BoardState.loading()) {
groupBackendSvc = GroupBackendService(viewId);
boardController = AppFlowyBoardController(
_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) =>
databaseController.moveGroup(
fromGroupId: fromGroupId,
@ -50,7 +71,8 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
}
},
onMoveGroupItemToGroup: (fromGroupId, fromIndex, toGroupId, toIndex) {
final fromRow = groupControllers[fromGroupId]?.rowAtIndex(fromIndex);
final fromRow =
groupControllers[fromGroupId]?.rowAtIndex(fromIndex);
final toRow = groupControllers[toGroupId]?.rowAtIndex(toIndex);
if (fromRow != null) {
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() {
on<BoardEvent>(
(event, emit) async {
await event.when(
initial: () async {
emit(BoardState.initial(viewId));
_startListening();
await _openGrid(emit);
await _openDatabase(emit);
},
createHeaderRow: (groupId) async {
final rowId = groupControllers[groupId]?.firstRow()?.id;
final position = rowId == null
? OrderObjectPositionTypePB.Start
: OrderObjectPositionTypePB.Before;
createRow: (groupId, position, title, targetRowId) async {
final primaryField = databaseController.fieldController.fieldInfos
.firstWhereOrNull((element) => element.isPrimary)!;
final void Function(RowDataBuilder)? cellBuilder = title == null
? null
: (builder) => builder.insertText(primaryField, title);
final result = await RowBackendService.createRow(
viewId: databaseController.viewId,
groupId: groupId,
position: position,
targetRowId: rowId,
targetRowId: targetRowId,
withCells: cellBuilder,
);
final startEditing = position != OrderObjectPositionTypePB.End;
final action = PlatformExtension.isMobile
? DidCreateRowAction.openAsPage
: startEditing
? DidCreateRowAction.startEditing
: DidCreateRowAction.none;
result.fold(
(rowMeta) => emit(state.copyWith(recentAddedRowMeta: rowMeta)),
(err) => Log.error(err),
(rowMeta) {
state.maybeMap(
ready: (value) {
didCreateRow?.value = DidCreateRowResult(
action: action,
rowMeta: rowMeta,
groupId: groupId,
);
},
createBottomRow: (groupId) async {
final rowId = groupControllers[groupId]?.lastRow()?.id;
final position = rowId == null
? OrderObjectPositionTypePB.End
: OrderObjectPositionTypePB.After;
final result = await RowBackendService.createRow(
viewId: databaseController.viewId,
groupId: groupId,
position: position,
targetRowId: rowId,
orElse: () {},
);
result.fold(
(rowMeta) => emit(state.copyWith(recentAddedRowMeta: rowMeta)),
},
(err) => Log.error(err),
);
},
@ -127,47 +141,41 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
final result = await groupBackendSvc.deleteGroup(groupId: groupId);
result.fold((_) {}, (err) => Log.error(err));
},
didCreateRow: (group, row, int? index) {
emit(
state.copyWith(
isEditingRow: true,
editingRow: BoardEditingRow(
group: group,
row: row,
index: index,
),
),
);
_groupItemStartEditing(group, row, true);
},
didReceiveGridUpdate: (DatabasePB grid) {
emit(state.copyWith(grid: grid));
},
didReceiveError: (FlowyError error) {
emit(state.copyWith(noneOrError: error));
didReceiveError: (error) {
emit(BoardState.error(error: error));
},
didReceiveGroups: (List<GroupPB> groups) {
final hiddenGroups = _filterHiddenGroups(hideUngrouped, groups);
state.maybeMap(
ready: (state) {
emit(
state.copyWith(
hiddenGroups: hiddenGroups,
hiddenGroups: _filterHiddenGroups(hideUngrouped, groups),
groupIds: groups.map((group) => group.groupId).toList(),
),
);
},
orElse: () {},
);
},
didUpdateLayoutSettings: (layoutSettings) {
final hiddenGroups = _filterHiddenGroups(hideUngrouped, groupList);
state.maybeMap(
ready: (state) {
emit(
state.copyWith(
layoutSettings: layoutSettings,
hiddenGroups: hiddenGroups,
hiddenGroups: _filterHiddenGroups(hideUngrouped, groupList),
),
);
},
toggleGroupVisibility: (GroupPB group, bool isVisible) async {
await _toggleGroupVisibility(group, isVisible);
orElse: () {},
);
},
setGroupVisibility: (GroupPB group, bool isVisible) async {
await _setGroupVisibility(group, isVisible);
},
toggleHiddenSectionVisibility: (isVisible) async {
await state.maybeMap(
ready: (state) async {
final newLayoutSettings = state.layoutSettings!;
newLayoutSettings.freeze();
@ -179,37 +187,16 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
boardLayoutSetting: newLayoutSetting,
);
},
orElse: () {},
);
},
reorderGroup: (fromGroupId, toGroupId) async {
_reorderGroup(fromGroupId, toGroupId, emit);
},
startEditingRow: (group, row) {
emit(
state.copyWith(
isEditingRow: true,
editingRow: BoardEditingRow(
group: group,
row: row,
index: null,
),
),
);
_groupItemStartEditing(group, row, true);
},
endEditingRow: (rowId) {
if (state.editingRow != null && state.isEditingRow) {
assert(state.editingRow!.row.id == rowId);
_groupItemStartEditing(
state.editingRow!.group,
state.editingRow!.row,
false,
);
emit(state.copyWith(isEditingRow: false, editingRow: null));
}
},
startEditingHeader: (String groupId) {
emit(
state.copyWith(isEditingHeader: true, editingHeaderId: groupId),
state.maybeMap(
ready: (state) => emit(state.copyWith(editingHeaderId: groupId)),
orElse: () {},
);
},
endEditingHeader: (String groupId, String? groupName) async {
@ -218,24 +205,59 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
groupId: groupId,
name: groupName,
);
emit(state.copyWith(isEditingHeader: false));
state.maybeMap(
ready: (state) => emit(state.copyWith(editingHeaderId: null)),
orElse: () {},
);
},
deleteCards: (groupedRowIds) async {
final rowIds = groupedRowIds.map((e) => e.rowId).toList();
await RowBackendService.deleteRows(viewId, rowIds);
},
moveGroupToAdjacentGroup: (groupedRowId, toPrevious) async {
final fromRow =
databaseController.rowCache.getRow(groupedRowId.rowId)?.rowMeta;
final currentGroupIndex =
boardController.groupIds.indexOf(groupedRowId.groupId);
final toGroupIndex =
toPrevious ? currentGroupIndex - 1 : currentGroupIndex + 1;
if (fromRow != null &&
toGroupIndex > -1 &&
toGroupIndex < boardController.groupIds.length) {
final toGroupId = boardController.groupDatas[toGroupIndex].id;
final result = await databaseController.moveGroupRow(
fromRow: fromRow,
fromGroupId: groupedRowId.groupId,
toGroupId: toGroupId,
);
result.fold(
(s) {
final previousState = state;
emit(
BoardState.setFocus(
groupedRowIds: [
GroupedRowId(
groupId: toGroupId,
rowId: groupedRowId.rowId,
),
],
),
);
emit(previousState);
},
(f) {},
);
}
},
);
},
);
}
void _groupItemStartEditing(GroupPB group, RowMetaPB row, bool isEdit) {
final fieldInfo = fieldController.getField(group.fieldId);
if (fieldInfo == null) {
return Log.warn("fieldInfo should not be null");
}
boardController.enableGroupDragging(!isEdit);
}
Future<void> _toggleGroupVisibility(GroupPB group, bool isVisible) async {
Future<void> _setGroupVisibility(GroupPB group, bool isVisible) async {
if (group.isDefault) {
await state.maybeMap(
ready: (state) async {
final newLayoutSettings = state.layoutSettings!;
newLayoutSettings.freeze();
@ -243,17 +265,20 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
(message) => message.hideUngroupedColumn = !isVisible,
);
return databaseController.updateLayoutSetting(
await databaseController.updateLayoutSetting(
boardLayoutSetting: newLayoutSetting,
);
}
},
orElse: () {},
);
} else {
await groupBackendSvc.updateGroup(
fieldId: groupControllers.values.first.group.fieldId,
groupId: group.groupId,
visible: isVisible,
);
}
}
void _reorderGroup(
String fromGroupId,
@ -277,6 +302,7 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
for (final controller in groupControllers.values) {
await controller.dispose();
}
boardController.dispose();
return super.close();
}
@ -284,11 +310,13 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
databaseController.databaseLayoutSetting?.board.hideUngroupedColumn ??
false;
FieldType get groupingFieldType {
final fieldInfo =
databaseController.fieldController.getField(groupList.first.fieldId)!;
return fieldInfo.fieldType;
FieldType? get groupingFieldType {
if (groupList.isEmpty) {
return null;
}
return databaseController.fieldController
.getField(groupList.first.fieldId)
?.fieldType;
}
void initializeGroups(List<GroupPB> groups) {
@ -321,16 +349,9 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
}
}
RowCache? getRowCache() => databaseController.rowCache;
RowCache get rowCache => databaseController.rowCache;
void _startListening() {
final onDatabaseChanged = DatabaseCallbacks(
onDatabaseChanged: (database) {
if (!isClosed) {
add(BoardEvent.didReceiveGridUpdate(database));
}
},
);
final onLayoutSettingsChanged = DatabaseLayoutSettingCallbacks(
onLayoutSettingsChanged: (layoutSettings) {
if (isClosed) {
@ -433,7 +454,6 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
);
databaseController.addListener(
onDatabaseChanged: onDatabaseChanged,
onLayoutSettingsChanged: onLayoutSettingsChanged,
onGroupChanged: onGroupChanged,
);
@ -451,22 +471,10 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
return <AppFlowyGroupItem>[...items];
}
Future<void> _openGrid(Emitter<BoardState> emit) async {
final result = await databaseController.open();
result.fold(
(grid) {
databaseController.setIsLoading(false);
emit(
state.copyWith(
loadingState: LoadingState.finish(FlowyResult.success(null)),
),
);
},
(err) => emit(
state.copyWith(
loadingState: LoadingState.finish(FlowyResult.failure(err)),
),
),
Future<void> _openDatabase(Emitter<BoardState> emit) {
return databaseController.open().fold(
(datbasePB) => databaseController.setIsLoading(false),
(err) => emit(BoardState.error(error: err)),
);
}
@ -474,8 +482,7 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
final delegate = GroupControllerDelegateImpl(
controller: boardController,
fieldController: fieldController,
onNewColumnItem: (groupId, row, index) =>
add(BoardEvent.didCreateRow(group, row, index)),
onNewColumnItem: (groupId, row, index) {},
);
final controller = GroupController(
@ -579,71 +586,77 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
@freezed
class BoardEvent with _$BoardEvent {
const factory BoardEvent.initial() = _InitialBoard;
const factory BoardEvent.createBottomRow(String groupId) = _CreateBottomRow;
const factory BoardEvent.createHeaderRow(String groupId) = _CreateHeaderRow;
const factory BoardEvent.createRow(
String groupId,
OrderObjectPositionTypePB position,
String? title,
String? targetRowId,
) = _CreateRow;
const factory BoardEvent.createGroup(String name) = _CreateGroup;
const factory BoardEvent.startEditingHeader(String groupId) =
_StartEditingHeader;
const factory BoardEvent.endEditingHeader(String groupId, String? groupName) =
_EndEditingHeader;
const factory BoardEvent.didCreateRow(
GroupPB group,
RowMetaPB row,
int? index,
) = _DidCreateRow;
const factory BoardEvent.startEditingRow(
GroupPB group,
RowMetaPB row,
) = _StartEditRow;
const factory BoardEvent.endEditingRow(RowId rowId) = _EndEditRow;
const factory BoardEvent.toggleGroupVisibility(
const factory BoardEvent.setGroupVisibility(
GroupPB group,
bool isVisible,
) = _ToggleGroupVisibility;
) = _SetGroupVisibility;
const factory BoardEvent.toggleHiddenSectionVisibility(bool isVisible) =
_ToggleHiddenSectionVisibility;
const factory BoardEvent.deleteGroup(String groupId) = _DeleteGroup;
const factory BoardEvent.reorderGroup(String fromGroupId, String toGroupId) =
_ReorderGroup;
const factory BoardEvent.didReceiveError(FlowyError error) = _DidReceiveError;
const factory BoardEvent.didReceiveGridUpdate(
DatabasePB grid,
) = _DidReceiveGridUpdate;
const factory BoardEvent.didReceiveGroups(List<GroupPB> groups) =
_DidReceiveGroups;
const factory BoardEvent.didUpdateLayoutSettings(
BoardLayoutSettingPB layoutSettings,
) = _DidUpdateLayoutSettings;
const factory BoardEvent.deleteCards(List<GroupedRowId> groupedRowIds) =
_DeleteCards;
const factory BoardEvent.moveGroupToAdjacentGroup(
GroupedRowId groupedRowId,
bool toPrevious,
) = _MoveGroupToAdjacentGroup;
}
@freezed
class BoardState with _$BoardState {
const factory BoardState({
const BoardState._();
const factory BoardState.loading() = _BoardLoadingState;
const factory BoardState.error({
required FlowyError error,
}) = _BoardErrorState;
const factory BoardState.ready({
required String viewId,
required DatabasePB? grid,
required List<String> groupIds,
required bool isEditingHeader,
required bool isEditingRow,
required LoadingState loadingState,
required FlowyError? noneOrError,
required BoardLayoutSettingPB? layoutSettings,
String? editingHeaderId,
BoardEditingRow? editingRow,
RowMetaPB? recentAddedRowMeta,
required List<GroupPB> hiddenGroups,
}) = _BoardState;
String? editingHeaderId,
}) = _BoardReadyState;
factory BoardState.initial(String viewId) => BoardState(
grid: null,
const factory BoardState.setFocus({
required List<GroupedRowId> groupedRowIds,
}) = _BoardSetFocusState;
factory BoardState.initial(String viewId) => BoardState.ready(
viewId: viewId,
groupIds: [],
isEditingHeader: false,
isEditingRow: false,
noneOrError: null,
loadingState: const LoadingState.loading(),
layoutSettings: null,
hiddenGroups: [],
);
bool get isLoading => maybeMap(loading: (_) => true, orElse: () => false);
bool get isError => maybeMap(error: (_) => true, orElse: () => false);
bool get isReady => maybeMap(ready: (_) => true, orElse: () => false);
bool get isSetFocus => maybeMap(setFocus: (_) => true, orElse: () => false);
}
List<GroupPB> _filterHiddenGroups(bool hideUngrouped, List<GroupPB> groups) {
@ -658,7 +671,7 @@ class GroupItem extends AppFlowyGroupItem {
required this.fieldInfo,
bool draggable = true,
}) {
super.draggable = draggable;
super.draggable.value = draggable;
}
final RowMetaPB row;
@ -668,6 +681,23 @@ class GroupItem extends AppFlowyGroupItem {
String get id => row.id.toString();
}
/// Identifies a card in a database view that has grouping. To support cases
/// in which a card can belong to more than one group at the same time (e.g.
/// FieldType.Multiselect), we include the card's group id as well.
///
class GroupedRowId extends Equatable {
const GroupedRowId({
required this.rowId,
required this.groupId,
});
final String rowId;
final String groupId;
@override
List<Object?> get props => [rowId, groupId];
}
class GroupControllerDelegateImpl extends GroupControllerDelegate {
GroupControllerDelegateImpl({
required this.controller,
@ -743,18 +773,6 @@ class GroupControllerDelegateImpl extends GroupControllerDelegate {
}
}
class BoardEditingRow {
BoardEditingRow({
required this.group,
required this.row,
required this.index,
});
GroupPB group;
RowMetaPB row;
int? index;
}
class GroupData {
GroupData({
required this.group,
@ -779,3 +797,21 @@ class CheckboxGroup {
// pub const CHECK: &str = "Yes";
bool get isCheck => group.groupId == "Yes";
}
enum DidCreateRowAction {
none,
openAsPage,
startEditing,
}
class DidCreateRowResult {
DidCreateRowResult({
required this.action,
required this.rowMeta,
required this.groupId,
});
final DidCreateRowAction action;
final RowMetaPB rowMeta;
final String groupId;
}

View File

@ -1,12 +1,11 @@
import 'dart:collection';
import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/database/board/mobile_board_content.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_state_container.dart';
import 'package:appflowy/mobile/presentation/database/board/mobile_board_page.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/application/row/row_cache.dart';
import 'package:appflowy/plugins/database/application/row/row_controller.dart';
import 'package:appflowy/plugins/database/board/application/board_actions_bloc.dart';
import 'package:appflowy/plugins/database/board/presentation/widgets/board_column_header.dart';
import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/header/field_type_extension.dart';
@ -15,7 +14,8 @@ import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart';
import 'package:appflowy/plugins/database/widgets/card/card_bloc.dart';
import 'package:appflowy/plugins/database/widgets/cell/card_cell_style_maps/desktop_board_card_cell_style.dart';
import 'package:appflowy/plugins/database/widgets/row/row_detail.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart';
import 'package:appflowy/shared/conditional_listenable_builder.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_board/appflowy_board.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
@ -28,12 +28,13 @@ import 'package:flutter/material.dart' hide Card;
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../workspace/application/view/view_bloc.dart';
import '../../widgets/card/card.dart';
import '../../widgets/cell/card_cell_builder.dart';
import '../application/board_bloc.dart';
import 'toolbar/board_setting_bar.dart';
import 'widgets/board_focus_scope.dart';
import 'widgets/board_hidden_groups.dart';
import 'widgets/board_shortcut_container.dart';
class BoardPageTabBarBuilderImpl extends DatabaseTabBarItemBuilder {
final _toggleExtension = ToggleExtensionNotifier();
@ -46,7 +47,17 @@ class BoardPageTabBarBuilderImpl extends DatabaseTabBarItemBuilder {
bool shrinkWrap,
String? initialRowId,
) =>
BoardPage(view: view, databaseController: controller);
PlatformExtension.isDesktop
? DesktopBoardPage(
key: _makeValueKey(controller),
view: view,
databaseController: controller,
)
: MobileBoardPage(
key: _makeValueKey(controller),
view: view,
databaseController: controller,
);
@override
Widget settingBar(BuildContext context, DatabaseController controller) =>
@ -79,12 +90,13 @@ class BoardPageTabBarBuilderImpl extends DatabaseTabBarItemBuilder {
ValueKey(controller.viewId);
}
class BoardPage extends StatelessWidget {
BoardPage({
class DesktopBoardPage extends StatefulWidget {
const DesktopBoardPage({
super.key,
required this.view,
required this.databaseController,
this.onEditStateChanged,
}) : super(key: ValueKey(view.id));
});
final ViewPB view;
@ -93,54 +105,154 @@ class BoardPage extends StatelessWidget {
/// Called when edit state changed
final VoidCallback? onEditStateChanged;
@override
State<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
Widget build(BuildContext context) {
return BlocProvider<BoardBloc>(
create: (context) => BoardBloc(
view: view,
databaseController: databaseController,
)..add(const BoardEvent.initial()),
return MultiBlocProvider(
providers: [
BlocProvider<BoardBloc>.value(
value: _boardBloc,
),
BlocProvider.value(
value: _boardActionsCubit,
),
],
child: BlocBuilder<BoardBloc, BoardState>(
buildWhen: (p, c) => p.loadingState != c.loadingState,
builder: (context, state) => state.loadingState.when(
loading: () => const Center(
builder: (context, state) => state.maybeMap(
loading: (_) => const Center(
child: CircularProgressIndicator.adaptive(),
),
idle: () => const SizedBox.shrink(),
finish: (result) => result.fold(
(_) => PlatformExtension.isMobile
? const MobileBoardContent()
: DesktopBoardContent(onEditStateChanged: onEditStateChanged),
(err) => PlatformExtension.isMobile
? FlowyMobileStateContainer.error(
emoji: '🛸',
title: LocaleKeys.board_mobile_failedToLoad.tr(),
errorMsg: err.toString(),
)
: FlowyErrorPage.message(
error: (err) => FlowyErrorPage.message(
err.toString(),
howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(),
),
orElse: () => _BoardContent(
onEditStateChanged: widget.onEditStateChanged,
focusScope: _focusScope,
boardController: _boardController,
),
),
),
);
}
void _handleDidCreateRow() async {
// work around: wait for the new card to be inserted into the board before enabling edit
await Future.delayed(const Duration(milliseconds: 50));
if (_didCreateRow.value != null) {
final result = _didCreateRow.value!;
switch (result.action) {
case DidCreateRowAction.openAsPage:
_boardActionsCubit.openCard(result.rowMeta);
break;
case DidCreateRowAction.startEditing:
_boardActionsCubit.startEditingRow(
GroupedRowId(
groupId: result.groupId,
rowId: result.rowMeta.id,
),
);
break;
default:
break;
}
}
}
}
class DesktopBoardContent extends StatefulWidget {
const DesktopBoardContent({
super.key,
class _BoardContent extends StatefulWidget {
const _BoardContent({
required this.boardController,
required this.focusScope,
this.onEditStateChanged,
});
final AppFlowyBoardController boardController;
final BoardFocusScope focusScope;
final VoidCallback? onEditStateChanged;
@override
State<DesktopBoardContent> createState() => _DesktopBoardContentState();
State<_BoardContent> createState() => _BoardContentState();
}
class _DesktopBoardContentState extends State<DesktopBoardContent> {
class _BoardContentState extends State<_BoardContent> {
final ScrollController scrollController = ScrollController();
final AppFlowyBoardScrollController scrollManager =
AppFlowyBoardScrollController();
@ -148,16 +260,19 @@ class _DesktopBoardContentState extends State<DesktopBoardContent> {
final config = const AppFlowyBoardConfig(
groupMargin: EdgeInsets.symmetric(horizontal: 4),
groupBodyPadding: EdgeInsets.symmetric(horizontal: 4),
groupFooterPadding: EdgeInsets.fromLTRB(4, 14, 4, 4),
groupFooterPadding: EdgeInsets.fromLTRB(8, 14, 8, 4),
groupHeaderPadding: EdgeInsets.symmetric(horizontal: 8),
cardMargin: EdgeInsets.symmetric(horizontal: 4, vertical: 3),
stretchGroupHeight: false,
);
late final cellBuilder = CardCellBuilder(
databaseController: context.read<BoardBloc>().databaseController,
databaseController: databaseController,
);
DatabaseController get databaseController =>
context.read<BoardBloc>().databaseController;
@override
void dispose() {
scrollController.dispose();
@ -166,15 +281,49 @@ class _DesktopBoardContentState extends State<DesktopBoardContent> {
@override
Widget build(BuildContext context) {
return BlocListener<BoardBloc, BoardState>(
return MultiBlocListener(
listeners: [
BlocListener<BoardBloc, BoardState>(
listener: (context, state) {
state.maybeMap(
ready: (value) {
widget.onEditStateChanged?.call();
},
child: BlocBuilder<BoardBloc, BoardState>(
builder: (context, state) {
final showCreateGroupButton =
context.read<BoardBloc>().groupingFieldType.canCreateNewGroup;
return Padding(
orElse: () {},
);
},
),
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),
child: AppFlowyBoard(
boardScrollController: scrollManager,
@ -183,7 +332,11 @@ class _DesktopBoardContentState extends State<DesktopBoardContent> {
groupConstraints: const BoxConstraints.tightFor(width: 256),
config: config,
leading: HiddenGroupsColumn(margin: config.groupHeaderPadding),
trailing: showCreateGroupButton
trailing: context
.read<BoardBloc>()
.groupingFieldType
?.canCreateNewGroup ??
false
? BoardTrailing(scrollController: scrollController)
: const HSpace(40),
headerBuilder: (_, groupData) => BlocProvider<BoardBloc>.value(
@ -193,110 +346,367 @@ class _DesktopBoardContentState extends State<DesktopBoardContent> {
margin: config.groupHeaderPadding,
),
),
footerBuilder: _buildFooter,
cardBuilder: (_, column, columnItem) => _buildCard(
context,
column,
columnItem,
footerBuilder: (_, groupData) => MultiBlocProvider(
providers: [
BlocProvider.value(
value: context.read<BoardBloc>(),
),
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) {
return Padding(
padding: config.groupFooterPadding,
Widget _startCreatingCardsButton() {
return BlocListener<BoardActionsCubit, BoardActionsState>(
listener: (context, state) {
state.maybeWhen(
startCreateBottomRow: (groupId) {
if (groupId == widget.columnData.id) {
setState(() => _isCreating = true);
}
},
orElse: () {},
);
},
child: FlowyTooltip(
message: LocaleKeys.board_column_addToColumnBottomTooltip.tr(),
child: FlowyHover(
child: AppFlowyGroupFooter(
child: SizedBox(
height: 36,
icon: FlowySvg(
child: FlowyButton(
leftIcon: FlowySvg(
FlowySvgs.add_s,
color: Theme.of(context).hintColor,
),
title: FlowyText.medium(
text: FlowyText.medium(
LocaleKeys.board_column_createNewCard.tr(),
color: Theme.of(context).hintColor,
),
onAddButtonClick: () => context
.read<BoardBloc>()
.add(BoardEvent.createBottomRow(columnData.id)),
onTap: () {
setState(() => _isCreating = true);
},
),
),
),
);
}
}
Widget _buildCard(
BuildContext context,
AppFlowyGroupData afGroupData,
AppFlowyGroupItem afGroupItem,
) {
class _BoardCard extends StatefulWidget {
const _BoardCard({
required this.afGroupData,
required this.groupItem,
required this.boardConfig,
required this.cellBuilder,
required this.notifier,
});
final AppFlowyGroupData afGroupData;
final GroupItem groupItem;
final AppFlowyBoardConfig boardConfig;
final CardCellBuilder cellBuilder;
final BoardFocusScope notifier;
@override
State<_BoardCard> createState() => _BoardCardState();
}
class _BoardCardState extends State<_BoardCard> {
bool _isEditing = false;
@override
Widget build(BuildContext context) {
final boardBloc = context.read<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.
if (rowCache == null) {
return SizedBox.shrink(key: ObjectKey(groupItem));
}
final groupData = widget.afGroupData.customData as GroupData;
final rowCache = boardBloc.rowCache;
final databaseController = boardBloc.databaseController;
final viewId = boardBloc.viewId;
final rowMeta =
rowCache.getRow(widget.groupItem.id)?.rowMeta ?? widget.groupItem.row;
final isEditing = boardBloc.state.isEditingRow &&
boardBloc.state.editingRow?.row.id == groupItem.row.id;
const nada = DoNothingAndStopPropagationIntent();
final groupItemId = "${groupData.group.groupId}${groupItem.row.id}";
final rowMeta = rowInfo?.rowMeta ?? groupItem.row;
return BlocListener<BoardActionsCubit, BoardActionsState>(
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(
key: ValueKey(groupItemId),
margin: config.cardMargin,
decoration: _makeBoxDecoration(context),
return previousContainsFocus != currentContainsFocus;
},
builder: (context, focusedItems, child) => Container(
margin: widget.boardConfig.cardMargin,
decoration: _makeBoxDecoration(
context,
groupData.group.groupId,
widget.groupItem.id,
),
child: child,
),
child: RowCard(
fieldController: databaseController.fieldController,
rowMeta: rowMeta,
viewId: viewId,
viewId: boardBloc.viewId,
rowCache: rowCache,
groupingFieldId: groupItem.fieldInfo.id,
isEditing: isEditing,
cellBuilder: cellBuilder,
openCard: (context) => _openCard(
groupingFieldId: widget.groupItem.fieldInfo.id,
isEditing: _isEditing,
cellBuilder: widget.cellBuilder,
onTap: (context) => _openCard(
context: context,
databaseController: databaseController,
groupId: groupData.group.groupId,
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(
cellStyleMap: desktopBoardCardCellStyleMap(context),
hoverStyle: HoverStyle(
hoverColor: Theme.of(context).brightness == Brightness.light
? const Color(0x0F1F2329)
: const Color(0x0FEFF4FB),
foregroundColorOnHover: Theme.of(context).colorScheme.onBackground,
foregroundColorOnHover:
Theme.of(context).colorScheme.onBackground,
),
),
onStartEditing: () =>
boardBloc.add(BoardEvent.startEditingRow(groupData.group, rowMeta)),
onEndEditing: () => boardBloc.add(BoardEvent.endEditingRow(rowMeta.id)),
context.read<BoardActionsCubit>().startEditingRow(
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(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.all(Radius.circular(6)),
border: Border.fromBorderSide(
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(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 {
@ -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/widgets/header/field_type_extension.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/group.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_board/appflowy_board.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
@ -65,7 +64,10 @@ class _BoardColumnHeaderState extends State<BoardColumnHeader> {
return BlocBuilder<BoardBloc, BoardState>(
builder: (context, state) {
if (state.isEditingHeader) {
return state.maybeMap(
orElse: () => const SizedBox.shrink(),
ready: (state) {
if (state.editingHeaderId != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
@ -87,9 +89,9 @@ class _BoardColumnHeaderState extends State<BoardColumnHeader> {
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => context
.read<BoardBloc>()
.add(BoardEvent.startEditingHeader(widget.groupData.id)),
onTap: () => context.read<BoardBloc>().add(
BoardEvent.startEditingHeader(widget.groupData.id),
),
child: FlowyText.medium(
widget.groupData.headerData.groupName,
overflow: TextOverflow.ellipsis,
@ -100,8 +102,7 @@ class _BoardColumnHeaderState extends State<BoardColumnHeader> {
);
}
if (state.isEditingHeader &&
state.editingHeaderId == widget.groupData.id) {
if (state.editingHeaderId == widget.groupData.id) {
title = _buildTextField(context);
}
@ -117,15 +118,22 @@ class _BoardColumnHeaderState extends State<BoardColumnHeader> {
_groupOptionsButton(context),
const HSpace(4),
FlowyTooltip(
message: LocaleKeys.board_column_addToColumnTopTooltip.tr(),
message:
LocaleKeys.board_column_addToColumnTopTooltip.tr(),
preferBelow: false,
child: FlowyIconButton(
width: 20,
icon: const FlowySvg(FlowySvgs.add_s),
iconColorOnHover: Theme.of(context).colorScheme.onSurface,
onPressed: () => context
.read<BoardBloc>()
.add(BoardEvent.createHeaderRow(widget.groupData.id)),
iconColorOnHover:
Theme.of(context).colorScheme.onSurface,
onPressed: () => context.read<BoardBloc>().add(
BoardEvent.createRow(
widget.groupData.id,
OrderObjectPositionTypePB.Start,
null,
null,
),
),
),
),
],
@ -134,6 +142,8 @@ class _BoardColumnHeaderState extends State<BoardColumnHeader> {
);
},
);
},
);
}
Widget _buildTextField(BuildContext context) {
@ -257,7 +267,7 @@ enum GroupOptions {
case hide:
context
.read<BoardBloc>()
.add(BoardEvent.toggleGroupVisibility(group, false));
.add(BoardEvent.setGroupVisibility(group, false));
break;
case delete:
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_skeleton/text_card_cell.dart';
import 'package:appflowy/plugins/database/widgets/row/row_detail.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:collection/collection.dart';
@ -23,7 +22,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class HiddenGroupsColumn extends StatelessWidget {
const HiddenGroupsColumn({super.key, required this.margin});
const HiddenGroupsColumn({
super.key,
required this.margin,
});
final EdgeInsets margin;
@ -31,7 +33,10 @@ class HiddenGroupsColumn extends StatelessWidget {
Widget build(BuildContext context) {
final databaseController = context.read<BoardBloc>().databaseController;
return BlocSelector<BoardBloc, BoardState, BoardLayoutSettingPB?>(
selector: (state) => state.layoutSettings,
selector: (state) => state.maybeMap(
orElse: () => null,
ready: (value) => value.layoutSettings,
),
builder: (context, layoutSettings) {
if (layoutSettings == null) {
return const SizedBox.shrink();
@ -126,7 +131,10 @@ class HiddenGroupList extends StatelessWidget {
@override
Widget build(BuildContext context) {
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(
color: Colors.transparent,
child: Stack(
@ -164,6 +172,8 @@ class HiddenGroupList extends StatelessWidget {
},
),
);
},
);
}
}
@ -248,6 +258,9 @@ class HiddenGroupButtonContent extends StatelessWidget {
value: bloc,
child: BlocBuilder<BoardBloc, BoardState>(
builder: (context, state) {
return state.maybeMap(
orElse: () => const SizedBox.shrink(),
ready: (state) {
final group = state.hiddenGroups.firstWhereOrNull(
(g) => g.groupId == groupId,
);
@ -288,8 +301,9 @@ class HiddenGroupButtonContent extends StatelessWidget {
FlowySvgs.show_m,
color: Theme.of(context).hintColor,
),
onPressed: () => context.read<BoardBloc>().add(
BoardEvent.toggleGroupVisibility(
onPressed: () =>
context.read<BoardBloc>().add(
BoardEvent.setGroupVisibility(
group,
true,
),
@ -301,6 +315,8 @@ class HiddenGroupButtonContent extends StatelessWidget {
),
);
},
);
},
),
);
},
@ -360,6 +376,9 @@ class HiddenGroupPopupItemList extends StatelessWidget {
Widget build(BuildContext context) {
return BlocBuilder<BoardBloc, BoardState>(
builder: (context, state) {
return state.maybeMap(
orElse: () => const SizedBox.shrink(),
ready: (state) {
final group = state.hiddenGroups.firstWhereOrNull(
(g) => g.groupId == groupId,
);
@ -389,7 +408,8 @@ class HiddenGroupPopupItemList extends StatelessWidget {
return HiddenGroupPopupItem(
cellContext: rowCache.loadCells(item).firstWhere(
(cellContext) => cellContext.fieldId == primaryFieldId,
(cellContext) =>
cellContext.fieldId == primaryFieldId,
),
rowController: rowController,
rowMeta: item,
@ -400,12 +420,9 @@ class HiddenGroupPopupItemList extends StatelessWidget {
FlowyOverlay.show(
context: context,
builder: (_) {
return BlocProvider.value(
value: context.read<ViewBloc>(),
child: RowDetailPage(
return RowDetailPage(
databaseController: databaseController,
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 {
final result = await RowBackendService.deleteRow(viewId, rowId);
final result = await RowBackendService.deleteRows(viewId, [rowId]);
result.fold(
(_) => null,
(e) => Log.error('Failed to delete event: $e', e),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,3 @@
import 'package:appflowy/plugins/database/application/cell/cell_controller.dart';
import 'package:flutter/material.dart';
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 {
// Each cell notifier will be bind to the [EditableRowNotifier], which enable
// 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/widget/flowy_tooltip.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../editable_cell_builder.dart';
@ -108,18 +109,11 @@ class _TextCellState extends State<TextCardCell> {
return BlocProvider.value(
value: cellBloc,
child: BlocConsumer<TextCellBloc, TextCellState>(
listenWhen: (previous, current) =>
previous.content != current.content && !current.enableEdit,
listenWhen: (previous, current) => previous.content != current.content,
listener: (context, state) {
if (!state.enableEdit) {
_textEditingController.text = state.content;
},
buildWhen: (previous, current) {
if (previous.content != current.content &&
_textEditingController.text == current.content) {
return false;
}
return previous != current;
},
builder: (context, state) {
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));
return IgnorePointer(
ignoring: !isEditing,
child: CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.escape): () =>
focusNode.unfocus(),
},
child: TextField(
controller: _textEditingController,
focusNode: focusNode,
onChanged: (_) {
if (_textEditingController.value.composing.isCollapsed) {
cellBloc.add(TextCellEvent.updateText(_textEditingController.text));
cellBloc
.add(TextCellEvent.updateText(_textEditingController.text));
}
},
onEditingComplete: () => focusNode.unfocus(),
@ -222,6 +222,8 @@ class _TextCellState extends State<TextCardCell> {
color: Theme.of(context).hintColor,
),
),
onTapOutside: (_) {},
),
),
);
}

View File

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

View File

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

View File

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

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"
description:
path: "."
ref: "404262fca4369bc35ff305316e4d59341a732f56"
resolved-ref: "404262fca4369bc35ff305316e4d59341a732f56"
ref: "8a6434ae3d02624b614a010af80f775db11bf22e"
resolved-ref: "8a6434ae3d02624b614a010af80f775db11bf22e"
url: "https://github.com/AppFlowy-IO/appflowy-board.git"
source: git
version: "0.1.2"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -38,7 +38,6 @@ void main() {
blocTest<BoardBloc, BoardState>(
'assert the number of groups is 1',
build: () => BoardBloc(
view: context.gridView,
databaseController: DatabaseController(view: context.gridView),
)..add(
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> {
EventBuilder::new(self.clone())
.event(DatabaseEvent::DeleteRow)
.payload(RowIdPB {
.event(DatabaseEvent::DeleteRows)
.payload(RepeatedRowIdPB {
view_id: view_id.to_string(),
row_id: row_id.to_string(),
group_id: None,
row_ids: vec![row_id.to_string()],
})
.async_send()
.await
@ -523,7 +522,7 @@ impl EventIntegrationTest {
) -> Vec<RelatedRowDataPB> {
EventBuilder::new(self.clone())
.event(DatabaseEvent::GetRelatedRowDatas)
.payload(RepeatedRowIdPB {
.payload(GetRelatedRowDataPB {
database_id,
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![])
.await;
// delete the row with empty row_id. It should return an error.
// delete the row with empty row_id. It should do nothing
let error = test.delete_row(&grid_view.id, "").await;
assert!(error.is_some());
assert!(error.is_none());
let database = test.get_database(&grid_view.id).await;
assert_eq!(database.rows.len(), 3);
}
#[tokio::test]

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)]
pub struct CreateRowPayloadPB {
#[pb(index = 1)]

View File

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

View File

@ -385,14 +385,19 @@ pub(crate) async fn update_row_meta_handler(
}
#[tracing::instrument(level = "debug", skip(data, manager), err)]
pub(crate) async fn delete_row_handler(
data: AFPluginData<RowIdPB>,
pub(crate) async fn delete_rows_handler(
data: AFPluginData<RepeatedRowIdPB>,
manager: AFPluginState<Weak<DatabaseManager>>,
) -> Result<(), FlowyError> {
let manager = upgrade_manager(manager)?;
let params: RowIdParams = data.into_inner().try_into()?;
let params: RepeatedRowIdPB = data.into_inner();
let database_editor = manager.get_database_with_view_id(&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(())
}
@ -1062,11 +1067,11 @@ pub(crate) async fn update_relation_cell_handler(
}
pub(crate) async fn get_related_row_datas_handler(
data: AFPluginData<RepeatedRowIdPB>,
data: AFPluginData<GetRelatedRowDataPB>,
manager: AFPluginState<Weak<DatabaseManager>>,
) -> DataResult<RepeatedRelatedRowDataPB, FlowyError> {
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 row_datas = database_editor
.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::GetRowMeta, get_row_meta_handler)
.event(DatabaseEvent::UpdateRowMeta, update_row_meta_handler)
.event(DatabaseEvent::DeleteRow, delete_row_handler)
.event(DatabaseEvent::DeleteRows, delete_rows_handler)
.event(DatabaseEvent::DuplicateRow, duplicate_row_handler)
.event(DatabaseEvent::MoveRow, move_row_handler)
// Cell
@ -223,8 +223,8 @@ pub enum DatabaseEvent {
#[event(input = "RowIdPB", output = "OptionalRowPB")]
GetRow = 51,
#[event(input = "RowIdPB")]
DeleteRow = 52,
#[event(input = "RepeatedRowIdPB")]
DeleteRows = 52,
#[event(input = "RowIdPB")]
DuplicateRow = 53,
@ -364,7 +364,7 @@ pub enum DatabaseEvent {
UpdateRelationCell = 171,
/// Get the names of the linked rows in a relation cell.
#[event(input = "RepeatedRowIdPB", output = "RepeatedRelatedRowDataPB")]
#[event(input = "GetRelatedRowDataPB", output = "RepeatedRelatedRowDataPB")]
GetRelatedRowDatas = 172,
/// Get the names of all the rows in a related database.

View File

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

View File

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