diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart index 330d86d54f..92bcbddfee 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart @@ -16,7 +16,9 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_chat_types/flutter_chat_types.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:nanoid/nanoid.dart'; + import 'chat_message_listener.dart'; + part 'chat_bloc.freezed.dart'; const sendMessageErrorKey = "sendMessageError"; 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 736a379637..39804c6851 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 @@ -1,9 +1,9 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_cell_editor.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_progress_bar.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -62,23 +62,24 @@ class _ChecklistItemsState extends State { if (showIncompleteOnly) { tasks.removeWhere((task) => task.isSelected); } - final children = tasks - .mapIndexed( - (index, task) => Padding( - padding: const EdgeInsets.symmetric(vertical: 2.0), - child: ChecklistItem( - key: ValueKey(task.data.id), - task: task, - autofocus: widget.state.newTask && index == tasks.length - 1, - onSubmitted: () { - if (index == tasks.length - 1) { - widget.bloc.add(const ChecklistCellEvent.createNewTask("")); - } - }, - ), - ), - ) - .toList(); + // final children = tasks + // .mapIndexed( + // (index, task) => Padding( + // padding: const EdgeInsets.symmetric(vertical: 2.0), + // child: ChecklistItem( + // key: ValueKey('${task.data.id}$index'), + // task: task, + // autofocus: widget.state.newTask && index == tasks.length - 1, + // onSubmitted: () { + // if (index == tasks.length - 1) { + // // create a new task under the last task if the users press enter + // widget.bloc.add(const ChecklistCellEvent.createNewTask('')); + // } + // }, + // ), + // ), + // ) + // .toList(); return Align( alignment: AlignmentDirectional.centerStart, child: Column( @@ -116,7 +117,7 @@ class _ChecklistItemsState extends State { ), ), const VSpace(2.0), - ...children, + _ChecklistCellEditors(tasks: tasks), ChecklistItemControl(cellNotifer: widget.cellContainerNotifier), ], ), @@ -124,6 +125,41 @@ class _ChecklistItemsState extends State { } } +class _ChecklistCellEditors extends StatelessWidget { + const _ChecklistCellEditors({ + required this.tasks, + }); + + final List tasks; + + @override + Widget build(BuildContext context) { + final bloc = context.read(); + final state = bloc.state; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...tasks.mapIndexed( + (index, task) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: ChecklistItem( + key: ValueKey('${task.data.id}$index'), + task: task, + autofocus: state.newTask && index == tasks.length - 1, + onSubmitted: () { + if (index == tasks.length - 1) { + // create a new task under the last task if the users press enter + bloc.add(const ChecklistCellEvent.createNewTask('')); + } + }, + ), + ), + ), + ], + ); + } +} + class ChecklistItemControl extends StatelessWidget { const ChecklistItemControl({super.key, required this.cellNotifer}); 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 324963ad0d..ba2dc28702 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,23 +1,21 @@ -import 'dart:async'; import 'dart:io'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.dart'; +import 'package:appflowy/util/debounce.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../application/cell/bloc/checklist_cell_bloc.dart'; - +import 'checklist_cell_textfield.dart'; import 'checklist_progress_bar.dart'; class ChecklistCellEditor extends StatefulWidget { @@ -89,7 +87,7 @@ class _ChecklistCellEditorState extends State { } } -/// Displays the a list of all the exisiting tasks and an input field to create +/// Displays the a list of all the existing tasks and an input field to create /// a new task if `isAddingNewTask` is true class ChecklistItemList extends StatelessWidget { const ChecklistItemList({ @@ -159,167 +157,136 @@ class ChecklistItem extends StatefulWidget { } class _ChecklistItemState extends State { - late final TextEditingController _textController; - final FocusNode _focusNode = FocusNode(skipTraversal: true); - final FocusNode _textFieldFocusNode = FocusNode(); + TextEditingController textController = TextEditingController(); + final textFieldFocusNode = FocusNode(); + final focusNode = FocusNode(skipTraversal: true); - bool _isHovered = false; - bool _isFocused = false; - Timer? _debounceOnChanged; + bool isHovered = false; + bool isFocused = false; + + final _debounceOnChanged = Debounce( + duration: const Duration(milliseconds: 300), + ); + + final selectTaskShortcut = { + SingleActivator( + LogicalKeyboardKey.enter, + meta: Platform.isMacOS, + control: !Platform.isMacOS, + ): const _SelectTaskIntent(), + const SingleActivator(LogicalKeyboardKey.escape): + const _EndEditingTaskIntent(), + }; @override void initState() { super.initState(); - _textController = TextEditingController(text: widget.task.data.name); - } - - @override - void dispose() { - _debounceOnChanged?.cancel(); - _textController.dispose(); - _focusNode.dispose(); - _textFieldFocusNode.dispose(); - super.dispose(); - } - - @override - void didUpdateWidget(ChecklistItem oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.task.data.name != oldWidget.task.data.name) { - final selection = _textController.selection; - // Ensure the selection offset is within the new text bounds - int offset = selection.start; - if (offset > widget.task.data.name.length) { - offset = widget.task.data.name.length; - } - _textController.selection = TextSelection.collapsed(offset: offset); + textController.text = widget.task.data.name; + if (widget.autofocus) { + WidgetsBinding.instance.addPostFrameCallback((_) { + focusNode.requestFocus(); + textFieldFocusNode.requestFocus(); + }); } } + @override + void dispose() { + _debounceOnChanged.dispose(); + + textController.dispose(); + focusNode.dispose(); + textFieldFocusNode.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + final isFocusedOrHovered = + isHovered || isFocused || textFieldFocusNode.hasFocus; + final color = isFocusedOrHovered + ? AFThemeExtension.of(context).lightGreyHover + : Colors.transparent; return FocusableActionDetector( - focusNode: _focusNode, - onShowHoverHighlight: (isHovered) { - setState(() => _isHovered = isHovered); - }, - onFocusChange: (isFocused) { - setState(() => _isFocused = isFocused); - }, - actions: { - _SelectTaskIntent: CallbackAction<_SelectTaskIntent>( - onInvoke: (_SelectTaskIntent intent) { - // Log.debug("checklist widget on enter"); - context - .read() - .add(ChecklistCellEvent.selectTask(widget.task.data.id)); - return; - }, - ), - _EndEditingTaskIntent: CallbackAction<_EndEditingTaskIntent>( - onInvoke: (_EndEditingTaskIntent intent) { - // Log.debug("checklist widget on escape"); - _textFieldFocusNode.unfocus(); - return; - }, - ), - }, - shortcuts: { - SingleActivator( - LogicalKeyboardKey.enter, - meta: Platform.isMacOS, - control: !Platform.isMacOS, - ): const _SelectTaskIntent(), - }, + focusNode: focusNode, + onShowHoverHighlight: (value) => setState(() { + isHovered = value; + }), + onFocusChange: (value) => setState(() { + isFocused = value; + }), + actions: _buildActions(), + shortcuts: selectTaskShortcut, child: Container( - constraints: BoxConstraints(minHeight: GridSize.popoverItemHeight), + constraints: BoxConstraints( + minHeight: GridSize.popoverItemHeight, + ), decoration: BoxDecoration( - color: _isHovered || _isFocused || _textFieldFocusNode.hasFocus - ? AFThemeExtension.of(context).lightGreyHover - : Colors.transparent, + color: color, borderRadius: Corners.s6Border, ), - child: Row( - children: [ - ExcludeFocus( - child: FlowyIconButton( - width: 32, - 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.id), - ), - ), - ), - Expanded( - child: Shortcuts( - shortcuts: const { - SingleActivator(LogicalKeyboardKey.escape): - _EndEditingTaskIntent(), - }, - child: Builder( - builder: (context) { - return TextField( - controller: _textController, - focusNode: _textFieldFocusNode, - 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(), - ), - textInputAction: widget.onSubmitted == null - ? TextInputAction.next - : null, - onChanged: (text) { - if (_textController.value.composing.isCollapsed) { - _debounceOnChangedText(text); - } - }, - onSubmitted: (description) { - if (widget.onSubmitted != null) { - // Log.debug("checklist widget on submitted"); - widget.onSubmitted?.call(); - } else { - // Log.debug("checklist widget Focus next task"); - Actions.invoke(context, const NextFocusIntent()); - } - _submitUpdateTaskDescription(description); - }, - ); - }, - ), - ), - ), - if (_isHovered || _isFocused || _textFieldFocusNode.hasFocus) - _DeleteTaskButton( - onPressed: () => context.read().add( - ChecklistCellEvent.deleteTask(widget.task.data.id), - ), - ), - ], + child: _buildChild( + context, + isFocusedOrHovered, ), ), ); } - void _debounceOnChangedText(String text) { - _debounceOnChanged?.cancel(); - _debounceOnChanged = Timer(const Duration(milliseconds: 300), () { - _submitUpdateTaskDescription(text); - }); + Widget _buildChild(BuildContext context, bool isFocusedOrHovered) { + return Row( + children: [ + ChecklistCellCheckIcon(task: widget.task), + Expanded( + child: ChecklistCellTextfield( + textController: textController, + focusNode: textFieldFocusNode, + autofocus: widget.autofocus, + onChanged: () { + _debounceOnChanged.call(() { + if (textController.selection.isCollapsed) { + _submitUpdateTaskDescription(textController.text); + } + }); + }, + onSubmitted: () { + _submitUpdateTaskDescription(textController.text); + + if (widget.onSubmitted != null) { + widget.onSubmitted?.call(); + } else { + Actions.invoke(context, const NextFocusIntent()); + } + }, + ), + ), + if (isFocusedOrHovered) + ChecklistCellDeleteButton( + onPressed: () => context.read().add( + ChecklistCellEvent.deleteTask(widget.task.data.id), + ), + ), + ], + ); + } + + Map> _buildActions() { + return { + _SelectTaskIntent: CallbackAction<_SelectTaskIntent>( + onInvoke: (_SelectTaskIntent intent) { + context + .read() + .add(ChecklistCellEvent.selectTask(widget.task.data.id)); + return; + }, + ), + _EndEditingTaskIntent: CallbackAction<_EndEditingTaskIntent>( + onInvoke: (_EndEditingTaskIntent intent) { + textFieldFocusNode.unfocus(); + return; + }, + ), + }; } void _submitUpdateTaskDescription(String description) { @@ -423,55 +390,3 @@ class _NewTaskItemState extends State { ); } } - -class _DeleteTaskButton extends StatefulWidget { - const _DeleteTaskButton({ - required this.onPressed, - }); - - final VoidCallback onPressed; - - @override - State<_DeleteTaskButton> createState() => _DeleteTaskButtonState(); -} - -class _DeleteTaskButtonState extends State<_DeleteTaskButton> { - final _materialStatesController = WidgetStatesController(); - - @override - void dispose() { - _materialStatesController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return TextButton( - onPressed: widget.onPressed, - onHover: (_) => setState(() {}), - onFocusChange: (_) => setState(() {}), - style: ButtonStyle( - fixedSize: const WidgetStatePropertyAll(Size.square(32)), - minimumSize: const WidgetStatePropertyAll(Size.square(32)), - maximumSize: const WidgetStatePropertyAll(Size.square(32)), - overlayColor: WidgetStateProperty.resolveWith((state) { - if (state.contains(WidgetState.focused)) { - return AFThemeExtension.of(context).greyHover; - } - return Colors.transparent; - }), - shape: const WidgetStatePropertyAll( - RoundedRectangleBorder(borderRadius: Corners.s6Border), - ), - ), - statesController: _materialStatesController, - child: FlowySvg( - FlowySvgs.delete_s, - color: _materialStatesController.value.contains(WidgetState.hovered) || - _materialStatesController.value.contains(WidgetState.focused) - ? Theme.of(context).colorScheme.error - : null, - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_textfield.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_textfield.dart new file mode 100644 index 0000000000..abd519f31d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_textfield.dart @@ -0,0 +1,129 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../application/cell/bloc/checklist_cell_bloc.dart'; + +class ChecklistCellCheckIcon extends StatelessWidget { + const ChecklistCellCheckIcon({ + super.key, + required this.task, + }); + + final ChecklistSelectOption task; + + @override + Widget build(BuildContext context) { + return ExcludeFocus( + child: FlowyIconButton( + width: 32, + icon: FlowySvg( + task.isSelected ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, + blendMode: BlendMode.dst, + ), + hoverColor: Colors.transparent, + onPressed: () => context.read().add( + ChecklistCellEvent.selectTask(task.data.id), + ), + ), + ); + } +} + +class ChecklistCellTextfield extends StatelessWidget { + const ChecklistCellTextfield({ + super.key, + required this.textController, + required this.focusNode, + required this.autofocus, + required this.onChanged, + this.onSubmitted, + }); + + final TextEditingController textController; + final FocusNode focusNode; + final bool autofocus; + final VoidCallback? onSubmitted; + final VoidCallback onChanged; + + @override + Widget build(BuildContext context) { + const contentPadding = EdgeInsets.symmetric( + vertical: 6.0, + horizontal: 2.0, + ); + return TextField( + controller: textController, + focusNode: focusNode, + style: Theme.of(context).textTheme.bodyMedium, + decoration: InputDecoration( + border: InputBorder.none, + isCollapsed: true, + contentPadding: contentPadding, + hintText: LocaleKeys.grid_checklist_taskHint.tr(), + ), + textInputAction: onSubmitted == null ? TextInputAction.next : null, + onChanged: (_) => onChanged(), + onSubmitted: (_) => onSubmitted?.call(), + ); + } +} + +class ChecklistCellDeleteButton extends StatefulWidget { + const ChecklistCellDeleteButton({ + super.key, + required this.onPressed, + }); + + final VoidCallback onPressed; + + @override + State createState() => + _ChecklistCellDeleteButtonState(); +} + +class _ChecklistCellDeleteButtonState extends State { + final _materialStatesController = WidgetStatesController(); + + @override + void dispose() { + _materialStatesController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TextButton( + onPressed: widget.onPressed, + onHover: (_) => setState(() {}), + onFocusChange: (_) => setState(() {}), + style: ButtonStyle( + fixedSize: const WidgetStatePropertyAll(Size.square(32)), + minimumSize: const WidgetStatePropertyAll(Size.square(32)), + maximumSize: const WidgetStatePropertyAll(Size.square(32)), + overlayColor: WidgetStateProperty.resolveWith((state) { + if (state.contains(WidgetState.focused)) { + return AFThemeExtension.of(context).greyHover; + } + return Colors.transparent; + }), + shape: const WidgetStatePropertyAll( + RoundedRectangleBorder(borderRadius: Corners.s6Border), + ), + ), + statesController: _materialStatesController, + child: FlowySvg( + FlowySvgs.delete_s, + color: _materialStatesController.value.contains(WidgetState.hovered) || + _materialStatesController.value.contains(WidgetState.focused) + ? Theme.of(context).colorScheme.error + : null, + ), + ); + } +}