Skip to content

Commit 94c8577

Browse files
Development (#132)
This pull request updates the documentation and changelog for the `ThemedNumberInput` and `ThemedDurationInput` Flutter widgets, clarifying their behavior, edge cases, and usage patterns. It also documents several recent bug fixes and improvements, including memory management, input validation, cursor handling, and enhanced visual feedback for step buttons. The changes provide comprehensive examples, testing guidance, and detailed explanations of widget parameters and behaviors. **Documentation and Behavior Clarifications:** * Expanded and clarified the documentation for `ThemedNumberInput` and `ThemedDurationInput` in `.claude-plugin/skills/number-duration-inputs/SKILL.md`, including: - Clearer explanations of button disabling at min/max, cursor behavior after step actions, and the distinction between button gating and keyboard input for min/max. [[1]](diffhunk://#diff-a386dd0ac15caaea86790a023d92a41226e4e9e00c5c1d99393b236576a9db45L13-R13) [[2]](diffhunk://#diff-a386dd0ac15caaea86790a023d92a41226e4e9e00c5c1d99393b236576a9db45L44-R78) [[3]](diffhunk://#diff-a386dd0ac15caaea86790a023d92a41226e4e9e00c5c1d99393b236576a9db45L108-R125) [[4]](diffhunk://#diff-a386dd0ac15caaea86790a023d92a41226e4e9e00c5c1d99393b236576a9db45L121-R138) [[5]](diffhunk://#diff-a386dd0ac15caaea86790a023d92a41226e4e9e00c5c1d99393b236576a9db45R148-R161) - Added a new "Gotchas & Edge Cases" section covering input parsing, cursor movement, focus node responsibility, and decimal separator effects. - Improved parameter descriptions and added more usage examples and testing patterns. [[1]](diffhunk://#diff-a386dd0ac15caaea86790a023d92a41226e4e9e00c5c1d99393b236576a9db45L162-R194) [[2]](diffhunk://#diff-a386dd0ac15caaea86790a023d92a41226e4e9e00c5c1d99393b236576a9db45L174-R214) [[3]](diffhunk://#diff-a386dd0ac15caaea86790a023d92a41226e4e9e00c5c1d99393b236576a9db45L197-R226) [[4]](diffhunk://#diff-a386dd0ac15caaea86790a023d92a41226e4e9e00c5c1d99393b236576a9db45L209-R238) [[5]](diffhunk://#diff-a386dd0ac15caaea86790a023d92a41226e4e9e00c5c1d99393b236576a9db45L226-L227) [[6]](diffhunk://#diff-a386dd0ac15caaea86790a023d92a41226e4e9e00c5c1d99393b236576a9db45L240-L241) **Bug Fixes and Improvements (as documented in `CHANGELOG.md`):** * Fixed `ThemedNumberInput` memory leak by properly disposing of its `TextEditingController`. * Fixed `inputRegExp` parameter being ignored; custom regex is now correctly applied to input filtering. * Fixed unsafe `onChanged` calls for unparseable input; such input is now ignored, preserving the last valid value. * Fixed cursor position after step button tap; cursor now moves to the end of the number as expected. * Improved step button UX: buttons show 0.4 opacity and ignore taps at boundaries, with new `prefixIconDisabled`/`suffixIconDisabled` support in `ThemedTextInput`. **Testing and Examples:** * Added comprehensive widget tests and example patterns for `ThemedNumberInput`, covering rendering, keyboard and button interactions, cursor behavior, locale formatting, lifecycle, and edge cases. [[1]](diffhunk://#diff-06572a96a58dc510037d5efa622f9bec8519bc1beab13c9f251e97e657a9d4edR3-R15) [[2]](diffhunk://#diff-a386dd0ac15caaea86790a023d92a41226e4e9e00c5c1d99393b236576a9db45L254-R424) **Version and Example Updates:** * Bumped plugin version to `7.5.26` in `.claude-plugin/plugin.json`. * Updated example view state in `example/lib/views/inputs/src/text.dart` to include new fields for testing. * Updated the changelog to reflect all fixes, improvements, and new documentation.
2 parents d9edf85 + 8c745af commit 94c8577

9 files changed

Lines changed: 998 additions & 101 deletions

File tree

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
22
"name": "layrz_theme",
33
"description": "Claude Code skills for layrz_theme Flutter widget library",
4-
"version": "7.5.23"
4+
"version": "7.5.26"
55
}

.claude-plugin/skills/number-duration-inputs/SKILL.md

Lines changed: 194 additions & 56 deletions
Large diffs are not rendered by default.

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
# Changelog
22

3+
## 7.5.26
4+
5+
- Fixed `ThemedNumberInput` memory leak: `TextEditingController` is now properly disposed in `dispose()`.
6+
- Fixed `ThemedNumberInput` `inputRegExp` parameter being silently ignored: the custom regex is now applied to the `FilteringTextInputFormatter` when provided, honoring the existing assertion that requires it when `format` is set.
7+
- Fixed `ThemedNumberInput` unsafe `onChanged` call when `format.tryParse()` returns `null`: unparseable input is now silently ignored and the last valid value is preserved, preventing `null` from being emitted for intermediate typed states.
8+
- Fixed `ThemedNumberInput` cursor position after step button tap: cursor now moves to the end of the formatted number after increment/decrement, instead of staying at the previous offset (which caused cursor to land mid-number when digit count changed, e.g. 9→10, 99→100, 999→1,000).
9+
- Fixed `ThemedNumberInput` doc comments misalignment: `placeholder`, `onChanged`, `value`, and `disabled` fields had their comments shifted by one field due to a copy-paste error.
10+
- Improved `ThemedNumberInput` `_regex` allocation: moved from a getter (new `RegExp` instance per access) to a `static final` field.
11+
- Improved `ThemedNumberInput` step button UX: buttons now show 0.4 opacity and ignore taps when the next step would exceed `minimum` or `maximum`, providing clear visual feedback at boundaries.
12+
- Added `prefixIconDisabled` and `suffixIconDisabled` parameters to `ThemedTextInput`: when `true`, the respective icon renders at 0.4 opacity and its `InkWell` tap is suppressed, enabling disabled visual states for icon buttons without hiding them.
13+
- Added comprehensive widget tests for `ThemedNumberInput` covering: rendering (label, initial value, null value, suffixText, prefixText, isRequired, errors, hideDetails), `onChanged` via keyboard (parsed value, clear→null, lone minus sign, unparseable input, negative numbers), step buttons (increment, decrement, default step, null value start, boundary enforcement at min/max, hidden when `hidePrefixSuffixActions` or `disabled`), cursor position after step (9→10, 99→100, 10→9, 999→1,000 regressions), decimal separators (dot and comma locale formatting), and lifecycle (mount/unmount without errors, repeated cycles, controller text clears on null value).
14+
- Updated `ThemedNumberInput` example showcase with sections covering: basic usage, prefix/suffix text, decimal separators side-by-side, step controls with min/max boundary feedback, dense/disabled/error/hideDetails states, isRequired indicator, and hidePrefixSuffixActions mode.
15+
316
## 7.5.25
417
- Added the `firstDay` and `lastDay` parameters to `ThemedCalendar` to allow setting a date range for the picker, preventing selection of dates outside the specified range.
518
- The `firstDay` and `lastDay` parameters are now supported in `ThemedDateRangePicker`, `ThemedDatePicker`, `ThemedDateTimeRangePicker`, `ThemedDateTimeSteppedPicker` and `ThemedDateTimePicker`.

example/lib/views/inputs/src/text.dart

Lines changed: 177 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ class TextInputView extends StatefulWidget {
99

1010
class _TextInputViewState extends State<TextInputView> {
1111
num? _value;
12+
num? _temperature;
13+
num? _price;
14+
num? _rating;
15+
num? _clamped;
16+
num? _stepped;
1217
String? _text;
1318
String _password = "Abc123!@#";
1419
Duration? _dur = const Duration();
@@ -147,34 +152,190 @@ class _TextInputViewState extends State<TextInputView> {
147152
),
148153
const SizedBox(height: 10),
149154
Text(
150-
"But, you can also use ThemedNumberInput to handle numbers easly, like the following example:",
151-
style: Theme.of(context).textTheme.titleMedium?.copyWith(
152-
fontWeight: FontWeight.bold,
153-
),
155+
"ThemedNumberInput",
156+
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
157+
),
158+
const SizedBox(height: 4),
159+
Text(
160+
"A number input with built-in formatting, step controls, and min/max constraints.",
161+
style: Theme.of(context).textTheme.bodyMedium,
154162
),
155163
const SizedBox(height: 10),
164+
const Divider(),
165+
166+
// Basic usage
167+
Text("Basic usage", style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
168+
const SizedBox(height: 4),
169+
Text(
170+
"Current value: ${_value ?? 'null'}",
171+
style: Theme.of(context).textTheme.bodySmall,
172+
),
156173
ThemedNumberInput(
157-
labelText: "Example label",
174+
labelText: "A simple number",
175+
value: _value,
176+
onChanged: (value) => setState(() => _value = value),
177+
),
178+
const SizedBox(height: 10),
179+
180+
// Prefix / suffix text
181+
Text(
182+
"With prefix and suffix text",
183+
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
184+
),
185+
const SizedBox(height: 4),
186+
Text(
187+
"Use prefixText and suffixText to add units or currency symbols.",
188+
style: Theme.of(context).textTheme.bodySmall,
189+
),
190+
ThemedNumberInput(
191+
labelText: "Price (USD)",
192+
value: _price,
193+
prefixText: '\$',
194+
maximumDecimalDigits: 2,
195+
onChanged: (value) => setState(() => _price = value),
196+
),
197+
ThemedNumberInput(
198+
labelText: "Temperature",
199+
value: _temperature,
200+
suffixText: '°C',
201+
maximumDecimalDigits: 1,
202+
onChanged: (value) => setState(() => _temperature = value),
203+
),
204+
const SizedBox(height: 10),
205+
206+
// Decimal separators
207+
Text(
208+
"Decimal separators",
209+
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
210+
),
211+
const SizedBox(height: 4),
212+
Text(
213+
"Use ThemedDecimalSeparator.dot (default) or .comma for European-style formatting.",
214+
style: Theme.of(context).textTheme.bodySmall,
215+
),
216+
ThemedNumberInput(
217+
labelText: "Dot separator (1,234.56)",
158218
value: _value,
159-
suffixText: '\$',
160219
decimalSeparator: ThemedDecimalSeparator.dot,
161-
onChanged: (value) {
162-
debugPrint("Value: $value");
163-
setState(() => _value = value);
164-
},
220+
maximumDecimalDigits: 4,
221+
onChanged: (value) => setState(() => _value = value),
165222
),
166223
ThemedNumberInput(
224+
labelText: "Comma separator (1.234,56)",
225+
value: _value,
167226
decimalSeparator: ThemedDecimalSeparator.comma,
227+
maximumDecimalDigits: 4,
228+
onChanged: (value) => setState(() => _value = value),
229+
),
230+
const SizedBox(height: 10),
231+
232+
// Step controls with min/max
233+
Text(
234+
"Step controls + min/max constraints",
235+
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
236+
),
237+
const SizedBox(height: 4),
238+
Text(
239+
"The − and + buttons respect minimum and maximum. Notice how they disable at the boundaries.",
240+
style: Theme.of(context).textTheme.bodySmall,
241+
),
242+
ThemedNumberInput(
243+
labelText: "Rating (0–10, step 1)",
244+
value: _rating,
245+
minimum: 0,
246+
maximum: 10,
247+
step: 1,
248+
onChanged: (value) => setState(() => _rating = value),
249+
),
250+
ThemedNumberInput(
251+
labelText: "Clamped (−50 to 50, step 5)",
252+
value: _clamped,
253+
minimum: -50,
254+
maximum: 50,
255+
step: 5,
256+
onChanged: (value) => setState(() => _clamped = value),
257+
),
258+
ThemedNumberInput(
259+
labelText: "Fine step (step 0.1)",
260+
value: _stepped,
261+
minimum: 0,
262+
maximum: 1,
263+
step: 0.1,
264+
maximumDecimalDigits: 1,
265+
onChanged: (value) => setState(() => _stepped = value),
266+
),
267+
const SizedBox(height: 10),
268+
269+
// Dense + disabled + errors
270+
Text(
271+
"Dense, disabled & validation errors",
272+
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
273+
),
274+
const SizedBox(height: 4),
275+
ThemedNumberInput(
276+
labelText: "Dense mode",
277+
value: _value,
168278
dense: true,
279+
onChanged: (value) => setState(() => _value = value),
280+
),
281+
ThemedNumberInput(
282+
labelText: "Disabled",
283+
value: 42,
284+
disabled: true,
285+
onChanged: null,
286+
),
287+
ThemedNumberInput(
288+
labelText: "With validation error",
169289
value: _value,
170-
labelText: 'Number input with comma as decimal separator',
171-
maximumDecimalDigits: 8,
172-
onChanged: (value) {
173-
debugPrint("Value: $value");
174-
setState(() => _value = value);
175-
},
290+
errors: const ["Value must be positive"],
291+
onChanged: (value) => setState(() => _value = value),
292+
),
293+
ThemedNumberInput(
294+
labelText: "hideDetails: true (no error space)",
295+
value: _value,
296+
hideDetails: true,
297+
errors: const ["Hidden error"],
298+
onChanged: (value) => setState(() => _value = value),
299+
),
300+
const SizedBox(height: 10),
301+
302+
// isRequired
303+
Text(
304+
"Required field",
305+
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
306+
),
307+
const SizedBox(height: 4),
308+
Text(
309+
"Use isRequired: true to show the * indicator.",
310+
style: Theme.of(context).textTheme.bodySmall,
311+
),
312+
ThemedNumberInput(
313+
labelText: "Quantity",
314+
value: _value,
315+
isRequired: true,
316+
onChanged: (value) => setState(() => _value = value),
176317
),
177318
const SizedBox(height: 10),
319+
320+
// hidePrefixSuffixActions
321+
Text(
322+
"Hide step actions",
323+
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
324+
),
325+
const SizedBox(height: 4),
326+
Text(
327+
"hidePrefixSuffixActions removes the − / + buttons entirely. Useful when you only want free-form input.",
328+
style: Theme.of(context).textTheme.bodySmall,
329+
),
330+
ThemedNumberInput(
331+
labelText: "No step buttons",
332+
value: _value,
333+
hidePrefixSuffixActions: true,
334+
onChanged: (value) => setState(() => _value = value),
335+
),
336+
const SizedBox(height: 10),
337+
const Divider(),
338+
const SizedBox(height: 10),
178339
Text(
179340
"Or handle durations, to do that, you can use ThemedDurationInput to handle easly, "
180341
"like the following example:",

example/pubspec.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,7 @@ packages:
390390
path: ".."
391391
relative: true
392392
source: path
393-
version: "7.5.25"
393+
version: "7.5.26"
394394
leak_tracker:
395395
dependency: transitive
396396
description:

lib/src/inputs/src/general/number_input.dart

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,16 @@ class ThemedNumberInput extends StatefulWidget {
77
/// [label] is the widget of the label of the input.
88
final Widget? label;
99

10-
/// [disabled] is the state of the input being disabled.
10+
/// [placeholder] is the placeholder of the input.
1111
final String? placeholder;
1212

13-
/// [placeholder] is the placeholder of the input.
13+
/// [onChanged] is the callback function when the input is changed.
1414
final void Function(num?)? onChanged;
1515

16-
/// [onChanged] is the callback function when the input is changed.
16+
/// [value] is the value of the input.
1717
final num? value;
1818

19-
/// [value] is the value of the input.
19+
/// [disabled] is the state of the input being disabled.
2020
final bool disabled;
2121

2222
/// [errors] is the list of errors of the input.
@@ -130,7 +130,8 @@ class ThemedNumberInput extends StatefulWidget {
130130
class _ThemedNumberInputState extends State<ThemedNumberInput> {
131131
bool get _hideActionButtons => widget.hidePrefixSuffixActions || widget.disabled;
132132

133-
RegExp get _regex => RegExp(r'[-0-9\,.]');
133+
static final RegExp _regex = RegExp(r'[-0-9\,.]');
134+
bool _stepTriggered = false;
134135
final _controller = TextEditingController();
135136
bool get isDense => widget.dense;
136137
Color get color => Theme.of(context).brightness == .dark ? Colors.white : Theme.of(context).primaryColor;
@@ -155,6 +156,12 @@ class _ThemedNumberInputState extends State<ThemedNumberInput> {
155156
_controller.selection = .fromPosition(TextPosition(offset: _controller.text.length));
156157
}
157158

159+
@override
160+
void dispose() {
161+
_controller.dispose();
162+
super.dispose();
163+
}
164+
158165
@override
159166
void didUpdateWidget(ThemedNumberInput oldWidget) {
160167
super.didUpdateWidget(oldWidget);
@@ -167,11 +174,19 @@ class _ThemedNumberInputState extends State<ThemedNumberInput> {
167174
return;
168175
}
169176
if (oldWidget.value != widget.value) {
177+
final String newValue = format.format(widget.value);
178+
179+
if (_stepTriggered) {
180+
_stepTriggered = false;
181+
_controller.text = newValue;
182+
_controller.selection = TextSelection.fromPosition(TextPosition(offset: newValue.length));
183+
return;
184+
}
185+
170186
// save the current cursor offset
171187
int previousCursorOffset = _controller.selection.extentOffset;
172188

173189
String oldValue = oldWidget.value != null ? format.format(oldWidget.value) : '';
174-
String newValue = format.format(widget.value);
175190

176191
String thousandSeparator = widget.decimalSeparator == .comma ? '.' : ',';
177192

@@ -208,6 +223,13 @@ class _ThemedNumberInputState extends State<ThemedNumberInput> {
208223

209224
@override
210225
Widget build(BuildContext context) {
226+
final bool canDecrement =
227+
!_hideActionButtons &&
228+
((widget.value ?? 0) - (widget.step ?? 1)) >= (widget.minimum ?? double.negativeInfinity);
229+
final bool canIncrement =
230+
!_hideActionButtons &&
231+
((widget.value ?? 0) + (widget.step ?? 1)) <= (widget.maximum ?? double.infinity);
232+
211233
return ThemedTextInput(
212234
controller: _controller,
213235
value: widget.value == null ? null : format.format(widget.value),
@@ -218,21 +240,19 @@ class _ThemedNumberInputState extends State<ThemedNumberInput> {
218240
prefixText: widget.prefixText,
219241
suffixText: widget.suffixText,
220242
prefixIcon: _hideActionButtons ? null : LayrzIcons.solarOutlineMinusSquare,
243+
prefixIconDisabled: !canDecrement,
221244
onPrefixTap: () {
222-
if (_hideActionButtons) return;
245+
if (!canDecrement) return;
246+
_stepTriggered = true;
223247
num newValue = (widget.value ?? 0) - (widget.step ?? 1);
224-
if (newValue < (widget.minimum ?? double.negativeInfinity)) {
225-
return;
226-
}
227248
widget.onChanged?.call(newValue);
228249
},
229250
suffixIcon: _hideActionButtons ? null : LayrzIcons.solarOutlineAddSquare,
251+
suffixIconDisabled: !canIncrement,
230252
onSuffixTap: () {
231-
if (_hideActionButtons) return;
253+
if (!canIncrement) return;
254+
_stepTriggered = true;
232255
num newValue = (widget.value ?? 0) + (widget.step ?? 1);
233-
if (newValue > (widget.maximum ?? double.infinity)) {
234-
return;
235-
}
236256
widget.onChanged?.call(newValue);
237257
},
238258
hideDetails: widget.hideDetails,
@@ -242,16 +262,20 @@ class _ThemedNumberInputState extends State<ThemedNumberInput> {
242262
isRequired: widget.isRequired,
243263
keyboardType: widget.keyboardType,
244264
inputFormatters: [
245-
FilteringTextInputFormatter.allow(_regex),
265+
FilteringTextInputFormatter.allow(widget.inputRegExp ?? _regex),
246266
...widget.inputFormatters,
247267
],
248268
onChanged: (value) {
249-
if (value.isEmpty) return widget.onChanged?.call(null);
269+
if (value.isEmpty) {
270+
widget.onChanged?.call(null);
271+
return;
272+
}
250273
if (value == '-') return;
251274

252275
final castedValue = format.tryParse(value);
253-
254-
widget.onChanged?.call(castedValue);
276+
if (castedValue != null) {
277+
widget.onChanged?.call(castedValue);
278+
}
255279
},
256280
onSubmitted: widget.onSubmitted,
257281
focusNode: widget.focusNode,

0 commit comments

Comments
 (0)