diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index c8d6b28..f8a18f1 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -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" } diff --git a/.claude-plugin/skills/number-duration-inputs/SKILL.md b/.claude-plugin/skills/number-duration-inputs/SKILL.md index 17a953c..47b4628 100644 --- a/.claude-plugin/skills/number-duration-inputs/SKILL.md +++ b/.claude-plugin/skills/number-duration-inputs/SKILL.md @@ -10,7 +10,7 @@ description: Use ThemedNumberInput or ThemedDurationInput in a layrz Flutter wid | `ThemedNumberInput` | `num?` | `void Function(num?)?` | Numeric fields: integers, decimals, quantities, coordinates | | `ThemedDurationInput` | `Duration?` | `Function(Duration?)?` | Time span fields: timeouts, intervals, elapsed time | -`ThemedNumberInput` renders a text field with minus (prefix) and plus (suffix) icon buttons for increment/decrement. `ThemedDurationInput` renders a readonly text field that opens a dialog with one `ThemedNumberInput` per visible time unit. +`ThemedNumberInput` renders a text field with minus (prefix) and plus (suffix) icon buttons for increment/decrement. The buttons show a **visual disabled state (0.4 opacity)** when the value reaches `minimum` or `maximum` — implemented via `prefixIconDisabled`/`suffixIconDisabled` on the internal `ThemedTextInput`. `ThemedDurationInput` renders a readonly text field that opens a dialog with one `ThemedNumberInput` per visible time unit. --- @@ -41,38 +41,41 @@ ThemedNumberInput( | `label` | `Widget?` | — | Required unless `labelText` is provided. Exactly one must be set. | | `value` | `num?` | `null` | Current numeric value | | `onChanged` | `void Function(num?)?` | `null` | Called with `null` when field is cleared | -| `minimum` | `num?` | `null` | Clamps the decrement button; does not block typed input | -| `maximum` | `num?` | `null` | Clamps the increment button; does not block typed input | -| `step` | `num?` | `1` | Amount added/subtracted by prefix/suffix buttons | +| `minimum` | `num?` | `null` | Disables decrement button visually at boundary; does **not** block typed input | +| `maximum` | `num?` | `null` | Disables increment button visually at boundary; does **not** block typed input | +| `step` | `num?` | `1` | Amount added/subtracted by the ± buttons | | `maximumDecimalDigits` | `int` | `4` | Max fraction digits shown (capped at 15 internally) | -| `decimalSeparator` | `ThemedDecimalSeparator` | `.dot` | `.dot` → en locale, `.comma` → pt locale | +| `decimalSeparator` | `ThemedDecimalSeparator` | `.dot` | `.dot` → en locale (`1,234.56`), `.comma` → pt locale (`1.234,56`) | | `format` | `NumberFormat?` | `null` | Custom `intl` `NumberFormat`. **Requires** `inputRegExp` when set. | -| `inputRegExp` | `RegExp?` | `null` | Required when `format` is provided; filters raw character input | -| `disabled` | `bool` | `false` | Disables typing and action buttons | -| `hidePrefixSuffixActions` | `bool` | `false` | Hides increment/decrement buttons without disabling the field | +| `inputRegExp` | `RegExp?` | `null` | Overrides the default `[-0-9\,.]` regex when provided. **Required** when `format` is set. | +| `disabled` | `bool` | `false` | Makes field read-only and hides ± buttons | +| `hidePrefixSuffixActions` | `bool` | `false` | Hides ± buttons without disabling the field | | `errors` | `List` | `[]` | Validation messages shown below the field | | `hideDetails` | `bool` | `false` | Hides the errors/hints row entirely | -| `isRequired` | `bool` | `false` | Shows required indicator | +| `isRequired` | `bool` | `false` | Shows `*` required indicator | | `dense` | `bool` | `false` | Reduces vertical padding | | `padding` | `EdgeInsets?` | `null` | Outer padding of the field | | `prefixText` | `String?` | `null` | Static text before the value (e.g. `'$'`) | | `suffixText` | `String?` | `null` | Static text after the value (e.g. `'kg'`) | | `keyboardType` | `TextInputType` | `TextInputType.number` | Keyboard hint on mobile | -| `focusNode` | `FocusNode?` | `null` | External focus node; not disposed by the widget | +| `focusNode` | `FocusNode?` | `null` | External focus node; **not** disposed by this widget (caller owns it) | | `onSubmitted` | `VoidCallback?` | `null` | Called when the user submits the field | | `inputFormatters` | `List` | `[]` | Extra formatters appended after the built-in regex filter | | `borderRadius` | `double?` | `null` | Corner radius override | ### Behavior notes -- `label` and `labelText` are **mutually exclusive**. Providing both (or neither) throws an assertion at runtime. -- When `format` is set you **must** also set `inputRegExp`. The widget asserts this at construction time. The default regex `[-0-9\,.]` only applies when no custom format is used. -- `minimum`/`maximum` only gate the **buttons** — they do not restrict keyboard input. Add a validator in your form layer if you need hard limits. -- Cursor position is preserved across reformats: the widget counts thousand-separator characters before and after the edit and adjusts the offset to avoid jumps. -- `onChanged` is called with `null` when the field is cleared, and is **not** called when the user types a bare `-` sign (intermediate state). -- `decimalSeparator: .comma` uses the `pt` locale `NumberFormat` internally, producing `1.234,56` style formatting. +- `label` and `labelText` are **mutually exclusive**. Providing both (or neither) throws an assertion at construction. +- When `format` is set you **must** also set `inputRegExp`. The widget asserts this at construction. The default regex `[-0-9\,.]` only applies when no custom format is used — if you provide `inputRegExp`, that takes priority. +- `minimum`/`maximum` only gate the **buttons** — they do not restrict keyboard input. Add a validator in your form layer if you need hard limits on typed values. +- The ± buttons show **0.4 opacity** (visually disabled) when the next step would exceed the boundary. This is reactive — no manual state management needed. +- **Cursor after step:** When using ± buttons, the cursor always moves to the end of the formatted number. When typing manually, the cursor stays at the current position. This distinction is tracked internally via a `_stepTriggered` flag. +- `onChanged` is called with `null` when the field is cleared, and is **not** called when the user types only a `-` sign (intermediate state waiting for digits) or when `format.tryParse()` returns null (unparseable input is silently ignored — last valid value is preserved). +- `decimalSeparator: .comma` uses the `pt` locale `NumberFormat` internally, producing `1.234,56` style output. - `hidePrefixSuffixActions` also activates automatically when `disabled: true`. +--- + ### Common patterns ```dart @@ -105,7 +108,21 @@ ThemedNumberInput( }, ) -// Custom NumberFormat (requires inputRegExp) +// Price field with currency prefix +ThemedNumberInput( + labelText: context.i18n.t('entity.price'), + value: price, + prefixText: '\$', + maximumDecimalDigits: 2, + minimum: 0, + errors: context.getErrors(key: 'price'), + onChanged: (value) { + price = value; + if (context.mounted) onChanged.call(); + }, +) + +// Custom NumberFormat (inputRegExp is required when format is set) ThemedNumberInput( labelText: context.i18n.t('entity.price'), value: price, @@ -118,7 +135,7 @@ ThemedNumberInput( }, ) -// Hidden action buttons (display-mode field, still editable via keyboard) +// No step buttons (free-form entry only) ThemedNumberInput( labelText: context.i18n.t('entity.offset'), value: offset, @@ -128,6 +145,20 @@ ThemedNumberInput( if (context.mounted) onChanged.call(); }, ) + +// Fine decimal steps (0.1 increments, 0–1 range) +ThemedNumberInput( + labelText: context.i18n.t('entity.opacity'), + value: opacity, + minimum: 0, + maximum: 1, + step: 0.1, + maximumDecimalDigits: 1, + onChanged: (value) { + opacity = value; + if (context.mounted) onChanged.call(); + }, +) ``` --- @@ -159,8 +190,8 @@ ThemedDurationInput( | `label` | `Widget?` | — | Required unless `labelText` is provided. Exactly one must be set. | | `value` | `Duration?` | `null` | Current duration value | | `onChanged` | `Function(Duration?)?` | `null` | Called only when the user taps Save in the dialog | -| `visibleValues` | `List` | `kThemedDurationSupported` | Units shown in the dialog. Subset of `[day, hour, minute, second]`. | -| `disabled` | `bool` | `false` | Disables tapping; the field is always readonly | +| `visibleValues` | `List` | `kThemedDurationSupported` | Units shown in the dialog. Subset of `[day, hour, minute, second]` | +| `disabled` | `bool` | `false` | Disables tapping; field is always readonly | | `errors` | `List` | `[]` | Validation messages shown below the field | | `padding` | `EdgeInsets?` | `null` | Outer padding of the field | | `prefixIcon` | `IconData?` | `null` | Icon before the text field | @@ -171,18 +202,16 @@ ThemedDurationInput( ### Behavior notes - The field is **always readonly** — tapping it opens a dialog. Keyboard entry is not supported. -- `onChanged` is called **only when the user taps Save**. Tapping Cancel or dismissing the dialog discards changes. -- The dialog has three action buttons: **Cancel** (discard), **Reset** (zeroes all unit fields, does not close), and **Save** (commits the value and closes). -- The display text is humanized using `Duration.humanize(...)` with the active `LayrzAppLocalizations` locale and the units in `visibleValues`. -- `visibleValues` must be a subset of `kThemedDurationSupported`. Passing an unsupported unit (`year`, `month`, `week`, `millisecond`) throws an assertion at construction. -- When only one unit is visible it occupies full width; when two or more are visible they are laid out in a two-column `ResponsiveRow`. -- If the odd-last unit rule applies (last item at an odd index), it stretches to full width automatically. -- `label` and `labelText` are **mutually exclusive**. +- `onChanged` fires **only when the user taps Save**. Cancel or dismiss discards changes. +- The dialog has three actions: **Cancel**, **Reset** (zeroes all units, doesn't close), and **Save** (commits and closes). +- Display text is humanized using `Duration.humanize(...)` with the active `LayrzAppLocalizations` locale. +- `visibleValues` must be a subset of `kThemedDurationSupported`. Passing `year`, `month`, `week`, or `millisecond` throws an assertion. +- When only one unit is visible it occupies full width; two or more use a two-column `ResponsiveRow`. ### Common patterns ```dart -// Hours and minutes only — useful for scheduling +// Hours and minutes only — for scheduling ThemedDurationInput( labelText: context.i18n.t('entity.shiftLength'), value: shiftLength, @@ -194,7 +223,7 @@ ThemedDurationInput( }, ) -// Days only — useful for expiration windows +// Days only — for expiration windows ThemedDurationInput( labelText: context.i18n.t('entity.retentionPeriod'), value: retentionPeriod, @@ -206,7 +235,7 @@ ThemedDurationInput( }, ) -// Full granularity with a custom prefix icon +// Full granularity with icon ThemedDurationInput( labelText: context.i18n.t('entity.connectionTimeout'), value: connectionTimeout, @@ -223,8 +252,6 @@ ThemedDurationInput( ## Integrating with layrz forms (onChanged + errors pattern) -### ThemedNumberInput - ```dart ThemedNumberInput( labelText: context.i18n.t('entity.fieldName'), @@ -237,8 +264,6 @@ ThemedNumberInput( ) ``` -### ThemedDurationInput - ```dart ThemedDurationInput( labelText: context.i18n.t('entity.fieldName'), @@ -251,36 +276,149 @@ ThemedDurationInput( ) ``` -### Stacking inputs +--- + +## Gotchas & Edge Cases + +### 1. `minimum`/`maximum` gate buttons, not keyboard + +The `minimum`/`maximum` parameters only disable the ± buttons visually. A user can still type a value outside the range. Add explicit validation in your form layer if hard limits are required. ```dart +// Button disabled at 0, but user can still type "-5" ThemedNumberInput( - labelText: context.i18n.t('entity.retries'), - value: object.retries, - errors: context.getErrors(key: 'retries'), - onChanged: (value) { - object.retries = value?.toInt(); - if (context.mounted) onChanged.call(); - }, -), -const SizedBox(height: 10), -ThemedDurationInput( - labelText: context.i18n.t('entity.retryDelay'), - value: object.retryDelay, - errors: context.getErrors(key: 'retryDelay'), - onChanged: (value) { - object.retryDelay = value; - if (context.mounted) onChanged.call(); - }, -), + value: qty, + minimum: 0, + step: 1, + errors: qty != null && qty! < 0 ? ['Must be >= 0'] : [], + onChanged: (v) => setState(() => qty = v), +) ``` +### 2. `onChanged` is NOT called for unparseable input + +When `format.tryParse()` returns null (e.g. a lone `.` or incomplete number), `onChanged` is silently skipped. The last valid value is preserved in the parent state. This is intentional — don't rely on `onChanged` being called on every keystroke. + +```dart +// User types "1." — onChanged not called until they type "1.5" +// Parent keeps the previous value during partial input +``` + +### 3. Cursor moves to end after ± buttons + +After tapping the increment or decrement button, the cursor always jumps to the end of the formatted number. This is correct and intentional — don't fight it. When typing manually, cursor position is preserved. + +```dart +// 9 → tap + → "10" with cursor at end (position 2) ✅ +// Was broken before: cursor would land at position 1 (between "1" and "0") +``` + +### 4. `format` requires `inputRegExp` — assertion at construction + +This is enforced via a Dart `assert`. If you provide a custom `format` without `inputRegExp`, the app crashes in debug mode immediately. Always pair them. + +```dart +// ❌ Crashes in debug +ThemedNumberInput( + format: NumberFormat.currency(symbol: '\$'), + // inputRegExp missing! +) + +// ✅ Correct +ThemedNumberInput( + format: NumberFormat.currency(symbol: '\$'), + inputRegExp: RegExp(r'[\d.]'), +) +``` + +### 5. `focusNode` lifecycle is the caller's responsibility + +If you pass a `focusNode`, **you** must dispose it. The widget only disposes `FocusNode` instances it creates internally (when `focusNode` is null). + +```dart +// Caller owns lifecycle +final _focus = FocusNode(); + +@override +void dispose() { + _focus.dispose(); // your responsibility + super.dispose(); +} + +ThemedNumberInput(focusNode: _focus, ...) +``` + +### 6. `decimalSeparator: .comma` affects the full format + +`.comma` uses the `pt` locale pattern internally, which means **thousands separator is `.`** and **decimal separator is `,`**. The input regex accepts both `.` and `,` regardless of locale, so typos can still occur. Validate the parsed `num` value rather than the raw string. + --- ## Choosing between the two - Use `ThemedNumberInput` when the field represents a **scalar number**: count, weight, speed, price, percentage, coordinates. -- Use `ThemedDurationInput` when the field represents a **time span** that is stored as a `Duration`. +- Use `ThemedDurationInput` when the field represents a **time span** stored as `Duration`. - Never use raw `TextField`, `TextFormField`, `Slider`, or any Material numeric widget — always use these components. -- Prefer `ThemedDurationInput` over storing duration as a plain integer (e.g. seconds) — it gives the user explicit unit controls and produces a human-readable display automatically. -- When you need to capture only a subset of time units (e.g. hours + minutes for a schedule), pass a filtered `visibleValues` list to `ThemedDurationInput` rather than building a manual multi-field layout with `ThemedNumberInput`. +- Prefer `ThemedDurationInput` over storing duration as a plain integer (seconds, milliseconds) — it gives the user explicit unit controls and produces a human-readable display automatically. + +--- + +## Testing ThemedNumberInput + +```dart +import 'package:layrz_icons/layrz_icons.dart'; +import 'package:layrz_theme/layrz_theme.dart'; + +// Find the ± buttons by icon +final addButton = find.byIcon(LayrzIcons.solarOutlineAddSquare); +final subButton = find.byIcon(LayrzIcons.solarOutlineMinusSquare); + +// Tap increment +await tester.tap(addButton); +await tester.pumpAndSettle(); +expect(currentValue, equals(expectedValue)); + +// Verify button is disabled at max (icon is wrapped in Opacity 0.4 InkWell with null onTap) +// To test the boundary, verify onChanged is NOT called at maximum: +int calls = 0; +// pump widget with value == maximum, tap add, assert calls == 0 + +// Enter text via keyboard +await tester.enterText(find.byType(TextField), '42'); +await tester.pump(); +expect(received, equals(42)); + +// Clear field → onChanged(null) +await tester.enterText(find.byType(TextField), ''); +await tester.pump(); +expect(received, isNull); + +// Verify cursor is at end after step (regression guard) +final tf = tester.widget(find.byType(TextField)); +expect( + tf.controller!.selection.extentOffset, + equals(tf.controller!.text.length), + reason: 'Cursor must be at end after step button tap', +); +``` + +### StatefulBuilder pattern for reactive tests + +```dart +Widget buildReactive(num? Function() get, void Function(num?) set) { + num? value = get(); + return MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + return ThemedNumberInput( + labelText: 'Number', + value: value, + onChanged: (v) => setState(() { value = v; set(v); }), + ); + }, + ), + ), + ); +} +``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 1085ffe..8999da0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/example/lib/views/inputs/src/text.dart b/example/lib/views/inputs/src/text.dart index ffea894..ed31934 100644 --- a/example/lib/views/inputs/src/text.dart +++ b/example/lib/views/inputs/src/text.dart @@ -9,6 +9,11 @@ class TextInputView extends StatefulWidget { class _TextInputViewState extends State { num? _value; + num? _temperature; + num? _price; + num? _rating; + num? _clamped; + num? _stepped; String? _text; String _password = "Abc123!@#"; Duration? _dur = const Duration(); @@ -147,34 +152,190 @@ class _TextInputViewState extends State { ), 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:", diff --git a/example/pubspec.lock b/example/pubspec.lock index d3b5fbd..1adc9f3 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -390,7 +390,7 @@ packages: path: ".." relative: true source: path - version: "7.5.25" + version: "7.5.26" leak_tracker: dependency: transitive description: diff --git a/lib/src/inputs/src/general/number_input.dart b/lib/src/inputs/src/general/number_input.dart index f104d5d..ed1c321 100644 --- a/lib/src/inputs/src/general/number_input.dart +++ b/lib/src/inputs/src/general/number_input.dart @@ -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. @@ -130,7 +130,8 @@ class ThemedNumberInput extends StatefulWidget { class _ThemedNumberInputState extends State { 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; @@ -155,6 +156,12 @@ class _ThemedNumberInputState extends State { _controller.selection = .fromPosition(TextPosition(offset: _controller.text.length)); } + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + @override void didUpdateWidget(ThemedNumberInput oldWidget) { super.didUpdateWidget(oldWidget); @@ -167,11 +174,19 @@ class _ThemedNumberInputState extends State { 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 ? '.' : ','; @@ -208,6 +223,13 @@ class _ThemedNumberInputState extends State { @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), @@ -218,21 +240,19 @@ class _ThemedNumberInputState extends State { 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, @@ -242,16 +262,20 @@ class _ThemedNumberInputState extends State { 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, diff --git a/lib/src/inputs/src/general/text_input.dart b/lib/src/inputs/src/general/text_input.dart index 046f50a..b438436 100644 --- a/lib/src/inputs/src/general/text_input.dart +++ b/lib/src/inputs/src/general/text_input.dart @@ -26,6 +26,12 @@ class ThemedTextInput extends StatefulWidget { /// [onPrefixTap] is the callback function when the prefix is tapped. final VoidCallback? onPrefixTap; + /// [prefixIconDisabled] disables the prefix icon visually (0.4 opacity) and ignores taps. + final bool prefixIconDisabled; + + /// [suffixIconDisabled] disables the suffix icon visually (0.4 opacity) and ignores taps. + final bool suffixIconDisabled; + /// [suffixIcon] is the suffix icon of the input. final IconData? suffixIcon; @@ -136,6 +142,8 @@ class ThemedTextInput extends StatefulWidget { this.prefixIcon, this.prefixWidget, this.onPrefixTap, + this.prefixIconDisabled = false, + this.suffixIconDisabled = false, this.suffixIcon, this.suffixText, this.onSuffixTap, @@ -285,10 +293,13 @@ class _ThemedTextInputState extends State with TickerProviderSt prefixes.add( Padding( padding: const .only(left: 10), - child: InkWell( - borderRadius: .circular(20), - onTap: widget.onPrefixTap, - child: Icon(widget.prefixIcon, size: 18), + child: Opacity( + opacity: widget.prefixIconDisabled ? 0.4 : 1.0, + child: InkWell( + borderRadius: .circular(20), + onTap: widget.prefixIconDisabled ? null : widget.onPrefixTap, + child: Icon(widget.prefixIcon, size: 18), + ), ), ), ); @@ -322,10 +333,13 @@ class _ThemedTextInputState extends State with TickerProviderSt suffixes.add( Padding( padding: const .only(right: 10), - child: InkWell( - borderRadius: .circular(20), - onTap: widget.onSuffixTap, - child: Icon(widget.suffixIcon, size: 18), + child: Opacity( + opacity: widget.suffixIconDisabled ? 0.4 : 1.0, + child: InkWell( + borderRadius: .circular(20), + onTap: widget.suffixIconDisabled ? null : widget.onSuffixTap, + child: Icon(widget.suffixIcon, size: 18), + ), ), ), ); diff --git a/pubspec.yaml b/pubspec.yaml index 9fb90e9..de96384 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: layrz_theme description: Layrz standard styling library for Flutter. Widget library following the Material Design 3 guidelines, with a focus on reliavility and functionality. -version: "7.5.25" +version: "7.5.26" homepage: https://theme.layrz.com repository: https://github.com/goldenm-software/layrz_theme diff --git a/test/widgets/number_input_test.dart b/test/widgets/number_input_test.dart new file mode 100644 index 0000000..cac8a48 --- /dev/null +++ b/test/widgets/number_input_test.dart @@ -0,0 +1,547 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:layrz_icons/layrz_icons.dart'; +import 'package:layrz_theme/layrz_theme.dart'; + +/// Wraps [ThemedNumberInput] in a [StatefulBuilder] so tests can drive +/// value changes from outside, simulating a real parent widget. +Widget _buildInput({ + num? initialValue, + void Function(num?)? onChanged, + num? minimum, + num? maximum, + num? step, + ThemedDecimalSeparator decimalSeparator = ThemedDecimalSeparator.dot, + int maximumDecimalDigits = 4, + bool disabled = false, + bool hidePrefixSuffixActions = false, + bool isRequired = false, + bool hideDetails = false, + List errors = const [], + String? suffixText, + String? prefixText, + String labelText = 'Number', +}) { + return MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + num? value = initialValue; + return ThemedNumberInput( + labelText: labelText, + value: value, + minimum: minimum, + maximum: maximum, + step: step, + decimalSeparator: decimalSeparator, + maximumDecimalDigits: maximumDecimalDigits, + disabled: disabled, + hidePrefixSuffixActions: hidePrefixSuffixActions, + isRequired: isRequired, + hideDetails: hideDetails, + errors: errors, + suffixText: suffixText, + prefixText: prefixText, + onChanged: (v) { + setState(() => value = v); + onChanged?.call(v); + }, + ); + }, + ), + ), + ); +} + +/// Builds a fully reactive version where the parent owns the state. +Widget _buildReactive({ + required num? Function() getValue, + required void Function(num?) setValue, + num? minimum, + num? maximum, + num? step, + ThemedDecimalSeparator decimalSeparator = ThemedDecimalSeparator.dot, + int maximumDecimalDigits = 4, + bool hidePrefixSuffixActions = false, +}) { + num? value = getValue(); + return MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + return ThemedNumberInput( + labelText: 'Number', + value: value, + minimum: minimum, + maximum: maximum, + step: step, + decimalSeparator: decimalSeparator, + maximumDecimalDigits: maximumDecimalDigits, + hidePrefixSuffixActions: hidePrefixSuffixActions, + onChanged: (v) { + setState(() { + value = v; + setValue(v); + }); + }, + ); + }, + ), + ), + ); +} + +void main() { + group('ThemedNumberInput', () { + // ───────────────────────────────────────────────────────────── + // RENDERING + // ───────────────────────────────────────────────────────────── + group('rendering', () { + testWidgets('renders without crashing', (tester) async { + await tester.pumpWidget(_buildInput()); + await tester.pumpAndSettle(); + expect(find.byType(ThemedNumberInput), findsOneWidget); + }); + + testWidgets('shows label text', (tester) async { + await tester.pumpWidget(_buildInput(labelText: 'My Label')); + await tester.pumpAndSettle(); + expect(find.text('My Label'), findsOneWidget); + }); + + testWidgets('shows initial value formatted in the text field', (tester) async { + await tester.pumpWidget(_buildInput(initialValue: 42)); + await tester.pumpAndSettle(); + final tf = tester.widget(find.byType(TextField)); + expect(tf.controller!.text, equals('42')); + }); + + testWidgets('shows empty field when value is null', (tester) async { + await tester.pumpWidget(_buildInput(initialValue: null)); + await tester.pumpAndSettle(); + final tf = tester.widget(find.byType(TextField)); + expect(tf.controller!.text, equals('')); + }); + + testWidgets('shows suffixText when provided', (tester) async { + await tester.pumpWidget(_buildInput(suffixText: '°C')); + await tester.pumpAndSettle(); + expect(find.text('°C'), findsOneWidget); + }); + + testWidgets('shows prefixText when provided', (tester) async { + await tester.pumpWidget(_buildInput(prefixText: '\$')); + await tester.pumpAndSettle(); + expect(find.text('\$'), findsOneWidget); + }); + + testWidgets('shows required asterisk when isRequired is true', (tester) async { + await tester.pumpWidget(_buildInput(isRequired: true)); + await tester.pumpAndSettle(); + expect(find.text('*'), findsOneWidget); + }); + + testWidgets('shows error text when errors list is non-empty', (tester) async { + await tester.pumpWidget(_buildInput(errors: ['Value is required'])); + await tester.pumpAndSettle(); + expect(find.text('Value is required'), findsOneWidget); + }); + + testWidgets('hides error space when hideDetails is true', (tester) async { + await tester.pumpWidget(_buildInput(errors: ['Hidden error'], hideDetails: true)); + await tester.pumpAndSettle(); + expect(find.text('Hidden error'), findsNothing); + }); + }); + + // ───────────────────────────────────────────────────────────── + // onChanged — keyboard input + // ───────────────────────────────────────────────────────────── + group('onChanged via keyboard', () { + testWidgets('calls onChanged with parsed num when user types a number', (tester) async { + num? received; + await tester.pumpWidget(_buildInput(onChanged: (v) => received = v)); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), '42'); + await tester.pump(); + + expect(received, equals(42)); + }); + + testWidgets('calls onChanged(null) when user clears the field', (tester) async { + num? received = 99; + await tester.pumpWidget(_buildInput(initialValue: 99, onChanged: (v) => received = v)); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), ''); + await tester.pump(); + + expect(received, isNull); + }); + + testWidgets('does NOT call onChanged when user types only a minus sign', (tester) async { + int callCount = 0; + await tester.pumpWidget(_buildInput(onChanged: (_) => callCount++)); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), '-'); + await tester.pump(); + + // "-" alone should not trigger onChanged + expect(callCount, equals(0)); + }); + + testWidgets('does NOT call onChanged when input cannot be parsed (C3 regression)', (tester) async { + // Regression for C3: tryParse returning null must not fire onChanged(null) + // We simulate this by directly calling the internal onChanged of ThemedTextInput + // with a string that passes the regex but cannot be parsed as a number (e.g. just "-"). + // "-" is handled separately, so we use a value that slips through in edge cases: + // a lone decimal separator "." which passes the regex but tryParse returns null. + num? lastReceived = 99; // sentinel — should stay 99 if onChanged is NOT called + await tester.pumpWidget(_buildInput(initialValue: 99, onChanged: (v) => lastReceived = v)); + await tester.pumpAndSettle(); + + // Enter a value that the formatter allows but NumberFormat.tryParse returns null for + await tester.enterText(find.byType(TextField), '.'); + await tester.pump(); + + // onChanged(null) must NOT have been called — lastReceived stays 99 + expect(lastReceived, equals(99)); + }); + + testWidgets('calls onChanged with negative number', (tester) async { + num? received; + await tester.pumpWidget(_buildInput(onChanged: (v) => received = v)); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), '-5'); + await tester.pump(); + + expect(received, equals(-5)); + }); + }); + + // ───────────────────────────────────────────────────────────── + // STEP BUTTONS — increment / decrement + // ───────────────────────────────────────────────────────────── + group('step buttons', () { + testWidgets('increment button increases value by step', (tester) async { + num? current = 5; + await tester.pumpWidget( + _buildReactive( + getValue: () => current, + setValue: (v) => current = v, + step: 1, + ), + ); + await tester.pumpAndSettle(); + + // suffix icon = add button + await tester.tap(find.byIcon(LayrzIcons.solarOutlineAddSquare)); + await tester.pumpAndSettle(); + + expect(current, equals(6)); + }); + + testWidgets('decrement button decreases value by step', (tester) async { + num? current = 5; + await tester.pumpWidget( + _buildReactive( + getValue: () => current, + setValue: (v) => current = v, + step: 1, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(LayrzIcons.solarOutlineMinusSquare)); + await tester.pumpAndSettle(); + + expect(current, equals(4)); + }); + + testWidgets('step defaults to 1 when not provided', (tester) async { + num? current = 10; + await tester.pumpWidget( + _buildReactive( + getValue: () => current, + setValue: (v) => current = v, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(LayrzIcons.solarOutlineAddSquare)); + await tester.pumpAndSettle(); + + expect(current, equals(11)); + }); + + testWidgets('value starts at 0 when null and step button is tapped', (tester) async { + num? current; + await tester.pumpWidget( + _buildReactive( + getValue: () => current, + setValue: (v) => current = v, + step: 1, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(LayrzIcons.solarOutlineAddSquare)); + await tester.pumpAndSettle(); + + expect(current, equals(1)); // 0 + 1 + }); + + testWidgets('increment is disabled at maximum — does not call onChanged', (tester) async { + int callCount = 0; + num? current = 10; + await tester.pumpWidget( + _buildReactive( + getValue: () => current, + setValue: (v) { + current = v; + callCount++; + }, + step: 1, + maximum: 10, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(LayrzIcons.solarOutlineAddSquare)); + await tester.pumpAndSettle(); + + expect(callCount, equals(0)); + expect(current, equals(10)); + }); + + testWidgets('decrement is disabled at minimum — does not call onChanged', (tester) async { + int callCount = 0; + num? current = 0; + await tester.pumpWidget( + _buildReactive( + getValue: () => current, + setValue: (v) { + current = v; + callCount++; + }, + step: 1, + minimum: 0, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(LayrzIcons.solarOutlineMinusSquare)); + await tester.pumpAndSettle(); + + expect(callCount, equals(0)); + expect(current, equals(0)); + }); + + testWidgets('step buttons are hidden when hidePrefixSuffixActions is true', (tester) async { + await tester.pumpWidget(_buildInput(hidePrefixSuffixActions: true)); + await tester.pumpAndSettle(); + + expect(find.byIcon(LayrzIcons.solarOutlineAddSquare), findsNothing); + expect(find.byIcon(LayrzIcons.solarOutlineMinusSquare), findsNothing); + }); + + testWidgets('step buttons are hidden when disabled is true', (tester) async { + await tester.pumpWidget(_buildInput(disabled: true, initialValue: 5)); + await tester.pumpAndSettle(); + + expect(find.byIcon(LayrzIcons.solarOutlineAddSquare), findsNothing); + expect(find.byIcon(LayrzIcons.solarOutlineMinusSquare), findsNothing); + }); + }); + + // ───────────────────────────────────────────────────────────── + // CURSOR POSITION — step bug regression + // ───────────────────────────────────────────────────────────── + group('cursor position after step', () { + testWidgets('cursor is at end after incrementing from 9 to 10 (digit count changes)', (tester) async { + // Regression: cursor was left at position 1 (between "1" and "0") instead of end. + num? current = 9; + await tester.pumpWidget( + _buildReactive( + getValue: () => current, + setValue: (v) => current = v, + step: 1, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(LayrzIcons.solarOutlineAddSquare)); + await tester.pumpAndSettle(); + + expect(current, equals(10)); + + final tf = tester.widget(find.byType(TextField)); + final cursorOffset = tf.controller!.selection.extentOffset; + final textLength = tf.controller!.text.length; + + // Cursor MUST be at the end — not stuck at old position 1 + expect( + cursorOffset, + equals(textLength), + reason: 'Cursor should be at end after step, got $cursorOffset on "${tf.controller!.text}"', + ); + }); + + testWidgets('cursor is at end after incrementing from 99 to 100', (tester) async { + num? current = 99; + await tester.pumpWidget( + _buildReactive( + getValue: () => current, + setValue: (v) => current = v, + step: 1, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(LayrzIcons.solarOutlineAddSquare)); + await tester.pumpAndSettle(); + + expect(current, equals(100)); + + final tf = tester.widget(find.byType(TextField)); + final cursorOffset = tf.controller!.selection.extentOffset; + final textLength = tf.controller!.text.length; + + expect(cursorOffset, equals(textLength), reason: 'Cursor stuck at $cursorOffset on "${tf.controller!.text}"'); + }); + + testWidgets('cursor is at end after decrementing from 10 to 9 (digit count shrinks)', (tester) async { + num? current = 10; + await tester.pumpWidget( + _buildReactive( + getValue: () => current, + setValue: (v) => current = v, + step: 1, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(LayrzIcons.solarOutlineMinusSquare)); + await tester.pumpAndSettle(); + + expect(current, equals(9)); + + final tf = tester.widget(find.byType(TextField)); + final cursorOffset = tf.controller!.selection.extentOffset; + final textLength = tf.controller!.text.length; + + expect(cursorOffset, equals(textLength), reason: 'Cursor stuck at $cursorOffset on "${tf.controller!.text}"'); + }); + + testWidgets('cursor is at end after incrementing into thousands (999 to 1,000)', (tester) async { + num? current = 999; + await tester.pumpWidget( + _buildReactive( + getValue: () => current, + setValue: (v) => current = v, + step: 1, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(LayrzIcons.solarOutlineAddSquare)); + await tester.pumpAndSettle(); + + expect(current, equals(1000)); + + final tf = tester.widget(find.byType(TextField)); + final cursorOffset = tf.controller!.selection.extentOffset; + final textLength = tf.controller!.text.length; + + expect(cursorOffset, equals(textLength), reason: 'Cursor stuck at $cursorOffset on "${tf.controller!.text}"'); + }); + }); + + // ───────────────────────────────────────────────────────────── + // DECIMAL SEPARATORS + // ───────────────────────────────────────────────────────────── + group('decimal separators', () { + testWidgets('dot separator formats 1234.5 as "1,234.5"', (tester) async { + await tester.pumpWidget( + _buildInput(initialValue: 1234.5, decimalSeparator: ThemedDecimalSeparator.dot, maximumDecimalDigits: 2), + ); + await tester.pumpAndSettle(); + + final tf = tester.widget(find.byType(TextField)); + expect(tf.controller!.text, equals('1,234.5')); + }); + + testWidgets('comma separator formats 1234.5 as "1.234,5"', (tester) async { + await tester.pumpWidget( + _buildInput(initialValue: 1234.5, decimalSeparator: ThemedDecimalSeparator.comma, maximumDecimalDigits: 2), + ); + await tester.pumpAndSettle(); + + final tf = tester.widget(find.byType(TextField)); + expect(tf.controller!.text, equals('1.234,5')); + }); + }); + + // ───────────────────────────────────────────────────────────── + // LIFECYCLE — memory / controller disposal + // ───────────────────────────────────────────────────────────── + group('lifecycle', () { + testWidgets('widget mounts and unmounts without errors', (tester) async { + // Regression for C1: controller was not disposed, causing errors on unmount + await tester.pumpWidget(_buildInput(initialValue: 42)); + await tester.pumpAndSettle(); + + // Replace with an empty container to force disposal + await tester.pumpWidget(const MaterialApp(home: Scaffold(body: SizedBox()))); + await tester.pumpAndSettle(); + + // No exceptions should have been thrown + expect(tester.takeException(), isNull); + }); + + testWidgets('repeated mount/unmount cycles do not throw', (tester) async { + for (int i = 0; i < 5; i++) { + await tester.pumpWidget(_buildInput(initialValue: i.toDouble())); + await tester.pumpAndSettle(); + await tester.pumpWidget(const MaterialApp(home: Scaffold(body: SizedBox()))); + await tester.pumpAndSettle(); + } + expect(tester.takeException(), isNull); + }); + + testWidgets('controller text clears when value changes to null', (tester) async { + // Regression: _updateCursorOffset must reset text to '' when value becomes null + num? current = 42; + late StateSetter externalSetState; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + externalSetState = setState; + return ThemedNumberInput( + labelText: 'Number', + value: current, + onChanged: (v) => setState(() => current = v), + ); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Force value to null from outside + externalSetState(() => current = null); + await tester.pumpAndSettle(); + + final tf = tester.widget(find.byType(TextField)); + expect(tf.controller!.text, equals('')); + }); + }); + }); +}