fix: composing in select option text field doesn't work (#4470)

* fix: composing in select option text field doesn't work

* style: improve code style
This commit is contained in:
Richard Shiue
2024-01-25 13:17:13 +08:00
committed by GitHub
parent 3f896ad64f
commit e88fb533c8
6 changed files with 123 additions and 134 deletions

View File

@ -11,7 +11,6 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:textfield_tags/textfield_tags.dart';
import '../../../../grid/presentation/layout/sizes.dart'; import '../../../../grid/presentation/layout/sizes.dart';
import '../../../../grid/presentation/widgets/common/type_option_separator.dart'; import '../../../../grid/presentation/widgets/common/type_option_separator.dart';
@ -32,13 +31,12 @@ class SelectOptionCellEditor extends StatefulWidget {
} }
class _SelectOptionCellEditorState extends State<SelectOptionCellEditor> { class _SelectOptionCellEditorState extends State<SelectOptionCellEditor> {
final TextEditingController textEditingController = TextEditingController();
final popoverMutex = PopoverMutex(); final popoverMutex = PopoverMutex();
final tagController = TextfieldTagsController();
@override @override
void dispose() { void dispose() {
popoverMutex.dispose(); popoverMutex.dispose();
tagController.dispose();
super.dispose(); super.dispose();
} }
@ -54,14 +52,14 @@ class _SelectOptionCellEditorState extends State<SelectOptionCellEditor> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_TextField( _TextField(
textEditingController: textEditingController,
popoverMutex: popoverMutex, popoverMutex: popoverMutex,
tagController: tagController,
), ),
const TypeOptionSeparator(spacing: 0.0), const TypeOptionSeparator(spacing: 0.0),
Flexible( Flexible(
child: _OptionList( child: _OptionList(
textEditingController: textEditingController,
popoverMutex: popoverMutex, popoverMutex: popoverMutex,
tagController: tagController,
), ),
), ),
], ],
@ -73,12 +71,12 @@ class _SelectOptionCellEditorState extends State<SelectOptionCellEditor> {
} }
class _OptionList extends StatelessWidget { class _OptionList extends StatelessWidget {
final TextEditingController textEditingController;
final PopoverMutex popoverMutex; final PopoverMutex popoverMutex;
final TextfieldTagsController tagController;
const _OptionList({ const _OptionList({
required this.textEditingController,
required this.popoverMutex, required this.popoverMutex,
required this.tagController,
}); });
@override @override
@ -117,23 +115,23 @@ class _OptionList extends StatelessWidget {
} }
void onPressedAddButton(BuildContext context) { void onPressedAddButton(BuildContext context) {
final text = tagController.textEditingController?.text; final text = textEditingController.text;
if (text != null) { if (text.isNotEmpty) {
context.read<SelectOptionCellEditorBloc>().add( context
SelectOptionEditorEvent.trySelectOption(text), .read<SelectOptionCellEditorBloc>()
); .add(SelectOptionEditorEvent.trySelectOption(text));
} }
tagController.textEditingController?.clear(); textEditingController.clear();
} }
} }
class _TextField extends StatelessWidget { class _TextField extends StatelessWidget {
final TextEditingController textEditingController;
final PopoverMutex popoverMutex; final PopoverMutex popoverMutex;
final TextfieldTagsController tagController;
const _TextField({ const _TextField({
required this.textEditingController,
required this.popoverMutex, required this.popoverMutex,
required this.tagController,
}); });
@override @override
@ -152,7 +150,7 @@ class _TextField extends StatelessWidget {
options: state.options, options: state.options,
selectedOptionMap: optionMap, selectedOptionMap: optionMap,
distanceToText: _editorPanelWidth * 0.7, distanceToText: _editorPanelWidth * 0.7,
tagController: tagController, textController: textEditingController,
textSeparators: const [','], textSeparators: const [','],
onClick: () => popoverMutex.close(), onClick: () => popoverMutex.close(),
newText: (text) { newText: (text) {

View File

@ -6,17 +6,16 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:textfield_tags/textfield_tags.dart'; import 'package:flutter/services.dart';
import 'extension.dart'; import 'extension.dart';
class SelectOptionTextField extends StatefulWidget { class SelectOptionTextField extends StatefulWidget {
final TextfieldTagsController tagController;
final List<SelectOptionPB> options; final List<SelectOptionPB> options;
final LinkedHashMap<String, SelectOptionPB> selectedOptionMap; final LinkedHashMap<String, SelectOptionPB> selectedOptionMap;
final double distanceToText; final double distanceToText;
final List<String> textSeparators; final List<String> textSeparators;
final TextEditingController? textController; final TextEditingController textController;
final Function(String) onSubmitted; final Function(String) onSubmitted;
final Function(String) newText; final Function(String) newText;
@ -29,13 +28,12 @@ class SelectOptionTextField extends StatefulWidget {
required this.options, required this.options,
required this.selectedOptionMap, required this.selectedOptionMap,
required this.distanceToText, required this.distanceToText,
required this.tagController,
required this.onSubmitted, required this.onSubmitted,
required this.onPaste, required this.onPaste,
required this.onRemove, required this.onRemove,
required this.newText, required this.newText,
required this.textSeparators, required this.textSeparators,
this.textController, required this.textController,
this.onClick, this.onClick,
}); });
@ -44,103 +42,105 @@ class SelectOptionTextField extends StatefulWidget {
} }
class _SelectOptionTextFieldState extends State<SelectOptionTextField> { class _SelectOptionTextFieldState extends State<SelectOptionTextField> {
final FocusNode focusNode = FocusNode(); late final FocusNode focusNode;
late final TextEditingController controller;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
controller = widget.textController ?? TextEditingController(); focusNode = FocusNode(
onKeyEvent: (node, event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.escape) {
if (!widget.textController.value.composing.isCollapsed) {
final TextRange(:start, :end) =
widget.textController.value.composing;
final text = widget.textController.text;
widget.textController.value = TextEditingValue(
text: "${text.substring(0, start)}${text.substring(end)}",
selection: TextSelection(baseOffset: start, extentOffset: start),
composing: const TextRange(start: -1, end: -1),
);
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
},
);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
focusNode.requestFocus(); focusNode.requestFocus();
}); });
widget.textController.addListener(_onChanged);
}
@override
void dispose() {
widget.textController.removeListener(_onChanged);
super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return TextFieldTags( return TextField(
textEditingController: controller, controller: widget.textController,
textfieldTagsController: widget.tagController,
initialTags: widget.selectedOptionMap.keys.toList(),
focusNode: focusNode, focusNode: focusNode,
textSeparators: widget.textSeparators, onTap: widget.onClick,
inputfieldBuilder: ( onSubmitted: (text) {
BuildContext context, if (text.isNotEmpty) {
editController, widget.onSubmitted(text.trim());
focusNode, focusNode.requestFocus();
error, widget.textController.clear();
onChanged, }
onSubmitted,
) {
return ((context, sc, tags, onTagDelegate) {
return TextField(
controller: editController,
focusNode: focusNode,
onTap: widget.onClick,
onChanged: (text) {
if (onChanged != null) {
onChanged(text);
}
_newText(text, editController);
},
onSubmitted: (text) {
if (onSubmitted != null) {
onSubmitted(text);
}
if (text.isNotEmpty) {
widget.onSubmitted(text.trim());
focusNode.requestFocus();
}
},
maxLines: 1,
style: Theme.of(context).textTheme.bodyMedium,
decoration: InputDecoration(
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.outline,
width: 1.0,
),
borderRadius: Corners.s10Border,
),
isDense: true,
prefixIcon: _renderTags(context, sc),
hintText: LocaleKeys.grid_selectOption_searchOption.tr(),
hintStyle: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(color: Theme.of(context).hintColor),
prefixIconConstraints:
BoxConstraints(maxWidth: widget.distanceToText),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 1.0,
),
borderRadius: Corners.s10Border,
),
),
);
});
}, },
maxLines: 1,
style: Theme.of(context).textTheme.bodyMedium,
decoration: InputDecoration(
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.outline,
width: 1.0,
),
borderRadius: Corners.s10Border,
),
isDense: true,
prefixIcon: _renderTags(context),
hintText: LocaleKeys.grid_selectOption_searchOption.tr(),
hintStyle: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(color: Theme.of(context).hintColor),
prefixIconConstraints: BoxConstraints(maxWidth: widget.distanceToText),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 1.0,
),
borderRadius: Corners.s10Border,
),
),
); );
} }
void _newText(String text, TextEditingController editingController) { void _onChanged() {
if (text.isEmpty) { if (!widget.textController.value.composing.isCollapsed) {
widget.newText('');
return; return;
} }
final result = splitInput(text.trimLeft(), widget.textSeparators); // split input
final (submitted, remainder) = splitInput(
widget.textController.text.trimLeft(),
widget.textSeparators,
);
editingController.text = result[1]; if (submitted.isNotEmpty) {
editingController.selection = widget.textController.text = remainder;
TextSelection.collapsed(offset: controller.text.length); widget.textController.selection =
widget.onPaste(result[0], result[1]); TextSelection.collapsed(offset: widget.textController.text.length);
}
widget.onPaste(submitted, remainder);
} }
Widget? _renderTags(BuildContext context, ScrollController sc) { Widget? _renderTags(BuildContext context) {
if (widget.selectedOptionMap.isEmpty) { if (widget.selectedOptionMap.isEmpty) {
return null; return null;
} }
@ -169,7 +169,7 @@ class _SelectOptionTextFieldState extends State<SelectOptionTextField> {
}, },
), ),
child: SingleChildScrollView( child: SingleChildScrollView(
controller: sc, controller: ScrollController(),
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: Wrap(spacing: 4, children: children), child: Wrap(spacing: 4, children: children),
), ),
@ -180,7 +180,7 @@ class _SelectOptionTextFieldState extends State<SelectOptionTextField> {
} }
@visibleForTesting @visibleForTesting
List splitInput(String input, List<String> textSeparators) { (List<String>, String) splitInput(String input, List<String> textSeparators) {
final List<String> splits = []; final List<String> splits = [];
String currentString = ''; String currentString = '';
@ -201,5 +201,5 @@ List splitInput(String input, List<String> textSeparators) {
final submittedOptions = splits.sublist(0, splits.length - 1).toList(); final submittedOptions = splits.sublist(0, splits.length - 1).toList();
final remainder = splits.elementAt(splits.length - 1).trimLeft(); final remainder = splits.elementAt(splits.length - 1).trimLeft();
return [submittedOptions, remainder]; return (submittedOptions, remainder);
} }

View File

@ -1872,14 +1872,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.5.9" version: "0.5.9"
textfield_tags:
dependency: "direct main"
description:
name: textfield_tags
sha256: c1d215f481e7e8da5c79719825e595db4f829bf1ad3fce4c7ce43d340aa72683
url: "https://pub.dev"
source: hosted
version: "2.0.2"
textstyle_extensions: textstyle_extensions:
dependency: transitive dependency: transitive
description: description:

View File

@ -74,7 +74,6 @@ dependencies:
clipboard: ^0.1.3 clipboard: ^0.1.3
connectivity_plus: ^5.0.2 connectivity_plus: ^5.0.2
easy_localization: ^3.0.2 easy_localization: ^3.0.2
textfield_tags: ^2.0.2
device_info_plus: ^9.0.1 device_info_plus: ^9.0.1
fluttertoast: ^8.2.2 fluttertoast: ^8.2.2
json_annotation: ^4.8.1 json_annotation: ^4.8.1

View File

@ -6,41 +6,43 @@ void main() {
group('split input unit test', () { group('split input unit test', () {
test('empty input', () { test('empty input', () {
List result = splitInput(' ', textSeparators); var (submitted, remainder) = splitInput(' ', textSeparators);
expect(result[0], []); expect(submitted, []);
expect(result[1], ''); expect(remainder, '');
result = splitInput(', , , ', textSeparators); (submitted, remainder) = splitInput(', , , ', textSeparators);
expect(result[0], []); expect(submitted, []);
expect(result[1], ''); expect(remainder, '');
}); });
test('simple input', () { test('simple input', () {
List result = splitInput('exampleTag', textSeparators); var (submitted, remainder) = splitInput('exampleTag', textSeparators);
expect(result[0], []); expect(submitted, []);
expect(result[1], 'exampleTag'); expect(remainder, 'exampleTag');
result = splitInput('tag with longer name', textSeparators); (submitted, remainder) =
expect(result[0], []); splitInput('tag with longer name', textSeparators);
expect(result[1], 'tag with longer name'); expect(submitted, []);
expect(remainder, 'tag with longer name');
result = splitInput('trailing space ', textSeparators); (submitted, remainder) = splitInput('trailing space ', textSeparators);
expect(result[0], []); expect(submitted, []);
expect(result[1], 'trailing space '); expect(remainder, 'trailing space ');
}); });
test('input with commas', () { test('input with commas', () {
List result = splitInput('a, b, c', textSeparators); var (submitted, remainder) = splitInput('a, b, c', textSeparators);
expect(result[0], ['a', 'b']); expect(submitted, ['a', 'b']);
expect(result[1], 'c'); expect(remainder, 'c');
result = splitInput('a, b, c, ', textSeparators); (submitted, remainder) = splitInput('a, b, c, ', textSeparators);
expect(result[0], ['a', 'b', 'c']); expect(submitted, ['a', 'b', 'c']);
expect(result[1], ''); expect(remainder, '');
result = splitInput(',tag 1 ,2nd tag, third tag ', textSeparators); (submitted, remainder) =
expect(result[0], ['tag 1', '2nd tag']); splitInput(',tag 1 ,2nd tag, third tag ', textSeparators);
expect(result[1], 'third tag '); expect(submitted, ['tag 1', '2nd tag']);
expect(remainder, 'third tag ');
}); });
}); });
} }

View File

@ -4,7 +4,6 @@ import 'package:appflowy/plugins/database/widgets/row/cells/select_option_cell/t
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:textfield_tags/textfield_tags.dart';
import '../bloc_test/grid_test/util.dart'; import '../bloc_test/grid_test/util.dart';
@ -22,7 +21,6 @@ void main() {
options: const [], options: const [],
selectedOptionMap: LinkedHashMap<String, SelectOptionPB>(), selectedOptionMap: LinkedHashMap<String, SelectOptionPB>(),
distanceToText: 0.0, distanceToText: 0.0,
tagController: TextfieldTagsController(),
onSubmitted: (text) => submit = text, onSubmitted: (text) => submit = text,
onPaste: (options, remaining) { onPaste: (options, remaining) {
remainder = remaining; remainder = remaining;