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
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "layrz_theme",
"description": "Claude Code skills for layrz_theme Flutter widget library",
"version": "7.5.23"
"version": "7.5.26"
}
250 changes: 194 additions & 56 deletions .claude-plugin/skills/number-duration-inputs/SKILL.md

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## 7.5.26

- Fixed `ThemedNumberInput` memory leak: `TextEditingController` is now properly disposed in `dispose()`.
- 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.
- 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.
- 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).
- Fixed `ThemedNumberInput` doc comments misalignment: `placeholder`, `onChanged`, `value`, and `disabled` fields had their comments shifted by one field due to a copy-paste error.
- Improved `ThemedNumberInput` `_regex` allocation: moved from a getter (new `RegExp` instance per access) to a `static final` field.
- 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.
- 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.
- 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).
- 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.

## 7.5.25
- 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.
- The `firstDay` and `lastDay` parameters are now supported in `ThemedDateRangePicker`, `ThemedDatePicker`, `ThemedDateTimeRangePicker`, `ThemedDateTimeSteppedPicker` and `ThemedDateTimePicker`.
Expand Down
193 changes: 177 additions & 16 deletions example/lib/views/inputs/src/text.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ class TextInputView extends StatefulWidget {

class _TextInputViewState extends State<TextInputView> {
num? _value;
num? _temperature;
num? _price;
num? _rating;
num? _clamped;
num? _stepped;
String? _text;
String _password = "Abc123!@#";
Duration? _dur = const Duration();
Expand Down Expand Up @@ -147,34 +152,190 @@ class _TextInputViewState extends State<TextInputView> {
),
const SizedBox(height: 10),
Text(
"But, you can also use ThemedNumberInput to handle numbers easly, like the following example:",
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
"ThemedNumberInput",
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
"A number input with built-in formatting, step controls, and min/max constraints.",
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 10),
const Divider(),

// Basic usage
Text("Basic usage", style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text(
"Current value: ${_value ?? 'null'}",
style: Theme.of(context).textTheme.bodySmall,
),
ThemedNumberInput(
labelText: "Example label",
labelText: "A simple number",
value: _value,
onChanged: (value) => setState(() => _value = value),
),
const SizedBox(height: 10),

// Prefix / suffix text
Text(
"With prefix and suffix text",
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
"Use prefixText and suffixText to add units or currency symbols.",
style: Theme.of(context).textTheme.bodySmall,
),
ThemedNumberInput(
labelText: "Price (USD)",
value: _price,
prefixText: '\$',
maximumDecimalDigits: 2,
onChanged: (value) => setState(() => _price = value),
),
ThemedNumberInput(
labelText: "Temperature",
value: _temperature,
suffixText: '°C',
maximumDecimalDigits: 1,
onChanged: (value) => setState(() => _temperature = value),
),
const SizedBox(height: 10),

// Decimal separators
Text(
"Decimal separators",
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
"Use ThemedDecimalSeparator.dot (default) or .comma for European-style formatting.",
style: Theme.of(context).textTheme.bodySmall,
),
ThemedNumberInput(
labelText: "Dot separator (1,234.56)",
value: _value,
suffixText: '\$',
decimalSeparator: ThemedDecimalSeparator.dot,
onChanged: (value) {
debugPrint("Value: $value");
setState(() => _value = value);
},
maximumDecimalDigits: 4,
onChanged: (value) => setState(() => _value = value),
),
ThemedNumberInput(
labelText: "Comma separator (1.234,56)",
value: _value,
decimalSeparator: ThemedDecimalSeparator.comma,
maximumDecimalDigits: 4,
onChanged: (value) => setState(() => _value = value),
),
const SizedBox(height: 10),

// Step controls with min/max
Text(
"Step controls + min/max constraints",
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
"The − and + buttons respect minimum and maximum. Notice how they disable at the boundaries.",
style: Theme.of(context).textTheme.bodySmall,
),
ThemedNumberInput(
labelText: "Rating (0–10, step 1)",
value: _rating,
minimum: 0,
maximum: 10,
step: 1,
onChanged: (value) => setState(() => _rating = value),
),
ThemedNumberInput(
labelText: "Clamped (−50 to 50, step 5)",
value: _clamped,
minimum: -50,
maximum: 50,
step: 5,
onChanged: (value) => setState(() => _clamped = value),
),
ThemedNumberInput(
labelText: "Fine step (step 0.1)",
value: _stepped,
minimum: 0,
maximum: 1,
step: 0.1,
maximumDecimalDigits: 1,
onChanged: (value) => setState(() => _stepped = value),
),
const SizedBox(height: 10),

// Dense + disabled + errors
Text(
"Dense, disabled & validation errors",
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
ThemedNumberInput(
labelText: "Dense mode",
value: _value,
dense: true,
onChanged: (value) => setState(() => _value = value),
),
ThemedNumberInput(
labelText: "Disabled",
value: 42,
disabled: true,
onChanged: null,
),
ThemedNumberInput(
labelText: "With validation error",
value: _value,
labelText: 'Number input with comma as decimal separator',
maximumDecimalDigits: 8,
onChanged: (value) {
debugPrint("Value: $value");
setState(() => _value = value);
},
errors: const ["Value must be positive"],
onChanged: (value) => setState(() => _value = value),
),
ThemedNumberInput(
labelText: "hideDetails: true (no error space)",
value: _value,
hideDetails: true,
errors: const ["Hidden error"],
onChanged: (value) => setState(() => _value = value),
),
const SizedBox(height: 10),

// isRequired
Text(
"Required field",
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
"Use isRequired: true to show the * indicator.",
style: Theme.of(context).textTheme.bodySmall,
),
ThemedNumberInput(
labelText: "Quantity",
value: _value,
isRequired: true,
onChanged: (value) => setState(() => _value = value),
),
const SizedBox(height: 10),

// hidePrefixSuffixActions
Text(
"Hide step actions",
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
"hidePrefixSuffixActions removes the − / + buttons entirely. Useful when you only want free-form input.",
style: Theme.of(context).textTheme.bodySmall,
),
ThemedNumberInput(
labelText: "No step buttons",
value: _value,
hidePrefixSuffixActions: true,
onChanged: (value) => setState(() => _value = value),
),
const SizedBox(height: 10),
const Divider(),
const SizedBox(height: 10),
Text(
"Or handle durations, to do that, you can use ThemedDurationInput to handle easly, "
"like the following example:",
Expand Down
2 changes: 1 addition & 1 deletion example/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@ packages:
path: ".."
relative: true
source: path
version: "7.5.25"
version: "7.5.26"
leak_tracker:
dependency: transitive
description:
Expand Down
60 changes: 42 additions & 18 deletions lib/src/inputs/src/general/number_input.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,16 @@ class ThemedNumberInput extends StatefulWidget {
/// [label] is the widget of the label of the input.
final Widget? label;

/// [disabled] is the state of the input being disabled.
/// [placeholder] is the placeholder of the input.
final String? placeholder;

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

/// [onChanged] is the callback function when the input is changed.
/// [value] is the value of the input.
final num? value;

/// [value] is the value of the input.
/// [disabled] is the state of the input being disabled.
final bool disabled;

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

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

@override
void dispose() {
_controller.dispose();
super.dispose();
}

@override
void didUpdateWidget(ThemedNumberInput oldWidget) {
super.didUpdateWidget(oldWidget);
Expand All @@ -167,11 +174,19 @@ class _ThemedNumberInputState extends State<ThemedNumberInput> {
return;
}
if (oldWidget.value != widget.value) {
final String newValue = format.format(widget.value);

if (_stepTriggered) {
_stepTriggered = false;
_controller.text = newValue;
_controller.selection = TextSelection.fromPosition(TextPosition(offset: newValue.length));
return;
}

// save the current cursor offset
int previousCursorOffset = _controller.selection.extentOffset;

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

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

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

@override
Widget build(BuildContext context) {
final bool canDecrement =
!_hideActionButtons &&
((widget.value ?? 0) - (widget.step ?? 1)) >= (widget.minimum ?? double.negativeInfinity);
final bool canIncrement =
!_hideActionButtons &&
((widget.value ?? 0) + (widget.step ?? 1)) <= (widget.maximum ?? double.infinity);

return ThemedTextInput(
controller: _controller,
value: widget.value == null ? null : format.format(widget.value),
Expand All @@ -218,21 +240,19 @@ class _ThemedNumberInputState extends State<ThemedNumberInput> {
prefixText: widget.prefixText,
suffixText: widget.suffixText,
prefixIcon: _hideActionButtons ? null : LayrzIcons.solarOutlineMinusSquare,
prefixIconDisabled: !canDecrement,
onPrefixTap: () {
if (_hideActionButtons) return;
if (!canDecrement) return;
_stepTriggered = true;
num newValue = (widget.value ?? 0) - (widget.step ?? 1);
if (newValue < (widget.minimum ?? double.negativeInfinity)) {
return;
}
widget.onChanged?.call(newValue);
},
suffixIcon: _hideActionButtons ? null : LayrzIcons.solarOutlineAddSquare,
suffixIconDisabled: !canIncrement,
onSuffixTap: () {
if (_hideActionButtons) return;
if (!canIncrement) return;
_stepTriggered = true;
num newValue = (widget.value ?? 0) + (widget.step ?? 1);
if (newValue > (widget.maximum ?? double.infinity)) {
return;
}
widget.onChanged?.call(newValue);
},
hideDetails: widget.hideDetails,
Expand All @@ -242,16 +262,20 @@ class _ThemedNumberInputState extends State<ThemedNumberInput> {
isRequired: widget.isRequired,
keyboardType: widget.keyboardType,
inputFormatters: [
FilteringTextInputFormatter.allow(_regex),
FilteringTextInputFormatter.allow(widget.inputRegExp ?? _regex),
...widget.inputFormatters,
],
onChanged: (value) {
if (value.isEmpty) return widget.onChanged?.call(null);
if (value.isEmpty) {
widget.onChanged?.call(null);
return;
}
if (value == '-') return;

final castedValue = format.tryParse(value);

widget.onChanged?.call(castedValue);
if (castedValue != null) {
widget.onChanged?.call(castedValue);
}
},
onSubmitted: widget.onSubmitted,
focusNode: widget.focusNode,
Expand Down
Loading
Loading