diff --git a/glade_forms/CHANGELOG.md b/glade_forms/CHANGELOG.md index 4c04ea8..98a7028 100644 --- a/glade_forms/CHANGELOG.md +++ b/glade_forms/CHANGELOG.md @@ -1,3 +1,13 @@ +## 1.2.0 +- **[Feat]**: Add `GladeFormListener` widget allowing to listen for model's changes +- **[Feat]**: Add `groupEdit()` method in GladeModel allows to update multiple inputs at once. + - Works great with `GladeFormListener` +- **[Feat]**: Add `valueTransform` in GladeInput. Transform value before it is assigned into value. + - Firstly `stringToTypeConverter` is called if needed, then `valueTransform`. +- **[Feat]**: Add `updateValue(T value)` as shorthand for inputs when field is not TextField. +- **[Feat]**: Add `resetToPure` method allowing to reset input into pure state. +- **[Fix]**: Conversion error does not update model's stats and formatted errors. + ## 1.1.2 - Fix links in readme diff --git a/glade_forms/README.md b/glade_forms/README.md index 42910ef..d45492d 100644 --- a/glade_forms/README.md +++ b/glade_forms/README.md @@ -22,7 +22,8 @@ A universal way to define form validators with support of translations. - [Validation](#validation) - [Using validators without GladeInput](#using-validators-without-gladeinput) - [GladeModel](#glademodel) - - [`GladeFormBuilder` and `GladeFormProvider`](#gladeformbuilder-and-gladeformprovider) + - [Flutter widgets](#flutter-widgets) + - [Edit multiple inputs at once](#edit-multiple-inputs-at-once) - [Dependencies](#dependencies) - [Controlling other inputs](#controlling-other-inputs) - [Translation](#translation) @@ -125,13 +126,18 @@ On each input we can define - **translateError** - If there are validation errors, function for error translations can be provided. - **inputKey** - For debug purposes and dependencies, each input can have unique name for simple identification. - **dependencies** - Each input can depend on another inputs for validation. - - **valueConverter** - If input is used by TextField and `T` is not a `String`, value converter should be provided. + - **stringTovalueConverter** - If input is used by TextField and `T` is not a `String`, value converter should be provided. - **valueComparator** - Sometimes it is handy to provied `initialValue` which will be never updated after input is mutated. `valueComparator` should be provided to compare `initialValue` and `value` if `T` is not comparable type by default. + - **valueTransform** - transform `T` value into different `T` value. An example of usage can be sanitazation of string input (trim(),...). - **defaultTranslation** - If error's translations are simple, the default translation settings can be set instead of custom `translateError` method. - **textEditingController** - It is possible to provide custom instance of controller instead of default one. Most of the time, input is created with `.create()` factory with defined validation, translation and other properties. +An overview how each input's value is updated. If needed it is converted from `string` into `T`, then transformed via `valueTransform` (if provided), after that new value is set. + +![input-flow-example](https://raw.githubusercontent.com/netglade/glade_forms/main/glade_forms/doc/flow-input-chart.png) + #### StringInput StringInput is specialized variant of GladeInput which has additional, string related, validations such as `isEmail`, `isUrl`, `maxLength` and more. @@ -198,10 +204,39 @@ and properties such as `isValid` or `formattedErrors` will not work. For updating input call either `updateValueWithString(String?)` to update `T` value with string (will be converted if needed) or set `value` directly (via setter). -#### `GladeFormBuilder` and `GladeFormProvider` +#### Flutter widgets `GladeModelProvider` is predefined widget to provide `GladeModel` to widget's subtreee. -Similarly `GladeFormBuilder` allows to listen to model's changes and rebuilts its child. + +`GladeFormBuilder` allows to listen to model's changes and rebuilts its child. + +`GladeFormListener` allows to listen to model's changes and react to it. Useful for invoking side-effects such as showing dialogs, snackbars etc. `listener` provides `lastUpdatedKeys` which is list of last updated input keys. + +`GladeFormConsumer` combines GladeFormBuilder and GladeFormListener together. + +#### Edit multiple inputs at once +With each update of input, via update or setting `.value` directly, listeners (if any) are triggered. Sometimes it is needed to edit multiple inputs at once and triggering listener in the end. + +For editing multiple values use `groupEdit()`. It takes void callback to update inputs. + +An example + +```dart +class FormModel extends GladeModel { + late GladeInput age; + late GladeInput name; + + // .... + + groupEdit(() { + age.value = 18; + name.value = 'default john', + }); +} + +``` + +After that listener will contain `lastUpdatedKeys` with keys of `age` and `name` inputs. ### Dependencies Input can have dependencies on other inputs to allow dependent validation. Define input's dependencies with `dependencies`. diff --git a/glade_forms/doc/flow-input-chart.png b/glade_forms/doc/flow-input-chart.png new file mode 100644 index 0000000..e6c0c03 Binary files /dev/null and b/glade_forms/doc/flow-input-chart.png differ diff --git a/glade_forms/example/lib/example.dart b/glade_forms/example/lib/example.dart index ff22f53..818d7a3 100644 --- a/glade_forms/example/lib/example.dart +++ b/glade_forms/example/lib/example.dart @@ -12,9 +12,9 @@ class _Model extends GladeModel { @override void initialize() { - name = GladeInput.stringInput(); - age = GladeInput.intInput(value: 0); - email = GladeInput.stringInput(validator: (validator) => (validator..isEmail()).build()); + name = GladeInput.stringInput(inputKey: 'name'); + age = GladeInput.intInput(value: 0, inputKey: 'age'); + email = GladeInput.stringInput(validator: (validator) => (validator..isEmail()).build(), inputKey: 'email'); super.initialize(); } @@ -25,7 +25,7 @@ class Example extends StatelessWidget { @override Widget build(BuildContext context) { - return GladeFormBuilder( + return GladeFormBuilder.create( create: (context) => _Model(), builder: (context, model, _) => Padding( padding: const EdgeInsets.all(32), diff --git a/glade_forms/lib/src/core/changes_info.dart b/glade_forms/lib/src/core/changes_info.dart index 6959932..1bb8067 100644 --- a/glade_forms/lib/src/core/changes_info.dart +++ b/glade_forms/lib/src/core/changes_info.dart @@ -3,7 +3,7 @@ import 'package:glade_forms/src/validator/validator_result.dart'; class ChangesInfo extends Equatable { final T? initialValue; - final T previousValue; + final T? previousValue; final T value; final ValidatorResult? validatorResult; diff --git a/glade_forms/lib/src/core/glade_input.dart b/glade_forms/lib/src/core/glade_input.dart index 95949c9..12748f5 100644 --- a/glade_forms/lib/src/core/glade_input.dart +++ b/glade_forms/lib/src/core/glade_input.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flutter/widgets.dart'; import 'package:glade_forms/src/converters/glade_type_converters.dart'; import 'package:glade_forms/src/core/changes_info.dart'; @@ -9,15 +11,18 @@ import 'package:glade_forms/src/core/type_helper.dart'; import 'package:glade_forms/src/model/glade_model.dart'; import 'package:glade_forms/src/validator/validator.dart'; import 'package:glade_forms/src/validator/validator_result.dart'; +import 'package:meta/meta.dart'; typedef ValueComparator = bool Function(T? initial, T? value); typedef ValidatorFactory = ValidatorInstance Function(GladeValidator v); typedef StringValidatorFactory = ValidatorInstance Function(StringValidator validator); - typedef OnChange = void Function(ChangesInfo info, InputDependencies dependencies); +typedef ValueTransform = T Function(T input); typedef StringInput = GladeInput; +T _defaultTransform(T input) => input; + class GladeInput extends ChangeNotifier { /// Compares initial and current value. @protected @@ -32,10 +37,9 @@ class GladeInput extends ChangeNotifier { final InputDependenciesFactory dependenciesFactory; /// An input's identification. - final String? inputKey; - - /// Initial value - does not change after creating. - final T? initialValue; + /// + /// Used within listener changes and dependency related funcions such as validation. + final String inputKey; final ErrorTranslator? translateError; @@ -45,6 +49,12 @@ class GladeInput extends ChangeNotifier { /// Called when input's value changed. OnChange? onChange; + /// Transforms passed value before assigning it into input. + ValueTransform valueTransform; + + /// Initial value - does not change after creating. + T? _initialValue; + TextEditingController? _textEditingController; final StringToTypeConverter _defaultConverter = StringToTypeConverter(converter: (x, _) => x as T); @@ -52,18 +62,25 @@ class GladeInput extends ChangeNotifier { /// Current input's value. T _value; + /// Previous inputs'value. + T? _previousValue; + /// Input did not updated its value from initialValue. bool _isPure; /// Input is in invalid state when there was conversion error. - bool _conversionError = false; + ConvertError? __conversionError; GladeModel? _bindedModel; + T? get initialValue => _initialValue; + TextEditingController? get controller => _textEditingController; T get value => _value; + T? get previousValue => _previousValue; + /// Input's value was not changed. bool get isPure => _isPure; @@ -72,27 +89,24 @@ class GladeInput extends ChangeNotifier { /// [value] is equal to [initialValue]. /// /// Can be dirty or pure. - bool get isUnchanged => (valueComparator?.call(initialValue, value) ?? value) == initialValue; + bool get isUnchanged => valueComparator?.call(initialValue, value) ?? (value == initialValue); /// Input does not have conversion error nor validation error. - bool get isValid => !_conversionError && _validator(value).isValid; + bool get isValid => !hasConversionError && _validator(value).isValid; bool get isNotValid => !isValid; - bool get hasConversionError => _conversionError; + bool get hasConversionError => __conversionError != null; /// String representattion of [value]. String get stringValue => stringTovalueConverter?.convertBack(value) ?? value.toString(); - // ignore: no_runtimetype_tostring, in this case it is ok - only for dev purposes - String get inputName => inputKey ?? '$runtimeType($value)'; - set value(T value) { - final previousValue = _value; - _value = value; + _previousValue = _value; - final strValue = stringValue; + _value = valueTransform(value); + final strValue = stringValue; // synchronize text controller with value _textEditingController?.value = TextEditingValue( text: strValue, @@ -100,12 +114,12 @@ class GladeInput extends ChangeNotifier { ); _isPure = false; - _conversionError = false; + __conversionError = null; // propagate input's changes onChange?.call( ChangesInfo( - previousValue: previousValue, + previousValue: _previousValue, value: value, initialValue: initialValue, validatorResult: validate(), @@ -113,27 +127,38 @@ class GladeInput extends ChangeNotifier { dependenciesFactory(), ); - _bindedModel?.notifyInputUpdated(); + _bindedModel?.notifyInputUpdated(this); notifyListeners(); } + // ignore: avoid_setters_without_getters, ok for internal use + set _conversionError(ConvertError value) { + __conversionError = value; + _bindedModel?.notifyInputUpdated(this); + } + GladeInput({ required T value, required this.validatorInstance, required bool isPure, - required this.initialValue, required this.valueComparator, - required this.inputKey, + required String? inputKey, required this.translateError, required this.stringTovalueConverter, - required this.dependenciesFactory, + required InputDependenciesFactory? dependenciesFactory, required this.defaultTranslations, required this.onChange, + required ValueTransform? valueTransform, + T? initialValue, TextEditingController? textEditingController, bool createTextController = true, }) : _isPure = isPure, _value = value, + _initialValue = initialValue, + dependenciesFactory = dependenciesFactory ?? (() => []), + inputKey = inputKey ?? '__${T.runtimeType}__${Random().nextInt(100000000)}', + valueTransform = valueTransform ?? _defaultTransform, _textEditingController = textEditingController ?? (createTextController ? TextEditingController( @@ -147,67 +172,10 @@ class GladeInput extends ChangeNotifier { validatorInstance.bindInput(this); } - GladeInput.pure( - T value, { - T? initialValue, - String? inputKey, - ValueComparator? valueComparator, - StringToTypeConverter? valueConverter, - ValidatorInstance? validatorInstance, - InputDependenciesFactory? dependencies, - ErrorTranslator? translateError, - DefaultTranslations? defaultTranslations, - OnChange? onChange, - TextEditingController? textEditingController, - bool createTextController = true, - }) : this( - value: value, - isPure: true, - inputKey: inputKey, - initialValue: initialValue ?? value, - valueComparator: valueComparator, - stringTovalueConverter: valueConverter, - dependenciesFactory: dependencies ?? () => [], - validatorInstance: validatorInstance ?? GladeValidator().build(), - translateError: translateError, - defaultTranslations: defaultTranslations, - onChange: onChange, - textEditingController: textEditingController, - createTextController: createTextController, - ); - - GladeInput.dirty( - T value, { - T? initialValue, - String? inputKey, - ValueComparator? valueComparator, - StringToTypeConverter? valueConverter, - ValidatorInstance? validatorInstance, - InputDependenciesFactory? dependencies, - ErrorTranslator? translateError, - DefaultTranslations? defaultTranslations, - OnChange? onChange, - TextEditingController? textEditingController, - bool createTextController = true, - }) : this( - value: value, - isPure: false, - inputKey: inputKey, - initialValue: initialValue, - valueComparator: valueComparator, - stringTovalueConverter: valueConverter, - dependenciesFactory: dependencies ?? () => [], - validatorInstance: validatorInstance ?? GladeValidator().build(), - translateError: translateError, - defaultTranslations: defaultTranslations, - onChange: onChange, - textEditingController: textEditingController, - createTextController: createTextController, - ); - factory GladeInput.create({ /// Sets current value of input. required T value, + String? inputKey, ValidatorFactory? validator, /// Initial value when GenericInput is created. @@ -216,43 +184,33 @@ class GladeInput extends ChangeNotifier { T? initialValue, bool pure = true, ErrorTranslator? translateError, - ValueComparator? comparator, - String? inputKey, + ValueComparator? valueComparator, StringToTypeConverter? valueConverter, InputDependenciesFactory? dependencies, OnChange? onChange, TextEditingController? textEditingController, bool createTextController = true, + ValueTransform? valueTransform, + DefaultTranslations? defaultTranslations, }) { final validatorInstance = validator?.call(GladeValidator()) ?? GladeValidator().build(); - return pure - ? GladeInput.pure( - value, - validatorInstance: validatorInstance, - initialValue: initialValue ?? value, - translateError: translateError, - valueComparator: comparator, - inputKey: inputKey, - valueConverter: valueConverter, - dependencies: dependencies, - onChange: onChange, - textEditingController: textEditingController, - createTextController: createTextController, - ) - : GladeInput.dirty( - value, - validatorInstance: validatorInstance, - initialValue: initialValue, - translateError: translateError, - valueComparator: comparator, - inputKey: inputKey, - valueConverter: valueConverter, - dependencies: dependencies, - onChange: onChange, - textEditingController: textEditingController, - createTextController: createTextController, - ); + return GladeInput( + value: value, + isPure: pure, + validatorInstance: validatorInstance, + initialValue: initialValue, + translateError: translateError, + valueComparator: valueComparator, + inputKey: inputKey, + stringTovalueConverter: valueConverter, + dependenciesFactory: dependencies, + onChange: onChange, + textEditingController: textEditingController, + createTextController: createTextController, + valueTransform: valueTransform, + defaultTranslations: defaultTranslations, + ); } // Predefined GenericInput without any validations. @@ -262,23 +220,24 @@ class GladeInput extends ChangeNotifier { /// In case of need of any validation use [GladeInput.create] directly. factory GladeInput.optional({ required T value, + String? inputKey, T? initialValue, bool pure = true, ErrorTranslator? translateError, - ValueComparator? comparator, - String? inputKey, + ValueComparator? valueComparator, StringToTypeConverter? valueConverter, InputDependenciesFactory? dependencies, OnChange? onChange, TextEditingController? textEditingController, bool createTextController = true, + ValueTransform? valueTransform, }) => GladeInput.create( validator: (v) => v.build(), value: value, initialValue: initialValue, translateError: translateError, - comparator: comparator, + valueComparator: valueComparator, valueConverter: valueConverter, inputKey: inputKey, pure: pure, @@ -286,6 +245,7 @@ class GladeInput extends ChangeNotifier { onChange: onChange, textEditingController: textEditingController, createTextController: createTextController, + valueTransform: valueTransform, ); /// Predefined GenericInput with predefined `notNull` validation. @@ -293,23 +253,24 @@ class GladeInput extends ChangeNotifier { /// In case of need of any aditional validation use [GladeInput.create] directly. factory GladeInput.required({ required T value, + String? inputKey, T? initialValue, bool pure = true, ErrorTranslator? translateError, - ValueComparator? comparator, - String? inputKey, + ValueComparator? valueComparator, StringToTypeConverter? valueConverter, InputDependenciesFactory? dependencies, OnChange? onChange, TextEditingController? textEditingController, bool createTextController = true, + ValueTransform? valueTransform, }) => GladeInput.create( validator: (v) => (v..notNull()).build(), value: value, initialValue: initialValue, translateError: translateError, - comparator: comparator, + valueComparator: valueComparator, valueConverter: valueConverter, inputKey: inputKey, pure: pure, @@ -317,23 +278,26 @@ class GladeInput extends ChangeNotifier { onChange: onChange, textEditingController: textEditingController, createTextController: createTextController, + valueTransform: valueTransform, ); + @internal // ignore: use_setters_to_change_properties, as method. void bindToModel(GladeModel model) => _bindedModel = model; static GladeInput intInput({ required int value, + String? inputKey, ValidatorFactory? validator, int? initialValue, bool pure = true, ErrorTranslator? translateError, - ValueComparator? comparator, - String? inputKey, + ValueComparator? valueComparator, InputDependenciesFactory? dependencies, OnChange? onChange, TextEditingController? textEditingController, bool createTextController = true, + ValueTransform? valueTransform, }) => GladeInput.create( value: value, @@ -341,27 +305,29 @@ class GladeInput extends ChangeNotifier { validator: validator, pure: pure, translateError: translateError, - comparator: comparator, + valueComparator: valueComparator, inputKey: inputKey, dependencies: dependencies, valueConverter: GladeTypeConverters.intConverter, onChange: onChange, textEditingController: textEditingController, createTextController: createTextController, + valueTransform: valueTransform, ); static GladeInput boolInput({ required bool value, + String? inputKey, ValidatorFactory? validator, bool? initialValue, bool pure = true, ErrorTranslator? translateError, - ValueComparator? comparator, - String? inputKey, + ValueComparator? valueComparator, InputDependenciesFactory? dependencies, OnChange? onChange, TextEditingController? textEditingController, bool createTextController = true, + ValueTransform? valueTransform, }) => GladeInput.create( value: value, @@ -369,69 +335,63 @@ class GladeInput extends ChangeNotifier { validator: validator, pure: pure, translateError: translateError, - comparator: comparator, + valueComparator: valueComparator, inputKey: inputKey, dependencies: dependencies, valueConverter: GladeTypeConverters.boolConverter, onChange: onChange, textEditingController: textEditingController, createTextController: createTextController, + valueTransform: valueTransform, ); static GladeInput stringInput({ + String? inputKey, String? value, StringValidatorFactory? validator, String? initialValue, bool pure = true, ErrorTranslator? translateError, DefaultTranslations? defaultTranslations, - String? inputKey, InputDependenciesFactory? dependencies, OnChange? onChange, TextEditingController? textEditingController, bool createTextController = true, bool isRequired = true, + ValueTransform? valueTransform, + ValueComparator? valueComparator, }) { final requiredInstance = validator?.call(StringValidator()..notEmpty()) ?? (StringValidator()..notEmpty()).build(); final optionalInstance = validator?.call(StringValidator()) ?? StringValidator().build(); - return pure - ? GladeInput.pure( - value, - initialValue: initialValue, - validatorInstance: isRequired ? requiredInstance : optionalInstance, - translateError: translateError, - defaultTranslations: defaultTranslations, - inputKey: inputKey, - dependencies: dependencies, - onChange: onChange, - textEditingController: textEditingController, - createTextController: createTextController, - ) - : GladeInput.dirty( - value, - initialValue: initialValue, - validatorInstance: isRequired ? requiredInstance : optionalInstance, - translateError: translateError, - inputKey: inputKey, - defaultTranslations: defaultTranslations, - dependencies: dependencies, - onChange: onChange, - textEditingController: textEditingController, - createTextController: createTextController, - ); + return GladeInput( + value: value, + isPure: pure, + initialValue: initialValue, + validatorInstance: isRequired ? requiredInstance : optionalInstance, + translateError: translateError, + defaultTranslations: defaultTranslations, + inputKey: inputKey, + dependenciesFactory: dependencies, + onChange: onChange, + textEditingController: textEditingController, + createTextController: createTextController, + valueComparator: valueComparator, + stringTovalueConverter: null, + valueTransform: valueTransform, + ); } - GladeInput asDirty(T value) => copyWith(isPure: false, value: value); - - GladeInput asPure(T value) => copyWith(isPure: true, value: value); - ValidatorResult validate() => _validator(value); String? translate({String delimiter = '.'}) => _translate(delimiter: delimiter, customError: validatorResult); - String errorFormatted({String delimiter = '|'}) => - validatorResult.isInvalid ? validatorResult.errors.map((e) => e.toString()).join(delimiter) : ''; + String errorFormatted({String delimiter = '|'}) { + // ignore: avoid-non-null-assertion, it is not null + if (hasConversionError) return _translateConversionError(__conversionError!); + + return validatorResult.isInvalid ? validatorResult.errors.map((e) => e.toString()).join(delimiter) : ''; + } /// Shorthand validator for TextFieldForm inputs. /// @@ -480,18 +440,40 @@ class GladeInput extends ChangeNotifier { try { this.value = converter.convert(strValue); - } on ConvertError { - _conversionError = true; + } on ConvertError catch (conversionError) { + _conversionError = conversionError; + } + } + + // ignore: use_setters_to_change_properties, used as shorthand for field setter. + void updateValue(T value) => this.value = value; + + /// Resets input into pure state. + /// + /// Allows to sets new initialValue and value if needed. + /// By default ([invokeUpdate]=`true`) setting value will trigger listeners. + void resetToPure({ValueGetter? value, ValueGetter? initialValue, bool invokeUpdate = true}) { + this._isPure = true; + if (value != null) { + if (invokeUpdate) { + this.value = value(); + } else { + _value = value(); + } + } + + if (initialValue != null) { + this._initialValue = initialValue(); } } @protected GladeInput copyWith({ + String? inputKey, ValueComparator? valueComparator, ValidatorInstance? validatorInstance, StringToTypeConverter? stringTovalueConverter, InputDependenciesFactory? dependenciesFactory, - String? inputKey, T? initialValue, ErrorTranslator? translateError, T? value, @@ -501,6 +483,7 @@ class GladeInput extends ChangeNotifier { TextEditingController? textEditingController, // ignore: avoid-unused-parameters, it is here just to be linter happy ¯\_(ツ)_/¯ bool? createTextController, + ValueTransform? valueTransform, }) { return GladeInput( value: value ?? this.value, @@ -515,6 +498,7 @@ class GladeInput extends ChangeNotifier { defaultTranslations: defaultTranslations ?? this.defaultTranslations, onChange: onChange ?? this.onChange, textEditingController: textEditingController ?? this._textEditingController, + valueTransform: valueTransform ?? this.valueTransform, ); } @@ -529,13 +513,7 @@ class GladeInput extends ChangeNotifier { } if (err is ConvertError) { - final defaultTranslationsTmp = this.defaultTranslations; - final translateErrorTmp = translateError; - if (translateErrorTmp != null) { - return translateErrorTmp(err, err.key, err.devErrorMessage, dependenciesFactory()); - } else if (defaultTranslationsTmp != null && defaultTranslationsTmp.defaultConversionMessage != null) { - return defaultTranslationsTmp.defaultConversionMessage; - } + return _translateConversionError(err); } //ignore: avoid-dynamic, ok for now @@ -546,6 +524,20 @@ class GladeInput extends ChangeNotifier { return err.toString(); } + String _translateConversionError(ConvertError err) { + final defaultTranslationsTmp = this.defaultTranslations; + final translateErrorTmp = translateError; + final defaultConversionMessage = defaultTranslationsTmp?.defaultConversionMessage; + + if (translateErrorTmp != null) { + return translateErrorTmp(err, err.key, err.devErrorMessage, dependenciesFactory()); + } else if (defaultConversionMessage != null) { + return defaultConversionMessage; + } + + return err.devErrorMessage; + } + ValidatorResult _validator(T value) { return validatorInstance.validate(value); } diff --git a/glade_forms/lib/src/model/glade_model.dart b/glade_forms/lib/src/model/glade_model.dart index 822623f..b81538f 100644 --- a/glade_forms/lib/src/model/glade_model.dart +++ b/glade_forms/lib/src/model/glade_model.dart @@ -3,6 +3,9 @@ import 'package:glade_forms/src/core/core.dart'; import 'package:meta/meta.dart'; abstract class GladeModel extends ChangeNotifier { + List> _lastUpdates = []; + bool _groupEdit = false; + bool get isValid => inputs.every((input) => input.isValid); bool get isNotValid => !isValid; @@ -15,15 +18,17 @@ abstract class GladeModel extends ChangeNotifier { List> get inputs; + List get lastUpdatedInputKeys => _lastUpdates.map((e) => e.inputKey).toList(); + /// Formats errors from `inputs`. String get formattedValidationErrors => inputs.map((e) { - if (e.hasConversionError) return '${e.inputKey ?? e.runtimeType} - CONVERSION ERROR'; + if (e.hasConversionError) return '${e.inputKey} - CONVERSION ERROR'; if (e.validatorResult.isInvalid) { - return '${e.inputKey ?? e.runtimeType} - ${e.errorFormatted()}'; + return '${e.inputKey} - ${e.errorFormatted()}'; } - return '${e.inputKey ?? e.runtimeType} - VALID'; + return '${e.inputKey} - VALID'; }).join('\n'); List get errors => inputs.map((e) => e.validatorResult).toList(); @@ -39,6 +44,11 @@ abstract class GladeModel extends ChangeNotifier { @mustBeOverridden @protected void initialize() { + assert( + inputs.map((e) => e.inputKey).length == inputs.map((e) => e.inputKey).toSet().length, + 'Model contains inputs with duplicated key!', + ); + for (final input in inputs) { input.bindToModel(this); } @@ -56,10 +66,30 @@ abstract class GladeModel extends ChangeNotifier { void updateInput, T>(INPUT input, T value) { if (input.value == value) return; + _lastUpdates = [input]; + input.value = value; notifyListeners(); } @internal - void notifyInputUpdated() => notifyListeners(); + void notifyInputUpdated(GladeInput input) { + if (_groupEdit) { + _lastUpdates.add(input); + } else { + _lastUpdates = [input]; + notifyListeners(); + } + } + + /// Use it to update multiple inputs at once before these changes are popragated through notifyListeners(). + void groupEdit(void Function() edit) { + _groupEdit = true; + + edit(); + + _groupEdit = false; + + notifyListeners(); + } } diff --git a/glade_forms/lib/src/widgets/glade_form_builder.dart b/glade_forms/lib/src/widgets/glade_form_builder.dart index c65b4b2..105473f 100644 --- a/glade_forms/lib/src/widgets/glade_form_builder.dart +++ b/glade_forms/lib/src/widgets/glade_form_builder.dart @@ -12,17 +12,11 @@ class GladeFormBuilder extends StatelessWidget { final Widget? child; factory GladeFormBuilder({ - required CreateModelFunction create, required GladeFormWidgetBuilder builder, Key? key, Widget? child, }) => - GladeFormBuilder._( - builder: builder, - create: create, - key: key, - child: child, - ); + GladeFormBuilder._(builder: builder, key: key, child: child); const GladeFormBuilder._({ required this.builder, @@ -32,6 +26,19 @@ class GladeFormBuilder extends StatelessWidget { this.child, }); + factory GladeFormBuilder.create({ + required CreateModelFunction create, + required GladeFormWidgetBuilder builder, + Widget? child, + Key? key, + }) => + GladeFormBuilder._( + builder: builder, + create: create, + key: key, + child: child, + ); + factory GladeFormBuilder.value({ required GladeFormWidgetBuilder builder, required M value, diff --git a/glade_forms/lib/src/widgets/glade_form_consumer.dart b/glade_forms/lib/src/widgets/glade_form_consumer.dart new file mode 100644 index 0000000..ab2ea6b --- /dev/null +++ b/glade_forms/lib/src/widgets/glade_form_consumer.dart @@ -0,0 +1,29 @@ +import 'package:flutter/widgets.dart'; +import 'package:glade_forms/src/model/glade_model.dart'; +import 'package:glade_forms/src/widgets/glade_form_builder.dart'; +import 'package:glade_forms/src/widgets/glade_form_listener.dart'; + +class GladeFormConsumer extends StatelessWidget { + final GladeFormWidgetBuilder builder; + final GladeFormListenerFn? listener; + final Widget? child; + + const GladeFormConsumer({ + required this.builder, + super.key, + this.listener, + this.child, + }); + + @override + Widget build(BuildContext context) { + if (listener case final listenerValue?) { + return GladeFormListener( + listener: listenerValue, + child: GladeFormBuilder(builder: builder, child: child), + ); + } + + return GladeFormBuilder(builder: builder, child: child); + } +} diff --git a/glade_forms/lib/src/widgets/glade_form_listener.dart b/glade_forms/lib/src/widgets/glade_form_listener.dart new file mode 100644 index 0000000..dc37942 --- /dev/null +++ b/glade_forms/lib/src/widgets/glade_form_listener.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:glade_forms/src/model/glade_model.dart'; +import 'package:provider/provider.dart'; + +typedef GladeFormListenerFn = void Function( + BuildContext context, + M model, + List lastUpdatedInputKey, +); + +class GladeFormListener extends StatefulWidget { + final Widget child; + final GladeFormListenerFn listener; + + const GladeFormListener({ + required this.listener, + required this.child, + super.key, + }); + + @override + State> createState() => _GladeFormListenerState(); +} + +class _GladeFormListenerState extends State> { + M? model; + + @override + void initState() { + super.initState(); + context.read().addListener(_onModelUpdate); + } + + @override + void dispose() { + model?.removeListener(_onModelUpdate); + super.dispose(); + } + + @override + void didChangeDependencies() { + model = context.read(); + super.didChangeDependencies(); + } + + void _onModelUpdate() { + final m = context.read(); + widget.listener(context, m, m.lastUpdatedInputKeys); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} diff --git a/glade_forms/lib/src/widgets/glade_model_debug_info.dart b/glade_forms/lib/src/widgets/glade_model_debug_info.dart index 748bf0a..01b2517 100644 --- a/glade_forms/lib/src/widgets/glade_model_debug_info.dart +++ b/glade_forms/lib/src/widgets/glade_model_debug_info.dart @@ -70,7 +70,7 @@ class GladeModelDebugInfo extends StatelessWidget { for (final x in model.inputs) TableRow( children: [ - Center(child: Text(x.inputName)), + Center(child: Text(x.inputKey)), Center(child: Text(x.isUnchanged.toString())), Center(child: Text(x.isValid.toString())), Center(child: Text(x.errorFormatted())), diff --git a/glade_forms/lib/src/widgets/widgets.dart b/glade_forms/lib/src/widgets/widgets.dart index 05cc8bd..020828a 100644 --- a/glade_forms/lib/src/widgets/widgets.dart +++ b/glade_forms/lib/src/widgets/widgets.dart @@ -1,3 +1,5 @@ export 'glade_form_builder.dart'; +export 'glade_form_consumer.dart'; +export 'glade_form_listener.dart'; export 'glade_model_debug_info.dart'; export 'glade_model_provider.dart'; diff --git a/glade_forms/pubspec.yaml b/glade_forms/pubspec.yaml index af5eda7..f1719f7 100644 --- a/glade_forms/pubspec.yaml +++ b/glade_forms/pubspec.yaml @@ -1,6 +1,6 @@ name: glade_forms description: A universal way to define form validators with support of translations. -version: 1.1.2 +version: 1.2.0 repository: https://github.com/netglade/glade_forms issue_tracker: https://github.com/netglade/glade_forms/issues screenshots: diff --git a/glade_forms/test/glade_input_test.dart b/glade_forms/test/glade_input_test.dart index e3c611c..81b7c28 100644 --- a/glade_forms/test/glade_input_test.dart +++ b/glade_forms/test/glade_input_test.dart @@ -3,7 +3,7 @@ import 'package:test/test.dart'; void main() { test('GladeInput with non-nullable type', () { - final input = GladeInput.create(validator: (v) => v.build(), value: 0); + final input = GladeInput.create(validator: (v) => v.build(), value: 0, inputKey: 'a'); expect(input.isValid, isTrue); }); diff --git a/glade_forms/test/model/group_edit_test.dart b/glade_forms/test/model/group_edit_test.dart new file mode 100644 index 0000000..dd580d3 --- /dev/null +++ b/glade_forms/test/model/group_edit_test.dart @@ -0,0 +1,85 @@ +import 'package:glade_forms/glade_forms.dart'; +import 'package:test/test.dart'; + +class _Model extends GladeModel { + late GladeInput a; + late GladeInput b; + + @override + List> get inputs => [a, b]; + + @override + void initialize() { + a = GladeInput.intInput(value: 0, inputKey: 'a'); + b = GladeInput.intInput(value: 1, inputKey: 'b'); + + super.initialize(); + } +} + +void main() { + test('When updating [a] listeners is called', () { + final model = _Model(); + var listenerCount = 0; + model.addListener(() { + listenerCount++; + }); + + model.a.value = 5; + expect(model.a.value, equals(5)); + expect(model.lastUpdatedInputKeys, equals(['a'])); + expect(listenerCount, equals(1), reason: 'Should be called'); + }); + + test('When updating [a] listeners is called', () { + final model = _Model(); + var listenerCount = 0; + model.addListener(() { + listenerCount++; + }); + + model.b.value = 5; + expect(model.b.value, equals(5)); + expect(model.lastUpdatedInputKeys, equals(['b'])); + expect(listenerCount, equals(1), reason: 'Should be called'); + }); + + test('When updating [a] and [b] listeners are called two times', () { + final model = _Model(); + var listenerCount = 0; + model.addListener(() { + listenerCount++; + }); + + model.a.value = 3; + + expect(model.a.value, equals(3)); + expect(model.lastUpdatedInputKeys, equals(['a'])); + + model.b.value = 5; + + expect(model.b.value, equals(5)); + expect(model.lastUpdatedInputKeys, equals(['b'])); + expect(listenerCount, equals(2), reason: 'Should be called'); + }); + + test('When updating [a] and [b] at once listener is called once', () { + final model = _Model(); + var listenerCount = 0; + model.addListener(() { + listenerCount++; + }); + + // ignore: cascade_invocations, under test ok. + model.groupEdit(() { + model.a.value = 3; + model.b.value = 5; + }); + + expect(model.a.value, equals(3)); + expect(model.b.value, equals(5)); + + expect(model.lastUpdatedInputKeys, equals(['a', 'b'])); + expect(listenerCount, equals(1), reason: 'Should be called'); + }); +} diff --git a/storybook/lib/usecases/complex_object_mapping_example.dart b/storybook/lib/usecases/complex_object_mapping_example.dart index 290eb09..d10c346 100644 --- a/storybook/lib/usecases/complex_object_mapping_example.dart +++ b/storybook/lib/usecases/complex_object_mapping_example.dart @@ -28,10 +28,12 @@ class _Model extends GladeModel { selectedItem = GladeInput.create( validator: (v) => (v..notNull()).build(), value: null, + inputKey: 'selectedItem', ); availableStats = GladeInput.create( validator: (v) => v.build(), value: [], + inputKey: 'stats', valueConverter: StringToTypeConverter( converter: (rawInput, cantConvert) { final r = RegExp(r'^\d+(,\s*\d+\s*)*$'); @@ -65,7 +67,7 @@ class ComplexObjectMappingExample extends StatelessWidget { return UsecaseContainer( shortDescription: 'Converters and complex objects', className: 'complex_object_mapping_example.dart', - child: GladeFormBuilder( + child: GladeFormBuilder.create( create: (context) => _Model(), builder: (context, model, _) { return Form( diff --git a/storybook/lib/usecases/one_checkbox_deps_validation.dart b/storybook/lib/usecases/one_checkbox_deps_validation.dart index 1a414e0..c0e5783 100644 --- a/storybook/lib/usecases/one_checkbox_deps_validation.dart +++ b/storybook/lib/usecases/one_checkbox_deps_validation.dart @@ -78,7 +78,7 @@ If *VIP content* is checked, **age** must be over 18. shortDescription: "Age input validation depends on checkbox's value", description: markdownData, className: 'one_checkbox_deps_validation.dart', - child: GladeFormBuilder( + child: GladeFormBuilder.create( create: (context) => AgeRestrictedModel(), builder: (context, formModel, _) => Padding( padding: const EdgeInsets.all(8), diff --git a/storybook/lib/usecases/quickstart_example.dart b/storybook/lib/usecases/quickstart_example.dart index 68e2adc..7b262e8 100644 --- a/storybook/lib/usecases/quickstart_example.dart +++ b/storybook/lib/usecases/quickstart_example.dart @@ -14,9 +14,9 @@ class _Model extends GladeModel { @override void initialize() { - name = GladeInput.stringInput(); - age = GladeInput.intInput(value: 0); - email = GladeInput.stringInput(validator: (validator) => (validator..isEmail()).build()); + name = GladeInput.stringInput(inputKey: 'name'); + age = GladeInput.intInput(value: 0, inputKey: 'age'); + email = GladeInput.stringInput(validator: (validator) => (validator..isEmail()).build(), inputKey: 'email'); super.initialize(); } @@ -29,7 +29,7 @@ class QuickStartExample extends StatelessWidget { Widget build(BuildContext context) { return UsecaseContainer( shortDescription: 'Quick start example', - child: GladeFormBuilder( + child: GladeFormBuilder.create( create: (context) => _Model(), builder: (context, model, _) => Padding( padding: const EdgeInsets.all(32), diff --git a/storybook/lib/usecases/two_way_checkbox_change.dart b/storybook/lib/usecases/two_way_checkbox_change.dart index 02aac4e..396453c 100644 --- a/storybook/lib/usecases/two_way_checkbox_change.dart +++ b/storybook/lib/usecases/two_way_checkbox_change.dart @@ -67,7 +67,9 @@ class AgeRestrictedModel extends GladeModel { final age = dependencies.byKey('age-input'); if (info.value && age.value < 18) { - age.value = 18; + groupEdit(() { + age.value = 18; + }); } }, ); @@ -91,7 +93,7 @@ If *age* is changed to value under 18, *vip content* is unchecked and vice-versa shortDescription: "Age input depends on checkbox's value automatically", description: markdownData, className: 'two_way_checkbox_change.dart', - child: GladeFormBuilder( + child: GladeFormBuilder.create( create: (context) => AgeRestrictedModel(), builder: (context, formModel, _) => Padding( padding: const EdgeInsets.all(8),