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
48 changed files with 2192 additions and 990 deletions

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) {
_textEditingController.text = state.content;
},
buildWhen: (previous, current) {
if (previous.content != current.content &&
_textEditingController.text == current.content) {
return false;
if (!state.enableEdit) {
_textEditingController.text = state.content;
}
return previous != current;
},
builder: (context, state) {
final isTitle = cellBloc.cellController.fieldInfo.isPrimary;
@ -196,31 +190,39 @@ class _TextCellState extends State<TextCardCell> {
widget.style.padding.add(const EdgeInsets.symmetric(vertical: 4.0));
return IgnorePointer(
ignoring: !isEditing,
child: TextField(
controller: _textEditingController,
focusNode: focusNode,
onChanged: (_) {
if (_textEditingController.value.composing.isCollapsed) {
cellBloc.add(TextCellEvent.updateText(_textEditingController.text));
}
child: CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.escape): () =>
focusNode.unfocus(),
},
onEditingComplete: () => focusNode.unfocus(),
maxLines: isEditing ? null : 2,
minLines: 1,
textInputAction: TextInputAction.done,
readOnly: !isEditing,
enableInteractiveSelection: isEditing,
style: widget.style.titleTextStyle,
decoration: InputDecoration(
contentPadding: padding,
border: InputBorder.none,
enabledBorder: InputBorder.none,
isDense: true,
isCollapsed: true,
hintText: LocaleKeys.grid_row_titlePlaceholder.tr(),
hintStyle: widget.style.titleTextStyle.copyWith(
color: Theme.of(context).hintColor,
child: TextField(
controller: _textEditingController,
focusNode: focusNode,
onChanged: (_) {
if (_textEditingController.value.composing.isCollapsed) {
cellBloc
.add(TextCellEvent.updateText(_textEditingController.text));
}
},
onEditingComplete: () => focusNode.unfocus(),
maxLines: isEditing ? null : 2,
minLines: 1,
textInputAction: TextInputAction.done,
readOnly: !isEditing,
enableInteractiveSelection: isEditing,
style: widget.style.titleTextStyle,
decoration: InputDecoration(
contentPadding: padding,
border: InputBorder.none,
enabledBorder: InputBorder.none,
isDense: true,
isCollapsed: true,
hintText: LocaleKeys.grid_row_titlePlaceholder.tr(),
hintStyle: widget.style.titleTextStyle.copyWith(
color: Theme.of(context).hintColor,
),
),
onTapOutside: (_) {},
),
),
);

View File

@ -80,7 +80,9 @@ class _TextCellState extends GridEditableTextCell<EditableTextCell> {
value: cellBloc,
child: BlocListener<TextCellBloc, TextCellState>(
listener: (context, state) {
_textEditingController.text = state.content;
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(