fix: cursor jump randomly in check list item (#5565)

* chore: remove debug logs

* fix: cursor jump ramdomly in checklist item
This commit is contained in:
Lucas.Xu 2024-06-19 09:33:27 +08:00 committed by GitHub
parent ed82ec8eef
commit 75cea400d2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 301 additions and 219 deletions

View File

@ -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";

View File

@ -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<ChecklistItems> {
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<ChecklistItems> {
),
),
const VSpace(2.0),
...children,
_ChecklistCellEditors(tasks: tasks),
ChecklistItemControl(cellNotifer: widget.cellContainerNotifier),
],
),
@ -124,6 +125,41 @@ class _ChecklistItemsState extends State<ChecklistItems> {
}
}
class _ChecklistCellEditors extends StatelessWidget {
const _ChecklistCellEditors({
required this.tasks,
});
final List<ChecklistSelectOption> tasks;
@override
Widget build(BuildContext context) {
final bloc = context.read<ChecklistCellBloc>();
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});

View File

@ -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<ChecklistCellEditor> {
}
}
/// 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<ChecklistItem> {
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<ChecklistCellBloc>()
.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<ChecklistCellBloc>().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<ChecklistCellBloc>().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<ChecklistCellBloc>().add(
ChecklistCellEvent.deleteTask(widget.task.data.id),
),
),
],
);
}
Map<Type, Action<Intent>> _buildActions() {
return {
_SelectTaskIntent: CallbackAction<_SelectTaskIntent>(
onInvoke: (_SelectTaskIntent intent) {
context
.read<ChecklistCellBloc>()
.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<NewTaskItem> {
);
}
}
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,
),
);
}
}

View File

@ -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<ChecklistCellBloc>().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<ChecklistCellDeleteButton> createState() =>
_ChecklistCellDeleteButtonState();
}
class _ChecklistCellDeleteButtonState extends State<ChecklistCellDeleteButton> {
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,
),
);
}
}