diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..11d127b9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,53 @@ +--- +name: "\U0001F41B Bug" +about: Something is crashing or not working as intended +labels: bug + +--- + +## Environment + +**Package version:** + +
+ Flutter doctor + + +``` +``` + +
+ +
+ Code sample + + + +```dart +``` + +
+ +## Description + +**Expected behavior:** + +**Current behavior:** + +## Steps to reproduce + +1. This +2. Than that +3. Then + +## Images + +## Stacktrace/Logcat diff --git a/.github/ISSUE_TEMPLATE/documentation.md b/.github/ISSUE_TEMPLATE/documentation.md new file mode 100644 index 00000000..b243d471 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.md @@ -0,0 +1,18 @@ +--- +name: "\U0001F4C3 Documentation Bug" +about: You want to report something that is wrong or missing from the documentation. +labels: documentation + +--- + +### Describe the change you would like to see + + +### How would the suggested change make the documentation more useful? + + +### Additional context + \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..a6b3c866 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,18 @@ +--- +name: "\U0001F680 Feature request" +about: Suggest new feature or request for this project +labels: enhancement + +--- + +## Environment + +**Package version:** + +## Description + +**What you'd like to happen:** + +**Alternatives you've considered:** + +**Images:** diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 00000000..0e9a57f1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,14 @@ +--- +name: "\U0001F914 Questions and Help" +about: You have a quetion or need help using this packages +labels: question + +--- + +## Environment + +**Package version:** + +## Describe your question + \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.MD b/.github/PULL_REQUEST_TEMPLATE.MD new file mode 100644 index 00000000..95ff2b04 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.MD @@ -0,0 +1,20 @@ +## Connection with issue(s) + + +Close #??? + + +Connected to #??? + +## Solution description + +## Screenshots or Videos + + + +## To Do + +- [ ] Check the original issue to confirm it is fully satisfied +- [ ] Add solution description to help guide reviewers +- [ ] Add unit test to verify new or fixed behaviour +- [ ] If apply, add documentation to code properties and package readme \ No newline at end of file diff --git a/.github/workflows/reactive_forms.yaml b/.github/workflows/reactive_forms.yaml index 938bbb71..15b08303 100644 --- a/.github/workflows/reactive_forms.yaml +++ b/.github/workflows/reactive_forms.yaml @@ -21,7 +21,7 @@ jobs: - uses: actions/checkout@v3 - uses: subosito/flutter-action@v2 with: - flutter-version: "3.0.0" + flutter-version: "3.16.0" channel: "stable" - run: flutter pub get - run: flutter test --no-pub --coverage @@ -34,7 +34,7 @@ jobs: - uses: actions/checkout@v3 - uses: subosito/flutter-action@v2 with: - flutter-version: "3.0.0" + flutter-version: "3.16.0" channel: "stable" - run: flutter pub get - name: Analyze lib @@ -49,13 +49,13 @@ jobs: - uses: actions/checkout@v3 - uses: subosito/flutter-action@v2 with: - flutter-version: "3.0.0" + flutter-version: "3.16.0" channel: "stable" - run: flutter pub get - name: Format lib - run: flutter format lib --set-exit-if-changed + run: dart format lib --set-exit-if-changed - name: Format test - run: flutter format test --set-exit-if-changed + run: dart format test --set-exit-if-changed publish-warnings: name: Publish warnings diff --git a/CHANGELOG.md b/CHANGELOG.md index 240be932..a731f1f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,100 @@ +# 17.0.0 + +## Breaking changes + +- Removed deprecated `onWillPop` from `ReactiveForm` and `ReactiveFormBuilder` widgets. +It was replaced with the `PopScope` widget. +- `Validators.number` allows now to define negative numbers and decimal numbers with the addition +of two optional arguments `allowNegatives` and `allowedDecimals`. + +## Features + +- Add `canPop` and `onPopInvoked` to `ReactiveForm` and `ReactiveFormBuilder` widgets. + +# 16.1.1 + +## Fixes + +- Add default Context Menu to `ReactiveTextField`. + +# 16.1.0 + +## Features + +- Add `markAsPending()` method to `AbstractControl` to allow set the status +to PENDING by demand. + +# 16.0.4 + +## Fixes + +- Add missing properties to `ReactiveSwitchListTile.adaptative()` widget. +- Add `showError()` to `ReactiveCheckbox` and `ReactiveCheckboxListTile` widgets. This does not +display any error messages but it is now compatible with the Flutter builtin behavior of Checkboxes +when Material 3 is enabled (`ThemeData(useMaterial3: true)`) in the active App Theme. + +## Enhances + +- Update `Readme.md` file with testing examples in the section +`ReactiveForm vs ReactiveFormBuilder which one?` + +# 16.0.3 + +## Fixes + +- Fix an issue with `FormGroup` and `FormArray` were recursive `Raw Value` was not working properly. + +# 16.0.2 + +## Fixes + +- Fix an issue with `FormBuilder` when trying to build a control with a nullable '?' data type. +- Fix an issue with `FormGroup` that was not triggering the event `collectionChanges` when a control +is removed. +- Fix an issue with `FormGroup` and `FormArray` when trying to find a control with a nullable '?' +data type. + +# 16.0.1 + +## Fixes + +- Update the intl dependency version, because in flutter 3.10, +`flutter_localizations` depends on intl 0.18.0. + +# 16.0.0 + +## Breaking Changes + +- Flutter >= 3.10 required for this version. + +## Fixes + +- Updated some documentation. +- Expose validator classes to allow direct instantiation. + +# 15.0.0 + +## Breaking Changes + +- All validators have been changed to classes with `const` constructors. +- The Asynchronous Validator is now a class from where any custom async validator can inherit. + +## Features + +- A new validator `Validators.delegate(...)` has been introduced to be used with a custom + validation function. +- A new validator `Validators.delegateAsync(...)` has been introduced to be used with a custom + async validation functions. + +# 14.3.0 + +- Fix the inkwell ripple effect in the **ReactiveDropdownField**. +- Add some other minor fixes. + +# 14.2.0 + +- Update intl to latest version 0.18.0. + # 14.1.0 ## Enhances diff --git a/README.md b/README.md index 697e5b4d..b9eaa6a8 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,8 @@ samples, guidance on mobile development, and a full API reference. ## Minimum Requirements -- Dart SDK: >= 2.17.0 <3.0.0 -- Flutter: >= 3.0.0 +- Dart SDK: >=3.2.0 <4.0.0 +- Flutter: >=3.16.0 > For using **Reactive Forms** in projects below Flutter 2.8.0 please use the version <= 10.7.0 of > **Reactive Forms**. @@ -88,7 +88,7 @@ dependencies: flutter: sdk: flutter - reactive_forms: ^14.1.0 + reactive_forms: ^17.0.0 ``` Then run the command `flutter packages get` on the console. @@ -209,22 +209,67 @@ There are common predefined validators, but you can implement custom validators ### Custom Validators -A custom **FormControl** validator is a function that receives the _control_ to validate and returns a **Map**. If the value of the _control_ is valid the function must returns **null** otherwise returns a **Map** with a key and custom information, in the previous example we just set **true** as custom information. +All validators are instances of classes that inherit from the `Validator` abstract class. +In order to implement a custom validator you can follow two different approaches: -Let's implement a custom validator that validates a control's value must be _true_: +1- Extend from `Validator` class and override the `validate` method. +2- Or implement a custom validator function|method, and use it with the `Validators.delegate(...)` validator. + +Let's implement a custom validator that validates a control's value must be `true`: + +### Inheriting from `Validator` class: + +Let's create a class that extends from `Validator` and overrides the `validate` method: + +```dart +/// Validator that validates the control's value must be `true`. +class RequiredTrueValidator extends Validator { + const RequiredTrueValidator() : super(); + + @override + Map? validate(AbstractControl control) { + return control.isNotNull && + control.value is bool && + control.value == true + ? null + : {'requiredTrue': true}; + } +} +``` + +The `validator` method is a function that receives the _control_ to validate and returns a `Map`. If the value of the _control_ is valid the function returns `null`, otherwise returns a `Map` with the error key and a custom information. In the previous example we have defined `requiredTrue` as the error key and `true` as the custom information. + +In order to use the new validator class we provide an instance of it in the FormControl definition. ```dart final form = FormGroup({ 'acceptLicense': FormControl( value: false, - validators: [_requiredTrue], // custom validator + validators: [ + RequiredTrueValidator(), // providing the new custom validator + ], ), }); ``` +### Using the `Validators.delegate()` validator: + +Sometimes it's more convenient to implement a custom validator in a separate method|function than in a different new class. In that case, it is necessary to use the `Validators.delegate()` validator. It creates a validator that delegates the validation to the external function|method. + ```dart -/// Validates that control's value must be `true` -Map _requiredTrue(AbstractControl control) { +final form = FormGroup({ + 'acceptLicense': FormControl( + value: false, + validators: [ + Validators.delegate(_requiredTrue) // delegates validation to a custom function + ], + ), +}); +``` + +```dart +/// Custom function that validates that control's value must be `true`. +Map? _requiredTrue(AbstractControl control) { return control.isNotNull && control.value is bool && control.value == true @@ -233,7 +278,7 @@ Map _requiredTrue(AbstractControl control) { } ``` -> You can see the current implementation of predefined validators in the source code to see more examples. +> Check the [Migration Guide](https://github.com/joanpablo/reactive_forms/wiki/Migration-Guide/_edit#breaking-changes-in-15x) to learn more about custom validators after version 15.0.0 of the package. ### Pattern Validator @@ -281,7 +326,7 @@ There are special validators that can be attached to **FormGroup**. In the next There are some cases where we want to implement a Form where a validation of a field depends on the value of another field. For example a sign-up form with _email_ and _emailConfirmation_ or _password_ and _passwordConfirmation_. -For that cases we could implement a custom validator and attach it to the **FormGroup**, let's see an example: +For those cases we could implement a custom validator as a class and attach it to the **FormGroup**. Let's see an example: ```dart final form = FormGroup({ @@ -293,7 +338,7 @@ final form = FormGroup({ ]), 'passwordConfirmation': FormControl(), }, validators: [ - _mustMatch('password', 'passwordConfirmation') + MustMatchValidator(controlName: 'password', matchingControlName: 'passwordConfirmation') ]); ``` @@ -304,8 +349,17 @@ In the previous code we have added two more fields to the form: _password_ and _ However the most important thing here is that we have attached a **validator** to the **FormGroup**. This validator is a custom validator and the implementation follows as: ```dart -ValidatorFunction _mustMatch(String controlName, String matchingControlName) { - return (AbstractControl control) { +class MustMatchValidator extends Validator { + final String controlName; + final String matchingControlName; + + MustMatchValidator({ + required this.controlName, + required this.matchingControlName, + }) : super(); + + @override + Map? validate(AbstractControl control) { final form = control as FormGroup; final formControl = form.control(controlName); @@ -321,7 +375,7 @@ ValidatorFunction _mustMatch(String controlName, String matchingControlName) { } return null; - }; + } } ``` @@ -344,7 +398,7 @@ final form = FormGroup({ Some times you want to perform a validation against a remote server, this operations are more time consuming and need to be done asynchronously. -For example you want to validate that the _email_ the user is currently typing in a _registration form_ is unique and is not already used in your application. **Asynchronous Validators** are just another tool so use it wisely. +For example you want to validate that the _email_ the user is currently typing in a _registration form_ is unique and is not already used in your application. **Asynchronous Validators** are just another tool so use them wisely. **Asynchronous Validators** are very similar to their synchronous counterparts, with the following difference: @@ -365,7 +419,9 @@ final form = FormGroup({ Validators.required, // traditional required and email validators Validators.email, ], - asyncValidators: [_uniqueEmail], // custom asynchronous validator :) + asyncValidators: [ + UniqueEmailAsyncValidator(), // custom asynchronous validator :) + ], ), }); ``` @@ -373,34 +429,44 @@ final form = FormGroup({ We have declared a simple **Form** with an email **field** that is _required_ and must have a valid email value, and we have include a custom async validator that will validate if the email is unique. Let's see the implementation of our new async validator: ```dart -/// just a simple array to simulate a database of emails in a server -const inUseEmails = ['johndoe@email.com', 'john@email.com']; - -/// Async validator example that simulates a request to a server -/// and validates if the email of the user is unique. -Future> _uniqueEmail(AbstractControl control) async { - final error = {'unique': false}; +/// Validator that validates the user's email is unique, sending a request to +/// the Server. +class UniqueEmailAsyncValidator extends AsyncValidator { + @override + Future?> validate(AbstractControl control) async { + final error = {'unique': false}; - final emailAlreadyUsed = await Future.delayed( - Duration(seconds: 5), // a delay to simulate a time consuming operation - () => inUseEmails.contains(control.value), - ); + final isUniqueEmail = await _getIsUniqueEmail(control.value.toString()); + if (!isUniqueEmail) { + control.markAsTouched(); + return error; + } - if (emailAlreadyUsed) { - control.markAsTouched(); - return error; + return null; } - return null; + /// Simulates a time consuming operation (i.e. a Server request) + Future _getIsUniqueEmail(String email) { + // simple array that simulates emails stored in the Server DB. + final storedEmails = ['johndoe@email.com', 'john@email.com']; + + return Future.delayed( + const Duration(seconds: 5), + () => !storedEmails.contains(email), + ); + } } ``` > Note the use of **control.markAsTouched()** to force the validation message to show up as soon as possible. -The previous implementation was a simple function that receives the **AbstractControl** and returns a [Future](https://api.dart.dev/stable/dart-async/Future-class.html) that completes 5 seconds after its call and performs a simple check: if the _value_ of the _control_ is contained in the _server_ array of emails. +The previous implementation was a simple validator that receives the **AbstractControl** and returns a [Future](https://api.dart.dev/stable/dart-async/Future-class.html) that completes 5 seconds after its call and performs a simple check: if the _value_ of the _control_ is contained in the _server_ array of emails. > If you want to see **Async Validators** in action with a **full example** using widgets and animations to feedback the user we strong advice you to visit our [Wiki](https://github.com/joanpablo/reactive_forms/wiki/Asynchronous-Validators). We have not included the full example in this README.md file just to simplify things here and to not anticipate things that we will see later in this doc. +> The validator `Validators.delegateAsync()` is another way to implement custom validator, for more reference +> check the [Custom validators](https://github.com/joanpablo/reactive_forms#custom-validators) section. + ### Debounce time in async validators Asynchronous validators have a debounce time that is useful if you want to minimize requests to a remote API. The debounce time is set in milliseconds and the default value is 250 milliseconds. @@ -409,7 +475,7 @@ You can set a different debounce time as an optionally argument in the **FormCon ```dart final control = FormControl( - asyncValidators: [_uniqueEmail], + asyncValidators: [UniqueEmailAsyncValidator()], asyncValidatorsDebounceTime: 1000, // sets 1 second of debounce time. ); ``` @@ -973,7 +1039,7 @@ set name(String newName) { > form.markAllAsTouched(); > ``` -### Overriding Reactive Widgets _show errors_ behavior +### Overriding Reactive Widgets show errors behavior The second way to customize when to show error messages is to override the method **showErrors** in reactive widgets. @@ -1505,6 +1571,86 @@ You should use **ReactiveFormBuilder** if: But the final decision is really up to you, you can use any of them in any situations ;) +## Widget testing + +**note: mark your fields with `Key`'s for easy access via widget tester** + +### example component + +```dart +class LoginForm extends StatefulWidget { + const LoginForm({Key? key}) : super(key: key); + + @override + LoginFormState createState() => LoginFormState(); +} + +class LoginFormState extends State { + final form = FormGroup({ + 'email': FormControl(validators: [Validators.required, Validators.email]), + 'password': FormControl(validators: [Validators.required]), + }); + + @override + Widget build(BuildContext context) { + return ReactiveForm( + formGroup: form, + child: Column( + children: [ + ReactiveTextField( + key: const Key('email'), + formControlName: 'email', + ), + ReactiveTextField( + key: const Key('password'), + formControlName: 'password', + obscureText: true, + ), + ElevatedButton( + key: const Key('submit'), + onPressed: () {}, + child: const Text('Submit'), + ), + ], + ), + ); + } +} +``` + +### example test + +```dart + +void main() { + testWidgets('LoginForm should pass with correct values', (tester) async { + // Build the widget. + await tester.pumpWidget(const MaterialApp( + home: Scaffold(body: LoginForm()), + )); + + await tester.enterText(find.byKey(const Key('email')), 'etc@test.qa'); + await tester.enterText(find.byKey(const Key('password')), 'password'); + + await tester.tap(find.byKey(const Key('submit'))); + + await tester.pump(); + + // Expect to find the item on screen if needed + expect(find.text('etc@test.qa'), findsOneWidget); + + // Get form state + final LoginFormState loginFormState = tester.state(find.byType(LoginForm)); + + // Check form state + expect(loginFormState.form.valid, true); + }); +} + +``` + + + ## Reactive Forms + [Provider](https://pub.dev/packages/provider) plugin :muscle: Although **Reactive Forms** can be used with any state management library or even without any one at all, **Reactive Forms** gets its maximum potential when is used in combination with a state management library like the [Provider](https://pub.dev/packages/provider) plugin. diff --git a/analysis_options.yaml b/analysis_options.yaml index d4fc3508..705fe1d0 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -2,11 +2,9 @@ include: package:lints/recommended.yaml analyzer: language: + strict-casts: true strict-inference: true strict-raw-types: true - strong-mode: - implicit-dynamic: false - implicit-casts: false linter: rules: - always_declare_return_types diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index f852a55f..6a21e162 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -39,7 +39,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.example.reactive_forms_example" - minSdkVersion 16 + minSdkVersion flutter.minSdkVersion targetSdkVersion 29 versionCode flutterVersionCode.toInteger() versionName flutterVersionName diff --git a/example/android/build.gradle b/example/android/build.gradle index 2ed02f9a..b5ae9fa3 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -26,6 +26,6 @@ subprojects { project.evaluationDependsOn(':app') } -task clean(type: Delete) { +tasks.register("clean", Delete) { delete rootProject.buildDir } diff --git a/example/lib/main.dart b/example/lib/main.dart index b5954542..8717eda8 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -40,7 +40,14 @@ class ReactiveFormsApp extends StatelessWidget { 'uniqueEmail': (_) => 'This email is already in use', }, child: MaterialApp( - theme: customTheme, + theme: ThemeData( + useMaterial3: true, + inputDecorationTheme: const InputDecorationTheme( + border: OutlineInputBorder(), + floatingLabelBehavior: FloatingLabelBehavior.auto, + alignLabelWithHint: true, + ), + ), routes: { Routes.complex: (_) => ComplexSample(), Routes.simple: (_) => SimpleSample(), @@ -55,11 +62,3 @@ class ReactiveFormsApp extends StatelessWidget { ); } } - -final customTheme = ThemeData.light().copyWith( - inputDecorationTheme: const InputDecorationTheme( - border: OutlineInputBorder(), - floatingLabelBehavior: FloatingLabelBehavior.auto, - alignLabelWithHint: true, - ), -); diff --git a/example/lib/sample_screen.dart b/example/lib/sample_screen.dart index 8aff4871..a168e88b 100644 --- a/example/lib/sample_screen.dart +++ b/example/lib/sample_screen.dart @@ -5,8 +5,7 @@ class SampleScreen extends StatelessWidget { final Widget body; final Widget? title; - const SampleScreen({Key? key, required this.body, this.title}) - : super(key: key); + const SampleScreen({super.key, required this.body, this.title}); @override Widget build(BuildContext context) { diff --git a/example/lib/samples/add_dynamic_controls_sample.dart b/example/lib/samples/add_dynamic_controls_sample.dart index fc34097e..48ed0aea 100644 --- a/example/lib/samples/add_dynamic_controls_sample.dart +++ b/example/lib/samples/add_dynamic_controls_sample.dart @@ -7,8 +7,8 @@ class ViewModelProvider extends InheritedWidget { ViewModelProvider({ required this.viewModel, - required Widget child, - }) : super(child: child); + required super.child, + }); static NewContactViewModel? of(BuildContext context) => context.findAncestorWidgetOfExactType()?.viewModel; diff --git a/example/lib/samples/array_sample.dart b/example/lib/samples/array_sample.dart index d05fa726..8c352ffb 100644 --- a/example/lib/samples/array_sample.dart +++ b/example/lib/samples/array_sample.dart @@ -10,7 +10,12 @@ class ArraySample extends StatefulWidget { class _ArraySampleState extends State { final contacts = ['john@email.com', 'susan@email.com', 'caroline@email.com']; final form = FormGroup({ - 'selectedContacts': FormArray([], validators: [_emptyAddressee]), + 'selectedContacts': FormArray( + [], + validators: [ + Validators.delegate(_emptyAddressee), + ], + ), }); FormArray get selectedContacts => diff --git a/example/lib/samples/complex_sample.dart b/example/lib/samples/complex_sample.dart index 45646d6c..1078a5b0 100644 --- a/example/lib/samples/complex_sample.dart +++ b/example/lib/samples/complex_sample.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart' hide ProgressIndicator; import 'package:reactive_forms/reactive_forms.dart'; import 'package:reactive_forms_example/progress_indicator.dart'; import 'package:reactive_forms_example/sample_screen.dart'; +import 'package:reactive_forms_example/samples/validators/unique_email_async_validator.dart'; class BooleanObject { final String name; @@ -13,7 +14,7 @@ class BooleanObject { other is BooleanObject && name == other.name; @override - int get hashCode => hashValues(name, name); + int get hashCode => Object.hash(name, name); } final yes = BooleanObject('Yes'); @@ -23,7 +24,7 @@ class ComplexSample extends StatelessWidget { FormGroup buildForm() => fb.group({ 'email': FormControl( validators: [Validators.required, Validators.email], - asyncValidators: [_uniqueEmail], + asyncValidators: [UniqueEmailAsyncValidator()], ), 'password': ['', Validators.required, Validators.minLength(8)], 'passwordConfirmation': '', @@ -48,6 +49,7 @@ class ComplexSample extends StatelessWidget { form: buildForm, builder: (context, form, child) { return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ReactiveTextField( formControlName: 'email', @@ -257,25 +259,3 @@ class ComplexSample extends StatelessWidget { ); } } - -/// Async validator in use emails example -const inUseEmails = ['johndoe@email.com', 'john@email.com']; - -/// Async validator example that simulates a request to a server -/// to validate if the email of the user is unique. -Future?> _uniqueEmail( - AbstractControl control) async { - final error = {'unique': false}; - - final emailAlreadyInUse = await Future.delayed( - const Duration(seconds: 5), // delay to simulate a time consuming operation - () => inUseEmails.contains(control.value.toString()), - ); - - if (emailAlreadyInUse) { - control.markAsTouched(); - return error; - } - - return null; -} diff --git a/example/lib/samples/login_sample.dart b/example/lib/samples/login_sample.dart index 95bb56a3..21aedec1 100644 --- a/example/lib/samples/login_sample.dart +++ b/example/lib/samples/login_sample.dart @@ -8,7 +8,10 @@ class LoginSample extends StatelessWidget { validators: [Validators.required, Validators.email], ), 'password': ['', Validators.required, Validators.minLength(8)], - 'rememberMe': false, + 'acceptTerms': FormControl( + value: false, + validators: [Validators.requiredTrue], + ), }); @override @@ -19,6 +22,7 @@ class LoginSample extends StatelessWidget { form: buildForm, builder: (context, form, child) { return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ReactiveTextField( formControlName: 'email', @@ -55,11 +59,10 @@ class LoginSample extends StatelessWidget { errorStyle: TextStyle(height: 0.7), ), ), - Row( - children: [ - ReactiveCheckbox(formControlName: 'rememberMe'), - const Text('Remember me') - ], + const SizedBox(height: 16.0), + ReactiveCheckboxListTile( + formControlName: 'acceptTerms', + title: const Text('Accept terms & conditions'), ), const SizedBox(height: 16.0), ElevatedButton( @@ -72,12 +75,16 @@ class LoginSample extends StatelessWidget { }, child: const Text('Sign Up'), ), + const SizedBox(height: 16.0), ElevatedButton( - onPressed: () => form.resetState({ - 'email': ControlState(value: null), - 'password': ControlState(value: null), - 'rememberMe': ControlState(value: false), - }, removeFocus: true), + onPressed: () => form.resetState( + { + 'email': ControlState(value: null), + 'password': ControlState(value: null), + 'acceptTerms': ControlState(value: false), + }, + removeFocus: true, + ), child: const Text('Reset all'), ), ], diff --git a/example/lib/samples/validators/unique_email_async_validator.dart b/example/lib/samples/validators/unique_email_async_validator.dart new file mode 100644 index 00000000..69993bf8 --- /dev/null +++ b/example/lib/samples/validators/unique_email_async_validator.dart @@ -0,0 +1,30 @@ +import 'package:reactive_forms/reactive_forms.dart'; + +/// Validator that validates the user's email is unique, sending a request to +/// the Server. +class UniqueEmailAsyncValidator extends AsyncValidator { + @override + Future?> validate( + AbstractControl control) async { + final error = {'unique': false}; + + final isUniqueEmail = await _getIsUniqueEmail(control.value.toString()); + if (!isUniqueEmail) { + control.markAsTouched(); + return error; + } + + return null; + } + + /// Simulates a time consuming operation (i.e. a Server request) + Future _getIsUniqueEmail(String email) { + // simple array that simulates emails stored in the Server DB. + final storedEmails = ['johndoe@email.com', 'john@email.com']; + + return Future.delayed( + const Duration(seconds: 5), + () => !storedEmails.contains(email), + ); + } +} diff --git a/example/pubspec.yaml b/example/pubspec.yaml index ba595993..850092e5 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -18,34 +18,30 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev version: 1.0.0+1 environment: - sdk: ">=2.17.0 <3.0.0" - flutter: ">=3.0.0" + sdk: ">=3.2.0 <4.0.0" + flutter: ">=3.16.0" dependencies: flutter: sdk: flutter - intl: ^0.17.0 + intl: ^0.19.0 reactive_forms: path: ../ #reactive_forms_widgets: ^0.3.1 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.5 + cupertino_icons: ^1.0.6 #reactive_dropdown_search: ^0.9.0 #reactive_touch_spin: ^0.6.0 #reactive_segmented_control: ^0.4.0 #reactive_date_time_picker: ^0.4.0 dev_dependencies: - lints: ^2.0.0 + flutter_lints: ^3.0.2 flutter_test: sdk: flutter -dependency_overrides: - reactive_forms: - path: ../ - # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/lib/reactive_forms.dart b/lib/reactive_forms.dart index f7420302..80c35905 100644 --- a/lib/reactive_forms.dart +++ b/lib/reactive_forms.dart @@ -18,11 +18,32 @@ export 'src/models/control_state.dart'; export 'src/models/control_status.dart'; export 'src/models/focus_controller.dart'; export 'src/models/form_builder.dart'; -export 'src/models/form_control_collection.dart'; export 'src/models/models.dart'; export 'src/utils/control_extensions.dart'; export 'src/utils/control_utils.dart'; +export 'src/validators/any_validator.dart'; +export 'src/validators/async_validator.dart'; export 'src/validators/compare_option.dart'; +export 'src/validators/compare_validator.dart'; +export 'src/validators/compose_or_validator.dart'; +export 'src/validators/compose_validator.dart'; +export 'src/validators/contains_validator.dart'; +export 'src/validators/credit_card_validator.dart'; +export 'src/validators/delegate_async_validator.dart'; +export 'src/validators/delegate_validator.dart'; +export 'src/validators/email_validator.dart'; +export 'src/validators/equals_validator.dart'; +export 'src/validators/max_length_validator.dart'; +export 'src/validators/max_validator.dart'; +export 'src/validators/min_length_validator.dart'; +export 'src/validators/min_validator.dart'; +export 'src/validators/must_match_validator.dart'; +export 'src/validators/number_validator.dart'; +export 'src/validators/pattern/default_pattern_evaluator.dart'; +export 'src/validators/pattern/pattern_evaluator.dart'; +export 'src/validators/pattern/regex_pattern_evaluator.dart'; +export 'src/validators/pattern_validator.dart'; +export 'src/validators/required_validator.dart'; export 'src/validators/validation_message.dart'; export 'src/validators/validator.dart'; export 'src/validators/validators.dart'; @@ -38,7 +59,6 @@ export 'src/widgets/inherited_streamer.dart'; export 'src/widgets/reactive_checkbox.dart'; export 'src/widgets/reactive_checkbox_list_tile.dart'; export 'src/widgets/reactive_date_picker.dart'; -export 'src/widgets/reactive_date_picker.dart'; export 'src/widgets/reactive_dropdown_field.dart'; export 'src/widgets/reactive_focusable_form_field.dart'; export 'src/widgets/reactive_form.dart'; diff --git a/lib/src/models/form_builder.dart b/lib/src/models/form_builder.dart index 7c415b4a..f3f6352e 100644 --- a/lib/src/models/form_builder.dart +++ b/lib/src/models/form_builder.dart @@ -61,11 +61,11 @@ class FormBuilder { /// ``` FormGroup group( Map controls, [ - List validators = const [], - List asyncValidators = const [], + List> validators = const [], + List> asyncValidators = const [], ]) { final map = controls - .map>((String key, Object value) { + .map>((String key, Object value) { if (value is String) { return MapEntry(key, FormControl(value: value)); } else if (value is int) { @@ -78,39 +78,39 @@ class FormBuilder { return MapEntry(key, FormControl(value: value)); } else if (value is TimeOfDay) { return MapEntry(key, FormControl(value: value)); - } else if (value is AbstractControl) { + } else if (value is AbstractControl) { return MapEntry(key, value); - } else if (value is ValidatorFunction) { - return MapEntry(key, FormControl(validators: [value])); - } else if (value is List) { - return MapEntry(key, FormControl(validators: value)); + } else if (value is Validator) { + return MapEntry(key, FormControl(validators: [value])); + } else if (value is List>) { + return MapEntry(key, FormControl(validators: value)); } else if (value is List) { if (value.isEmpty) { - return MapEntry(key, FormControl()); + return MapEntry(key, FormControl()); } else { final defaultValue = value.first; final validators = List.of(value.skip(1)); if (validators.isNotEmpty && - validators.any((validator) => validator is! ValidatorFunction)) { + validators.any((validator) => validator is! Validator)) { throw FormBuilderInvalidInitializationException( 'Invalid validators initialization'); } - if (defaultValue is ValidatorFunction) { + if (defaultValue is Validator) { throw FormBuilderInvalidInitializationException( 'Expected first value in array to be default value of the control and not a validator.'); } final effectiveValidators = validators - .map((v) => v! as ValidatorFunction) + .map>((v) => v! as Validator) .toList(); - final control = _control(defaultValue, effectiveValidators); - return MapEntry(key, control as AbstractControl); + + return MapEntry(key, _control(defaultValue, effectiveValidators)); } } - return MapEntry(key, FormControl(value: value)); + return MapEntry(key, FormControl(value: value)); }); return FormGroup( @@ -161,8 +161,8 @@ class FormBuilder { /// ``` FormControl control( T value, [ - List validators = const [], - List asyncValidators = const [], + List> validators = const [], + List> asyncValidators = const [], ]) { return FormControl( value: value, @@ -201,8 +201,8 @@ class FormBuilder { /// FormArray array( List value, [ - List validators = const [], - List asyncValidators = const [], + List> validators = const [], + List> asyncValidators = const [], ]) { return FormArray( value.map>((v) { @@ -221,7 +221,7 @@ class FormBuilder { } FormControl _control( - dynamic value, List validators) { + dynamic value, List> validators) { if (value is AbstractControl) { throw FormBuilderInvalidInitializationException( 'Default value of control must not be an AbstractControl.'); @@ -241,7 +241,7 @@ class FormBuilder { return FormControl(value: value); } - return FormControl(value: value, validators: validators); + return FormControl(value: value, validators: validators); } } diff --git a/lib/src/models/form_control_collection.dart b/lib/src/models/form_control_collection.dart deleted file mode 100644 index 31defe19..00000000 --- a/lib/src/models/form_control_collection.dart +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright 2020 Joan Pablo Jimenez Milian. All rights reserved. -// Use of this source code is governed by the MIT license that can be -// found in the LICENSE file. - -import 'dart:async'; - -import 'package:flutter/foundation.dart'; -import 'package:reactive_forms/reactive_forms.dart'; - -/// The base class form [FormGroup] and [FormArray]. -/// Its provides methods for get a control by name and a [Listenable] -/// that emits events each time you add or remove a control to the collection. -abstract class FormControlCollection { - final _collectionChanges = - StreamController>>.broadcast(); - - /// Retrieves a child control given the control's [name] or path. - /// - /// The [name] is a dot-delimited string that define the path to the - /// control. - /// - /// Throws [FormControlNotFoundException] if no control founded with - /// the specified [name]/path. - AbstractControl control(String name); - - /// Checks if collection contains a control by a given [name]. - /// - /// Returns true if collection contains the control, otherwise returns false. - bool contains(String name); - - /// Emits when a control is added or removed from collection. - Stream>> get collectionChanges => - _collectionChanges.stream; - - /// Close stream that emit collection change events - void closeCollectionEvents() { - _collectionChanges.close(); - } - - /// Notify to listeners that the collection changed. - /// - /// This is for internal use only. - @protected - void emitsCollectionChanged(List> controls) { - _collectionChanges.add(List.unmodifiable(controls)); - } - - /// Walks the [path] to find the matching control. - /// - /// Returns null if no match is found. - AbstractControl? findControlInCollection(List path) { - if (path.isEmpty) { - return null; - } - - final result = path.fold(this as AbstractControl, (control, name) { - if (control is FormControlCollection) { - final collection = control; - return collection.contains(name) ? collection.control(name) : null; - } else { - return null; - } - }); - - return result != null ? result as AbstractControl : null; - } -} diff --git a/lib/src/models/models.dart b/lib/src/models/models.dart index 47b28395..9d809f32 100644 --- a/lib/src/models/models.dart +++ b/lib/src/models/models.dart @@ -22,9 +22,9 @@ abstract class AbstractControl { final _statusChanges = StreamController.broadcast(); final _valueChanges = StreamController.broadcast(); final _touchChanges = StreamController.broadcast(); - final List _validators = []; - final List _asyncValidators = - []; + final List> _validators = >[]; + final List> _asyncValidators = + >[]; StreamSubscription?>? _asyncValidationSubscription; Map _errors = {}; @@ -47,8 +47,8 @@ abstract class AbstractControl { /// Constructor of the [AbstractControl]. AbstractControl({ - List validators = const [], - List asyncValidators = const [], + List> validators = const [], + List> asyncValidators = const [], int asyncValidatorsDebounceTime = 250, bool disabled = false, bool touched = false, @@ -89,8 +89,8 @@ abstract class AbstractControl { /// /// In [FormGroup] these come in handy when you want to perform validation /// that considers the value of more than one child control. - List get validators => - List.unmodifiable(_validators); + List> get validators => + List>.unmodifiable(_validators); /// Sets the synchronous [validators] that are active on this control. Calling /// this overwrites any existing sync validators. @@ -111,7 +111,7 @@ abstract class AbstractControl { /// This argument is only taking into account if [autoValidate] is equals to /// `true`. void setValidators( - List validators, { + List> validators, { bool autoValidate = false, bool updateParent = true, bool emitEvent = true, @@ -137,8 +137,8 @@ abstract class AbstractControl { /// /// In [FormGroup] these come in handy when you want to perform validation /// that considers the value of more than one child control. - List get asyncValidators => - List.unmodifiable(_asyncValidators); + List> get asyncValidators => + List>.unmodifiable(_asyncValidators); /// Sets the async [validators] that are active on this control. Calling this /// overwrites any existing async validators. @@ -159,7 +159,7 @@ abstract class AbstractControl { /// This argument is only taking into account if [autoValidate] is equals to /// `true`. void setAsyncValidators( - List validators, { + List> validators, { bool autoValidate = false, bool updateParent = true, bool emitEvent = true, @@ -228,6 +228,8 @@ abstract class AbstractControl { /// * VALID: This control has passed all validation checks. /// * INVALID: This control has failed at least one validation check. /// * PENDING: This control is in the midst of conducting a validation check. + /// * DISABLED: This control is exempt from ancestor calculations of + /// validity or value. /// /// These status values are mutually exclusive, so a control cannot be both /// valid AND invalid or invalid AND pending. @@ -420,6 +422,28 @@ abstract class AbstractControl { _updateAncestors(updateParent); } + /// Marks the control as `pending`. + /// + /// A control is pending while the control performs async validation. + /// + /// When [updateParent] is false, mark only this control. When true or not + /// supplied, marks all direct ancestors. Default is true. + /// + /// When [emitEvent] is true or not supplied (the default), a [statusChanged] + /// event is emitted. + /// + void markAsPending({bool updateParent = true, bool emitEvent = true}) { + _status = ControlStatus.pending; + + if (emitEvent) { + this._statusChanges.add(_status); + } + + if (updateParent) { + parent?.markAsPending(updateParent: updateParent, emitEvent: emitEvent); + } + } + /// Disposes the control void dispose() { _statusChanges.close(); @@ -602,7 +626,7 @@ abstract class AbstractControl { Map _runValidators() { final errors = {}; for (final validator in validators) { - final error = validator(this); + final error = validator.validate(this); if (error != null) { errors.addAll(error); } @@ -676,8 +700,9 @@ abstract class AbstractControl { _debounceTimer = Timer( Duration(milliseconds: _asyncValidatorsDebounceTime), () { - final validatorsStream = Stream.fromFutures( - asyncValidators.map((validator) => validator(this)).toList()); + final validatorsStream = Stream.fromFutures(asyncValidators + .map((validator) => validator.validate(this)) + .toList()); final asyncValidationErrors = {}; _asyncValidationSubscription = validatorsStream.listen( @@ -808,18 +833,12 @@ class FormControl extends AbstractControl { /// FormControl({ T? value, - List validators = const [], - List asyncValidators = const [], - int asyncValidatorsDebounceTime = 250, - bool touched = false, - bool disabled = false, - }) : super( - validators: validators, - asyncValidators: asyncValidators, - asyncValidatorsDebounceTime: asyncValidatorsDebounceTime, - disabled: disabled, - touched: touched, - ) { + super.validators, + super.asyncValidators, + super.asyncValidatorsDebounceTime, + super.touched, + super.disabled, + }) { if (value != null) { this.value = value; } else { @@ -1009,6 +1028,76 @@ class FormControl extends AbstractControl { AbstractControl findControl(String path) => this; } +/// The base class form [FormGroup] and [FormArray]. +/// Its provides methods for get a control by name and a [Listenable] +/// that emits events each time you add or remove a control to the collection. +abstract class FormControlCollection extends AbstractControl { + FormControlCollection({ + super.validators, + super.asyncValidators, + super.asyncValidatorsDebounceTime, + super.disabled, + }); + + final _collectionChanges = + StreamController>>.broadcast(); + + /// Retrieves a child control given the control's [name] or path. + /// + /// The [name] is a dot-delimited string that define the path to the + /// control. + /// + /// Throws [FormControlNotFoundException] if no control founded with + /// the specified [name]/path. + AbstractControl control(String name); + + /// Checks if collection contains a control by a given [name]. + /// + /// Returns true if collection contains the control, otherwise returns false. + bool contains(String name); + + /// Gets the value of the control, including any disabled control. + /// + /// Retrieves all values regardless of disabled status of children controls. + T get rawValue; + + /// Emits when a control is added or removed from collection. + Stream>> get collectionChanges => + _collectionChanges.stream; + + /// Close stream that emit collection change events + void closeCollectionEvents() { + _collectionChanges.close(); + } + + /// Notify to listeners that the collection changed. + /// + /// This is for internal use only. + @protected + void emitsCollectionChanged(List> controls) { + _collectionChanges.add(List.unmodifiable(controls)); + } + + /// Walks the [path] to find the matching control. + /// + /// Returns null if no match is found. + AbstractControl? findControlInCollection(List path) { + if (path.isEmpty) { + return null; + } + + final result = path.fold?>(this, (control, name) { + if (control != null && control is FormControlCollection) { + return control.contains(name) ? control.control(name) : null; + } else { + return null; + } + }); + + return result; + } +} + /// Tracks the value and validity state of a group of FormControl instances. /// /// A FormGroup aggregates the values of each child FormControl into one object, @@ -1017,8 +1106,7 @@ class FormControl extends AbstractControl { /// It calculates its status by reducing the status values of its children. /// For example, if one of the controls in a group is invalid, the entire group /// becomes invalid. -class FormGroup extends AbstractControl> - with FormControlCollection { +class FormGroup extends FormControlCollection> { final Map> _controls = {}; /// Creates a new FormGroup instance. @@ -1057,17 +1145,14 @@ class FormGroup extends AbstractControl> /// See also [AbstractControl.validators] FormGroup( Map> controls, { - List validators = const [], - List asyncValidators = const [], - int asyncValidatorsDebounceTime = 250, + super.validators, + super.asyncValidators, + super.asyncValidatorsDebounceTime, bool disabled = false, }) : assert( !controls.keys.any((name) => name.contains(_controlNameDelimiter)), 'Control name should not contain dot($_controlNameDelimiter)'), super( - validators: validators, - asyncValidators: asyncValidators, - asyncValidatorsDebounceTime: asyncValidatorsDebounceTime, disabled: disabled, ) { addAll(controls); @@ -1080,8 +1165,15 @@ class FormGroup extends AbstractControl> /// Gets the value of the [FormGroup], including any disabled controls. /// /// Retrieves all values regardless of disabled status. - Map get rawValue => _controls - .map((key, control) => MapEntry(key, control.value)); + @override + Map get rawValue => + _controls.map((key, control) { + if (control is FormControlCollection) { + return MapEntry(key, control.rawValue); + } + + return MapEntry(key, control.value); + }); @override bool contains(String name) { @@ -1137,7 +1229,7 @@ class FormGroup extends AbstractControl> Map> get controls => Map>.unmodifiable(_controls); - /// Reduce the value of the group is a key-value pair for each control + /// Reduce the value of the group as a key-value pair for each control /// in the group. /// /// ### Example: @@ -1496,6 +1588,8 @@ class FormGroup extends AbstractControl> _controls.removeWhere((key, value) => key == name); updateValueAndValidity(updateParent: updateParent, emitEvent: emitEvent); + + emitsCollectionChanged(_controls.values.toList()); } @override @@ -1522,8 +1616,7 @@ class FormGroup extends AbstractControl> /// /// FormArray is one of the three fundamental building blocks used to define /// forms in Reactive Forms, along with [FormControl] and [FormGroup]. -class FormArray extends AbstractControl> - with FormControlCollection { +class FormArray extends FormControlCollection> { final List> _controls = []; /// Creates a new [FormArray] instance. @@ -1563,14 +1656,11 @@ class FormArray extends AbstractControl> /// See also [AbstractControl.validators] FormArray( List> controls, { - List validators = const [], - List asyncValidators = const [], - int asyncValidatorsDebounceTime = 250, + super.validators, + super.asyncValidators, + super.asyncValidatorsDebounceTime, bool disabled = false, }) : super( - validators: validators, - asyncValidators: asyncValidators, - asyncValidatorsDebounceTime: asyncValidatorsDebounceTime, disabled: disabled, ) { addAll(controls); @@ -1587,8 +1677,14 @@ class FormArray extends AbstractControl> /// Gets the value of the [FormArray], including any disabled controls. /// /// Retrieves all values regardless of disabled status. - List get rawValue => - _controls.map((control) => control.value).toList(); + @override + List get rawValue => _controls.map((control) { + if (control is FormControlCollection) { + return (control as FormControlCollection).rawValue; + } + + return control.value; + }).toList(); /// Sets the value of the [FormArray]. /// diff --git a/lib/src/validators/any_validator.dart b/lib/src/validators/any_validator.dart index 9d686de5..83834651 100644 --- a/lib/src/validators/any_validator.dart +++ b/lib/src/validators/any_validator.dart @@ -15,7 +15,7 @@ class AnyValidator extends Validator { /// Constructs an instance of the validator. /// /// The argument [test] must not be null. - AnyValidator(this.test); + const AnyValidator(this.test) : super(); @override Map? validate(AbstractControl control) { diff --git a/lib/src/validators/async_validator.dart b/lib/src/validators/async_validator.dart new file mode 100644 index 00000000..7f0d8fd4 --- /dev/null +++ b/lib/src/validators/async_validator.dart @@ -0,0 +1,17 @@ +// Copyright 2020 Joan Pablo Jimenez Milian. All rights reserved. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +import 'package:reactive_forms/reactive_forms.dart'; + +/// An abstract class extended by classes that perform asynchronous validation. +abstract class AsyncValidator { + const AsyncValidator(); + + /// Validates the [control]. + Future?> validate(AbstractControl control); + + Future?> call(AbstractControl control) { + return validate(control); + } +} diff --git a/lib/src/validators/compare_validator.dart b/lib/src/validators/compare_validator.dart index 6f551b52..283dfdeb 100644 --- a/lib/src/validators/compare_validator.dart +++ b/lib/src/validators/compare_validator.dart @@ -14,11 +14,11 @@ class CompareValidator extends Validator { /// /// The arguments [controlName], [compareControlName] and [compareOption] /// must not be null. - CompareValidator( + const CompareValidator( this.controlName, this.compareControlName, this.compareOption, - ); + ) : super(); @override Map? validate(AbstractControl control) { diff --git a/lib/src/validators/compose_or_validator.dart b/lib/src/validators/compose_or_validator.dart index 18c3833c..bd2bea8f 100644 --- a/lib/src/validators/compose_or_validator.dart +++ b/lib/src/validators/compose_or_validator.dart @@ -10,19 +10,19 @@ import 'package:reactive_forms/reactive_forms.dart'; /// returns 'null', otherwise returns the union of the individual error /// maps returned by each validator. class ComposeOrValidator extends Validator { - final List validators; + final List> validators; /// Constructs an instance of the validator. /// /// The argument [validators] must not be null. - ComposeOrValidator(this.validators); + const ComposeOrValidator(this.validators) : super(); @override Map? validate(AbstractControl control) { final composedError = {}; for (final validator in validators) { - final error = validator(control); + final error = validator.validate(control); if (error != null) { composedError.addAll(error); } else { diff --git a/lib/src/validators/compose_validator.dart b/lib/src/validators/compose_validator.dart index 2047fa5c..8f066e1a 100644 --- a/lib/src/validators/compose_validator.dart +++ b/lib/src/validators/compose_validator.dart @@ -10,19 +10,19 @@ import 'package:reactive_forms/reactive_forms.dart'; /// returns 'null', otherwise returns the union of the individual error /// maps returned by each validator. class ComposeValidator extends Validator { - final List validators; + final List> validators; /// Constructs an instance of the validator. /// /// The argument [validators] must not be null. - ComposeValidator(this.validators); + const ComposeValidator(this.validators) : super(); @override Map? validate(AbstractControl control) { final composedError = {}; for (final validator in validators) { - final error = validator(control); + final error = validator.validate(control); if (error != null) { composedError.addAll(error); } diff --git a/lib/src/validators/contains_validator.dart b/lib/src/validators/contains_validator.dart index bff198fe..ec627e7a 100644 --- a/lib/src/validators/contains_validator.dart +++ b/lib/src/validators/contains_validator.dart @@ -11,7 +11,7 @@ class ContainsValidator extends Validator { /// Constructs the instance of the validator. /// /// The argument [values] must not be null. - ContainsValidator(this.values); + const ContainsValidator(this.values) : super(); @override Map? validate(AbstractControl control) { diff --git a/lib/src/validators/credit_card_validator.dart b/lib/src/validators/credit_card_validator.dart index 43dfae66..a77ef808 100644 --- a/lib/src/validators/credit_card_validator.dart +++ b/lib/src/validators/credit_card_validator.dart @@ -3,11 +3,14 @@ // found in the LICENSE file. import 'package:reactive_forms/reactive_forms.dart'; -import 'package:reactive_forms/src/validators/number_validator.dart'; /// A credit card validator that validates that the control's value is a valid /// credit card. class CreditCardValidator extends Validator { + static final RegExp onlyNumbersRegex = RegExp(r'^[0-9]+$'); + + const CreditCardValidator() : super(); + @override Map? validate(AbstractControl control) { final error = {ValidationMessage.creditCard: true}; @@ -17,7 +20,7 @@ class CreditCardValidator extends Validator { } final cardNumber = control.value.toString().replaceAll(' ', ''); - final isNumber = NumberValidator.numberRegex.hasMatch(cardNumber); + final isNumber = onlyNumbersRegex.hasMatch(cardNumber); return isNumber && cardNumber.length >= 13 && diff --git a/lib/src/validators/delegate_async_validator.dart b/lib/src/validators/delegate_async_validator.dart new file mode 100644 index 00000000..20204314 --- /dev/null +++ b/lib/src/validators/delegate_async_validator.dart @@ -0,0 +1,28 @@ +// Copyright 2020 Joan Pablo Jimenez Milian. All rights reserved. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +import 'package:reactive_forms/reactive_forms.dart'; + +/// Signature of a function that receives a control and returns a Future +/// that emits validation errors if present, otherwise null. +typedef AsyncValidatorFunction = Future?> Function( + AbstractControl control); + +/// Validator that delegates the validation to an external function. +class DelegateAsyncValidator extends AsyncValidator { + final AsyncValidatorFunction _asyncValidator; + + /// Creates an instance of the [DelegateAsyncValidator] class. + /// + /// The [DelegateAsyncValidator] validator delegates the validation to the + /// external asynchronous [validator] function. + const DelegateAsyncValidator(AsyncValidatorFunction asyncValidator) + : _asyncValidator = asyncValidator, + super(); + + @override + Future?> validate(AbstractControl control) { + return _asyncValidator(control); + } +} diff --git a/lib/src/validators/delegate_validator.dart b/lib/src/validators/delegate_validator.dart new file mode 100644 index 00000000..f0dcd0bb --- /dev/null +++ b/lib/src/validators/delegate_validator.dart @@ -0,0 +1,28 @@ +// Copyright 2020 Joan Pablo Jimenez Milian. All rights reserved. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +import 'package:reactive_forms/reactive_forms.dart'; + +/// Signature of a function that receives a control and synchronously +/// returns a map of validation errors if present, otherwise null. +typedef ValidatorFunction = Map? Function( + AbstractControl control); + +/// Validator that delegates the validation to an external function. +class DelegateValidator extends Validator { + final ValidatorFunction _validator; + + /// Creates an instance of the [DelegateValidator] class. + /// + /// The [DelegateValidator] validator delegates the validation to the + /// external [validator] function. + const DelegateValidator(ValidatorFunction validator) + : _validator = validator, + super(); + + @override + Map? validate(AbstractControl control) { + return _validator(control); + } +} diff --git a/lib/src/validators/email_validator.dart b/lib/src/validators/email_validator.dart index 2344e9df..c781440c 100644 --- a/lib/src/validators/email_validator.dart +++ b/lib/src/validators/email_validator.dart @@ -9,6 +9,8 @@ class EmailValidator extends Validator { static final RegExp emailRegex = RegExp( r'^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$'); + const EmailValidator() : super(); + @override Map? validate(AbstractControl control) { // don't validate empty values to allow optional controls diff --git a/lib/src/validators/equals_validator.dart b/lib/src/validators/equals_validator.dart index 2e19f4f2..68d4b826 100644 --- a/lib/src/validators/equals_validator.dart +++ b/lib/src/validators/equals_validator.dart @@ -16,10 +16,10 @@ class EqualsValidator extends Validator { /// The argument [validationMessage] is optional and specify the key text for /// the validation error. I none value is supplied then the default value is /// [ValidationMessage.equals]. - EqualsValidator( + const EqualsValidator( this.value, { this.validationMessage = ValidationMessage.equals, - }); + }) : super(); @override Map? validate(AbstractControl control) { diff --git a/lib/src/validators/max_length_validator.dart b/lib/src/validators/max_length_validator.dart index b935a232..5ea9ec09 100644 --- a/lib/src/validators/max_length_validator.dart +++ b/lib/src/validators/max_length_validator.dart @@ -12,7 +12,7 @@ class MaxLengthValidator extends Validator { /// Constructs a [MaxLengthValidator]. /// /// The argument [maxLength] must not be null. - MaxLengthValidator(this.maxLength); + const MaxLengthValidator(this.maxLength) : super(); @override Map? validate(AbstractControl control) { diff --git a/lib/src/validators/max_validator.dart b/lib/src/validators/max_validator.dart index 862c70a1..033d492d 100644 --- a/lib/src/validators/max_validator.dart +++ b/lib/src/validators/max_validator.dart @@ -12,7 +12,7 @@ class MaxValidator extends Validator { /// Constructs the instance of the validator. /// /// The argument [max] must not be null. - MaxValidator(this.max); + const MaxValidator(this.max) : super(); @override Map? validate(AbstractControl control) { diff --git a/lib/src/validators/min_length_validator.dart b/lib/src/validators/min_length_validator.dart index 93df0194..583698f5 100644 --- a/lib/src/validators/min_length_validator.dart +++ b/lib/src/validators/min_length_validator.dart @@ -12,7 +12,7 @@ class MinLengthValidator extends Validator { /// Constructs a [MinLengthValidator]. /// /// The argument [minLength] argument must not be null. - MinLengthValidator(this.minLength); + const MinLengthValidator(this.minLength) : super(); @override Map? validate(AbstractControl control) { diff --git a/lib/src/validators/min_validator.dart b/lib/src/validators/min_validator.dart index 323e82a8..63c45bd3 100644 --- a/lib/src/validators/min_validator.dart +++ b/lib/src/validators/min_validator.dart @@ -12,7 +12,7 @@ class MinValidator extends Validator { /// Constructs the instance of the validator. /// /// The argument [min] must not be null. - MinValidator(this.min); + const MinValidator(this.min) : super(); @override Map? validate(AbstractControl control) { diff --git a/lib/src/validators/must_match_validator.dart b/lib/src/validators/must_match_validator.dart index e1726d0b..ab76af64 100644 --- a/lib/src/validators/must_match_validator.dart +++ b/lib/src/validators/must_match_validator.dart @@ -12,8 +12,9 @@ class MustMatchValidator extends Validator { final bool markAsDirty; /// Constructs an instance of [MustMatchValidator] - MustMatchValidator( - this.controlName, this.matchingControlName, this.markAsDirty); + const MustMatchValidator( + this.controlName, this.matchingControlName, this.markAsDirty) + : super(); @override Map? validate(AbstractControl control) { diff --git a/lib/src/validators/number_validator.dart b/lib/src/validators/number_validator.dart index f0ca71ca..2dd322de 100644 --- a/lib/src/validators/number_validator.dart +++ b/lib/src/validators/number_validator.dart @@ -3,17 +3,97 @@ // found in the LICENSE file. import 'package:reactive_forms/reactive_forms.dart'; +import 'package:reactive_forms/src/validators/number_validator_error.dart'; /// Validator that validates if control's value is a numeric value. class NumberValidator extends Validator { /// The regex expression of a numeric string value. - static final RegExp numberRegex = RegExp(r'^-?[0-9]+$'); + static final RegExp notNumbersRegex = RegExp(r'[^0-9.]'); + + /// The allowed number of decimal places in the validated string. + /// + /// This value specifies the maximum number of digits allowed after the + /// decimal point in the validated string. Defaults to 0 (no decimals). + final int allowedDecimals; + + /// Whether the validator allows negative numbers. + /// + /// If set to `true`, the validator will accept strings representing + /// negative numbers (prefixed with a '-'). Defaults to `true`. + final bool allowNegatives; + + /// Creates a new NumberValidator instance to validate strings representing numbers. + /// + /// [allowedDecimals] (optional): The allowed number of decimal places. Defaults to 0. + /// [allowNegatives] (optional): Whether to allow negative numbers. Defaults to true. + const NumberValidator({ + this.allowedDecimals = 0, + this.allowNegatives = true, + }) : super(); @override Map? validate(AbstractControl control) { - return (control.value == null) || - !numberRegex.hasMatch(control.value.toString()) - ? {ValidationMessage.number: true} - : null; + if (control.value == null) { + return { + ValidationMessage.number: NumberValidatorError.nullValue, + }; + } + + // Check for leading/trailing spaces + final numberString = control.value.toString().trim(); + + // Check for empty string + if (numberString.isEmpty) { + return { + ValidationMessage.number: NumberValidatorError.invalidNumber, + }; + } + + // Check for negative sign, if allowed + final hasNegativeSign = numberString.startsWith('-'); + if (hasNegativeSign && !allowNegatives) { + return { + ValidationMessage.number: NumberValidatorError.unsignedNumber, + }; + } + + // Remove the negative sign, if present, for further validation + final unsignedNumberString = + hasNegativeSign ? numberString.substring(1) : numberString; + + // Check for valid decimal positions + if (!_validateNumberDecimals(allowedDecimals, unsignedNumberString)) { + return { + ValidationMessage.number: NumberValidatorError.invalidDecimals, + }; + } + + // Check for valid numeric characters using a regular expression + if (unsignedNumberString.contains(notNumbersRegex)) { + return { + ValidationMessage.number: NumberValidatorError.invalidNumber, + }; + } + + // No errors, the control value is a valid number + return null; + } + + bool _validateNumberDecimals(int allowedDecimals, String numberString) { + // Split the number string at the decimal point + final parts = numberString.split('.'); + + if (parts.length > 2) { + // More than one decimal point, invalid format + return false; + } + + if (parts.length == 1) { + // No decimal part, validate it has 0 decimals + return allowedDecimals == 0; + } + + // Check if the decimal part length is equal to the allowed decimals + return parts[1].length == allowedDecimals; } } diff --git a/lib/src/validators/number_validator_error.dart b/lib/src/validators/number_validator_error.dart new file mode 100644 index 00000000..5d75643d --- /dev/null +++ b/lib/src/validators/number_validator_error.dart @@ -0,0 +1,6 @@ +class NumberValidatorError { + static const String nullValue = 'nullValue'; + static const String invalidDecimals = 'invalidDecimals'; + static const String unsignedNumber = 'unsignedNumber'; + static const String invalidNumber = 'invalidNumber'; +} diff --git a/lib/src/validators/pattern_validator.dart b/lib/src/validators/pattern_validator.dart index 2fba5e42..c84b0099 100644 --- a/lib/src/validators/pattern_validator.dart +++ b/lib/src/validators/pattern_validator.dart @@ -3,7 +3,6 @@ // found in the LICENSE file. import 'package:reactive_forms/reactive_forms.dart'; -import 'package:reactive_forms/src/validators/pattern/pattern_evaluator.dart'; /// Validator that requires the control's value to match a regex pattern. class PatternValidator extends Validator { @@ -13,8 +12,9 @@ class PatternValidator extends Validator { /// Constructs an instance of [PatternValidator]. /// /// The [evaluator] argument must not be null. - PatternValidator(this.evaluator, - {this.validationMessage = ValidationMessage.pattern}); + const PatternValidator(this.evaluator, + {this.validationMessage = ValidationMessage.pattern}) + : super(); @override Map? validate(AbstractControl control) { diff --git a/lib/src/validators/required_validator.dart b/lib/src/validators/required_validator.dart index bebfe002..13f3367a 100644 --- a/lib/src/validators/required_validator.dart +++ b/lib/src/validators/required_validator.dart @@ -6,6 +6,8 @@ import 'package:reactive_forms/reactive_forms.dart'; /// Validator that requires the control have a non-empty value. class RequiredValidator extends Validator { + const RequiredValidator() : super(); + @override Map? validate(AbstractControl control) { final error = {ValidationMessage.required: true}; diff --git a/lib/src/validators/validator.dart b/lib/src/validators/validator.dart index 6972b570..8d05f14f 100644 --- a/lib/src/validators/validator.dart +++ b/lib/src/validators/validator.dart @@ -4,7 +4,14 @@ import 'package:reactive_forms/reactive_forms.dart'; -/// An interface implemented by classes that perform synchronous validation. +/// An abstract class extended by classes that perform synchronous validation. abstract class Validator { + const Validator(); + + /// Validates the [control]. Map? validate(AbstractControl control); + + Map? call(AbstractControl control) { + return validate(control); + } } diff --git a/lib/src/validators/validators.dart b/lib/src/validators/validators.dart index 86932c9f..e587b4b9 100644 --- a/lib/src/validators/validators.dart +++ b/lib/src/validators/validators.dart @@ -3,92 +3,85 @@ // found in the LICENSE file. import 'package:reactive_forms/reactive_forms.dart'; -import 'package:reactive_forms/src/validators/any_validator.dart'; -import 'package:reactive_forms/src/validators/compare_validator.dart'; -import 'package:reactive_forms/src/validators/compose_or_validator.dart'; -import 'package:reactive_forms/src/validators/compose_validator.dart'; -import 'package:reactive_forms/src/validators/contains_validator.dart'; -import 'package:reactive_forms/src/validators/credit_card_validator.dart'; -import 'package:reactive_forms/src/validators/email_validator.dart'; -import 'package:reactive_forms/src/validators/equals_validator.dart'; -import 'package:reactive_forms/src/validators/max_length_validator.dart'; -import 'package:reactive_forms/src/validators/max_validator.dart'; -import 'package:reactive_forms/src/validators/min_length_validator.dart'; -import 'package:reactive_forms/src/validators/min_validator.dart'; -import 'package:reactive_forms/src/validators/must_match_validator.dart'; -import 'package:reactive_forms/src/validators/number_validator.dart'; -import 'package:reactive_forms/src/validators/pattern/default_pattern_evaluator.dart'; -import 'package:reactive_forms/src/validators/pattern/pattern_evaluator.dart'; -import 'package:reactive_forms/src/validators/pattern/regex_pattern_evaluator.dart'; -import 'package:reactive_forms/src/validators/pattern_validator.dart'; -import 'package:reactive_forms/src/validators/required_validator.dart'; -/// Signature of a function that receives a control and synchronously -/// returns a map of validation errors if present, otherwise null. -typedef ValidatorFunction = Map? Function( - AbstractControl control); +/// Provides a set of built-in validators that can be used by form controls, +/// form groups, and form arrays. +class Validators { + /// Creates a validator that delegates the validation to the external [validator] + /// function. + static Validator delegate(ValidatorFunction validator) => + DelegateValidator(validator); -/// Signature of a function that receives a control and returns a Future -/// that emits validation errors if present, otherwise null. -typedef AsyncValidatorFunction = Future?> Function( - AbstractControl control); + /// Creates a validator that delegates the validation to the external + /// asynchronous [validator] function. + static AsyncValidator delegateAsync( + AsyncValidatorFunction validator) => + DelegateAsyncValidator(validator); -/// Provides a set of built-in validators that can be used by form controls. -class Validators { - /// Gets a validator that requires the control have a non-empty value. - static ValidatorFunction get required => RequiredValidator().validate; + /// Creates a validator that requires the control have a non-empty value. + static Validator get required => const RequiredValidator(); - /// Gets a validator that requires the control's value be true. + /// Creates a validator that requires the control's value be true. /// This validator is commonly used for required checkboxes. - static ValidatorFunction get requiredTrue => EqualsValidator(true, - validationMessage: ValidationMessage.requiredTrue) - .validate; + static Validator get requiredTrue => const EqualsValidator( + true, + validationMessage: ValidationMessage.requiredTrue, + ); - /// Gets a validator that requires the control's value pass an email + /// Creates a validator that requires the control's value pass an email /// validation test. - static ValidatorFunction get email => EmailValidator().validate; + static Validator get email => const EmailValidator(); - /// Gets a validator that validates if control's value is a numeric value. - static ValidatorFunction get number => NumberValidator().validate; + /// Creates a validator function that checks if a control's value is a valid number. + /// + /// [allowedDecimals] (optional): The allowed number of decimal places. Defaults to 0. + /// [allowNegatives] (optional): Whether to allow negative numbers. Defaults to true. + static Validator number({ + int allowedDecimals = 0, + bool allowNegatives = true, + }) => + NumberValidator( + allowedDecimals: allowedDecimals, + allowNegatives: allowNegatives, + ); - /// Gets a validator that validates if the control's value is a valid + /// Creates a validator that validates if the control's value is a valid /// credit card number. - static ValidatorFunction get creditCard => CreditCardValidator().validate; + static Validator get creditCard => const CreditCardValidator(); - /// Gets a validator that requires the control's value to be equals to + /// Creates a validator that requires the control's value to be equals to /// argument [value]. /// /// The argument [value] must not be null. - static ValidatorFunction equals(T value) => - EqualsValidator(value).validate; + static Validator equals(T value) => EqualsValidator(value); - /// Gets a validator that requires the control's value to be greater than + /// Creates a validator that requires the control's value to be greater than /// or equal to [min] value. /// /// The argument [min] must not be null. - static ValidatorFunction min(T min) => MinValidator(min).validate; + static Validator min(T min) => MinValidator(min); - /// Gets a validator that requires the control's value to be less than + /// Creates a validator that requires the control's value to be less than /// or equal to [max] value. /// /// The argument [max] must not be null. - static ValidatorFunction max(T max) => MaxValidator(max).validate; + static Validator max(T max) => MaxValidator(max); - /// Gets a validator that requires the length of the control's value to be + /// Creates a validator that requires the length of the control's value to be /// greater than or equal to the provided [minLength]. /// /// The argument [minLength] argument must not be null. - static ValidatorFunction minLength(int minLength) => - MinLengthValidator(minLength).validate; + static Validator minLength(int minLength) => + MinLengthValidator(minLength); - /// Gets a validator that requires the length of the control's value to be + /// Creates a validator that requires the length of the control's value to be /// less than or equal to the provided [maxLength]. /// /// The argument [maxLength] must not be null. - static ValidatorFunction maxLength(int maxLength) => - MaxLengthValidator(maxLength).validate; + static Validator maxLength(int maxLength) => + MaxLengthValidator(maxLength); - /// Gets a validator that requires the control's value to match a + /// Creates a validator that requires the control's value to match a /// regex [pattern]. /// /// The argument [pattern] must not be null. @@ -146,7 +139,7 @@ class Validators { /// /// expect(password.valid, true); /// ``` - static ValidatorFunction pattern( + static Validator pattern( Pattern pattern, { String validationMessage = ValidationMessage.pattern, }) { @@ -159,11 +152,10 @@ class Validators { evaluator = DefaultPatternEvaluator(pattern); } - return PatternValidator(evaluator, validationMessage: validationMessage) - .validate; + return PatternValidator(evaluator, validationMessage: validationMessage); } - /// Gets a [FormGroup] validator that checks the controls [controlName] and + /// Creates a [FormGroup] validator that checks the controls [controlName] and /// [matchingControlName] have the same values. /// /// The arguments [controlName] and [matchingControlName] must not be null. @@ -220,14 +212,13 @@ class Validators { /// ), /// ... /// ``` - static ValidatorFunction mustMatch( + static Validator mustMatch( String controlName, String matchingControlName, {bool markAsDirty = true}) { - return MustMatchValidator(controlName, matchingControlName, markAsDirty) - .validate; + return MustMatchValidator(controlName, matchingControlName, markAsDirty); } - /// Gets a [FormGroup] validator that compares two controls in the group. + /// Creates a [FormGroup] validator that compares two controls in the group. /// /// The arguments [controlName], [compareControlName] and [compareOption] /// must not be null. @@ -240,13 +231,12 @@ class Validators { /// 'balance': 50.00, /// }, [Validators.compare('amount', 'balance', CompareOption.lowerOrEquals)]); /// ``` - static ValidatorFunction compare( + static Validator compare( String controlName, String compareControlName, CompareOption compareOption, ) { - return CompareValidator(controlName, compareControlName, compareOption) - .validate; + return CompareValidator(controlName, compareControlName, compareOption); } /// Compose multiple validators into a single validator that returns the union @@ -254,8 +244,8 @@ class Validators { /// validators. /// /// The argument [validators] must not be null. - static ValidatorFunction compose(List validators) { - return ComposeValidator(validators).validate; + static Validator compose(List> validators) { + return ComposeValidator(validators); } /// Compose multiple validators into a single validator that returns the union @@ -267,11 +257,11 @@ class Validators { /// If at least one of the [validators] evaluates as 'VALID' then the compose /// validator evaluates as 'VALID' and returns null, otherwise returns /// the union of all the individual errors returned by each validator. - static ValidatorFunction composeOR(List validators) { - return ComposeOrValidator(validators).validate; + static Validator composeOR(List> validators) { + return ComposeOrValidator(validators); } - /// Gets a validator that requires the control's value contains all the + /// Creates a validator that requires the control's value contains all the /// values specified in [values]. /// /// The argument [values] must not be null. @@ -293,11 +283,11 @@ class Validators { /// ], validators: [Validators.contains([1,3])] /// ); /// ``` - static ValidatorFunction contains(List values) { - return ContainsValidator(values).validate; + static Validator contains(List values) { + return ContainsValidator(values); } - /// Gets a validator that requires any element of the control's iterable value + /// Creates a validator that requires any element of the control's iterable value /// satisfies [test]. /// /// Checks every element in control's value in iteration order, and marks @@ -327,7 +317,7 @@ class Validators { /// /// print(control.valid); // outputs: true /// ``` - static ValidatorFunction any(AnyValidatorFunctionTest test) { - return AnyValidator(test).validate; + static Validator any(AnyValidatorFunctionTest test) { + return AnyValidator(test); } } diff --git a/lib/src/widgets/inherited_streamer.dart b/lib/src/widgets/inherited_streamer.dart index 7203a7e0..15f27360 100644 --- a/lib/src/widgets/inherited_streamer.dart +++ b/lib/src/widgets/inherited_streamer.dart @@ -7,8 +7,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; abstract class InheritedStreamer extends InheritedWidget { - const InheritedStreamer(this.stream, Widget child, {Key? key}) - : super(key: key, child: child); + const InheritedStreamer(this.stream, Widget child, {super.key}) + : super(child: child); final Stream stream; diff --git a/lib/src/widgets/reactive_checkbox.dart b/lib/src/widgets/reactive_checkbox.dart index ebe826c9..b47ad73d 100644 --- a/lib/src/widgets/reactive_checkbox.dart +++ b/lib/src/widgets/reactive_checkbox.dart @@ -7,25 +7,23 @@ import 'package:reactive_forms/reactive_forms.dart'; /// This is a convenience widget that wraps a [Checkbox] widget in a /// [ReactiveCheckbox]. -/// -/// Can optionally provide a [formControl] to bind this widget to a control. -/// -/// Can optionally provide a [formControlName] to bind this ReactiveFormField -/// to a [FormControl]. -/// -/// Must provide one of the arguments [formControl] or a [formControlName], -/// but not both at the same time. -/// -/// For documentation about the various parameters, see the [Checkbox] class -/// and [Checkbox], the constructor. class ReactiveCheckbox extends ReactiveFocusableFormField { /// Create an instance of a [ReactiveCheckbox]. /// - /// The [formControlName] arguments must not be null. + /// Can optionally provide a [formControl] to bind this widget to a control. + /// + /// Can optionally provide a [formControlName] to bind this ReactiveFormField + /// to a [FormControl]. + /// + /// Must provide one of the arguments [formControl] or a [formControlName], + /// but not both at the same time. + /// + /// For documentation about the various parameters, see the [Checkbox] class + /// and the [Checkbox] constructor. ReactiveCheckbox({ - Key? key, - String? formControlName, - FormControl? formControl, + super.key, + super.formControlName, + super.formControl, bool tristate = false, Color? activeColor, Color? checkColor, @@ -38,15 +36,15 @@ class ReactiveCheckbox extends ReactiveFocusableFormField { MaterialStateProperty? fillColor, MaterialStateProperty? overlayColor, double? splashRadius, - FocusNode? focusNode, + super.focusNode, OutlinedBorder? shape, BorderSide? side, ReactiveFormFieldCallback? onChanged, + ShowErrorsFunction? showErrors, }) : super( - key: key, - formControl: formControl, - formControlName: formControlName, - focusNode: focusNode, + showErrors: showErrors ?? + (control) => + control.invalid && (control.dirty || control.touched), builder: (field) { return Checkbox( value: tristate ? field.value : field.value ?? false, @@ -65,6 +63,7 @@ class ReactiveCheckbox extends ReactiveFocusableFormField { focusNode: field.focusNode, shape: shape, side: side, + isError: field.errorText != null, onChanged: field.control.enabled ? (value) { field.didChange(value); diff --git a/lib/src/widgets/reactive_checkbox_list_tile.dart b/lib/src/widgets/reactive_checkbox_list_tile.dart index c33fa688..a350de75 100644 --- a/lib/src/widgets/reactive_checkbox_list_tile.dart +++ b/lib/src/widgets/reactive_checkbox_list_tile.dart @@ -7,27 +7,23 @@ import 'package:reactive_forms/reactive_forms.dart'; /// This is a convenience widget that wraps a [CheckboxListTile] widget in a /// [ReactiveCheckboxListTile]. -/// -/// Can optionally provide a [formControl] to bind this widget to a control. -/// -/// Can optionally provide a [formControlName] to bind this ReactiveFormField -/// to a [FormControl]. -/// -/// Must provide one of the arguments [formControl] or a [formControlName], -/// but not both at the same time. -/// -/// For documentation about the various parameters, see the [CheckboxListTile] -/// class and [CheckboxListTile], the constructor. class ReactiveCheckboxListTile extends ReactiveFocusableFormField { /// Create an instance of a [ReactiveCheckbox]. /// - /// The [formControlName] arguments must not be null. + /// Can optionally provide a [formControl] to bind this widget to a control. /// - /// See also [CheckboxListTile] + /// Can optionally provide a [formControlName] to bind this ReactiveFormField + /// to a [FormControl]. + /// + /// Must provide one of the arguments [formControl] or a [formControlName], + /// but not both at the same time. + /// + /// For documentation about the various parameters, see the [CheckboxListTile] + /// class and the [CheckboxListTile] constructor. ReactiveCheckboxListTile({ - Key? key, - String? formControlName, - FormControl? formControl, + super.key, + super.formControlName, + super.formControl, Color? activeColor, Color? checkColor, Widget? title, @@ -44,21 +40,36 @@ class ReactiveCheckboxListTile extends ReactiveFocusableFormField { Color? tileColor, ShapeBorder? shape, VisualDensity? visualDensity, - FocusNode? focusNode, + super.focusNode, bool? enableFeedback, OutlinedBorder? checkboxShape, BorderSide? side, ReactiveFormFieldCallback? onChanged, + MouseCursor? mouseCursor, + MaterialStateProperty? fillColor, + Color? hoverColor, + MaterialStateProperty? overlayColor, + double? splashRadius, + MaterialTapTargetSize? materialTapTargetSize, + ValueChanged? onFocusChange, + ShowErrorsFunction? showErrors, }) : super( - key: key, - formControl: formControl, - formControlName: formControlName, - focusNode: focusNode, + showErrors: showErrors ?? + (control) => + control.invalid && (control.dirty || control.touched), builder: (field) { return CheckboxListTile( value: tristate ? field.value : field.value ?? false, + mouseCursor: mouseCursor, + fillColor: fillColor, + hoverColor: hoverColor, + overlayColor: overlayColor, + materialTapTargetSize: materialTapTargetSize, + splashRadius: splashRadius, activeColor: activeColor, checkColor: checkColor, + onFocusChange: onFocusChange, + isError: field.errorText != null, title: title, subtitle: subtitle, isThreeLine: isThreeLine, @@ -77,6 +88,7 @@ class ReactiveCheckboxListTile extends ReactiveFocusableFormField { enableFeedback: enableFeedback, checkboxShape: checkboxShape, side: side, + enabled: field.control.enabled, onChanged: field.control.enabled ? (value) { field.didChange(value); diff --git a/lib/src/widgets/reactive_date_picker.dart b/lib/src/widgets/reactive_date_picker.dart index 318e090f..644cf67e 100644 --- a/lib/src/widgets/reactive_date_picker.dart +++ b/lib/src/widgets/reactive_date_picker.dart @@ -55,9 +55,9 @@ class ReactiveDatePicker extends ReactiveFormField { /// For documentation about the various parameters, see the [showTimePicker] /// function parameters. ReactiveDatePicker({ - Key? key, - String? formControlName, - FormControl? formControl, + super.key, + super.formControlName, + super.formControl, required ReactiveDatePickerBuilder builder, required DateTime firstDate, required DateTime lastDate, @@ -82,9 +82,6 @@ class ReactiveDatePicker extends ReactiveFormField { TextInputType? keyboardType, Offset? anchorPoint, }) : super( - key: key, - formControl: formControl, - formControlName: formControlName, builder: (ReactiveFormFieldState field) { return builder( field.context, diff --git a/lib/src/widgets/reactive_dropdown_field.dart b/lib/src/widgets/reactive_dropdown_field.dart index 2f8a3d69..cab9f763 100644 --- a/lib/src/widgets/reactive_dropdown_field.dart +++ b/lib/src/widgets/reactive_dropdown_field.dart @@ -5,7 +5,7 @@ import 'package:flutter/material.dart'; import 'package:reactive_forms/reactive_forms.dart'; -/// A reactive widget that wraps a [DropdownButton]. +/// A reactive widget that wraps a [DropdownButtonFormField]. class ReactiveDropdownField extends ReactiveFocusableFormField { /// Creates a [DropdownButton] widget wrapped in an [InputDecorator]. /// @@ -20,15 +20,18 @@ class ReactiveDropdownField extends ReactiveFocusableFormField { /// If [readOnly] is true, the button will be disabled, the down arrow will /// be grayed out, and the disabledHint will be shown (if provided). /// - /// The [DropdownButton] [items] parameters must not be null. + /// The [items] parameter must not be null. + /// + /// For more information about all various parameters, + /// see [DropdownButtonFormField] constructor. ReactiveDropdownField({ - Key? key, - String? formControlName, - FormControl? formControl, - FocusNode? focusNode, + super.key, + super.formControlName, + super.formControl, + super.focusNode, required List> items, - Map? validationMessages, - ShowErrorsFunction? showErrors, + super.validationMessages, + super.showErrors, DropdownButtonBuilder? selectedItemBuilder, Widget? hint, InputDecoration decoration = const InputDecoration(), @@ -45,22 +48,16 @@ class ReactiveDropdownField extends ReactiveFocusableFormField { double? itemHeight, Color? dropdownColor, Color? focusColor, - Widget? underline, bool autofocus = false, double? menuMaxHeight, bool? enableFeedback, AlignmentGeometry alignment = AlignmentDirectional.centerStart, BorderRadius? borderRadius, + EdgeInsetsGeometry? padding, ReactiveFormFieldCallback? onTap, ReactiveFormFieldCallback? onChanged, }) : assert(itemHeight == null || itemHeight > 0), super( - key: key, - formControl: formControl, - formControlName: formControlName, - validationMessages: validationMessages, - showErrors: showErrors, - focusNode: focusNode, builder: (ReactiveFormFieldState field) { final effectiveDecoration = decoration.applyDefaults( Theme.of(field.context).inputDecorationTheme, @@ -85,46 +82,41 @@ class ReactiveDropdownField extends ReactiveFocusableFormField { } } - return InputDecorator( + return DropdownButtonFormField( + value: effectiveValue, decoration: effectiveDecoration.copyWith( errorText: field.errorText, enabled: !isDisabled, ), - isEmpty: effectiveValue == null, - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: effectiveValue, - items: items, - selectedItemBuilder: selectedItemBuilder, - hint: hint, - disabledHint: effectiveDisabledHint, - elevation: elevation, - style: style, - icon: icon, - iconDisabledColor: iconDisabledColor, - iconEnabledColor: iconEnabledColor, - iconSize: iconSize, - isDense: isDense, - isExpanded: isExpanded, - itemHeight: itemHeight, - focusNode: field.focusNode, - dropdownColor: dropdownColor, - focusColor: focusColor, - underline: underline, - autofocus: autofocus, - menuMaxHeight: menuMaxHeight, - enableFeedback: enableFeedback, - alignment: alignment, - borderRadius: borderRadius, - onTap: onTap != null ? () => onTap(field.control) : null, - onChanged: isDisabled - ? null - : (value) { - field.didChange(value); - onChanged?.call(field.control); - }, - ), - ), + items: items, + selectedItemBuilder: selectedItemBuilder, + hint: hint, + disabledHint: effectiveDisabledHint, + elevation: elevation, + style: style, + icon: icon, + iconDisabledColor: iconDisabledColor, + iconEnabledColor: iconEnabledColor, + iconSize: iconSize, + isDense: isDense, + isExpanded: isExpanded, + itemHeight: itemHeight, + focusNode: field.focusNode, + dropdownColor: dropdownColor, + focusColor: focusColor, + autofocus: autofocus, + menuMaxHeight: menuMaxHeight, + enableFeedback: enableFeedback, + alignment: alignment, + borderRadius: borderRadius, + padding: padding, + onTap: onTap != null ? () => onTap(field.control) : null, + onChanged: isDisabled + ? null + : (value) { + field.didChange(value); + onChanged?.call(field.control); + }, ); }, ); diff --git a/lib/src/widgets/reactive_form.dart b/lib/src/widgets/reactive_form.dart index 0dc19e58..d99aeaae 100644 --- a/lib/src/widgets/reactive_form.dart +++ b/lib/src/widgets/reactive_form.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:reactive_forms/reactive_forms.dart'; import 'package:reactive_forms/src/widgets/form_control_inherited_notifier.dart'; +import 'package:reactive_forms/src/widgets/reactive_form_pop_scope.dart'; /// This class is responsible for create a [FormControlInheritedStreamer] for /// exposing a [FormGroup] to all descendants widgets. @@ -18,27 +19,22 @@ class ReactiveForm extends StatelessWidget { /// The form group control that is bound to this widget. final FormGroup formGroup; - /// Enables the form to veto attempts by the user to dismiss the [ModalRoute] - /// that contains the form. - /// - /// If the callback returns a Future that resolves to false, the form's route - /// will not be popped. - /// - /// See also: - /// - /// * [WillPopScope], another widget that provides a way to intercept the - /// back button. - final WillPopCallback? onWillPop; + /// Determine whether a route can popped. See [PopScope] for more details. + final ReactiveFormCanPopCallback? canPop; + + /// A callback invoked when a route is popped. See [PopScope] for more details. + final ReactiveFormPopInvokedCallback? onPopInvoked; /// Creates and instance of [ReactiveForm]. /// /// The [formGroup] and [child] arguments are required. const ReactiveForm({ - Key? key, + super.key, required this.formGroup, required this.child, - this.onWillPop, - }) : super(key: key); + this.canPop, + this.onPopInvoked, + }); /// Returns the nearest model up its widget tree. /// @@ -67,8 +63,9 @@ class ReactiveForm extends StatelessWidget { return FormControlInheritedStreamer( control: formGroup, stream: formGroup.statusChanged, - child: WillPopScope( - onWillPop: onWillPop, + child: ReactiveFormPopScope( + canPop: canPop, + onPopInvoked: onPopInvoked, child: child, ), ); diff --git a/lib/src/widgets/reactive_form_array.dart b/lib/src/widgets/reactive_form_array.dart index 3f5de0b6..48f40ef8 100644 --- a/lib/src/widgets/reactive_form_array.dart +++ b/lib/src/widgets/reactive_form_array.dart @@ -33,16 +33,15 @@ class ReactiveFormArray extends StatefulWidget { /// subtree does not depend on the value of the [FormArray] that is bind /// with this widget. const ReactiveFormArray({ - Key? key, + super.key, required this.builder, this.formArrayName, this.formArray, this.child, - }) : assert( + }) : assert( (formArrayName != null && formArray == null) || (formArrayName == null && formArray != null), - 'Must provide a formArrayName or a formArray, but not both at the same time.'), - super(key: key); + 'Must provide a formArrayName or a formArray, but not both at the same time.'); @override ReactiveFormArrayState createState() => ReactiveFormArrayState(); diff --git a/lib/src/widgets/reactive_form_builder.dart b/lib/src/widgets/reactive_form_builder.dart index 3098a2d2..03c60950 100644 --- a/lib/src/widgets/reactive_form_builder.dart +++ b/lib/src/widgets/reactive_form_builder.dart @@ -21,21 +21,15 @@ class ReactiveFormBuilder extends StatefulWidget { /// Called to create the FormGroup that will be bind to this widget. final ReactiveFormBuilderCreator form; + /// Determine whether a route can be popped. See [PopScope] for more details. + final bool Function(FormGroup formGroup)? canPop; + + /// A callback invoked when a route is popped. See [PopScope] for more details. + final void Function(FormGroup formGroup, bool didPop)? onPopInvoked; + /// The widget below this widget in the tree. final Widget? child; - /// Enables the form to veto attempts by the user to dismiss the [ModalRoute] - /// that contains the form. - /// - /// If the callback returns a Future that resolves to false, the form's route - /// will not be popped. - /// - /// See also: - /// - /// * [WillPopScope], another widget that provides a way to intercept the - /// back button. - final WillPopCallback? onWillPop; - /// Creates and instance of [ReactiveFormBuilder]. /// /// The [form] and [builder] arguments must not be null. @@ -56,13 +50,31 @@ class ReactiveFormBuilder extends StatefulWidget { /// } /// } /// ``` + /// ### Example: Allows the route to be popped only if the form is valid. + /// ```dart + /// class MyWidget extends StatelessWidget { + /// @override + /// Widget build(BuildContext context) { + /// return ReactiveFormBuilder( + /// form: (context) => FormGroup({'name': FormControl()}), + /// canPop: (formGroup) => formGroup.valid + /// builder: (context, form, child) { + /// return ReactiveTextField( + /// formControlName: 'name', + /// ); + /// }, + /// ); + /// } + /// } + /// ``` const ReactiveFormBuilder({ - Key? key, - this.child, - this.onWillPop, + super.key, required this.form, required this.builder, - }) : super(key: key); + this.canPop, + this.onPopInvoked, + this.child, + }); @override ReactiveFormBuilderState createState() => ReactiveFormBuilderState(); @@ -81,7 +93,8 @@ class ReactiveFormBuilderState extends State { Widget build(BuildContext context) { return ReactiveForm( formGroup: _form, - onWillPop: widget.onWillPop, + canPop: widget.canPop, + onPopInvoked: widget.onPopInvoked, child: widget.builder(context, _form, widget.child), ); } diff --git a/lib/src/widgets/reactive_form_config.dart b/lib/src/widgets/reactive_form_config.dart index f90ea389..fc4028fa 100644 --- a/lib/src/widgets/reactive_form_config.dart +++ b/lib/src/widgets/reactive_form_config.dart @@ -33,10 +33,10 @@ class ReactiveFormConfig extends InheritedWidget { /// ); /// ``` const ReactiveFormConfig({ - required Widget child, + required super.child, required this.validationMessages, - Key? key, - }) : super(child: child, key: key); + super.key, + }); @override bool updateShouldNotify(covariant ReactiveFormConfig oldWidget) { diff --git a/lib/src/widgets/reactive_form_consumer.dart b/lib/src/widgets/reactive_form_consumer.dart index e0e51309..f9159968 100644 --- a/lib/src/widgets/reactive_form_consumer.dart +++ b/lib/src/widgets/reactive_form_consumer.dart @@ -36,10 +36,10 @@ class ReactiveFormConsumer extends StatelessWidget { /// subtree does not depend on the value of the [FormGroup] that is bind /// with this widget. const ReactiveFormConsumer({ - Key? key, + super.key, required this.builder, this.child, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/src/widgets/reactive_form_field.dart b/lib/src/widgets/reactive_form_field.dart index 8a0db6f1..35b7366a 100644 --- a/lib/src/widgets/reactive_form_field.dart +++ b/lib/src/widgets/reactive_form_field.dart @@ -61,7 +61,7 @@ class ReactiveFormField extends StatefulWidget { /// /// The [builder] arguments are required. ReactiveFormField({ - Key? key, + super.key, this.formControl, this.formControlName, this.valueAccessor, @@ -73,8 +73,7 @@ class ReactiveFormField extends StatefulWidget { (formControlName != null && formControl == null) || (formControlName == null && formControl != null), 'Must provide a formControlName or a formControl, but not both at the same time.'), - _builder = builder, - super(key: key); + _builder = builder; @override ReactiveFormFieldState createState() => diff --git a/lib/src/widgets/reactive_form_pop_scope.dart b/lib/src/widgets/reactive_form_pop_scope.dart new file mode 100644 index 00000000..932f6171 --- /dev/null +++ b/lib/src/widgets/reactive_form_pop_scope.dart @@ -0,0 +1,52 @@ +// Copyright 2020 Joan Pablo Jimenez Milian. All rights reserved. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:reactive_forms/reactive_forms.dart'; + +/// This is the signature to determine whether a route can popped. +/// See [PopScope] for more details. +typedef ReactiveFormCanPopCallback = bool Function(FormGroup formGroup); + +/// This is the signature of the callback invoked when a route is popped. +/// See [PopScope] for more details. +typedef ReactiveFormPopInvokedCallback = void Function( + FormGroup formGroup, bool didPop); + +class ReactiveFormPopScope extends StatelessWidget { + /// The widget below this widget in the tree. + final Widget child; + + /// Determine whether a route can popped. See [PopScope] for more details. + final ReactiveFormCanPopCallback? canPop; + + /// A callback invoked when a route is popped. See [PopScope] for more details. + final ReactiveFormPopInvokedCallback? onPopInvoked; + + const ReactiveFormPopScope({ + super.key, + this.canPop, + this.onPopInvoked, + required this.child, + }); + + @override + Widget build(BuildContext context) { + if (canPop == null && onPopInvoked == null) { + return child; + } + + return ReactiveFormConsumer( + builder: (context, formGroup, _) { + return PopScope( + canPop: canPop?.call(formGroup) ?? true, + onPopInvoked: onPopInvoked != null + ? (didPop) => onPopInvoked!(formGroup, didPop) + : null, + child: child, + ); + }, + ); + } +} diff --git a/lib/src/widgets/reactive_radio.dart b/lib/src/widgets/reactive_radio.dart index 38bb7e0c..7afdbd9a 100644 --- a/lib/src/widgets/reactive_radio.dart +++ b/lib/src/widgets/reactive_radio.dart @@ -29,9 +29,9 @@ class ReactiveRadio extends ReactiveFocusableFormField { /// For documentation about the various parameters, see the [Radio] class /// and [Radio], the constructor. ReactiveRadio({ - Key? key, - String? formControlName, - FormControl? formControl, + super.key, + super.formControlName, + super.formControl, required T value, Color? activeColor, Color? focusColor, @@ -44,13 +44,9 @@ class ReactiveRadio extends ReactiveFocusableFormField { double? splashRadius, bool autofocus = false, bool toggleable = false, - FocusNode? focusNode, + super.focusNode, ReactiveFormFieldCallback? onChanged, }) : super( - key: key, - formControl: formControl, - formControlName: formControlName, - focusNode: focusNode, builder: (field) { return Radio( value: value, diff --git a/lib/src/widgets/reactive_radio_list_tile.dart b/lib/src/widgets/reactive_radio_list_tile.dart index 3b9a9c6b..f21f9611 100644 --- a/lib/src/widgets/reactive_radio_list_tile.dart +++ b/lib/src/widgets/reactive_radio_list_tile.dart @@ -26,9 +26,9 @@ class ReactiveRadioListTile extends ReactiveFocusableFormField { /// /// See also [RadioListTile] ReactiveRadioListTile({ - Key? key, - String? formControlName, - FormControl? formControl, + super.key, + super.formControlName, + super.formControl, required T value, Color? activeColor, Color? selectedTileColor, @@ -45,19 +45,29 @@ class ReactiveRadioListTile extends ReactiveFocusableFormField { bool autofocus = false, bool selected = false, VisualDensity? visualDensity, - FocusNode? focusNode, + super.focusNode, bool? enableFeedback, ReactiveFormFieldCallback? onChanged, + MouseCursor? mouseCursor, + MaterialStateProperty? fillColor, + Color? hoverColor, + MaterialStateProperty? overlayColor, + double? splashRadius, + MaterialTapTargetSize? materialTapTargetSize, + ValueChanged? onFocusChange, }) : super( - key: key, - formControl: formControl, - formControlName: formControlName, - focusNode: focusNode, builder: (field) { return RadioListTile( value: value, groupValue: field.value, + mouseCursor: mouseCursor, activeColor: activeColor, + hoverColor: hoverColor, + overlayColor: overlayColor, + materialTapTargetSize: materialTapTargetSize, + fillColor: fillColor, + splashRadius: splashRadius, + onFocusChange: onFocusChange, selectedTileColor: selectedTileColor, tileColor: tileColor, title: title, diff --git a/lib/src/widgets/reactive_slider.dart b/lib/src/widgets/reactive_slider.dart index 062ca8ec..d7ff9504 100644 --- a/lib/src/widgets/reactive_slider.dart +++ b/lib/src/widgets/reactive_slider.dart @@ -30,29 +30,28 @@ class ReactiveSlider extends ReactiveFocusableFormField { /// /// The [labelBuilder] is called each time the [FormControl] changes its value /// so you can supply a label to the Slider. - ReactiveSlider({ - Key? key, - String? formControlName, - FormControl? formControl, - double min = 0.0, - double max = 1.0, - int? divisions, - ReactiveSliderLabelBuilder? labelBuilder, - Color? activeColor, - Color? inactiveColor, - Color? thumbColor, - SemanticFormatterCallback? semanticFormatterCallback, - bool autofocus = false, - MouseCursor? mouseCursor, - FocusNode? focusNode, - ReactiveFormFieldCallback? onChangeEnd, - ReactiveFormFieldCallback? onChangeStart, - ReactiveFormFieldCallback? onChanged, - }) : super( - key: key, - formControl: formControl, - formControlName: formControlName, - focusNode: focusNode, + ReactiveSlider( + {super.key, + super.formControlName, + super.formControl, + double min = 0.0, + double max = 1.0, + int? divisions, + ReactiveSliderLabelBuilder? labelBuilder, + Color? activeColor, + Color? inactiveColor, + Color? thumbColor, + SemanticFormatterCallback? semanticFormatterCallback, + bool autofocus = false, + MouseCursor? mouseCursor, + super.focusNode, + ReactiveFormFieldCallback? onChangeEnd, + ReactiveFormFieldCallback? onChangeStart, + ReactiveFormFieldCallback? onChanged, + double? secondaryTrackValue, + Color? secondaryActiveColor, + MaterialStateProperty? overlayColor}) + : super( builder: (field) { var value = field.value; if (value == null) { @@ -68,6 +67,9 @@ class ReactiveSlider extends ReactiveFocusableFormField { min: min, max: max, divisions: divisions, + secondaryTrackValue: secondaryTrackValue, + secondaryActiveColor: secondaryActiveColor, + overlayColor: overlayColor, label: labelBuilder != null ? labelBuilder(field.value ?? min) : null, diff --git a/lib/src/widgets/reactive_status_listenable_builder.dart b/lib/src/widgets/reactive_status_listenable_builder.dart index 540305da..1268d2e1 100644 --- a/lib/src/widgets/reactive_status_listenable_builder.dart +++ b/lib/src/widgets/reactive_status_listenable_builder.dart @@ -32,16 +32,15 @@ class ReactiveStatusListenableBuilder extends StatelessWidget { /// at the same time. /// const ReactiveStatusListenableBuilder({ - Key? key, + super.key, this.formControlName, this.formControl, required this.builder, this.child, - }) : assert( + }) : assert( (formControlName != null && formControl == null) || (formControlName == null && formControl != null), - 'Must provide a formControlName or a formControl, but not both at the same time.'), - super(key: key); + 'Must provide a formControlName or a formControl, but not both at the same time.'); @override Widget build(BuildContext context) { diff --git a/lib/src/widgets/reactive_switch.dart b/lib/src/widgets/reactive_switch.dart index ca75ab53..92028b9d 100644 --- a/lib/src/widgets/reactive_switch.dart +++ b/lib/src/widgets/reactive_switch.dart @@ -33,10 +33,10 @@ class ReactiveSwitch extends ReactiveFocusableFormField { /// For documentation about the various parameters, see the [Switch] class /// and [Switch], the constructor. ReactiveSwitch({ - Key? key, - String? formControlName, - FormControl? formControl, - FocusNode? focusNode, + super.key, + super.formControlName, + super.formControl, + super.focusNode, Color? activeColor, Color? activeTrackColor, Color? inactiveThumbColor, @@ -56,15 +56,17 @@ class ReactiveSwitch extends ReactiveFocusableFormField { MaterialStateProperty? overlayColor, double? splashRadius, ReactiveFormFieldCallback? onChanged, + MaterialStateProperty? trackOutlineColor, + MaterialStateProperty? thumbIcon, + ValueChanged? onFocusChange, }) : super( - key: key, - formControl: formControl, - formControlName: formControlName, - focusNode: focusNode, builder: (field) { return Switch( value: field.value ?? false, activeColor: activeColor, + trackOutlineColor: trackOutlineColor, + thumbIcon: thumbIcon, + onFocusChange: onFocusChange, activeTrackColor: activeTrackColor, inactiveThumbColor: inactiveThumbColor, inactiveTrackColor: inactiveTrackColor, @@ -114,10 +116,10 @@ class ReactiveSwitch extends ReactiveFocusableFormField { /// For documentation about the various parameters, see the [Switch.adaptive] /// constructor. ReactiveSwitch.adaptive({ - Key? key, - String? formControlName, - FormControl? formControl, - FocusNode? focusNode, + super.key, + super.formControlName, + super.formControl, + super.focusNode, Color? activeColor, Color? activeTrackColor, Color? inactiveThumbColor, @@ -138,10 +140,6 @@ class ReactiveSwitch extends ReactiveFocusableFormField { double? splashRadius, ReactiveFormFieldCallback? onChanged, }) : super( - key: key, - formControl: formControl, - formControlName: formControlName, - focusNode: focusNode, builder: (field) { return Switch.adaptive( value: field.value ?? false, diff --git a/lib/src/widgets/reactive_switch_list_tile.dart b/lib/src/widgets/reactive_switch_list_tile.dart index ef0fc0ef..66c970fe 100644 --- a/lib/src/widgets/reactive_switch_list_tile.dart +++ b/lib/src/widgets/reactive_switch_list_tile.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by the MIT license that can be // found in the LICENSE file. +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:reactive_forms/reactive_forms.dart'; @@ -25,9 +26,9 @@ class ReactiveSwitchListTile extends ReactiveFocusableFormField { /// /// See also [CheckboxListTile] ReactiveSwitchListTile({ - Key? key, - String? formControlName, - FormControl? formControl, + super.key, + super.formControlName, + super.formControl, Color? tileColor, Color? activeColor, Color? activeTrackColor, @@ -49,13 +50,21 @@ class ReactiveSwitchListTile extends ReactiveFocusableFormField { Color? selectedTileColor, VisualDensity? visualDensity, bool? enableFeedback, - FocusNode? focusNode, + super.focusNode, ReactiveFormFieldCallback? onChanged, + ImageErrorListener? onActiveThumbImageError, + ImageErrorListener? onInactiveThumbImageError, + MaterialStateProperty? thumbColor, + MaterialStateProperty? trackColor, + MaterialStateProperty? trackOutlineColor, + MaterialStateProperty? thumbIcon, + MaterialTapTargetSize? materialTapTargetSize, + DragStartBehavior dragStartBehavior = DragStartBehavior.start, + MouseCursor? mouseCursor, + MaterialStateProperty? overlayColor, + double? splashRadius, + ValueChanged? onFocusChange, }) : super( - key: key, - formControl: formControl, - formControlName: formControlName, - focusNode: focusNode, builder: (field) { return SwitchListTile( value: field.value ?? false, @@ -63,6 +72,18 @@ class ReactiveSwitchListTile extends ReactiveFocusableFormField { activeTrackColor: activeTrackColor, inactiveThumbColor: inactiveThumbColor, inactiveTrackColor: inactiveTrackColor, + mouseCursor: mouseCursor, + overlayColor: overlayColor, + splashRadius: splashRadius, + onFocusChange: onFocusChange, + thumbColor: thumbColor, + trackColor: trackColor, + dragStartBehavior: dragStartBehavior, + materialTapTargetSize: materialTapTargetSize, + thumbIcon: thumbIcon, + trackOutlineColor: trackOutlineColor, + onActiveThumbImageError: onActiveThumbImageError, + onInactiveThumbImageError: onInactiveThumbImageError, hoverColor: hoverColor, activeThumbImage: activeThumbImage, title: title, @@ -107,22 +128,35 @@ class ReactiveSwitchListTile extends ReactiveFocusableFormField { /// For documentation about the various parameters, see the /// [SwitchListTile.adaptive] constructor. ReactiveSwitchListTile.adaptative({ - Key? key, - String? formControlName, - FormControl? formControl, + super.key, + super.formControlName, + super.formControl, Color? activeColor, - ImageProvider? activeThumbImage, Color? activeTrackColor, + Color? inactiveThumbColor, + Color? inactiveTrackColor, + ImageProvider? activeThumbImage, + ImageErrorListener? onActiveThumbImageError, + ImageProvider? inactiveThumbImage, + ImageErrorListener? onInactiveThumbImageError, + MaterialStateProperty? thumbColor, + MaterialStateProperty? trackColor, + MaterialStateProperty? trackOutlineColor, + MaterialStateProperty? thumbIcon, + MaterialTapTargetSize? materialTapTargetSize, + DragStartBehavior dragStartBehavior = DragStartBehavior.start, + MouseCursor? mouseCursor, + MaterialStateProperty? overlayColor, + double? splashRadius, bool autofocus = false, + bool? applyCupertinoTheme, EdgeInsetsGeometry? contentPadding, ListTileControlAffinity controlAffinity = ListTileControlAffinity.platform, bool? dense, bool? enableFeedback, - FocusNode? focusNode, + super.focusNode, + ValueChanged? onFocusChange, Color? hoverColor, - Color? inactiveThumbColor, - ImageProvider? inactiveThumbImage, - Color? inactiveTrackColor, bool isThreeLine = false, Widget? secondary, bool selected = false, @@ -134,26 +168,35 @@ class ReactiveSwitchListTile extends ReactiveFocusableFormField { VisualDensity? visualDensity, ReactiveFormFieldCallback? onChanged, }) : super( - key: key, - formControl: formControl, - formControlName: formControlName, - focusNode: focusNode, builder: (field) { return SwitchListTile.adaptive( value: field.value ?? false, activeColor: activeColor, - activeThumbImage: activeThumbImage, activeTrackColor: activeTrackColor, + inactiveThumbColor: inactiveThumbColor, + inactiveTrackColor: inactiveTrackColor, + activeThumbImage: activeThumbImage, + onActiveThumbImageError: onActiveThumbImageError, + inactiveThumbImage: inactiveThumbImage, + onInactiveThumbImageError: onInactiveThumbImageError, + thumbColor: thumbColor, + trackColor: trackColor, + trackOutlineColor: trackOutlineColor, + thumbIcon: thumbIcon, + materialTapTargetSize: materialTapTargetSize, + dragStartBehavior: dragStartBehavior, + mouseCursor: mouseCursor, + overlayColor: overlayColor, + splashRadius: splashRadius, autofocus: autofocus, + applyCupertinoTheme: applyCupertinoTheme, contentPadding: contentPadding, controlAffinity: controlAffinity, dense: dense, enableFeedback: enableFeedback, focusNode: field.focusNode, + onFocusChange: onFocusChange, hoverColor: hoverColor, - inactiveThumbColor: inactiveThumbColor, - inactiveThumbImage: inactiveThumbImage, - inactiveTrackColor: inactiveTrackColor, isThreeLine: isThreeLine, secondary: secondary, selected: selected, diff --git a/lib/src/widgets/reactive_text_field.dart b/lib/src/widgets/reactive_text_field.dart index 599f9dd0..40cda1bf 100644 --- a/lib/src/widgets/reactive_text_field.dart +++ b/lib/src/widgets/reactive_text_field.dart @@ -19,6 +19,13 @@ import 'package:reactive_forms/reactive_forms.dart'; class ReactiveTextField extends ReactiveFormField { final TextEditingController? _textController; + static Widget _defaultContextMenuBuilder( + BuildContext context, EditableTextState editableTextState) { + return AdaptiveTextSelectionToolbar.editableText( + editableTextState: editableTextState, + ); + } + /// Creates a [ReactiveTextField] that contains a [TextField]. /// /// Can optionally provide a [formControl] to bind this widget to a control. @@ -83,13 +90,13 @@ class ReactiveTextField extends ReactiveFormField { /// For documentation about the various parameters, see the [TextField] class /// and [TextField], the constructor. ReactiveTextField({ - Key? key, - String? formControlName, - FormControl? formControl, - Map? validationMessages, - ControlValueAccessor? valueAccessor, - ShowErrorsFunction? showErrors, - FocusNode? focusNode, + super.key, + super.formControlName, + super.formControl, + super.validationMessages, + super.valueAccessor, + super.showErrors, + super.focusNode, InputDecoration decoration = const InputDecoration(), TextInputType? keyboardType, TextCapitalization textCapitalization = TextCapitalization.none, @@ -101,7 +108,8 @@ class ReactiveTextField extends ReactiveFormField { TextAlignVertical? textAlignVertical, bool autofocus = false, bool readOnly = false, - ToolbarOptions? toolbarOptions, + EditableTextContextMenuBuilder? contextMenuBuilder = + _defaultContextMenuBuilder, bool? showCursor, bool obscureText = false, String obscuringCharacter = '•', @@ -141,15 +149,15 @@ class ReactiveTextField extends ReactiveFormField { ReactiveFormFieldCallback? onEditingComplete, ReactiveFormFieldCallback? onSubmitted, ReactiveFormFieldCallback? onChanged, + UndoHistoryController? undoController, + bool? cursorOpacityAnimates, + TapRegionCallback? onTapOutside, + ContentInsertionConfiguration? contentInsertionConfiguration, + bool canRequestFocus = true, + SpellCheckConfiguration? spellCheckConfiguration, + TextMagnifierConfiguration? magnifierConfiguration, }) : _textController = controller, super( - key: key, - formControl: formControl, - formControlName: formControlName, - valueAccessor: valueAccessor, - validationMessages: validationMessages, - showErrors: showErrors, - focusNode: focusNode, builder: (ReactiveFormFieldState field) { final state = field as _ReactiveTextFieldState; final effectiveDecoration = decoration @@ -169,7 +177,7 @@ class ReactiveTextField extends ReactiveFormField { textDirection: textDirection, textCapitalization: textCapitalization, autofocus: autofocus, - toolbarOptions: toolbarOptions, + contextMenuBuilder: contextMenuBuilder, readOnly: readOnly, showCursor: showCursor, obscureText: obscureText, @@ -223,6 +231,13 @@ class ReactiveTextField extends ReactiveFormField { field.didChange(value); onChanged?.call(field.control); }, + undoController: undoController, + cursorOpacityAnimates: cursorOpacityAnimates, + onTapOutside: onTapOutside, + contentInsertionConfiguration: contentInsertionConfiguration, + canRequestFocus: canRequestFocus, + spellCheckConfiguration: spellCheckConfiguration, + magnifierConfiguration: magnifierConfiguration, ); }, ); @@ -277,4 +292,13 @@ class _ReactiveTextFieldState : TextEditingController(); _textController.text = initialValue == null ? '' : initialValue.toString(); } + + @override + void dispose() { + final currentWidget = widget as ReactiveTextField; + if (currentWidget._textController == null) { + _textController.dispose(); + } + super.dispose(); + } } diff --git a/lib/src/widgets/reactive_time_picker.dart b/lib/src/widgets/reactive_time_picker.dart index 22a25d5d..501bf975 100644 --- a/lib/src/widgets/reactive_time_picker.dart +++ b/lib/src/widgets/reactive_time_picker.dart @@ -60,9 +60,9 @@ class ReactiveTimePicker extends ReactiveFormField { /// For documentation about the various parameters, see the [showTimePicker] /// function parameters. ReactiveTimePicker({ - Key? key, - String? formControlName, - FormControl? formControl, + super.key, + super.formControlName, + super.formControl, required ReactiveTimePickerBuilder builder, TransitionBuilder? transitionBuilder, bool useRootNavigator = true, @@ -78,9 +78,6 @@ class ReactiveTimePicker extends ReactiveFormField { EntryModeChangeCallback? onEntryModeChanged, Offset? anchorPoint, }) : super( - key: key, - formControl: formControl, - formControlName: formControlName, builder: (ReactiveFormFieldState field) { return builder( field.context, diff --git a/lib/src/widgets/reactive_value_listenable_builder.dart b/lib/src/widgets/reactive_value_listenable_builder.dart index 09a19aeb..af355f40 100644 --- a/lib/src/widgets/reactive_value_listenable_builder.dart +++ b/lib/src/widgets/reactive_value_listenable_builder.dart @@ -38,16 +38,15 @@ class ReactiveValueListenableBuilder extends StatelessWidget { /// subtree does not depend on the value of the [FormControl] that is bind /// with this widget. const ReactiveValueListenableBuilder({ - Key? key, + super.key, required this.builder, this.formControlName, this.formControl, this.child, - }) : assert( + }) : assert( (formControlName != null && formControl == null) || (formControlName == null && formControl != null), - 'Must provide a formControlName or a formControl, but not both at the same time.'), - super(key: key); + 'Must provide a formControlName or a formControl, but not both at the same time.'); @override Widget build(BuildContext context) { diff --git a/pubspec.yaml b/pubspec.yaml index 0e062b9e..61eef461 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,19 +1,19 @@ name: reactive_forms description: This is a model-driven approach to handling form inputs and validations, heavily inspired in Angular Reactive Forms. -version: 14.1.0 +version: 17.0.0 homepage: "https://github.com/joanpablo/reactive_forms" environment: - sdk: ">=2.17.0 <3.0.0" - flutter: ">=3.0.0" + sdk: ">=3.2.0 <4.0.0" + flutter: ">=3.16.0" dependencies: flutter: sdk: flutter - intl: ^0.17.0 + intl: ">=0.19.0 <1.0.0" dev_dependencies: - lints: ^2.0.0 + flutter_lints: ^3.0.2 flutter_test: sdk: flutter diff --git a/test/src/models/form_array_test.dart b/test/src/models/form_array_test.dart index 53578b09..d081ce1b 100644 --- a/test/src/models/form_array_test.dart +++ b/test/src/models/form_array_test.dart @@ -16,7 +16,7 @@ void main() { 'selectedEmails': FormArray( [], // an empty array of controls validators: [ - _emptyAddressee + Validators.delegate(_emptyAddressee), ], // validates that at least one email is selected ), }); @@ -50,7 +50,7 @@ void main() { 'selectedEmails': FormArray( [], // an empty array of controls validators: [ - _emptyAddressee + Validators.delegate(_emptyAddressee), ], // validates that at least one email is selected ), }); @@ -398,18 +398,50 @@ void main() { expect(array.rawValue, ['Reactive', 'Forms']); }); - test('Enable a array enable children', () { - // Given: a form with a disable control - final array = FormArray([ + test('Disabled array includes all controls in value and raw value', () { + // Given: a disabled array + final array = FormArray([ FormControl(value: 'Reactive'), - FormControl(value: 'Forms', disabled: true), - ]); + FormControl(value: 'Forms'), + ], disabled: true); + + // Expect: value and raw value includes all controls + expect(array.value!.length, 2); + expect(array.rawValue.length, 2); + expect(array.rawValue, ['Reactive', 'Forms']); + expect(array.value, ['Reactive', 'Forms']); + }); - // When: enable form - array.markAsEnabled(); + test('Disabled array includes all controls of nested group', () { + // Given: a disabled array + final array = FormArray([ + FormControl(value: 'Reactive'), + FormControl(value: 'Forms'), + ], disabled: true); + + // Expect: value and raw value includes all controls + expect(array.value!.length, 2); + expect(array.rawValue.length, 2); + expect(array.rawValue, ['Reactive', 'Forms']); + expect(array.value, ['Reactive', 'Forms']); + }); - // Then: all controls are enabled - expect(array.controls.every((control) => control.enabled), true); + test('Enable a array enable children', () { + // Given: a disabled array of groups + final addressArray = FormArray([ + fb.group({ + 'country': FormControl(value: 'Canada'), + 'city': FormControl(value: 'Toronto', disabled: true), + }), + ], disabled: true); + + // Expect: value and raw value includes all controls + expect(addressArray.value, [ + {'country': 'Canada', 'city': 'Toronto'} + ]); + expect(addressArray.rawValue, [ + {'country': 'Canada', 'city': 'Toronto'} + ]); }); test('Array valid when invalid control is disable', () { @@ -469,181 +501,240 @@ void main() { // Then: state error expect(addValue, throwsStateError); }); - }); - test('Set empty array value to array does not update values', () { - // Given: an array with items with default values - final array = FormArray([ - FormControl(value: 1), - FormControl(value: 2), - FormControl(value: 3), - ]); - - // And: reset array - array.value = []; - - //Then: items has initial default values - expect(array.control('0').value, 1); - expect(array.control('1').value, 2); - expect(array.control('2').value, 3); - }); - test('Get control with nested name', () { - // Given: a nested array - final form = FormGroup({ - 'numbers': FormArray([ + test('Set empty array value to array does not update values', () { + // Given: an array with items with default values + final array = FormArray([ + FormControl(value: 1), + FormControl(value: 2), + FormControl(value: 3), + ]); + + // And: reset array + array.value = []; + + //Then: items has initial default values + expect(array.control('0').value, 1); + expect(array.control('1').value, 2); + expect(array.control('2').value, 3); + }); + + test('Get control with nested name', () { + // Given: a nested array + final form = FormGroup({ + 'numbers': FormArray([ + FormControl(value: 1), + FormControl(value: 2), + FormControl(value: 3), + ]), + }); + + // When: get a nested control + final control = form.control('numbers.2'); + + // Then: control is not null + expect(control is FormControl, true); + expect(control.value, 3); + }); + + test('Array of groups', () { + // Given: an array of groups + final addressArray = FormArray([ + fb.group({'city': 'Sofia'}), + fb.group({'city': 'Havana'}), + ]); + + // Expect: array is created + expect(addressArray.controls.length, 2); + expect(addressArray.control('0').value, {'city': 'Sofia'}); + }); + + test('Value on nested disabled Form Group', () { + // Given: an array of groups with a disabled control + final addressArray = FormArray([ + fb.group({ + 'city': FormControl( + value: 'Toronto', + disabled: true, + ), + }), + ]); + + // Expect: array is created + expect(addressArray.controls.length, 1); + expect(addressArray.value, [ + {'city': 'Toronto'} + ]); + }); + + test('Raw Value on nested Form Group', () { + // Given: an array of groups with a disabled control + final addressArray = FormArray([ + fb.group({ + 'country': FormControl(value: 'Canada'), + 'city': FormControl(value: 'Toronto', disabled: true), + }), + ]); + + // Expect: array is created + expect(addressArray.rawValue, [ + { + 'country': 'Canada', + 'city': 'Toronto', + }, + ]); + }); + + test('Focused a control', () { + // Given: an array + final array = FormArray([ FormControl(value: 1), FormControl(value: 2), FormControl(value: 3), - ]), + ]); + + // When: set a control focus + array.focus('0'); + + // Then: control is focused + expect((array.control('0') as FormControl).hasFocus, true, + reason: 'control is not focused'); }); - // When: get a nested control - final control = form.control('numbers.2'); + test('Focused first control if no argument specified', () { + // Given: an array + final array = FormArray([ + FormControl(value: 1), + FormControl(value: 2), + FormControl(value: 3), + ]); - // Then: control is not null - expect(control is FormControl, true); - expect(control.value, 3); - }); + // When: set a control focus + array.focus(); - test('Array of groups', () { - // Given: an array of groups - final addressArray = FormArray([ - fb.group({'city': 'Sofia'}), - fb.group({'city': 'Havana'}), - ]); + // Then: control is focused + expect( + (array.control('0') as FormControl).hasFocus, + true, + reason: 'control is not focused', + ); + }); - // Expect: array is created - expect(addressArray.controls.length, 2); - expect(addressArray.control('0').value, {'city': 'Sofia'}); - }); + test('Focused a nested control', () { + // Given: array + final addressArray = FormArray([ + fb.group({'city': 'Sofia'}), + fb.group({'city': 'Havana'}), + ]); - test('Focused a control', () { - // Given: an array - final array = FormArray([ - FormControl(value: 1), - FormControl(value: 2), - FormControl(value: 3), - ]); + // When: set a control focus + addressArray.focus('0.city'); - // When: set a control focus - array.focus('0'); + final city = addressArray.control('0.city') as FormControl; - // Then: control is focused - expect((array.control('0') as FormControl).hasFocus, true, - reason: 'control is not focused'); - }); + // Then: control is focused + expect(city.hasFocus, true, reason: 'control is not focused'); + }); - test('Focused first control if no argument specified', () { - // Given: an array - final array = FormArray([ - FormControl(value: 1), - FormControl(value: 2), - FormControl(value: 3), - ]); - - // When: set a control focus - array.focus(); - - // Then: control is focused - expect( - (array.control('0') as FormControl).hasFocus, - true, - reason: 'control is not focused', - ); - }); + test('Remove Focus to all control', () { + // Given: array + final array = FormArray([ + FormControl(value: 1), + FormControl(value: 2), + ]); - test('Focused a nested control', () { - // Given: array - final addressArray = FormArray([ - fb.group({'city': 'Sofia'}), - fb.group({'city': 'Havana'}), - ]); + // And: all control with focus + array.focus('0'); + array.focus('1'); - // When: set a control focus - addressArray.focus('0.city'); + // When: remove focus to a control + array.unfocus(); - final city = addressArray.control('0.city') as FormControl; + // Then: any control has focus + expect((array.control('0') as FormControl).hasFocus, false, + reason: 'control is focused'); + expect((array.control('1') as FormControl).hasFocus, false, + reason: 'control is focused'); + }); - // Then: control is focused - expect(city.hasFocus, true, reason: 'control is not focused'); - }); + test('Clear Array', () { + // Given: array + final array = FormArray([ + FormControl(value: 1), + FormControl(value: 2), + ]); - test('Remove Focus to all control', () { - // Given: array - final array = FormArray([ - FormControl(value: 1), - FormControl(value: 2), - ]); - - // And: all control with focus - array.focus('0'); - array.focus('1'); - - // When: remove focus to a control - array.unfocus(); - - // Then: any control has focus - expect((array.control('0') as FormControl).hasFocus, false, - reason: 'control is focused'); - expect((array.control('1') as FormControl).hasFocus, false, - reason: 'control is focused'); - }); + // When: clear array + array.clear(); - test('Clear Array', () { - // Given: array - final array = FormArray([ - FormControl(value: 1), - FormControl(value: 2), - ]); + // Then: any control has focus + expect(array.controls.length, 0, reason: 'array is not empty'); + }); - // When: clear array - array.clear(); + test('Disabled Array marks all children as disabled', () { + // Given: a disabled form + final array = FormArray( + [ + FormControl(), + FormControl(), + ], + disabled: true, + ); - // Then: any control has focus - expect(array.controls.length, 0, reason: 'array is not empty'); - }); + // Then: array is disabled and all controls are disabled + expect(array.disabled, true, reason: 'array is enabled'); + expect(array.controls[0].disabled, true, reason: 'first is enabled'); + expect(array.controls[1].disabled, true, reason: 'second is enabled'); + }); - test('Initialize disabled array', () { - // Given: a disabled form - final array = FormArray([ - FormControl(), - FormControl(), - ], disabled: true); - - // Then: array is disabled and all controls are disabled - expect(array.enabled, false, reason: 'array is enabled'); - expect(array.controls[0].enabled, false, reason: 'first is enabled'); - expect(array.controls[1].enabled, false, reason: 'second is enabled'); - }); + test('Disabled array changes to enable when enable children', () { + // Given: a disabled array + final array = FormArray([ + FormControl(), + FormControl(), + ], disabled: true); - test('Disabled array changes to enable when enable children', () { - // Given: a disabled array - final array = FormArray([ - FormControl(), - FormControl(), - ], disabled: true); + // When: enabled child + array.controls.first.markAsEnabled(); - // When: enabled child - array.controls.first.markAsEnabled(); + // Then: array is enabled + expect(array.enabled, true, reason: 'array is disabled'); + expect(array.controls[0].enabled, true, reason: 'first is disabled'); + expect(array.controls[1].disabled, true, reason: 'second is enabled'); + }); - // Then: array is enabled - expect(array.enabled, true, reason: 'array is disabled'); - expect(array.controls[0].enabled, true, reason: 'first is disabled'); - expect(array.controls[1].disabled, true, reason: 'second is enabled'); - }); + test('Patch array value', () { + // Given: an array + final array = FormArray([ + FormControl(value: 1), + FormControl(value: 2), + ]); - test('Patch array value', () { - // Given: an array - final array = FormArray([ - FormControl(value: 1), - FormControl(value: 2), - ]); + // When: patch array value + array.patchValue([2]); - // When: patch array value - array.patchValue([2]); + // Then: array value is patched + expect(array.value, [2, 2], reason: 'array value not patched'); + }); - // Then: array value is patched - expect(array.value, [2, 2], reason: 'array value not patched'); + test( + 'Test that markAsPending() a control, set pending status to the array ' + 'as well.', () { + // Given: an array with valid status. + final array = FormArray([ + FormControl(value: 1), + ]); + + // Expect: the array to be VALID and not PENDING. + expect(array.valid, true); + expect(array.pending, false); + + // When: I call mark a child control as PENDING. + array.controls.first.markAsPending(); + + // Then: the status of the array is PENDING as well. + expect(array.pending, true); + }); }); } diff --git a/test/src/models/form_builder_test.dart b/test/src/models/form_builder_test.dart index 19cfa10d..cc3ddbc7 100644 --- a/test/src/models/form_builder_test.dart +++ b/test/src/models/form_builder_test.dart @@ -99,6 +99,18 @@ void main() { reason: 'control is not instance of FormControl'); }); + test('Build a group with a nullable form control', () { + // Given: a form control with nullable type and a form group created with it + final control = fb.control(null); + final form = fb.group({ + 'control': control, + }); + + // Expect a form group created with the correct type + expect(form.control('control') is FormControl, true, + reason: 'control is not instance of FormControl'); + }); + test('Build a group with single validator', () { // Given: a form group builder creation final validator = Validators.required; @@ -107,7 +119,7 @@ void main() { }); // Expect a form group created - expect(form.control('control') is FormControl, true, + expect(form.control('control') is FormControl, true, reason: 'control is not instance of FormControl'); expect(form.control('control').validators.first, validator, reason: 'validator not set'); diff --git a/test/src/models/form_control_test.dart b/test/src/models/form_control_test.dart index f308431e..6ce51867 100644 --- a/test/src/models/form_control_test.dart +++ b/test/src/models/form_control_test.dart @@ -365,13 +365,26 @@ void main() { expect(formControl.asyncValidators.isEmpty, true); // When: setting new async validators - Future?> asyncValidator( - AbstractControl control) => - Future.value(null); - formControl.setAsyncValidators([asyncValidator]); + formControl.setAsyncValidators( + [Validators.delegateAsync((control) => Future.value(null))]); // Then: a new async validator is added expect(formControl.asyncValidators.length, 1); }); + + test('Test that markAsPending() change the status to PENDING.', () { + // Given: a control with valid status. + final control = FormControl(value: 'Reactive Forms'); + + // Expect: the control to be VALID and not PENDING. + expect(control.valid, true); + expect(control.pending, false); + + // When: I call markAsPending() method. + control.markAsPending(); + + // Then: the status is PENDING. + expect(control.pending, true); + }); }); } diff --git a/test/src/models/form_group_test.dart b/test/src/models/form_group_test.dart index 2c194354..72b9dd78 100644 --- a/test/src/models/form_group_test.dart +++ b/test/src/models/form_group_test.dart @@ -285,6 +285,7 @@ void main() { expect(formValue.length, 1); expect(formValue.keys.first, 'name'); expect(formValue.values.first, 'Reactive'); + expect(formValue, {'name': 'Reactive'}); }); test('Enable a group enable children and recalculate validity', () { @@ -869,12 +870,71 @@ void main() { test('A control disabled is part of group Raw Value', () { // Given: a form with a disable control final form = FormGroup({ - 'name': FormControl(value: 'Reactive'), - 'email': FormControl(value: 'Forms', disabled: true), + 'name': FormControl(value: 'Flutter Reactive Forms'), + 'email': FormControl(value: 'rf@email.com', disabled: true), + }); + + // Expect: raw value includes disabled controls + expect(form.rawValue, { + 'name': 'Flutter Reactive Forms', + 'email': 'rf@email.com', + }); + }); + + test('A control disabled is part of recursive group Raw Value', () { + // Given: a nested form group with a disable control + final form = FormGroup({ + 'name': FormControl(value: 'Flutter Reactive Forms'), + 'email': FormControl(value: 'rf@email.com', disabled: true), + 'address': FormGroup({ + 'city': FormControl(value: 'Toronto'), + 'street': FormControl(value: 'Cherry St', disabled: true) + }) + }); + + // Expect: raw value includes disabled controls + expect(form.rawValue, { + 'name': 'Flutter Reactive Forms', + 'email': 'rf@email.com', + 'address': {'city': 'Toronto', 'street': 'Cherry St'} + }); + }); + + test( + 'Disabled children of nested array are part of recursive group Raw Value', + () { + // Given: a nested form group with a disable control + final form = FormGroup({ + 'brands': FormArray([ + FormControl(value: 'Nissan'), + FormControl(value: 'Mercedes', disabled: true), + FormControl(value: 'Toyota'), + ]) }); // Expect: raw value includes disabled controls - expect(form.rawValue, {'name': 'Reactive', 'email': 'Forms'}); + expect(form.rawValue, { + 'brands': ['Nissan', 'Mercedes', 'Toyota'] + }); + }); + + test('A disabled group includes all controls in the value', () { + // Given: a disabled group + final form = FormGroup({ + 'name': FormControl(value: 'Flutter Reactive Forms'), + 'email': FormControl(value: 'rf@email.com'), + }, disabled: true); + + // Expect: value and raw value includes disabled controls + expect(form.value, { + 'name': 'Flutter Reactive Forms', + 'email': 'rf@email.com', + }); + + expect(form.rawValue, { + 'name': 'Flutter Reactive Forms', + 'email': 'rf@email.com', + }); }); test('Remove control from FormGroup', () { @@ -922,5 +982,23 @@ void main() { // Expect: an assertion error expect(form, throwsAssertionError); }); + + test( + 'Test that markAsPending() a control, set pending status to the group ' + 'as well.', () { + // Given: a form group with valid status. + final form = + FormGroup({'name': FormControl(value: "Reactive Forms")}); + + // Expect: the group to be VALID and not PENDING. + expect(form.valid, true); + expect(form.pending, false); + + // When: I call mark a child control as PENDING. + form.control('name').markAsPending(); + + // Then: the status of the Form Group is PENDING as well. + expect(form.pending, true); + }); }); } diff --git a/test/src/validators/any_validator_test.dart b/test/src/validators/any_validator_test.dart index 9bc29d5c..2a939e07 100644 --- a/test/src/validators/any_validator_test.dart +++ b/test/src/validators/any_validator_test.dart @@ -3,6 +3,17 @@ import 'package:reactive_forms/reactive_forms.dart'; void main() { group('Any Validator tests', () { + test('Control value is null', () { + final array = FormControl>( + value: null, + validators: [ + Validators.any?>((value) => value?.isNotEmpty ?? false) + ], + ); + + expect(array.valid, false); + }); + test('At least one control in array has no empty value', () { // Given: an array of String with one not empty control and a validator final array = FormArray([ diff --git a/test/src/validators/compare_validator_test.dart b/test/src/validators/compare_validator_test.dart index a5853671..92a14dea 100644 --- a/test/src/validators/compare_validator_test.dart +++ b/test/src/validators/compare_validator_test.dart @@ -3,6 +3,20 @@ import 'package:reactive_forms/reactive_forms.dart'; void main() { group('Compare Validator Tests', () { + test('Lower compare in non FormGroup control', () { + final control = FormControl( + value: null, + validators: [ + Validators.compare('amount', 'balance', CompareOption.lower), + ], + ); + + expect(control.valid, false); + expect(control.errors, { + ValidationMessage.compare: true, + }); + }); + test('Lower compare', () { // Given: a valid form final form = fb.group({ diff --git a/test/src/validators/compose_validators_test.dart b/test/src/validators/compose_validators_test.dart index 77ae2544..0d4c3bd8 100644 --- a/test/src/validators/compose_validators_test.dart +++ b/test/src/validators/compose_validators_test.dart @@ -114,7 +114,7 @@ void main() { Validators.compose([ Validators.minLength(10), Validators.maxLength(10), - Validators.number, + Validators.number(), ]), ]), ], diff --git a/test/src/validators/min_length_validator_test.dart b/test/src/validators/min_length_validator_test.dart index 53ee7430..1f7b379a 100644 --- a/test/src/validators/min_length_validator_test.dart +++ b/test/src/validators/min_length_validator_test.dart @@ -15,6 +15,18 @@ void main() { expect(control.hasError(ValidationMessage.minLength), true); }); + test('FormControl invalid if minLength invalid', () { + // Given: a invalid control + final control = FormControl>( + value: ['Hello'], + validators: [Validators.minLength(6)], + ); + + // Expect: control is invalid + expect(control.invalid, true); + expect(control.hasError(ValidationMessage.minLength), true); + }); + test('FormControl valid if minLength valid', () { // Given: a valid control final control = FormControl( diff --git a/test/src/validators/number_validator_test.dart b/test/src/validators/number_validator_test.dart index f6999156..d6354ddd 100644 --- a/test/src/validators/number_validator_test.dart +++ b/test/src/validators/number_validator_test.dart @@ -1,10 +1,11 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:reactive_forms/reactive_forms.dart'; +import 'package:reactive_forms/src/validators/number_validator_error.dart'; void main() { group('Number Validator Tests', () { test('FormControl invalid if not a number', () { - final control = FormControl(validators: [Validators.number]); + final control = FormControl(validators: [Validators.number()]); control.value = 'hello'; @@ -13,11 +14,57 @@ void main() { }); test('FormControl valid if a number', () { - final control = FormControl(validators: [Validators.number]); + final control = FormControl(validators: [Validators.number()]); control.value = '10'; expect(control.valid, true); }); + + test('FormControl negative number', () { + final control = FormControl(validators: [Validators.number()]); + + control.value = '-10'; + + expect(control.valid, true); + }); + + test('FormControl decimal numbers', () { + final control = FormControl( + validators: [Validators.number(allowedDecimals: 3)], + ); + + control.value = '10.123'; + + expect(control.valid, true); + }); + + test('FormControl invalid decimal number with default allowedDecimals', () { + final control = FormControl( + validators: [Validators.number()], + ); + + control.value = '10.123'; + + expect(control.valid, false); + expect( + control.errors[ValidationMessage.number], + NumberValidatorError.invalidDecimals, + ); + }); + + test('FormControl invalid decimal numbers', () { + final control = FormControl( + validators: [Validators.number(allowedDecimals: 2)], + ); + + control.value = '10.123'; + + expect(control.valid, false); + expect( + control.errors[ValidationMessage.number], + NumberValidatorError.invalidDecimals, + ); + }); }); } diff --git a/test/src/widgets/reactive_checkbox_list_tile_testing_widget.dart b/test/src/widgets/reactive_checkbox_list_tile_testing_widget.dart index f49abc4c..73418b59 100644 --- a/test/src/widgets/reactive_checkbox_list_tile_testing_widget.dart +++ b/test/src/widgets/reactive_checkbox_list_tile_testing_widget.dart @@ -10,12 +10,12 @@ class ReactiveCheckboxListTileTestingWidget extends StatelessWidget { final ReactiveFormFieldCallback? onChanged; const ReactiveCheckboxListTileTestingWidget({ - Key? key, + super.key, required this.form, this.tristate = false, this.focusNode, this.onChanged, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/test/src/widgets/reactive_checkbox_testing_widget.dart b/test/src/widgets/reactive_checkbox_testing_widget.dart index 2f12079d..d72dca60 100644 --- a/test/src/widgets/reactive_checkbox_testing_widget.dart +++ b/test/src/widgets/reactive_checkbox_testing_widget.dart @@ -10,12 +10,12 @@ class ReactiveCheckboxTestingWidget extends StatelessWidget { final ReactiveFormFieldCallback? onChanged; const ReactiveCheckboxTestingWidget({ - Key? key, + super.key, required this.form, this.tristate = false, this.focusNode, this.onChanged, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/test/src/widgets/reactive_date_picker_testing_widget.dart b/test/src/widgets/reactive_date_picker_testing_widget.dart index 510badcb..db5386c7 100644 --- a/test/src/widgets/reactive_date_picker_testing_widget.dart +++ b/test/src/widgets/reactive_date_picker_testing_widget.dart @@ -8,12 +8,12 @@ class ReactiveDatePickerTestingWidget extends StatelessWidget { final DateTime? initialDate; const ReactiveDatePickerTestingWidget({ - Key? key, + super.key, required this.form, this.lastDate, this.firstDate, this.initialDate, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/test/src/widgets/reactive_dropdown_field_test.dart b/test/src/widgets/reactive_dropdown_field_test.dart index 731717c6..afb10b82 100644 --- a/test/src/widgets/reactive_dropdown_field_test.dart +++ b/test/src/widgets/reactive_dropdown_field_test.dart @@ -348,6 +348,40 @@ void main() { }, ); + testWidgets( + 'A disabled Dropdown use items to show selected item', + (WidgetTester tester) async { + // Given: a form with disabled control + final items = ['true', 'false']; + final form = FormGroup({ + 'dropdown': FormControl( + value: items.elementAt(0), + disabled: true, + ), + }); + + // And: a widget that is bound to the form + + await tester.pumpWidget(ReactiveDropdownTestingWidget( + form: form, + items: ['true', 'false'], + )); + + // Then: dropdown disabledHint value is equals to selectedItemBuilder + // equivalent item + final dropdownType = + DropdownButton(items: null, onChanged: null).runtimeType; + final dropdown = tester + .firstWidget>(find.byType(dropdownType)); + + // Then: disabled hint is shown + + final disabledHintFinder = find.byWidget(dropdown.disabledHint!); + + expect(disabledHintFinder, findsWidgets); + }, + ); + testWidgets( 'A disabled Dropdown uses selectedItemBuilder to show selected item', (WidgetTester tester) async { diff --git a/test/src/widgets/reactive_dropdown_testing_widget.dart b/test/src/widgets/reactive_dropdown_testing_widget.dart index 74ecd13c..bd2c4641 100644 --- a/test/src/widgets/reactive_dropdown_testing_widget.dart +++ b/test/src/widgets/reactive_dropdown_testing_widget.dart @@ -11,7 +11,7 @@ class ReactiveDropdownTestingWidget extends StatelessWidget { final DropdownButtonBuilder? selectedItemBuilder; const ReactiveDropdownTestingWidget({ - Key? key, + super.key, required this.form, required this.items, this.onChanged, @@ -19,7 +19,7 @@ class ReactiveDropdownTestingWidget extends StatelessWidget { this.readOnly = false, this.disabledHint, this.selectedItemBuilder, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/test/src/widgets/reactive_form_array_testing_widget.dart b/test/src/widgets/reactive_form_array_testing_widget.dart index 6173a00d..dede3eac 100644 --- a/test/src/widgets/reactive_form_array_testing_widget.dart +++ b/test/src/widgets/reactive_form_array_testing_widget.dart @@ -5,9 +5,9 @@ class ReactiveFormArrayTestingWidget extends StatelessWidget { final FormGroup form; const ReactiveFormArrayTestingWidget({ - Key? key, + super.key, required this.form, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/test/src/widgets/reactive_form_builder_testing_widget.dart b/test/src/widgets/reactive_form_builder_testing_widget.dart index b62b4546..748b1833 100644 --- a/test/src/widgets/reactive_form_builder_testing_widget.dart +++ b/test/src/widgets/reactive_form_builder_testing_widget.dart @@ -7,13 +7,13 @@ class ReactiveFormBuilderTestingWidget extends StatelessWidget { final Map bindings; const ReactiveFormBuilderTestingWidget({ - Key? key, + super.key, required this.form, this.validationMessages, this.bindings = const { 'textField': 'name', }, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/test/src/widgets/reactive_form_consumer_testing_widget.dart b/test/src/widgets/reactive_form_consumer_testing_widget.dart index c950417d..a7149585 100644 --- a/test/src/widgets/reactive_form_consumer_testing_widget.dart +++ b/test/src/widgets/reactive_form_consumer_testing_widget.dart @@ -5,9 +5,9 @@ class ReactiveFormConsumerTestingWidget extends StatelessWidget { final FormGroup form; const ReactiveFormConsumerTestingWidget({ - Key? key, + super.key, required this.form, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/test/src/widgets/reactive_radio_list_tile_testing_widget.dart b/test/src/widgets/reactive_radio_list_tile_testing_widget.dart index 8f034421..953ec2b3 100644 --- a/test/src/widgets/reactive_radio_list_tile_testing_widget.dart +++ b/test/src/widgets/reactive_radio_list_tile_testing_widget.dart @@ -9,11 +9,11 @@ class ReactiveRadioListTileTestingWidget extends StatelessWidget { final ReactiveFormFieldCallback? onChanged; const ReactiveRadioListTileTestingWidget({ - Key? key, + super.key, required this.form, this.focusNode, this.onChanged, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/test/src/widgets/reactive_radio_testing_widget.dart b/test/src/widgets/reactive_radio_testing_widget.dart index b7cc83ed..74e63825 100644 --- a/test/src/widgets/reactive_radio_testing_widget.dart +++ b/test/src/widgets/reactive_radio_testing_widget.dart @@ -9,11 +9,11 @@ class ReactiveRadioTestingWidget extends StatelessWidget { final ReactiveFormFieldCallback? onChanged; const ReactiveRadioTestingWidget({ - Key? key, + super.key, required this.form, this.focusNode, this.onChanged, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/test/src/widgets/reactive_slider_testing_widget.dart b/test/src/widgets/reactive_slider_testing_widget.dart index fd588044..b6e5d905 100644 --- a/test/src/widgets/reactive_slider_testing_widget.dart +++ b/test/src/widgets/reactive_slider_testing_widget.dart @@ -12,14 +12,14 @@ class ReactiveSliderTestingWidget extends StatelessWidget { final ReactiveFormFieldCallback? onChanged; const ReactiveSliderTestingWidget({ - Key? key, + super.key, required this.form, this.focusNode, this.labelBuilder, this.onChanged, this.onChangeStart, this.onChangeEnd, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/test/src/widgets/reactive_status_listenable_builder_test.dart b/test/src/widgets/reactive_status_listenable_builder_test.dart index f5be67b9..fb3d3da6 100644 --- a/test/src/widgets/reactive_status_listenable_builder_test.dart +++ b/test/src/widgets/reactive_status_listenable_builder_test.dart @@ -135,7 +135,7 @@ void main() { final form = FormGroup({ 'control': FormControl( validators: [Validators.required], - asyncValidators: [failedAsyncValidator], + asyncValidators: [Validators.delegateAsync(failedAsyncValidator)], asyncValidatorsDebounceTime: 0, ), }); @@ -157,15 +157,13 @@ void main() { }, ); - Future?> asyncValidator( - AbstractControl control) async { - return Future.value(null); - } - testWidgets( 'Async Validator change status to valid', (WidgetTester tester) async { // Given: a form with a field and async validator + final asyncValidator = + Validators.delegateAsync((control) => Future.value(null)); + final form = FormGroup({ 'control': FormControl( validators: [Validators.required], diff --git a/test/src/widgets/reactive_status_listenable_builder_testing_widget.dart b/test/src/widgets/reactive_status_listenable_builder_testing_widget.dart index f3ba460b..666e2b32 100644 --- a/test/src/widgets/reactive_status_listenable_builder_testing_widget.dart +++ b/test/src/widgets/reactive_status_listenable_builder_testing_widget.dart @@ -5,9 +5,9 @@ class ReactiveStatusListenableTestingWidget extends StatelessWidget { final FormGroup form; const ReactiveStatusListenableTestingWidget({ - Key? key, + super.key, required this.form, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/test/src/widgets/reactive_switch_list_tile_testing_widget.dart b/test/src/widgets/reactive_switch_list_tile_testing_widget.dart index 9da1ba3f..b30b729a 100644 --- a/test/src/widgets/reactive_switch_list_tile_testing_widget.dart +++ b/test/src/widgets/reactive_switch_list_tile_testing_widget.dart @@ -12,14 +12,14 @@ class ReactiveSwitchListTileTestingWidget extends StatelessWidget { final bool renderAdaptative; const ReactiveSwitchListTileTestingWidget({ - Key? key, + super.key, required this.form, this.renderAdaptative = true, this.focusNode, this.adaptativeFocusNode, this.onChanged, this.adaptativeOnChanged, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/test/src/widgets/reactive_switch_testing_widget.dart b/test/src/widgets/reactive_switch_testing_widget.dart index 48da1665..47e6d3f9 100644 --- a/test/src/widgets/reactive_switch_testing_widget.dart +++ b/test/src/widgets/reactive_switch_testing_widget.dart @@ -10,12 +10,12 @@ class ReactiveSwitchTestingWidget extends StatelessWidget { final ReactiveFormFieldCallback? adaptativeOnChanged; const ReactiveSwitchTestingWidget({ - Key? key, + super.key, required this.form, this.focusNode, this.onChanged, this.adaptativeOnChanged, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/test/src/widgets/reactive_text_field_test.dart b/test/src/widgets/reactive_text_field_test.dart index a2329bd0..4e8e4826 100644 --- a/test/src/widgets/reactive_text_field_test.dart +++ b/test/src/widgets/reactive_text_field_test.dart @@ -388,14 +388,15 @@ void main() { // Given: a form with custom validator var isControlDirty = false; - Map? customValidator( - AbstractControl control) { + Map? validator(AbstractControl control) { isControlDirty = control.dirty; return null; } final form = FormGroup({ - 'name': FormControl(validators: [customValidator]), + 'name': FormControl( + validators: [Validators.delegate(validator)], + ), }); // And: a widget that is bind to the form diff --git a/test/src/widgets/reactive_text_field_testing_widget.dart b/test/src/widgets/reactive_text_field_testing_widget.dart index 3cc11a13..e3210dbe 100644 --- a/test/src/widgets/reactive_text_field_testing_widget.dart +++ b/test/src/widgets/reactive_text_field_testing_widget.dart @@ -13,7 +13,7 @@ class ReactiveTextFieldTestingWidget extends StatelessWidget { final ReactiveFormFieldCallback? onEditingComplete; const ReactiveTextFieldTestingWidget({ - Key? key, + super.key, required this.form, this.validationMessages, this.showErrors, @@ -25,7 +25,7 @@ class ReactiveTextFieldTestingWidget extends StatelessWidget { this.onTap, this.onSubmitted, this.onEditingComplete, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/test/src/widgets/reactive_time_picker_testing_widget.dart b/test/src/widgets/reactive_time_picker_testing_widget.dart index abda518a..97d1c592 100644 --- a/test/src/widgets/reactive_time_picker_testing_widget.dart +++ b/test/src/widgets/reactive_time_picker_testing_widget.dart @@ -5,9 +5,9 @@ class ReactiveTimePickerTestingWidget extends StatelessWidget { final FormGroup form; const ReactiveTimePickerTestingWidget({ - Key? key, + super.key, required this.form, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/test/src/widgets/reactive_value_listenable_builder_testing_widget.dart b/test/src/widgets/reactive_value_listenable_builder_testing_widget.dart index d0b006cb..0eb6a570 100644 --- a/test/src/widgets/reactive_value_listenable_builder_testing_widget.dart +++ b/test/src/widgets/reactive_value_listenable_builder_testing_widget.dart @@ -5,9 +5,9 @@ class ReactiveValueListenableTestingWidget extends StatelessWidget { final FormGroup form; const ReactiveValueListenableTestingWidget({ - Key? key, + super.key, required this.form, - }) : super(key: key); + }); @override Widget build(BuildContext context) {