diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1a1ee1e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## [0.0.1] - 12 October 2022 + +- Initial release. diff --git a/README.md b/README.md index 5d983ce..2a7ffd3 100644 --- a/README.md +++ b/README.md @@ -2,34 +2,83 @@ [![style: effective dart](https://img.shields.io/badge/style-effective_dart-40c4ff.svg)](https://github.com/tenhobi/effective_dart) -Short description of what your package is, why you created it. What issues it fixes and how it works. Also mention the available platforms +Custom widget that allows for an inputfield spilt over multiple fields +Ported from the appshell. ## Setup -What setup steps are neccesarry and why +To use this package, add `flutter_single_character_input` as a [dependency in your pubspec.yaml file](https://flutter.dev/docs/development/platform-integration/platform-channels). -
-PLATFORM - -specific platform steps - -
+```dart + flutter_single_character_input: + git: + url: https://github.com/Iconica-Development/flutter_single_character_input.git + ref: master +``` ## How to use -How can we use the package descibe the most common ways with examples in - ```dart - codeblocks + SingleCharacterInput( + characters: [ + InputCharacter( + hint: '1', + keyboardType: const TextInputType.numberWithOptions( + signed: true, + decimal: true, + ), + formatter: (value) { + if (RegExp('[0-9]').hasMatch(value)) { + return value; + } + return ''; + }, + ), + InputCharacter( + hint: 'B', + keyboardType: TextInputType.name, + formatter: (value) { + if (RegExp('[A-Za-z]').hasMatch(value)) { + return value.toUpperCase(); + } + return ''; + }, + ), + ], + textStyle: Theme.of(context).textTheme.headline1?.copyWith( + fontWeight: FontWeight.w400, + fontSize: 28, + ), + inputDecoration: InputDecoration( + hintStyle: Theme.of(context).textTheme.bodyText1?.copyWith( + fontWeight: FontWeight.w400, + color: const Color(0xFFBBBBBB), + fontSize: 28, + ), + isDense: true, + isCollapsed: true, + ), + buildDecoration: (context, input) { + return Container( + margin: const EdgeInsets.all(5), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 15), + width: 32, + child: input, + ), + ); + }, + onChanged: (value, finished) {}, + ), ``` ## Issues -Please file any issues, bugs or feature request as an issue on our [GitHub](REPO URL) page. Commercial support is available if you need help with integration with your app or services. You can contact us at [support@iconica.nl](mailto:support@iconica.nl). +Please file any issues, bugs or feature request as an issue on our [GitHub](https://github.com/Iconica-Development/flutter_single_character_input) page. Commercial support is available if you need help with integration with your app or services. You can contact us at [support@iconica.nl](mailto:support@iconica.nl). ## Want to contribute -If you would like to contribute to the plugin (e.g. by improving the documentation, solving a bug or adding a cool new feature), please carefully review our [contribution guide](./CONTRIBUTING.md) and send us your [pull request](URL TO PULL REQUEST TAB IN REPO). +If you would like to contribute to the plugin (e.g. by improving the documentation, solving a bug or adding a cool new feature), please carefully review our [contribution guide](./CONTRIBUTING.md) and send us your [pull request](https://github.com/Iconica-Development/flutter_single_character_input/pulls). ## Author diff --git a/analysis_options.yaml b/analysis_options.yaml index 0530d9c..eaf2f3e 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -3,212 +3,3 @@ analyzer: errors: todo: ignore exclude: [lib/generated_plugin_registrant.dart] -linter: - # https://dart.dev/tools/linter-rules#lints - rules: - # error rules - always_use_package_imports: false - avoid_dynamic_calls: true - avoid_empty_else: true - avoid_print: true - avoid_relative_lib_imports: true - avoid_returning_null_for_future: true - avoid_slow_async_io: true - avoid_type_to_string: true - avoid_types_as_parameter_names: true - avoid_web_libraries_in_flutter: true - cancel_subscriptions: true - close_sinks: true - comment_references: false - control_flow_in_finally: true - diagnostic_describe_all_properties: false - empty_statements: true - hash_and_equals: true - invariant_booleans: true - iterable_contains_unrelated_type: true - list_remove_unrelated_type: true - literal_only_boolean_expressions: true - no_adjacent_strings_in_list: true - no_duplicate_case_values: true - no_logic_in_create_state: true - prefer_relative_imports: false - prefer_void_to_null: true - test_types_in_equals: true - throw_in_finally: true - unnecessary_statements: true - unrelated_type_equality_checks: true - unsafe_html: true - use_build_context_synchronously: true - use_key_in_widget_constructors: true - valid_regexps: true - # style rules - always_declare_return_types: true - always_put_control_body_on_new_line: true - always_put_required_named_parameters_first: true - always_require_non_null_named_parameters: true - always_specify_types: false - annotate_overrides: true - avoid_annotating_with_dynamic: false - avoid_bool_literals_in_conditional_expressions: true - avoid_catches_without_on_clauses: false - avoid_catching_errors: false - avoid_classes_with_only_static_members: true - avoid_double_and_int_checks: true - avoid_equals_and_hash_code_on_mutable_classes: false - avoid_escaping_inner_quotes: false - avoid_field_initializers_in_const_classes: true - avoid_final_parameters: true - avoid_function_literals_in_foreach_calls: true - avoid_implementing_value_types: true - avoid_init_to_null: true - avoid_js_rounded_ints: true - avoid_multiple_declarations_per_line: true - avoid_null_checks_in_equality_operators: true - avoid_positional_boolean_parameters: true - avoid_private_typedef_functions: true - avoid_redundant_argument_values: false - avoid_renaming_method_parameters: true - avoid_return_types_on_setters: true - avoid_returning_null: true - avoid_returning_null_for_void: true - avoid_returning_this: true - avoid_setters_without_getters: true - avoid_shadowing_type_parameters: true - avoid_single_cascade_in_expression_statements: true - avoid_types_on_closure_parameters: false - avoid_unnecessary_containers: false - avoid_unused_constructor_parameters: true - avoid_void_async: true - await_only_futures: true - camel_case_extensions: true - camel_case_types: true - cascade_invocations: true - cast_nullable_to_non_nullable: true - conditional_uri_does_not_exist: true - constant_identifier_names: true - curly_braces_in_flow_control_structures: true - deprecated_consistency: true - directives_ordering: true - do_not_use_environment: true - empty_catches: true - empty_constructor_bodies: true - eol_at_end_of_file: true - exhaustive_cases: true - file_names: true - flutter_style_todos: true - implementation_imports: true - join_return_with_assignment: true - leading_newlines_in_multiline_strings: true - library_names: true - library_prefixes: true - library_private_types_in_public_api: true - lines_longer_than_80_chars: true - missing_whitespace_between_adjacent_strings: true - no_default_cases: true - no_leading_underscores_for_library_prefixes: true - no_leading_underscores_for_local_identifiers: true - no_runtimeType_toString: true - non_constant_identifier_names: true - noop_primitive_operations: true - null_check_on_nullable_type_parameter: true - null_closures: true - omit_local_variable_types: true - one_member_abstracts: true - only_throw_errors: true - overridden_fields: true - package_api_docs: true - package_prefixed_library_names: true - parameter_assignments: true - prefer_adjacent_string_concatenation: true - prefer_asserts_in_initializer_lists: true - prefer_asserts_with_message: true - prefer_collection_literals: true - prefer_conditional_assignment: true - prefer_const_constructors: true - prefer_const_constructors_in_immutables: true - prefer_const_declarations: false - prefer_const_literals_to_create_immutables: false - prefer_constructors_over_static_methods: true - prefer_contains: true - prefer_double_quotes: false - prefer_equal_for_default_values: true - prefer_expression_function_bodies: false - prefer_final_fields: true - prefer_final_in_for_each: false - prefer_final_locals: false - prefer_final_parameters: false - prefer_for_elements_to_map_fromIterable: true - prefer_foreach: true - prefer_function_declarations_over_variables: true - prefer_generic_function_type_aliases: true - prefer_if_elements_to_conditional_expressions: true - prefer_if_null_operators: true - prefer_initializing_formals: true - prefer_inlined_adds: true - prefer_int_literals: false - prefer_interpolation_to_compose_strings: true - prefer_is_empty: true - prefer_is_not_empty: true - prefer_is_not_operator: true - prefer_iterable_whereType: true - prefer_mixin: true - prefer_null_aware_method_calls: true - prefer_null_aware_operators: true - prefer_single_quotes: true - prefer_spread_collections: true - prefer_typing_uninitialized_variables: true - provide_deprecation_message: true - public_member_api_docs: false - recursive_getters: true - require_trailing_commas: true - sized_box_for_whitespace: true - sized_box_shrink_expand: true - slash_for_doc_comments: true - sort_child_properties_last: true - sort_constructors_first: true - sort_unnamed_constructors_first: true - tighten_type_of_initializing_formals: true - type_annotate_public_apis: true - type_init_formals: true - unawaited_futures: true - unnecessary_await_in_return: true - unnecessary_brace_in_string_interps: true - unnecessary_const: false - unnecessary_constructor_name: true - unnecessary_final: true - unnecessary_getters_setters: true - unnecessary_lambdas: true - unnecessary_late: true - unnecessary_new: true - unnecessary_null_aware_assignments: true - unnecessary_null_checks: true - unnecessary_null_in_if_null_operators: true - unnecessary_nullable_for_final_variable_declarations: true - unnecessary_overrides: true - unnecessary_parenthesis: true - unnecessary_raw_strings: true - unnecessary_string_escapes: true - unnecessary_string_interpolations: true - unnecessary_this: true - use_decorated_box: true - use_full_hex_values_for_flutter_colors: true - use_function_type_syntax_for_parameters: true - use_if_null_to_convert_nulls_to_bools: true - use_is_even_rather_than_modulo: true - use_late_for_private_fields_and_variables: true - use_named_constants: true - use_raw_strings: false - use_rethrow_when_possible: true - use_setters_to_change_properties: true - use_string_buffers: true - use_test_throws_matchers: true - use_to_and_as_if_applicable: true - void_checks: true - # pub rules - depend_on_referenced_packages: true - lowercase_with_underscores: true - secure_pubspec_urls: false - sort_pub_dependencies: false - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/example/lib/main.dart b/example/lib/main.dart index 8273dd2..33bd3a6 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,17 +1,111 @@ import 'package:flutter/material.dart'; -import 'package:flutter_single_character_input/flutter_single_character_input.dart'; +import 'package:flutter_single_character_input/single_character_input.dart'; void main() { runApp(const MaterialApp(home: FlutterSingleCharacterInputDemo())); } -class FlutterSingleCharacterInputDemo extends StatelessWidget { +class FlutterSingleCharacterInputDemo extends StatefulWidget { const FlutterSingleCharacterInputDemo({Key? key}) : super(key: key); + @override + State createState() => + _FlutterSingleCharacterInputDemoState(); +} + +class _FlutterSingleCharacterInputDemoState + extends State { + CharacterFormatter get _numberFormatter => (String value) { + if (RegExp('[0-9]').hasMatch(value)) { + return value; + } + return ''; + }; + + CharacterFormatter get _textFormatter => (String value) { + if (RegExp('[A-Za-z]').hasMatch(value)) { + return value.toUpperCase(); + } + return ''; + }; + @override Widget build(BuildContext context) { - return const Scaffold( - body: SingleCharacterInput(), + return Scaffold( + body: Center( + child: SingleCharacterInput( + characters: [ + InputCharacter( + hint: '1', + keyboardType: const TextInputType.numberWithOptions( + signed: true, + decimal: true, + ), + formatter: _numberFormatter, + ), + InputCharacter( + hint: '2', + keyboardType: const TextInputType.numberWithOptions( + signed: true, + decimal: true, + ), + formatter: _numberFormatter, + ), + InputCharacter( + hint: '3', + keyboardType: const TextInputType.numberWithOptions( + signed: true, + decimal: true, + ), + formatter: _numberFormatter, + ), + InputCharacter( + hint: '4', + keyboardType: const TextInputType.numberWithOptions( + signed: true, + decimal: true, + ), + formatter: _numberFormatter, + ), + InputCharacter( + hint: 'A', + keyboardType: TextInputType.name, + formatter: _textFormatter, + ), + InputCharacter( + hint: 'B', + keyboardType: TextInputType.name, + formatter: _textFormatter, + ), + ], + textStyle: Theme.of(context).textTheme.headline1?.copyWith( + fontWeight: FontWeight.w400, + fontSize: 28, + ), + inputDecoration: InputDecoration( + hintStyle: Theme.of(context).textTheme.bodyText1?.copyWith( + fontWeight: FontWeight.w400, + color: const Color(0xFFBBBBBB), + fontSize: 28, + ), + isDense: true, + isCollapsed: true, + ), + buildDecoration: (context, input) { + return Container( + margin: const EdgeInsets.all(5), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 15), + width: 32, + child: input, + ), + ); + }, + onChanged: (value, finished) { + // setState(() {}); + }, + ), + ), ); } } diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index 233546e..8b13789 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -1,29 +1 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. -import 'package:example/main.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const FlutterSingleCharacterInputDemo()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -} diff --git a/lib/flutter_single_character_input.dart b/lib/flutter_single_character_input.dart deleted file mode 100644 index 98280b2..0000000 --- a/lib/flutter_single_character_input.dart +++ /dev/null @@ -1,3 +0,0 @@ -library flutter_single_character_input; - -export 'src/flutter_single_character_input.dart'; diff --git a/lib/single_character_input.dart b/lib/single_character_input.dart new file mode 100644 index 0000000..619d3b8 --- /dev/null +++ b/lib/single_character_input.dart @@ -0,0 +1,4 @@ +library single_character_input; + +export 'src/input_character.dart'; +export 'src/single_character_input.dart'; diff --git a/lib/src/flutter_single_character_input.dart b/lib/src/flutter_single_character_input.dart deleted file mode 100644 index b1a9f11..0000000 --- a/lib/src/flutter_single_character_input.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/src/widgets/container.dart'; -import 'package:flutter/src/widgets/framework.dart'; - -class SingleCharacterInput extends StatefulWidget { - const SingleCharacterInput({super.key}); - - @override - State createState() => _SingleCharacterInputState(); -} - -class _SingleCharacterInputState extends State { - @override - Widget build(BuildContext context) { - return const Text('HELLO THERE'); - } -} diff --git a/lib/src/input_character.dart b/lib/src/input_character.dart new file mode 100644 index 0000000..8d51953 --- /dev/null +++ b/lib/src/input_character.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +class InputCharacter { + const InputCharacter({ + required this.keyboardType, + this.readOnly = false, + this.hint = '', + this.formatter, + }); + + final TextInputType keyboardType; + final CharacterFormatter? formatter; + final bool readOnly; + final String hint; + + String format(String value) { + return formatter?.call(value) ?? value; + } +} + +typedef CharacterFormatter = String Function(String); diff --git a/lib/src/single_character_input.dart b/lib/src/single_character_input.dart new file mode 100644 index 0000000..65f0e19 --- /dev/null +++ b/lib/src/single_character_input.dart @@ -0,0 +1,306 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_single_character_input/src/input_character.dart'; + +class SingleCharacterInput extends StatefulWidget { + const SingleCharacterInput({ + required this.characters, + required this.onChanged, + this.textAlign = TextAlign.center, + this.cursorTextAlign = TextAlign.center, + this.buildCustomInput, + this.buildDecoration, + this.inputDecoration, + this.spacing = 0, + this.textStyle, + Key? key, + }) : super(key: key); + + final Widget Function(BuildContext context, List inputs)? + buildCustomInput; + + /// Called when building a single input. Can be used to wrap the input. + final Widget Function(BuildContext context, Widget input)? buildDecoration; + + /// Gets called when the value is changed. + /// Passes the changed value and if all inputs are filled in. + final void Function(String value, bool isComplete) onChanged; + + final InputDecoration? inputDecoration; + + /// List of all character fields which are used to create inputs. + final List characters; + + final TextAlign textAlign; + final TextAlign cursorTextAlign; + final double spacing; + final TextStyle? textStyle; + + @override + State createState() => _SingleCharacterInputState(); +} + +class _SingleCharacterInputState extends State + with SingleTickerProviderStateMixin { + late final TextEditingController _mainController; + late final Map _mainNodes; + String _currentValue = ''; + int _currentIndex = 0; + late TextInputType _currentKeyboard; + late Animation _cursorAnimation; + late AnimationController _cursorAnimationController; + + @override + void initState() { + super.initState(); + _mainController = TextEditingController(); + _mainNodes = widget.characters + .asMap() + .map((key, value) => MapEntry(value.keyboardType, FocusNode())); + _currentKeyboard = widget.characters.first.keyboardType; + _cursorAnimationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 500), + ); + _cursorAnimation = Tween(begin: 0.0, end: 0.8).animate( + CurvedAnimation( + curve: Curves.linear, + parent: _cursorAnimationController, + ), + ); + + _cursorAnimationController + ..addStatusListener((AnimationStatus status) { + if (status == AnimationStatus.completed) { + _cursorAnimationController.repeat(reverse: true); + } + }) + ..forward(); + + _mainNodes.forEach((key, value) { + value.addListener(() { + setState(() {}); + }); + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + _mainNodes[_currentKeyboard]?.requestFocus(); + setState(() {}); + }); + } + + @override + void dispose() { + _mainController.dispose(); + for (var element in _mainNodes.values) { + element.dispose(); + } + _cursorAnimationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + WidgetsBinding.instance.addPostFrameCallback((_) { + _mainNodes[_currentKeyboard]?.unfocus(); + _mainNodes[_currentKeyboard]?.requestFocus(); + }); + setState(() {}); + }, + child: Wrap( + direction: Axis.horizontal, + children: [ + Offstage( + child: Column( + children: _mainNodes + .map((key, value) { + return MapEntry( + key, + TextField( + focusNode: value, + controller: _mainController, + textCapitalization: TextCapitalization.characters, + keyboardType: key, + inputFormatters: [ + FilteringTextInputFormatter.allow( + RegExp('[a-zA-Z0-9]'), + ), + LengthLimitingTextInputFormatter( + widget.characters.length, + ), + ], + onChanged: (String value) { + if (value.length > _currentIndex) { + var result = widget.characters[_currentIndex] + .format(value[_currentIndex]); + if (value[_currentIndex] != result) { + value = value.replaceRange( + _currentIndex, + _currentIndex + 1, + result, + ); + _mainController.value = + _mainController.value.copyWith( + text: value, + selection: TextSelection.collapsed( + offset: value.length, + ), + ); + } + } + _onChanged(value); + }, + ), + ); + }) + .values + .toList(), + ), + ), + if (widget.buildCustomInput != null) ...[ + widget.buildCustomInput!.call( + context, + widget.characters + .asMap() + .map( + (key, value) => MapEntry(key, _createCharacter(key)), + ) + .values + .toList(), + ), + ] else ...[ + for (var i = 0; i < widget.characters.length; i++) ...[ + _createCharacter(i), + if (i < widget.characters.length - 1 && widget.spacing > 0) ...[ + SizedBox( + height: widget.spacing, + width: widget.spacing, + ), + ] + ], + ], + ], + ), + ); + } + + void _onChanged(String value) { + widget.onChanged.call(value, value.length == widget.characters.length); + setState(() { + _currentValue = value; + }); + + var nextIndex = min(_currentValue.length, widget.characters.length - 1); + if (_currentIndex != nextIndex) { + setState(() { + _currentIndex = nextIndex; + }); + } + + var nextKeyboard = widget.characters[_currentIndex].keyboardType; + if (_currentKeyboard != nextKeyboard) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _mainNodes[nextKeyboard]?.requestFocus(); + }); + setState(() { + _currentKeyboard = nextKeyboard; + }); + } + } + + Widget _createCharacter(int index) { + var char = widget.characters[index]; + if (char.readOnly) { + return Text( + char.hint, + style: widget.textStyle, + ); + } + Widget input = TextField( + textCapitalization: TextCapitalization.characters, + decoration: widget.inputDecoration?.copyWith( + label: _createLabel(index), + floatingLabelBehavior: FloatingLabelBehavior.never, + ), + textAlign: widget.textAlign, + style: widget.textStyle, + readOnly: true, + onTap: () { + WidgetsBinding.instance.addPostFrameCallback((_) { + _mainNodes[_currentKeyboard]?.unfocus(); + _mainNodes[_currentKeyboard]?.requestFocus(); + }); + setState(() {}); + }, + controller: TextEditingController.fromValue( + TextEditingValue(text: _getCurrentInputValue(index)), + ), + ); + if (widget.buildDecoration != null) { + return widget.buildDecoration!.call(context, input); + } + return input; + } + + String _getCurrentInputValue(index) { + if (_currentValue.length > index) { + return _currentValue[index]; + } + return ''; + } + + bool _hasFocus() { + return _mainNodes.values.any((element) => element.hasFocus); + } + + Widget _createLabel(int index) { + if (index < _currentValue.length) { + return const SizedBox.shrink(); + } + if (index == _currentValue.length && _hasFocus()) { + return AnimatedBuilder( + animation: _cursorAnimation, + builder: (context, _) { + return Opacity( + opacity: _cursorAnimation.value, + child: Container( + alignment: _getAlignment(widget.cursorTextAlign), + child: Text( + '|', + style: widget.textStyle, + textAlign: widget.cursorTextAlign, + ), + ), + ); + }, + ); + } else { + return Container( + alignment: _getAlignment(widget.textAlign), + child: Text( + widget.characters[index].hint, + style: widget.inputDecoration?.hintStyle ?? widget.textStyle, + textAlign: widget.textAlign, + ), + ); + } + } + + Alignment _getAlignment(TextAlign textAlign) { + switch (textAlign) { + case TextAlign.left: + case TextAlign.start: + return Alignment.centerLeft; + case TextAlign.right: + case TextAlign.end: + return Alignment.centerRight; + case TextAlign.center: + case TextAlign.justify: + return Alignment.center; + } + } +} diff --git a/test/flutter-single-character-input_test.dart b/test/single_character_input_test.dart similarity index 100% rename from test/flutter-single-character-input_test.dart rename to test/single_character_input_test.dart