From 1f4225536aec336d9ba06c1f3dcaac5b2b69b8ed Mon Sep 17 00:00:00 2001 From: aydinguven-leancode Date: Fri, 19 Jun 2026 09:57:58 +0200 Subject: [PATCH 1/9] [LMG-394] Migration from Cubit to Value Notifier based field state management Migrated from Cubit (Bloc) to value notifier. Removed dependencies: flutter_bloc, rx_dart, equatable, Changes are reflected on examples Changes are reflected on tests --- .../focusable_text_field_controller.dart | 26 + .../password_field_controller.dart} | 9 +- .../cubits/focusable_text_field_cubit.dart | 17 - example/lib/screens/complex_form.dart | 101 ++- example/lib/screens/delivery_form.dart | 54 +- example/lib/screens/password_form.dart | 56 +- example/lib/screens/quiz_form.dart | 67 +- example/lib/screens/scroll_form.dart | 155 ++-- example/lib/screens/simple_form.dart | 33 +- example/lib/widgets/app_text_field.dart | 99 ++- example/lib/widgets/form_dropdown_field.dart | 57 +- example/lib/widgets/form_password_field.dart | 49 +- example/lib/widgets/form_switch_field.dart | 39 +- example/lib/widgets/form_text_field.dart | 158 ++-- example/pubspec.lock | 489 +---------- example/pubspec.yaml | 10 +- example/test/screens/password_form_test.dart | 26 +- example/test/screens/simple_form_test.dart | 81 +- lib/leancode_forms.dart | 12 +- lib/src/field/boolean_field_controller.dart | 14 + lib/src/field/boolean_field_cubit.dart | 12 - lib/src/field/builder/field_builder.dart | 27 +- lib/src/field/cubit/field_cubit.dart | 349 -------- lib/src/field/field_controller.dart | 373 +++++++++ ...art => multi_select_field_controller.dart} | 18 +- .../field/single_select_field_controller.dart | 23 + lib/src/field/single_select_field_cubit.dart | 20 - lib/src/field/text_field_controller.dart | 50 ++ lib/src/field/text_field_cubit.dart | 15 - lib/src/form_group/form_group_controller.dart | 389 +++++++++ .../form_group_cubit/form_group_cubit.dart | 365 -------- lib/src/utils/disposable.dart | 30 - .../utils/extensions/stream_extensions.dart | 20 - lib/src/validators/validators.dart | 3 +- pubspec.yaml | 10 +- test/src/field/field_builder_test.dart | 29 + test/src/field/field_controller_test.dart | 332 ++++++++ test/src/field/field_cubit_test.dart | 355 -------- .../form_group_controller_test.dart | 779 ++++++++++++++++++ .../form_group_cubit_test.dart | 728 ---------------- .../extensions/stream_extensions_test.dart | 31 - 41 files changed, 2593 insertions(+), 2917 deletions(-) create mode 100644 example/lib/controllers/focusable_text_field_controller.dart rename example/lib/{cubits/password_field_cubit.dart => controllers/password_field_controller.dart} (86%) delete mode 100644 example/lib/cubits/focusable_text_field_cubit.dart create mode 100644 lib/src/field/boolean_field_controller.dart delete mode 100644 lib/src/field/boolean_field_cubit.dart delete mode 100644 lib/src/field/cubit/field_cubit.dart create mode 100644 lib/src/field/field_controller.dart rename lib/src/field/{multi_select_field_cubit.dart => multi_select_field_controller.dart} (50%) create mode 100644 lib/src/field/single_select_field_controller.dart delete mode 100644 lib/src/field/single_select_field_cubit.dart create mode 100644 lib/src/field/text_field_controller.dart delete mode 100644 lib/src/field/text_field_cubit.dart create mode 100644 lib/src/form_group/form_group_controller.dart delete mode 100644 lib/src/form_group_cubit/form_group_cubit.dart delete mode 100644 lib/src/utils/disposable.dart delete mode 100644 lib/src/utils/extensions/stream_extensions.dart create mode 100644 test/src/field/field_builder_test.dart create mode 100644 test/src/field/field_controller_test.dart delete mode 100644 test/src/field/field_cubit_test.dart create mode 100644 test/src/form_group/form_group_controller_test.dart delete mode 100644 test/src/form_group_cubit/form_group_cubit_test.dart delete mode 100644 test/src/utils/extensions/stream_extensions_test.dart diff --git a/example/lib/controllers/focusable_text_field_controller.dart b/example/lib/controllers/focusable_text_field_controller.dart new file mode 100644 index 0000000..7a0e3c3 --- /dev/null +++ b/example/lib/controllers/focusable_text_field_controller.dart @@ -0,0 +1,26 @@ +import 'package:flutter/widgets.dart'; +import 'package:leancode_forms/leancode_forms.dart'; + +/// A [TextFieldController] that owns a [FocusNode] for scroll-to-error flows. +class FocusableTextFieldController + extends TextFieldController { + FocusableTextFieldController({ + super.initialValue, + super.validator, + super.asyncValidator, + super.asyncValidationDebounce, + super.name, + }); + + /// The focus node of the field. + final focusNode = FocusNode(); + + /// Focuses the field. + void focus() => focusNode.requestFocus(); + + @override + void dispose() { + focusNode.dispose(); + super.dispose(); + } +} diff --git a/example/lib/cubits/password_field_cubit.dart b/example/lib/controllers/password_field_controller.dart similarity index 86% rename from example/lib/cubits/password_field_cubit.dart rename to example/lib/controllers/password_field_controller.dart index be245dd..f0cec28 100644 --- a/example/lib/cubits/password_field_cubit.dart +++ b/example/lib/controllers/password_field_controller.dart @@ -1,10 +1,11 @@ import 'package:leancode_forms/leancode_forms.dart'; import 'package:leancode_forms_example/main.dart'; -/// A specialization of [FieldCubit] for a password field. -class PasswordFieldCubit extends FieldCubit> { - /// Creates a new [PasswordFieldCubit]. - PasswordFieldCubit({ +/// A specialization of [TextFieldController] for a password field. Errors are +/// reported as a list of rule violations. +class PasswordFieldController extends TextFieldController> { + /// Creates a new [PasswordFieldController]. + PasswordFieldController({ super.initialValue = '', this.minLength = 8, this.numberRequired = false, diff --git a/example/lib/cubits/focusable_text_field_cubit.dart b/example/lib/cubits/focusable_text_field_cubit.dart deleted file mode 100644 index 1111484..0000000 --- a/example/lib/cubits/focusable_text_field_cubit.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:leancode_forms/leancode_forms.dart'; - -class FocusableTextFieldCubit extends TextFieldCubit { - FocusableTextFieldCubit({ - super.initialValue, - super.validator, - super.asyncValidator, - super.asyncValidationDebounce, - }); - - /// Focuses the field. - void focus() => focusNode.requestFocus(); - - /// The focus node of the field. - final focusNode = FocusNode(); -} diff --git a/example/lib/screens/complex_form.dart b/example/lib/screens/complex_form.dart index 41be625..f76b379 100644 --- a/example/lib/screens/complex_form.dart +++ b/example/lib/screens/complex_form.dart @@ -1,11 +1,12 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:leancode_forms/leancode_forms.dart'; import 'package:leancode_forms_example/main.dart'; import 'package:leancode_forms_example/screens/form_page.dart'; import 'package:leancode_forms_example/widgets/form_dropdown_field.dart'; import 'package:leancode_forms_example/widgets/form_text_field.dart'; -import 'package:rxdart/rxdart.dart'; +import 'package:provider/provider.dart'; /// This is an example of a simple form with two fields. /// The form is validated ONLY when the submit button is pressed. @@ -14,8 +15,8 @@ class ComplexFormScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider( - create: (context) => ComplexFormCubit(), + return ChangeNotifierProvider( + create: (_) => ComplexFormController(), child: const ComplexForm(), ); } @@ -26,13 +27,14 @@ class ComplexForm extends StatelessWidget { @override Widget build(BuildContext context) { + final controller = context.read(); return FormPage( title: 'Complex Form', child: SingleChildScrollView( child: Column( children: [ FormDropdownField( - field: context.read().type, + field: controller.type, labelBuilder: (value) => value?.name ?? 'Select subform type', translateError: validatorTranslator, labelText: 'Subform Type', @@ -40,22 +42,22 @@ class ComplexForm extends StatelessWidget { ), Builder( builder: (context) { - final type = context.select( - (cubit) => cubit.subformType, + final type = context.select( + (c) => c.subformType, ); return switch (type) { SubformType.human => HumanSubform( - cubit: context.read().humanSubform, + controller: controller.humanSubform, ), SubformType.dog => DogSubform( - cubit: context.read().dogSubform, + controller: controller.dogSubform, ), _ => const SizedBox(), }; }, ), ElevatedButton( - onPressed: context.read().submit, + onPressed: controller.submit, child: const Text('Submit'), ), ], @@ -66,16 +68,16 @@ class ComplexForm extends StatelessWidget { } class HumanSubform extends StatelessWidget { - const HumanSubform({super.key, required this.cubit}); + const HumanSubform({super.key, required this.controller}); - final HumanSubformCubit cubit; + final HumanSubformController controller; @override Widget build(BuildContext context) { return Column( children: [ FormDropdownField( - field: cubit.gender, + field: controller.gender, labelBuilder: (value) => value.name, translateError: validatorTranslator, labelText: 'Gender', @@ -84,7 +86,7 @@ class HumanSubform extends StatelessWidget { ), const SizedBox(height: 16), FormTextField( - field: cubit.age, + field: controller.age, translateError: validatorTranslator, labelText: 'Age', hintText: 'Enter human age', @@ -95,16 +97,16 @@ class HumanSubform extends StatelessWidget { } class DogSubform extends StatelessWidget { - const DogSubform({super.key, required this.cubit}); + const DogSubform({super.key, required this.controller}); - final DogSubformCubit cubit; + final DogSubformController controller; @override Widget build(BuildContext context) { return Column( children: [ FormDropdownField( - field: cubit.breed, + field: controller.breed, labelBuilder: (value) => value.name, translateError: validatorTranslator, labelText: 'Breed', @@ -112,7 +114,7 @@ class DogSubform extends StatelessWidget { ), const SizedBox(height: 16), FormTextField( - field: cubit.age, + field: controller.age, translateError: validatorTranslator, labelText: 'Age', hintText: 'Enter dog age', @@ -122,35 +124,41 @@ class DogSubform extends StatelessWidget { } } -class ComplexFormCubit extends FormGroupCubit { - ComplexFormCubit() { - registerFields([ - type, - ]); - addDisposable( - type.stream - .map((event) => event.value) - .distinct() - .debounceTime(const Duration(milliseconds: 500)) - .listen(_onTypeUpdated) - .cancel, - ); +class ComplexFormController extends FormGroupController { + ComplexFormController() { + registerFields([type]); + type.addListener(_onTypeListenerFired); } - final type = SingleSelectFieldCubit( + final type = SingleSelectFieldController( options: SubformType.values, initialValue: null, ); SubformType? subformType; - final dogSubform = DogSubformCubit(); + final dogSubform = DogSubformController(); + final humanSubform = HumanSubformController(); + + Timer? _typeDebounce; + SubformType? _lastSeenType; - final humanSubform = HumanSubformCubit(); + void _onTypeListenerFired() { + final current = type.value.value; + if (current == _lastSeenType) { + return; + } + _lastSeenType = current; + _typeDebounce?.cancel(); + _typeDebounce = Timer(const Duration(milliseconds: 500), () { + _onTypeUpdated(current); + }); + } Future _onTypeUpdated(SubformType? type) async { await Future.delayed(const Duration(milliseconds: 300)); subformType = type; + notifyListeners(); if (type == SubformType.human) { addSubform(humanSubform); @@ -173,35 +181,44 @@ class ComplexFormCubit extends FormGroupCubit { debugPrint('Form is invalid!'); } } + + @override + void dispose() { + _typeDebounce?.cancel(); + type.removeListener(_onTypeListenerFired); + humanSubform.dispose(); + dogSubform.dispose(); + super.dispose(); + } } -class HumanSubformCubit extends FormGroupCubit { - HumanSubformCubit() { +class HumanSubformController extends FormGroupController { + HumanSubformController() { registerFields([ gender, age, ]); } - final gender = SingleSelectFieldCubit( + final gender = SingleSelectFieldController( initialValue: Gender.male, options: Gender.values, ); - final age = TextFieldCubit( + final age = TextFieldController( validator: filled(ValidationError.empty), ); } -class DogSubformCubit extends FormGroupCubit { - DogSubformCubit() { +class DogSubformController extends FormGroupController { + DogSubformController() { registerFields([ breed, age, ]); } - final breed = SingleSelectFieldCubit( + final breed = SingleSelectFieldController( initialValue: null, options: Breed.values, validator: (value) { @@ -212,7 +229,7 @@ class DogSubformCubit extends FormGroupCubit { }, ); - final age = TextFieldCubit( + final age = TextFieldController( validator: filled(ValidationError.empty), ); } diff --git a/example/lib/screens/delivery_form.dart b/example/lib/screens/delivery_form.dart index 4196790..10a7649 100644 --- a/example/lib/screens/delivery_form.dart +++ b/example/lib/screens/delivery_form.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:leancode_forms/leancode_forms.dart'; import 'package:leancode_forms_example/main.dart'; import 'package:leancode_forms_example/screens/form_page.dart'; import 'package:leancode_forms_example/widgets/form_dropdown_field.dart'; import 'package:leancode_forms_example/widgets/form_text_field.dart'; +import 'package:provider/provider.dart'; /// This is an example of a form with dynamically added subforms. class DeliveryListFormScreen extends StatelessWidget { @@ -12,8 +12,8 @@ class DeliveryListFormScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider( - create: (context) => DeliveryListFormCubit(), + return ChangeNotifierProvider( + create: (_) => DeliveryListFormController(), child: const DeliveryListForm(), ); } @@ -24,27 +24,27 @@ class DeliveryListForm extends StatelessWidget { @override Widget build(BuildContext context) { + final controller = context.watch(); return FormPage( title: 'Delivery List Form', child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - ...context.watch().deliveryList.map( - (e) => ConsumerSubform( - key: ValueKey(e.hashCode), - form: e, - onRemove: - context.watch().removeConsumer, - ), - ), + ...controller.deliveryList.map( + (e) => ConsumerSubform( + key: ValueKey(e.hashCode), + form: e, + onRemove: controller.removeConsumer, + ), + ), ElevatedButton( - onPressed: context.read().addConsumer, + onPressed: controller.addConsumer, child: const Text('Add Consumer'), ), const SizedBox(height: 16), ElevatedButton( - onPressed: context.read().submit, + onPressed: controller.submit, child: const Text('Submit'), ), ], @@ -61,8 +61,8 @@ class ConsumerSubform extends StatelessWidget { required this.onRemove, }); - final ConsumerSubformCubit form; - final ValueChanged onRemove; + final ConsumerSubformController form; + final ValueChanged onRemove; @override Widget build(BuildContext context) { @@ -99,27 +99,29 @@ class ConsumerSubform extends StatelessWidget { } } -class DeliveryListFormCubit extends FormGroupCubit { - DeliveryListFormCubit(); +class DeliveryListFormController extends FormGroupController { + DeliveryListFormController(); - final deliveryList = {}; + final deliveryList = {}; void addConsumer() { - final consumerForm = ConsumerSubformCubit(); + final consumerForm = ConsumerSubformController(); addSubform(consumerForm); deliveryList.add(consumerForm); + notifyListeners(); } - void removeConsumer(ConsumerSubformCubit form) { + void removeConsumer(ConsumerSubformController form) { removeSubform(form); deliveryList.remove(form); + notifyListeners(); } void submit() { if (validate()) { for (final consumer in deliveryList) { - debugPrint('Consumer email: ${consumer.email.state.value}'); - debugPrint('Consumer country: ${consumer.country.state.value}'); + debugPrint('Consumer email: ${consumer.email.value.value}'); + debugPrint('Consumer country: ${consumer.country.value.value}'); } debugPrint('Form is valid'); } else { @@ -128,19 +130,19 @@ class DeliveryListFormCubit extends FormGroupCubit { } } -class ConsumerSubformCubit extends FormGroupCubit { - ConsumerSubformCubit() { +class ConsumerSubformController extends FormGroupController { + ConsumerSubformController() { registerFields([ email, country, ]); } - final email = TextFieldCubit( + final email = TextFieldController( validator: filled(ValidationError.empty), ); - final country = SingleSelectFieldCubit( + final country = SingleSelectFieldController( initialValue: null, options: Country.values, validator: (country) { diff --git a/example/lib/screens/password_form.dart b/example/lib/screens/password_form.dart index 8b866ff..1fd45b7 100644 --- a/example/lib/screens/password_form.dart +++ b/example/lib/screens/password_form.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:leancode_forms/leancode_forms.dart'; -import 'package:leancode_forms_example/cubits/password_field_cubit.dart'; +import 'package:leancode_forms_example/controllers/password_field_controller.dart'; import 'package:leancode_forms_example/main.dart'; import 'package:leancode_forms_example/screens/form_page.dart'; import 'package:leancode_forms_example/widgets/form_password_field.dart'; import 'package:leancode_forms_example/widgets/form_switch_field.dart'; import 'package:leancode_forms_example/widgets/form_text_field.dart'; +import 'package:provider/provider.dart'; /// This is an example of a form with a password/repeat password fields. /// In this form repeatPassword field is validated according to value in the password field. @@ -15,8 +15,8 @@ class PasswordFormScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider( - create: (context) => PasswordFormCubit(), + return ChangeNotifierProvider( + create: (_) => PasswordFormController(), child: const PasswordForm(), ); } @@ -27,14 +27,14 @@ class PasswordForm extends StatelessWidget { @override Widget build(BuildContext context) { + final controller = context.read(); return FormPage( title: 'Password Form', child: Column( children: [ - //This field starts to be validated as soon as it loses focus for the first time FormTextField( - field: context.read().username, - onUnfocus: () => context.read().username + field: controller.username, + onUnfocus: () => controller.username ..setAutovalidate(true) ..validate(), translateError: validatorTranslator, @@ -43,26 +43,26 @@ class PasswordForm extends StatelessWidget { ), const SizedBox(height: 16), FormSwitchField( - field: context.read().switchField, + field: controller.switchField, labelText: 'Repeat password should be 10 characters long', ), const SizedBox(height: 16), FormPasswordField( - field: context.read().password, + field: controller.password, translateError: (error) => validatorTranslator(error.first), labelText: 'Password', hintText: 'Enter your password', ), const SizedBox(height: 16), FormTextField( - field: context.read().repeatPassword, + field: controller.repeatPassword, translateError: validatorTranslator, labelText: 'Repeat Password', hintText: 'Repeat your password', ), const SizedBox(height: 16), ElevatedButton( - onPressed: context.read().submit, + onPressed: controller.submit, child: const Text('Submit'), ), ], @@ -71,19 +71,19 @@ class PasswordForm extends StatelessWidget { } } -Validator passwordMatch( - PasswordFieldCubit passwordCubit, +Validator passwordMatch( + PasswordFieldController passwordController, E message, ) => (value) { - if (value != passwordCubit.state.value) { + if (value != passwordController.value.value) { return message; } return null; }; -class PasswordFormCubit extends FormGroupCubit { - PasswordFormCubit() { +class PasswordFormController extends FormGroupController { + PasswordFormController() { registerFields([ username, switchField, @@ -92,36 +92,36 @@ class PasswordFormCubit extends FormGroupCubit { ]); } - final username = TextFieldCubit( + final username = TextFieldController( validator: filled(ValidationError.empty) & atLeastLength(5, ValidationError.toShort), ); - final switchField = BooleanFieldCubit(); + final switchField = BooleanFieldController(); - final password = PasswordFieldCubit( + final password = PasswordFieldController( numberRequired: true, specialCharRequired: true, upperCaseRequired: true, lowerCaseRequired: true, ); - late final repeatPassword = TextFieldCubit( + late final repeatPassword = TextFieldController( validator: passwordMatch(password, ValidationError.doesNotMatch), )..subscribeToFields([switchField, password]); void submit() { if (validate()) { - debugPrint('Username: ${username.state.value}'); - debugPrint('Switch field: ${switchField.state.value}'); - debugPrint('Password: ${password.state.value}'); - debugPrint('Repeated password: ${repeatPassword.state.value}'); + debugPrint('Username: ${username.value.value}'); + debugPrint('Switch field: ${switchField.value.value}'); + debugPrint('Password: ${password.value.value}'); + debugPrint('Repeated password: ${repeatPassword.value.value}'); } else { debugPrint('Form is invalid'); - debugPrint('Username: ${username.state.value}'); - debugPrint('Switch field: ${switchField.state.value}'); - debugPrint('Password: ${password.state.value}'); - debugPrint('Repeated password: ${repeatPassword.state.value}'); + debugPrint('Username: ${username.value.value}'); + debugPrint('Switch field: ${switchField.value.value}'); + debugPrint('Password: ${password.value.value}'); + debugPrint('Repeated password: ${repeatPassword.value.value}'); } } } diff --git a/example/lib/screens/quiz_form.dart b/example/lib/screens/quiz_form.dart index 8371eb9..630a5c6 100644 --- a/example/lib/screens/quiz_form.dart +++ b/example/lib/screens/quiz_form.dart @@ -1,10 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:leancode_forms/leancode_forms.dart'; import 'package:leancode_forms_example/main.dart'; import 'package:leancode_forms_example/screens/form_page.dart'; import 'package:leancode_forms_example/widgets/form_text_field.dart'; +import 'package:provider/provider.dart'; /// This is an example of a form which is asynchronously validated after pressing the submit button. /// Errors on the fields are set/cleared manually after the validation is complete. @@ -13,20 +12,21 @@ class QuizFormScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider( - create: (context) => QuizCubit(), + return ChangeNotifierProvider( + create: (_) => QuizController(), child: const QuizForm(), ); } } -class QuizForm extends HookWidget { +class QuizForm extends StatelessWidget { const QuizForm({super.key}); @override Widget build(BuildContext context) { - final formStatus = context.select( - (cubit) => cubit.state.validationStatus, + final controller = context.read(); + final formStatus = context.select( + (c) => c.validationStatus, ); return FormPage( @@ -35,7 +35,7 @@ class QuizForm extends HookWidget { children: [ const Text('What is the longest river in the world?'), FormTextField( - field: context.read().formCubit.riverQuestion, + field: controller.formController.riverQuestion, trimOnUnfocus: true, translateError: validatorTranslator, hintText: 'Answer here', @@ -43,14 +43,14 @@ class QuizForm extends HookWidget { const SizedBox(height: 16), const Text('What is the highest mountain in the world?'), FormTextField( - field: context.read().formCubit.mountQuestion, + field: controller.formController.mountQuestion, trimOnUnfocus: true, translateError: validatorTranslator, hintText: 'Answer here', ), const SizedBox(height: 16), ElevatedButton( - onPressed: context.read().submit, + onPressed: controller.submit, child: const Text('Send answers'), ), const SizedBox(height: 16), @@ -68,29 +68,37 @@ enum ValidationStatus { none, } -class QuizCubit extends Cubit { - QuizCubit() : super(QuizState()); +class QuizController extends ChangeNotifier { + QuizController(); - final QuizFormCubit formCubit = QuizFormCubit(); + final QuizFormController formController = QuizFormController(); + + ValidationStatus _validationStatus = ValidationStatus.none; + ValidationStatus get validationStatus => _validationStatus; + + void _setStatus(ValidationStatus status) { + _validationStatus = status; + notifyListeners(); + } Future submit() async { - emit(QuizState(validationStatus: ValidationStatus.inProgress)); + _setStatus(ValidationStatus.inProgress); debugPrint('Validation in progress...'); final result = await quizValidation( - formCubit.riverQuestion.state.value, - formCubit.mountQuestion.state.value, + formController.riverQuestion.value.value, + formController.mountQuestion.value.value, ); - formCubit.riverQuestion.setError( + formController.riverQuestion.setError( result.$1 ? null : ValidationError.invalidAnswer, ); - formCubit.mountQuestion.setError( + formController.mountQuestion.setError( result.$2 ? null : ValidationError.invalidAnswer, ); if (result.$1 && result.$2) { - emit(QuizState(validationStatus: ValidationStatus.valid)); + _setStatus(ValidationStatus.valid); debugPrint('Validation successful!'); } else { - emit(QuizState(validationStatus: ValidationStatus.invalid)); + _setStatus(ValidationStatus.invalid); debugPrint('Validation failed!'); } } @@ -99,23 +107,22 @@ class QuizCubit extends Cubit { await Future.delayed(const Duration(seconds: 1)); return (answer1 == 'Nile', answer2 == 'Everest'); } -} -class QuizState { - QuizState({this.validationStatus = ValidationStatus.none}); - - final ValidationStatus validationStatus; + @override + void dispose() { + formController.dispose(); + super.dispose(); + } } -class QuizFormCubit extends FormGroupCubit { - QuizFormCubit() { +class QuizFormController extends FormGroupController { + QuizFormController() { registerFields([ riverQuestion, mountQuestion, ]); } - final riverQuestion = TextFieldCubit(); - - final mountQuestion = TextFieldCubit(); + final riverQuestion = TextFieldController(); + final mountQuestion = TextFieldController(); } diff --git a/example/lib/screens/scroll_form.dart b/example/lib/screens/scroll_form.dart index e351265..b8e1e22 100644 --- a/example/lib/screens/scroll_form.dart +++ b/example/lib/screens/scroll_form.dart @@ -1,91 +1,107 @@ -import 'package:bloc_presentation/bloc_presentation.dart'; +import 'dart:async'; + import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:leancode_forms/leancode_forms.dart'; -import 'package:leancode_forms_example/cubits/focusable_text_field_cubit.dart'; +import 'package:leancode_forms_example/controllers/focusable_text_field_controller.dart'; import 'package:leancode_forms_example/main.dart'; import 'package:leancode_forms_example/screens/form_page.dart'; import 'package:leancode_forms_example/utils/extensions/iterable_extensions.dart'; import 'package:leancode_forms_example/widgets/form_text_field.dart'; +import 'package:provider/provider.dart'; class ScrollFormScreen extends StatelessWidget { const ScrollFormScreen({super.key}); @override Widget build(BuildContext context) { - return BlocProvider( - create: (context) => ScrollFormCubit(), + return ChangeNotifierProvider( + create: (_) => ScrollFormController(), child: const ScrollForm(), ); } } -class ScrollForm extends StatelessWidget { +class ScrollForm extends StatefulWidget { const ScrollForm({super.key}); @override - Widget build(BuildContext context) { - void scrollToFistError() { - final scrollFormCubit = context.read(); - final fields = [ - scrollFormCubit.firstField, - scrollFormCubit.secondField, - scrollFormCubit.thirdField, - ]; - fields.firstWhereOrNull((field) => field.state.isInvalid)?.focus(); - } + State createState() => _ScrollFormState(); +} + +class _ScrollFormState extends State { + StreamSubscription? _eventSubscription; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _eventSubscription ??= context.read().events.listen( + (event) { + if (event is SubmitFailedWithErrors) { + _scrollToFirstError(context.read()); + } + }, + ); + } + + void _scrollToFirstError(ScrollFormController controller) { + final fields = [ + controller.firstField, + controller.secondField, + controller.thirdField, + ]; + fields.firstWhereOrNull((f) => f.value.isInvalid)?.focus(); + } + @override + void dispose() { + _eventSubscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final controller = context.read(); return FormPage( title: 'Scroll Form', - child: BlocPresentationListener( - listener: (context, event) { - if (event is SubmitFailedWithErrors) { - scrollToFistError(); - } - }, - child: SingleChildScrollView( - child: Column( - children: [ - FocusableFormTextField( - field: context.read().firstField, - translateError: validatorTranslator, - labelText: 'First field', - hintText: 'Write here...', - onFieldSubmitted: (_) => - context.read().secondField.focus(), - ), - const SizedBox(height: 260), - FocusableFormTextField( - field: context.read().secondField, - translateError: validatorTranslator, - labelText: 'Second field', - hintText: 'Write here...', - onFieldSubmitted: (_) => - context.read().thirdField.focus(), - ), - const SizedBox(height: 260), - FocusableFormTextField( - field: context.read().thirdField, - translateError: validatorTranslator, - labelText: 'Third field', - hintText: 'Write here...', - ), - const SizedBox(height: 260), - ElevatedButton( - onPressed: context.read().submit, - child: const Text('Submit'), - ), - ], - ), + child: SingleChildScrollView( + child: Column( + children: [ + FocusableFormTextField( + field: controller.firstField, + translateError: validatorTranslator, + labelText: 'First field', + hintText: 'Write here...', + onFieldSubmitted: (_) => controller.secondField.focus(), + ), + const SizedBox(height: 260), + FocusableFormTextField( + field: controller.secondField, + translateError: validatorTranslator, + labelText: 'Second field', + hintText: 'Write here...', + onFieldSubmitted: (_) => controller.thirdField.focus(), + ), + const SizedBox(height: 260), + FocusableFormTextField( + field: controller.thirdField, + translateError: validatorTranslator, + labelText: 'Third field', + hintText: 'Write here...', + ), + const SizedBox(height: 260), + ElevatedButton( + onPressed: controller.submit, + child: const Text('Submit'), + ), + ], ), ), ); } } -class ScrollFormCubit extends FormGroupCubit - with BlocPresentationMixin { - ScrollFormCubit() { +class ScrollFormController extends FormGroupController { + ScrollFormController() { registerFields([ firstField, secondField, @@ -93,27 +109,36 @@ class ScrollFormCubit extends FormGroupCubit ]); } - final firstField = FocusableTextFieldCubit( + final firstField = FocusableTextFieldController( validator: filled(ValidationError.empty), ); - final secondField = FocusableTextFieldCubit( + final secondField = FocusableTextFieldController( validator: filled(ValidationError.empty), ); - final thirdField = FocusableTextFieldCubit( + final thirdField = FocusableTextFieldController( validator: filled(ValidationError.empty), ); + final _eventsController = StreamController.broadcast(); + Stream get events => _eventsController.stream; + void submit() { if (validate()) { debugPrint('Submit successful'); } else { - emitPresentation(const SubmitFailedWithErrors()); + _eventsController.add(const SubmitFailedWithErrors()); } } + + @override + void dispose() { + _eventsController.close(); + super.dispose(); + } } -sealed class ScrollFormCubitEvent {} +sealed class ScrollFormEvent {} -class SubmitFailedWithErrors implements ScrollFormCubitEvent { +class SubmitFailedWithErrors implements ScrollFormEvent { const SubmitFailedWithErrors(); } diff --git a/example/lib/screens/simple_form.dart b/example/lib/screens/simple_form.dart index 0bc31e6..c98dc79 100644 --- a/example/lib/screens/simple_form.dart +++ b/example/lib/screens/simple_form.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:leancode_forms/leancode_forms.dart'; import 'package:leancode_forms_example/main.dart'; import 'package:leancode_forms_example/screens/form_page.dart'; import 'package:leancode_forms_example/widgets/form_text_field.dart'; +import 'package:provider/provider.dart'; /// This is an example of a simple form with two basic fields and one field with async validation. /// The form is validated ONLY when the submit button is pressed. @@ -12,8 +12,8 @@ class SimpleFormScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider( - create: (context) => SimpleFormCubit(), + return ChangeNotifierProvider( + create: (_) => SimpleFormController(), child: const SimpleForm(), ); } @@ -24,33 +24,34 @@ class SimpleForm extends StatelessWidget { @override Widget build(BuildContext context) { + final controller = context.read(); return FormPage( title: 'Simple Form', child: SingleChildScrollView( child: Column( children: [ FormTextField( - field: context.read().firstName, + field: controller.firstName, translateError: validatorTranslator, labelText: 'First Name', hintText: 'Enter your first name', canSetToInitial: true, ), FormTextField( - field: context.read().lastName, + field: controller.lastName, translateError: validatorTranslator, labelText: 'Last Name', hintText: 'Enter your last name', canSetToInitial: true, ), FormTextField( - field: context.read().email, + field: controller.email, translateError: validatorTranslator, labelText: 'Email', hintText: 'Enter your email', ), ElevatedButton( - onPressed: context.read().submit, + onPressed: controller.submit, child: const Text('Submit'), ), ], @@ -60,8 +61,8 @@ class SimpleForm extends StatelessWidget { } } -class SimpleFormCubit extends FormGroupCubit { - SimpleFormCubit() { +class SimpleFormController extends FormGroupController { + SimpleFormController() { registerFields([ firstName, lastName, @@ -69,18 +70,17 @@ class SimpleFormCubit extends FormGroupCubit { ]); } - final firstName = TextFieldCubit( + final firstName = TextFieldController( initialValue: 'John', validator: filled(ValidationError.empty), ); - final lastName = TextFieldCubit( + final lastName = TextFieldController( initialValue: 'Foo', validator: filled(ValidationError.empty), ); - //A field with async validation - late final email = TextFieldCubit( + late final email = TextFieldController( validator: filled(ValidationError.empty), asyncValidator: _onEmailChanged, asyncValidationDebounce: const Duration(milliseconds: 500), @@ -93,11 +93,10 @@ class SimpleFormCubit extends FormGroupCubit { } void submit() { - //Change to true to enable autovalidation of each field after pressing submit. if (validate(enableAutovalidate: false)) { - debugPrint('First name: ${firstName.state.value}'); - debugPrint('Last name: ${lastName.state.value}'); - debugPrint('Email: ${email.state.value}'); + debugPrint('First name: ${firstName.value.value}'); + debugPrint('Last name: ${lastName.value.value}'); + debugPrint('Email: ${email.value.value}'); } else { debugPrint('Form is invalid'); } diff --git a/example/lib/widgets/app_text_field.dart b/example/lib/widgets/app_text_field.dart index e632a65..8222197 100644 --- a/example/lib/widgets/app_text_field.dart +++ b/example/lib/widgets/app_text_field.dart @@ -1,19 +1,15 @@ import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:leancode_forms/leancode_forms.dart'; -/// This is an example of custom text field created for an app. -/// It's created in order to show how to use [FieldBuilder] with custom fields. -class AppTextField extends HookWidget { +/// A text field that renders an externally-owned [TextEditingController] and +/// [FocusNode]. The widget never disposes the controller, and only disposes a +/// fallback [FocusNode] it allocated itself. +class AppTextField extends StatefulWidget { const AppTextField({ super.key, - this.controller, + required this.controller, this.focusNode, - this.initialValue, - this.onChanged, this.onUnfocus, this.onFieldSubmitted, - required this.setValue, this.trimOnUnfocus = false, this.labelText, this.hintText, @@ -22,75 +18,78 @@ class AppTextField extends HookWidget { this.onSetToInitial, }); - final TextEditingController? controller; + final TextEditingController controller; final FocusNode? focusNode; - final String? initialValue; - final ValueChanged? onChanged; final VoidCallback? onUnfocus; final ValueChanged? onFieldSubmitted; - final ValueChanged setValue; final bool trimOnUnfocus; final String? labelText; final String? hintText; final String? errorText; final Widget? suffix; - final String Function()? onSetToInitial; + final VoidCallback? onSetToInitial; @override - Widget build(BuildContext context) { - final textEditingController = - controller ?? useTextEditingController(text: initialValue); - final focusNode = this.focusNode ?? useFocusNode(); + State createState() => _AppTextFieldState(); +} - useEffect( - () { - void listener() { - if (!focusNode.hasFocus) { - onUnfocus?.call(); - if (trimOnUnfocus) { - final trimmedValue = textEditingController.text.trim(); - textEditingController.text = trimmedValue; - setValue(trimmedValue); - } - } - } +class _AppTextFieldState extends State { + FocusNode? _ownedFocusNode; - focusNode.addListener(listener); - return () => focusNode.removeListener(listener); - }, - [], - ); + FocusNode get _focusNode => + widget.focusNode ?? (_ownedFocusNode ??= FocusNode()); + @override + void initState() { + super.initState(); + _focusNode.addListener(_onFocusChange); + } + + @override + void dispose() { + _focusNode.removeListener(_onFocusChange); + _ownedFocusNode?.dispose(); + super.dispose(); + } + + void _onFocusChange() { + if (_focusNode.hasFocus) { + return; + } + widget.onUnfocus?.call(); + if (widget.trimOnUnfocus) { + widget.controller.text = widget.controller.text.trim(); + } + } + + @override + Widget build(BuildContext context) { return Row( children: [ Flexible( child: TextFormField( autocorrect: false, - focusNode: focusNode, - controller: textEditingController, - onChanged: onChanged, - onTapOutside: (_) => focusNode.unfocus(), - onFieldSubmitted: onFieldSubmitted, + focusNode: _focusNode, + controller: widget.controller, + onTapOutside: (_) => _focusNode.unfocus(), + onFieldSubmitted: widget.onFieldSubmitted, decoration: InputDecoration( - labelText: labelText, - hintText: hintText, - errorText: errorText, - suffix: suffix, + labelText: widget.labelText, + hintText: widget.hintText, + errorText: widget.errorText, + suffix: widget.suffix, ), ), ), const SizedBox(width: 16), ElevatedButton( - onPressed: () { - textEditingController.clear(); - setValue(''); - }, + onPressed: widget.controller.clear, child: const Text('Empty'), ), - if (onSetToInitial case final onSetToInitial?) ...[ + if (widget.onSetToInitial case final onSetToInitial?) ...[ const SizedBox(width: 16), ElevatedButton( - onPressed: () => textEditingController.text = onSetToInitial(), + onPressed: onSetToInitial, child: const Text('Set to initial'), ), ], diff --git a/example/lib/widgets/form_dropdown_field.dart b/example/lib/widgets/form_dropdown_field.dart index 73c18ce..12d8e37 100644 --- a/example/lib/widgets/form_dropdown_field.dart +++ b/example/lib/widgets/form_dropdown_field.dart @@ -1,27 +1,40 @@ +import 'package:flutter/material.dart'; import 'package:leancode_forms/leancode_forms.dart'; import 'package:leancode_forms_example/widgets/app_dropdown_field.dart'; -class FormDropdownField extends FieldBuilder { - FormDropdownField({ +class FormDropdownField extends StatelessWidget { + const FormDropdownField({ super.key, - required SingleSelectFieldCubit super.field, - required String Function(T) labelBuilder, - required ErrorTranslator translateError, - String? labelText, - String? hintText, - bool canSetToInitial = false, - }) : super( - builder: (context, state) => AppDropdownField( - value: state.value, - options: field.options, - onChanged: field.select, - labelBuilder: labelBuilder, - label: labelText, - hint: hintText, - errorText: - state.error != null ? translateError(state.error!) : null, - onSetToInitial: canSetToInitial ? field.clear : null, - onEmpty: () => field.select(null), - ), - ); + required this.field, + required this.labelBuilder, + required this.translateError, + this.labelText, + this.hintText, + this.canSetToInitial = false, + }); + + final SingleSelectFieldController field; + final String Function(T) labelBuilder; + final ErrorTranslator translateError; + final String? labelText; + final String? hintText; + final bool canSetToInitial; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: field, + builder: (context, state, _) => AppDropdownField( + value: state.value, + options: field.options, + onChanged: field.select, + labelBuilder: labelBuilder, + label: labelText, + hint: hintText, + errorText: state.error != null ? translateError(state.error!) : null, + onSetToInitial: canSetToInitial ? field.clear : null, + onEmpty: () => field.select(null), + ), + ); + } } diff --git a/example/lib/widgets/form_password_field.dart b/example/lib/widgets/form_password_field.dart index 67eb88b..e21df42 100644 --- a/example/lib/widgets/form_password_field.dart +++ b/example/lib/widgets/form_password_field.dart @@ -1,28 +1,35 @@ import 'package:flutter/material.dart'; import 'package:leancode_forms/leancode_forms.dart'; -import 'package:leancode_forms_example/cubits/password_field_cubit.dart'; +import 'package:leancode_forms_example/controllers/password_field_controller.dart'; import 'package:leancode_forms_example/main.dart'; import 'package:leancode_forms_example/widgets/app_text_field.dart'; -class FormPasswordField extends FieldBuilder> { - FormPasswordField({ +class FormPasswordField extends StatelessWidget { + const FormPasswordField({ super.key, - required PasswordFieldCubit super.field, - required ErrorTranslator> translateError, - TextEditingController? controller, - String? labelText, - String? hintText, - }) : super( - builder: (context, state) => AppTextField( - onChanged: field.getValueSetter(), - setValue: field.setValue, - errorText: (state.error?.isNotEmpty ?? false) - ? translateError(state.error!) - : null, - initialValue: state.value, - controller: controller, - labelText: labelText, - hintText: hintText, - ), - ); + required this.field, + required this.translateError, + this.labelText, + this.hintText, + }); + + final PasswordFieldController field; + final ErrorTranslator> translateError; + final String? labelText; + final String? hintText; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>>( + valueListenable: field, + builder: (context, state, _) => AppTextField( + controller: field.textController, + labelText: labelText, + hintText: hintText, + errorText: (state.error?.isNotEmpty ?? false) + ? translateError(state.error!) + : null, + ), + ); + } } diff --git a/example/lib/widgets/form_switch_field.dart b/example/lib/widgets/form_switch_field.dart index f2e5aaa..efeed30 100644 --- a/example/lib/widgets/form_switch_field.dart +++ b/example/lib/widgets/form_switch_field.dart @@ -1,21 +1,30 @@ import 'package:flutter/material.dart'; import 'package:leancode_forms/leancode_forms.dart'; -class FormSwitchField extends FieldBuilder { - FormSwitchField({ +class FormSwitchField extends StatelessWidget { + const FormSwitchField({ super.key, - required super.field, - String? labelText, - }) : super( - builder: (context, state) => Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - if (labelText != null) Flexible(child: Text(labelText)), - Switch( - value: state.value, - onChanged: field.getValueSetter(), - ), - ], + required this.field, + this.labelText, + }); + + final BooleanFieldController field; + final String? labelText; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: field, + builder: (context, state, _) => Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (labelText != null) Flexible(child: Text(labelText!)), + Switch( + value: state.value, + onChanged: field.getValueSetter(), ), - ); + ], + ), + ); + } } diff --git a/example/lib/widgets/form_text_field.dart b/example/lib/widgets/form_text_field.dart index 8e95c6b..9e1c124 100644 --- a/example/lib/widgets/form_text_field.dart +++ b/example/lib/widgets/form_text_field.dart @@ -1,82 +1,94 @@ import 'package:flutter/material.dart'; import 'package:leancode_forms/leancode_forms.dart'; -import 'package:leancode_forms_example/cubits/focusable_text_field_cubit.dart'; +import 'package:leancode_forms_example/controllers/focusable_text_field_controller.dart'; import 'package:leancode_forms_example/widgets/app_text_field.dart'; -class FormTextField extends FieldBuilder { - FormTextField({ +class FormTextField extends StatelessWidget { + const FormTextField({ super.key, - required TextFieldCubit super.field, - required ErrorTranslator translateError, - TextEditingController? controller, - VoidCallback? onUnfocus, - ValueChanged? onFieldSubmitted, - bool? trimOnUnfocus, - String? labelText, - String? hintText, - bool canSetToInitial = false, - }) : super( - builder: (context, state) => AppTextField( - key: key, - onChanged: field.getValueSetter(), - onUnfocus: onUnfocus, - onFieldSubmitted: onFieldSubmitted, - setValue: field.setValue, - trimOnUnfocus: trimOnUnfocus ?? false, - errorText: - state.error != null ? translateError(state.error!) : null, - initialValue: state.value, - controller: controller, - labelText: labelText, - hintText: hintText, - suffix: state.isValidating - ? const SizedBox.square( - dimension: 16, - child: CircularProgressIndicator(), - ) - : null, - onSetToInitial: canSetToInitial - ? () { - field.clear(); - return field.state.value; - } - : null, - ), - ); + required this.field, + required this.translateError, + this.onUnfocus, + this.onFieldSubmitted, + this.trimOnUnfocus = false, + this.labelText, + this.hintText, + this.canSetToInitial = false, + }); + + final TextFieldController field; + final ErrorTranslator translateError; + final VoidCallback? onUnfocus; + final ValueChanged? onFieldSubmitted; + final bool trimOnUnfocus; + final String? labelText; + final String? hintText; + final bool canSetToInitial; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: field, + builder: (context, state, _) => AppTextField( + controller: field.textController, + onUnfocus: onUnfocus, + onFieldSubmitted: onFieldSubmitted, + trimOnUnfocus: trimOnUnfocus, + labelText: labelText, + hintText: hintText, + errorText: state.error != null ? translateError(state.error!) : null, + suffix: state.isValidating + ? const SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(), + ) + : null, + onSetToInitial: canSetToInitial ? field.clear : null, + ), + ); + } } -class FocusableFormTextField extends FieldBuilder { - FocusableFormTextField({ +class FocusableFormTextField extends StatelessWidget { + const FocusableFormTextField({ super.key, - required FocusableTextFieldCubit super.field, - required ErrorTranslator translateError, - TextEditingController? controller, - VoidCallback? onUnfocus, - ValueChanged? onFieldSubmitted, - bool? trimOnUnfocus, - String? labelText, - String? hintText, - }) : super( - builder: (context, state) => AppTextField( - key: key, - onChanged: field.getValueSetter(), - onUnfocus: onUnfocus, - onFieldSubmitted: onFieldSubmitted, - setValue: field.setValue, - trimOnUnfocus: trimOnUnfocus ?? false, - errorText: - state.error != null ? translateError(state.error!) : null, - initialValue: state.value, - controller: controller, - focusNode: field.focusNode, - labelText: labelText, - hintText: hintText, - suffix: state.isValidating - ? const SizedBox.square( - dimension: 16, - child: CircularProgressIndicator(), - ) - : null, - ), - ); + required this.field, + required this.translateError, + this.onUnfocus, + this.onFieldSubmitted, + this.trimOnUnfocus = false, + this.labelText, + this.hintText, + }); + + final FocusableTextFieldController field; + final ErrorTranslator translateError; + final VoidCallback? onUnfocus; + final ValueChanged? onFieldSubmitted; + final bool trimOnUnfocus; + final String? labelText; + final String? hintText; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: field, + builder: (context, state, _) => AppTextField( + controller: field.textController, + focusNode: field.focusNode, + onUnfocus: onUnfocus, + onFieldSubmitted: onFieldSubmitted, + trimOnUnfocus: trimOnUnfocus, + labelText: labelText, + hintText: hintText, + errorText: state.error != null ? translateError(state.error!) : null, + suffix: state.isValidating + ? const SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(), + ) + : null, + ), + ); + } } diff --git a/example/pubspec.lock b/example/pubspec.lock index 283cf22..7e3f7b9 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -1,43 +1,6 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" - url: "https://pub.dev" - source: hosted - version: "76.0.0" - _macros: - dependency: transitive - description: dart - source: sdk - version: "0.3.3" - analyzer: - dependency: transitive - description: - name: analyzer - sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" - url: "https://pub.dev" - source: hosted - version: "6.11.0" - analyzer_plugin: - dependency: transitive - description: - name: analyzer_plugin - sha256: "9661b30b13a685efaee9f02e5d01ed9f2b423bd889d28a304d02d704aee69161" - url: "https://pub.dev" - source: hosted - version: "0.11.3" - args: - dependency: transitive - description: - name: args - sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 - url: "https://pub.dev" - source: hosted - version: "2.4.2" async: dependency: transitive description: @@ -46,30 +9,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.12.0" - bloc: - dependency: transitive - description: - name: bloc - sha256: "52c10575f4445c61dd9e0cafcc6356fdd827c4c64dd7945ef3c4105f6b6ac189" - url: "https://pub.dev" - source: hosted - version: "9.0.0" - bloc_presentation: - dependency: "direct main" - description: - name: bloc_presentation - sha256: "03ea22745a23274a7fa4425ac16e120838471d3073fa37a3332c18641cb2d8a2" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - bloc_test: - dependency: "direct dev" - description: - name: bloc_test - sha256: "1dd549e58be35148bc22a9135962106aa29334bc1e3f285994946a1057b29d7b" - url: "https://pub.dev" - source: hosted - version: "10.0.0" boolean_selector: dependency: transitive description: @@ -86,30 +25,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff - url: "https://pub.dev" - source: hosted - version: "2.0.3" - ci: - dependency: transitive - description: - name: ci - sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13" - url: "https://pub.dev" - source: hosted - version: "0.1.0" - cli_util: - dependency: transitive - description: - name: cli_util - sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c - url: "https://pub.dev" - source: hosted - version: "0.4.2" clock: dependency: transitive description: @@ -126,86 +41,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" - convert: - dependency: transitive - description: - name: convert - sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" - url: "https://pub.dev" - source: hosted - version: "3.1.1" - coverage: - dependency: transitive - description: - name: coverage - sha256: e3493833ea012784c740e341952298f1cc77f1f01b1bbc3eb4eecf6984fb7f43 - url: "https://pub.dev" - source: hosted - version: "1.11.1" - crypto: - dependency: transitive - description: - name: crypto - sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab - url: "https://pub.dev" - source: hosted - version: "3.0.3" - custom_lint: - dependency: transitive - description: - name: custom_lint - sha256: "3486c470bb93313a9417f926c7dd694a2e349220992d7b9d14534dc49c15bba9" - url: "https://pub.dev" - source: hosted - version: "0.7.0" - custom_lint_builder: - dependency: transitive - description: - name: custom_lint_builder - sha256: "42cdc41994eeeddab0d7a722c7093ec52bd0761921eeb2cbdbf33d192a234759" - url: "https://pub.dev" - source: hosted - version: "0.7.0" - custom_lint_core: - dependency: transitive - description: - name: custom_lint_core - sha256: "02450c3e45e2a6e8b26c4d16687596ab3c4644dd5792e3313aa9ceba5a49b7f5" - url: "https://pub.dev" - source: hosted - version: "0.7.0" - custom_lint_visitor: - dependency: transitive - description: - name: custom_lint_visitor - sha256: bfe9b7a09c4775a587b58d10ebb871d4fe618237639b1e84d5ec62d7dfef25f9 - url: "https://pub.dev" - source: hosted - version: "1.0.0+6.11.0" - dart_style: - dependency: transitive - description: - name: dart_style - sha256: "7306ab8a2359a48d22310ad823521d723acfed60ee1f7e37388e8986853b6820" - url: "https://pub.dev" - source: hosted - version: "2.3.8" - diff_match_patch: - dependency: transitive - description: - name: diff_match_patch - sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" - url: "https://pub.dev" - source: hosted - version: "0.4.1" - equatable: - dependency: transitive - description: - name: equatable - sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 - url: "https://pub.dev" - source: hosted - version: "2.0.5" fake_async: dependency: transitive description: @@ -214,120 +49,16 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.2" - file: - dependency: transitive - description: - name: file - sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" - url: "https://pub.dev" - source: hosted - version: "7.0.0" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.dev" - source: hosted - version: "1.1.1" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" - flutter_bloc: - dependency: "direct main" - description: - name: flutter_bloc - sha256: "153856bdaac302bbdc58a1d1403d50c40557254aa05eaeed40515d88a25a526b" - url: "https://pub.dev" - source: hosted - version: "9.0.0" - flutter_hooks: - dependency: "direct main" - description: - name: flutter_hooks - sha256: cde36b12f7188c85286fba9b38cc5a902e7279f36dd676967106c041dc9dde70 - url: "https://pub.dev" - source: hosted - version: "0.20.5" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" - freezed_annotation: - dependency: transitive - description: - name: freezed_annotation - sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 - url: "https://pub.dev" - source: hosted - version: "2.4.4" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" - url: "https://pub.dev" - source: hosted - version: "3.2.0" - glob: - dependency: transitive - description: - name: glob - sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - hotreloader: - dependency: transitive - description: - name: hotreloader - sha256: bc167a1163807b03bada490bfe2df25b0d744df359227880220a5cbd04e5734b - url: "https://pub.dev" - source: hosted - version: "4.3.0" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" - url: "https://pub.dev" - source: hosted - version: "3.2.1" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" - url: "https://pub.dev" - source: hosted - version: "4.0.2" - io: - dependency: transitive - description: - name: io - sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" - url: "https://pub.dev" - source: hosted - version: "1.0.4" - js: - dependency: transitive - description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.dev" - source: hosted - version: "0.6.7" - json_annotation: - dependency: transitive - description: - name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" - url: "https://pub.dev" - source: hosted - version: "4.9.0" leak_tracker: dependency: transitive description: @@ -358,31 +89,15 @@ packages: path: ".." relative: true source: path - version: "0.1.2" + version: "0.2.0" leancode_lint: dependency: "direct dev" description: name: leancode_lint - sha256: "7560e71bfc4807948427ea4a38e12c68c25ebc5cecb5064e90d0f217330e5b4a" - url: "https://pub.dev" - source: hosted - version: "15.0.0" - logging: - dependency: transitive - description: - name: logging - sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + sha256: da5ae45a53e9cc78c7306ff4a292d266867bc81a7629b867f5c3d01d64c8939c url: "https://pub.dev" source: hosted - version: "1.2.0" - macros: - dependency: transitive - description: - name: macros - sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" - url: "https://pub.dev" - source: hosted - version: "0.1.3-main.0" + version: "6.0.0" matcher: dependency: transitive description: @@ -407,22 +122,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.16.0" - mime: - dependency: transitive - description: - name: mime - sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e - url: "https://pub.dev" - source: hosted - version: "1.0.4" - mocktail: - dependency: transitive - description: - name: mocktail - sha256: "9503969a7c2c78c7292022c70c0289ed6241df7a9ba720010c0b215af29a5a58" - url: "https://pub.dev" - source: hosted - version: "1.0.0" nested: dependency: transitive description: @@ -431,22 +130,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - node_preamble: - dependency: transitive - description: - name: node_preamble - sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" - url: "https://pub.dev" - source: hosted - version: "2.0.2" - package_config: - dependency: transitive - description: - name: package_config - sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" - url: "https://pub.dev" - source: hosted - version: "2.1.0" path: dependency: transitive description: @@ -455,99 +138,19 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" - pool: - dependency: transitive - description: - name: pool - sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" - url: "https://pub.dev" - source: hosted - version: "1.5.1" provider: - dependency: transitive - description: - name: provider - sha256: cdbe7530b12ecd9eb455bdaa2fcb8d4dad22e80b8afb4798b41479d5ce26847f - url: "https://pub.dev" - source: hosted - version: "6.0.5" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - pubspec_parse: - dependency: transitive - description: - name: pubspec_parse - sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" - url: "https://pub.dev" - source: hosted - version: "1.5.0" - rxdart: dependency: "direct main" description: - name: rxdart - sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" - url: "https://pub.dev" - source: hosted - version: "0.28.0" - shelf: - dependency: transitive - description: - name: shelf - sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 - url: "https://pub.dev" - source: hosted - version: "1.4.1" - shelf_packages_handler: - dependency: transitive - description: - name: shelf_packages_handler - sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - shelf_static: - dependency: transitive - description: - name: shelf_static - sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e - url: "https://pub.dev" - source: hosted - version: "1.1.2" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "6.1.5+1" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" - source_map_stack_trace: - dependency: transitive - description: - name: source_map_stack_trace - sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - source_maps: - dependency: transitive - description: - name: source_maps - sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" - url: "https://pub.dev" - source: hosted - version: "0.10.12" source_span: dependency: transitive description: @@ -556,14 +159,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" - sprintf: - dependency: transitive - description: - name: sprintf - sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" - url: "https://pub.dev" - source: hosted - version: "7.0.0" stack_trace: dependency: transitive description: @@ -580,14 +175,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" - stream_transform: - dependency: transitive - description: - name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 - url: "https://pub.dev" - source: hosted - version: "2.1.1" string_scanner: dependency: transitive description: @@ -604,14 +191,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" - test: - dependency: transitive - description: - name: test - sha256: "301b213cd241ca982e9ba50266bd3f5bd1ea33f1455554c5abb85d1be0e2d87e" - url: "https://pub.dev" - source: hosted - version: "1.25.15" test_api: dependency: transitive description: @@ -620,30 +199,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.4" - test_core: - dependency: transitive - description: - name: test_core - sha256: "84d17c3486c8dfdbe5e12a50c8ae176d15e2a771b96909a9442b40173649ccaa" - url: "https://pub.dev" - source: hosted - version: "0.6.8" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c - url: "https://pub.dev" - source: hosted - version: "1.3.2" - uuid: - dependency: transitive - description: - name: uuid - sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff - url: "https://pub.dev" - source: hosted - version: "4.5.1" vector_math: dependency: transitive description: @@ -660,38 +215,6 @@ packages: url: "https://pub.dev" source: hosted version: "14.3.1" - watcher: - dependency: transitive - description: - name: watcher - sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b - url: "https://pub.dev" - source: hosted - version: "2.4.0" - webkit_inspection_protocol: - dependency: transitive - description: - name: webkit_inspection_protocol - sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" - url: "https://pub.dev" - source: hosted - version: "1.2.1" - yaml: - dependency: transitive - description: - name: yaml - sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" - url: "https://pub.dev" - source: hosted - version: "3.1.2" sdks: dart: ">=3.7.0-0 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 5e49948..0bc874e 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,26 +1,22 @@ name: leancode_forms_example description: A new Flutter project. publish_to: "none" -version: 0.1.1 +version: 0.2.0 environment: sdk: ">=3.1.0 <4.0.0" dependencies: - bloc_presentation: ^1.1.0 flutter: sdk: flutter - flutter_bloc: ^9.0.0 - flutter_hooks: ^0.20.5 leancode_forms: path: .. - rxdart: ^0.28.0 + provider: ^6.1.2 dev_dependencies: - bloc_test: ^10.0.0 flutter_test: sdk: flutter - leancode_lint: ^15.0.0 + leancode_lint: ^6.0.0 flutter: uses-material-design: true diff --git a/example/test/screens/password_form_test.dart b/example/test/screens/password_form_test.dart index 80ea5ed..7230695 100644 --- a/example/test/screens/password_form_test.dart +++ b/example/test/screens/password_form_test.dart @@ -1,21 +1,17 @@ -import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:leancode_forms/leancode_forms.dart'; import 'package:leancode_forms_example/main.dart'; import 'package:leancode_forms_example/screens/password_form.dart'; void main() { - blocTest( - 'sets error in repeatPassword field when passwords do not match', - build: PasswordFormCubit.new, - act: (cubit) { - cubit.password.setValue('Password!1'); - cubit.repeatPassword.setValue('1234567'); - cubit.validate(); - }, - verify: (cubit) { - expect(cubit.password.state.error, null); - expect(cubit.repeatPassword.state.error, ValidationError.doesNotMatch); - }, - ); + test('sets error in repeatPassword when passwords do not match', () { + final controller = PasswordFormController(); + addTearDown(controller.dispose); + + controller.password.setValue('Password!1'); + controller.repeatPassword.setValue('1234567'); + controller.validate(); + + expect(controller.password.value.error, null); + expect(controller.repeatPassword.value.error, ValidationError.doesNotMatch); + }); } diff --git a/example/test/screens/simple_form_test.dart b/example/test/screens/simple_form_test.dart index ba6596a..ce63a9f 100644 --- a/example/test/screens/simple_form_test.dart +++ b/example/test/screens/simple_form_test.dart @@ -1,47 +1,46 @@ -import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:leancode_forms/leancode_forms.dart'; import 'package:leancode_forms_example/main.dart'; import 'package:leancode_forms_example/screens/simple_form.dart'; void main() { - blocTest( - 'sets email when setValue is called', - build: SimpleFormCubit.new, - act: (cubit) => cubit.email.setValue('john@email.com'), - verify: (cubit) { - expect(cubit.email.state.value, 'john@email.com'); - }, - ); - - blocTest( - 'sets ValidationErrors.emailTaken when email is taken', - build: SimpleFormCubit.new, - act: (cubit) => cubit.email.setValue('john@email.com'), - wait: const Duration(seconds: 2), - verify: (cubit) async { - expect(cubit.email.state.error, ValidationError.emailTaken); - }, - ); - - blocTest( - 'should not have any errors before submit method invoked', - build: SimpleFormCubit.new, - verify: (cubit) { - expect(cubit.email.state.error, null); - expect(cubit.firstName.state.error, null); - expect(cubit.lastName.state.error, null); - }, - ); - - blocTest( - 'validates fields and sets errors after submit method invoked', - build: SimpleFormCubit.new, - act: (cubit) => cubit.validate(), - verify: (cubit) { - expect(cubit.email.state.error, ValidationError.empty); - expect(cubit.firstName.state.error, ValidationError.empty); - expect(cubit.lastName.state.error, ValidationError.empty); - }, - ); + test('sets email when setValue is called', () { + final controller = SimpleFormController(); + addTearDown(controller.dispose); + + controller.email.setValue('john@email.com'); + + expect(controller.email.value.value, 'john@email.com'); + }); + + test('sets ValidationError.emailTaken when email is taken', () async { + final controller = SimpleFormController(); + addTearDown(controller.dispose); + + controller.email.setValue('john@email.com'); + await Future.delayed(const Duration(seconds: 2)); + + expect(controller.email.value.error, ValidationError.emailTaken); + }); + + test('has no errors before submit is invoked', () { + final controller = SimpleFormController(); + addTearDown(controller.dispose); + + expect(controller.email.value.error, null); + expect(controller.firstName.value.error, null); + expect(controller.lastName.value.error, null); + }); + + test('validate flags only empty fields as invalid', () { + final controller = SimpleFormController(); + addTearDown(controller.dispose); + + controller.validate(); + + // email defaults to '' so it fails `filled`; firstName/lastName have + // non-empty initial values so they pass. + expect(controller.email.value.error, ValidationError.empty); + expect(controller.firstName.value.error, null); + expect(controller.lastName.value.error, null); + }); } diff --git a/lib/leancode_forms.dart b/lib/leancode_forms.dart index c67f4f0..437d360 100644 --- a/lib/leancode_forms.dart +++ b/lib/leancode_forms.dart @@ -1,8 +1,8 @@ -export 'src/field/boolean_field_cubit.dart'; +export 'src/field/boolean_field_controller.dart'; export 'src/field/builder/field_builder.dart'; -export 'src/field/cubit/field_cubit.dart'; -export 'src/field/multi_select_field_cubit.dart'; -export 'src/field/single_select_field_cubit.dart'; -export 'src/field/text_field_cubit.dart'; -export 'src/form_group_cubit/form_group_cubit.dart'; +export 'src/field/field_controller.dart'; +export 'src/field/multi_select_field_controller.dart'; +export 'src/field/single_select_field_controller.dart'; +export 'src/field/text_field_controller.dart'; +export 'src/form_group/form_group_controller.dart'; export 'src/validators/validators.dart'; diff --git a/lib/src/field/boolean_field_controller.dart b/lib/src/field/boolean_field_controller.dart new file mode 100644 index 0000000..7c68412 --- /dev/null +++ b/lib/src/field/boolean_field_controller.dart @@ -0,0 +1,14 @@ +import 'package:leancode_forms/src/field/field_controller.dart'; + +/// A specialization of [FieldController] for a [bool] value. +class BooleanFieldController + extends FieldController { + /// Creates a new [BooleanFieldController]. + BooleanFieldController({ + super.initialValue = false, + super.validator, + super.asyncValidator, + super.asyncValidationDebounce, + super.name, + }); +} diff --git a/lib/src/field/boolean_field_cubit.dart b/lib/src/field/boolean_field_cubit.dart deleted file mode 100644 index 82e81c9..0000000 --- a/lib/src/field/boolean_field_cubit.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:leancode_forms/src/field/cubit/field_cubit.dart'; - -/// A specialization of [FieldCubit] for a [bool] value. -class BooleanFieldCubit extends FieldCubit { - /// Creates a new [BooleanFieldCubit]. - BooleanFieldCubit({ - super.initialValue = false, - super.validator, - super.asyncValidator, - super.asyncValidationDebounce, - }); -} diff --git a/lib/src/field/builder/field_builder.dart b/lib/src/field/builder/field_builder.dart index 94b719b..55c46d6 100644 --- a/lib/src/field/builder/field_builder.dart +++ b/lib/src/field/builder/field_builder.dart @@ -1,8 +1,12 @@ import 'package:flutter/widgets.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:leancode_forms/src/field/cubit/field_cubit.dart'; +import 'package:leancode_forms/src/field/field_controller.dart'; -/// Listens to the given [field] and rerenders the child using [builder]. +/// A thin wrapper around [ValueListenableBuilder] that rebuilds whenever the +/// given [field] notifies. Saves typing the `>` type argument +/// at call sites. +/// +/// For finer control (e.g. the `child:` optimization on +/// [ValueListenableBuilder]), use [ValueListenableBuilder] directly. class FieldBuilder extends StatelessWidget { /// Creates a new [FieldBuilder]. const FieldBuilder({ @@ -11,20 +15,17 @@ class FieldBuilder extends StatelessWidget { required this.builder, }); - /// The [FieldCubit] to listen to. - final FieldCubit field; + /// The field to listen to. + final FieldController field; - /// The builder to use to build the child. - final BlocWidgetBuilder> builder; + /// Called with the latest [FieldState] every time [field] notifies. + final Widget Function(BuildContext context, FieldState state) builder; @override Widget build(BuildContext context) { - return BlocBuilder, FieldState>( - bloc: field, - builder: builder, + return ValueListenableBuilder>( + valueListenable: field, + builder: (context, state, _) => builder(context, state), ); } } - -/// Translates an error to a string. -typedef ErrorTranslator = String Function(E); diff --git a/lib/src/field/cubit/field_cubit.dart b/lib/src/field/cubit/field_cubit.dart deleted file mode 100644 index 84b230a..0000000 --- a/lib/src/field/cubit/field_cubit.dart +++ /dev/null @@ -1,349 +0,0 @@ -import 'dart:async'; - -import 'package:equatable/equatable.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:leancode_forms/src/utils/cancelable_future.dart'; -import 'package:rxdart/rxdart.dart'; - -/// A validate function receiving the current value and returning an error code. -/// If null is returned, the value is considered valid. -typedef Validator = E? Function(T); - -/// An async validate function receiving the current value and returning an error code. -typedef AsyncValidator = Future Function(T); - -/// A single form field which can be validated. -/// Stores the current value, error text, and whether autovalidate is on. -/// [T] is the held value, [E] is the type of an error. [E] cannot be nullable -/// to be able to unambiguously detect lack of errors. -/// -/// If autovalidate is true, the validator will be run after each field change. -// ignore_for_file: avoid_positional_boolean_parameters -class FieldCubit extends Cubit> { - /// Creates a new [FieldCubit] with an initial value and a validator. - FieldCubit({ - required T initialValue, - Validator? validator, - AsyncValidator? asyncValidator, - Duration asyncValidationDebounce = const Duration(milliseconds: 300), - }) : _initialValue = initialValue, - _validator = validator ?? ((_) => null), - _asyncValidator = asyncValidator, - _asyncValidationDebounce = asyncValidationDebounce, - super( - FieldState(value: initialValue), - ); - - final T _initialValue; - - final Validator _validator; - - final AsyncValidator? _asyncValidator; - - final Duration _asyncValidationDebounce; - - Timer? _debounceTimer; - - CancelableFuture? _asyncValidationFuture; - - StreamSubscription? _fieldsSubscription; - - /// Subscribes to the [fields] and revalidate the field when any of the fields change. - void subscribeToFields(List> fields) { - _fieldsSubscription?.cancel(); - - _fieldsSubscription = Rx.combineLatest( - fields.map((field) => field.stream.map((s) => s.value).distinct()), - (_) => {}, - ).listen((_) { - if (state.autovalidate) { - // Setting the same value will trigger a validation. - setValue(state.value); - } - }); - } - - /// Set a new [value]. When [force] is true, [state] is always updated to a new [value], - /// otherwise if [state] is readonly, [setValue] is a noop - void setValue(T value, {bool force = false}) { - if (state.readOnly && !force) { - return; - } - - E? validationError; - - validationError = - state.autovalidate ? _validator(value) : state.validationError; - - if (validationError == null && _asyncValidator != null) { - _runAsyncValidator(value); - return; - } - - emit( - FieldState( - value: value, - validationError: validationError, - asyncError: state.asyncError, - autovalidate: state.autovalidate, - readOnly: state.readOnly, - status: - validationError == null ? FieldStatus.valid : FieldStatus.invalid, - ), - ); - } - - Future _runAsyncValidator(T value) async { - // Cancel the previous debounce timer if it exists. - _debounceTimer?.cancel(); - _asyncValidationFuture?.cancel(); - - // Create a new Completer to handle the async validation result. - final completer = Completer(); - - /// Update the field state with the pending status. - emit( - FieldState( - value: value, - validationError: state.validationError, - asyncError: state.asyncError, - autovalidate: state.autovalidate, - readOnly: state.readOnly, - status: FieldStatus.pending, - ), - ); - - // Start a new debounce timer. - _debounceTimer = Timer(_asyncValidationDebounce, () async { - /// Update the field state with the validating status. - emit( - FieldState( - value: value, - validationError: state.validationError, - asyncError: state.asyncError, - autovalidate: state.autovalidate, - readOnly: state.readOnly, - status: FieldStatus.validating, - ), - ); - - // Run the async validator and complete the Completer with the result. - _asyncValidationFuture = CancelableFuture( - future: _asyncValidator!(value), - onComplete: completer.complete, - ); - }); - - // Wait for the async validation to complete. - final error = await completer.future; - - // Update the field state with the async validation result. - emit( - FieldState( - value: value, - asyncError: error, - autovalidate: state.autovalidate, - readOnly: state.readOnly, - status: error == null ? FieldStatus.valid : FieldStatus.invalid, - ), - ); - } - - /// Returns `null` if field is readonly. Otherwise returns [setValue]. - /// - /// Useful in contexts where setting `null` as the `onChange` callback causes - /// the field to be disabled. - ValueSetter? getValueSetter() { - if (state.readOnly) { - return null; - } - - return setValue; - } - - /// Emits a [FieldState] with a new [error]. - void setError(E? error) { - emit( - FieldState( - value: state.value, - validationError: error, - autovalidate: state.autovalidate, - readOnly: state.readOnly, - status: FieldStatus.invalid, - ), - ); - } - - /// Returns true if there are no errors. - /// If validator return different error than the current one, the state is updated. - bool validate() { - if (state.asyncError != null || state.isInProgress) { - return false; - } - - final error = _validator(state.value); - - if (error != state.validationError) { - emit( - FieldState( - value: state.value, - validationError: error, - asyncError: state.asyncError, - autovalidate: state.autovalidate, - readOnly: state.readOnly, - status: error == null ? FieldStatus.valid : FieldStatus.invalid, - ), - ); - } - - return state.validationError == null; - } - - /// When autovalidate is true, setting a new value will trigger a validation - void setAutovalidate(bool autovalidate) { - emit( - FieldState( - value: state.value, - validationError: state.validationError, - asyncError: state.asyncError, - autovalidate: autovalidate, - readOnly: state.readOnly, - status: state.status, - ), - ); - } - - /// Prevents further changes of value [T]. - void markReadOnly() { - emit( - FieldState( - value: state.value, - validationError: state.validationError, - asyncError: state.asyncError, - autovalidate: state.autovalidate, - readOnly: true, - status: state.status, - ), - ); - } - - /// Allows further changes of value [T]. - void unmarkReadOnly() { - emit( - FieldState( - value: state.value, - validationError: state.validationError, - asyncError: state.asyncError, - autovalidate: state.autovalidate, - status: state.status, - ), - ); - } - - /// Clears all errors on this field. - void clearErrors() { - emit( - FieldState( - value: state.value, - autovalidate: state.autovalidate, - readOnly: state.readOnly, - ), - ); - } - - /// Resets the field to its initial value. - void reset() { - emit(FieldState(value: _initialValue)); - } - - @override - Future close() { - _fieldsSubscription?.cancel(); - return super.close(); - } -} - -/// The status of a [FieldCubit]. -enum FieldStatus { - /// The field is valid. - valid, - - /// The field is invalid. - invalid, - - /// The field is pending validation. - pending, - - /// The field is being async validated. - validating, -} - -/// The state of a [FieldCubit]. -class FieldState with EquatableMixin { - /// Creates a new [FieldState]. - const FieldState({ - required this.value, - this.validationError, - this.asyncError, - this.autovalidate = false, - this.readOnly = false, - this.status = FieldStatus.valid, - }); - - /// Returns true if there are no errors. - bool get isValid => status == FieldStatus.valid; - - /// Returns true if field status is being validated. - bool get isValidating => status == FieldStatus.validating; - - /// Returns true if field status is pending. - bool get isPending => status == FieldStatus.pending; - - /// Returns true if field status is pending or validating. - bool get isInProgress => isPending || isValidating; - - /// Returns true if field status is invalid. - bool get isInvalid => status == FieldStatus.invalid; - - /// The current value. - /// Can be set manually by calling [FieldCubit.setValue]. - final T value; - - /// The current validationError. - /// If null, the value is considered valid. - /// Can be set manually by calling [FieldCubit.setError]. - /// Can be cleared by calling [FieldCubit.clearErrors]. - /// Can be set by the validator when [FieldCubit.validate] is called. - final E? validationError; - - /// The current async error. - final E? asyncError; - - /// The current error. - E? get error => validationError ?? asyncError; - - /// Whether autovalidate is on. - /// If true, the validator will be run after each field change. - /// If false, the validator will only be run when [FieldCubit.validate] is called. - /// Can be changed by calling [FieldCubit.setAutovalidate]. - final bool autovalidate; - - /// Whether the field is readonly. - /// If true, the value cannot be changed. - /// Can be changed by calling [FieldCubit.markReadOnly] and [FieldCubit.unmarkReadOnly]. - final bool readOnly; - - /// The current status of the field. - final FieldStatus status; - - @override - List get props => [ - value, - validationError, - asyncError, - autovalidate, - readOnly, - status, - ]; -} diff --git a/lib/src/field/field_controller.dart b/lib/src/field/field_controller.dart new file mode 100644 index 0000000..0054934 --- /dev/null +++ b/lib/src/field/field_controller.dart @@ -0,0 +1,373 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:leancode_forms/src/utils/cancelable_future.dart'; + +/// A validate function receiving the current value and returning an error code. +/// If null is returned, the value is considered valid. +typedef Validator = E? Function(T); + +/// An async validate function receiving the current value and returning an error code. +typedef AsyncValidator = Future Function(T); + +/// Translates an error to a string. +typedef ErrorTranslator = String Function(E); + +/// A single form field which can be validated. +/// Stores the current value, error text, and whether autovalidate is on. +/// [T] is the held value, [E] is the type of an error. [E] cannot be nullable +/// to be able to unambiguously detect lack of errors. +/// +/// If autovalidate is true, the validator will be run after each field change. +// ignore_for_file: avoid_positional_boolean_parameters +class FieldController + extends ValueNotifier> { + /// Creates a new [FieldController] with an initial value and a validator. + FieldController({ + required T initialValue, + Validator? validator, + AsyncValidator? asyncValidator, + Duration asyncValidationDebounce = const Duration(milliseconds: 300), + this.name, + }) : _initialValue = initialValue, + _validator = validator ?? ((_) => null), + _asyncValidator = asyncValidator, + _asyncValidationDebounce = asyncValidationDebounce, + super(FieldState(value: initialValue)); + + /// Optional name for the field. Useful for debugging, logging, and + /// serialization. Not used for identity — fields are still identified by + /// reference. + final String? name; + + final T _initialValue; + + final Validator _validator; + + final AsyncValidator? _asyncValidator; + + final Duration _asyncValidationDebounce; + + Timer? _debounceTimer; + + CancelableFuture? _asyncValidationFuture; + + VoidCallback? _fieldsSubscriptionCleanup; + + /// The current state. Alias for [value] kept for readability at call sites + /// that previously read `cubit.state`. + FieldState get state => value; + + /// Subscribes to the [fields] and revalidates this field whenever any of + /// their values change. Only fires on value changes — status changes on the + /// observed fields are ignored. + void subscribeToFields(List> fields) { + _fieldsSubscriptionCleanup?.call(); + + final lastValues = [for (final f in fields) f.value.value]; + + void listener() { + var changed = false; + for (var i = 0; i < fields.length; i++) { + final v = fields[i].value.value; + if (v != lastValues[i]) { + lastValues[i] = v; + changed = true; + } + } + if (changed && value.autovalidate) { + setValue(value.value); + } + } + + for (final f in fields) { + f.addListener(listener); + } + _fieldsSubscriptionCleanup = () { + for (final f in fields) { + f.removeListener(listener); + } + }; + } + + /// Set a new [newValue]. When [force] is true, [value] is always updated to + /// a new [newValue]; otherwise if the state is readonly, [setValue] is a + /// noop. + void setValue(T newValue, {bool force = false}) { + if (value.readOnly && !force) { + return; + } + + final validationError = + value.autovalidate ? _validator(newValue) : value.validationError; + + if (validationError == null && _asyncValidator != null) { + _runAsyncValidator(newValue); + return; + } + + value = FieldState( + value: newValue, + validationError: validationError, + asyncError: value.asyncError, + autovalidate: value.autovalidate, + readOnly: value.readOnly, + status: validationError == null + ? FieldStatus.valid + : FieldStatus.invalid, + ); + } + + Future _runAsyncValidator(T newValue) async { + _debounceTimer?.cancel(); + _asyncValidationFuture?.cancel(); + + final completer = Completer(); + + value = FieldState( + value: newValue, + validationError: value.validationError, + asyncError: value.asyncError, + autovalidate: value.autovalidate, + readOnly: value.readOnly, + status: FieldStatus.pending, + ); + + _debounceTimer = Timer(_asyncValidationDebounce, () async { + value = FieldState( + value: newValue, + validationError: value.validationError, + asyncError: value.asyncError, + autovalidate: value.autovalidate, + readOnly: value.readOnly, + status: FieldStatus.validating, + ); + + _asyncValidationFuture = CancelableFuture( + future: _asyncValidator!(newValue), + onComplete: completer.complete, + ); + }); + + final error = await completer.future; + + value = FieldState( + value: newValue, + asyncError: error, + autovalidate: value.autovalidate, + readOnly: value.readOnly, + status: error == null ? FieldStatus.valid : FieldStatus.invalid, + ); + } + + /// Returns `null` if field is readonly. Otherwise returns [setValue]. + /// + /// Useful in contexts where setting `null` as the `onChange` callback causes + /// the field to be disabled. + ValueSetter? getValueSetter() { + if (value.readOnly) { + return null; + } + return setValue; + } + + /// Sets a new [error] and marks the field as invalid. + void setError(E? error) { + value = FieldState( + value: value.value, + validationError: error, + autovalidate: value.autovalidate, + readOnly: value.readOnly, + status: FieldStatus.invalid, + ); + } + + /// Runs the sync validator. Returns true if there are no errors. + /// If the validator returns a different error than the current one, the + /// state is updated. + bool validate() { + if (value.asyncError != null || value.isInProgress) { + return false; + } + + final error = _validator(value.value); + + if (error != value.validationError) { + value = FieldState( + value: value.value, + validationError: error, + asyncError: value.asyncError, + autovalidate: value.autovalidate, + readOnly: value.readOnly, + status: error == null ? FieldStatus.valid : FieldStatus.invalid, + ); + } + + return value.validationError == null; + } + + /// When autovalidate is true, setting a new value will trigger a validation. + void setAutovalidate(bool autovalidate) { + value = FieldState( + value: value.value, + validationError: value.validationError, + asyncError: value.asyncError, + autovalidate: autovalidate, + readOnly: value.readOnly, + status: value.status, + ); + } + + /// Prevents further changes of value [T]. + void markReadOnly() { + value = FieldState( + value: value.value, + validationError: value.validationError, + asyncError: value.asyncError, + autovalidate: value.autovalidate, + readOnly: true, + status: value.status, + ); + } + + /// Allows further changes of value [T]. + void unmarkReadOnly() { + value = FieldState( + value: value.value, + validationError: value.validationError, + asyncError: value.asyncError, + autovalidate: value.autovalidate, + status: value.status, + ); + } + + /// Clears all errors on this field. + void clearErrors() { + value = FieldState( + value: value.value, + autovalidate: value.autovalidate, + readOnly: value.readOnly, + ); + } + + /// Resets the field to its initial value. + void reset() { + value = FieldState(value: _initialValue); + } + + @override + void dispose() { + _debounceTimer?.cancel(); + _asyncValidationFuture?.cancel(); + _fieldsSubscriptionCleanup?.call(); + _fieldsSubscriptionCleanup = null; + super.dispose(); + } +} + +/// The status of a [FieldController]. +enum FieldStatus { + /// The field is valid. + valid, + + /// The field is invalid. + invalid, + + /// The field is pending validation. + pending, + + /// The field is being async validated. + validating, +} + +/// The state of a [FieldController]. +/// +/// Value-equal: two [FieldState]s are equal when every field matches. This +/// is required for [ValueNotifier]'s built-in dedup — without it, every +/// [FieldController.setValue] call would notify listeners even when the new +/// state is identical to the old one. +/// +/// **Maintainer note:** when adding a new field below, you MUST also add it +/// to both [operator ==] and [hashCode] at the bottom of the class, otherwise +/// the new field is silently invisible to dedup and structural comparisons. +class FieldState { + /// Creates a new [FieldState]. + const FieldState({ + required this.value, + this.validationError, + this.asyncError, + this.autovalidate = false, + this.readOnly = false, + this.status = FieldStatus.valid, + }); + + /// Returns true if there are no errors. + bool get isValid => status == FieldStatus.valid; + + /// Returns true if field status is being validated. + bool get isValidating => status == FieldStatus.validating; + + /// Returns true if field status is pending. + bool get isPending => status == FieldStatus.pending; + + /// Returns true if field status is pending or validating. + bool get isInProgress => isPending || isValidating; + + /// Returns true if field status is invalid. + bool get isInvalid => status == FieldStatus.invalid; + + /// The current value. + /// Can be set manually by calling [FieldController.setValue]. + final T value; + + /// The current validationError. + /// If null, the value is considered valid. + /// Can be set manually by calling [FieldController.setError]. + /// Can be cleared by calling [FieldController.clearErrors]. + /// Can be set by the validator when [FieldController.validate] is called. + final E? validationError; + + /// The current async error. + final E? asyncError; + + /// The current error. + E? get error => validationError ?? asyncError; + + /// Whether autovalidate is on. + /// If true, the validator will be run after each field change. + /// If false, the validator will only be run when + /// [FieldController.validate] is called. + /// Can be changed by calling [FieldController.setAutovalidate]. + final bool autovalidate; + + /// Whether the field is readonly. + /// If true, the value cannot be changed. + /// Can be changed by calling [FieldController.markReadOnly] and + /// [FieldController.unmarkReadOnly]. + final bool readOnly; + + /// The current status of the field. + final FieldStatus status; + + // ⚠️ Maintainer: keep these in sync with the fields declared above. + @override + bool operator ==(Object other) => + identical(this, other) || + other is FieldState && + value == other.value && + validationError == other.validationError && + asyncError == other.asyncError && + autovalidate == other.autovalidate && + readOnly == other.readOnly && + status == other.status; + + @override + int get hashCode => Object.hash( + value, + validationError, + asyncError, + autovalidate, + readOnly, + status, + ); +} diff --git a/lib/src/field/multi_select_field_cubit.dart b/lib/src/field/multi_select_field_controller.dart similarity index 50% rename from lib/src/field/multi_select_field_cubit.dart rename to lib/src/field/multi_select_field_controller.dart index e9a84f3..b9412c9 100644 --- a/lib/src/field/multi_select_field_cubit.dart +++ b/lib/src/field/multi_select_field_controller.dart @@ -1,12 +1,14 @@ -import 'package:leancode_forms/src/field/cubit/field_cubit.dart'; +import 'package:leancode_forms/src/field/field_controller.dart'; -/// A specialization of [FieldCubit] for a multiple choice of [V] values. -class MultiSelectFieldCubit extends FieldCubit, E> { - /// Creates a new [MultiSelectFieldCubit]. - MultiSelectFieldCubit({ +/// A specialization of [FieldController] for a multiple choice of [V] values. +class MultiSelectFieldController + extends FieldController, E> { + /// Creates a new [MultiSelectFieldController]. + MultiSelectFieldController({ required super.initialValue, super.validator, required this.options, + super.name, }); /// List of options to select from. @@ -14,7 +16,7 @@ class MultiSelectFieldCubit extends FieldCubit, E> { /// Toggles the given [value]. void toggleElement(V value) { - if (state.value.contains(value)) { + if (this.value.value.contains(value)) { removeValue(value); } else { addValue(value); @@ -23,12 +25,12 @@ class MultiSelectFieldCubit extends FieldCubit, E> { /// Adds the given [value]. void addValue(V value) { - setValue(Set.from(state.value)..add(value)); + setValue(Set.from(this.value.value)..add(value)); } /// Removes the given [value]. void removeValue(V value) { - setValue(Set.from(state.value)..remove(value)); + setValue(Set.from(this.value.value)..remove(value)); } /// Resets selected values to the initial value. diff --git a/lib/src/field/single_select_field_controller.dart b/lib/src/field/single_select_field_controller.dart new file mode 100644 index 0000000..d32de38 --- /dev/null +++ b/lib/src/field/single_select_field_controller.dart @@ -0,0 +1,23 @@ +import 'package:leancode_forms/src/field/field_controller.dart'; + +/// A specialization of [FieldController] for a single choice of [V] from a +/// list of [options]. +class SingleSelectFieldController + extends FieldController { + /// Creates a new [SingleSelectFieldController]. + SingleSelectFieldController({ + required super.initialValue, + super.validator, + required this.options, + super.name, + }); + + /// List of options to select from. + final List options; + + /// Sets the value of the field to the [option]. + void select(V? option) => setValue(option); + + /// Resets selected value to the initial one. + void clear() => reset(); +} diff --git a/lib/src/field/single_select_field_cubit.dart b/lib/src/field/single_select_field_cubit.dart deleted file mode 100644 index c93d9f2..0000000 --- a/lib/src/field/single_select_field_cubit.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:leancode_forms/src/field/cubit/field_cubit.dart'; - -/// A specialization of [FieldCubit] for a single choice of [V] value from List of [options]. -class SingleSelectFieldCubit extends FieldCubit { - /// Creates a new [SingleSelectFieldCubit]. - SingleSelectFieldCubit({ - required super.initialValue, - super.validator, - required this.options, - }); - - /// List of options to select from. - final List options; - - /// Sets the value of the field to the [option]. - void select(V? option) => setValue(option); - - /// Resets selected value to the initial one. - void clear() => reset(); -} diff --git a/lib/src/field/text_field_controller.dart b/lib/src/field/text_field_controller.dart new file mode 100644 index 0000000..4cbc704 --- /dev/null +++ b/lib/src/field/text_field_controller.dart @@ -0,0 +1,50 @@ +import 'package:flutter/widgets.dart'; +import 'package:leancode_forms/src/field/field_controller.dart'; + +/// A specialization of [FieldController] for a [String] value. +/// +/// Owns a [TextEditingController] kept in two-way sync with the field value. +/// Widgets can bind directly to [textController]; programmatic changes via +/// [setValue] / [reset] / [clearErrors] propagate to the text controller, and +/// user input on the text controller propagates back to the field state. +class TextFieldController extends FieldController { + /// Creates a new [TextFieldController]. + TextFieldController({ + super.initialValue = '', + super.validator, + super.asyncValidator, + super.asyncValidationDebounce, + super.name, + }) : textController = TextEditingController(text: initialValue) { + textController.addListener(_onTextControllerChanged); + addListener(_onFieldChanged); + } + + /// The [TextEditingController] bound to this field. Lifecycle owned by this + /// controller — do not dispose externally. + final TextEditingController textController; + + void _onTextControllerChanged() { + if (textController.text != value.value) { + setValue(textController.text); + } + } + + void _onFieldChanged() { + if (textController.text != value.value) { + textController.text = value.value; + } + } + + /// Clears the value of the field, resetting it to its initial value. + void clear() => reset(); + + @override + void dispose() { + removeListener(_onFieldChanged); + textController + ..removeListener(_onTextControllerChanged) + ..dispose(); + super.dispose(); + } +} diff --git a/lib/src/field/text_field_cubit.dart b/lib/src/field/text_field_cubit.dart deleted file mode 100644 index 816cb70..0000000 --- a/lib/src/field/text_field_cubit.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:leancode_forms/src/field/cubit/field_cubit.dart'; - -/// A specialization of [FieldCubit] for a [String] value. -class TextFieldCubit extends FieldCubit { - /// Creates a new [TextFieldCubit]. - TextFieldCubit({ - super.initialValue = '', - super.validator, - super.asyncValidator, - super.asyncValidationDebounce, - }); - - /// Clears the value of the field. - void clear() => reset(); -} diff --git a/lib/src/form_group/form_group_controller.dart b/lib/src/form_group/form_group_controller.dart new file mode 100644 index 0000000..04bba7f --- /dev/null +++ b/lib/src/form_group/form_group_controller.dart @@ -0,0 +1,389 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:leancode_forms/src/field/field_controller.dart'; + +/// A parent of multiple [FieldController]s. Manages group validation and tracks +/// changes as well as cleans up needed resources. +/// +/// A form is a tree which can be recursively defined: +/// 1. A form is the root of its own form tree +/// 2. A form has direct leaves, which are fields +/// 3. A form can have subtrees, which are forms (called subforms) +/// +/// Most methods broadcast to all subforms. +/// +/// Introducing cycles in forms is not supported and not checked against (most +/// likely will cause a stack overflow somewhere). +// ignore_for_file: avoid_positional_boolean_parameters +class FormGroupController extends ValueNotifier { + /// Creates a new [FormGroupController]. + FormGroupController({ + this.debugName = '', + this.validateAll = false, + }) : super(const FormGroupState()); + + /// A debug label for this form. Not significant to the form. + final String debugName; + + /// When true, whenever any field changes, all other fields get + /// their validator called if they have autovalidate enabled. + final bool validateAll; + + /// The current state. Alias for [value] kept for readability at call sites + /// that previously read `cubit.state`. + FormGroupState get state => value; + + /// Fires when any leaf field's value changes (recursively through subforms), + /// or when fields are registered. + Listenable get onValuesChanged => _onValuesChanged; + final ChangeNotifier _onValuesChanged = ChangeNotifier(); + + /// Fires when any leaf field's status changes (recursively through subforms). + Listenable get onStatusChanged => _onStatusChanged; + final ChangeNotifier _onStatusChanged = ChangeNotifier(); + + List _initialFieldsState = const []; + + final Set> _ownedFields = {}; + final List _childCleanups = []; + + /// Takes ownership of registered fields. Will dispose all controllers when + /// the form group is disposed. + /// Fields are expected to be filled with initial states. + void registerFields(List> fields) { + _runChildCleanups(); + + value = FormGroupState( + wasModified: value.wasModified, + fields: fields, + subforms: value.subforms, + validationEnabled: value.validationEnabled, + validating: value.validating, + ); + + _ownedFields.addAll(fields); + _initialFieldsState = getFieldValues(); + _wireChildren(); + _onValuesChanged.notifyListeners(); + } + + /// Returns a list of all field values. + @visibleForTesting + List getFieldValues() { + return value.fields.map((f) => f.value.value).toList(); + } + + /// Recursively calls validate on all subforms/fields if + /// `state.validationEnabled` is true. + /// [enableAutovalidate] can enable autovalidate on this form. + /// + /// Returns the result of validate calls, or always `true` if + /// `state.validationEnabled` is false. + bool validate({bool enableAutovalidate = true}) { + if (enableAutovalidate) { + setAutovalidate(true); + } + if (!value.validationEnabled) { + return true; + } + + // Eager list to prevent short circuits; all fields/subforms must be called. + return [ + for (final field in value.fields) field.validate(), + for (final subform in value.subforms) + subform.validate(enableAutovalidate: enableAutovalidate), + ].every((e) => e); + } + + /// Marks all leaf fields as readonly. + void markReadOnly() { + for (final field in value.fields) { + field.markReadOnly(); + } + for (final subform in value.subforms) { + subform.markReadOnly(); + } + } + + /// Unmarks all leaf fields as readonly. + void unmarkReadOnly() { + for (final field in value.fields) { + field.unmarkReadOnly(); + } + for (final subform in value.subforms) { + subform.unmarkReadOnly(); + } + } + + /// Sets autovalidate on all leaf fields. + void setAutovalidate(bool autovalidate) { + for (final field in value.fields) { + field.setAutovalidate(autovalidate); + } + for (final subform in value.subforms) { + subform.setAutovalidate(autovalidate); + } + } + + /// Resets all leaf fields to their initial states. + void resetAll() { + for (final field in value.fields) { + field.reset(); + } + for (final subform in value.subforms) { + subform.resetAll(); + } + } + + /// Clears all errors on all leaf fields. + void clearErrors() { + for (final field in value.fields) { + field.clearErrors(); + } + for (final subform in value.subforms) { + subform.clearErrors(); + } + } + + /// Adds a subform to the current form. + /// If [form] was already added as a subform this is a noop. + void addSubform(FormGroupController form) { + if (value.subforms.contains(form)) { + return; + } + _runChildCleanups(); + + value = FormGroupState( + wasModified: value.wasModified, + fields: value.fields, + subforms: {...value.subforms, form}, + validationEnabled: value.validationEnabled, + validating: value.validating, + ); + + _wireChildren(); + } + + /// Removes and disposes an owned subform. + /// If [form] was not a subform this is a noop. + Future removeSubform( + FormGroupController form, { + bool close = true, + }) async { + if (!value.subforms.contains(form)) { + return; + } + _runChildCleanups(); + + value = FormGroupState( + wasModified: value.wasModified, + fields: value.fields, + subforms: {...value.subforms}..remove(form), + validationEnabled: value.validationEnabled, + validating: value.validating, + ); + + if (close) { + form.dispose(); + } + + _wireChildren(); + } + + /// Calls validate on all fields with autovalidate on. + void validateWithAutovalidate() { + for (final field in value.fields) { + if (field.value.autovalidate) { + field.validate(); + } + } + for (final subform in value.subforms) { + subform.validateWithAutovalidate(); + } + } + + /// Changes optionality of this form. When `validationEnabled` is set to + /// false, all errors are cleared. + void setValidationEnabled(bool validationEnabled) { + if (validationEnabled == value.validationEnabled) { + return; + } + value = FormGroupState( + wasModified: value.wasModified, + fields: value.fields, + subforms: value.subforms, + validationEnabled: validationEnabled, + validating: value.validating, + ); + if (validationEnabled) { + validateWithAutovalidate(); + } else { + clearErrors(); + } + } + + void _wireChildren() { + for (final field in value.fields) { + var lastValue = field.value.value; + var lastStatus = field.value.status; + void listener() { + final s = field.value; + if (s.value != lastValue) { + lastValue = s.value; + _handleValuesChanged(); + } + if (s.status != lastStatus) { + lastStatus = s.status; + _handleStatusChanged(); + } + } + + field.addListener(listener); + _childCleanups.add(() => field.removeListener(listener)); + } + + for (final subform in value.subforms) { + subform.onValuesChanged.addListener(_handleValuesChanged); + subform.onStatusChanged.addListener(_handleStatusChanged); + _childCleanups.add(() { + subform.onValuesChanged.removeListener(_handleValuesChanged); + subform.onStatusChanged.removeListener(_handleStatusChanged); + }); + } + } + + void _runChildCleanups() { + for (final cleanup in _childCleanups) { + cleanup(); + } + _childCleanups.clear(); + } + + void _handleValuesChanged() { + final subformsWereModified = value.subforms.any( + (subform) => subform.value.wasModified, + ); + final fieldsWereModified = !const DeepCollectionEquality() + .equals(_initialFieldsState, getFieldValues()); + + if (validateAll) { + validateWithAutovalidate(); + } + + value = FormGroupState( + wasModified: subformsWereModified || fieldsWereModified, + fields: value.fields, + subforms: value.subforms, + validationEnabled: value.validationEnabled, + validating: value.validating, + ); + _onValuesChanged.notifyListeners(); + } + + void _handleStatusChanged() { + final subformsValidating = value.subforms.any( + (subform) => subform.value.validating, + ); + final fieldsValidating = value.fields.any( + (field) => field.value.isInProgress, + ); + + value = FormGroupState( + wasModified: value.wasModified, + fields: value.fields, + subforms: value.subforms, + validationEnabled: value.validationEnabled, + validating: fieldsValidating || subformsValidating, + ); + _onStatusChanged.notifyListeners(); + } + + @override + void dispose() { + _runChildCleanups(); + for (final field in _ownedFields) { + field.dispose(); + } + _ownedFields.clear(); + for (final subform in value.subforms) { + subform.dispose(); + } + _onValuesChanged.dispose(); + _onStatusChanged.dispose(); + super.dispose(); + } +} + +/// The state of a [FormGroupController]. +/// +/// Value-equal: two [FormGroupState]s are equal when every field matches. +/// This is required for [ValueNotifier]'s built-in dedup — without it, +/// every internal state recompute (`_handleValuesChanged`, +/// `_handleStatusChanged`, etc.) would notify listeners even when the +/// recomputed state is identical to the previous one. +/// +/// **Maintainer note:** when adding a new field below, you MUST also add it +/// to both [operator ==] and [hashCode] at the bottom of the class, otherwise +/// the new field is silently invisible to dedup and structural comparisons. +class FormGroupState { + /// Creates a new [FormGroupState]. + const FormGroupState({ + this.wasModified = false, + this.fields = const [], + this.subforms = const {}, + this.validationEnabled = true, + this.validating = false, + }); + + /// wasModified is true when any of the field values differ since the + /// last `registerFields` or when any of the subforms has wasModified=true. + final bool wasModified; + + /// List of all registered fields by this form. + final List> fields; + + /// Set of registered subforms. Reference equality is assumed. + final Set subforms; + + /// If false, validators are not ran and `validate` always returns true. + final bool validationEnabled; + + /// Returns true if fields are currently being validated. + final bool validating; + + /// List of this form's fields including subforms' fields. + Iterable> get allFields => + fields.followedBy(subforms.expand((e) => e.value.allFields)); + + /// Map of all validation errors (including subforms') grouped by fields. + Map, dynamic> get validationErrors => { + for (final field in allFields) + if (field.value.validationError case final error?) field: error, + }; + + // ⚠️ Maintainer: keep these in sync with the fields declared above. `fields` + // and `subforms` use ListEquality/SetEquality (with identity-based element + // comparison, since FieldController/FormGroupController don't override `==`). + @override + bool operator ==(Object other) => + identical(this, other) || + other is FormGroupState && + wasModified == other.wasModified && + const ListEquality>() + .equals(fields, other.fields) && + const SetEquality() + .equals(subforms, other.subforms) && + validationEnabled == other.validationEnabled && + validating == other.validating; + + @override + int get hashCode => Object.hash( + wasModified, + const ListEquality>().hash(fields), + const SetEquality().hash(subforms), + validationEnabled, + validating, + ); +} diff --git a/lib/src/form_group_cubit/form_group_cubit.dart b/lib/src/form_group_cubit/form_group_cubit.dart deleted file mode 100644 index bbf0656..0000000 --- a/lib/src/form_group_cubit/form_group_cubit.dart +++ /dev/null @@ -1,365 +0,0 @@ -import 'dart:async'; - -import 'package:collection/collection.dart'; -import 'package:equatable/equatable.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:leancode_forms/src/field/cubit/field_cubit.dart'; -import 'package:leancode_forms/src/utils/disposable.dart'; -import 'package:leancode_forms/src/utils/extensions/stream_extensions.dart'; -import 'package:rxdart/rxdart.dart'; - -/// A parent of multiple [FieldCubit]s. Manages group validation and tracks changes -/// as well as cleans up needed resources. -/// -/// A form is a tree which can be recursively defined: -/// 1. A form is the root of its own form tree -/// 2. A form has direct leaves, which are fields -/// 3. A form can have subtrees, which are forms (called subforms) -/// -/// Most methods broadcast to all subforms. -/// -/// Introducing cycles in forms is not supported and not checked against (most likely will cause a stack overflow somewhere). -class FormGroupCubit extends Cubit with Disposable { - /// Creates a new [FormGroupCubit]. - FormGroupCubit({ - this.debugName = '', - this.validateAll = false, - }) : super(const FormGroupState()) { - addDisposable(_fieldsController.close); - addDisposable(_fieldsStatusController.close); - addDisposable(() => _onFieldsChangeSubscription?.cancel()); - addDisposable(() => _onFieldsStatusChangeSubscription?.cancel()); - addDisposable( - stream - .map( - (event) => ( - fields: event.fields, - subforms: event.subforms, - ), - ) - .distinct() - .listen(_onFieldsChanged) - .cancel, - ); - addDisposable( - onValuesChangedStream.listen((_) => _onFieldsStateChanged()).cancel, - ); - addDisposable( - onStatusChangedStream.listen((_) => _onFieldsStatusChanged()).cancel, - ); - addDisposable(() => Future.wait(state.subforms.map((e) => e.close()))); - } - - /// A debug label for this form. Not significant to the form. - final String debugName; - - /// When true, whenever any field changes, all other fields get - /// their validator called if they have autovalidate enabled. - final bool validateAll; - - List _initialFieldsState = []; - - StreamSubscription? _onFieldsChangeSubscription; - final _fieldsController = StreamController.broadcast(); - - /// Emits when any of the leaf fields have their value changed. - Stream get onValuesChangedStream => _fieldsController.stream; - - StreamSubscription? _onFieldsStatusChangeSubscription; - final _fieldsStatusController = StreamController.broadcast(); - - /// Emits when any of the leaf fields have their status changed. - Stream get onStatusChangedStream => - _fieldsStatusController.stream; - - Future _onFieldsChanged( - ({ - List> fields, - Set subforms, - }) data, - ) async { - await _onFieldsChangeSubscription?.cancel(); - await _onFieldsStatusChangeSubscription?.cancel(); - final (:fields, :subforms) = data; - - _onFieldsChangeSubscription = Rx.merge( - fields.map( - (field) { - return field.stream - .map((state) => state.value) - .distinctWithFirst(field.state.value); - }, - ).followedBy( - subforms.map((e) => e.onValuesChangedStream), - ), - ).listen(_fieldsController.add); - - _onFieldsStatusChangeSubscription = Rx.merge( - fields.map( - (field) { - return field.stream - .map((state) => state.status) - .distinctWithFirst(field.state.status); - }, - ).followedBy( - subforms.map((e) => e.onStatusChangedStream), - ), - ).listen(_fieldsStatusController.add); - } - - /// Takes ownership of registered fields. Will dispose all cubits. - /// Fields are expected to be filled with initial states. - void registerFields(List> fields) { - emit( - FormGroupState( - wasModified: state.wasModified, - fields: fields, - subforms: state.subforms, - validationEnabled: state.validationEnabled, - ), - ); - - addDisposable(() => Future.wait(fields.map((e) => e.close()))); - - _initialFieldsState = getFieldValues(); - // inform that the fields have changed - _fieldsController.add(null); - } - - /// Returns a list of all field values. - @visibleForTesting - List getFieldValues() { - return state.fields.map((f) => f.state.value).toList(); - } - - /// Recursively calls validate on all subforms/fields if `state.validationEnabled` is true. - /// [enableAutovalidate] can enable autovalidate on this form. - /// - /// Returns the result of validate calls, or always `true` if `state.validationEnabled` is false. - bool validate({bool enableAutovalidate = true}) { - if (enableAutovalidate) { - setAutovalidate(true); - } - if (!state.validationEnabled) { - return true; - } - - // Eager list to prevent short circuits, all fields should be called with validate - return [ - for (final field in state.fields) field.validate(), - for (final subform in state.subforms) - subform.validate(enableAutovalidate: enableAutovalidate), - ].every((e) => e); - } - - /// Marks all leaf fields as readonly. - void markReadOnly() { - for (final field in state.fields) { - field.markReadOnly(); - } - for (final subform in state.subforms) { - subform.markReadOnly(); - } - } - - /// Unmarks all leaf fields as readonly. - void unmarkReadOnly() { - for (final field in state.fields) { - field.unmarkReadOnly(); - } - for (final subform in state.subforms) { - subform.unmarkReadOnly(); - } - } - - /// Sets autovalidate on all leaf fields. - // ignore: avoid_positional_boolean_parameters - void setAutovalidate(bool autovalidate) { - for (final field in state.fields) { - field.setAutovalidate(autovalidate); - } - for (final subform in state.subforms) { - subform.setAutovalidate(autovalidate); - } - } - - /// Resets all leaf fields to their initial states. - void resetAll() { - for (final field in state.fields) { - field.reset(); - } - for (final subform in state.subforms) { - subform.resetAll(); - } - } - - /// Clears all errors on all leaf fields. - void clearErrors() { - for (final field in state.fields) { - field.clearErrors(); - } - for (final subform in state.subforms) { - subform.clearErrors(); - } - } - - /// Adds a subform to the current form. - /// If [form] was already added as a subform this is a noop. - void addSubform(FormGroupCubit form) { - emit( - FormGroupState( - wasModified: state.wasModified, - fields: state.fields, - subforms: {...state.subforms, form}, - validationEnabled: state.validationEnabled, - ), - ); - } - - /// Removes and disposes an owned subform. - /// If [form] was not a subform this is a noop. - Future removeSubform(FormGroupCubit form, {bool close = true}) async { - if (state.subforms.contains(form)) { - emit( - FormGroupState( - wasModified: state.wasModified, - fields: state.fields, - subforms: {...state.subforms}..remove(form), - validationEnabled: state.validationEnabled, - ), - ); - if (close) { - await form.close(); - } - } - } - - /// Calls validate on all fields with autovalidate on. - void validateWithAutovalidate() { - for (final field in state.fields) { - if (field.state.autovalidate) { - field.validate(); - } - } - for (final subform in state.subforms) { - subform.validateWithAutovalidate(); - } - } - - /// Changes optionality of this form. When `validationEnabled` is set to false, - /// all errors are cleared. - // ignore: avoid_positional_boolean_parameters - void setValidationEnabled(bool validationEnabled) { - if (validationEnabled == state.validationEnabled) { - return; - } - emit( - FormGroupState( - wasModified: state.wasModified, - fields: state.fields, - subforms: state.subforms, - validationEnabled: validationEnabled, - ), - ); - if (validationEnabled) { - validateWithAutovalidate(); - } else { - clearErrors(); - } - } - - void _onFieldsStateChanged() { - final subformsWereModified = state.subforms.any( - (subform) => subform.state.wasModified, - ); - late final fieldsWereModified = !const DeepCollectionEquality() - .equals(_initialFieldsState, getFieldValues()); - - if (validateAll) { - validateWithAutovalidate(); - } - - emit( - FormGroupState( - wasModified: subformsWereModified || fieldsWereModified, - fields: state.fields, - subforms: state.subforms, - validationEnabled: state.validationEnabled, - ), - ); - } - - void _onFieldsStatusChanged() { - final subformsIsValidating = state.subforms.any( - (subform) => subform.state.validating, - ); - - final fieldsAreValidating = state.fields.any( - (field) => field.state.isInProgress, - ); - - emit( - FormGroupState( - wasModified: state.wasModified, - fields: state.fields, - subforms: state.subforms, - validationEnabled: state.validationEnabled, - validating: fieldsAreValidating || subformsIsValidating, - ), - ); - } - - @override - Future close() async { - await dispose(); - return super.close(); - } -} - -/// The state of a [FormGroupCubit]. -class FormGroupState with EquatableMixin { - /// Creates a new [FormGroupState]. - const FormGroupState({ - this.wasModified = false, - this.fields = const [], - this.subforms = const {}, - this.validationEnabled = true, - this.validating = false, - }); - - /// wasModified is true when any of the field values differ since the - /// last `registerFields` or when any of the subforms has wasModified=true. - final bool wasModified; - - /// List of all registered fields by this form. - final List> fields; - - /// Set of registered subforms. Reference equality is assumed. - final Set subforms; - - /// If false, validators are not ran and `validate` always returns true. - final bool validationEnabled; - - /// Returns true if fields are currently being validated. - final bool validating; - - /// List of this form's fields including subforms' fields. - Iterable> get allFields => - fields.followedBy(subforms.expand((e) => e.state.allFields)); - - /// Map of all validation errors (including subfroms') grouped by fields - Map, dynamic> get validationErrors => { - for (final field in allFields) - if (field.state.validationError case final error?) field: error, - }; - - @override - List get props => [ - wasModified, - fields, - subforms, - validationEnabled, - validating, - ]; -} diff --git a/lib/src/utils/disposable.dart b/lib/src/utils/disposable.dart deleted file mode 100644 index ab7e548..0000000 --- a/lib/src/utils/disposable.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; - -/// A callback that disposes of a resource. -typedef DisposeCallback = FutureOr Function(); - -/// Disposable mixin with automatic disposal -mixin Disposable { - final List _disposeCallbacks = []; - bool _isDisposed = false; - - /// Whether the object is disposed. - bool get isDisposed => _isDisposed; - - /// Adds a [disposeCallback] to be called when the object is disposed. - @protected - void addDisposable(DisposeCallback disposeCallback) { - _disposeCallbacks.add(disposeCallback); - } - - /// Disposes of the object. - @mustCallSuper - Future dispose() async { - final future = Future.wait(_disposeCallbacks.map((e) async => await e())); - _disposeCallbacks.clear(); - await future; - _isDisposed = true; - } -} diff --git a/lib/src/utils/extensions/stream_extensions.dart b/lib/src/utils/extensions/stream_extensions.dart deleted file mode 100644 index fdddb8f..0000000 --- a/lib/src/utils/extensions/stream_extensions.dart +++ /dev/null @@ -1,20 +0,0 @@ -/// Extensions for [Stream] of type [T]. -extension StreamExtensions on Stream { - /// distinct() will always emit the first event since there is no previous one to compare with. - /// This method seeds the stream with an initial value - /// and starts emitting distinct values as soon as there is a value different from the initial one. - Stream distinctWithFirst(T firstValue) { - var isFirstEmit = true; - - return distinct().where((value) { - if (isFirstEmit) { - isFirstEmit = false; - if (firstValue == value) { - return false; - } - } - - return true; - }); - } -} diff --git a/lib/src/validators/validators.dart b/lib/src/validators/validators.dart index fffdd61..98c3148 100644 --- a/lib/src/validators/validators.dart +++ b/lib/src/validators/validators.dart @@ -1,5 +1,4 @@ -import 'package:leancode_forms/leancode_forms.dart'; -import 'package:leancode_forms/src/field/cubit/field_cubit.dart'; +import 'package:leancode_forms/src/field/field_controller.dart'; /// Creates a new validator. /// diff --git a/pubspec.yaml b/pubspec.yaml index 396caa4..fb3d873 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: leancode_forms -description: A package for managing form state based on BLoC. -version: 0.1.2 +description: A package for managing form state based on ValueNotifier and ChangeNotifier. +version: 0.2.0 homepage: https://github.com/leancodepl/leancode_forms.git environment: @@ -9,14 +9,10 @@ environment: dependencies: collection: ^1.17.1 - equatable: ^2.0.5 flutter: sdk: flutter - flutter_bloc: ^9.0.0 - rxdart: ^0.28.0 dev_dependencies: - bloc_test: ^10.0.0 flutter_test: sdk: flutter - leancode_lint: ^15.0.0 + leancode_lint: ^15.0.0 \ No newline at end of file diff --git a/test/src/field/field_builder_test.dart b/test/src/field/field_builder_test.dart new file mode 100644 index 0000000..9bdd292 --- /dev/null +++ b/test/src/field/field_builder_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:leancode_forms/leancode_forms.dart'; + +void main() { + testWidgets('FieldBuilder rebuilds when the field changes', (tester) async { + final field = TextFieldController(initialValue: 'first'); + addTearDown(field.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: FieldBuilder( + field: field, + builder: (context, state) => Text(state.value), + ), + ), + ), + ); + + expect(find.text('first'), findsOneWidget); + + field.setValue('second'); + await tester.pump(); + + expect(find.text('first'), findsNothing); + expect(find.text('second'), findsOneWidget); + }); +} diff --git a/test/src/field/field_controller_test.dart b/test/src/field/field_controller_test.dart new file mode 100644 index 0000000..72cf596 --- /dev/null +++ b/test/src/field/field_controller_test.dart @@ -0,0 +1,332 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:leancode_forms/leancode_forms.dart'; + +enum _Error { + malformed, + valueRequired, +} + +class _ValidatorMock { + _Error? validationResult; + + _Error? call(int? value) => validationResult; +} + +const _initialValue = 0; + +typedef _Field = FieldController; +typedef _State = FieldState; + +/// Returns a list that records every new state emitted by [notifier]. +List<_State> _record(_Field notifier) { + final emissions = <_State>[]; + notifier.addListener(() => emissions.add(notifier.value)); + return emissions; +} + +void main() { + late _Field field; + late _ValidatorMock validator; + + setUp(() { + validator = _ValidatorMock(); + field = FieldController( + initialValue: _initialValue, + validator: validator, + ); + }); + + tearDown(() { + field.dispose(); + }); + + group('setValue', () { + test('updates the value', () { + final emissions = _record(field); + field.setValue(10); + expect(emissions, const [_State(value: 10)]); + }); + + test('does not update error if autovalidate is off', () { + validator.validationResult = _Error.malformed; + final emissions = _record(field); + field.setValue(10); + expect(emissions, const [_State(value: 10)]); + }); + + test('updates error if autovalidate is on', () { + field.setAutovalidate(true); + validator.validationResult = _Error.malformed; + final emissions = _record(field); + field.setValue(10); + expect(emissions, const [ + _State( + value: 10, + validationError: _Error.malformed, + autovalidate: true, + status: FieldStatus.invalid, + ), + ]); + }); + + test('does not update the value when field is readonly', () { + field.markReadOnly(); + final emissions = _record(field); + field.setValue(10); + expect(emissions, isEmpty); + }); + + test('updates the value when field is readonly and force is true', () { + field.markReadOnly(); + final emissions = _record(field); + field.setValue(10, force: true); + expect(emissions, const [_State(value: 10, readOnly: true)]); + }); + }); + + group('reset', () { + test('resets state to initial state', () { + field.value = const _State( + value: 10, + validationError: _Error.malformed, + asyncError: _Error.malformed, + autovalidate: true, + readOnly: true, + status: FieldStatus.invalid, + ); + final emissions = _record(field); + field.reset(); + expect(emissions, const [_State(value: 0)]); + }); + }); + + group('clearErrors', () { + test('clears validationError and asyncError. Sets status to valid', () { + field.value = const _State( + value: 1, + validationError: _Error.valueRequired, + asyncError: _Error.malformed, + ); + final emissions = _record(field); + field.clearErrors(); + expect(emissions, const [_State(value: 1)]); + }); + + test('does nothing if errors were not present', () { + field.value = const _State(value: 1); + final emissions = _record(field); + field.clearErrors(); + expect(emissions, isEmpty); + }); + }); + + group('validate', () { + test('when is valid', () { + validator.validationResult = null; + final result = field.validate(); + + expect(result, true); + expect(field.value, const _State(value: _initialValue)); + expect(field.value.isValid, true); + }); + + test('when is not valid', () { + validator.validationResult = _Error.malformed; + final result = field.validate(); + + expect(result, false); + expect( + field.value, + const _State( + value: _initialValue, + validationError: _Error.malformed, + status: FieldStatus.invalid, + ), + ); + expect(field.value.isValid, false); + }); + + test( + 'when validation result is the same as previous, does not emit new state', + () { + validator.validationResult = _Error.malformed; + field.value = const _State( + value: 1, + validationError: _Error.malformed, + status: FieldStatus.invalid, + ); + final emissions = _record(field); + field.validate(); + expect(emissions, isEmpty); + }); + }); + + group('getValueSetter', () { + test('is null when field is readonly', () { + field.markReadOnly(); + expect(field.getValueSetter(), null); + }); + + test('is setValue when field is not readonly', () { + expect(field.getValueSetter(), field.setValue); + }); + }); + + group('async validation', () { + late _Field asyncField; + + setUp(() { + asyncField = FieldController( + initialValue: _initialValue, + asyncValidator: (value) async { + await Future.delayed(const Duration(milliseconds: 100)); + return validator.validationResult; + }, + ); + }); + + tearDown(() { + asyncField.dispose(); + }); + + test('emits pending, validating, then final invalid', () async { + validator.validationResult = _Error.malformed; + final emissions = _record(asyncField); + + asyncField.setValue(10); + await Future.delayed(const Duration(milliseconds: 600)); + + expect(emissions, const [ + _State(value: 10, status: FieldStatus.pending), + _State(value: 10, status: FieldStatus.validating), + _State( + value: 10, + status: FieldStatus.invalid, + asyncError: _Error.malformed, + ), + ]); + }); + + test('restarts async validation when value changes while pending', + () async { + validator.validationResult = _Error.malformed; + final emissions = _record(asyncField); + + asyncField.setValue(10); + await Future.delayed(const Duration(milliseconds: 150)); + asyncField.setValue(20); + await Future.delayed(const Duration(milliseconds: 600)); + + expect(emissions, const [ + _State(value: 10, status: FieldStatus.pending), + _State(value: 20, status: FieldStatus.pending), + _State(value: 20, status: FieldStatus.validating), + _State( + value: 20, + status: FieldStatus.invalid, + asyncError: _Error.malformed, + ), + ]); + }); + + test('restarts async validation when value changes while validating', + () async { + validator.validationResult = _Error.malformed; + final emissions = _record(asyncField); + + asyncField.setValue(10); + await Future.delayed(const Duration(milliseconds: 300)); + asyncField.setValue(20); + await Future.delayed(const Duration(milliseconds: 600)); + + expect(emissions, const [ + _State(value: 10, status: FieldStatus.pending), + _State(value: 10, status: FieldStatus.validating), + _State(value: 20, status: FieldStatus.pending), + _State(value: 20, status: FieldStatus.validating), + _State( + value: 20, + status: FieldStatus.invalid, + asyncError: _Error.malformed, + ), + ]); + }); + }); + + group('setError', () { + test('sets error and changes field status to invalid', () { + final emissions = _record(field); + field.setError(_Error.malformed); + expect(emissions, const [ + _State( + value: _initialValue, + validationError: _Error.malformed, + status: FieldStatus.invalid, + ), + ]); + }); + }); + + group('subscribeToFields', () { + test('should run validation when subscribed field changes', () async { + validator.validationResult = _Error.malformed; + final field2 = FieldController(initialValue: 0); + final field1 = FieldController( + initialValue: 0, + validator: validator, + ) + ..setAutovalidate(true) + ..subscribeToFields([field2]); + + field2.setValue(10); + await Future.delayed(Duration.zero); + expect(field1.value.error, _Error.malformed); + + field1.dispose(); + field2.dispose(); + }); + + test('should run async validation when subscribed field changes', () async { + validator.validationResult = _Error.malformed; + final field2 = FieldController(initialValue: 0); + final field1 = FieldController( + initialValue: 0, + asyncValidator: (_) async => validator.validationResult, + ) + ..setAutovalidate(true) + ..subscribeToFields([field2]); + + field2.setValue(10); + await Future.delayed(const Duration(milliseconds: 500)); + expect(field1.value.error, _Error.malformed); + + field1.dispose(); + field2.dispose(); + }); + }); + + group('TextFieldController text controller sync', () { + test('typing into textController updates field value', () { + final tf = TextFieldController<_Error>(initialValue: 'a') + ..textController.text = 'abc'; + expect(tf.value.value, 'abc'); + tf.dispose(); + }); + + test('setValue mirrors to textController', () { + final tf = TextFieldController<_Error>(initialValue: 'a') + ..setValue('hello'); + expect(tf.textController.text, 'hello'); + tf.dispose(); + }); + + test('reset clears both field and textController', () { + final tf = TextFieldController<_Error>() + ..setValue('typed') + ..reset(); + expect(tf.value.value, ''); + expect(tf.textController.text, ''); + tf.dispose(); + }); + }); +} diff --git a/test/src/field/field_cubit_test.dart b/test/src/field/field_cubit_test.dart deleted file mode 100644 index 86c1eb9..0000000 --- a/test/src/field/field_cubit_test.dart +++ /dev/null @@ -1,355 +0,0 @@ -import 'package:bloc_test/bloc_test.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:leancode_forms/leancode_forms.dart'; -import 'package:leancode_forms/src/field/cubit/field_cubit.dart'; - -enum _Error { - malformed, - valueRequired, -} - -class _ValidatorMock { - _Error? validationResult; - - _Error? call(int? value) => validationResult; -} - -const _initialValue = 0; - -typedef _FieldCubit = FieldCubit; -typedef _FieldState = FieldState; - -void main() { - late FieldCubit cubit; - late _ValidatorMock validator; - - setUp(() { - validator = _ValidatorMock(); - cubit = FieldCubit(initialValue: _initialValue, validator: validator); - }); - - tearDown(() async { - await cubit.close(); - }); - - group('setValue', () { - blocTest<_FieldCubit, _FieldState>( - 'updates the value', - build: () => cubit, - act: (cubit) => cubit.setValue(10), - expect: () => const [ - _FieldState(value: 10), - ], - ); - - blocTest<_FieldCubit, _FieldState>( - 'does not update error if autovalidate is off', - setUp: () { - validator.validationResult = _Error.malformed; - }, - build: () => cubit, - act: (cubit) => cubit.setValue(10), - expect: () => const [ - _FieldState(value: 10), - ], - ); - - blocTest<_FieldCubit, _FieldState>( - 'updates error if autovalidate is on', - setUp: () { - cubit.setAutovalidate(true); - validator.validationResult = _Error.malformed; - }, - build: () => cubit, - act: (cubit) => cubit.setValue(10), - expect: () => const [ - _FieldState( - value: 10, - validationError: _Error.malformed, - autovalidate: true, - status: FieldStatus.invalid, - ), - ], - ); - - blocTest<_FieldCubit, _FieldState>( - 'does not update the value when field is readonly', - build: () => cubit, - setUp: () { - cubit.markReadOnly(); - }, - act: (cubit) { - cubit.setValue(10); - }, - expect: () => const [], - ); - - blocTest<_FieldCubit, _FieldState>( - 'updates the value when field is readonly and force is true', - build: () => cubit, - setUp: () { - cubit.markReadOnly(); - }, - act: (cubit) { - cubit.setValue(10, force: true); - }, - expect: () => const [ - _FieldState( - value: 10, - readOnly: true, - ), - ], - ); - }); - - group('reset', () { - blocTest<_FieldCubit, _FieldState>( - 'resets state to initial state', - build: () => cubit, - seed: () => const _FieldState( - value: 10, - validationError: _Error.malformed, - asyncError: _Error.malformed, - autovalidate: true, - readOnly: true, - status: FieldStatus.invalid, - ), - act: (cubit) => cubit.reset(), - expect: () => const [ - _FieldState(value: 0), - ], - ); - - group('clearErrors', () { - blocTest<_FieldCubit, _FieldState>( - 'clears validationError and asyncError. Sets status to valid', - build: () => cubit, - seed: () => const _FieldState( - value: 1, - validationError: _Error.valueRequired, - asyncError: _Error.malformed, - ), - act: (cubit) => cubit.clearErrors(), - expect: () => const [ - _FieldState(value: 1), - ], - ); - - blocTest<_FieldCubit, _FieldState>( - 'does nothing if errors were not present', - build: () => cubit, - seed: () => const _FieldState(value: 1), - act: (cubit) => cubit.clearErrors(), - expect: () => const [], - ); - }); - - group('validate', () { - test('when is valid', () { - validator.validationResult = null; - final validationResult = cubit.validate(); - - expect(validationResult, true); - expect(cubit.state, const _FieldState(value: _initialValue)); - expect(cubit.state.isValid, true); - }); - - test('when is not valid', () { - validator.validationResult = _Error.malformed; - final validationResult = cubit.validate(); - - expect(validationResult, false); - expect( - cubit.state, - const _FieldState( - value: _initialValue, - validationError: _Error.malformed, - status: FieldStatus.invalid, - ), - ); - expect(cubit.state.isValid, false); - }); - - blocTest<_FieldCubit, _FieldState>( - 'when validation result is the same as previous, does not emit new state', - setUp: () { - validator.validationResult = _Error.malformed; - }, - build: () => cubit, - seed: () => const _FieldState( - value: 1, - validationError: _Error.malformed, - status: FieldStatus.invalid, - ), - act: (cubit) => cubit.validate(), - expect: () => const [], - ); - }); - - group('getValueSetter', () { - test('is null when field is readonly', () { - cubit.markReadOnly(); - - expect(cubit.getValueSetter(), null); - }); - - test('is setValue when field is not readonly', () { - expect(cubit.getValueSetter(), cubit.setValue); - }); - }); - - group('async validation', () { - setUp(() { - cubit = FieldCubit( - initialValue: _initialValue, - asyncValidator: (value) async { - await Future.delayed(const Duration(milliseconds: 100)); - return validator.validationResult; - }, - ); - }); - - blocTest<_FieldCubit, _FieldState>( - 'should emit pending and validating states when async validating the field', - build: () => cubit, - act: (cubit) async { - cubit.setValue(10); - }, - setUp: () { - validator.validationResult = _Error.malformed; - }, - wait: const Duration(milliseconds: 600), - expect: () => const [ - _FieldState( - value: 10, - status: FieldStatus.pending, - ), - _FieldState( - value: 10, - status: FieldStatus.validating, - ), - _FieldState( - value: 10, - status: FieldStatus.invalid, - asyncError: _Error.malformed, - ), - ], - ); - - blocTest<_FieldCubit, _FieldState>( - 'should restart async validation when value changes while pending', - build: () => cubit, - act: (cubit) async { - cubit.setValue(10); - await Future.delayed(const Duration(milliseconds: 150)); - cubit.setValue(20); - }, - wait: const Duration(milliseconds: 600), - setUp: () { - validator.validationResult = _Error.malformed; - }, - expect: () => const [ - _FieldState( - value: 10, - status: FieldStatus.pending, - ), - _FieldState( - value: 20, - status: FieldStatus.pending, - ), - _FieldState( - value: 20, - status: FieldStatus.validating, - ), - _FieldState( - value: 20, - status: FieldStatus.invalid, - asyncError: _Error.malformed, - ), - ], - ); - - blocTest<_FieldCubit, _FieldState>( - 'should restart async validation when value changes while validating', - build: () => cubit, - act: (cubit) async { - cubit.setValue(10); - await Future.delayed(const Duration(milliseconds: 300)); - cubit.setValue(20); - }, - wait: const Duration(milliseconds: 600), - setUp: () { - validator.validationResult = _Error.malformed; - }, - expect: () => const [ - _FieldState( - value: 10, - status: FieldStatus.pending, - ), - _FieldState( - value: 10, - status: FieldStatus.validating, - ), - _FieldState( - value: 20, - status: FieldStatus.pending, - ), - _FieldState( - value: 20, - status: FieldStatus.validating, - ), - _FieldState( - value: 20, - status: FieldStatus.invalid, - asyncError: _Error.malformed, - ), - ], - ); - }); - - group('setError', () { - blocTest<_FieldCubit, _FieldState>( - 'sets error and changes field status to invalid', - build: () => cubit, - act: (cubit) => cubit.setError(_Error.malformed), - expect: () => const [ - _FieldState( - value: _initialValue, - validationError: _Error.malformed, - status: FieldStatus.invalid, - ), - ], - ); - }); - - group('subscribeToFields', () { - test('should run validation when subscribed field changes', () async { - validator.validationResult = _Error.malformed; - final field2 = FieldCubit(initialValue: 0); - final field1 = FieldCubit(initialValue: 0, validator: validator) - ..setAutovalidate(true) - ..subscribeToFields([field2]); - - field2.setValue(10); - await Future.delayed(Duration.zero); - expect(field1.state.error, _Error.malformed); - }); - - test('should run async validation when subscribed field changes', - () async { - validator.validationResult = _Error.malformed; - final field2 = FieldCubit(initialValue: 0); - final field1 = FieldCubit( - initialValue: 0, - asyncValidator: (_) async => validator.validationResult, - ) - ..setAutovalidate(true) - ..subscribeToFields([field2]); - - field2.setValue(10); - await Future.delayed(const Duration(milliseconds: 350)); - expect(field1.state.error, _Error.malformed); - }); - }); - }); -} diff --git a/test/src/form_group/form_group_controller_test.dart b/test/src/form_group/form_group_controller_test.dart new file mode 100644 index 0000000..2b09974 --- /dev/null +++ b/test/src/form_group/form_group_controller_test.dart @@ -0,0 +1,779 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:leancode_forms/leancode_forms.dart'; + +enum _Error1 { valueRequired } + +enum _Error2 { malformed } + +const _initialValue1 = 'initial'; +const _initialValue2 = 0; + +class _ValidatorMock { + E? validationResult; + + E? call(T? value) => validationResult; +} + +/// Records every new state emitted by [notifier]. +List _record(ValueNotifier notifier) { + final emissions = []; + notifier.addListener(() => emissions.add(notifier.value)); + return emissions; +} + +/// Counts how many times a [Listenable] fires. +int Function() _countCalls(Listenable listenable) { + var count = 0; + listenable.addListener(() => count++); + return () => count; +} + +void main() { + group('FormGroupController', () { + late FormGroupController form; + late FormGroupController subform; + late TextFieldController<_Error1> field1; + late FieldController field2; + late FieldController subformField; + late _ValidatorMock validator1; + late _ValidatorMock validator2; + + setUp(() { + validator1 = _ValidatorMock(); + validator2 = _ValidatorMock(); + field1 = TextFieldController( + initialValue: _initialValue1, + validator: validator1, + ); + field2 = FieldController( + initialValue: _initialValue2, + validator: validator2, + ); + subformField = FieldController( + initialValue: _initialValue2, + validator: validator2, + ); + form = FormGroupController(); + subform = FormGroupController(); + }); + + tearDown(() { + // The form owns field1/field2/subform/subformField transitively when + // they get registered/added — but tests that don't register them have + // to clean up by hand. dispose() is idempotent-ish so we keep tearDown + // minimal and let each test handle its own scopes. + }); + + test('has correct initial state', () { + expect(form.value, const FormGroupState()); + form.dispose(); + }); + + group('getFieldValues', () { + test('when no fields are registered', () { + final values = form.getFieldValues(); + expect(values, isEmpty); + form.dispose(); + }); + + test('when fields are registered', () { + form.registerFields([field1, field2]); + final values = form.getFieldValues(); + expect(values, [_initialValue1, _initialValue2]); + form.dispose(); + }); + + test('when fields are registered and have new values', () { + form.registerFields([field1, field2]); + field1.setValue('hello'); + field2.setValue(10); + final values = form.getFieldValues(); + expect(values, ['hello', 10]); + form.dispose(); + }); + + test('when fields are unregistered', () { + form + ..registerFields([field1, field2]) + ..registerFields([]); + field1.setValue('hello'); + field2.setValue(10); + final values = form.getFieldValues(); + expect(values, isEmpty); + form.dispose(); + }); + }); + + group('wasModified', () { + test('is false after register', () { + final emissions = _record(form); + form.registerFields([field1, field2]); + expect(emissions, [ + FormGroupState(fields: [field1, field2]), + ]); + form.dispose(); + }); + + test('is true if subform was modified', () async { + subform.registerFields([subformField]); + form + ..addSubform(subform) + ..registerFields([field1, field2]); + + await Future.delayed(Duration.zero); + final emissions = _record(form); + subformField.setValue(123); + await Future.delayed(Duration.zero); + + expect(emissions, [ + FormGroupState( + wasModified: true, + fields: [field1, field2], + subforms: {subform}, + ), + ]); + form.dispose(); + }); + + test('is true if field1 changes', () async { + form.registerFields([field1, field2]); + + await Future.delayed(Duration.zero); + final emissions = _record(form); + field1.setValue('value'); + await Future.delayed(Duration.zero); + + expect(emissions, [ + FormGroupState(wasModified: true, fields: [field1, field2]), + ]); + form.dispose(); + }); + + test('is true if field2 changes', () async { + form.registerFields([field1, field2]); + + await Future.delayed(Duration.zero); + final emissions = _record(form); + field2.setValue(0xb0b); + await Future.delayed(Duration.zero); + + expect(emissions, [ + FormGroupState(wasModified: true, fields: [field1, field2]), + ]); + form.dispose(); + }); + + test('does not change if field was unregistered', () async { + form + ..registerFields([field1, field2]) + ..registerFields([]); + + await Future.delayed(Duration.zero); + final emissions = _record(form); + field2.setValue(0xb0b); + await Future.delayed(Duration.zero); + + expect(emissions, isEmpty); + form.dispose(); + }); + }); + + group('validate', () { + test('enables autovalidate in fields', () { + subform.registerFields([subformField]); + form + ..registerFields([field1, field2]) + ..addSubform(subform) + ..validate(); + + expect(field1.value.autovalidate, true); + expect(field2.value.autovalidate, true); + expect(subformField.value.autovalidate, true); + form.dispose(); + }); + + test('does not enable autovalidate in fields', () { + subform.registerFields([subformField]); + form + ..registerFields([field1, field2]) + ..addSubform(subform) + ..validate(enableAutovalidate: false); + + expect(field1.value.autovalidate, false); + expect(field2.value.autovalidate, false); + expect(subformField.value.autovalidate, false); + form.dispose(); + }); + + test('is valid when all are valid', () { + subform.registerFields([subformField]); + validator1.validationResult = null; + validator2.validationResult = null; + form + ..registerFields([field1, field2]) + ..addSubform(subform); + + expect(form.validate(), true); + form.dispose(); + }); + + test('is not valid if a subform is not valid', () { + subform.registerFields([subformField]); + validator1.validationResult = null; + validator2.validationResult = _Error2.malformed; + form + ..registerFields([field1]) + ..addSubform(subform); + + expect(form.validate(), false); + form.dispose(); + field2.dispose(); + }); + + test('is not valid when any is invalid', () { + validator1.validationResult = _Error1.valueRequired; + validator2.validationResult = null; + form.registerFields([field1, field2]); + + expect(form.validate(), false); + form.dispose(); + subform.dispose(); + subformField.dispose(); + }); + + test('does not short-circuit on validation', () { + subform.registerFields([subformField]); + validator1.validationResult = _Error1.valueRequired; + validator2.validationResult = _Error2.malformed; + + form + ..registerFields([field1, field2]) + ..addSubform(subform) + ..validate(enableAutovalidate: false); + + expect(field1.value.error, _Error1.valueRequired); + expect(field2.value.error, _Error2.malformed); + expect(subformField.value.error, _Error2.malformed); + form.dispose(); + }); + + test('is valid when validationEnabled is false', () { + validator1.validationResult = _Error1.valueRequired; + form + ..registerFields([field1]) + ..setValidationEnabled(false); + + expect(form.validate(), true); + form.dispose(); + field2.dispose(); + subform.dispose(); + subformField.dispose(); + }); + + test('enables autovalidate even when validationEnabled is false', () { + form + ..registerFields([field1]) + ..setValidationEnabled(false) + ..validate(); + + expect(field1.value.autovalidate, true); + form.dispose(); + field2.dispose(); + subform.dispose(); + subformField.dispose(); + }); + + test('is not valid when any of the fields is pending async validation', + () async { + validator1.validationResult = null; + final asyncField = TextFieldController<_Error1>( + initialValue: _initialValue1, + asyncValidator: (_) async => validator1.validationResult, + ); + form.registerFields([asyncField]); + + asyncField.setValue('value'); + + expect(form.validate(), false); + await Future.delayed(const Duration(milliseconds: 500)); + form.dispose(); + field1.dispose(); + field2.dispose(); + subform.dispose(); + subformField.dispose(); + }); + + test('is not valid when async validation of the field fails', () async { + validator1.validationResult = _Error1.valueRequired; + final asyncField = TextFieldController<_Error1>( + initialValue: _initialValue1, + asyncValidator: (_) async => validator1.validationResult, + ); + form.registerFields([asyncField]); + + asyncField.setValue('value'); + await Future.delayed(const Duration(milliseconds: 500)); + expect(form.validate(), false); + + form.dispose(); + field1.dispose(); + field2.dispose(); + subform.dispose(); + subformField.dispose(); + }); + + test( + 'is not valid when any of the fields in subform is pending async validation', + () async { + validator2.validationResult = null; + final asyncSubformField = FieldController( + initialValue: 0, + asyncValidator: (_) async => validator2.validationResult, + ); + final asyncSubform = FormGroupController() + ..registerFields([asyncSubformField]); + form.addSubform(asyncSubform); + + asyncSubformField.setValue(10); + expect(form.validate(), false); + + await Future.delayed(const Duration(milliseconds: 500)); + form.dispose(); + field1.dispose(); + field2.dispose(); + subform.dispose(); + subformField.dispose(); + }); + }); + + group('onValuesChanged', () { + test('fires on field change', () async { + form.registerFields([field1, field2]); + await Future.delayed(Duration.zero); + final getCount = _countCalls(form.onValuesChanged); + + field1.setValue('value'); + await Future.delayed(Duration.zero); + + expect(getCount(), greaterThanOrEqualTo(1)); + form.dispose(); + }); + + test('fires on subform field change', () async { + subform.registerFields([subformField]); + form + ..registerFields([field1, field2]) + ..addSubform(subform); + + await Future.delayed(Duration.zero); + final getCount = _countCalls(form.onValuesChanged); + subformField.setValue(123); + await Future.delayed(Duration.zero); + + expect(getCount(), greaterThanOrEqualTo(1)); + form.dispose(); + }); + + test('fires when new fields are registered', () async { + form.registerFields([field1]); + await Future.delayed(Duration.zero); + final getCount = _countCalls(form.onValuesChanged); + + form.registerFields([field1, field2]); + await Future.delayed(Duration.zero); + + expect(getCount(), greaterThanOrEqualTo(1)); + form.dispose(); + subform.dispose(); + subformField.dispose(); + }); + + test('fires when new fields are registered for subform', () async { + form + ..registerFields([field1]) + ..addSubform(subform); + await Future.delayed(Duration.zero); + final getCount = _countCalls(form.onValuesChanged); + + subform.registerFields([subformField]); + await Future.delayed(Duration.zero); + + expect(getCount(), greaterThanOrEqualTo(1)); + form.dispose(); + field2.dispose(); + }); + + test('does not fire on validation error', () async { + form.registerFields([field1, field2]); + await Future.delayed(Duration.zero); + final getCount = _countCalls(form.onValuesChanged); + + field1.setError(_Error1.valueRequired); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(getCount(), 0); + form.dispose(); + subform.dispose(); + subformField.dispose(); + }); + + test('does not fire on enabling autovalidate', () async { + form.registerFields([field1, field2]); + await Future.delayed(Duration.zero); + final getCount = _countCalls(form.onValuesChanged); + + field1.setAutovalidate(true); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(getCount(), 0); + form.dispose(); + subform.dispose(); + subformField.dispose(); + }); + + test('does not fire on same value', () async { + form.registerFields([field1, field2]); + field1.setValue('value'); + await Future.delayed(Duration.zero); + final getCount = _countCalls(form.onValuesChanged); + + field1.setValue('value'); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(getCount(), 0); + form.dispose(); + subform.dispose(); + subformField.dispose(); + }); + }); + + test('markReadOnly', () { + subform.registerFields([subformField]); + form + ..registerFields([field1, field2]) + ..addSubform(subform) + ..markReadOnly(); + + expect(field1.value.readOnly, true); + expect(field2.value.readOnly, true); + expect(subformField.value.readOnly, true); + form.dispose(); + }); + + test('clearErrors', () { + field1.setError(_Error1.valueRequired); + field2.setError(_Error2.malformed); + subformField.setError(_Error2.malformed); + + subform.registerFields([subformField]); + form + ..registerFields([field1, field2]) + ..addSubform(subform) + ..clearErrors(); + + expect(field1.value.error, null); + expect(field2.value.error, null); + expect(subformField.value.error, null); + + expect(field1.value.isValid, true); + expect(field2.value.isValid, true); + expect(subformField.value.isValid, true); + form.dispose(); + }); + + group('setAutovalidate', () { + test('to true', () { + subform.registerFields([subformField]); + form + ..registerFields([field1, field2]) + ..addSubform(subform) + ..setAutovalidate(true); + + expect(field1.value.autovalidate, true); + expect(field2.value.autovalidate, true); + expect(subformField.value.autovalidate, true); + form.dispose(); + }); + + test('to false', () { + subform.registerFields([subformField]); + form + ..registerFields([field1, field2]) + ..addSubform(subform) + ..setAutovalidate(true) + ..setAutovalidate(false); + + expect(field1.value.autovalidate, false); + expect(field2.value.autovalidate, false); + expect(subformField.value.autovalidate, false); + form.dispose(); + }); + }); + + group('addSubform', () { + test('adds a new subform', () { + final emissions = _record(form); + form.addSubform(subform); + expect(emissions, [ + FormGroupState(subforms: {subform}), + ]); + form.dispose(); + field1.dispose(); + field2.dispose(); + subformField.dispose(); + }); + + test('is noop if form was already added', () { + form.addSubform(subform); + final emissions = _record(form); + form.addSubform(subform); + expect(emissions, isEmpty); + form.dispose(); + field1.dispose(); + field2.dispose(); + subformField.dispose(); + }); + }); + + group('validateAll', () { + late FormGroupController validateAllForm; + + setUp(() { + validateAllForm = FormGroupController(validateAll: true); + }); + + test('validate is called on other autovalidate fields', () async { + subform.registerFields([subformField]); + validateAllForm + ..registerFields([field1, field2]) + ..addSubform(subform); + field1.setAutovalidate(true); + validator1.validationResult = _Error1.valueRequired; + + field2.setValue(42); + await Future.delayed(Duration.zero); + + expect(field1.value.error, _Error1.valueRequired); + expect(field2.value.error, null); + expect(subformField.value.error, null); + validateAllForm.dispose(); + }); + + test('validate is called on other autovalidate subforms', () async { + subform.registerFields([subformField]); + validateAllForm + ..registerFields([field1, field2]) + ..addSubform(subform); + subformField.setAutovalidate(true); + validator2.validationResult = _Error2.malformed; + + field2.setValue(42); + await Future.delayed(Duration.zero); + + expect(field1.value.error, null); + expect(field2.value.error, null); + expect(subformField.value.error, _Error2.malformed); + validateAllForm.dispose(); + }); + }); + + group('setValidationEnabled', () { + test('sets validationEnabled to false', () { + final emissions = _record(form); + form.setValidationEnabled(false); + expect(emissions, [ + const FormGroupState(validationEnabled: false), + ]); + form.dispose(); + field1.dispose(); + field2.dispose(); + subform.dispose(); + subformField.dispose(); + }); + + test('sets validationEnabled to true', () { + form.setValidationEnabled(false); + final emissions = _record(form); + form.setValidationEnabled(true); + expect(emissions, [const FormGroupState()]); + form.dispose(); + field1.dispose(); + field2.dispose(); + subform.dispose(); + subformField.dispose(); + }); + + test('is noop if the same validationEnabled was already set', () { + final emissions = _record(form); + form.setValidationEnabled(true); + expect(emissions, isEmpty); + form.dispose(); + field1.dispose(); + field2.dispose(); + subform.dispose(); + subformField.dispose(); + }); + + test( + 'clears errors when validationEnabled is set to false and was true before', + () { + field1.setError(_Error1.valueRequired); + field2.setError(_Error2.malformed); + subformField.setError(_Error2.malformed); + subform.registerFields([subformField]); + + form + ..setValidationEnabled(true) + ..registerFields([field1, field2]) + ..addSubform(subform) + ..setValidationEnabled(false); + + expect(field1.value.error, null); + expect(field2.value.error, null); + expect(subformField.value.error, null); + form.dispose(); + }); + + test('does not clear errors when validationEnabled is set to true', () { + field1.setError(_Error1.valueRequired); + field2.setError(_Error2.malformed); + subformField.setError(_Error2.malformed); + subform.registerFields([subformField]); + + form + ..setValidationEnabled(false) + ..registerFields([field1, field2]) + ..addSubform(subform) + ..setValidationEnabled(true); + + expect(field1.value.error, _Error1.valueRequired); + expect(field2.value.error, _Error2.malformed); + expect(subformField.value.error, _Error2.malformed); + form.dispose(); + }); + + test( + 'does not clear errors when validationEnabled is set to false and was already false before', + () { + field1.setError(_Error1.valueRequired); + field2.setError(_Error2.malformed); + subformField.setError(_Error2.malformed); + subform.registerFields([subformField]); + + form + ..setValidationEnabled(false) + ..registerFields([field1, field2]) + ..addSubform(subform) + ..setValidationEnabled(false); + + expect(field1.value.error, _Error1.valueRequired); + expect(field2.value.error, _Error2.malformed); + expect(subformField.value.error, _Error2.malformed); + form.dispose(); + }); + }); + + group('removeSubform', () { + test('removes a previously added subform and disposes it', () async { + form.addSubform(subform); + final emissions = _record(form); + + await form.removeSubform(subform); + + expect(emissions, [const FormGroupState()]); + // Subform is disposed — touching its onValuesChanged would throw, so + // we don't probe it further. Form does not dispose it twice on close. + form.dispose(); + field1.dispose(); + field2.dispose(); + subformField.dispose(); + }); + + test( + 'removes a previously added subform but does not dispose it when close is false', + () async { + form.addSubform(subform); + final emissions = _record(form); + + await form.removeSubform(subform, close: false); + + expect(emissions, [const FormGroupState()]); + // Subform should still be alive — confirm by reading its state. + expect(subform.value, const FormGroupState()); + + form.dispose(); + subform.dispose(); + field1.dispose(); + field2.dispose(); + subformField.dispose(); + }); + + test('is noop if form was not added', () async { + final emissions = _record(form); + await form.removeSubform(subform); + expect(emissions, isEmpty); + form.dispose(); + subform.dispose(); + field1.dispose(); + field2.dispose(); + subformField.dispose(); + }); + }); + + test('disposes all dependencies', () { + subform.registerFields([subformField]); + form + ..registerFields([field1, field2]) + ..addSubform(subform) + ..dispose(); + // Re-disposing throws in debug — instead verify children are unusable + // by checking they don't react to setValue (their listeners are gone). + // Direct disposed-check on ValueNotifier isn't exposed publicly, so we + // rely on the absence of crashes during form.dispose(). + }); + + group('resetAll', () { + test('resets all fields state to initial', () { + subform.registerFields([subformField]); + form + ..registerFields([field1, field2]) + ..addSubform(subform); + + field1.setValue('value'); + field2.setValue(42); + subformField.setValue(42); + + form.resetAll(); + + expect(field1.value.value, _initialValue1); + expect(field2.value.value, _initialValue2); + expect(subformField.value.value, _initialValue2); + form.dispose(); + }); + }); + + group('validateWithAutovalidate', () { + test('validates only the fields which have set autovalidate to true', () { + subform.registerFields([subformField]); + form + ..registerFields([field1, field2]) + ..addSubform(subform); + + field1.setAutovalidate(true); + field2.setAutovalidate(false); + subformField.setAutovalidate(true); + + validator1.validationResult = _Error1.valueRequired; + validator2.validationResult = _Error2.malformed; + + form.validateWithAutovalidate(); + + expect(field1.value.error, _Error1.valueRequired); + expect(field2.value.error, null); + expect(subformField.value.error, _Error2.malformed); + form.dispose(); + }); + }); + }); +} diff --git a/test/src/form_group_cubit/form_group_cubit_test.dart b/test/src/form_group_cubit/form_group_cubit_test.dart deleted file mode 100644 index 61b862e..0000000 --- a/test/src/form_group_cubit/form_group_cubit_test.dart +++ /dev/null @@ -1,728 +0,0 @@ -import 'dart:async'; - -import 'package:bloc_test/bloc_test.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:leancode_forms/src/field/cubit/field_cubit.dart'; -import 'package:leancode_forms/src/field/text_field_cubit.dart'; -import 'package:leancode_forms/src/form_group_cubit/form_group_cubit.dart'; - -enum _Error1 { valueRequired } - -enum _Error2 { malformed } - -const _initialValue1 = 'initial'; -const _initialValue2 = 0; - -class _ValidatorMock { - E? validationResult; - - E? call(T? value) => validationResult; -} - -void main() { - group('FormGroupCubit', () { - late FormGroupCubit form; - late FormGroupCubit subform; - late TextFieldCubit<_Error1> field1; - late FieldCubit field2; - late FieldCubit subformField; - late _ValidatorMock validator1; - late _ValidatorMock validator2; - - setUp(() { - validator1 = _ValidatorMock(); - validator2 = _ValidatorMock(); - field1 = TextFieldCubit( - initialValue: _initialValue1, - validator: validator1, - ); - field2 = FieldCubit( - initialValue: _initialValue2, - validator: validator2, - ); - subformField = FieldCubit( - initialValue: _initialValue2, - validator: validator2, - ); - form = FormGroupCubit(); - subform = FormGroupCubit(); - }); - - tearDown(() async { - await field1.close(); - await field2.close(); - await subformField.close(); - await subform.close(); - await form.close(); - }); - - test('has correct initial state', () { - expect(form.state, const FormGroupState()); - }); - - group('getFieldValues', () { - test('when no fields are registered', () { - final values = form.getFieldValues(); - - expect(values, isEmpty); - }); - - test('when fields are registered', () { - form.registerFields([field1, field2]); - final values = form.getFieldValues(); - - expect(values, [_initialValue1, _initialValue2]); - }); - - test('when fields are registered and have new values', () { - form.registerFields([field1, field2]); - - field1.setValue('hello'); - field2.setValue(10); - - final values = form.getFieldValues(); - - expect(values, ['hello', 10]); - }); - - test('when fields are unregistered', () { - form - ..registerFields([field1, field2]) - ..registerFields([]); - - field1.setValue('hello'); - field2.setValue(10); - - final values = form.getFieldValues(); - - expect(values, isEmpty); - }); - }); - - group('wasModified', () { - blocTest( - 'is false after register', - build: () => form, - act: (cubit) => cubit.registerFields([field1, field2]), - expect: () => [ - FormGroupState( - fields: [field1, field2], - ), - ], - ); - - blocTest( - 'is true if subform was modified', - build: () => form, - setUp: () { - subform.registerFields([subformField]); - form - ..addSubform(subform) - ..registerFields([field1, field2]); - }, - act: (cubit) async { - await Future.delayed(Duration.zero); - subformField.setValue(123); - }, - expect: () => [ - FormGroupState( - wasModified: true, - fields: [field1, field2], - subforms: {subform}, - ), - ], - ); - - blocTest( - 'is true if field1 changes', - build: () => form, - setUp: () { - form.registerFields([field1, field2]); - }, - act: (cubit) async { - await Future.delayed(Duration.zero); - field1.setValue('value'); - }, - expect: () => [ - FormGroupState( - wasModified: true, - fields: [field1, field2], - ), - ], - ); - - blocTest( - 'is true if field2 changes', - build: () => form, - setUp: () { - form.registerFields([field1, field2]); - }, - act: (cubit) async { - await Future.delayed(Duration.zero); - field2.setValue(0xb0b); - }, - expect: () => [ - FormGroupState(wasModified: true, fields: [field1, field2]), - ], - ); - - blocTest( - 'does not change if field was unregistered', - build: () => form, - setUp: () { - form - ..registerFields([field1, field2]) - ..registerFields([]); - }, - act: (cubit) { - field2.setValue(0xb0b); - }, - expect: () => [], - ); - }); - - group('validate', () { - test('enables autovalidate in fields', () { - subform.registerFields([subformField]); - form - ..registerFields([field1, field2]) - ..addSubform(subform) - ..validate(); - - expect(field1.state.autovalidate, true); - expect(field2.state.autovalidate, true); - expect(subformField.state.autovalidate, true); - }); - - test('does not enable autovalidate in fields', () { - subform.registerFields([subformField]); - form - ..registerFields([field1, field2]) - ..addSubform(subform) - ..validate(enableAutovalidate: false); - - expect(field1.state.autovalidate, false); - expect(field2.state.autovalidate, false); - expect(subformField.state.autovalidate, false); - }); - - test('is valid when all are valid', () { - subform.registerFields([subformField]); - validator1.validationResult = null; - validator2.validationResult = null; - form - ..registerFields([field1, field2]) - ..addSubform(subform); - - final isValid = form.validate(); - - expect(isValid, true); - }); - - test('is not valid if a subform is not valid', () { - subform.registerFields([subformField]); - validator1.validationResult = null; - validator2.validationResult = _Error2.malformed; - form - ..registerFields([field1]) - ..addSubform(subform); - - final isValid = form.validate(); - - expect(isValid, false); - }); - - test('is not valid when any is invalid', () { - validator1.validationResult = _Error1.valueRequired; - validator2.validationResult = null; - form.registerFields([field1, field2]); - - final isValid = form.validate(); - - expect(isValid, false); - }); - - test('does not short-circuit on validation', () { - subform.registerFields([subformField]); - validator1.validationResult = _Error1.valueRequired; - validator2.validationResult = _Error2.malformed; - - form - ..registerFields([field1, field2]) - ..addSubform(subform) - ..validate(enableAutovalidate: false); - - expect(field1.state.error, _Error1.valueRequired); - expect(field2.state.error, _Error2.malformed); - expect(subformField.state.error, _Error2.malformed); - }); - - test('is valid when validationEnabled is false', () { - validator1.validationResult = _Error1.valueRequired; - form - ..registerFields([field1]) - ..setValidationEnabled(false); - - final isValid = form.validate(); - - expect(isValid, true); - }); - - test('enables autovalidate even when validationEnabled is false', () { - form - ..registerFields([field1]) - ..setValidationEnabled(false) - ..validate(); - - expect(field1.state.autovalidate, true); - }); - - test('is not valid when any of the fields is pending async validation', - () async { - validator1.validationResult = null; - final field = TextFieldCubit<_Error1>( - initialValue: _initialValue1, - asyncValidator: (_) async => validator1.validationResult, - ); - form.registerFields([field]); - - field.setValue('value'); - - final isValid = form.validate(); - expect(isValid, false); - await Future.delayed(const Duration(milliseconds: 400)); - }); - - test('is not valid when async validation of the field fails', () async { - validator1.validationResult = _Error1.valueRequired; - final field = TextFieldCubit<_Error1>( - initialValue: _initialValue1, - asyncValidator: (_) async => validator1.validationResult, - ); - form.registerFields([field]); - - field.setValue('value'); - await Future.delayed(const Duration(milliseconds: 500)); - final isValid = form.validate(); - - expect(isValid, false); - }); - - test( - 'is not valid when any of the fields in subform is pending async validation', - () { - validator2.validationResult = null; - subformField = FieldCubit( - initialValue: 0, - asyncValidator: (_) async => validator2.validationResult, - ); - subform = FormGroupCubit()..registerFields([subformField]); - form.addSubform(subform); - - subformField.setValue(10); - final isValid = form.validate(); - - expect(isValid, false); - }); - }); - - group('onValuesChangedStream', () { - test('pings on field change', () async { - form.registerFields([field1, field2]); - - unawaited(expectLater(form.onValuesChangedStream, emits(anything))); - await Future.delayed(Duration.zero); - field1.setValue('value'); - }); - - test('pings on subform field change', () async { - subform.registerFields([subformField]); - form - ..registerFields([field1, field2]) - ..addSubform(subform); - - unawaited(expectLater(form.onValuesChangedStream, emits(anything))); - await Future.delayed(Duration.zero); - subformField.setValue(123); - }); - - test('pings when new fields are registered', () async { - form.registerFields([field1]); - - unawaited(expectLater(form.onValuesChangedStream, emits(anything))); - await Future.delayed(Duration.zero); - form.registerFields([field1, field2]); - }); - - test('pings when new fields are registered for subform', () async { - form - ..registerFields([field1]) - ..addSubform(subform); - - unawaited(expectLater(form.onValuesChangedStream, emits(anything))); - await Future.delayed(Duration.zero); - subform.registerFields([subformField]); - }); - - test('does not ping on validation error', () async { - form.registerFields([field1, field2]); - - final sub = form.onValuesChangedStream.listen((event) { - fail('got an event'); - }); - - field1.setError(_Error1.valueRequired); - - await Future.delayed(const Duration(milliseconds: 10)); - - await sub.cancel(); - }); - - test('does not ping on enabling autovalidate', () async { - form.registerFields([field1, field2]); - - final sub = form.onValuesChangedStream.listen((event) { - fail('got an event'); - }); - - field1.setAutovalidate(true); - - await Future.delayed(const Duration(milliseconds: 10)); - - await sub.cancel(); - }); - - test('does not ping on same value', () async { - form.registerFields([field1, field2]); - field1.setValue('value'); - - final sub = form.onValuesChangedStream.listen((event) { - fail('got an event'); - }); - - field1.setValue('value'); - - await Future.delayed(const Duration(milliseconds: 10)); - - await sub.cancel(); - }); - }); - test('markReadOnly', () { - subform.registerFields([subformField]); - form - ..registerFields([field1, field2]) - ..addSubform(subform) - ..markReadOnly(); - - expect(field1.state.readOnly, true); - expect(field2.state.readOnly, true); - expect(subformField.state.readOnly, true); - }); - - test('clearErrors', () { - field1.setError(_Error1.valueRequired); - field2.setError(_Error2.malformed); - subformField.setError(_Error2.malformed); - - subform.registerFields([subformField]); - form - ..registerFields([field1, field2]) - ..addSubform(subform) - ..clearErrors(); - - expect(field1.state.error, null); - expect(field2.state.error, null); - expect(subformField.state.error, null); - - expect(field1.state.isValid, true); - expect(field2.state.isValid, true); - expect(subformField.state.isValid, true); - }); - - group('setAutovalidate', () { - test('to true', () { - subform.registerFields([subformField]); - form - ..registerFields([field1, field2]) - ..addSubform(subform) - ..setAutovalidate(true); - - expect(field1.state.autovalidate, true); - expect(field2.state.autovalidate, true); - expect(subformField.state.autovalidate, true); - }); - test('to false', () { - subform.registerFields([subformField]); - form - ..registerFields([field1, field2]) - ..addSubform(subform) - ..setAutovalidate(true) - ..setAutovalidate(false); - - expect(field1.state.autovalidate, false); - expect(field2.state.autovalidate, false); - expect(subformField.state.autovalidate, false); - }); - }); - - group('addSubform', () { - blocTest( - 'adds a new subform', - build: () => form, - act: (cubit) { - cubit.addSubform(subform); - }, - expect: () => [ - FormGroupState(subforms: {subform}), - ], - ); - - blocTest( - 'is noop if form was already added', - build: () => form, - setUp: () => form.addSubform(subform), - act: (cubit) { - cubit.addSubform(subform); - }, - expect: () => [], - ); - }); - - group('validateAll', () { - late FormGroupCubit form; - - setUp(() { - form = FormGroupCubit(validateAll: true); - }); - - test('validate is called on other autovalidate fields', () async { - subform.registerFields([subformField]); - form - ..registerFields([field1, field2]) - ..addSubform(subform); - field1.setAutovalidate(true); - validator1.validationResult = _Error1.valueRequired; - - field2.setValue(42); - await Future.delayed(Duration.zero); - - expect(field1.state.error, _Error1.valueRequired); - expect(field2.state.error, null); - expect(subformField.state.error, null); - }); - - test('validate is called on other autovalidate subforms', () async { - subform.registerFields([subformField]); - form - ..registerFields([field1, field2]) - ..addSubform(subform); - subformField.setAutovalidate(true); - validator2.validationResult = _Error2.malformed; - - field2.setValue(42); - await Future.delayed(Duration.zero); - - expect(field1.state.error, null); - expect(field2.state.error, null); - expect(subformField.state.error, _Error2.malformed); - }); - }); - - group('setValidationEnabled', () { - blocTest( - 'sets validationEnabled to false', - build: () => form, - seed: () => const FormGroupState(), - act: (cubit) { - cubit.setValidationEnabled(false); - }, - expect: () => [ - const FormGroupState(validationEnabled: false), - ], - ); - - blocTest( - 'sets validationEnabled to true', - build: () => form, - seed: () => const FormGroupState(validationEnabled: false), - act: (cubit) { - cubit.setValidationEnabled(true); - }, - expect: () => [ - const FormGroupState(), - ], - ); - - blocTest( - 'is noop if the same validationEnabled was already set', - build: () => form, - seed: () => const FormGroupState(), - act: (cubit) { - cubit.setValidationEnabled(true); - }, - expect: () => [], - ); - - test( - 'clears errors when validationEnabled is set to false and was true before', - () { - field1.setError(_Error1.valueRequired); - field2.setError(_Error2.malformed); - subformField.setError(_Error2.malformed); - subform.registerFields([subformField]); - - form - ..setValidationEnabled(true) - ..registerFields([field1, field2]) - ..addSubform(subform) - ..setValidationEnabled(false); - - expect(field1.state.error, null); - expect(field2.state.error, null); - expect(subformField.state.error, null); - }); - - test('does not clear errors when validationEnabled is set to true', () { - field1.setError(_Error1.valueRequired); - field2.setError(_Error2.malformed); - subformField.setError(_Error2.malformed); - subform.registerFields([subformField]); - - form - ..setValidationEnabled(false) - ..registerFields([field1, field2]) - ..addSubform(subform) - ..setValidationEnabled(true); - - expect(field1.state.error, _Error1.valueRequired); - expect(field2.state.error, _Error2.malformed); - expect(subformField.state.error, _Error2.malformed); - }); - - test( - 'does not clear errors when validationEnabled is set to false and was already false before', - () { - field1.setError(_Error1.valueRequired); - field2.setError(_Error2.malformed); - subformField.setError(_Error2.malformed); - subform.registerFields([subformField]); - - form - ..setValidationEnabled(false) - ..registerFields([field1, field2]) - ..addSubform(subform) - ..setValidationEnabled(false); - - expect(field1.state.error, _Error1.valueRequired); - expect(field2.state.error, _Error2.malformed); - expect(subformField.state.error, _Error2.malformed); - }); - }); - - group('removeSubform', () { - blocTest( - 'removes a previously added subform and disposes it', - build: () => form, - setUp: () { - form.addSubform(subform); - }, - act: (cubit) async { - await cubit.removeSubform(subform); - }, - expect: () => [ - const FormGroupState(), - ], - verify: (cubit) { - expect(subform.isClosed, true); - }, - ); - - blocTest( - 'removes a previously added subform but does not disposes it when close is false', - build: () => form, - setUp: () { - form.addSubform(subform); - }, - act: (cubit) async { - await cubit.removeSubform(subform, close: false); - }, - expect: () => [ - const FormGroupState(), - ], - verify: (cubit) { - expect(subform.isClosed, false); - }, - ); - - blocTest( - 'is noop if form was not added', - build: () => form, - act: (cubit) { - cubit.removeSubform(subform); - }, - expect: () => [], - verify: (cubit) { - expect(subform.isClosed, false); - }, - ); - }); - - test('disposes all dependencies', () async { - subform.registerFields([subformField]); - - form - ..registerFields([field1, field2]) - ..addSubform(subform); - await form.close(); - - expect(form.isDisposed, true); - expect(form.isClosed, true); - expect(field1.isClosed, true); - expect(field2.isClosed, true); - expect(subform.isClosed, true); - expect(subform.isDisposed, true); - expect(subformField.isClosed, true); - }); - - group('resetAll', () { - test('resets all fields state to initial', () { - subform.registerFields([subformField]); - form - ..registerFields([field1, field2]) - ..addSubform(subform); - - field1.setValue('value'); - field2.setValue(42); - subformField.setValue(42); - - form.resetAll(); - - expect(field1.state.value, _initialValue1); - expect(field2.state.value, _initialValue2); - expect(subformField.state.value, _initialValue2); - }); - }); - - group('validateWithAutovalidate', () { - test('validates only the fields which have set autovalidate to true', () { - subform.registerFields([subformField]); - form - ..registerFields([field1, field2]) - ..addSubform(subform); - - field1.setAutovalidate(true); - field2.setAutovalidate(false); - subformField.setAutovalidate(true); - - validator1.validationResult = _Error1.valueRequired; - validator2.validationResult = _Error2.malformed; - - form.validateWithAutovalidate(); - - expect(field1.state.error, _Error1.valueRequired); - expect(field2.state.error, null); - expect(subformField.state.error, _Error2.malformed); - }); - }); - }); -} diff --git a/test/src/utils/extensions/stream_extensions_test.dart b/test/src/utils/extensions/stream_extensions_test.dart deleted file mode 100644 index 8306b30..0000000 --- a/test/src/utils/extensions/stream_extensions_test.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:leancode_forms/src/utils/extensions/stream_extensions.dart'; - -void main() { - test( - 'distinctWithFirst starts emitting distinct values as soon as the value in the stream is different from the initialValue', - () async { - final stream = Stream.fromIterable([1, 1, 1, 1, 1, 4, 5, 1, 6, 7, 7]); - const initialValue = 1; - final distinctStream = stream.distinctWithFirst(initialValue); - - expect( - distinctStream, - emitsInOrder([4, 5, 1, 6, 7]), - ); - }); - - test( - 'distinctWithFirst does not emit any value when all are equal to the initial', - () async { - final input = Stream.fromIterable([1, 1, 1, 1, 1, 1]); - const initialValue = 1; - - final distinctStream = input.distinctWithFirst(initialValue); - - expect( - distinctStream, - emitsInOrder([]), - ); - }); -} From b0203eceaf2db61a27eaa3a2403b2d19d57af5f2 Mon Sep 17 00:00:00 2001 From: aydinguven-leancode Date: Fri, 19 Jun 2026 13:00:45 +0200 Subject: [PATCH 2/9] [LMG-404] updated readme.md and changelog entries on LMG-394 --- CHANGELOG.md | 22 + EXAMPLES.md | 409 ++++++++++++++++++ MIGRATION.md | 290 +++++++++++++ README.md | 366 ++++++++++------ example/lib/main.dart | 3 + example/lib/screens/complex_form.dart | 14 + example/lib/screens/delivery_form.dart | 12 + example/lib/screens/home_page.dart | 4 + .../lib/screens/optimized_rendering_form.dart | 262 +++++++++++ example/lib/screens/password_form.dart | 12 + example/lib/screens/quiz_form.dart | 13 + example/lib/screens/scroll_form.dart | 13 + example/lib/screens/simple_form.dart | 13 + .../widgets/form_text_field_avatar_card.dart | 101 +++++ .../widgets/form_text_field_with_banner.dart | 68 +++ .../widgets/form_text_field_with_icon.dart | 63 +++ example/lib/widgets/screen_description.dart | 43 ++ example/pubspec.lock | 42 +- 18 files changed, 1591 insertions(+), 159 deletions(-) create mode 100644 EXAMPLES.md create mode 100644 MIGRATION.md create mode 100644 example/lib/screens/optimized_rendering_form.dart create mode 100644 example/lib/widgets/form_text_field_avatar_card.dart create mode 100644 example/lib/widgets/form_text_field_with_banner.dart create mode 100644 example/lib/widgets/form_text_field_with_icon.dart create mode 100644 example/lib/widgets/screen_description.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 7443b28..022f78e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,25 @@ +## 0.2.0 + +> Upgrading from 0.1.x? See [MIGRATION.md](./MIGRATION.md) for a step-by-step guide. + +* **Breaking:** We've rebuilt the library on `ValueNotifier` / `ChangeNotifier`. That means `flutter_bloc` and `rxdart` no longer are a dependency. +* **Breaking:** Renamed core classes: + * `FieldCubit` → `FieldController` + * `TextFieldCubit` → `TextFieldController` + * `BooleanFieldCubit` → `BooleanFieldController` + * `SingleSelectFieldCubit` → `SingleSelectFieldController` + * `MultiSelectFieldCubit` → `MultiSelectFieldController` + * `FormGroupCubit` → `FormGroupController` +* `FieldBuilder` is kept. It is now a thin wrapper around `ValueListenableBuilder` instead of `BlocBuilder`. The `field:` parameter is now typed `FieldController` (previously `FieldCubit`); the builder signature is otherwise unchanged. We can use +* `ValueListenableBuilder` directly. +* **Breaking:** Lifecycle method renamed from `close()` to `dispose()` on both controllers. +* **Breaking:** `FormGroupController` exposes `onValuesChanged` and `onStatusChanged` as `Listenable`s (previously `Stream`s named `onValuesChangedStream` / `onStatusChangedStream`). +* `TextFieldController` now owns a `TextEditingController` (`field.textController`) kept in two-way sync with the field value. Widgets bind to it directly; programmatic changes (`setValue`, `reset`, `clear`) propagate to the text controller, and user input propagates back. Removes the dual-write bug class around resetting fields, set-to-initial flows, etc. +* `FieldController` gained an optional `String? name` parameter for debugging, logging, and serialization. +* `Disposable` mixin removed (lifecycle is handled by `ChangeNotifier.dispose`). +* Internal `distinctWithFirst` stream extension and `CancelableFuture` are no longer exported. +* Dropped the `equatable` dependency. `FieldState` and `FormGroupState` now manual `==` / `hashCode`. No behavioral change — value-equality semantics are identical. + ## 0.1.2 * Bumped `bloc` to `^9.0.0`. diff --git a/EXAMPLES.md b/EXAMPLES.md new file mode 100644 index 0000000..918f944 --- /dev/null +++ b/EXAMPLES.md @@ -0,0 +1,409 @@ +# Examples + +Side-by-side snippets showing the same form scenario in three flavors: + +1. **0.1.x (pre-migration)** — built on `flutter_bloc` + `rxdart` +2. **0.2.0 with `FieldBuilder`** — the recommended default; tiny wrapper around `ValueListenableBuilder` +3. **0.2.0 with `ValueListenableBuilder`** directly — for cases where you want the SDK primitive (e.g. the `child:` optimization) + +For a step-by-step migration walkthrough see [MIGRATION.md](./MIGRATION.md). + +--- + +## 0.1.x — pre-migration (BLoC-based) + +> These examples won't compile against 0.2.0. They're here so you can recognize your old code and find the matching new-shape example below. + +### 1. A simple text field with an error message + +```dart +class _MyError {} + +final field = TextFieldCubit<_MyError>( + initialValue: '', + validator: filled(_MyError()), +); + +// In the widget tree: +FieldBuilder( + field: field, + builder: (context, state) { + return TextFormField( + onChanged: field.getValueSetter(), + decoration: InputDecoration( + errorText: state.error != null ? 'Required' : null, + ), + ); + }, +); +``` + +### 2. A form with submit-time validation + +```dart +class SimpleFormCubit extends FormGroupCubit { + SimpleFormCubit() { + registerFields([firstName, email]); + } + + final firstName = TextFieldCubit( + initialValue: 'John', + validator: filled(ValidationError.empty), + ); + + final email = TextFieldCubit( + validator: filled(ValidationError.empty), + ); + + void submit() { + if (validate()) { + print('First: ${firstName.state.value}'); + print('Email: ${email.state.value}'); + } + } +} + +// Provider: +BlocProvider( + create: (_) => SimpleFormCubit(), + child: const SimpleForm(), +) + +// Inside the widget tree: +context.read().submit(); +``` + +### 3. Async email validation + +```dart +class SignupCubit extends FormGroupCubit { + SignupCubit() { + registerFields([email]); + } + + final email = TextFieldCubit( + validator: filled(ValidationError.empty), + asyncValidator: _checkEmail, + asyncValidationDebounce: const Duration(milliseconds: 500), + ); + + Future _checkEmail(String value) async { + final taken = ['taken@example.com']; + await Future.delayed(const Duration(milliseconds: 700)); + return taken.contains(value) ? ValidationError.emailTaken : null; + } +} + +// Render: +FieldBuilder( + field: context.read().email, + builder: (context, state) => TextFormField( + onChanged: context.read().email.getValueSetter(), + decoration: InputDecoration( + errorText: state.error?.toString(), + suffix: state.isValidating + ? const SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(), + ) + : null, + ), + ), +) +``` + +### 4. Cross-field validation (password / repeat-password) + +```dart +class PasswordFormCubit extends FormGroupCubit { + PasswordFormCubit() { + registerFields([password, repeatPassword]); + } + + final password = TextFieldCubit( + validator: atLeastLength(8, ValidationError.toShort), + ); + + late final repeatPassword = TextFieldCubit( + validator: (value) => + value == password.state.value ? null : ValidationError.doesNotMatch, + )..subscribeToFields([password]); +} +``` + +--- + +## 0.2.0 — using `FieldBuilder` (recommended) + +Same scenarios as above, ported to the new API. `FieldBuilder` is a thin wrapper around `ValueListenableBuilder` — it saves typing the `>` type argument, and keeps your widget code looking almost identical to the 0.1.x version. + +### 1. A simple text field with an error message + +```dart +class _MyError {} + +final field = TextFieldController<_MyError>( + initialValue: '', + validator: filled(_MyError()), +); + +// In the widget tree: +FieldBuilder( + field: field, + builder: (context, state) { + return TextFormField( + controller: field.textController, // <-- no manual wiring + decoration: InputDecoration( + errorText: state.error != null ? 'Required' : null, + ), + ); + }, +); +``` + +Note the `controller: field.textController` — `TextFieldController` owns its own `TextEditingController` now, kept in two-way sync with the field value. No `onChanged: field.setValue` needed; programmatic resets (`field.reset()`, `field.clear()`) propagate to the visible text automatically. + +### 2. A form with submit-time validation + +```dart +class SimpleFormController extends FormGroupController { + SimpleFormController() { + registerFields([firstName, email]); + } + + final firstName = TextFieldController( + initialValue: 'John', + validator: filled(ValidationError.empty), + ); + + final email = TextFieldController( + validator: filled(ValidationError.empty), + ); + + void submit() { + if (validate()) { + print('First: ${firstName.value.value}'); + print('Email: ${email.value.value}'); + } + } +} + +// Provider (using the `provider` package): +ChangeNotifierProvider( + create: (_) => SimpleFormController(), + child: const SimpleForm(), +) + +// Inside the widget tree: +context.read().submit(); +``` + +### 3. Async email validation + +```dart +class SignupController extends FormGroupController { + SignupController() { + registerFields([email]); + } + + final email = TextFieldController( + validator: filled(ValidationError.empty), + asyncValidator: _checkEmail, + asyncValidationDebounce: const Duration(milliseconds: 500), + ); + + Future _checkEmail(String value) async { + final taken = ['taken@example.com']; + await Future.delayed(const Duration(milliseconds: 700)); + return taken.contains(value) ? ValidationError.emailTaken : null; + } +} + +// Render: +FieldBuilder( + field: context.read().email, + builder: (context, state) => TextFormField( + controller: context.read().email.textController, + decoration: InputDecoration( + errorText: state.error?.toString(), + suffix: state.isValidating + ? const SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(), + ) + : null, + ), + ), +) +``` + +### 4. Cross-field validation (password / repeat-password) + +```dart +class PasswordFormController extends FormGroupController { + PasswordFormController() { + registerFields([password, repeatPassword]); + } + + final password = TextFieldController( + validator: atLeastLength(8, ValidationError.toShort), + ); + + late final repeatPassword = TextFieldController( + validator: (value) => + value == password.value.value ? null : ValidationError.doesNotMatch, + )..subscribeToFields([password]); +} +``` + +`subscribeToFields` works exactly as before — only the implementation underneath changed (no more `rxdart`). + +### 5. A reusable custom form widget + +Wrap `FieldBuilder` in a `StatelessWidget` to keep call sites tidy across a real app: + +```dart +class FormTextField extends StatelessWidget { + const FormTextField({ + super.key, + required this.field, + required this.translateError, + this.labelText, + }); + + final TextFieldController field; + final ErrorTranslator translateError; + final String? labelText; + + @override + Widget build(BuildContext context) { + return FieldBuilder( + field: field, + builder: (context, state) => TextFormField( + controller: field.textController, + decoration: InputDecoration( + labelText: labelText, + errorText: state.error != null ? translateError(state.error!) : null, + ), + ), + ); + } +} +``` + +Call site: +```dart +FormTextField( + field: controller.email, + translateError: validatorTranslator, + labelText: 'Email', +) +``` + +--- + +## 0.2.0 — using `ValueListenableBuilder` directly + +`FieldBuilder` is shorthand. When you want the SDK primitive instead, drop down to `ValueListenableBuilder`. Reasons to reach for it: + +- **The `child:` optimization** — keep an expensive subtree out of the rebuild path. +- **No extra import** beyond `flutter/widgets.dart` (`FieldBuilder` adds a dependency on `package:leancode_forms`). +- **Consistency with other notifier-based code** in your codebase. + +Trade-off: you have to type the `>` type argument explicitly. + +### 1. A simple text field — explicit form + +```dart +ValueListenableBuilder>( + valueListenable: field, + builder: (context, state, _) => TextFormField( + controller: field.textController, + decoration: InputDecoration( + errorText: state.error != null ? 'Required' : null, + ), + ), +); +``` + +Same output as the `FieldBuilder` version. Six extra characters of type argument, one extra `_` for the unused `child` slot. + +### 2. Using the `child:` optimization + +If part of the subtree is expensive but invariant in the field state, pass it as `child:` so it's built once and reused on every rebuild: + +```dart +ValueListenableBuilder>( + valueListenable: field, + child: const _ExpensiveLeadingIcon(), // built once + builder: (context, state, child) { + return Row( + children: [ + child!, // reused on every rebuild + Expanded( + child: TextFormField( + controller: field.textController, + decoration: InputDecoration( + errorText: state.error != null ? 'Required' : null, + ), + ), + ), + ], + ); + }, +); +``` + +`FieldBuilder` doesn't expose `child:` (the goal there is brevity, not knobs). If you need this optimization, `ValueListenableBuilder` is the right tool. + +### 3. Watching only a derived slice — combine with `ValueListenable` adapters + +`FieldController` is a full `ValueListenable>`. If a part of your UI cares only about, say, `state.isValidating`, you can wrap it once and listen to the derived notifier directly. (Pattern using `ValueListenableBuilder.builder` with a small selector helper.) + +```dart +ValueListenableBuilder>( + valueListenable: field, + builder: (context, state, _) { + // Only this subtree rebuilds; surrounding widgets can subscribe separately. + if (state.isValidating) { + return const LinearProgressIndicator(); + } + if (state.error != null) { + return Text('Error: ${state.error}', style: const TextStyle(color: Colors.red)); + } + return const SizedBox.shrink(); + }, +); +``` + +You could of course do the same inside a `FieldBuilder`. The point is that with `ValueListenableBuilder` you stay on SDK types end to end — useful if you're already composing with other `ValueListenable`s elsewhere in the screen (animations, scroll positions, theme observers). + +### 4. Listening outside a widget tree + +Both `FieldBuilder` and `ValueListenableBuilder` are widgets. If you need to react to changes from a non-widget context (a service, another controller, a `late final` initializer), skip both and use `addListener` directly: + +```dart +void _onChange() { + print('value is now ${field.value.value}, status ${field.value.status}'); +} + +field.addListener(_onChange); +// later: +field.removeListener(_onChange); +``` + +Same primitive `FieldBuilder` and `ValueListenableBuilder` use internally — just without the widget plumbing. + +--- + +## When to pick which + +| Situation | Use | +| --- | --- | +| Most form widgets | `FieldBuilder` | +| Migrating from 0.1.x code that already used `FieldBuilder` | `FieldBuilder` (just rename the field type) | +| Need the `child:` optimization for a static subtree | `ValueListenableBuilder` | +| Already composing with other `ValueListenable`s on the screen | `ValueListenableBuilder` | +| Reacting outside a widget tree | `field.addListener(...)` directly | + +There's no behavioral difference between `FieldBuilder` and `ValueListenableBuilder` — the first is the second wrapped in 30 lines. Pick whichever reads better at the call site. \ No newline at end of file diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..00fdd01 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,290 @@ +# Migrating from 0.1.x to 0.2.0 + +This guide is for anyone upgrading a form built on `leancode_forms` 0.1.x to 0.2.0. + +At a high level, **0.2.0 keeps the same form-building model** — `FieldX` + `FormGroup` + validators + subforms — but rebuilds the foundation underneath: + +- The library no longer depends on `flutter_bloc` or `rxdart`. Everything runs on `ValueNotifier` / `ChangeNotifier` from the Flutter SDK. +- Every `*Cubit` class is renamed to `*Controller`. The public API on each is otherwise nearly identical. +- Lifecycle is now synchronous (`dispose()` instead of `Future close()`). +- `TextFieldController` now owns a `TextEditingController` and keeps it bidirectionally synced with the field value — killing a class of widget-level bugs. + +Most call sites need only a class rename and an import cleanup. + +--- + +## 1. Rename reference + +| 0.1.x | 0.2.0 | +| --- | --- | +| `FieldCubit` | `FieldController` | +| `TextFieldCubit` | `TextFieldController` | +| `BooleanFieldCubit` | `BooleanFieldController` | +| `SingleSelectFieldCubit` | `SingleSelectFieldController` | +| `MultiSelectFieldCubit` | `MultiSelectFieldController` | +| `FormGroupCubit` | `FormGroupController` | +| `FieldBuilder` | **Kept** — now a wrapper around `ValueListenableBuilder`; `field:` param re-typed to `FieldController` | +| `form.onValuesChangedStream` (`Stream`) | `form.onValuesChanged` (`Listenable`) | +| `form.onStatusChangedStream` (`Stream`) | `form.onStatusChanged` (`Listenable`) | +| `Future close()` | `void dispose()` | + +Dependency changes for your `pubspec.yaml`: + +- **Drop:** `flutter_bloc`, `rxdart` +- (Example/test only — not the library itself.) **Drop:** `bloc_test`, `bloc_presentation`, `flutter_hooks` if you used them; **add:** `provider` if you want a drop-in replacement for `BlocProvider` / `context.read` + +--- + +## 2. Migrating your form classes + +For most forms, the change is a search-and-replace of class names. + +**0.1.x:** +```dart +class SimpleFormCubit extends FormGroupCubit { + SimpleFormCubit() { + registerFields([firstName, lastName, email]); + } + + final firstName = TextFieldCubit( + initialValue: 'John', + validator: filled(ValidationError.empty), + ); + final lastName = TextFieldCubit(initialValue: 'Foo'); + final email = TextFieldCubit( + validator: filled(ValidationError.empty), + asyncValidator: _onEmailChanged, + ); + + Future _onEmailChanged(String value) async { /* ... */ } +} +``` + +**0.2.0:** +```dart +class SimpleFormController extends FormGroupController { + SimpleFormController() { + registerFields([firstName, lastName, email]); + } + + final firstName = TextFieldController( + initialValue: 'John', + validator: filled(ValidationError.empty), + ); + final lastName = TextFieldController(initialValue: 'Foo'); + final email = TextFieldController( + validator: filled(ValidationError.empty), + asyncValidator: _onEmailChanged, + ); + + Future _onEmailChanged(String value) async { /* ... */ } +} +``` + +Reading the current state still works either way: + +```dart +// 0.1.x and still works in 0.2.0: +controller.firstName.state.value; + +// Idiomatic 0.2.0: +controller.firstName.value.value; +``` + +We kept a `.state` getter alias on both `FieldController` and `FormGroupController` so existing call sites can stay untouched if you'd rather not touch them. + +> **Why the rebuild?** `ValueNotifier` is a synchronous state container: when you assign a new value, listeners run in the same call stack — no broadcast stream, no microtask hop. Same observable behavior in real apps, simpler mental model, no `rxdart` plumbing, and the whole library now sits on Flutter SDK primitives only. + +--- + +## 3. Migrating your widgets — `FieldBuilder` stays + +`FieldBuilder` is still here. We re-implemented it as a thin wrapper around the SDK's `ValueListenableBuilder` instead of `flutter_bloc`'s `BlocBuilder`. The constructor and builder signature are unchanged; only the `field:` parameter's static type went from `FieldCubit` to `FieldController` — and your specialized field types (`TextFieldController`, etc.) automatically satisfy that. + +```dart +// Identical in 0.1.x and 0.2.0: +FieldBuilder( + field: controller.email, + builder: (context, state) => Text(state.value), +) +``` + +If you want the SDK's `child:` optimization for a static subtree, drop down to `ValueListenableBuilder`: + +```dart +ValueListenableBuilder>( + valueListenable: controller.email, + child: const ExpensiveStaticIcon(), + builder: (context, state, child) => Row( + children: [child!, Text(state.value)], + ), +) +``` + +> **Why a wrapper, not removal?** Strictly speaking, `FieldBuilder` is now redundant — the SDK ships an equivalent. But keeping the name lets your existing widget code compile after a class rename and saves typing the `>` argument at every call site. Pure DX sugar. + +--- + +## 4. Lifecycle: `close()` → `dispose()` + +**0.1.x:** +```dart +@override +Future close() async { + await field.close(); + return super.close(); +} +``` + +**0.2.0:** +```dart +@override +void dispose() { + field.dispose(); + super.dispose(); +} +``` + +> **Why synchronous?** `Cubit.close()` returns a `Future` because it closes an underlying broadcast `StreamController`. `ChangeNotifier.dispose()` is synchronous — it just nulls out the listener list. No `await` needed in consumer code. + +### Watch out — `dispose()` is not idempotent + +Calling `dispose()` twice on a `ValueNotifier`/`ChangeNotifier` throws `'A ValueNotifier was used after being disposed'` in debug mode. The old `Cubit.close()` was idempotent and tolerated re-close silently — so 0.1.x code that disposed a field by hand *and* let `FormGroupCubit.close()` dispose it again worked by accident. + +In 0.2.0: + +- Let `FormGroupController` own field disposal via `registerFields(...)`. Don't call `dispose()` on owned fields yourself. +- If you registered the same field twice (multi-`registerFields` patterns), it's still safe — the library tracks ownership with a `Set` and disposes each owned controller exactly once. + +--- + +## 5. `TextFieldController` now owns its `TextEditingController` + +In 0.1.x, every consumer widget had to own its own `TextEditingController`, seed it from `field.state.value`, and wire `onChanged: field.setValue` back the other way. That setup silently dropped any programmatic changes (`field.reset()`, `field.setValue('foo')`, cross-field updates) — the on-screen text didn't update because the widget's controller had already been seeded once. + +**0.1.x widget code:** +```dart +final c = useTextEditingController(text: field.state.value); +TextField( + controller: c, + onChanged: field.setValue, +); +``` + +**0.2.0 widget code:** +```dart +TextField( + controller: field.textController, +); +``` + +That's it. The widget no longer needs `useTextEditingController`, `useEffect`, or an `onChanged` callback. `TextFieldController` allocates the `TextEditingController` internally, sets up bidirectional listeners on construction, and disposes everything in `dispose()`. + +To migrate a custom text widget: + +1. Remove the `useTextEditingController` (or any manually-allocated `TextEditingController`). +2. Remove `initialValue: state.value` and `onChanged: field.setValue` plumbing. +3. Pass `controller: field.textController` to the underlying `TextField`/`TextFormField`. + +> **Why this changed.** `TextEditingController` is itself a `ValueNotifier`. Letting both the widget and the field own a copy of the same string was the root cause of cursor jumps and "set-to-initial doesn't reset the text" bugs. Now there's one source of truth, owned at the field level where it belongs. + +--- + +## 6. `FormGroupController` exposes `Listenable`s, not `Stream`s + +**0.1.x:** +```dart +final sub = form.onValuesChangedStream.listen((_) => doSomething()); +// ... +await sub.cancel(); +``` + +**0.2.0:** +```dart +form.onValuesChanged.addListener(doSomething); +// ... +form.onValuesChanged.removeListener(doSomething); +``` + +Same swap for `onStatusChanged` (was `onStatusChangedStream`). + +If you used `StreamSubscription` cancellation in `dispose`, replace it with `removeListener` paired to the original named callback. That means you can't use an inline closure for the listener — name it. + +> **Why this changed.** The dual broadcast `StreamController`s in `FormGroupCubit` had to be re-merged with `Rx.merge` every time fields or subforms changed. We replaced them with per-field listeners plus closure-captured `lastValue`/`lastStatus` caches: pull-style reads, no rxdart, easier to read in source. The trade-off is the public API switches from `Stream<…>` to `Listenable` — close enough to be a mechanical change at most call sites. + +--- + +## 7. What didn't change + +Everything not listed above. Quick reassurance checklist — if your call site only uses these surfaces, the migration is "rename `*Cubit` to `*Controller`" and you're done: + +- **Validators:** `Validator` / `AsyncValidator` typedefs unchanged. All ready-to-use validators (`filled`, `atLeastLength`, `notLongerThan`, `notNull`, `notEmpty`, `or`, `and`, `&`, `|`, etc.) have the same names and signatures. +- **Field methods:** `setValue`, `validate`, `setAutovalidate`, `markReadOnly`, `unmarkReadOnly`, `clearErrors`, `reset`, `setError`, `getValueSetter`, `subscribeToFields` — all unchanged on `FieldController`. +- **Form-group methods:** `registerFields`, `addSubform`, `removeSubform`, `setValidationEnabled`, `validateWithAutovalidate`, `resetAll`, `markReadOnly`, `clearErrors` — all unchanged on `FormGroupController`. +- **Async validation flow:** `pending` → `validating` → `valid`/`invalid` sequence, debounce timer, cancel-on-new-value semantics — bit-for-bit identical. +- **`wasModified` / `validating` aggregate flags:** still tracked the same way. `DeepCollectionEquality` is still the baseline. +- **`subscribeToFields`:** still filters out status-only changes. Implementation switched from `Rx.combineLatest + distinct` to a manual `lastValues` cache; observable behavior is the same. + +--- + +## 8. Migrating example/test code that used `flutter_bloc` directly + +If your app uses `flutter_bloc`'s `BlocProvider` to inject the form controller, you have two options: + +### Option A — switch to `provider` + +`ChangeNotifierProvider` from the `provider` package is a near-drop-in replacement, and `context.read` / `context.watch` / `context.select` keep working the same way: + +```dart +// 0.1.x +BlocProvider( + create: (_) => SimpleFormCubit(), + child: const SimpleForm(), +) + +// 0.2.0 with `provider` +ChangeNotifierProvider( + create: (_) => SimpleFormController(), + child: const SimpleForm(), +) +``` + +### Option B — plain Flutter, no DI dep + +Hoist the controller into a `StatefulWidget` and pass it down through constructor args. Slightly more boilerplate per screen, zero deps. + +```dart +class SimpleFormScreen extends StatefulWidget { + const SimpleFormScreen({super.key}); + @override + State createState() => _SimpleFormScreenState(); +} + +class _SimpleFormScreenState extends State { + late final controller = SimpleFormController(); + @override + void dispose() { controller.dispose(); super.dispose(); } + @override + Widget build(_) => SimpleForm(controller: controller); +} +``` + +### Other example-side deps + +- **`bloc_test`** has no direct replacement. Rewrite tests as plain `test(...)` blocks. Capture emissions with `addListener` if you need to assert sequences: + ```dart + final emissions = >[]; + field.addListener(() => emissions.add(field.value)); + field.setValue(10); + expect(emissions, [const FieldState(value: 10)]); + ``` +- **`bloc_presentation`** (for "submit failed → emit a UI event") has no direct replacement. The example replaces it with a plain `Stream` exposed by the controller (see `example/lib/screens/scroll_form.dart`). +- **`flutter_hooks`** only matters if you used it for `useTextEditingController` / `useFocusNode` — both are unnecessary once `TextFieldController` owns the text controller. Drop if it was the only reason you had it. + +--- + +## Reference + +- The new architecture is documented in [`README.md`](./README.md). +- The full breaking-change summary is in [`CHANGELOG.md`](./CHANGELOG.md) under the 0.2.0 entry. +- The `example/` app demonstrates every pattern in the new shape. \ No newline at end of file diff --git a/README.md b/README.md index 4229658..c5fc64c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -A package for creating and managing form based on BLoC. +A package for creating and managing forms with `ValueNotifier` / `ChangeNotifier`. ## Getting Started @@ -8,47 +8,48 @@ A package for creating and managing form based on BLoC. flutter pub add leancode_forms ``` -## Usage - +# Usage +Let's go through the basics of the package while explaining some of the key terms/concepts. + ## Creating a Simple Form -To create a simple form, first, you need to define a `FormGroupCubit` that will manage its fields. The easiest way to do this is by extending the `FormGroupCubit` class. +To create a simple form, you need to define a `FormGroupController` that will manage its fields. +Common way to do this is by extending the `FormGroupController` class. + ```dart -class SimpleFormCubit extends FormGroupCubit { - SimpleFormCubit(); +class SimpleFormController extends FormGroupController { + SimpleFormController(); } ``` -Next, inside the form cubit, you should define the form fields. You can either use one of the [predefined field cubits](#predefined-field-cubits) or [create custom `FieldCubit`](#creating-custom-fieldcubit). In simple form, we will use `TextFieldCubit` which is a `FieldCubit` implementation for text inputs. +Next, inside the form controller, you define the form fields. You can either use one of the [predefined field controllers](#predefined-field-controllers) or [create a custom `FieldController`](#creating-custom-fieldcontroller). In this simple form, we use `TextFieldController` — the `FieldController` specialization for text inputs. ```dart -class SimpleFormCubit extends FormGroupCubit { - SimpleFormCubit(); +class SimpleFormController extends FormGroupController { + SimpleFormController(); - final firstName = TextFieldCubit(); - - final lastName = TextFieldCubit(); + final firstName = TextFieldController(); + final lastName = TextFieldController(); } ``` -**Important:** To make FormGroupCubit manage the defined fields, you need to register them by calling the `registerFields()` method. This also ensures that the field cubits will be disposed together with the form cubit. +**Important:** To make the form manage the defined fields, you should register them via `registerFields()`. The form takes ownership and disposes them when the form itself is disposed. ```dart -class SimpleFormCubit extends FormGroupCubit { - SimpleFormCubit() { +class SimpleFormController extends FormGroupController { + SimpleFormController() { registerFields([ firstName, lastName, ]); } - final firstName = TextFieldCubit(); - - final lastName = TextFieldCubit(); + final firstName = TextFieldController(); + final lastName = TextFieldController(); } ``` -You can provide the cubit created in this way in the same manner as any other cubit. +You can provide the controller through any DI mechanism. With the `provider` package: ```dart class SimpleForm extends StatelessWidget { @@ -56,82 +57,158 @@ class SimpleForm extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider( - create: (context) => SimpleFormCubit(), - child: /*FORM WIDGETS*/, + return ChangeNotifierProvider( + create: (_) => SimpleFormController(), + child: /* Here the form fields */, ); } } ``` -### Creating a Widgets for Defined Fields -The simplest way to create a form field widget is to wrap a single widget (i.e. `FormTextField`) with `FieldBuilder`. -`FieldBuilder` is a widget that takes two arguments: -- **`field`** - instance of a `FieldCubit` that `FieldBuilder` should listen to, -- **`builder`** - a callback function that defines how to build the child widget based on the `FieldState`. +### Creating widgets for defined fields + +Every `FieldController` is a wrapper around the `ValueNotifier>`, so you can render one with the SDK's `ValueListenableBuilder`: + +```dart +final firstName = context.read().firstName; + +ValueListenableBuilder>( + valueListenable: firstName, + builder: (context, state, _) { + return TextFormField( + controller: firstName.textController, + decoration: InputDecoration( + errorText: state.error != null ? translate(state.error!) : null, + ), + ); + }, +); +``` + +Or use the shorthand `FieldBuilder` from this package. It is the same thing, but saves typing the `>` type argument: ```dart -final firstNameFieldCubit = context.read().firstName; -FieldBuilder( - field: firstNameFieldCubit, +FieldBuilder( + field: firstName, builder: (context, state) { return TextFormField( - onChanged: firstNameFieldCubit.getValueSetter(), + controller: firstName.textController, + decoration: InputDecoration( + errorText: state.error != null ? translate(state.error!) : null, + ), ); }, ); ``` +`FieldBuilder` is a thin wrapper around `ValueListenableBuilder`. Of course you can use `ValueListenableBuilder` directly when you need the SDK's `child:` optimization for a static subtree. that means, a part of your widget tree that doesn't depend on the field state (e.g. an expensive icon) and shouldn't be rebuilt on every change. `ValueListenableBuilder`'s `child:` parameter lets you build that piece once and reuse the same instance across every rebuild. + +```dart +ValueListenableBuilder>( + valueListenable: firstName, + child: const Icon(Icons.email), // built once + builder: (context, state, child) => Row( + children: [ + child!, // reused on every rebuild + const SizedBox(width: 8), + Expanded( + child: TextFormField( + controller: firstName.textController, + decoration: InputDecoration( + errorText: state.error != null ? translate(state.error!) : null, + ), + ), + ), + ], + ), +); +``` + +See `example/lib/widgets/form_text_field_with_icon.dart` for the widget, and `example/lib/screens/optimized_rendering_form.dart` for a runnable screen that uses it (accessible from the example app's home page under "Optimized Rendering"). + +`TextFieldController` owns its own `TextEditingController` (`field.textController`) and keeps it in two-way sync with the field value — user input flows into the field state, and programmatic changes (`setValue`, `reset`, `clear`) flow into the text controller. + +### How the field modifications flow + +Every `FieldController` is a `ValueListenable>`. Widgets subscribe to it directly (via `FieldBuilder` or `ValueListenableBuilder`), and only the subscribed subtree rebuilds when the field changes — the parent form widget doesn't need to participate. + +The chain for a single keystroke in a text field: + +``` +user types 'h' + ↓ +TextFormField writes 'h' into field.textController [Flutter SDK] + ↓ +field.textController.notifyListeners() + ↓ +TextFieldController._onTextControllerChanged [internal sync] + ↓ calls setValue('h') +field.value = FieldState(value: 'h', ...) + ↓ +field.notifyListeners() + ↓ + ├── TextFieldController._onFieldChanged [internal sync — noop here] + └── ValueListenableBuilder's listener [UI rebuild] + ↓ + only that one FormTextField's subtree rebuilds +``` + +Two layers of listeners are alive at all times on a `TextFieldController`: + +- **Internal sync layer.** Two listeners set up in `TextFieldController`'s constructor: one keeps the field state in sync with the text buffer (`_onTextControllerChanged`), the other keeps the text buffer in sync with the field state (`_onFieldChanged`). This is how `field.reset()` actually updates the visible text, and how cross-field updates from `subscribeToFields` propagate to the UI. +- **Widget subscription layer.** Each `ValueListenableBuilder` / `FieldBuilder` in the widget tree subscribes independently to the field it cares about. When the field notifies, only that subtree rebuilds — not the rest of the form. + +Practical implication for parents: use `context.read()` to get the controller handle (no subscription, no parent rebuilds), then let each field widget subscribe to its own field on its own. The parent does not need to rebuild on field changes — the leaf widgets do that on their own, granularly. + +For form-level state (e.g. a "saving..." indicator that depends on `controller.value.validating`), use `context.select((c) => c.value.validating)` — that subscribes only to changes in the selected slice, not to every field change in the form. + +### Validating simple form fields + +Pass a `Validator` to any `FieldController`. A validator takes a value and returns an error (any type you want), or `null` if the value is valid. -### Validating Simple Form Fields -You can provide a validator function to each `FieldCubit`. -`Validator` is defined as a function which takes a value of a field and returns an error of any type you want. ```dart typedef Validator = E? Function(T); ``` -There is a set of [ready-to-use validators](#ready-to-use-validators) but you can simply create your own validator. Let's add a validators to our simple form: + +There is a set of [ready-to-use validators](#ready-to-use-validators), or you can write your own: + ```dart -class SimpleFormCubit extends FormGroupCubit { - SimpleFormCubit() { +class SimpleFormController extends FormGroupController { + SimpleFormController() { registerFields([ firstName, lastName, ]); } - final firstName = TextFieldCubit( + final firstName = TextFieldController( validator: (value) { if (value.isEmpty) { return 'First name cannot be empty'; } - } + return null; + }, ); - final lastName = TextFieldCubit( + final lastName = TextFieldController( validator: (value) { if (value.isEmpty) { return 'Last name cannot be empty'; } - } + return null; + }, ); } ``` -To run the validation, you have to call `validate()` method on the field. -Validation can also be triggered automatically when value of the field changes. In order to achieve such behavior you need to set `autovalidate` to `true`. -To validate whole simple form you can call `validate()` method on the form cubit. It will iterate through all the fields and return false if any of the form fields is not valid. +Call `validate()` on a field to run its sync validator. Set `autovalidate` to `true` to run the validator on every value change. -```dart -class SimpleFormCubit extends FormGroupCubit { - SimpleFormCubit() { - registerFields([ - firstName, - lastName, - ]); - } +To validate the whole form, call `validate()` on the form controller. It iterates through every field (and every subform) and returns `false` if any of them is invalid. - /*FORM FIELDS*/ +```dart +class SimpleFormController extends FormGroupController { + /* fields */ - void validateForm() { + void submit() { if (validate()) { print('Form is valid'); } else { @@ -141,154 +218,167 @@ class SimpleFormCubit extends FormGroupCubit { } ``` -## Ready-To-Use Validators -There is a set of validators which you can use: - - `boundedNonNegativeInteger` - validates if a string represents a non-negative integer that is less than or equal to a specified upper bound, - - `positiveInteger` - validates if a string represents a positive integer (greater than 0), - - `nonNegativeInteger` - validates if a string represents a non-negative integer (greater than or equal to 0), - - `positiveDecimal` - validates if a string represents a positive decimal number (greater than 0), - - `nonNegativeDecimal` - validates if a string represents a non-negative decimal number (greater than or equal to 0), - - `exactly` - validates if a string is exactly equal to a specified string, - - `filled` - rejects null and empty strings (including whitespace-only strings), - - `notLongerThan` - rejects strings longer than a specified maximum length, - - `atLeastLength` - rejects strings shorter than a specified minimum length, - - `notNull` - rejects null values, - - `notEmpty` - rejects null and empty lists, - - `nothing` - matches empty strings and returns an error message if the string is not empty, - - `or` - allows you to combine multiple validators using logical OR. If at least one of the validators accepts the input, it returns null (no error), - - `and` - allows you to combine multiple validators using logical AND. If all of the validators accept the input, it returns null (no error). +## Ready-to-use validators + +There is a set of validators ready to use: + +- `boundedNonNegativeInteger` — validates if a string represents a non-negative integer that is less than or equal to a specified upper bound, +- `positiveInteger` — validates if a string represents a positive integer (greater than 0), +- `nonNegativeInteger` — validates if a string represents a non-negative integer (greater than or equal to 0), +- `positiveDecimal` — validates if a string represents a positive decimal number (greater than 0), +- `nonNegativeDecimal` — validates if a string represents a non-negative decimal number (greater than or equal to 0), +- `exactly` — validates if a string is exactly equal to a specified string, +- `filled` — rejects null and empty strings (including whitespace-only strings), +- `notLongerThan` — rejects strings longer than a specified maximum length, +- `atLeastLength` — rejects strings shorter than a specified minimum length, +- `notNull` — rejects null values, +- `notEmpty` — rejects null and empty lists, +- `nothing` — matches empty strings and returns an error message if the string is not empty, +- `or` — combines multiple validators with logical OR; passes if at least one accepts, +- `and` — combines multiple validators with logical AND; passes only if all accept. -Additionally, there are extension methods (`&` and `|`) for combining validators with logical AND and OR operations, respectively. +There are also `&` and `|` extension methods for combining validators with logical AND and OR, respectively. -## Async Validators -If you want to validate the field using asynchronous function, you can do it by passing `asyncValidator` to a `FieldCubit`. Async validator is an equivalent of basic validator but returns a `Future` that resolves to an error. Async validator does not run when you call `validate()`. +## Async validators -### Validators Order -If you pass both `validator` and `asyncValidator` to `FieldCubit`, async will be invoked only if basic validator will not return any error. +To validate a field with an asynchronous function, pass an `asyncValidator` to a `FieldController`. An async validator is the same shape as a sync one but returns a `Future`. Async validators do **not** run when you call `validate()`. -### Debouncing Async Validator -If you set `autovalidate` to `true`, async validator will be triggered every time value of the field changes. To prevent excessive calls to the async validator while a user is typing or interacting with the form field, the `asyncValidationDebounce` is used. +### Validator order -### Field State During Async Validation -When async validation is triggered, the field's state is updated to indicate that it is in the "pending" status using the `FieldStatus.pending` value. While async validation is in progress, the `FieldCubit` sets the field's status to "validating" using the `FieldStatus.validating` value. Once async validation completes (whether successful or with an error), the field state is updated accordingly. -If you call `validate()` function on a field which state is "validating" or "pending" at the moment it will return `false`. +If you pass both `validator` and `asyncValidator`, the async one only runs if the sync validator passes (returns null). -If you want to see an example of a form with async validation take a look at `SimpleFormScreen` in example. +### Debouncing the async validator -## Validation based on value of another field +When `autovalidate` is true, the async validator runs every time the value changes. The `asyncValidationDebounce` (default 300ms) prevents excessive calls while a user is typing. -Sometimes you want to validate one field based on the value of another field (e.g., the 'password' field and the 'confirm password' field). To facilitate the implementation of such a case, you can use the `subscribeToFields` method of `FieldCubit`. +### Field state during async validation + +When async validation is triggered, the field's status transitions: `pending` → `validating` → `valid` / `invalid`. `FieldStatus.pending` covers the debounce window; `FieldStatus.validating` covers the in-flight future. If you call `validate()` on a field whose status is `pending` or `validating`, it returns `false`. + +For an example of a form with async validation, see `SimpleFormScreen` in the example app. + +## Validation based on another field's value + +Sometimes a field's validity depends on another field's value (e.g. "password" vs. "confirm password"). Use `subscribeToFields` on a `FieldController`: ```dart -class PasswordFormCubit extends FormGroupCubit { - PasswordFormCubit() { +class PasswordFormController extends FormGroupController { + PasswordFormController() { registerFields([ password, repeatPassword, ]); } - final password = TextFieldCubit( + final password = TextFieldController( validator: atLeastLength(8, 'Password is too short'), ); - late final repeatPassword = TextFieldCubit( - validator: exactly(password.state.value, 'Passwords do not match'), + late final repeatPassword = TextFieldController( + validator: (value) => value == password.value.value + ? null + : 'Passwords do not match', )..subscribeToFields([password]); } ``` -Every time the value of the `password` field changes, it will trigger the validator of the `repeatPassword` field. +Every time the value of the `password` field changes, the `repeatPassword` validator runs (provided `autovalidate` is on). Status changes on the observed fields (e.g. async-validating) are filtered out — only genuine value changes trigger revalidation. -If you want to see a fully functional form utilizing `subscribeToFields`, take a look at the `PasswordFormScreen` in the example folder. +For a fully working example, see `PasswordFormScreen` in the example app. -## `FieldCubit` +## `FieldController` -### Predefined field cubits +### Predefined field controllers -The package contains a collection of field cubits useful for implementing commonly occurring form fields. +The package ships with controllers covering the most common field shapes: -- `TextFieldCubit` - specialization of `FieldCubit` for a `String` value, -- `BooleanFieldCubit` - specialization of `FieldCubit` for a `bool` value, -- `SingleSelectFieldCubit` - specialization of `FieldCubit` for a single choice of value from list of options, -- `MultiSelectFieldCubit` - specialization of `FieldCubit` for a multiple choice of values from list of options. +- `TextFieldController` — specialization for a `String` value; owns a `TextEditingController`, +- `BooleanFieldController` — specialization for a `bool` value, +- `SingleSelectFieldController` — specialization for a single choice of value from a list of options, +- `MultiSelectFieldController` — specialization for multiple choices from a list of options. -`TextFieldCubit`, `SingleSelectFieldCubit` and `MultiSelectFieldCubit` contain the `clear()` method that resets the value of the field to the initial value by calling `reset()`. You can also call `reset()` as it is defined in the `FieldCubit` class. +`TextFieldController`, `SingleSelectFieldController` and `MultiSelectFieldController` each expose a `clear()` method that resets the value to the initial one by calling `reset()`. `reset()` is also available on the base `FieldController`. -### Creating custom `FieldCubit` +### Creating a custom `FieldController` -If none of the existing `FieldCubit` implementations meet your requirements, you can create your own. Simply create a class that extends `FieldCubit`. Inside such cubit, you can add any method or a field. +If none of the existing specializations fit, extend `FieldController` directly: ```dart -class IntegerFieldCubit extends FieldCubit { - IntegerFieldCubit({ +class IntegerFieldController extends FieldController { + IntegerFieldController({ super.initialValue = 0, super.validator, super.asyncValidator, super.asyncValidationDebounce, + super.name, }); - bool get isNegative => state.value.isNegative; + bool get isNegative => value.value.isNegative; - void negate() => setValue(-state.value); + void negate() => setValue(-value.value); } ``` -## Creating form field widget +## Creating a form field widget -When you create a UI for your form, you can define widget like it is shown in [Simple Form Example](#creating-a-widgets-for-defined-fields). However, this approach can lead to a lot of boilerplate code, especially when one form widget is used multiple times. In such cases, it's best to create a custom widget by extending `FieldBuilder`. +For a one-off field you can just wrap it in `ValueListenableBuilder` inline. When you reuse the same field shape across the app, extract it into a `StatelessWidget`: ```dart -class FormTextField extends FieldBuilder { - FormTextField({ +class FormTextField extends StatelessWidget { + const FormTextField({ super.key, - required TextFieldCubit super.field, - required ErrorTranslator errorTranslator, - ValueChanged? onFieldSubmitted, - String? labelText, - String? hintText, - }) : super( - builder: (context, state) => TextFormField( - onChanged: field.getValueSetter(), - onFieldSubmitted: onFieldSubmitted, - decoration: InputDecoration( - labelText: labelText, - hintText: hintText, - errorText: - state.error != null ? errorTranslator(state.error!) : null, - ), - ), - ); + required this.field, + required this.translateError, + this.labelText, + this.hintText, + }); + + final TextFieldController field; + final ErrorTranslator translateError; + final String? labelText; + final String? hintText; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: field, + builder: (context, state, _) => TextFormField( + controller: field.textController, + decoration: InputDecoration( + labelText: labelText, + hintText: hintText, + errorText: state.error != null ? translateError(state.error!) : null, + ), + ), + ); + } } ``` ## Subforms -It happens that a created form contains a subform that is dynamically added to the page, affecting the validation result of the entire form. `leancode_forms` allows you to manage fields of such a form. `FormGroupCubit` includes `addSubform` method that enable you to add another `FormGroupCubit` as a subform to the base form. Added subform fields will be taken into account when methods which affects all fields will be invoked (such as `validate`, `markReadOnly` `setValidationEnabled`). This can also prove useful when you're creating a form with a large number of fields, resulting in a FormGroupCubit having a high number of LOC (Lines of Code). Dividing it into smaller subforms can improve code readability. +When a form contains a subform that's dynamically added to the page and affects the overall validation result, `leancode_forms` lets you nest forms. `FormGroupController.addSubform` adds another `FormGroupController` as a child; its fields participate in `validate`, `markReadOnly`, `setValidationEnabled`, and the other broadcast operations. This is also a good way to split a large form into smaller, more readable pieces. ```dart -class BaseFormCubit extends FormGroupCubit { - BaseFormCubit() { - registerFields([ - field, - ]); +class BaseFormController extends FormGroupController { + BaseFormController() { + registerFields([field]); } - final field = TextFieldCubit(); - - final subform = SubformCubit(); + final field = TextFieldController(); + final subform = SubformController(); - // Adds subform to the base form + /// Adds the subform to the base form. void extendForm() { addSubform(subform); } } -class SubformCubit extends FormGroupCubit { - SubformCubit() { +class SubformController extends FormGroupController { + SubformController() { registerFields([subformField]); } - final subformField = TextFieldCubit(); + final subformField = TextFieldController(); } ``` diff --git a/example/lib/main.dart b/example/lib/main.dart index 8927b98..490af96 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:leancode_forms_example/screens/complex_form.dart'; import 'package:leancode_forms_example/screens/delivery_form.dart'; import 'package:leancode_forms_example/screens/home_page.dart'; +import 'package:leancode_forms_example/screens/optimized_rendering_form.dart'; import 'package:leancode_forms_example/screens/password_form.dart'; import 'package:leancode_forms_example/screens/quiz_form.dart'; import 'package:leancode_forms_example/screens/scroll_form.dart'; @@ -19,6 +20,7 @@ class Routes { static const quiz = '/quiz'; static const complex = '/complex'; static const scroll = '/scroll'; + static const optimized = '/optimized'; } enum ValidationError { @@ -75,6 +77,7 @@ class MainApp extends StatelessWidget { Routes.quiz: (_) => const QuizFormScreen(), Routes.complex: (_) => const ComplexFormScreen(), Routes.scroll: (_) => const ScrollFormScreen(), + Routes.optimized: (_) => const OptimizedRenderingFormScreen(), }, ); } diff --git a/example/lib/screens/complex_form.dart b/example/lib/screens/complex_form.dart index f76b379..08b070a 100644 --- a/example/lib/screens/complex_form.dart +++ b/example/lib/screens/complex_form.dart @@ -6,6 +6,7 @@ import 'package:leancode_forms_example/main.dart'; import 'package:leancode_forms_example/screens/form_page.dart'; import 'package:leancode_forms_example/widgets/form_dropdown_field.dart'; import 'package:leancode_forms_example/widgets/form_text_field.dart'; +import 'package:leancode_forms_example/widgets/screen_description.dart'; import 'package:provider/provider.dart'; /// This is an example of a simple form with two fields. @@ -33,6 +34,19 @@ class ComplexForm extends StatelessWidget { child: SingleChildScrollView( child: Column( children: [ + ScreenDescription([ + bold('Subform switching. '), + plain('Choosing a type swaps the active subform (human / dog). ' + 'The parent uses a '), + bold('debounced listener'), + plain(' on the dropdown field to call '), + code('addSubform'), + plain(' or '), + code('removeSubform'), + plain('. Only the '), + bold('active'), + plain(' subform participates in validation.'), + ]), FormDropdownField( field: controller.type, labelBuilder: (value) => value?.name ?? 'Select subform type', diff --git a/example/lib/screens/delivery_form.dart b/example/lib/screens/delivery_form.dart index 10a7649..8fe4e00 100644 --- a/example/lib/screens/delivery_form.dart +++ b/example/lib/screens/delivery_form.dart @@ -4,6 +4,7 @@ import 'package:leancode_forms_example/main.dart'; import 'package:leancode_forms_example/screens/form_page.dart'; import 'package:leancode_forms_example/widgets/form_dropdown_field.dart'; import 'package:leancode_forms_example/widgets/form_text_field.dart'; +import 'package:leancode_forms_example/widgets/screen_description.dart'; import 'package:provider/provider.dart'; /// This is an example of a form with dynamically added subforms. @@ -31,6 +32,17 @@ class DeliveryListForm extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + ScreenDescription([ + bold('Dynamic subforms. '), + plain('Each consumer is its own '), + code('FormGroupController'), + plain(' added as a subform to the parent. The parent\'s '), + code('validate()'), + plain(' recursively validates every consumer; disposing the ' + 'parent '), + bold('cascades'), + plain(' to every subform.'), + ]), ...controller.deliveryList.map( (e) => ConsumerSubform( key: ValueKey(e.hashCode), diff --git a/example/lib/screens/home_page.dart b/example/lib/screens/home_page.dart index 1b4dc95..8f36825 100644 --- a/example/lib/screens/home_page.dart +++ b/example/lib/screens/home_page.dart @@ -37,6 +37,10 @@ class HomePage extends StatelessWidget { onPressed: () => Navigator.of(context).pushNamed(Routes.scroll), child: const Text('Scroll Form'), ), + ElevatedButton( + onPressed: () => Navigator.of(context).pushNamed(Routes.optimized), + child: const Text('Optimized Rendering'), + ), ], ), ); diff --git a/example/lib/screens/optimized_rendering_form.dart b/example/lib/screens/optimized_rendering_form.dart new file mode 100644 index 0000000..5380705 --- /dev/null +++ b/example/lib/screens/optimized_rendering_form.dart @@ -0,0 +1,262 @@ +import 'package:flutter/material.dart'; +import 'package:leancode_forms/leancode_forms.dart'; +import 'package:leancode_forms_example/main.dart'; +import 'package:leancode_forms_example/screens/form_page.dart'; +import 'package:leancode_forms_example/widgets/form_text_field_avatar_card.dart'; +import 'package:leancode_forms_example/widgets/form_text_field_with_banner.dart'; +import 'package:leancode_forms_example/widgets/form_text_field_with_icon.dart'; +import 'package:leancode_forms_example/widgets/screen_description.dart'; +import 'package:provider/provider.dart'; + +/// Demonstrates `ValueListenableBuilder`'s `child:` optimization across +/// three layouts of increasing visual weight: a small leading icon, a +/// profile-card with avatar, and a large decorative banner. +/// +/// In each case the "static" piece never depends on field state, so it's +/// built once and reused via the `child:` parameter — instead of being +/// reconstructed on every keystroke or async-validation tick. +class OptimizedRenderingFormScreen extends StatelessWidget { + const OptimizedRenderingFormScreen({super.key}); + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (_) => OptimizedRenderingFormController(), + child: const _OptimizedRenderingForm(), + ); + } +} + +class _OptimizedRenderingForm extends StatelessWidget { + const _OptimizedRenderingForm(); + + @override + Widget build(BuildContext context) { + final controller = context.read(); + return FormPage( + title: 'Optimized Rendering', + child: SingleChildScrollView( + child: Column( + children: [ + ScreenDescription([ + bold('Optimized rebuilds. '), + plain('Demonstrates '), + code('ValueListenableBuilder.child'), + plain(' across three layouts of increasing visual weight — '), + bold('leading icon'), + plain(', '), + bold('avatar card'), + plain(', and '), + bold('banner header'), + plain('. Each keeps a '), + bold('static subtree'), + plain(' that never depends on field state out of the rebuild ' + 'cycle. The email field has '), + bold('async validation'), + plain(', so its text field rebuilds often — but the banner ' + 'above it does not.'), + ]), + FormTextFieldWithIcon( + field: controller.firstName, + translateError: validatorTranslator, + icon: const _FancyLeadingIcon( + icon: Icons.person, + color: Color(0xFF7C4DFF), + ), + labelText: 'First Name', + hintText: 'Enter your first name', + ), + const SizedBox(height: 16), + FormTextFieldAvatarCard( + field: controller.nickname, + translateError: validatorTranslator, + avatarCaption: 'Profile', + avatarIcon: Icons.face_retouching_natural, + avatarColor: const Color(0xFFFF7043), + labelText: 'Nickname', + hintText: 'Pick a display name', + ), + const SizedBox(height: 8), + FormTextFieldWithBanner( + field: controller.email, + translateError: validatorTranslator, + banner: const _FancyBanner( + title: 'Stay in touch', + subtitle: 'We only email about important account updates.', + colors: [Color(0xFF00BFA5), Color(0xFF1DE9B6)], + icon: Icons.mail_outline, + ), + labelText: 'Email', + hintText: 'Enter your email', + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: controller.submit, + child: const Text('Submit'), + ), + ], + ), + ), + ); + } +} + +/// A visually heavy leading icon: gradient background, drop shadow, padding, +/// and a tinted icon on top. The kind of thing you'd want to build once and +/// reuse — exactly what the `child:` parameter is for. +class _FancyLeadingIcon extends StatelessWidget { + const _FancyLeadingIcon({required this.icon, required this.color}); + + final IconData icon; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + width: 48, + height: 48, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [color, color.withValues(alpha: 0.7)], + ), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: color.withValues(alpha: 0.4), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: Icon(icon, color: Colors.white, size: 26), + ); + } +} + +/// A decorative banner header: gradient fill, a couple of soft circles, and +/// a title + subtitle layered on top. Stands in for the kind of marketing +/// hero / image header a real form might have — and the kind of thing you +/// definitely don't want to rebuild on every keystroke. +class _FancyBanner extends StatelessWidget { + const _FancyBanner({ + required this.title, + required this.subtitle, + required this.colors, + required this.icon, + }); + + final String title; + final String subtitle; + final List colors; + final IconData icon; + + @override + Widget build(BuildContext context) { + return Container( + height: 120, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: colors, + ), + ), + child: Stack( + children: [ + Positioned( + top: -24, + right: -24, + child: Container( + width: 96, + height: 96, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.18), + ), + ), + ), + Positioned( + bottom: -16, + left: 40, + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.12), + ), + ), + ), + Positioned( + top: 16, + right: 16, + child: Icon(icon, color: Colors.white, size: 28), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + title, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 20, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class OptimizedRenderingFormController extends FormGroupController { + OptimizedRenderingFormController() { + registerFields([firstName, nickname, email]); + } + + final firstName = TextFieldController( + validator: filled(ValidationError.empty), + ); + + final nickname = TextFieldController( + validator: filled(ValidationError.empty), + ); + + late final email = TextFieldController( + validator: filled(ValidationError.empty), + asyncValidator: _onEmailChanged, + asyncValidationDebounce: const Duration(milliseconds: 500), + ); + + Future _onEmailChanged(String value) async { + final takenEmail = ['john@email.com', 'jack@email.com']; + await Future.delayed(const Duration(milliseconds: 700)); + return takenEmail.contains(value) ? ValidationError.emailTaken : null; + } + + void submit() { + if (validate()) { + debugPrint('First name: ${firstName.value.value}'); + debugPrint('Nickname: ${nickname.value.value}'); + debugPrint('Email: ${email.value.value}'); + } else { + debugPrint('Form is invalid'); + } + } +} diff --git a/example/lib/screens/password_form.dart b/example/lib/screens/password_form.dart index 1fd45b7..d13a009 100644 --- a/example/lib/screens/password_form.dart +++ b/example/lib/screens/password_form.dart @@ -6,6 +6,7 @@ import 'package:leancode_forms_example/screens/form_page.dart'; import 'package:leancode_forms_example/widgets/form_password_field.dart'; import 'package:leancode_forms_example/widgets/form_switch_field.dart'; import 'package:leancode_forms_example/widgets/form_text_field.dart'; +import 'package:leancode_forms_example/widgets/screen_description.dart'; import 'package:provider/provider.dart'; /// This is an example of a form with a password/repeat password fields. @@ -32,6 +33,17 @@ class PasswordForm extends StatelessWidget { title: 'Password Form', child: Column( children: [ + ScreenDescription([ + bold('Cross-field validation. '), + plain('The "Repeat Password" field listens to the password ' + 'field via '), + code('subscribeToFields'), + plain(' and re-validates whenever the password changes. The ' + 'username field demonstrates '), + bold('autovalidation'), + plain(': it only starts showing errors after losing focus ' + 'for the first time.'), + ]), FormTextField( field: controller.username, onUnfocus: () => controller.username diff --git a/example/lib/screens/quiz_form.dart b/example/lib/screens/quiz_form.dart index 630a5c6..5cefa92 100644 --- a/example/lib/screens/quiz_form.dart +++ b/example/lib/screens/quiz_form.dart @@ -3,6 +3,7 @@ import 'package:leancode_forms/leancode_forms.dart'; import 'package:leancode_forms_example/main.dart'; import 'package:leancode_forms_example/screens/form_page.dart'; import 'package:leancode_forms_example/widgets/form_text_field.dart'; +import 'package:leancode_forms_example/widgets/screen_description.dart'; import 'package:provider/provider.dart'; /// This is an example of a form which is asynchronously validated after pressing the submit button. @@ -33,6 +34,18 @@ class QuizForm extends StatelessWidget { title: 'Quiz Form', child: Column( children: [ + ScreenDescription([ + bold('Manual error setting. '), + plain('Validation happens '), + bold('after'), + plain(' the user presses Submit — the controller calls '), + code('setError'), + plain(' directly on each field once the async check returns. ' + 'The status text at the bottom reflects the controller\'s ' + 'own '), + code('ValueNotifier'), + plain('-backed state.'), + ]), const Text('What is the longest river in the world?'), FormTextField( field: controller.formController.riverQuestion, diff --git a/example/lib/screens/scroll_form.dart b/example/lib/screens/scroll_form.dart index b8e1e22..5c7a67f 100644 --- a/example/lib/screens/scroll_form.dart +++ b/example/lib/screens/scroll_form.dart @@ -7,6 +7,7 @@ import 'package:leancode_forms_example/main.dart'; import 'package:leancode_forms_example/screens/form_page.dart'; import 'package:leancode_forms_example/utils/extensions/iterable_extensions.dart'; import 'package:leancode_forms_example/widgets/form_text_field.dart'; +import 'package:leancode_forms_example/widgets/screen_description.dart'; import 'package:provider/provider.dart'; class ScrollFormScreen extends StatelessWidget { @@ -66,6 +67,18 @@ class _ScrollFormState extends State { child: SingleChildScrollView( child: Column( children: [ + ScreenDescription([ + bold('Focus management. '), + plain('Each field owns its own '), + code('FocusNode'), + plain('. On submit failure the controller emits a '), + code('SubmitFailedWithErrors'), + plain(' event over a plain broadcast '), + code('Stream'), + plain('; the widget listens and focuses the '), + bold('first invalid field'), + plain(', scrolling it into view.'), + ]), FocusableFormTextField( field: controller.firstField, translateError: validatorTranslator, diff --git a/example/lib/screens/simple_form.dart b/example/lib/screens/simple_form.dart index c98dc79..6f2b0e9 100644 --- a/example/lib/screens/simple_form.dart +++ b/example/lib/screens/simple_form.dart @@ -3,6 +3,7 @@ import 'package:leancode_forms/leancode_forms.dart'; import 'package:leancode_forms_example/main.dart'; import 'package:leancode_forms_example/screens/form_page.dart'; import 'package:leancode_forms_example/widgets/form_text_field.dart'; +import 'package:leancode_forms_example/widgets/screen_description.dart'; import 'package:provider/provider.dart'; /// This is an example of a simple form with two basic fields and one field with async validation. @@ -30,6 +31,18 @@ class SimpleForm extends StatelessWidget { child: SingleChildScrollView( child: Column( children: [ + ScreenDescription([ + plain('A basic form with two text fields and one with '), + bold('async validation'), + plain(' (email). Validation only runs on '), + bold('submit'), + plain(' — type anything and press Submit to see errors appear. ' + 'Try '), + code('john@email.com'), + plain(' or '), + code('jack@email.com'), + plain(' to trigger the async "email taken" error.'), + ]), FormTextField( field: controller.firstName, translateError: validatorTranslator, diff --git a/example/lib/widgets/form_text_field_avatar_card.dart b/example/lib/widgets/form_text_field_avatar_card.dart new file mode 100644 index 0000000..a1c2bb2 --- /dev/null +++ b/example/lib/widgets/form_text_field_avatar_card.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:leancode_forms/leancode_forms.dart'; + +/// A profile-card form field: a static [CircleAvatar] with a caption sits +/// on the left, the text field sits on the right. The whole left-hand +/// presentation block is the "static subtree" — it doesn't depend on the +/// field state, so it's built once and reused via `ValueListenableBuilder`'s +/// `child:` parameter. +class FormTextFieldAvatarCard extends StatelessWidget { + const FormTextFieldAvatarCard({ + super.key, + required this.field, + required this.translateError, + required this.avatarCaption, + required this.avatarIcon, + required this.avatarColor, + this.labelText, + this.hintText, + }); + + final TextFieldController field; + final ErrorTranslator translateError; + final String avatarCaption; + final IconData avatarIcon; + final Color avatarColor; + + final String? labelText; + final String? hintText; + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(vertical: 8), + child: Padding( + padding: const EdgeInsets.all(12), + child: ValueListenableBuilder>( + valueListenable: field, + child: _AvatarBlock( + caption: avatarCaption, + icon: avatarIcon, + color: avatarColor, + ), // <-- built once, reused on every rebuild + builder: (context, state, child) => Row( + children: [ + child!, // <-- the same `_AvatarBlock` instance every rebuild + const SizedBox(width: 12), + Expanded( + child: TextFormField( + controller: field.textController, + decoration: InputDecoration( + labelText: labelText, + hintText: hintText, + errorText: state.error != null + ? translateError(state.error!) + : null, + suffix: state.isValidating + ? const SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(), + ) + : null, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AvatarBlock extends StatelessWidget { + const _AvatarBlock({ + required this.caption, + required this.icon, + required this.color, + }); + + final String caption; + final IconData icon; + final Color color; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + CircleAvatar( + radius: 28, + backgroundColor: color, + child: Icon(icon, color: Colors.white, size: 28), + ), + const SizedBox(height: 6), + Text( + caption, + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600), + ), + ], + ); + } +} diff --git a/example/lib/widgets/form_text_field_with_banner.dart b/example/lib/widgets/form_text_field_with_banner.dart new file mode 100644 index 0000000..d8d0bd2 --- /dev/null +++ b/example/lib/widgets/form_text_field_with_banner.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:leancode_forms/leancode_forms.dart'; + +/// A form text field with a large decorative banner header. The [banner] is +/// the static subtree — it never depends on the field's state, so it's +/// built once when this widget mounts and reused on every rebuild via +/// `ValueListenableBuilder`'s `child:` parameter. +/// +/// The banner is intentionally the largest "static" content of the three +/// variants in this example — pairing it with an async-validated field +/// (which rebuilds on every keystroke and async-validation tick) makes +/// the optimization most visible here. +class FormTextFieldWithBanner extends StatelessWidget { + const FormTextFieldWithBanner({ + super.key, + required this.field, + required this.translateError, + required this.banner, + this.labelText, + this.hintText, + }); + + final TextFieldController field; + final ErrorTranslator translateError; + + /// Built once when this widget is mounted and reused on every rebuild via + /// `ValueListenableBuilder`'s `child:` parameter. + final Widget banner; + + final String? labelText; + final String? hintText; + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(vertical: 8), + clipBehavior: Clip.antiAlias, + child: ValueListenableBuilder>( + valueListenable: field, + child: banner, // <-- built once, reused on every rebuild + builder: (context, state, child) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + child!, // <-- the same `banner` instance every rebuild + Padding( + padding: const EdgeInsets.all(12), + child: TextFormField( + controller: field.textController, + decoration: InputDecoration( + labelText: labelText, + hintText: hintText, + errorText: + state.error != null ? translateError(state.error!) : null, + suffix: state.isValidating + ? const SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(), + ) + : null, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/example/lib/widgets/form_text_field_with_icon.dart b/example/lib/widgets/form_text_field_with_icon.dart new file mode 100644 index 0000000..b4c53fe --- /dev/null +++ b/example/lib/widgets/form_text_field_with_icon.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:leancode_forms/leancode_forms.dart'; + +/// A form text field with a leading icon. Demonstrates the +/// `ValueListenableBuilder.child:` optimization — the [icon] widget is built +/// once when this widget is mounted, and reused on every subsequent rebuild +/// instead of being constructed fresh on every keystroke. +/// +/// For most form fields you should reach for [FieldBuilder] instead — it's +/// shorter and equally correct. Use this pattern only when part of the +/// subtree (here, the leading icon) is genuinely expensive AND doesn't +/// depend on the field state. +class FormTextFieldWithIcon extends StatelessWidget { + const FormTextFieldWithIcon({ + super.key, + required this.field, + required this.translateError, + required this.icon, + this.labelText, + this.hintText, + }); + + final TextFieldController field; + final ErrorTranslator translateError; + + /// Built once and reused on every rebuild via `ValueListenableBuilder`'s + /// `child:` parameter. + final Widget icon; + + final String? labelText; + final String? hintText; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: field, + child: icon, // <-- built once, reused on every rebuild + builder: (context, state, child) => Row( + children: [ + child!, // <-- the same `icon` instance every rebuild + const SizedBox(width: 8), + Expanded( + child: TextFormField( + controller: field.textController, + decoration: InputDecoration( + labelText: labelText, + hintText: hintText, + errorText: + state.error != null ? translateError(state.error!) : null, + suffix: state.isValidating + ? const SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(), + ) + : null, + ), + ), + ), + ], + ), + ); + } +} diff --git a/example/lib/widgets/screen_description.dart b/example/lib/widgets/screen_description.dart new file mode 100644 index 0000000..5dfdca9 --- /dev/null +++ b/example/lib/widgets/screen_description.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +/// A small information card shown at the top of each example form screen, +/// describing what the screen demonstrates. Accepts a list of [InlineSpan]s +/// so callers can mix plain text with bold / inline-code spans using the +/// [plain], [bold] and [code] helpers below. +class ScreenDescription extends StatelessWidget { + const ScreenDescription(this.spans, {super.key}); + + final List spans; + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: 16), + child: Padding( + padding: const EdgeInsets.all(12), + child: Text.rich( + TextSpan(children: spans), + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ); + } +} + +/// A regular-weight text span. Equivalent to `TextSpan(text: text)` — +/// provided as a helper for symmetry with [bold] and [code]. +TextSpan plain(String text) => TextSpan(text: text); + +/// A bolded text span, for emphasis on key terms. +TextSpan bold(String text) => + TextSpan(text: text, style: const TextStyle(fontWeight: FontWeight.bold)); + +/// A monospace text span with a subtle background, for inline references to +/// code identifiers (class names, method names, types). +TextSpan code(String text) => TextSpan( + text: text, + style: const TextStyle( + fontFamily: 'monospace', + backgroundColor: Color(0xFFEEEEEE), + ), + ); diff --git a/example/pubspec.lock b/example/pubspec.lock index 7e3f7b9..e06d3fb 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.3.3" flutter: dependency: "direct main" description: flutter @@ -63,26 +63,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.8" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" leancode_forms: dependency: "direct main" description: @@ -102,26 +102,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.18.0" nested: dependency: transitive description: @@ -195,18 +195,18 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.11" vector_math: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: @@ -216,5 +216,5 @@ packages: source: hosted version: "14.3.1" sdks: - dart: ">=3.7.0-0 <4.0.0" + dart: ">=3.10.0-0 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" From c27ab28ec60e829d26a535c463e133e5349c732a Mon Sep 17 00:00:00 2001 From: aydinguven-leancode Date: Fri, 19 Jun 2026 13:28:12 +0200 Subject: [PATCH 3/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index c5fc64c..6ce5249 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,8 @@ A package for creating and managing forms with `ValueNotifier` / `ChangeNotifier flutter pub add leancode_forms ``` -# Usage +## Usage Let's go through the basics of the package while explaining some of the key terms/concepts. - ## Creating a Simple Form To create a simple form, you need to define a `FormGroupController` that will manage its fields. From a9ea59680cdf0b072a3ea2e622933576df13b562 Mon Sep 17 00:00:00 2001 From: aydinguven-leancode Date: Fri, 19 Jun 2026 13:29:50 +0200 Subject: [PATCH 4/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 022f78e..85ce283 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,7 @@ * `SingleSelectFieldCubit` → `SingleSelectFieldController` * `MultiSelectFieldCubit` → `MultiSelectFieldController` * `FormGroupCubit` → `FormGroupController` -* `FieldBuilder` is kept. It is now a thin wrapper around `ValueListenableBuilder` instead of `BlocBuilder`. The `field:` parameter is now typed `FieldController` (previously `FieldCubit`); the builder signature is otherwise unchanged. We can use -* `ValueListenableBuilder` directly. +* `FieldBuilder` is kept. It is now a thin wrapper around `ValueListenableBuilder` instead of `BlocBuilder`. The `field:` parameter is now typed `FieldController` (previously `FieldCubit`); the builder signature is otherwise unchanged. You can also use `ValueListenableBuilder` directly. * **Breaking:** Lifecycle method renamed from `close()` to `dispose()` on both controllers. * **Breaking:** `FormGroupController` exposes `onValuesChanged` and `onStatusChanged` as `Listenable`s (previously `Stream`s named `onValuesChangedStream` / `onStatusChangedStream`). * `TextFieldController` now owns a `TextEditingController` (`field.textController`) kept in two-way sync with the field value. Widgets bind to it directly; programmatic changes (`setValue`, `reset`, `clear`) propagate to the text controller, and user input propagates back. Removes the dual-write bug class around resetting fields, set-to-initial flows, etc. From 73860262e8f87782f1d69b2cc06d5b3f81cd8fdd Mon Sep 17 00:00:00 2001 From: aydinguven-leancode Date: Mon, 22 Jun 2026 10:11:35 +0200 Subject: [PATCH 5/9] A few minor fixes on PR Removed unnecessary //ignore commands Reverted version change on leancode_lint in example project Added debugLabel --- example/lib/controllers/focusable_text_field_controller.dart | 4 +++- lib/src/field/field_controller.dart | 1 - lib/src/form_group/form_group_controller.dart | 1 - pubspec.yaml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/example/lib/controllers/focusable_text_field_controller.dart b/example/lib/controllers/focusable_text_field_controller.dart index 7a0e3c3..73a473b 100644 --- a/example/lib/controllers/focusable_text_field_controller.dart +++ b/example/lib/controllers/focusable_text_field_controller.dart @@ -13,7 +13,9 @@ class FocusableTextFieldController }); /// The focus node of the field. - final focusNode = FocusNode(); + late final focusNode = FocusNode( + debugLabel: 'FocusableTextFieldController${name?.isNotEmpty ?? false ? '($name)' : ''}', + ); /// Focuses the field. void focus() => focusNode.requestFocus(); diff --git a/lib/src/field/field_controller.dart b/lib/src/field/field_controller.dart index 0054934..478e0a9 100644 --- a/lib/src/field/field_controller.dart +++ b/lib/src/field/field_controller.dart @@ -19,7 +19,6 @@ typedef ErrorTranslator = String Function(E); /// to be able to unambiguously detect lack of errors. /// /// If autovalidate is true, the validator will be run after each field change. -// ignore_for_file: avoid_positional_boolean_parameters class FieldController extends ValueNotifier> { /// Creates a new [FieldController] with an initial value and a validator. diff --git a/lib/src/form_group/form_group_controller.dart b/lib/src/form_group/form_group_controller.dart index 04bba7f..a184412 100644 --- a/lib/src/form_group/form_group_controller.dart +++ b/lib/src/form_group/form_group_controller.dart @@ -16,7 +16,6 @@ import 'package:leancode_forms/src/field/field_controller.dart'; /// /// Introducing cycles in forms is not supported and not checked against (most /// likely will cause a stack overflow somewhere). -// ignore_for_file: avoid_positional_boolean_parameters class FormGroupController extends ValueNotifier { /// Creates a new [FormGroupController]. FormGroupController({ diff --git a/pubspec.yaml b/pubspec.yaml index fb3d873..d8036ff 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,4 +15,4 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - leancode_lint: ^15.0.0 \ No newline at end of file + leancode_lint: ^15.0.0 From 7035ad044cd27b280124468d16285401ba07928a Mon Sep 17 00:00:00 2001 From: aydinguven-leancode Date: Mon, 22 Jun 2026 13:07:46 +0200 Subject: [PATCH 6/9] PR fixes Removed the bits about previous versions from EXAMPLES.md Added `child` param to fieldBuilder class to fully support builder closures Various minor fixes --- EXAMPLES.md | 165 +++--------------- example/test/screens/simple_form_test.dart | 1 + lib/src/field/builder/field_builder.dart | 19 +- lib/src/form_group/form_group_controller.dart | 15 +- test/src/field/field_builder_test.dart | 2 +- 5 files changed, 39 insertions(+), 163 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 918f944..3dd1269 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -1,141 +1,8 @@ # Examples -Side-by-side snippets showing the same form scenario in three flavors: +## Using `FieldBuilder` (recommended) -1. **0.1.x (pre-migration)** — built on `flutter_bloc` + `rxdart` -2. **0.2.0 with `FieldBuilder`** — the recommended default; tiny wrapper around `ValueListenableBuilder` -3. **0.2.0 with `ValueListenableBuilder`** directly — for cases where you want the SDK primitive (e.g. the `child:` optimization) - -For a step-by-step migration walkthrough see [MIGRATION.md](./MIGRATION.md). - ---- - -## 0.1.x — pre-migration (BLoC-based) - -> These examples won't compile against 0.2.0. They're here so you can recognize your old code and find the matching new-shape example below. - -### 1. A simple text field with an error message - -```dart -class _MyError {} - -final field = TextFieldCubit<_MyError>( - initialValue: '', - validator: filled(_MyError()), -); - -// In the widget tree: -FieldBuilder( - field: field, - builder: (context, state) { - return TextFormField( - onChanged: field.getValueSetter(), - decoration: InputDecoration( - errorText: state.error != null ? 'Required' : null, - ), - ); - }, -); -``` - -### 2. A form with submit-time validation - -```dart -class SimpleFormCubit extends FormGroupCubit { - SimpleFormCubit() { - registerFields([firstName, email]); - } - - final firstName = TextFieldCubit( - initialValue: 'John', - validator: filled(ValidationError.empty), - ); - - final email = TextFieldCubit( - validator: filled(ValidationError.empty), - ); - - void submit() { - if (validate()) { - print('First: ${firstName.state.value}'); - print('Email: ${email.state.value}'); - } - } -} - -// Provider: -BlocProvider( - create: (_) => SimpleFormCubit(), - child: const SimpleForm(), -) - -// Inside the widget tree: -context.read().submit(); -``` - -### 3. Async email validation - -```dart -class SignupCubit extends FormGroupCubit { - SignupCubit() { - registerFields([email]); - } - - final email = TextFieldCubit( - validator: filled(ValidationError.empty), - asyncValidator: _checkEmail, - asyncValidationDebounce: const Duration(milliseconds: 500), - ); - - Future _checkEmail(String value) async { - final taken = ['taken@example.com']; - await Future.delayed(const Duration(milliseconds: 700)); - return taken.contains(value) ? ValidationError.emailTaken : null; - } -} - -// Render: -FieldBuilder( - field: context.read().email, - builder: (context, state) => TextFormField( - onChanged: context.read().email.getValueSetter(), - decoration: InputDecoration( - errorText: state.error?.toString(), - suffix: state.isValidating - ? const SizedBox.square( - dimension: 16, - child: CircularProgressIndicator(), - ) - : null, - ), - ), -) -``` - -### 4. Cross-field validation (password / repeat-password) - -```dart -class PasswordFormCubit extends FormGroupCubit { - PasswordFormCubit() { - registerFields([password, repeatPassword]); - } - - final password = TextFieldCubit( - validator: atLeastLength(8, ValidationError.toShort), - ); - - late final repeatPassword = TextFieldCubit( - validator: (value) => - value == password.state.value ? null : ValidationError.doesNotMatch, - )..subscribeToFields([password]); -} -``` - ---- - -## 0.2.0 — using `FieldBuilder` (recommended) - -Same scenarios as above, ported to the new API. `FieldBuilder` is a thin wrapper around `ValueListenableBuilder` — it saves typing the `>` type argument, and keeps your widget code looking almost identical to the 0.1.x version. +Common form scenarios using `FieldBuilder` — a thin wrapper around `ValueListenableBuilder` that saves typing the `>` type argument and reads as "build for a field" at call sites. ### 1. A simple text field with an error message @@ -161,7 +28,7 @@ FieldBuilder( ); ``` -Note the `controller: field.textController` — `TextFieldController` owns its own `TextEditingController` now, kept in two-way sync with the field value. No `onChanged: field.setValue` needed; programmatic resets (`field.reset()`, `field.clear()`) propagate to the visible text automatically. +Note the `controller: field.textController` — `TextFieldController` owns its own `TextEditingController`, kept in two-way sync with the field value. Programmatic resets (`field.reset()`, `field.clear()`) propagate to the visible text automatically. ### 2. A form with submit-time validation @@ -194,8 +61,13 @@ ChangeNotifierProvider( child: const SimpleForm(), ) -// Inside the widget tree: -context.read().submit(); +// Inside the SimpleForm widget — context.read is the right call here, +// because we want a stable reference for the onPressed callback (no +// subscription to rebuilds). +ElevatedButton( + onPressed: () => context.read().submit(), + child: const Text('Submit'), +) ``` ### 3. Async email validation @@ -219,11 +91,14 @@ class SignupController extends FormGroupController { } } -// Render: -FieldBuilder( - field: context.read().email, +// Inside the widget's build method — grab the field once, then close +// over it inside the builder instead of re-fetching from context: +final email = context.read().email; + +return FieldBuilder( + field: email, builder: (context, state) => TextFormField( - controller: context.read().email.textController, + controller: email.textController, decoration: InputDecoration( errorText: state.error?.toString(), suffix: state.isValidating @@ -234,7 +109,7 @@ FieldBuilder( : null, ), ), -) +); ``` ### 4. Cross-field validation (password / repeat-password) @@ -256,7 +131,7 @@ class PasswordFormController extends FormGroupController { } ``` -`subscribeToFields` works exactly as before — only the implementation underneath changed (no more `rxdart`). +`subscribeToFields` listens to the given fields and re-runs this field's validator whenever any of their values change. ### 5. A reusable custom form widget @@ -302,7 +177,7 @@ FormTextField( --- -## 0.2.0 — using `ValueListenableBuilder` directly +## Using `ValueListenableBuilder` directly `FieldBuilder` is shorthand. When you want the SDK primitive instead, drop down to `ValueListenableBuilder`. Reasons to reach for it: diff --git a/example/test/screens/simple_form_test.dart b/example/test/screens/simple_form_test.dart index ce63a9f..bc2736f 100644 --- a/example/test/screens/simple_form_test.dart +++ b/example/test/screens/simple_form_test.dart @@ -17,6 +17,7 @@ void main() { addTearDown(controller.dispose); controller.email.setValue('john@email.com'); + // TODO: replace with an await on the field's pending async validation. await Future.delayed(const Duration(seconds: 2)); expect(controller.email.value.error, ValidationError.emailTaken); diff --git a/lib/src/field/builder/field_builder.dart b/lib/src/field/builder/field_builder.dart index 55c46d6..5da2ad0 100644 --- a/lib/src/field/builder/field_builder.dart +++ b/lib/src/field/builder/field_builder.dart @@ -1,31 +1,32 @@ import 'package:flutter/widgets.dart'; import 'package:leancode_forms/src/field/field_controller.dart'; -/// A thin wrapper around [ValueListenableBuilder] that rebuilds whenever the -/// given [field] notifies. Saves typing the `>` type argument -/// at call sites. -/// -/// For finer control (e.g. the `child:` optimization on -/// [ValueListenableBuilder]), use [ValueListenableBuilder] directly. +/// Rebuilds whenever [field] notifies. Thin wrapper around +/// [ValueListenableBuilder] that hides the `>` type argument. class FieldBuilder extends StatelessWidget { /// Creates a new [FieldBuilder]. const FieldBuilder({ super.key, required this.field, required this.builder, + this.child, }); /// The field to listen to. final FieldController field; - /// Called with the latest [FieldState] every time [field] notifies. - final Widget Function(BuildContext context, FieldState state) builder; + /// Called every time [field] notifies. + final ValueWidgetBuilder> builder; + + /// Forwarded to [ValueListenableBuilder.child]. + final Widget? child; @override Widget build(BuildContext context) { return ValueListenableBuilder>( valueListenable: field, - builder: (context, state, _) => builder(context, state), + builder: builder, + child: child, ); } } diff --git a/lib/src/form_group/form_group_controller.dart b/lib/src/form_group/form_group_controller.dart index a184412..662ba22 100644 --- a/lib/src/form_group/form_group_controller.dart +++ b/lib/src/form_group/form_group_controller.dart @@ -363,25 +363,24 @@ class FormGroupState { }; // ⚠️ Maintainer: keep these in sync with the fields declared above. `fields` - // and `subforms` use ListEquality/SetEquality (with identity-based element - // comparison, since FieldController/FormGroupController don't override `==`). + // and `subforms` compare element-wise via Flutter's listEquals/setEquals + // (identity-based, since FieldController/FormGroupController don't override + // `==`). @override bool operator ==(Object other) => identical(this, other) || other is FormGroupState && wasModified == other.wasModified && - const ListEquality>() - .equals(fields, other.fields) && - const SetEquality() - .equals(subforms, other.subforms) && + listEquals(fields, other.fields) && + setEquals(subforms, other.subforms) && validationEnabled == other.validationEnabled && validating == other.validating; @override int get hashCode => Object.hash( wasModified, - const ListEquality>().hash(fields), - const SetEquality().hash(subforms), + Object.hashAll(fields), + Object.hashAllUnordered(subforms), validationEnabled, validating, ); diff --git a/test/src/field/field_builder_test.dart b/test/src/field/field_builder_test.dart index 9bdd292..4997c8f 100644 --- a/test/src/field/field_builder_test.dart +++ b/test/src/field/field_builder_test.dart @@ -12,7 +12,7 @@ void main() { home: Scaffold( body: FieldBuilder( field: field, - builder: (context, state) => Text(state.value), + builder: (context, state, _) => Text(state.value), ), ), ), From f1a28f3658d24e5350b78af2fa497e67f6957ca2 Mon Sep 17 00:00:00 2001 From: aydinguven-leancode Date: Mon, 22 Jun 2026 15:15:32 +0200 Subject: [PATCH 7/9] Changed context.read to context.select in examples --- EXAMPLES.md | 7 ++++--- example/lib/screens/complex_form.dart | 3 ++- example/lib/screens/optimized_rendering_form.dart | 3 ++- example/lib/screens/password_form.dart | 3 ++- example/lib/screens/quiz_form.dart | 3 ++- example/lib/screens/scroll_form.dart | 3 ++- example/lib/screens/simple_form.dart | 3 ++- 7 files changed, 16 insertions(+), 9 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 3dd1269..29df41e 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -91,9 +91,10 @@ class SignupController extends FormGroupController { } } -// Inside the widget's build method — grab the field once, then close -// over it inside the builder instead of re-fetching from context: -final email = context.read().email; +// Let's select field reference +final email = context.select>( + (c) => c.email, +); return FieldBuilder( field: email, diff --git a/example/lib/screens/complex_form.dart b/example/lib/screens/complex_form.dart index 08b070a..f5c3740 100644 --- a/example/lib/screens/complex_form.dart +++ b/example/lib/screens/complex_form.dart @@ -28,7 +28,8 @@ class ComplexForm extends StatelessWidget { @override Widget build(BuildContext context) { - final controller = context.read(); + final controller = + context.select((c) => c); return FormPage( title: 'Complex Form', child: SingleChildScrollView( diff --git a/example/lib/screens/optimized_rendering_form.dart b/example/lib/screens/optimized_rendering_form.dart index 5380705..666400a 100644 --- a/example/lib/screens/optimized_rendering_form.dart +++ b/example/lib/screens/optimized_rendering_form.dart @@ -32,7 +32,8 @@ class _OptimizedRenderingForm extends StatelessWidget { @override Widget build(BuildContext context) { - final controller = context.read(); + final controller = context.select((c) => c); return FormPage( title: 'Optimized Rendering', child: SingleChildScrollView( diff --git a/example/lib/screens/password_form.dart b/example/lib/screens/password_form.dart index d13a009..4a7ba77 100644 --- a/example/lib/screens/password_form.dart +++ b/example/lib/screens/password_form.dart @@ -28,7 +28,8 @@ class PasswordForm extends StatelessWidget { @override Widget build(BuildContext context) { - final controller = context.read(); + final controller = context + .select((c) => c); return FormPage( title: 'Password Form', child: Column( diff --git a/example/lib/screens/quiz_form.dart b/example/lib/screens/quiz_form.dart index 5cefa92..9976bfd 100644 --- a/example/lib/screens/quiz_form.dart +++ b/example/lib/screens/quiz_form.dart @@ -25,7 +25,8 @@ class QuizForm extends StatelessWidget { @override Widget build(BuildContext context) { - final controller = context.read(); + final controller = + context.select((c) => c); final formStatus = context.select( (c) => c.validationStatus, ); diff --git a/example/lib/screens/scroll_form.dart b/example/lib/screens/scroll_form.dart index 5c7a67f..1ed06c7 100644 --- a/example/lib/screens/scroll_form.dart +++ b/example/lib/screens/scroll_form.dart @@ -61,7 +61,8 @@ class _ScrollFormState extends State { @override Widget build(BuildContext context) { - final controller = context.read(); + final controller = + context.select((c) => c); return FormPage( title: 'Scroll Form', child: SingleChildScrollView( diff --git a/example/lib/screens/simple_form.dart b/example/lib/screens/simple_form.dart index 6f2b0e9..30e2c91 100644 --- a/example/lib/screens/simple_form.dart +++ b/example/lib/screens/simple_form.dart @@ -25,7 +25,8 @@ class SimpleForm extends StatelessWidget { @override Widget build(BuildContext context) { - final controller = context.read(); + final controller = + context.select((c) => c); return FormPage( title: 'Simple Form', child: SingleChildScrollView( From 90fc15ed2619d457290101aa97139a07f6091b48 Mon Sep 17 00:00:00 2001 From: aydinguven-leancode Date: Mon, 22 Jun 2026 15:21:36 +0200 Subject: [PATCH 8/9] Replaced .map with for loop --- example/lib/screens/delivery_form.dart | 9 ++++----- example/lib/widgets/app_dropdown_field.dart | 15 +++++++-------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/example/lib/screens/delivery_form.dart b/example/lib/screens/delivery_form.dart index 8fe4e00..a1d70bb 100644 --- a/example/lib/screens/delivery_form.dart +++ b/example/lib/screens/delivery_form.dart @@ -43,13 +43,12 @@ class DeliveryListForm extends StatelessWidget { bold('cascades'), plain(' to every subform.'), ]), - ...controller.deliveryList.map( - (e) => ConsumerSubform( - key: ValueKey(e.hashCode), - form: e, + for (final form in controller.deliveryList) + ConsumerSubform( + key: ValueKey(form.hashCode), + form: form, onRemove: controller.removeConsumer, ), - ), ElevatedButton( onPressed: controller.addConsumer, child: const Text('Add Consumer'), diff --git a/example/lib/widgets/app_dropdown_field.dart b/example/lib/widgets/app_dropdown_field.dart index aa6a66b..32c0849 100644 --- a/example/lib/widgets/app_dropdown_field.dart +++ b/example/lib/widgets/app_dropdown_field.dart @@ -32,14 +32,13 @@ class AppDropdownField extends StatelessWidget { child: DropdownButtonFormField( value: value, onChanged: onChanged, - items: options - .map( - (e) => DropdownMenuItem( - value: e, - child: Text(labelBuilder(e)), - ), - ) - .toList(), + items: [ + for (final option in options) + DropdownMenuItem( + value: option, + child: Text(labelBuilder(option)), + ), + ], decoration: InputDecoration( labelText: label, hintText: hint, From 8c3e4a84115e361612cb432c133330fc191cc81b Mon Sep 17 00:00:00 2001 From: aydinguven-leancode Date: Mon, 22 Jun 2026 15:39:14 +0200 Subject: [PATCH 9/9] Refactor on widget structure --- .../lib/screens/optimized_rendering_form.dart | 117 +++++++++--------- example/lib/screens/password_form.dart | 3 +- example/lib/screens/quiz_form.dart | 6 +- 3 files changed, 63 insertions(+), 63 deletions(-) diff --git a/example/lib/screens/optimized_rendering_form.dart b/example/lib/screens/optimized_rendering_form.dart index 666400a..6c6c371 100644 --- a/example/lib/screens/optimized_rendering_form.dart +++ b/example/lib/screens/optimized_rendering_form.dart @@ -36,67 +36,66 @@ class _OptimizedRenderingForm extends StatelessWidget { OptimizedRenderingFormController>((c) => c); return FormPage( title: 'Optimized Rendering', - child: SingleChildScrollView( - child: Column( - children: [ - ScreenDescription([ - bold('Optimized rebuilds. '), - plain('Demonstrates '), - code('ValueListenableBuilder.child'), - plain(' across three layouts of increasing visual weight — '), - bold('leading icon'), - plain(', '), - bold('avatar card'), - plain(', and '), - bold('banner header'), - plain('. Each keeps a '), - bold('static subtree'), - plain(' that never depends on field state out of the rebuild ' - 'cycle. The email field has '), - bold('async validation'), - plain(', so its text field rebuilds often — but the banner ' - 'above it does not.'), - ]), - FormTextFieldWithIcon( - field: controller.firstName, - translateError: validatorTranslator, - icon: const _FancyLeadingIcon( - icon: Icons.person, - color: Color(0xFF7C4DFF), - ), - labelText: 'First Name', - hintText: 'Enter your first name', - ), - const SizedBox(height: 16), - FormTextFieldAvatarCard( - field: controller.nickname, - translateError: validatorTranslator, - avatarCaption: 'Profile', - avatarIcon: Icons.face_retouching_natural, - avatarColor: const Color(0xFFFF7043), - labelText: 'Nickname', - hintText: 'Pick a display name', - ), - const SizedBox(height: 8), - FormTextFieldWithBanner( - field: controller.email, - translateError: validatorTranslator, - banner: const _FancyBanner( - title: 'Stay in touch', - subtitle: 'We only email about important account updates.', - colors: [Color(0xFF00BFA5), Color(0xFF1DE9B6)], - icon: Icons.mail_outline, - ), - labelText: 'Email', - hintText: 'Enter your email', + child: ListView( + padding: EdgeInsets.zero, + children: [ + ScreenDescription([ + bold('Optimized rebuilds. '), + plain('Demonstrates '), + code('ValueListenableBuilder.child'), + plain(' across three layouts of increasing visual weight — '), + bold('leading icon'), + plain(', '), + bold('avatar card'), + plain(', and '), + bold('banner header'), + plain('. Each keeps a '), + bold('static subtree'), + plain(' that never depends on field state out of the rebuild ' + 'cycle. The email field has '), + bold('async validation'), + plain(', so its text field rebuilds often — but the banner ' + 'above it does not.'), + ]), + FormTextFieldWithIcon( + field: controller.firstName, + translateError: validatorTranslator, + icon: const _FancyLeadingIcon( + icon: Icons.person, + color: Color(0xFF7C4DFF), ), - const SizedBox(height: 24), - ElevatedButton( - onPressed: controller.submit, - child: const Text('Submit'), + labelText: 'First Name', + hintText: 'Enter your first name', + ), + const SizedBox(height: 16), + FormTextFieldAvatarCard( + field: controller.nickname, + translateError: validatorTranslator, + avatarCaption: 'Profile', + avatarIcon: Icons.face_retouching_natural, + avatarColor: const Color(0xFFFF7043), + labelText: 'Nickname', + hintText: 'Pick a display name', + ), + const SizedBox(height: 8), + FormTextFieldWithBanner( + field: controller.email, + translateError: validatorTranslator, + banner: const _FancyBanner( + title: 'Stay in touch', + subtitle: 'We only email about important account updates.', + colors: [Color(0xFF00BFA5), Color(0xFF1DE9B6)], + icon: Icons.mail_outline, ), - ], - ), + labelText: 'Email', + hintText: 'Enter your email', + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: controller.submit, + child: const Text('Submit'), + ), + ], ), ); } diff --git a/example/lib/screens/password_form.dart b/example/lib/screens/password_form.dart index 4a7ba77..bdd78ef 100644 --- a/example/lib/screens/password_form.dart +++ b/example/lib/screens/password_form.dart @@ -32,7 +32,8 @@ class PasswordForm extends StatelessWidget { .select((c) => c); return FormPage( title: 'Password Form', - child: Column( + child: ListView( + padding: EdgeInsets.zero, children: [ ScreenDescription([ bold('Cross-field validation. '), diff --git a/example/lib/screens/quiz_form.dart b/example/lib/screens/quiz_form.dart index 9976bfd..1d2285f 100644 --- a/example/lib/screens/quiz_form.dart +++ b/example/lib/screens/quiz_form.dart @@ -25,15 +25,15 @@ class QuizForm extends StatelessWidget { @override Widget build(BuildContext context) { - final controller = - context.select((c) => c); + final controller = context.select((c) => c); final formStatus = context.select( (c) => c.validationStatus, ); return FormPage( title: 'Quiz Form', - child: Column( + child: ListView( + padding: EdgeInsets.zero, children: [ ScreenDescription([ bold('Manual error setting. '),