Skip to content

Commit d666475

Browse files
authored
chore(ui): add options range and auto focus for new poll options (#2342)
Co-authored-by: xsahil03x <[email protected]>
1 parent 8cf2f7b commit d666475

13 files changed

+396
-13
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: 104 additions & 12 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

@@ -216,8 +277,10 @@ class _PollOptionReorderableListViewState
216277

217278
return value.copyWith(error: _validateOption(value));
218279
});
280+
});
219281

220-
// Notify the parent widget about the change
282+
// Notify the parent widget about the change
283+
WidgetsBinding.instance.addPostFrameCallback((_) {
221284
widget.onOptionsChanged?.call([..._options.values]);
222285
});
223286
}
@@ -234,23 +297,50 @@ class _PollOptionReorderableListViewState
234297
_options = <String, PollOptionItem>{
235298
for (final option in options) option.id: option,
236299
};
300+
});
237301

238-
// Notify the parent widget about the change
302+
// Notify the parent widget about the change
303+
WidgetsBinding.instance.addPostFrameCallback((_) {
239304
widget.onOptionsChanged?.call([..._options.values]);
240305
});
241306
}
242307

243308
void _onAddOptionPressed() {
309+
// Check if we've reached the maximum number of options allowed
310+
if (widget.optionsRange?.max case final maxOptions?) {
311+
// Don't add more options if we've reached the limit
312+
if (_options.length >= maxOptions) return;
313+
}
314+
315+
// Create a new option item
316+
final option = PollOptionItem();
317+
244318
setState(() {
245-
// Create a new option and add it to the map.
246-
final option = PollOptionItem();
247319
_options[option.id] = option;
320+
_focusNodes[option.id] = FocusNode();
321+
});
248322

249-
// Notify the parent widget about the change
323+
// Notify the parent widget about the change and request focus on the
324+
// newly added option text field.
325+
WidgetsBinding.instance.addPostFrameCallback((_) {
250326
widget.onOptionsChanged?.call([..._options.values]);
327+
_focusNodes[option.id]?.requestFocus();
251328
});
252329
}
253330

331+
bool get _canAddMoreOptions {
332+
// Don't allow adding if there's already an empty option
333+
final hasEmptyOption = _options.values.any((it) => it.text.isEmpty);
334+
if (hasEmptyOption) return false;
335+
336+
// Check max options limit
337+
if (widget.optionsRange?.max case final maxOptions?) {
338+
return _options.length < maxOptions;
339+
}
340+
341+
return true;
342+
}
343+
254344
@override
255345
Widget build(BuildContext context) {
256346
final theme = StreamPollCreatorTheme.of(context);
@@ -271,13 +361,15 @@ class _PollOptionReorderableListViewState
271361
physics: const NeverScrollableScrollPhysics(),
272362
proxyDecorator: _proxyDecorator,
273363
separatorBuilder: (_, __) => const SizedBox(height: 8),
364+
onReorderStart: (_) => FocusScope.of(context).unfocus(),
274365
onReorder: _onOptionReorder,
275366
itemBuilder: (context, index) {
276367
final option = _options.values.elementAt(index);
277368
return PollOptionListItem(
278369
key: Key(option.id),
279370
option: option,
280371
hintText: widget.itemHintText,
372+
focusNode: _focusNodes[option.id],
281373
onChanged: _onOptionChanged,
282374
);
283375
},
@@ -287,7 +379,7 @@ class _PollOptionReorderableListViewState
287379
SizedBox(
288380
width: double.infinity,
289381
child: FilledButton.tonal(
290-
onPressed: _onAddOptionPressed,
382+
onPressed: _canAddMoreOptions ? _onAddOptionPressed : null,
291383
style: TextButton.styleFrom(
292384
alignment: Alignment.centerLeft,
293385
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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'package:flutter/material.dart';
2+
import 'package:flutter/services.dart';
23
import 'package:stream_chat_flutter/src/misc/empty_widget.dart';
34
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
45

@@ -141,6 +142,7 @@ class _StreamPollTextFieldState extends State<StreamPollTextField> {
141142
style: widget.style ?? theme.textTheme.headline,
142143
keyboardType: widget.keyboardType,
143144
autofocus: widget.autoFocus,
145+
inputFormatters: [FilteringTextInputFormatter.deny(RegExp(r'^\s'))],
144146
decoration: InputDecoration(
145147
filled: true,
146148
isCollapsed: true,
-6 Bytes
Loading
-4 Bytes
Loading
-6 Bytes
Loading
13 Bytes
Loading

0 commit comments

Comments
 (0)