From a235c2cf09a4ae92f1a11a8cebaf2c7839aad8b6 Mon Sep 17 00:00:00 2001 From: Kamil Kras <38427679+xVemu@users.noreply.github.com> Date: Sun, 22 Feb 2026 20:29:00 +0100 Subject: [PATCH 1/2] Add isDirty and wasModified to FieldCubit --- lib/src/field/cubit/field_cubit.dart | 30 ++++++++++ test/src/field/field_cubit_test.dart | 85 +++++++++++++++++++++++++++- 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/lib/src/field/cubit/field_cubit.dart b/lib/src/field/cubit/field_cubit.dart index 84b230a..8eb9a16 100644 --- a/lib/src/field/cubit/field_cubit.dart +++ b/lib/src/field/cubit/field_cubit.dart @@ -88,6 +88,8 @@ class FieldCubit extends Cubit> { asyncError: state.asyncError, autovalidate: state.autovalidate, readOnly: state.readOnly, + isDirty: true, + wasModified: value != _initialValue, status: validationError == null ? FieldStatus.valid : FieldStatus.invalid, ), @@ -111,6 +113,8 @@ class FieldCubit extends Cubit> { autovalidate: state.autovalidate, readOnly: state.readOnly, status: FieldStatus.pending, + isDirty: true, + wasModified: value != _initialValue, ), ); @@ -125,6 +129,8 @@ class FieldCubit extends Cubit> { autovalidate: state.autovalidate, readOnly: state.readOnly, status: FieldStatus.validating, + isDirty: state.isDirty, + wasModified: state.wasModified, ), ); @@ -145,6 +151,8 @@ class FieldCubit extends Cubit> { asyncError: error, autovalidate: state.autovalidate, readOnly: state.readOnly, + isDirty: state.isDirty, + wasModified: state.wasModified, status: error == null ? FieldStatus.valid : FieldStatus.invalid, ), ); @@ -171,6 +179,8 @@ class FieldCubit extends Cubit> { autovalidate: state.autovalidate, readOnly: state.readOnly, status: FieldStatus.invalid, + isDirty: state.isDirty, + wasModified: state.wasModified, ), ); } @@ -192,6 +202,8 @@ class FieldCubit extends Cubit> { asyncError: state.asyncError, autovalidate: state.autovalidate, readOnly: state.readOnly, + isDirty: state.isDirty, + wasModified: state.wasModified, status: error == null ? FieldStatus.valid : FieldStatus.invalid, ), ); @@ -210,6 +222,8 @@ class FieldCubit extends Cubit> { autovalidate: autovalidate, readOnly: state.readOnly, status: state.status, + isDirty: state.isDirty, + wasModified: state.wasModified, ), ); } @@ -224,6 +238,8 @@ class FieldCubit extends Cubit> { autovalidate: state.autovalidate, readOnly: true, status: state.status, + isDirty: state.isDirty, + wasModified: state.wasModified, ), ); } @@ -237,6 +253,8 @@ class FieldCubit extends Cubit> { asyncError: state.asyncError, autovalidate: state.autovalidate, status: state.status, + isDirty: state.isDirty, + wasModified: state.wasModified, ), ); } @@ -248,6 +266,8 @@ class FieldCubit extends Cubit> { value: state.value, autovalidate: state.autovalidate, readOnly: state.readOnly, + isDirty: state.isDirty, + wasModified: state.wasModified, ), ); } @@ -289,6 +309,8 @@ class FieldState with EquatableMixin { this.autovalidate = false, this.readOnly = false, this.status = FieldStatus.valid, + this.isDirty = false, + this.wasModified = false, }); /// Returns true if there are no errors. @@ -337,6 +359,12 @@ class FieldState with EquatableMixin { /// The current status of the field. final FieldStatus status; + /// Whether the value is different from the initial value. + final bool wasModified; + + /// Whether this field has ever been changed via [FieldCubit.setValue]. + final bool isDirty; + @override List get props => [ value, @@ -345,5 +373,7 @@ class FieldState with EquatableMixin { autovalidate, readOnly, status, + isDirty, + wasModified, ]; } diff --git a/test/src/field/field_cubit_test.dart b/test/src/field/field_cubit_test.dart index 86c1eb9..a4f4113 100644 --- a/test/src/field/field_cubit_test.dart +++ b/test/src/field/field_cubit_test.dart @@ -38,7 +38,7 @@ void main() { build: () => cubit, act: (cubit) => cubit.setValue(10), expect: () => const [ - _FieldState(value: 10), + _FieldState(value: 10, isDirty: true, wasModified: true), ], ); @@ -50,7 +50,7 @@ void main() { build: () => cubit, act: (cubit) => cubit.setValue(10), expect: () => const [ - _FieldState(value: 10), + _FieldState(value: 10, isDirty: true, wasModified: true), ], ); @@ -68,6 +68,8 @@ void main() { validationError: _Error.malformed, autovalidate: true, status: FieldStatus.invalid, + isDirty: true, + wasModified: true, ), ], ); @@ -97,11 +99,66 @@ void main() { _FieldState( value: 10, readOnly: true, + isDirty: true, + wasModified: true, ), ], ); }); + group('modification flags', () { + blocTest<_FieldCubit, _FieldState>( + 'isDirty and wasModified are false for initial state', + build: () => cubit, + verify: (cubit) { + expect(cubit.state.isDirty, false); + expect(cubit.state.wasModified, false); + }, + ); + + blocTest<_FieldCubit, _FieldState>( + 'setValue marks field as dirty and modified for non-initial value', + build: () => cubit, + act: (cubit) => cubit.setValue(10), + expect: () => const [ + _FieldState(value: 10, isDirty: true, wasModified: true), + ], + ); + + blocTest<_FieldCubit, _FieldState>( + 'setValue marks field as dirty but not modified for initial value', + build: () => cubit, + act: (cubit) => cubit.setValue(_initialValue), + expect: () => const [ + _FieldState(value: _initialValue, isDirty: true), + ], + ); + + blocTest<_FieldCubit, _FieldState>( + 'setting initial value marks field as dirty but not modified', + build: () => cubit, + act: (cubit) => cubit + ..setValue(10) + ..setValue(_initialValue), + expect: () => const [ + _FieldState(value: 10, isDirty: true, wasModified: true), + _FieldState(value: _initialValue, isDirty: true), + ], + ); + + blocTest<_FieldCubit, _FieldState>( + 'reset clears isDirty and wasModified', + build: () => cubit, + act: (cubit) => cubit + ..setValue(10) + ..reset(), + expect: () => const [ + _FieldState(value: 10, isDirty: true, wasModified: true), + _FieldState(value: _initialValue), + ], + ); + }); + group('reset', () { blocTest<_FieldCubit, _FieldState>( 'resets state to initial state', @@ -223,15 +280,21 @@ void main() { _FieldState( value: 10, status: FieldStatus.pending, + isDirty: true, + wasModified: true, ), _FieldState( value: 10, status: FieldStatus.validating, + isDirty: true, + wasModified: true, ), _FieldState( value: 10, status: FieldStatus.invalid, asyncError: _Error.malformed, + isDirty: true, + wasModified: true, ), ], ); @@ -252,19 +315,27 @@ void main() { _FieldState( value: 10, status: FieldStatus.pending, + isDirty: true, + wasModified: true, ), _FieldState( value: 20, status: FieldStatus.pending, + isDirty: true, + wasModified: true, ), _FieldState( value: 20, status: FieldStatus.validating, + isDirty: true, + wasModified: true, ), _FieldState( value: 20, status: FieldStatus.invalid, asyncError: _Error.malformed, + isDirty: true, + wasModified: true, ), ], ); @@ -285,23 +356,33 @@ void main() { _FieldState( value: 10, status: FieldStatus.pending, + isDirty: true, + wasModified: true, ), _FieldState( value: 10, status: FieldStatus.validating, + isDirty: true, + wasModified: true, ), _FieldState( value: 20, status: FieldStatus.pending, + isDirty: true, + wasModified: true, ), _FieldState( value: 20, status: FieldStatus.validating, + isDirty: true, + wasModified: true, ), _FieldState( value: 20, status: FieldStatus.invalid, asyncError: _Error.malformed, + isDirty: true, + wasModified: true, ), ], ); From 5c1aee3ac1fc3c779b67e5d2f3f8aa1ddcc99d0c Mon Sep 17 00:00:00 2001 From: Kamil Kras <38427679+xVemu@users.noreply.github.com> Date: Mon, 23 Feb 2026 09:35:13 +0100 Subject: [PATCH 2/2] Add isDirty to FormGroupCubit --- .../form_group_cubit/form_group_cubit.dart | 45 +++++++++------- .../form_group_cubit_test.dart | 53 +++++++++++++++---- 2 files changed, 69 insertions(+), 29 deletions(-) diff --git a/lib/src/form_group_cubit/form_group_cubit.dart b/lib/src/form_group_cubit/form_group_cubit.dart index bbf0656..4940fbc 100644 --- a/lib/src/form_group_cubit/form_group_cubit.dart +++ b/lib/src/form_group_cubit/form_group_cubit.dart @@ -1,8 +1,6 @@ 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'; @@ -58,8 +56,6 @@ class FormGroupCubit extends Cubit with Disposable { /// their validator called if they have autovalidate enabled. final bool validateAll; - List _initialFieldsState = []; - StreamSubscription? _onFieldsChangeSubscription; final _fieldsController = StreamController.broadcast(); @@ -114,6 +110,7 @@ class FormGroupCubit extends Cubit with Disposable { emit( FormGroupState( wasModified: state.wasModified, + isDirty: state.isDirty, fields: fields, subforms: state.subforms, validationEnabled: state.validationEnabled, @@ -122,17 +119,10 @@ class FormGroupCubit extends Cubit with Disposable { 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. /// @@ -210,6 +200,7 @@ class FormGroupCubit extends Cubit with Disposable { emit( FormGroupState( wasModified: state.wasModified, + isDirty: state.isDirty, fields: state.fields, subforms: {...state.subforms, form}, validationEnabled: state.validationEnabled, @@ -224,6 +215,7 @@ class FormGroupCubit extends Cubit with Disposable { emit( FormGroupState( wasModified: state.wasModified, + isDirty: state.isDirty, fields: state.fields, subforms: {...state.subforms}..remove(form), validationEnabled: state.validationEnabled, @@ -257,6 +249,7 @@ class FormGroupCubit extends Cubit with Disposable { emit( FormGroupState( wasModified: state.wasModified, + isDirty: state.isDirty, fields: state.fields, subforms: state.subforms, validationEnabled: validationEnabled, @@ -270,19 +263,30 @@ class FormGroupCubit extends Cubit with Disposable { } void _onFieldsStateChanged() { + if (validateAll) { + validateWithAutovalidate(); + } + final subformsWereModified = state.subforms.any( (subform) => subform.state.wasModified, ); - late final fieldsWereModified = !const DeepCollectionEquality() - .equals(_initialFieldsState, getFieldValues()); - if (validateAll) { - validateWithAutovalidate(); - } + final fieldsWereModified = state.fields.any( + (field) => field.state.wasModified, + ); + + final subformsAreDirty = state.subforms.any( + (subform) => subform.state.isDirty, + ); + + final fieldsAreDirty = state.fields.any( + (field) => field.state.isDirty, + ); emit( FormGroupState( wasModified: subformsWereModified || fieldsWereModified, + isDirty: subformsAreDirty || fieldsAreDirty, fields: state.fields, subforms: state.subforms, validationEnabled: state.validationEnabled, @@ -302,6 +306,7 @@ class FormGroupCubit extends Cubit with Disposable { emit( FormGroupState( wasModified: state.wasModified, + isDirty: state.isDirty, fields: state.fields, subforms: state.subforms, validationEnabled: state.validationEnabled, @@ -322,16 +327,19 @@ class FormGroupState with EquatableMixin { /// Creates a new [FormGroupState]. const FormGroupState({ this.wasModified = false, + this.isDirty = 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. + /// Whether any of the fields differ from their initial value. final bool wasModified; + /// Whether any of the fields or subforms were modified. + final bool isDirty; + /// List of all registered fields by this form. final List> fields; @@ -357,6 +365,7 @@ class FormGroupState with EquatableMixin { @override List get props => [ wasModified, + isDirty, fields, subforms, validationEnabled, diff --git a/test/src/form_group_cubit/form_group_cubit_test.dart b/test/src/form_group_cubit/form_group_cubit_test.dart index 61b862e..9a5abaf 100644 --- a/test/src/form_group_cubit/form_group_cubit_test.dart +++ b/test/src/form_group_cubit/form_group_cubit_test.dart @@ -62,14 +62,14 @@ void main() { group('getFieldValues', () { test('when no fields are registered', () { - final values = form.getFieldValues(); + final values = form.state.fields.map((f) => f.state.value); expect(values, isEmpty); }); test('when fields are registered', () { form.registerFields([field1, field2]); - final values = form.getFieldValues(); + final values = form.state.fields.map((f) => f.state.value); expect(values, [_initialValue1, _initialValue2]); }); @@ -80,7 +80,7 @@ void main() { field1.setValue('hello'); field2.setValue(10); - final values = form.getFieldValues(); + final values = form.state.fields.map((f) => f.state.value); expect(values, ['hello', 10]); }); @@ -93,15 +93,15 @@ void main() { field1.setValue('hello'); field2.setValue(10); - final values = form.getFieldValues(); + final values = form.state.fields.map((f) => f.state.value); expect(values, isEmpty); }); }); - group('wasModified', () { + group('modification flags', () { blocTest( - 'is false after register', + 'are false after register', build: () => form, act: (cubit) => cubit.registerFields([field1, field2]), expect: () => [ @@ -112,7 +112,7 @@ void main() { ); blocTest( - 'is true if subform was modified', + 'are true if subform was modified', build: () => form, setUp: () { subform.registerFields([subformField]); @@ -127,6 +127,7 @@ void main() { expect: () => [ FormGroupState( wasModified: true, + isDirty: true, fields: [field1, field2], subforms: {subform}, ), @@ -134,7 +135,7 @@ void main() { ); blocTest( - 'is true if field1 changes', + 'are true if field1 changes', build: () => form, setUp: () { form.registerFields([field1, field2]); @@ -146,13 +147,14 @@ void main() { expect: () => [ FormGroupState( wasModified: true, + isDirty: true, fields: [field1, field2], ), ], ); blocTest( - 'is true if field2 changes', + 'are true if field2 changes', build: () => form, setUp: () { form.registerFields([field1, field2]); @@ -162,12 +164,41 @@ void main() { field2.setValue(0xb0b); }, expect: () => [ - FormGroupState(wasModified: true, fields: [field1, field2]), + FormGroupState( + wasModified: true, + isDirty: true, + fields: [field1, field2], + ), + ], + ); + + blocTest( + 'isDirty stays true even after value returns to initial one', + build: () => form, + setUp: () { + form.registerFields([field1, field2]); + }, + act: (cubit) async { + await Future.delayed(Duration.zero); + field1.setValue('value'); + await Future.delayed(Duration.zero); + field1.setValue(_initialValue1); + }, + expect: () => [ + FormGroupState( + wasModified: true, + isDirty: true, + fields: [field1, field2], + ), + FormGroupState( + isDirty: true, + fields: [field1, field2], + ), ], ); blocTest( - 'does not change if field was unregistered', + 'do not change if field was unregistered', build: () => form, setUp: () { form