Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/stream_chat_flutter/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ class PollOptionListItem extends StatelessWidget {
this.showControls = true,
this.onRemove,
this.onChanged,
this.onSubmitted,
}) : onAddOptionPressed = null,
_variant = .option;

Expand All @@ -75,6 +76,7 @@ class PollOptionListItem extends StatelessWidget {
showControls = false,
onRemove = null,
onChanged = null,
onSubmitted = null,
onAddOptionPressed = onPressed,
_variant = .addOption;

Expand All @@ -100,6 +102,9 @@ class PollOptionListItem extends StatelessWidget {
/// Callback called when the poll option item is changed.
final ValueSetter<PollOptionItem>? onChanged;

/// Callback called when this option is submitted from the keyboard.
final ValueSetter<PollOptionItem>? onSubmitted;

/// Callback called when the "add an option" button is pressed.
///
/// If `null`, the button is disabled.
Expand All @@ -125,6 +130,11 @@ class PollOptionListItem extends StatelessWidget {
hintText: hintText,
focusNode: focusNode,
textCapitalization: TextCapitalization.sentences,
textInputAction: TextInputAction.next,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
onEditingComplete: switch (onSubmitted) {
final onSubmitted? => () => onSubmitted.call(option!),
_ => null,
},
helperText: option!.error,
helperState: option!.error != null ? StreamHelperState.error : null,
leading: showControls
Expand Down Expand Up @@ -374,8 +384,19 @@ class _PollOptionReorderableListViewState extends State<PollOptionReorderableLis
}

Future<void> _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) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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);
Expand Down Expand Up @@ -450,6 +471,18 @@ class _PollOptionReorderableListViewState extends State<PollOptionReorderableLis
});
}

void _onOptionSubmitted(PollOptionItem option) {
final ids = [..._options.keys];
final index = ids.indexOf(option.id);

if (ids.elementAtOrNull(index + 1) case final nextId?) {
return _focusNodes[nextId]?.requestFocus();
}

if (_canAddMoreOptions) return _onAddOptionPressed();
_focusNodes[option.id]?.unfocus();
}

bool get _canAddMoreOptions {
// Don't allow adding if there's already an empty option
final hasEmptyOption = _options.values.any((it) => it.text.isEmpty);
Expand Down Expand Up @@ -504,6 +537,7 @@ class _PollOptionReorderableListViewState extends State<PollOptionReorderableLis
showControls: _options.length > 1,
onRemove: _onOptionRemoved,
onChanged: _onOptionChanged,
onSubmitted: _onOptionSubmitted,
);
},
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <PollOptionItem>[];

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<TextField>(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<TextField>(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<TextField>(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');
Expand Down
Loading