Skip to content

Commit f816e52

Browse files
committed
feat(poll): add options range and auto focus for new poll options
This commit introduces the following enhancements to poll creation: - **Options Range:** A `optionsRange` parameter has been added to `PollOptionReorderableListView` and integrated into `StreamPollCreatorWidget`. This allows developers to specify a minimum and maximum number of options for a poll. - If `optionsRange.min` is set, the list will automatically populate with empty option fields to meet the minimum requirement on initialization. - If `optionsRange.max` is set, the "Add option" button will be disabled once the maximum number of options is reached. - **Auto Focus on New Options:** When a new poll option is added, the text field for that option will automatically receive focus, improving the user experience. - **Prevent Leading Spaces in Options:** `StreamPollTextField` now uses a `FilteringTextInputFormatter` to prevent users from entering leading whitespace in poll options. These changes provide more control over poll creation and enhance usability.
1 parent 26bd2ee commit f816e52

File tree

4 files changed

+101
-7
lines changed

4 files changed

+101
-7
lines changed

packages/stream_chat_flutter/lib/src/misc/separated_reorderable_list_view.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ class SeparatedReorderableListView extends ReorderableListView {
1313
required IndexedWidgetBuilder separatorBuilder,
1414
required int itemCount,
1515
required ReorderCallback onReorder,
16+
super.onReorderStart,
17+
super.onReorderEnd,
1618
super.itemExtent,
1719
super.prototypeItem,
1820
super.proxyDecorator,

packages/stream_chat_flutter/lib/src/poll/creator/poll_option_reorderable_list_view.dart

Lines changed: 97 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ class PollOptionListItem extends StatelessWidget {
5656
super.key,
5757
required this.option,
5858
this.hintText,
59+
this.focusNode,
5960
this.onChanged,
6061
});
6162

@@ -65,6 +66,9 @@ class PollOptionListItem extends StatelessWidget {
6566
/// Hint to be displayed in the poll option list item.
6667
final String? hintText;
6768

69+
/// The focus node for the text field.
70+
final FocusNode? focusNode;
71+
6872
/// Callback called when the poll option item is changed.
6973
final ValueSetter<PollOptionItem>? onChanged;
7074

@@ -90,6 +94,7 @@ class PollOptionListItem extends StatelessWidget {
9094
borderRadius: borderRadius,
9195
errorText: option.error,
9296
errorStyle: theme.optionsTextFieldErrorStyle,
97+
focusNode: focusNode,
9398
onChanged: (text) => onChanged?.call(option.copyWith(text: text)),
9499
),
95100
),
@@ -115,6 +120,7 @@ class PollOptionReorderableListView extends StatefulWidget {
115120
this.itemHintText,
116121
this.allowDuplicate = false,
117122
this.initialOptions = const [],
123+
this.optionsRange,
118124
this.onOptionsChanged,
119125
});
120126

@@ -132,6 +138,12 @@ class PollOptionReorderableListView extends StatefulWidget {
132138
/// The initial list of poll options.
133139
final List<PollOptionItem> initialOptions;
134140

141+
/// The range of allowed options (min and max).
142+
///
143+
/// If `null`, there are no limits. If only min or max is specified,
144+
/// the other bound is unlimited.
145+
final Range<int>? optionsRange;
146+
135147
/// Callback called when the items are updated or reordered.
136148
final ValueSetter<List<PollOptionItem>>? onOptionsChanged;
137149

@@ -142,9 +154,59 @@ class PollOptionReorderableListView extends StatefulWidget {
142154

143155
class _PollOptionReorderableListViewState
144156
extends State<PollOptionReorderableListView> {
145-
late var _options = <String, PollOptionItem>{
146-
for (final option in widget.initialOptions) option.id: option,
147-
};
157+
late Map<String, FocusNode> _focusNodes;
158+
late Map<String, PollOptionItem> _options;
159+
160+
@override
161+
void initState() {
162+
super.initState();
163+
_initializeOptions();
164+
}
165+
166+
@override
167+
void dispose() {
168+
_disposeOptions();
169+
super.dispose();
170+
}
171+
172+
void _initializeOptions() {
173+
_focusNodes = <String, FocusNode>{};
174+
_options = <String, PollOptionItem>{};
175+
176+
for (final option in widget.initialOptions) {
177+
_options[option.id] = option;
178+
_focusNodes[option.id] = FocusNode();
179+
}
180+
181+
// Ensure we have at least the minimum number of options
182+
_ensureMinimumOptions(notifyParent: true);
183+
}
184+
185+
void _ensureMinimumOptions({bool notifyParent = false}) {
186+
// Ensure we have at least the minimum number of options
187+
final minOptions = widget.optionsRange?.min ?? 1;
188+
189+
var optionsAdded = false;
190+
while (_options.length < minOptions) {
191+
final option = PollOptionItem();
192+
_options[option.id] = option;
193+
_focusNodes[option.id] = FocusNode();
194+
optionsAdded = true;
195+
}
196+
197+
// Notify parent if we added options and it's requested
198+
if (optionsAdded && notifyParent) {
199+
WidgetsBinding.instance.addPostFrameCallback((_) {
200+
widget.onOptionsChanged?.call([..._options.values]);
201+
});
202+
}
203+
}
204+
205+
void _disposeOptions() {
206+
_focusNodes.values.forEach((it) => it.dispose());
207+
_focusNodes.clear();
208+
_options.clear();
209+
}
148210

149211
@override
150212
void didUpdateWidget(covariant PollOptionReorderableListView oldWidget) {
@@ -159,9 +221,8 @@ class _PollOptionReorderableListViewState
159221
);
160222

161223
if (optionItemEquality.equals(currOptions, newOptions) case false) {
162-
_options = <String, PollOptionItem>{
163-
for (final option in widget.initialOptions) option.id: option,
164-
};
224+
_disposeOptions();
225+
_initializeOptions();
165226
}
166227
}
167228

@@ -241,14 +302,41 @@ class _PollOptionReorderableListViewState
241302
}
242303

243304
void _onAddOptionPressed() {
305+
// Check if we've reached the maximum number of options allowed
306+
if (widget.optionsRange?.max case final maxOptions?) {
307+
// Don't add more options if we've reached the limit
308+
if (_options.length >= maxOptions) return;
309+
}
310+
244311
setState(() {
245312
// Create a new option and add it to the map.
246313
final option = PollOptionItem();
247314
_options[option.id] = option;
248315

316+
// Create focus node for the new option
317+
_focusNodes[option.id] = FocusNode();
318+
249319
// Notify the parent widget about the change
250320
widget.onOptionsChanged?.call([..._options.values]);
251321
});
322+
323+
// Focus on the newly created option after the widget rebuilds
324+
325+
final newOption = _options.values.last;
326+
_focusNodes[newOption.id]?.requestFocus();
327+
}
328+
329+
bool get _canAddMoreOptions {
330+
// Don't allow adding if there's already an empty option
331+
final hasEmptyOption = _options.values.any((it) => it.text.isEmpty);
332+
if (hasEmptyOption) return false;
333+
334+
// Check max options limit
335+
if (widget.optionsRange?.max case final maxOptions?) {
336+
return _options.length < maxOptions;
337+
}
338+
339+
return true;
252340
}
253341

254342
@override
@@ -271,13 +359,15 @@ class _PollOptionReorderableListViewState
271359
physics: const NeverScrollableScrollPhysics(),
272360
proxyDecorator: _proxyDecorator,
273361
separatorBuilder: (_, __) => const SizedBox(height: 8),
362+
onReorderStart: (_) => FocusScope.of(context).unfocus(),
274363
onReorder: _onOptionReorder,
275364
itemBuilder: (context, index) {
276365
final option = _options.values.elementAt(index);
277366
return PollOptionListItem(
278367
key: Key(option.id),
279368
option: option,
280369
hintText: widget.itemHintText,
370+
focusNode: _focusNodes[option.id],
281371
onChanged: _onOptionChanged,
282372
);
283373
},
@@ -287,7 +377,7 @@ class _PollOptionReorderableListViewState
287377
SizedBox(
288378
width: double.infinity,
289379
child: FilledButton.tonal(
290-
onPressed: _onAddOptionPressed,
380+
onPressed: _canAddMoreOptions ? _onAddOptionPressed : null,
291381
style: TextButton.styleFrom(
292382
alignment: Alignment.centerLeft,
293383
textStyle: theme.optionsTextFieldStyle,

packages/stream_chat_flutter/lib/src/poll/creator/stream_poll_creator_widget.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ class StreamPollCreatorWidget extends StatelessWidget {
6565
title: translations.optionLabel(isPlural: true),
6666
itemHintText: translations.optionLabel(),
6767
allowDuplicate: config.allowDuplicateOptions,
68+
optionsRange: config.optionsRange,
6869
initialOptions: [
6970
for (final option in poll.options)
7071
PollOptionItem(id: option.id, text: option.text),

packages/stream_chat_flutter/lib/src/poll/stream_poll_text_field.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ class _StreamPollTextFieldState extends State<StreamPollTextField> {
141141
style: widget.style ?? theme.textTheme.headline,
142142
keyboardType: widget.keyboardType,
143143
autofocus: widget.autoFocus,
144+
inputFormatters: [FilteringTextInputFormatter.deny(RegExp(r'^\s'))],
144145
decoration: InputDecoration(
145146
filled: true,
146147
isCollapsed: true,

0 commit comments

Comments
 (0)