diff --git a/stevia/lib/src/widgets/foundation/text_input_formatters.dart b/stevia/lib/src/widgets/foundation/text_input_formatters.dart new file mode 100644 index 00000000..4a1a24aa --- /dev/null +++ b/stevia/lib/src/widgets/foundation/text_input_formatters.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:sugar/sugar.dart'; +import 'dart:math' as math; + +/// A [IntTextInputFormatter] validates whether the text being edited is an integer in a given [Range]. +/// +/// There is **no** guarantee that the text being edited is an integer since it may be empty or `-`. +/// +/// Empty text and a single `-` are ignored. Furthermore, a [IntTextInputFormatter] trims all commas separating parts of +/// the integer, and leading and trailing whitespaces. For example, both ` ` and `-` are allowed while ` 1,000 ` is trimmed +/// to `1000`. +/// +/// +/// It is recommended to use set the enclosing [TextField]'s `keyboardType` to [TextInputType.number]. +/// +/// ```dart +/// TextField( +/// keyboardType: TextInputType.number, +/// inputFormatters: [ IntTextInputFormatter(Interval.closedOpen(0, 5)) ], // 0 <= value < 5 +/// ); +/// ``` +class IntTextInputFormatter extends TextInputFormatter { + final Range _range; + + /// Creates a [IntTextInputFormatter] in the given [Range]. + IntTextInputFormatter(this._range): super(); + + @override + TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { + if (newValue.text.isEmpty || newValue.text == '-') { + return newValue; + } + + final trimmed = newValue.text.trim().replaceAll(',', ''); + return switch (int.tryParse(trimmed)) { + final value? when _range.contains(value) => switch (newValue.text.length == trimmed.length) { + true => newValue, + false => TextEditingValue( + text: trimmed, + selection: newValue.selection.copyWith( + baseOffset: math.min(newValue.selection.start, trimmed.length), + extentOffset: math.min(newValue.selection.end, trimmed.length), + ), + composing: !newValue.composing.isCollapsed && trimmed.length > newValue.composing.start ? TextRange( + start: newValue.composing.start, + end: math.min(newValue.composing.end, trimmed.length), + ): TextRange.empty, + ) + }, + _ => oldValue, + }; + } +} diff --git a/stevia/lib/widgets.dart b/stevia/lib/widgets.dart index dab45cd4..2e2d33a5 100644 --- a/stevia/lib/widgets.dart +++ b/stevia/lib/widgets.dart @@ -13,9 +13,10 @@ /// * [StreamValueBuilder] /// /// ## Foundation -/// General-purpose widgets. +/// General-purpose widgets and Flutter services. /// /// * [ColorFilters] +/// * [IntTextInputFormatter] /// /// ## Resizable /// Widgets that contain children which can be resized either horizontally or vertically. @@ -38,6 +39,7 @@ export 'src/widgets/async/future/future_builder.dart' show export 'src/widgets/async/stream_value_builder.dart'; export 'src/widgets/foundation/color_filters.dart' hide Matrix5; +export 'src/widgets/foundation/text_input_formatters.dart'; export 'src/widgets/resizable/resizable_box.dart'; export 'src/widgets/resizable/resizable_icon.dart'; diff --git a/stevia/test/src/widgets/foundation/text_input_formatters_test.dart b/stevia/test/src/widgets/foundation/text_input_formatters_test.dart new file mode 100644 index 00000000..e8d295eb --- /dev/null +++ b/stevia/test/src/widgets/foundation/text_input_formatters_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart' hide Interval; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:stevia/stevia.dart'; +import 'package:sugar/sugar.dart'; + +void main() { + group('IntTextInputFormatter', () { + late Widget widget; + + setUp(() => widget = MaterialApp( + home: Scaffold( + body: TextField( + keyboardType: TextInputType.number, + inputFormatters: [ IntTextInputFormatter(Interval.closedOpen(-50, 50)) ], + ), + ) + )); + + for (final (actual, expected) in [ + ('-50', '-50'), + ('-51', ''), + ('49', '49'), + ('50', ''), + ('-', '-'), + ('0.0', ''), + (' 0 ', '0'), + ('1,0', '10'), + (' 1,0 ', '10'), + ]) { + testWidgets('values', (tester) async { + await tester.pumpWidget(widget); + await tester.enterText(find.byType(TextField), actual); + + expect(find.text(expected), findsOneWidget); + }); + } + + testWidgets('empty string', (tester) async { + await tester.pumpWidget(widget); + await tester.enterText(find.byType(TextField), '1'); + await tester.enterText(find.byType(TextField), ''); + + expect(find.text(''), findsOneWidget); + }); + }); +}