diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/checklist_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/checklist_cell_bloc.dart index 756a3dafb5..7fe92cd137 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/checklist_cell_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/checklist_cell_bloc.dart @@ -11,7 +11,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'checklist_cell_bloc.freezed.dart'; class ChecklistSelectOption { - ChecklistSelectOption(this.isSelected, this.data); + ChecklistSelectOption({required this.isSelected, required this.data}); final bool isSelected; final SelectOptionPB data; @@ -26,6 +26,7 @@ class ChecklistCellBloc extends Bloc { ), super(ChecklistCellState.initial(cellController)) { _dispatch(); + _startListening(); } final ChecklistCellController cellController; @@ -46,9 +47,6 @@ class ChecklistCellBloc extends Bloc { on( (event, emit) async { await event.when( - initial: () { - _startListening(); - }, didReceiveOptions: (data) { if (data == null) { emit( @@ -71,8 +69,8 @@ class ChecklistCellBloc extends Bloc { updateTaskName: (option, name) { _updateOption(option, name); }, - selectTask: (option) async { - await _checklistCellService.select(optionId: option.id); + selectTask: (id) async { + await _checklistCellService.select(optionId: id); }, createNewTask: (name) async { final result = await _checklistCellService.create(name: name); @@ -81,8 +79,8 @@ class ChecklistCellBloc extends Bloc { (err) => Log.error(err), ); }, - deleteTask: (option) async { - await _deleteOption([option]); + deleteTask: (id) async { + await _deleteOption([id]); }, ); }, @@ -102,21 +100,17 @@ class ChecklistCellBloc extends Bloc { void _updateOption(SelectOptionPB option, String name) async { final result = await _checklistCellService.updateName(option: option, name: name); - result.fold((l) => null, (err) => Log.error(err)); } - Future _deleteOption(List options) async { - final result = await _checklistCellService.delete( - optionIds: options.map((e) => e.id).toList(), - ); + Future _deleteOption(List options) async { + final result = await _checklistCellService.delete(optionIds: options); result.fold((l) => null, (err) => Log.error(err)); } } @freezed class ChecklistCellEvent with _$ChecklistCellEvent { - const factory ChecklistCellEvent.initial() = _InitialCell; const factory ChecklistCellEvent.didReceiveOptions( ChecklistCellDataPB? data, ) = _DidReceiveCellUpdate; @@ -124,12 +118,10 @@ class ChecklistCellEvent with _$ChecklistCellEvent { SelectOptionPB option, String name, ) = _UpdateTaskName; - const factory ChecklistCellEvent.selectTask(SelectOptionPB task) = - _SelectTask; + const factory ChecklistCellEvent.selectTask(String taskId) = _SelectTask; const factory ChecklistCellEvent.createNewTask(String description) = _CreateNewTask; - const factory ChecklistCellEvent.deleteTask(SelectOptionPB option) = - _DeleteTask; + const factory ChecklistCellEvent.deleteTask(String taskId) = _DeleteTask; } @freezed @@ -157,16 +149,14 @@ List _makeChecklistSelectOptions( if (data == null) { return []; } - - final List options = []; - final List allOptions = List.from(data.options); - final selectedOptionIds = data.selectedOptions.map((e) => e.id).toList(); - - for (final option in allOptions) { - options.add( - ChecklistSelectOption(selectedOptionIds.contains(option.id), option), - ); - } - - return options; + return data.options + .map( + (option) => ChecklistSelectOption( + isSelected: data.selectedOptions.any( + (selected) => selected.id == option.id, + ), + data: option, + ), + ) + .toList(); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/relation_cell_service.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/relation_cell_service.dart deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/checklist_card_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/checklist_card_cell.dart index 63cd450cff..a45b621dde 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/checklist_card_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/checklist_card_cell.dart @@ -42,7 +42,7 @@ class _ChecklistCellState extends State { widget.databaseController, widget.cellContext, ).as(), - )..add(const ChecklistCellEvent.initial()); + ); }, child: BlocBuilder( builder: (context, state) { diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart index 3c4576e4c3..69d7c07c09 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart @@ -64,14 +64,17 @@ class _ChecklistItemsState extends State { } final children = tasks .mapIndexed( - (index, task) => ChecklistItem( - task: task, - autofocus: widget.state.newTask && index == tasks.length - 1, - onSubmitted: () { - if (index == tasks.length - 1) { - widget.bloc.add(const ChecklistCellEvent.createNewTask("")); - } - }, + (index, task) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: ChecklistItem( + task: task, + autofocus: widget.state.newTask && index == tasks.length - 1, + onSubmitted: () { + if (index == tasks.length - 1) { + widget.bloc.add(const ChecklistCellEvent.createNewTask("")); + } + }, + ), ), ) .toList(); @@ -111,7 +114,7 @@ class _ChecklistItemsState extends State { ], ), ), - const VSpace(4), + const VSpace(2.0), ...children, ChecklistItemControl(cellNotifer: widget.cellContainerNotifier), ], @@ -136,7 +139,7 @@ class ChecklistItemControl extends StatelessWidget { .read() .add(const ChecklistCellEvent.createNewTask("")), child: Container( - margin: const EdgeInsets.fromLTRB(8.0, 4.0, 8.0, 0), + margin: const EdgeInsets.fromLTRB(8.0, 2.0, 8.0, 0), height: 12, child: AnimatedSwitcher( duration: const Duration(milliseconds: 150), diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/checklist.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/checklist.dart index 2f3407aea6..dd7bc6c2c1 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/checklist.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/checklist.dart @@ -58,7 +58,7 @@ class GridChecklistCellState extends GridCellState { widget.databaseController, widget.cellContext, ).as(), - )..add(const ChecklistCellEvent.initial()); + ); @override void dispose() { diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_editor.dart index a2d0c4adb6..f985e26560 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_editor.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; @@ -23,10 +24,10 @@ class ChecklistCellEditor extends StatefulWidget { final ChecklistCellController cellController; @override - State createState() => _GridChecklistCellState(); + State createState() => _ChecklistCellEditorState(); } -class _GridChecklistCellState extends State { +class _ChecklistCellEditorState extends State { /// Focus node for the new task text field late final FocusNode newTaskFocusNode; @@ -56,18 +57,14 @@ class _GridChecklistCellState extends State { return Column( mainAxisSize: MainAxisSize.min, children: [ - AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: state.tasks.isEmpty - ? const SizedBox.shrink() - : Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), - child: ChecklistProgressBar( - tasks: state.tasks, - percent: state.percent, - ), - ), - ), + if (state.tasks.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: ChecklistProgressBar( + tasks: state.tasks, + percent: state.percent, + ), + ), ChecklistItemList( options: state.tasks, onUpdateTask: () => newTaskFocusNode.requestFocus(), @@ -92,7 +89,7 @@ class _GridChecklistCellState extends State { /// Displays the a list of all the exisiting tasks and an input field to create /// a new task if `isAddingNewTask` is true -class ChecklistItemList extends StatefulWidget { +class ChecklistItemList extends StatelessWidget { const ChecklistItemList({ super.key, required this.options, @@ -102,26 +99,19 @@ class ChecklistItemList extends StatefulWidget { final List options; final VoidCallback onUpdateTask; - @override - State createState() => _ChecklistItemListState(); -} - -class _ChecklistItemListState extends State { @override Widget build(BuildContext context) { - if (widget.options.isEmpty) { + if (options.isEmpty) { return const SizedBox.shrink(); } - final itemList = widget.options + final itemList = options .mapIndexed( (index, option) => Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: ChecklistItem( task: option, - onSubmitted: index == widget.options.length - 1 - ? widget.onUpdateTask - : null, + onSubmitted: index == options.length - 1 ? onUpdateTask : null, key: ValueKey(option.data.id), ), ), @@ -140,6 +130,22 @@ class _ChecklistItemListState extends State { } } +class _SelectTaskIntent extends Intent { + const _SelectTaskIntent(); +} + +class _DeleteTaskIntent extends Intent { + const _DeleteTaskIntent(); +} + +class _StartEditingTaskIntent extends Intent { + const _StartEditingTaskIntent(); +} + +class _EndEditingTaskIntent extends Intent { + const _EndEditingTaskIntent(); +} + /// Represents an existing task @visibleForTesting class ChecklistItem extends StatefulWidget { @@ -160,58 +166,80 @@ class ChecklistItem extends StatefulWidget { class _ChecklistItemState extends State { late final TextEditingController _textController; - late final FocusNode _focusNode; + final FocusNode _focusNode = FocusNode(); bool _isHovered = false; + bool _isFocused = false; Timer? _debounceOnChanged; @override void initState() { super.initState(); _textController = TextEditingController(text: widget.task.data.name); - _focusNode = FocusNode( - onKeyEvent: (node, event) { - if (event.logicalKey == LogicalKeyboardKey.escape) { - node.unfocus(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, - ); - if (widget.autofocus) { - _focusNode.requestFocus(); - } } @override void dispose() { + _debounceOnChanged?.cancel(); _textController.dispose(); _focusNode.dispose(); - _debounceOnChanged?.cancel(); super.dispose(); } @override void didUpdateWidget(ChecklistItem oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.task.data.name != oldWidget.task.data.name && - !_focusNode.hasFocus) { + if (widget.task.data.name != oldWidget.task.data.name) { _textController.text = widget.task.data.name; } } @override Widget build(BuildContext context) { - final icon = FlowySvg( - widget.task.isSelected ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, - blendMode: BlendMode.dst, - ); - return MouseRegion( - onEnter: (event) => setState(() => _isHovered = true), - onExit: (event) => setState(() => _isHovered = false), + return FocusableActionDetector( + onShowHoverHighlight: (isHovered) { + setState(() => _isHovered = isHovered); + }, + onFocusChange: (isFocused) { + setState(() => _isFocused = isFocused); + }, + actions: { + _SelectTaskIntent: CallbackAction<_SelectTaskIntent>( + onInvoke: (_SelectTaskIntent intent) => context + .read() + .add(ChecklistCellEvent.selectTask(widget.task.data.id)), + ), + _DeleteTaskIntent: CallbackAction<_DeleteTaskIntent>( + onInvoke: (_DeleteTaskIntent intent) => context + .read() + .add(ChecklistCellEvent.deleteTask(widget.task.data.id)), + ), + _StartEditingTaskIntent: CallbackAction<_StartEditingTaskIntent>( + onInvoke: (_StartEditingTaskIntent intent) => + _focusNode.requestFocus(), + ), + _EndEditingTaskIntent: CallbackAction<_EndEditingTaskIntent>( + onInvoke: (_EndEditingTaskIntent intent) => _focusNode.unfocus(), + ), + }, + shortcuts: { + const SingleActivator(LogicalKeyboardKey.space): + const _SelectTaskIntent(), + const SingleActivator(LogicalKeyboardKey.delete): + const _DeleteTaskIntent(), + const SingleActivator(LogicalKeyboardKey.enter): + const _StartEditingTaskIntent(), + if (Platform.isMacOS) + const SingleActivator(LogicalKeyboardKey.enter, meta: true): + const _SelectTaskIntent() + else + const SingleActivator(LogicalKeyboardKey.enter, control: true): + const _SelectTaskIntent(), + }, + descendantsAreTraversable: false, child: Container( constraints: BoxConstraints(minHeight: GridSize.popoverItemHeight), decoration: BoxDecoration( - color: _isHovered + color: _isHovered || _isFocused || _focusNode.hasFocus ? AFThemeExtension.of(context).lightGreyHover : Colors.transparent, borderRadius: Corners.s6Border, @@ -220,43 +248,65 @@ class _ChecklistItemState extends State { children: [ FlowyIconButton( width: 32, - icon: icon, + icon: FlowySvg( + widget.task.isSelected + ? FlowySvgs.check_filled_s + : FlowySvgs.uncheck_s, + blendMode: BlendMode.dst, + ), hoverColor: Colors.transparent, onPressed: () => context.read().add( - ChecklistCellEvent.selectTask(widget.task.data), + ChecklistCellEvent.selectTask(widget.task.data.id), ), ), Expanded( - child: TextField( - controller: _textController, - focusNode: _focusNode, - style: Theme.of(context).textTheme.bodyMedium, - decoration: InputDecoration( - border: InputBorder.none, - isCollapsed: true, - contentPadding: EdgeInsets.only( - top: 8.0, - bottom: 8.0, - left: 2.0, - right: _isHovered ? 2.0 : 8.0, - ), - hintText: LocaleKeys.grid_checklist_taskHint.tr(), - ), - onChanged: _debounceOnChangedText, - onSubmitted: (description) { - _submitUpdateTaskDescription(description); - widget.onSubmitted?.call(); + child: Shortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.space): + DoNothingAndStopPropagationIntent(), + SingleActivator(LogicalKeyboardKey.delete): + DoNothingAndStopPropagationIntent(), + SingleActivator(LogicalKeyboardKey.enter): + DoNothingAndStopPropagationIntent(), + SingleActivator(LogicalKeyboardKey.escape): + _EndEditingTaskIntent(), }, + child: TextField( + controller: _textController, + focusNode: _focusNode, + autofocus: widget.autofocus, + style: Theme.of(context).textTheme.bodyMedium, + decoration: InputDecoration( + border: InputBorder.none, + isCollapsed: true, + contentPadding: EdgeInsets.only( + top: 8.0, + bottom: 8.0, + left: 2.0, + right: _isHovered ? 2.0 : 8.0, + ), + hintText: LocaleKeys.grid_checklist_taskHint.tr(), + ), + onChanged: (text) { + if (_textController.value.composing.isCollapsed) { + _debounceOnChangedText(text); + } + }, + onSubmitted: (description) { + _submitUpdateTaskDescription(description); + widget.onSubmitted?.call(); + }, + ), ), ), - if (_isHovered) + if (_isHovered || _isFocused || _focusNode.hasFocus) FlowyIconButton( width: 32, icon: const FlowySvg(FlowySvgs.delete_s), hoverColor: Colors.transparent, iconColorOnHover: Theme.of(context).colorScheme.error, onPressed: () => context.read().add( - ChecklistCellEvent.deleteTask(widget.task.data), + ChecklistCellEvent.deleteTask(widget.task.data.id), ), ), ], @@ -276,7 +326,7 @@ class _ChecklistItemState extends State { context.read().add( ChecklistCellEvent.updateTaskName( widget.task.data, - description.trim(), + description, ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_checklist_cell_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_checklist_cell_editor.dart index 944d438e69..f775896aec 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_checklist_cell_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_checklist_cell_editor.dart @@ -159,7 +159,7 @@ class _ChecklistItemState extends State<_ChecklistItem> { borderRadius: BorderRadius.circular(22), onTap: () => context .read() - .add(ChecklistCellEvent.selectTask(widget.task.data)), + .add(ChecklistCellEvent.selectTask(widget.task.data.id)), child: SizedBox.square( dimension: 44, child: Center( @@ -239,7 +239,7 @@ class _ChecklistItemState extends State<_ChecklistItem> { child: InkWell( onTap: () { context.read().add( - ChecklistCellEvent.deleteTask(widget.task.data), + ChecklistCellEvent.deleteTask(widget.task.data.id), ); context.pop(); }, diff --git a/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/popover.dart b/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/popover.dart index 8a18b447a7..4c93b40e78 100644 --- a/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/popover.dart +++ b/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/popover.dart @@ -161,15 +161,16 @@ class PopoverState extends State { ), ); - return FocusScope( - onKey: (node, event) { - if (event.logicalKey == LogicalKeyboardKey.escape) { - _removeRootOverlay(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.escape): () => + _removeRootOverlay(), }, - child: Stack(children: children), + child: FocusScope( + child: Stack( + children: children, + ), + ), ); }); _rootEntry.addEntry(context, this, newEntry, widget.asBarrier);