diff --git a/.fvm/fvm_config.json b/.fvm/fvm_config.json index 2bb3348..3e33393 100644 --- a/.fvm/fvm_config.json +++ b/.fvm/fvm_config.json @@ -1,4 +1,3 @@ { - "flutterSdkVersion": "3.13.3", - "flavors": {} + "flutterSdkVersion": "3.13.3" } \ No newline at end of file diff --git a/.fvm/release b/.fvm/release new file mode 100644 index 0000000..faedad4 --- /dev/null +++ b/.fvm/release @@ -0,0 +1 @@ +3.13.3 \ No newline at end of file diff --git a/.fvm/version b/.fvm/version new file mode 100644 index 0000000..faedad4 --- /dev/null +++ b/.fvm/version @@ -0,0 +1 @@ +3.13.3 \ No newline at end of file diff --git a/.fvm/versions/3.13.3 b/.fvm/versions/3.13.3 new file mode 120000 index 0000000..e409c66 --- /dev/null +++ b/.fvm/versions/3.13.3 @@ -0,0 +1 @@ +C:/Users/nymsa/fvm/versions/3.13.3 \ No newline at end of file diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 0000000..7c3600f --- /dev/null +++ b/.fvmrc @@ -0,0 +1,3 @@ +{ + "flutter": "3.13.3" +} \ No newline at end of file diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6cd18b3..ff76aa9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,7 +11,7 @@ jobs: - uses: actions/checkout@v4 - name: Configure FVM - uses: kuhnroyal/flutter-fvm-config-action@v1 + uses: kuhnroyal/flutter-fvm-config-action@v2 id: fvm-config-action with: path: ".fvm/fvm_config.json" diff --git a/.github/workflows/storybook.yaml b/.github/workflows/storybook.yaml index e4cd04b..a9bb642 100644 --- a/.github/workflows/storybook.yaml +++ b/.github/workflows/storybook.yaml @@ -36,7 +36,7 @@ jobs: - uses: actions/checkout@v4 - name: Configure FVM - uses: kuhnroyal/flutter-fvm-config-action@v1 + uses: kuhnroyal/flutter-fvm-config-action@v2 with: path: ".fvm/fvm_config.json" @@ -57,14 +57,14 @@ jobs: run: flutter build web --base-href "/glade_forms/" - name: Setup Pages - uses: actions/configure-pages@v3 + uses: actions/configure-pages@v4 - name: Upload artifact - uses: actions/upload-pages-artifact@v2 + uses: actions/upload-pages-artifact@v3 with: # Upload entire repository path: "storybook/build/web/" - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v2 + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 610202d..71a6a54 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ melos_my_project.iml .vscode/* !.vscode/tasks.json +!.vscode/launch.json !.vscode/settings.json .packages diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..579d1a7 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,22 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "storybook", + "cwd": "storybook", + "request": "launch", + "type": "dart", + "program": "lib/main.dart" + }, + { + "name": "storybook (profile)", + "cwd": "storybook", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 07c439d..ddc2c1c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { - "dart.flutterSdkPath": ".fvm/flutter_sdk", - "dart.lineLength": 120, + "dart.flutterSdkPath": ".fvm\\versions\\3.13.3", + "dart.lineLength": 120, + "yaml.schemaStore.enable": false } \ No newline at end of file diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..3d9d7b2 --- /dev/null +++ b/TODO.md @@ -0,0 +1,10 @@ +GladeModel = sync verze + - Pouze SYNC inputy + - Podpora default TextFieldu +AsyncGladeModel + - ASYNC inicializace + - SYNC i ASYNC inputy + - Podpora pro GladeAsyncTextField + - Validace async + - IsValid async + - Translate async \ No newline at end of file diff --git a/glade_forms/README.md b/glade_forms/README.md index d97bbd1..efa4156 100644 --- a/glade_forms/README.md +++ b/glade_forms/README.md @@ -26,6 +26,7 @@ A universal way to define form validators with support of translations. - [Edit multiple inputs at once](#edit-multiple-inputs-at-once) - [Dependencies](#dependencies) - [Controlling other inputs](#controlling-other-inputs) + - [Asynchronous form](#asynchronous-form) - [Translation](#translation) - [Converters](#converters) - [Debugging](#debugging) @@ -73,7 +74,7 @@ Then use `GladeFormBuilder` and connect the model to standard Flutter form and it's inputs like this: ```dart -GladeFormBuilder( +GladeFormBuilder.create( create: (context) => _Model(), builder: (context, model) => Form( autovalidateMode: AutovalidateMode.onUserInteraction, @@ -312,6 +313,103 @@ onChange: (info, dependencies) { ![two-way-inputs-example](https://raw.githubusercontent.com/netglade/glade_forms/main/glade_forms/doc/two-way-dependencies.gif) +### Asynchronous form + +When the default data for the form are for example fetched from external API you can use `GladeModelAsync` like this: + +```dart +Future _fetchName() { + return Future.delayed(const Duration(seconds: 2), () => 'John Doe'); +} +class _Model extends GladeModelAsync { + late StringInput name; + late GladeInput age; + late StringInput email; + late GladeInput vip; + @override + List> get inputs => [name, age, email, vip]; + _Model(); + @override + Future initializeAsync() async { + final nameValue = await _fetchName(); + name = GladeInput.stringInput(inputKey: 'name', value: nameValue); + age = GladeInput.intInput(value: 0, inputKey: 'age'); + email = GladeInput.stringInput(validator: (validator) => (validator..isEmail()).build(), inputKey: 'email'); + vip = GladeInput.create( + validator: (v) => (v..notNull()).build(), + value: false, + inputKey: 'vip', + dependencies: () => [name], + onChangeAsync: (info, dependencies) async { + final nameInput = dependencies.byKey('name'); + final fetchedName = await _fetchName(); + groupEdit(() { + nameInput.value = fetchedName; + }); + }, + ); + await super.initializeAsync(); + } +} +``` + +Then use `GladeFormBuilder` and connect the model to standard Flutter form and it's inputs like this: + +```dart +GladeFormBuilder.create( + create: (context) => _Model(), + builder: (context, model, _) => Padding( + padding: const EdgeInsets.all(32), + child: Form( + autovalidateMode: AutovalidateMode.onUserInteraction, + child: Column( + children: [ + if (model.isChanging) const LinearProgressIndicator(color: Colors.red, backgroundColor: Colors.grey), + TextFormField( + controller: model.name.controller, + validator: model.name.textFormFieldInputValidator, + onChanged: model.name.updateValueWithString, + decoration: const InputDecoration(labelText: 'Name'), + readOnly: model.isChanging, + ), + TextFormField( + controller: model.age.controller, + validator: model.age.textFormFieldInputValidator, + onChanged: model.age.updateValueWithString, + decoration: const InputDecoration(labelText: 'Age'), + readOnly: model.isChanging, + ), + TextFormField( + controller: model.email.controller, + validator: model.email.textFormFieldInputValidator, + onChanged: model.email.updateValueWithString, + decoration: const InputDecoration(labelText: 'Email'), + readOnly: model.isChanging, + ), + CheckboxListTile( + value: model.vip.value, + title: Row( + children: [ + const Text('VIP Content '), + if (model.isChanging) const Text('isChanging', style: TextStyle(color: Colors.red)), + ], + ), + onChanged: (v) => model.vip.value = v ?? false, + ), + const SizedBox(height: 10), + ElevatedButton( + onPressed: model.isValid + ? () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Saved'))) + : null, + child: const Text('Save'), + ), + ], + ), + ), + ), +), +``` + ### Translation Each validation error (and conversion error if any) can be translated. Provide `translateError` function which accepts: diff --git a/glade_forms/analysis_options.yaml b/glade_forms/analysis_options.yaml index 14cbcba..e0c7a4c 100644 --- a/glade_forms/analysis_options.yaml +++ b/glade_forms/analysis_options.yaml @@ -3,5 +3,7 @@ include: package:netglade_analysis/lints.yaml dart_code_metrics: extends: - package:netglade_analysis/dcm.yaml + rules: + - prefer-correct-callback-field-name: false pubspec-rules: prefer-publish-to-none: false diff --git a/glade_forms/lib/src/core/async_glade_input.dart b/glade_forms/lib/src/core/async_glade_input.dart new file mode 100644 index 0000000..4daf92c --- /dev/null +++ b/glade_forms/lib/src/core/async_glade_input.dart @@ -0,0 +1,599 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:glade_forms/src/converters/glade_type_converters.dart'; +import 'package:glade_forms/src/core/changes_info.dart'; +import 'package:glade_forms/src/core/convert_error.dart'; +import 'package:glade_forms/src/core/error_translator.dart'; +import 'package:glade_forms/src/core/glade_input_base.dart'; +import 'package:glade_forms/src/core/input_dependencies.dart'; +import 'package:glade_forms/src/core/string_to_type_converter.dart'; +import 'package:glade_forms/src/core/type_helper.dart'; +import 'package:glade_forms/src/model/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 OnChangeAsync = Future Function(ChangesInfo info, InputDependencies dependencies); +typedef AsyncStringInput = AsyncGladeInput; + +T _defaultTransform(T input) => input; + +class AsyncGladeInput extends GladeInputBase { + /// Compares initial and current value. + @protected + final ValueComparator? valueComparator; + + @protected + final ValidatorInstance validatorInstance; + + @protected + final StringToTypeConverter? stringTovalueConverter; + + final ErrorTranslator? translateError; + + /// Validation message for conversion error. + final DefaultTranslations? defaultTranslations; + + /// Called when input's value changed. + OnChangeAsync? onChangeAsync; + + /// Transforms passed value before assigning it into input. + ValueTransform valueTransform; + + final InputDependenciesFactory _dependenciesFactory; + + /// Initial value - does not change after creating. + T? _initialValue; + + TextEditingController? _textEditingController; + + final StringToTypeConverter _defaultConverter = StringToTypeConverter(converter: (x, _) => x as T); + + /// 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. + ConvertError? __conversionError; + + GladeModelBase? _bindedModel; + + bool __isChanging = false; + + T? get initialValue => _initialValue; + + TextEditingController? get controller => _textEditingController; + + @override + T get value => _value; + + T? get previousValue => _previousValue; + + /// Input's value was not changed. + @override + bool get isPure => _isPure; + + @override + @Deprecated('Use [validatorResultAsync] in AsyncGladeInput') + ValidatorResult get validatorResult => throw UnsupportedError('Use [validatorResultAsync] in AsyncGladeInput'); + + @override + Future> get validatorResultAsync => _validator(value); + + /// [value] is equal to [initialValue]. + /// + /// Can be dirty or pure. + @override + bool get isUnchanged => valueComparator?.call(initialValue, value) ?? (value == initialValue); + + /// Input does not have conversion error nor validation error. + @override + @Deprecated('Use [isValidAsync] in AsyncGladeInput') + bool get isValid => throw UnsupportedError('Use [isValidAsync] in AsyncGladeInput'); + + @override + Future get isValidAsync async => !hasConversionError && (await _validator(value)).isValid; + + @override + @Deprecated('Use [isNotValidAsync] in AsyncGladeInput') + bool get isNotValid => throw UnsupportedError('Use [isNotValidAsync] in AsyncGladeInput'); + + @override + Future get isNotValidAsync async => !(await isValidAsync); + + @override + bool get hasConversionError => __conversionError != null; + + /// String representattion of [value]. + String get stringValue => stringTovalueConverter?.convertBack(value) ?? value.toString(); + + @override + InputDependencies get dependencies => _dependenciesFactory(); + + @override + bool get isChanging => __isChanging; + + // ignore: avoid_setters_without_getters, ok for internal use + set _conversionError(ConvertError value) { + __conversionError = value; + _bindedModel?.notifyInputUpdated(this); + } + + AsyncGladeInput({ + required T value, + required this.validatorInstance, + required bool isPure, + required this.valueComparator, + required super.inputKey, + required this.translateError, + required this.stringTovalueConverter, + required InputDependenciesFactory? dependenciesFactory, + required this.defaultTranslations, + required this.onChangeAsync, + required ValueTransform? valueTransform, + T? initialValue, + TextEditingController? textEditingController, + bool createTextController = true, + }) : _isPure = isPure, + _value = value, + _initialValue = initialValue, + _dependenciesFactory = dependenciesFactory ?? (() => []), + valueTransform = valueTransform ?? _defaultTransform, + _textEditingController = textEditingController ?? + (createTextController + ? TextEditingController( + text: switch (value) { + final String? x => x, + != null => stringTovalueConverter?.convertBack(value), + _ => null, + }, + ) + : null) { + validatorInstance.bindInput(this); + } + + factory AsyncGladeInput.create({ + /// Sets current value of input. + required T value, + String? inputKey, + ValidatorFactory? validator, + + /// Initial value when GenericInput is created. + /// + /// This value can potentially differ from value. Used for computing `isUnchanged`. + T? initialValue, + bool pure = true, + ErrorTranslator? translateError, + ValueComparator? valueComparator, + StringToTypeConverter? valueConverter, + InputDependenciesFactory? dependencies, + OnChangeAsync? onChangeAsync, + TextEditingController? textEditingController, + bool createTextController = true, + ValueTransform? valueTransform, + DefaultTranslations? defaultTranslations, + }) { + final validatorInstance = validator?.call(GladeValidator()) ?? GladeValidator().build(); + + return AsyncGladeInput( + value: value, + isPure: pure, + validatorInstance: validatorInstance, + initialValue: initialValue, + translateError: translateError, + valueComparator: valueComparator, + inputKey: inputKey, + stringTovalueConverter: valueConverter, + dependenciesFactory: dependencies, + onChangeAsync: onChangeAsync, + textEditingController: textEditingController, + createTextController: createTextController, + valueTransform: valueTransform, + defaultTranslations: defaultTranslations, + ); + } + + /// + /// Useful for input which allows null value without additional validations. + /// + /// In case of need of any validation use [GladeInput.create] directly. + factory AsyncGladeInput.optional({ + required T value, + String? inputKey, + T? initialValue, + bool pure = true, + ErrorTranslator? translateError, + ValueComparator? valueComparator, + StringToTypeConverter? valueConverter, + InputDependenciesFactory? dependencies, + OnChangeAsync? onChangeAsync, + TextEditingController? textEditingController, + bool createTextController = true, + ValueTransform? valueTransform, + }) => + AsyncGladeInput.create( + validator: (v) => v.build(), + value: value, + initialValue: initialValue, + translateError: translateError, + valueComparator: valueComparator, + valueConverter: valueConverter, + inputKey: inputKey, + pure: pure, + dependencies: dependencies, + onChangeAsync: onChangeAsync, + textEditingController: textEditingController, + createTextController: createTextController, + valueTransform: valueTransform, + ); + + /// Predefined GenericInput with predefined `notNull` validation. + /// + /// In case of need of any aditional validation use [GladeInput.create] directly. + factory AsyncGladeInput.required({ + required T value, + String? inputKey, + T? initialValue, + bool pure = true, + ErrorTranslator? translateError, + ValueComparator? valueComparator, + StringToTypeConverter? valueConverter, + InputDependenciesFactory? dependencies, + OnChangeAsync? onChangeAsync, + TextEditingController? textEditingController, + bool createTextController = true, + ValueTransform? valueTransform, + }) => + AsyncGladeInput.create( + validator: (v) => (v..notNull()).build(), + value: value, + initialValue: initialValue, + translateError: translateError, + valueComparator: valueComparator, + valueConverter: valueConverter, + inputKey: inputKey, + pure: pure, + dependencies: dependencies, + onChangeAsync: onChangeAsync, + textEditingController: textEditingController, + createTextController: createTextController, + valueTransform: valueTransform, + ); + + @override + @Deprecated('Use [updateValueAsync] in AsyncGladeInput') + void updateValue(T value) => throw UnsupportedError('Use [updateValueAsync] in AsyncGladeInput'); + + @override + Future updateValueAsync(T value) async { + __isChanging = true; + _previousValue = _value; + + _value = valueTransform(value); + + final strValue = stringValue; + // synchronize text controller with value + _textEditingController?.value = TextEditingValue( + text: strValue, + selection: TextSelection.collapsed(offset: strValue.length), + ); + + _isPure = false; + __conversionError = null; + + // propagate input's changes + await onChangeAsync?.call( + ChangesInfo( + previousValue: _previousValue, + value: value, + initialValue: initialValue, + validatorResult: await validate(), + ), + _dependenciesFactory(), + ); + __isChanging = false; + _bindedModel?.notifyInputUpdated(this); + + notifyListeners(); + } + + @override + @internal + // ignore: use_setters_to_change_properties, as method. + void bindToModel(GladeModelBase model) => _bindedModel = model; + + static AsyncGladeInput intInput({ + required int value, + String? inputKey, + ValidatorFactory? validator, + int? initialValue, + bool pure = true, + ErrorTranslator? translateError, + ValueComparator? valueComparator, + InputDependenciesFactory? dependencies, + OnChangeAsync? onChangeAsync, + TextEditingController? textEditingController, + bool createTextController = true, + ValueTransform? valueTransform, + }) => + AsyncGladeInput.create( + value: value, + initialValue: initialValue, + validator: validator, + pure: pure, + translateError: translateError, + valueComparator: valueComparator, + inputKey: inputKey, + dependencies: dependencies, + valueConverter: GladeTypeConverters.intConverter, + onChangeAsync: onChangeAsync, + textEditingController: textEditingController, + createTextController: createTextController, + valueTransform: valueTransform, + ); + + static AsyncGladeInput boolInput({ + required bool value, + String? inputKey, + ValidatorFactory? validator, + bool? initialValue, + bool pure = true, + ErrorTranslator? translateError, + ValueComparator? valueComparator, + InputDependenciesFactory? dependencies, + OnChangeAsync? onChangeAsync, + TextEditingController? textEditingController, + bool createTextController = true, + ValueTransform? valueTransform, + }) => + AsyncGladeInput.create( + value: value, + initialValue: initialValue, + validator: validator, + pure: pure, + translateError: translateError, + valueComparator: valueComparator, + inputKey: inputKey, + dependencies: dependencies, + valueConverter: GladeTypeConverters.boolConverter, + onChangeAsync: onChangeAsync, + textEditingController: textEditingController, + createTextController: createTextController, + valueTransform: valueTransform, + ); + + static AsyncGladeInput stringInput({ + String? inputKey, + String? value, + StringValidatorFactory? validator, + String? initialValue, + bool pure = true, + ErrorTranslator? translateError, + DefaultTranslations? defaultTranslations, + InputDependenciesFactory? dependencies, + OnChangeAsync? onChangeAsync, + 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 AsyncStringInput( + value: value, + isPure: pure, + initialValue: initialValue, + validatorInstance: isRequired ? requiredInstance : optionalInstance, + translateError: translateError, + defaultTranslations: defaultTranslations, + inputKey: inputKey, + dependenciesFactory: dependencies, + onChangeAsync: onChangeAsync, + textEditingController: textEditingController, + createTextController: createTextController, + valueComparator: valueComparator, + stringTovalueConverter: null, + valueTransform: valueTransform, + ); + } + + Future> validate() => _validator(value); + + Future translate({String delimiter = '.'}) async => + _translate(delimiter: delimiter, customError: await validatorResultAsync); + + @override + @Deprecated('Use [errorFormattedAsync] in AsyncGladeInput') + String errorFormatted({String delimiter = '|'}) => + throw UnsupportedError('Use [errorFormattedAsync] in AsyncGladeInput'); + + @override + Future errorFormattedAsync({String delimiter = '|'}) async { + // ignore: avoid-non-null-assertion, it is not null + if (hasConversionError) return _translateConversionError(__conversionError!); + + final validation = await validatorResultAsync; + + return validation.isInvalid ? validation.errors.map((e) => e.toString()).join(delimiter) : ''; + } + + /// Shorthand validator for TextFieldForm inputs. + /// + /// Returns translated validation message. + /// If there are multiple errors they are concenated into one string with [delimiter]. + Future textFormFieldInputValidatorCustom(String? value, {String delimiter = '.'}) async { + assert( + TypeHelper.typesEqual() || TypeHelper.typesEqual() || stringTovalueConverter != null, + 'For non-string values [converter] must be provided. TInput type: $T', + ); + final converter = stringTovalueConverter ?? _defaultConverter; + + try { + final convertedValue = converter.convert(value); + final convertedError = await _validator(convertedValue); + + return !convertedError.isValid ? await _translate(delimiter: delimiter, customError: convertedError) : null; + } on ConvertError catch (e) { + return e.error != null + ? await _translate(delimiter: delimiter, customError: e) + : e.devError(value, extra: await validatorResultAsync); + } + } + + /// Shorthand validator for TextFieldForm inputs. + /// + /// Returns translated validation message. + Future textFormFieldInputValidator(String? value) => textFormFieldInputValidatorCustom(value); + + /// Shorthand validator for Form field input. + /// + /// Returns translated validation message. + Future formFieldValidator(T value) async { + final convertedError = await _validator(value); + + return convertedError.isInvalid ? _translate(customError: convertedError) : null; + } + + @override + @Deprecated('Use [updateValueWithStringAsync] in AsyncGladeInput') + void updateValueWithString(String? strValue) => + throw UnsupportedError('Use [updateValueWithStringAsync] in AsyncGladeInput'); + + @override + Future updateValueWithStringAsync(String? strValue) async { + assert( + TypeHelper.typesEqual() || TypeHelper.typesEqual() || stringTovalueConverter != null, + 'For non-string values [converter] must be provided. TInput type: ${T.runtimeType}', + ); + + final converter = stringTovalueConverter ?? _defaultConverter; + + try { + await updateValueAsync(converter.convert(strValue)); + } on ConvertError catch (e) { + _conversionError = e; + } + } + + // 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) { + updateValue(value()); + } else { + _value = value(); + } + } + + if (initialValue != null) { + this._initialValue = initialValue(); + } + } + + @protected + AsyncGladeInput copyWith({ + String? inputKey, + ValueComparator? valueComparator, + ValidatorInstance? validatorInstance, + StringToTypeConverter? stringTovalueConverter, + InputDependenciesFactory? dependenciesFactory, + T? initialValue, + ErrorTranslator? translateError, + T? value, + bool? isPure, + DefaultTranslations? defaultTranslations, + OnChangeAsync? onChangeAsync, + TextEditingController? textEditingController, + // ignore: avoid-unused-parameters, it is here just to be linter happy ¯\_(ツ)_/¯ + bool? createTextController, + ValueTransform? valueTransform, + }) { + return AsyncGladeInput( + value: value ?? this.value, + valueComparator: valueComparator ?? this.valueComparator, + validatorInstance: validatorInstance ?? this.validatorInstance, + stringTovalueConverter: stringTovalueConverter ?? this.stringTovalueConverter, + dependenciesFactory: dependenciesFactory ?? this._dependenciesFactory, + inputKey: inputKey ?? this.inputKey, + initialValue: initialValue ?? this.initialValue, + translateError: translateError ?? this.translateError, + isPure: isPure ?? this.isPure, + defaultTranslations: defaultTranslations ?? this.defaultTranslations, + onChangeAsync: onChangeAsync ?? this.onChangeAsync, + textEditingController: textEditingController ?? this._textEditingController, + valueTransform: valueTransform ?? this.valueTransform, + ); + } + + /// Translates input's errors (validation or conversion). + Future _translate({String delimiter = '.', Object? customError}) async { + final err = customError ?? await validatorResultAsync; + + if (err is ValidatorResult && err.isValid) return null; + + if (err is ValidatorResult) { + return _translateGenericErrors(err, delimiter); + } + + if (err is ConvertError) { + return _translateConversionError(err); + } + + //ignore: avoid-dynamic, ok for now + if (err is List) { + return err.map((x) => x.toString()).join('.'); + } + + 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; + } + + Future> _validator(T value) => validatorInstance.validateAsync(value); + + String _translateGenericErrors(ValidatorResult inputErrors, String delimiter) { + final translateErrorTmp = translateError; + + final defaultTranslationsTmp = this.defaultTranslations; + if (translateErrorTmp != null) { + return inputErrors.errors + .map((e) => translateErrorTmp(e, e.key, e.devErrorMessage, _dependenciesFactory())) + .join(delimiter); + } + + return inputErrors.errors.map((e) { + if (defaultTranslationsTmp != null && (e.isNullError || e.hasStringEmptyOrNullErrorKey)) { + return defaultTranslationsTmp.defaultValueIsNullOrEmptyMessage ?? e.toString(); + } + + return e.toString(); + }).join(delimiter); + } +} diff --git a/glade_forms/lib/src/core/core.dart b/glade_forms/lib/src/core/core.dart index bcf0106..01f25b1 100644 --- a/glade_forms/lib/src/core/core.dart +++ b/glade_forms/lib/src/core/core.dart @@ -1,7 +1,10 @@ +export 'async_glade_input.dart'; +export 'changes_info.dart'; export 'convert_error.dart'; export 'error_translator.dart'; export 'glade_error_keys.dart'; export 'glade_input.dart'; +export 'glade_input_base.dart'; export 'glade_input_error.dart'; export 'input_dependencies.dart'; export 'string_to_type_converter.dart'; diff --git a/glade_forms/lib/src/core/glade_input.dart b/glade_forms/lib/src/core/glade_input.dart index b19dd3e..c61f7c8 100644 --- a/glade_forms/lib/src/core/glade_input.dart +++ b/glade_forms/lib/src/core/glade_input.dart @@ -1,29 +1,22 @@ -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'; import 'package:glade_forms/src/core/convert_error.dart'; import 'package:glade_forms/src/core/error_translator.dart'; +import 'package:glade_forms/src/core/glade_input_base.dart'; import 'package:glade_forms/src/core/input_dependencies.dart'; import 'package:glade_forms/src/core/string_to_type_converter.dart'; import 'package:glade_forms/src/core/type_helper.dart'; -import 'package:glade_forms/src/model/glade_model.dart'; +import 'package:glade_forms/src/model/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 { +class GladeInput extends GladeInputBase { /// Compares initial and current value. @protected final ValueComparator? valueComparator; @@ -34,13 +27,6 @@ class GladeInput extends ChangeNotifier { @protected final StringToTypeConverter? stringTovalueConverter; - final InputDependenciesFactory dependenciesFactory; - - /// An input's identification. - /// - /// Used within listener changes and dependency related funcions such as validation. - final String inputKey; - final ErrorTranslator? translateError; /// Validation message for conversion error. @@ -52,6 +38,8 @@ class GladeInput extends ChangeNotifier { /// Transforms passed value before assigning it into input. ValueTransform valueTransform; + final InputDependenciesFactory _dependenciesFactory; + /// Initial value - does not change after creating. T? _initialValue; @@ -71,37 +59,56 @@ class GladeInput extends ChangeNotifier { /// Input is in invalid state when there was conversion error. ConvertError? __conversionError; - GladeModel? _bindedModel; + GladeModelBase? _bindedModel; + + bool _isChanging = false; T? get initialValue => _initialValue; TextEditingController? get controller => _textEditingController; + @override T get value => _value; T? get previousValue => _previousValue; /// Input's value was not changed. + @override bool get isPure => _isPure; + @override ValidatorResult get validatorResult => _validator(value); + @override + Future> get validatorResultAsync async => validatorResult; + /// [value] is equal to [initialValue]. /// /// Can be dirty or pure. + @override bool get isUnchanged => valueComparator?.call(initialValue, value) ?? (value == initialValue); /// Input does not have conversion error nor validation error. + @override bool get isValid => !hasConversionError && _validator(value).isValid; - bool get isNotValid => !isValid; + @override + Future get isValidAsync async => isValid; + @override bool get hasConversionError => __conversionError != null; /// String representattion of [value]. String get stringValue => stringTovalueConverter?.convertBack(value) ?? value.toString(); + @override + InputDependencies get dependencies => _dependenciesFactory(); + + @override + bool get isChanging => _isChanging; + set value(T value) { + _isChanging = true; _previousValue = _value; _value = valueTransform(value); @@ -124,9 +131,9 @@ class GladeInput extends ChangeNotifier { initialValue: initialValue, validatorResult: validate(), ), - dependenciesFactory(), + _dependenciesFactory(), ); - + _isChanging = false; _bindedModel?.notifyInputUpdated(this); notifyListeners(); @@ -143,7 +150,7 @@ class GladeInput extends ChangeNotifier { required this.validatorInstance, required bool isPure, required this.valueComparator, - required String? inputKey, + required super.inputKey, required this.translateError, required this.stringTovalueConverter, required InputDependenciesFactory? dependenciesFactory, @@ -156,8 +163,7 @@ class GladeInput extends ChangeNotifier { }) : _isPure = isPure, _value = value, _initialValue = initialValue, - dependenciesFactory = dependenciesFactory ?? (() => []), - inputKey = inputKey ?? '__${T.runtimeType}__${Random().nextInt(100000000)}', + _dependenciesFactory = dependenciesFactory ?? (() => []), valueTransform = valueTransform ?? _defaultTransform, _textEditingController = textEditingController ?? (createTextController @@ -213,7 +219,6 @@ class GladeInput extends ChangeNotifier { ); } - // Predefined GenericInput without any validations. /// /// Useful for input which allows null value without additional validations. /// @@ -281,9 +286,10 @@ class GladeInput extends ChangeNotifier { valueTransform: valueTransform, ); + @override @internal // ignore: use_setters_to_change_properties, as method. - void bindToModel(GladeModel model) => _bindedModel = model; + void bindToModel(GladeModelBase model) => _bindedModel = model; static GladeInput intInput({ required int value, @@ -386,6 +392,7 @@ class GladeInput extends ChangeNotifier { String? translate({String delimiter = '.'}) => _translate(delimiter: delimiter, customError: validatorResult); + @override String errorFormatted({String delimiter = '|'}) { // ignore: avoid-non-null-assertion, it is not null if (hasConversionError) return _translateConversionError(__conversionError!); @@ -393,6 +400,9 @@ class GladeInput extends ChangeNotifier { return validatorResult.isInvalid ? validatorResult.errors.map((e) => e.toString()).join(delimiter) : ''; } + @override + Future errorFormattedAsync({String delimiter = '|'}) async => errorFormatted(delimiter: delimiter); + /// Shorthand validator for TextFieldForm inputs. /// /// Returns translated validation message. @@ -409,10 +419,10 @@ class GladeInput extends ChangeNotifier { final convertedError = _validator(convertedValue); return !convertedError.isValid ? _translate(delimiter: delimiter, customError: convertedError) : null; - } on ConvertError catch (formatError) { - return formatError.error != null - ? _translate(delimiter: delimiter, customError: formatError) - : formatError.devError(value, extra: validatorResult); + } on ConvertError catch (e) { + return e.error != null + ? _translate(delimiter: delimiter, customError: e) + : e.devError(value, extra: validatorResult); } } @@ -430,6 +440,7 @@ class GladeInput extends ChangeNotifier { return convertedError.isInvalid ? _translate(customError: convertedError) : null; } + @override void updateValueWithString(String? strValue) { assert( TypeHelper.typesEqual() || TypeHelper.typesEqual() || stringTovalueConverter != null, @@ -440,14 +451,21 @@ class GladeInput extends ChangeNotifier { try { this.value = converter.convert(strValue); - } on ConvertError catch (conversionError) { - _conversionError = conversionError; + } on ConvertError catch (e) { + _conversionError = e; } } + @override + Future updateValueWithStringAsync(String? strValue) async => updateValueWithString(strValue); + // ignore: use_setters_to_change_properties, used as shorthand for field setter. + @override void updateValue(T value) => this.value = value; + @override + Future updateValueAsync(T value) async => updateValue(value); + /// Resets input into pure state. /// /// Allows to sets new initialValue and value if needed. @@ -485,12 +503,12 @@ class GladeInput extends ChangeNotifier { bool? createTextController, ValueTransform? valueTransform, }) { - return GladeInput( + return GladeInput( value: value ?? this.value, valueComparator: valueComparator ?? this.valueComparator, validatorInstance: validatorInstance ?? this.validatorInstance, stringTovalueConverter: stringTovalueConverter ?? this.stringTovalueConverter, - dependenciesFactory: dependenciesFactory ?? this.dependenciesFactory, + dependenciesFactory: dependenciesFactory ?? this._dependenciesFactory, inputKey: inputKey ?? this.inputKey, initialValue: initialValue ?? this.initialValue, translateError: translateError ?? this.translateError, @@ -530,7 +548,7 @@ class GladeInput extends ChangeNotifier { final defaultConversionMessage = defaultTranslationsTmp?.defaultConversionMessage; if (translateErrorTmp != null) { - return translateErrorTmp(err, err.key, err.devErrorMessage, dependenciesFactory()); + return translateErrorTmp(err, err.key, err.devErrorMessage, _dependenciesFactory()); } else if (defaultConversionMessage != null) { return defaultConversionMessage; } @@ -548,7 +566,7 @@ class GladeInput extends ChangeNotifier { final defaultTranslationsTmp = this.defaultTranslations; if (translateErrorTmp != null) { return inputErrors.errors - .map((e) => translateErrorTmp(e, e.key, e.devErrorMessage, dependenciesFactory())) + .map((e) => translateErrorTmp(e, e.key, e.devErrorMessage, _dependenciesFactory())) .join(delimiter); } diff --git a/glade_forms/lib/src/core/glade_input_base.dart b/glade_forms/lib/src/core/glade_input_base.dart new file mode 100644 index 0000000..04d9c17 --- /dev/null +++ b/glade_forms/lib/src/core/glade_input_base.dart @@ -0,0 +1,65 @@ +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:glade_forms/src/core/changes_info.dart'; +import 'package:glade_forms/src/core/input_dependencies.dart'; +import 'package:glade_forms/src/model/glade_model_base.dart'; +import 'package:glade_forms/src/validator/validator.dart'; +import 'package:glade_forms/src/validator/validator_result.dart'; + +/// isValid +/// isPure +/// isUnchanged +/// isChanging +/// inputKey +/// errorFormatted +/// validatorResult +/// bindToModel +/// canUpdate +/// updateValue + +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); + +abstract class GladeInputBase extends ChangeNotifier { + /// An input's identification. + /// + /// Used within listener changes and dependency related funcions such as validation. + final String inputKey; + + bool get isValid; + Future get isValidAsync; + bool get isPure; + bool get isUnchanged; + bool get isChanging; + bool get hasConversionError; + + bool get isNotValid => !isValid; + Future get isNotValidAsync async => !(await isValidAsync); + + T get value; + + InputDependencies get dependencies; + + ValidatorResult get validatorResult; + + Future> get validatorResultAsync; + + GladeInputBase({required String? inputKey}) + : inputKey = inputKey ?? '__${T.runtimeType}__${Random().nextInt(100000000)}'; + + String errorFormatted({String delimiter = '|'}); + + Future errorFormattedAsync({String delimiter = '|'}); + + void updateValue(T value); + Future updateValueAsync(T value); + + void updateValueWithString(String? strValue); + Future updateValueWithStringAsync(String? strValue); + + void bindToModel(GladeModelBase model); +} diff --git a/glade_forms/lib/src/core/input_dependencies.dart b/glade_forms/lib/src/core/input_dependencies.dart index 0c97f9e..888eea8 100644 --- a/glade_forms/lib/src/core/input_dependencies.dart +++ b/glade_forms/lib/src/core/input_dependencies.dart @@ -8,10 +8,10 @@ typedef InputDependenciesFactory = InputDependencies Function(); extension InputDependenciesFunctions on InputDependencies { /// Finds input by its key or throws. - GladeInput byKey(String key) => firstWhere((x) => x.inputKey == key).cast>(); + GladeInput byKey(String key) => firstWhere((x) => x.inputKey == key).cast(); /// Finds input by its key or returns null. - GladeInput? byKeyOrNull(String key) => firstWhereOrNull((x) => x.inputKey == key).castOrNull>(); + GladeInput? byKeyOrNull(String key) => firstWhereOrNull((x) => x.inputKey == key).castOrNull(); } extension ObjectEx on Object? { diff --git a/glade_forms/lib/src/model/glade_model.dart b/glade_forms/lib/src/model/glade_model.dart index b81538f..8c4354a 100644 --- a/glade_forms/lib/src/model/glade_model.dart +++ b/glade_forms/lib/src/model/glade_model.dart @@ -1,38 +1,8 @@ -import 'package:flutter/foundation.dart'; -import 'package:glade_forms/src/core/core.dart'; +import 'package:glade_forms/src/core/glade_input.dart'; +import 'package:glade_forms/src/model/glade_model_base.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; - - bool get isPure => inputs.every((input) => input.isPure); - - bool get isUnchanged => inputs.every((input) => input.isUnchanged); - - bool get isDirty => !isPure; - - 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} - CONVERSION ERROR'; - - if (e.validatorResult.isInvalid) { - return '${e.inputKey} - ${e.errorFormatted()}'; - } - - return '${e.inputKey} - VALID'; - }).join('\n'); - - List get errors => inputs.map((e) => e.validatorResult).toList(); - +abstract class GladeModel extends GladeModelBase> { GladeModel() { initialize(); } @@ -40,56 +10,11 @@ abstract class GladeModel extends ChangeNotifier { /// Initialize model's inputs. /// /// `super.initialize()` must be called in the end. + @override @mustCallSuper @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); - } - } - - /// Updates model's input with String? value using its converter. - void stringFieldUpdateInput>(INPUT input, String? value) { - if (input.value == value) return; - - input.updateValueWithString(value); - notifyListeners(); - } - - /// Updates model's input value. - void updateInput, T>(INPUT input, T value) { - if (input.value == value) return; - - _lastUpdates = [input]; - - input.value = value; - notifyListeners(); - } - - @internal - 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(); + super.initialize(); } } diff --git a/glade_forms/lib/src/model/glade_model_async.dart b/glade_forms/lib/src/model/glade_model_async.dart new file mode 100644 index 0000000..c81e4f5 --- /dev/null +++ b/glade_forms/lib/src/model/glade_model_async.dart @@ -0,0 +1,24 @@ +import 'dart:async'; + +import 'package:glade_forms/src/core/glade_input_base.dart'; +import 'package:glade_forms/src/model/model.dart'; +import 'package:meta/meta.dart'; + +/// Async version of GladeModel. +abstract class GladeModelAsync extends GladeModelBase> { + GladeModelAsync() { + unawaited(initializeAsync()); + } + + /// Initialize model's inputs. + /// + /// `super.initialize()` must be called in the end. + @mustCallSuper + @mustBeOverridden + @protected + Future initializeAsync() { + super.initialize(); + + return Future.value(); + } +} diff --git a/glade_forms/lib/src/model/glade_model_base.dart b/glade_forms/lib/src/model/glade_model_base.dart new file mode 100644 index 0000000..cbc9f7e --- /dev/null +++ b/glade_forms/lib/src/model/glade_model_base.dart @@ -0,0 +1,100 @@ +import 'package:flutter/foundation.dart'; +import 'package:glade_forms/src/core/core.dart'; +import 'package:meta/meta.dart'; + +typedef OnEdit = void Function(); + +abstract class GladeModelBase> extends ChangeNotifier { + List _lastUpdates = []; + bool _groupEdit = false; + bool _isInitialized = false; + + bool get isInitialized => _isInitialized; + + bool get isValid => inputs.every((input) => input.isValid) && isInitialized; + + bool get isNotValid => !isValid; + + bool get isPure => inputs.every((input) => input.isPure); + + bool get isUnchanged => inputs.every((input) => input.isUnchanged); + + bool get isDirty => !isPure; + + bool get isChanging => inputs.any((input) => input.isChanging); + + 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} - CONVERSION ERROR'; + + if (e.validatorResult.isInvalid) { + return '${e.inputKey} - ${e.errorFormatted()}'; + } + + return '${e.inputKey} - VALID'; + }).join('\n'); + + List get errors => inputs.map((e) => e.validatorResult).toList(); + + /// Initialize model's inputs. + /// + /// `super.initialize()` must be called in the end. + @mustCallSuper + @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); + } + + _isInitialized = true; + notifyListeners(); + } + + /// Updates model's input with String? value using its converter. + void stringFieldUpdateInput>(INPUT input, String? value) { + if (input.value == value) return; + + input.updateValueWithString(value); + notifyListeners(); + } + + /// Updates model's input value. + void updateInput(INPUT input, T value) { + if (input.value == value) return; + + _lastUpdates = [input]; + + input.updateValue(value); + notifyListeners(); + } + + @internal + void notifyInputUpdated(TINPUT 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(OnEdit edit) { + _groupEdit = true; + + edit(); + + _groupEdit = false; + + notifyListeners(); + } +} diff --git a/glade_forms/lib/src/model/model.dart b/glade_forms/lib/src/model/model.dart index 659af4a..02710e8 100644 --- a/glade_forms/lib/src/model/model.dart +++ b/glade_forms/lib/src/model/model.dart @@ -1 +1,3 @@ export 'glade_model.dart'; +export 'glade_model_async.dart'; +export 'glade_model_base.dart'; diff --git a/glade_forms/lib/src/validator/glade_validator.dart b/glade_forms/lib/src/validator/glade_validator.dart index 3280f5a..87df9b1 100644 --- a/glade_forms/lib/src/validator/glade_validator.dart +++ b/glade_forms/lib/src/validator/glade_validator.dart @@ -11,6 +11,12 @@ typedef ValidateFunction = GladeValidatorError? Function( Object? extra, }); +typedef ValidateFunctionAsync = Future?> Function( + T value, { + required InputDependencies dependencies, + Object? extra, +}); + class GladeValidator { List> parts = []; @@ -20,7 +26,7 @@ class GladeValidator { /// Beware that some validators assume non-null value. bool stopOnFirstError = true, }) => - ValidatorInstance(parts: parts, stopOnFirstError: stopOnFirstError); + ValidatorInstance(parts: parts, stopOnFirstError: stopOnFirstError); void clear() => parts = []; @@ -32,6 +38,14 @@ class GladeValidator { }) => parts.add(CustomValidationPart(customValidator: onValidate, key: key, dependencies: dependencies)); + /// Checks value with custom validation function. + void customAsync( + ValidateFunctionAsync onValidate, { + Object? key, + InputDependencies dependencies = const [], + }) => + parts.add(CustomValidationPartAsync(customValidator: onValidate, key: key, dependencies: dependencies)); + /// Checks value through custom validator [part]. void customPart(InputValidatorPart part) => parts.add(part); @@ -64,4 +78,22 @@ class GladeValidator { key: key, ), ); + + /// Value must satisfy given [predicate]. Returns [ValueSatisfyPredicateError]. + void satisfyAsync( + SatisfyPredicateAsync predicate, { + OnValidateError? devError, + InputDependencies dependencies = const [], + Object? extra, + Object? key, + }) => + parts.add( + SatisfyPredicatePartAsync( + predicate: predicate, + devError: devError ?? (value, _) => 'Value ${value ?? 'NULL'} does not satisfy given predicate.', + extra: extra, + dependencies: dependencies, + key: key, + ), + ); } diff --git a/glade_forms/lib/src/validator/part/custom_validation_part.dart b/glade_forms/lib/src/validator/part/custom_validation_part.dart index 2361a60..ee16b82 100644 --- a/glade_forms/lib/src/validator/part/custom_validation_part.dart +++ b/glade_forms/lib/src/validator/part/custom_validation_part.dart @@ -1,13 +1,7 @@ -import 'package:glade_forms/src/core/core.dart'; -import 'package:glade_forms/src/validator/part/input_validator_part.dart'; -import 'package:glade_forms/src/validator/validator_error/glade_validator_error.dart'; +import 'package:glade_forms/src/src.dart'; class CustomValidationPart extends InputValidatorPart { - final GladeValidatorError? Function( - T value, { - required InputDependencies dependencies, - Object? extra, - }) customValidator; + final ValidateFunction customValidator; const CustomValidationPart({ required this.customValidator, @@ -22,4 +16,40 @@ class CustomValidationPart extends InputValidatorPart { InputDependencies dependencies = const [], }) => customValidator(value, extra: extra, dependencies: dependencies); + + @override + Future?> validateAsync( + T value, { + required Object? extra, + InputDependencies dependencies = const [], + }) { + return Future.value(validate(value, extra: extra, dependencies: dependencies)); + } +} + +class CustomValidationPartAsync extends InputValidatorPart { + final ValidateFunctionAsync customValidator; + + const CustomValidationPartAsync({ + required this.customValidator, + required super.dependencies, + super.key, + }); + + @override + GladeValidatorError validate( + T value, { + required Object? extra, + InputDependencies dependencies = const [], + }) => + throw UnsupportedError('Use [validateAsync] in CustomValidationPartAsync'); + + @override + Future?> validateAsync( + T value, { + required Object? extra, + InputDependencies dependencies = const [], + }) { + return customValidator(value, extra: extra, dependencies: dependencies); + } } diff --git a/glade_forms/lib/src/validator/part/input_validator_part.dart b/glade_forms/lib/src/validator/part/input_validator_part.dart index af7c1ff..6c3e18f 100644 --- a/glade_forms/lib/src/validator/part/input_validator_part.dart +++ b/glade_forms/lib/src/validator/part/input_validator_part.dart @@ -14,4 +14,10 @@ abstract class InputValidatorPart { required Object? extra, InputDependencies dependencies = const [], }); + + Future?> validateAsync( + T value, { + required Object? extra, + InputDependencies dependencies = const [], + }); } diff --git a/glade_forms/lib/src/validator/part/satisfy_predicate_part.dart b/glade_forms/lib/src/validator/part/satisfy_predicate_part.dart index 62936bf..707ae71 100644 --- a/glade_forms/lib/src/validator/part/satisfy_predicate_part.dart +++ b/glade_forms/lib/src/validator/part/satisfy_predicate_part.dart @@ -3,6 +3,7 @@ import 'package:glade_forms/src/validator/part/input_validator_part.dart'; import 'package:glade_forms/src/validator/validator_error/validator_error.dart'; typedef SatisfyPredicate = bool Function(T value, Object? extra, InputDependencies dependencies); +typedef SatisfyPredicateAsync = Future Function(T value, Object? extra, InputDependencies dependencies); class SatisfyPredicatePart extends InputValidatorPart { final OnValidateError devError; @@ -35,4 +36,51 @@ class SatisfyPredicatePart extends InputValidatorPart { key: key, ); } + + @override + Future?> validateAsync( + T value, { + required Object? extra, + InputDependencies dependencies = const [], + }) { + return Future.value(validate(value, extra: extra, dependencies: dependencies)); + } +} + +class SatisfyPredicatePartAsync extends InputValidatorPart { + final OnValidateError devError; + + // ignore: no-object-declaration, extra can be any object + final Object? extra; + + final SatisfyPredicateAsync predicate; + + const SatisfyPredicatePartAsync({ + required this.predicate, + required this.devError, + required super.dependencies, + this.extra, + super.key, + }); + + @override + GladeValidatorError validate(T value, {required Object? extra, InputDependencies dependencies = const []}) { + throw UnsupportedError('Use [validateAsync] in SatisfyPredicatePartAsync'); + } + + @override + Future?> validateAsync( + T value, { + required Object? extra, + InputDependencies dependencies = const [], + }) async { + return (await predicate(value, extra, dependencies)) + ? null + : ValueSatisfyPredicateError( + value: value, + devError: devError, + extra: extra, + key: key, + ); + } } diff --git a/glade_forms/lib/src/validator/validator_error/glade_validator_error.dart b/glade_forms/lib/src/validator/validator_error/glade_validator_error.dart index 5f4de9a..4fcb6a4 100644 --- a/glade_forms/lib/src/validator/validator_error/glade_validator_error.dart +++ b/glade_forms/lib/src/validator/validator_error/glade_validator_error.dart @@ -31,8 +31,8 @@ abstract class GladeValidatorError extends GladeInputError with EquatableM this.extra, super.key, }) : devError = devError ?? - ((value, _) => - 'Value "${value ?? 'NULL'}" does not satisfy validation. [This is default validation meessage. Consider to set `devErro` to cutomize validation errors]'); + ((v, _) => + 'Value "${v ?? 'NULL'}" does not satisfy validation. [This is default validation meessage. Consider to set `devErro` to cutomize validation errors]'); factory GladeValidatorError.cantBeNull(T? value, {Object? extra, Object? key}) => ValueNullError(value: value, key: key, extra: extra); diff --git a/glade_forms/lib/src/validator/validator_instance.dart b/glade_forms/lib/src/validator/validator_instance.dart index b983192..3800c01 100644 --- a/glade_forms/lib/src/validator/validator_instance.dart +++ b/glade_forms/lib/src/validator/validator_instance.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:glade_forms/src/core/core.dart'; import 'package:glade_forms/src/validator/part/input_validator_part.dart'; import 'package:glade_forms/src/validator/validator_error/glade_validator_error.dart'; @@ -7,7 +9,7 @@ class ValidatorInstance { /// Stops validation on first error. final bool stopOnFirstError; - GladeInput? _input; + GladeInputBase? _input; final List> _parts; ValidatorInstance({ @@ -16,22 +18,38 @@ class ValidatorInstance { }) : _parts = parts; // ignore: use_setters_to_change_properties, this is ok - void bindInput(GladeInput input) => _input = input; + void bindInput(GladeInputBase input) => _input = input; - /// Performs validation on given [value]. ValidatorResult validate(T value, {Object? extra}) { final errors = >[]; for (final part in _parts) { - final error = part.validate(value, extra: extra, dependencies: _input?.dependenciesFactory() ?? []); + final error = part.validate(value, extra: extra, dependencies: _input?.dependencies ?? []); + + if (error != null) { + errors.add(error); + + if (stopOnFirstError) return ValidatorResult(errors: errors, associatedInput: _input); + } + } + + return ValidatorResult(errors: errors, associatedInput: _input); + } + + /// Performs validation on given [value]. + Future> validateAsync(T value, {Object? extra}) async { + final errors = >[]; + + for (final part in _parts) { + final error = await part.validateAsync(value, extra: extra, dependencies: _input?.dependencies ?? []); if (error != null) { errors.add(error); - if (stopOnFirstError) return ValidatorResult(errors: errors, associatedInput: _input); + if (stopOnFirstError) return ValidatorResult(errors: errors, associatedInput: _input); } } - return ValidatorResult(errors: errors, associatedInput: _input); + return ValidatorResult(errors: errors, associatedInput: _input); } } diff --git a/glade_forms/lib/src/validator/validator_result.dart b/glade_forms/lib/src/validator/validator_result.dart index 397ecd2..756c7be 100644 --- a/glade_forms/lib/src/validator/validator_result.dart +++ b/glade_forms/lib/src/validator/validator_result.dart @@ -3,7 +3,7 @@ import 'package:glade_forms/src/core/core.dart'; import 'package:glade_forms/src/validator/validator_error/glade_validator_error.dart'; class ValidatorResult extends Equatable { - final GladeInput? associatedInput; + final GladeInputBase? associatedInput; final List> errors; bool get isValid => errors.isEmpty; diff --git a/glade_forms/lib/src/widgets/async_glade_form_field.dart b/glade_forms/lib/src/widgets/async_glade_form_field.dart new file mode 100644 index 0000000..3b1e14c --- /dev/null +++ b/glade_forms/lib/src/widgets/async_glade_form_field.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:glade_forms/src/core/core.dart'; + +//todo for now always validate on user interaction. +class AsyncGladeFormField extends StatefulWidget { + final AsyncGladeInput input; + final VoidCallback? onValidation; + + const AsyncGladeFormField({ + required this.input, + super.key, + this.onValidation, + }); + + @override + State createState() => _AsyncGladeFormFieldState(); +} + +class _AsyncGladeFormFieldState extends State { + bool isValidating = false; + String? validationError; + + @override + Widget build(BuildContext context) { + return TextFormField( + autovalidateMode: AutovalidateMode.onUserInteraction, + controller: widget.input.controller, + // ignore: prefer-extracting-callbacks, asdsasa asdas. + validator: (value) { + if (isValidating) return null; + + return validationError; + }, + // ignore: prefer-extracting-callbacks, should be ok., avoid-passing-async-when-sync-expected + onChanged: (value) async { + setState(() { + isValidating = true; + }); + await widget.input.updateValueWithStringAsync(value); + final validation = await widget.input.textFormFieldInputValidator(value); + + if (context.mounted) { + setState(() { + validationError = validation; + isValidating = false; + }); + } + }, + ); + } +} diff --git a/glade_forms/lib/src/widgets/glade_form_builder.dart b/glade_forms/lib/src/widgets/glade_form_builder.dart index 105473f..d5139a4 100644 --- a/glade_forms/lib/src/widgets/glade_form_builder.dart +++ b/glade_forms/lib/src/widgets/glade_form_builder.dart @@ -1,25 +1,32 @@ import 'package:flutter/material.dart'; -import 'package:glade_forms/src/model/glade_model.dart'; +import 'package:glade_forms/src/model/model.dart'; import 'package:glade_forms/src/widgets/glade_model_provider.dart'; import 'package:provider/provider.dart'; -typedef GladeFormWidgetBuilder = Widget Function(BuildContext context, M model, Widget? child); +typedef GladeFormWidgetBuilder = Widget Function( + BuildContext context, + M model, + Widget? child, +); -class GladeFormBuilder extends StatelessWidget { +class GladeFormBuilder extends StatelessWidget { final CreateModelFunction? create; final M? value; final GladeFormWidgetBuilder builder; + final Widget loadingWidget; final Widget? child; factory GladeFormBuilder({ required GladeFormWidgetBuilder builder, + Widget loadingWidget = const Center(child: CircularProgressIndicator()), Key? key, Widget? child, }) => - GladeFormBuilder._(builder: builder, key: key, child: child); + GladeFormBuilder._(builder: builder, loadingWidget: loadingWidget, key: key, child: child); const GladeFormBuilder._({ required this.builder, + required this.loadingWidget, super.key, this.create, this.value, @@ -29,11 +36,13 @@ class GladeFormBuilder extends StatelessWidget { factory GladeFormBuilder.create({ required CreateModelFunction create, required GladeFormWidgetBuilder builder, + Widget loadingWidget = const Center(child: CircularProgressIndicator()), Widget? child, Key? key, }) => GladeFormBuilder._( builder: builder, + loadingWidget: loadingWidget, create: create, key: key, child: child, @@ -42,30 +51,42 @@ class GladeFormBuilder extends StatelessWidget { factory GladeFormBuilder.value({ required GladeFormWidgetBuilder builder, required M value, + Widget loadingWidget = const Center(child: CircularProgressIndicator()), Widget? child, Key? key, }) => GladeFormBuilder._( builder: builder, value: value, + loadingWidget: loadingWidget, key: key, child: child, ); @override Widget build(BuildContext context) { + // if(!value.isInitialized) if (create case final createFn?) { return GladeModelProvider( create: createFn, - child: Consumer(builder: builder, child: child), + child: Consumer( + builder: (context, model, ch) => model.isInitialized ? builder(context, model, ch) : loadingWidget, + child: child, + ), ); } else if (value case final modelValue?) { return GladeModelProvider.value( value: modelValue, - child: Consumer(builder: builder, child: child), + child: Consumer( + builder: (context, model, ch) => model.isInitialized ? builder(context, model, ch) : loadingWidget, + child: child, + ), ); } - return Consumer(builder: builder, child: child); + return Consumer( + builder: (context, model, ch) => model.isInitialized ? builder(context, model, ch) : loadingWidget, + child: child, + ); } } diff --git a/glade_forms/lib/src/widgets/glade_form_listener.dart b/glade_forms/lib/src/widgets/glade_form_listener.dart index dc37942..f16b981 100644 --- a/glade_forms/lib/src/widgets/glade_form_listener.dart +++ b/glade_forms/lib/src/widgets/glade_form_listener.dart @@ -23,7 +23,7 @@ class GladeFormListener extends StatefulWidget { } class _GladeFormListenerState extends State> { - M? model; + M? _model; @override void initState() { @@ -33,13 +33,13 @@ class _GladeFormListenerState extends State(); + _model = context.read(); super.didChangeDependencies(); } 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 01b2517..243135f 100644 --- a/glade_forms/lib/src/widgets/glade_model_debug_info.dart +++ b/glade_forms/lib/src/widgets/glade_model_debug_info.dart @@ -119,7 +119,7 @@ class _DangerStrips extends StatelessWidget { required this.gap, }); - List getListOfStripes(int count) { + List _getListOfStripes(int count) { final stripes = []; for (var i = 0; i < count; i++) { stripes.add( @@ -140,8 +140,7 @@ class _DangerStrips extends StatelessWidget { width: double.infinity, child: LayoutBuilder( builder: (context, constraints) { - // ignore: avoid-returning-widgets, ok here - return Stack(children: getListOfStripes((constraints.maxWidth / 2).ceil())); + return Stack(children: _getListOfStripes((constraints.maxWidth / 2).ceil())); }, ), ); diff --git a/glade_forms/lib/src/widgets/glade_model_provider.dart b/glade_forms/lib/src/widgets/glade_model_provider.dart index 03072fc..378a406 100644 --- a/glade_forms/lib/src/widgets/glade_model_provider.dart +++ b/glade_forms/lib/src/widgets/glade_model_provider.dart @@ -1,10 +1,10 @@ import 'package:flutter/widgets.dart'; -import 'package:glade_forms/src/model/glade_model.dart'; +import 'package:glade_forms/src/model/model.dart'; import 'package:provider/provider.dart'; -typedef CreateModelFunction = M Function(BuildContext context); +typedef CreateModelFunction = M Function(BuildContext context); -class GladeModelProvider extends StatelessWidget { +class GladeModelProvider extends StatelessWidget { final CreateModelFunction? create; final M? value; final Widget child; diff --git a/glade_forms/pubspec.yaml b/glade_forms/pubspec.yaml index f6dac70..eb7eae0 100644 --- a/glade_forms/pubspec.yaml +++ b/glade_forms/pubspec.yaml @@ -20,5 +20,5 @@ dependencies: provider: ^6.0.5 dev_dependencies: - netglade_analysis: ^5.0.0 + netglade_analysis: ^6.0.0 test: ^1.24.6 diff --git a/glade_forms/test/model/group_edit_test.dart b/glade_forms/test/model/group_edit_test.dart index dd580d3..5ebef1b 100644 --- a/glade_forms/test/model/group_edit_test.dart +++ b/glade_forms/test/model/group_edit_test.dart @@ -31,7 +31,7 @@ void main() { expect(listenerCount, equals(1), reason: 'Should be called'); }); - test('When updating [a] listeners is called', () { + test('When updating [b] listeners is called', () { final model = _Model(); var listenerCount = 0; model.addListener(() { diff --git a/storybook/analysis_options.yaml b/storybook/analysis_options.yaml index 11650bc..32a2fc9 100644 --- a/storybook/analysis_options.yaml +++ b/storybook/analysis_options.yaml @@ -9,3 +9,5 @@ analyzer: dart_code_metrics: extends: - package:netglade_analysis/dcm.yaml + rules: + - prefer-correct-callback-field-name: false diff --git a/storybook/lib/localization_addon_custom.dart b/storybook/lib/localization_addon_custom.dart index 87fc90a..a40a635 100644 --- a/storybook/lib/localization_addon_custom.dart +++ b/storybook/lib/localization_addon_custom.dart @@ -9,6 +9,7 @@ class LocalizationAddonCustom extends WidgetbookAddon { // ignore: avoid-dynamic, ok here final List> localizationsDelegates; final ValueChanged onChange; + final Locale initialLocale; @override List get fields { @@ -16,9 +17,9 @@ class LocalizationAddonCustom extends WidgetbookAddon { ListField( name: 'name', values: locales, - initialValue: initialSetting, + initialValue: initialLocale, labelBuilder: (locale) => locale.toLanguageTag(), - onChanged: (context, locale) => locale != null ? onChange(locale) : null, + // onChanged: (context, locale) => locale != null ? onChange(locale) : null, ), ]; } @@ -36,11 +37,9 @@ class LocalizationAddonCustom extends WidgetbookAddon { initialLocale == null || locales.contains(initialLocale), 'initialLocale must be in locales', ), - super( - name: 'Locale', - // ignore: avoid-unsafe-collection-methods, ok here - initialSetting: initialLocale ?? locales.first, - ); + // ignore: avoid-unsafe-collection-methods, the locales are set programmatically and should have at least one element + initialLocale = initialLocale ?? locales.first, + super(name: 'Locale', initialSetting: initialLocale!); @override Locale valueFromQueryGroup(Map group) { diff --git a/storybook/lib/main.dart b/storybook/lib/main.dart index f1148d3..1f63708 100644 --- a/storybook/lib/main.dart +++ b/storybook/lib/main.dart @@ -6,6 +6,7 @@ import 'package:glade_forms_storybook/generated/locale_loader.g.dart'; import 'package:glade_forms_storybook/localization_addon_custom.dart'; import 'package:glade_forms_storybook/usecases/complex_object_mapping_example.dart'; import 'package:glade_forms_storybook/usecases/one_checkbox_deps_validation.dart'; +import 'package:glade_forms_storybook/usecases/quickstart_async_example.dart'; import 'package:glade_forms_storybook/usecases/quickstart_example.dart'; import 'package:glade_forms_storybook/usecases/two_way_checkbox_change.dart'; import 'package:widgetbook/widgetbook.dart'; @@ -45,6 +46,7 @@ class App extends StatelessWidget { ], directories: [ WidgetbookUseCase(name: 'Quickstart form', builder: (context) => const QuickStartExample()), + WidgetbookUseCase(name: 'Quickstart async form', builder: (context) => const QuickStartAsyncExample()), WidgetbookCategory( name: 'Dependencies', children: [ diff --git a/storybook/lib/shared/usecase_container.dart b/storybook/lib/shared/usecase_container.dart index 63744af..0472d1f 100644 --- a/storybook/lib/shared/usecase_container.dart +++ b/storybook/lib/shared/usecase_container.dart @@ -29,7 +29,7 @@ class UsecaseContainer extends HookWidget { appBar: AppBar( title: Text(shortDescription), bottom: TabBar( - tabs: [ + tabs: [ const Tab(text: 'Live Preview', icon: Icon(Icons.live_tv)), if (className != null) const Tab(text: 'Example code', icon: Icon(Icons.code)), if (description != null) const Tab(text: 'Notes', icon: Icon(Icons.notes)), @@ -37,7 +37,7 @@ class UsecaseContainer extends HookWidget { ), ), body: TabBarView( - children: [ + children: [ Center(child: child), if (className case final classNameX?) _CodeSample(fileName: classNameX), if (description case final descriptionX?) Markdown(data: descriptionX), @@ -52,14 +52,13 @@ class _CodeSample extends HookWidget { final String fileName; const _CodeSample({required this.fileName}); - Future getFileContent() { + Future _getFileContent() { return rootBundle.loadString('lib/usecases/$fileName'); } @override Widget build(BuildContext context) { - // ignore: avoid-async-call-in-sync-function, memoize future - final getFileContentFutureMemo = useMemoized(getFileContent); + final getFileContentFutureMemo = useMemoized(_getFileContent); final getFileFuture = useFuture(getFileContentFutureMemo); if (getFileFuture.connectionState == ConnectionState.waiting) { diff --git a/storybook/lib/usecases/quickstart_async_example.dart b/storybook/lib/usecases/quickstart_async_example.dart new file mode 100644 index 0000000..8d50caf --- /dev/null +++ b/storybook/lib/usecases/quickstart_async_example.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:glade_forms/glade_forms.dart'; +import 'package:glade_forms_storybook/shared/usecase_container.dart'; + +Future _fetchName() { + return Future.delayed(const Duration(seconds: 2), () => 'John Doe'); +} + +class _Model extends GladeModelAsync { + late StringInput name; + late AsyncGladeInput age; + late StringInput email; + late AsyncGladeInput vip; + + @override + List> get inputs => [name, age, email, vip]; + + _Model(); + + @override + Future initializeAsync() async { + final nameValue = await _fetchName(); + + name = GladeInput.stringInput(inputKey: 'name', value: nameValue); + age = AsyncGladeInput.intInput(value: 0, inputKey: 'age'); + email = GladeInput.stringInput(validator: (validator) => (validator..isEmail()).build(), inputKey: 'email'); + vip = AsyncGladeInput.create( + validator: (v) => (v..notNull()).build(), + value: false, + inputKey: 'vip', + dependencies: () => [name], + ); + + await super.initializeAsync(); + } +} + +class QuickStartAsyncExample extends StatelessWidget { + const QuickStartAsyncExample({super.key}); + + @override + Widget build(BuildContext context) { + return UsecaseContainer( + shortDescription: 'Quick start example', + child: GladeFormBuilder.create( + create: (context) => _Model(), + builder: (context, model, _) => Padding( + padding: const EdgeInsets.all(32), + child: Form( + autovalidateMode: AutovalidateMode.onUserInteraction, + child: Column( + children: [ + if (model.isChanging) const LinearProgressIndicator(color: Colors.red, backgroundColor: Colors.grey), + TextFormField( + controller: model.name.controller, + validator: model.name.textFormFieldInputValidator, + onChanged: model.name.updateValueWithString, + decoration: const InputDecoration(labelText: 'Name'), + readOnly: model.isChanging, + ), + TextFormField( + controller: model.age.controller, + // validator: model.age.textFormFieldInputValidator, //TODO + onChanged: model.age.updateValueWithString, + decoration: const InputDecoration(labelText: 'Age'), + readOnly: model.isChanging, + ), + TextFormField( + controller: model.email.controller, + validator: model.email.textFormFieldInputValidator, + onChanged: model.email.updateValueWithString, + decoration: const InputDecoration(labelText: 'Email'), + readOnly: model.isChanging, + ), + CheckboxListTile( + value: model.vip.value, + title: Row( + children: [ + const Text('VIP Content '), + if (model.isChanging) const Text('isChanging', style: TextStyle(color: Colors.red)), + ], + ), + onChanged: (v) => model.vip.updateValueAsync(v ?? false), + ), + const SizedBox(height: 10), + ElevatedButton( + onPressed: model.isValid + ? () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Saved'))) + : null, + child: const Text('Save'), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/storybook/pubspec.yaml b/storybook/pubspec.yaml index c953531..c650c53 100644 --- a/storybook/pubspec.yaml +++ b/storybook/pubspec.yaml @@ -20,7 +20,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - netglade_analysis: ^5.0.0 + netglade_analysis: ^6.0.0 flutter: uses-material-design: true