diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 7631953f6b..9ca5667717 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -1,5 +1,13 @@ ## Upcoming +✅ Added + +- Added a next action to the poll creator's option fields, which moves to the next option or adds a new one from the last. + +🔄 Changed + +- Removing an empty poll option no longer asks for confirmation. + 🐞 Fixed - Fixed a voice recording sent as a reply without text showing inside a filled container instead of directly on the message bubble. diff --git a/packages/stream_chat_flutter/lib/src/poll/creator/poll_option_reorderable_list_view.dart b/packages/stream_chat_flutter/lib/src/poll/creator/poll_option_reorderable_list_view.dart index 4d3d0da9c1..3ceaabb58c 100644 --- a/packages/stream_chat_flutter/lib/src/poll/creator/poll_option_reorderable_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/poll/creator/poll_option_reorderable_list_view.dart @@ -62,6 +62,7 @@ class PollOptionListItem extends StatelessWidget { this.showControls = true, this.onRemove, this.onChanged, + this.onSubmitted, }) : onAddOptionPressed = null, _variant = .option; @@ -75,6 +76,7 @@ class PollOptionListItem extends StatelessWidget { showControls = false, onRemove = null, onChanged = null, + onSubmitted = null, onAddOptionPressed = onPressed, _variant = .addOption; @@ -100,6 +102,9 @@ class PollOptionListItem extends StatelessWidget { /// Callback called when the poll option item is changed. final ValueSetter? onChanged; + /// Callback called when this option is submitted from the keyboard. + final ValueSetter? onSubmitted; + /// Callback called when the "add an option" button is pressed. /// /// If `null`, the button is disabled. @@ -125,6 +130,11 @@ class PollOptionListItem extends StatelessWidget { hintText: hintText, focusNode: focusNode, textCapitalization: TextCapitalization.sentences, + textInputAction: TextInputAction.next, + onEditingComplete: switch (onSubmitted) { + final onSubmitted? => () => onSubmitted.call(option!), + _ => null, + }, helperText: option!.error, helperState: option!.error != null ? StreamHelperState.error : null, leading: showControls @@ -374,8 +384,19 @@ class _PollOptionReorderableListViewState extends State _onOptionRemoved(PollOptionItem option) async { - final confirm = await showPollDeleteOptionDialog(context: context); - if (!mounted || confirm != true) return; + final wasFocused = _focusNodes[option.id]?.hasFocus ?? false; + if (option.text.isNotEmpty) { + final confirm = await showPollDeleteOptionDialog(context: context); + if (!mounted || confirm != true) return; + } + + // Keeps the keyboard up when the focused option is removed. + if (wasFocused) { + final ids = [..._options.keys]; + final index = ids.indexOf(option.id); + final neighbor = index > 0 ? ids[index - 1] : ids.elementAtOrNull(index + 1); + if (neighbor != null) _focusNodes[neighbor]?.requestFocus(); + } setState(() { _options.remove(option.id); @@ -450,6 +471,18 @@ class _PollOptionReorderableListViewState extends State it.text.isEmpty); @@ -504,6 +537,7 @@ class _PollOptionReorderableListViewState extends State 1, onRemove: _onOptionRemoved, onChanged: _onOptionChanged, + onSubmitted: _onOptionSubmitted, ); }, ), diff --git a/packages/stream_chat_flutter/test/src/poll/creator/poll_option_reorderable_list_view_test.dart b/packages/stream_chat_flutter/test/src/poll/creator/poll_option_reorderable_list_view_test.dart index 92d8703990..3cbc078c87 100644 --- a/packages/stream_chat_flutter/test/src/poll/creator/poll_option_reorderable_list_view_test.dart +++ b/packages/stream_chat_flutter/test/src/poll/creator/poll_option_reorderable_list_view_test.dart @@ -590,6 +590,132 @@ void main() { expect(field.focusNode?.hasFocus, isTrue); }); + group('when the removed option is empty', () { + testWidgets('removes it without asking for confirmation', (tester) async { + var optionsChanged = []; + + await tester.pumpWidget( + _wrapWithMaterialApp( + PollOptionReorderableListView( + initialOptions: [ + PollOptionItem(text: 'Option 1'), + PollOptionItem(), + ], + onOptionsChanged: (options) => optionsChanged = options, + ), + ), + ); + + await tester.tap(find.byIcon(StreamIconData.minusCircle).last); + await tester.pumpAndSettle(); + + expect(find.text('Delete Option'), findsNothing); + expect(optionsChanged.map((it) => it.text), ['Option 1']); + }); + + testWidgets('moves focus to the previous option when it was focused', (tester) async { + await tester.pumpWidget( + _wrapWithMaterialApp( + PollOptionReorderableListView( + initialOptions: [ + PollOptionItem(text: 'Option 1'), + PollOptionItem(), + ], + ), + ), + ); + + await tester.showKeyboard(find.byType(TextField).last); + await tester.tap(find.byIcon(StreamIconData.minusCircle).last); + await tester.pumpAndSettle(); + tester.testTextInput.enterText('Option 1 edited'); + await tester.pump(); + + expect(find.text('Option 1 edited'), findsOneWidget); + }); + }); + + testWidgets('keeps typing in the previous option after removing a focused option with text', (tester) async { + await tester.pumpWidget( + _wrapWithMaterialApp( + PollOptionReorderableListView( + initialOptions: [ + PollOptionItem(text: 'Option 1'), + PollOptionItem(text: 'Option 2'), + PollOptionItem(text: 'Option 3'), + ], + ), + ), + ); + + await tester.showKeyboard(find.byType(TextField).last); + await _removeOption(tester, find.byIcon(StreamIconData.minusCircle).last); + tester.testTextInput.enterText('Option 2 edited'); + await tester.pump(); + + expect(find.text('Option 1'), findsOneWidget); + expect(find.text('Option 2 edited'), findsOneWidget); + }); + + testWidgets('moves focus to the next option on the keyboard next action', (tester) async { + await tester.pumpWidget( + _wrapWithMaterialApp( + PollOptionReorderableListView( + initialOptions: [ + PollOptionItem(text: 'Option 1'), + PollOptionItem(text: 'Option 2'), + ], + ), + ), + ); + + await tester.showKeyboard(find.byType(TextField).first); + await tester.testTextInput.receiveAction(TextInputAction.next); + await tester.pumpAndSettle(); + + final fields = tester.widgetList(find.byType(TextField)); + expect(fields.last.focusNode?.hasFocus, isTrue); + }); + + testWidgets('adds and focuses a new option on the keyboard next action from the last one', (tester) async { + await tester.pumpWidget( + _wrapWithMaterialApp( + PollOptionReorderableListView( + initialOptions: [PollOptionItem(text: 'Option 1')], + ), + ), + ); + + await tester.showKeyboard(find.byType(TextField)); + await tester.testTextInput.receiveAction(TextInputAction.next); + await tester.pumpAndSettle(); + + final fields = tester.widgetList(find.byType(TextField)); + expect(fields, hasLength(2)); + expect(fields.last.focusNode?.hasFocus, isTrue); + }); + + testWidgets('closes the keyboard on the next action when no option can be added', (tester) async { + await tester.pumpWidget( + _wrapWithMaterialApp( + PollOptionReorderableListView( + optionsRange: (min: null, max: 2), + initialOptions: [ + PollOptionItem(text: 'Option 1'), + PollOptionItem(text: 'Option 2'), + ], + ), + ), + ); + + await tester.showKeyboard(find.byType(TextField).last); + await tester.testTextInput.receiveAction(TextInputAction.next); + await tester.pumpAndSettle(); + + final fields = tester.widgetList(find.byType(TextField)); + expect(fields.any((it) => it.focusNode?.hasFocus ?? false), isFalse); + }); + group('Semantics / reorder', () { const moveToStart = CustomSemanticsAction(label: 'Move to the start'); const moveToEnd = CustomSemanticsAction(label: 'Move to the end');