mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
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:
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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: (_) {},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
},
|
||||
),
|
||||
|
@ -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(
|
||||
|
Reference in New Issue
Block a user