diff --git a/docs.json b/docs.json index f65d511..acb08af 100644 --- a/docs.json +++ b/docs.json @@ -54,6 +54,11 @@ "href": "/advanced/control-other-inputs", "icon": "network-wired" }, + { + "title": "Composed model", + "href": "/advanced/composed-model", + "icon": "box-open" + }, { "title": "Debugging", "href": "/advanced/debugging", diff --git a/docs/advanced/composed-model.mdx b/docs/advanced/composed-model.mdx new file mode 100644 index 0000000..e5018c6 --- /dev/null +++ b/docs/advanced/composed-model.mdx @@ -0,0 +1,40 @@ +--- +title: Composed forms +description: Building dynamic lists of forms +--- + +Composed forms are ideal when you need to manage multiple instances of the same form. The number of forms doesn’t need to be known in advance — new instances can be added or removed at any time. + +The core building block of this system is the `GladeComposedModel`, which maintains and manages a list of individual `GladeModel` or even another `GladeComposedModel` instances contained within the composed form. + +Because `GladeComposedModel` extends `ChangeNotifier`, any dependent widgets automatically rebuild when the model changes. + +**Using a composed model** + +Composed models behave just like ordinary `GladeModel` objects — they are even provided with the same `GladeModelProvider` and consumed with `GladeFormBuilder`: + +```dart +GladeModelProvider( + create: (context) => MyComposedModel([MyFormModel()]), + child: GladeFormBuilder( + builder: (context, composedModel, child) => ... + ), +) +``` + +**Managing contained models** + +You can dynamically add or remove form models: + +```dart +composedModel.addModel(model); +composedModel.removeModel(model); +``` + +Whenever any contained form model changes — or a model is added or removed — the entire composed form builder rebuilds to reflect the updated list. + +**Rendering multiple identical forms** + +Since all form models within a composed model share the same type, it's often helpful to automatically generate the same form widget for each model. This is where `GladeComposedListBuilder` comes in. It iterates through all models and builds the appropriate form widget for each one. + +![composed-example](/assets/composed-model.gif) \ No newline at end of file diff --git a/docs/assets/composed-model.gif b/docs/assets/composed-model.gif new file mode 100644 index 0000000..6ea4477 Binary files /dev/null and b/docs/assets/composed-model.gif differ diff --git a/glade_forms/CHANGELOG.md b/glade_forms/CHANGELOG.md index 4cd6ced..d8b63c2 100644 --- a/glade_forms/CHANGELOG.md +++ b/glade_forms/CHANGELOG.md @@ -1,4 +1,7 @@ ## 5.1.0 +- **[Add]**: Add `GladeComposedModel` to allow multi-forms creation +- **[Add]**: Add `ComposedExample` to demonstrate `GladeComposedModel` functionality +- **[Add]**: Add `NestedComposedExample` to demonstrate nested `GladeComposedModel` functionality - **[Add]**: Add `GladeModel.fillDebugMetadata()` method to provide debug metadata as key-value pairs. - This metadata is displayed in `GladeModelDebugInfo` widget. diff --git a/glade_forms/lib/src/model/glade_composed_model.dart b/glade_forms/lib/src/model/glade_composed_model.dart new file mode 100644 index 0000000..4a74125 --- /dev/null +++ b/glade_forms/lib/src/model/glade_composed_model.dart @@ -0,0 +1,64 @@ +import 'package:glade_forms/src/src.dart'; + +import 'package:glade_forms/src/validator/validator_result.dart'; + +abstract class GladeComposedModel extends GladeModelBase { + final List _models = []; + + /// Returns true if all models are valid. + @override + bool get isValid => models.every((model) => model.isValid); + + @override + bool get isValidWithoutWarnings => models.every((model) => model.isValidWithoutWarnings); + + @override + bool get isPure => models.every((model) => model.isPure); + + /// Returns true if model is not pure. + @override + bool get isDirty => !isPure; + + /// Returns true if all models have unchanged inputs. + /// + /// Input is unchanged if its value is same as initial value, even if value was updated into initial value. + @override + bool get isUnchanged => models.every((model) => model.isUnchanged); + + /// Models that this composed model is currently listening to. + List get models => _models; + + @override + List> get validatorResults => [ + for (final e in models) ...e.validatorResults, + ]; + + /// Constructor can take form models to start with. + GladeComposedModel([List? initialModels]) { + if (initialModels != null) { + for (final model in initialModels) { + addModel(model); + } + } + } + + /// Adds model to `models` list. + /// Whenever form model changes, it triggers also change on this composed model. + void addModel(M model) { + _models.add(model); + model + ..addListener(notifyListeners) + ..bindToComposedModel(this); + notifyListeners(); + } + + /// Removes model from `models` list. + /// Also unregisters from listening to its changes. + void removeModel(M model) { + final _ = _models.remove(model); + model + ..removeListener(notifyListeners) + ..unbindFromComposedModel(this); + notifyListeners(); + } +} diff --git a/glade_forms/lib/src/model/glade_model.dart b/glade_forms/lib/src/model/glade_model.dart index 2d75a1d..1c0130a 100644 --- a/glade_forms/lib/src/model/glade_model.dart +++ b/glade_forms/lib/src/model/glade_model.dart @@ -1,33 +1,34 @@ import 'package:flutter/foundation.dart'; -import 'package:glade_forms/src/core/core.dart'; +import 'package:glade_forms/src/src.dart'; import 'package:glade_forms/src/validator/validator_result.dart'; import 'package:meta/meta.dart'; -abstract class GladeModel extends ChangeNotifier { - List> _lastUpdates = []; +abstract class GladeModel extends GladeModelBase { bool _groupEdit = false; /// Returns true if all inputs are valid. + @override bool get isValid => inputs.every((input) => input.isValid); + @override bool get isValidWithoutWarnings => inputs.every((input) => input.isValidAndWithoutWarnings); - /// Returns true if any input is not valid. - bool get isNotValid => !isValid; - /// Returns true if all inputs are pure. /// /// Input is pure if its value is same as initial value and value was never updated. /// /// Pure can be reset when [setInputValuesAsNewInitialValues] or [resetToInitialValue] on model or its inputs are called. + @override bool get isPure => inputs.every((input) => input.isPure); /// Returns true if model is not pure. + @override bool get isDirty => !isPure; /// Returns true if all inputs are unchanged. /// /// Input is unchanged if its value is same as initial value, even if value was updated into initial value. + @override bool get isUnchanged => inputs.where((input) => input.trackUnchanged).every((input) => input.isUnchanged); ValidationTranslator get defaultValidationTranslate => (error, key, devMessage, dependencies) => devMessage; @@ -44,8 +45,6 @@ abstract class GladeModel extends ChangeNotifier { /// By default equals to [inputs]. List> get allInputs => inputs; - List get lastUpdatedInputKeys => _lastUpdates.map((e) => e.inputKey).toList(); - /// Formats errors from `inputs`. String get formattedValidationErrors => inputs.map((e) => e.errorFormatted()).where((element) => element.isNotEmpty).join('\n'); @@ -65,6 +64,7 @@ abstract class GladeModel extends ChangeNotifier { return '${e.inputKey} - VALID'; }).join('\n'); + @override List> get validatorResults => inputs.map((e) => e.validatorResult).toList(); /// Returns true if model has any debug metadata. @@ -106,7 +106,7 @@ abstract class GladeModel extends ChangeNotifier { void updateInput, T>(INPUT input, T value) { if (input.value == value) return; - _lastUpdates = [input]; + lastUpdates = [input]; input.value = value; notifyListeners(); @@ -115,9 +115,9 @@ abstract class GladeModel extends ChangeNotifier { @internal void notifyInputUpdated(GladeInput input) { if (_groupEdit) { - _lastUpdates.add(input); + lastUpdates.add(input); } else { - _lastUpdates = [input]; + lastUpdates = [input]; notifyDependencies(); notifyListeners(); } @@ -138,7 +138,7 @@ abstract class GladeModel extends ChangeNotifier { /// Notifies dependant inputs about changes. void notifyDependencies() { - final updatedKeys = _lastUpdates.map((e) => e.inputKey).toSet(); + final updatedKeys = lastUpdates.map((e) => e.inputKey).toSet(); for (final input in inputs) { final updatedKeysExceptInputItself = updatedKeys.difference({input.inputKey}); final union = input.dependencies.map((e) => e.inputKey).toSet().intersection(updatedKeysExceptInputItself); 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..7fc0b45 --- /dev/null +++ b/glade_forms/lib/src/model/glade_model_base.dart @@ -0,0 +1,42 @@ +import 'package:flutter/foundation.dart'; +import 'package:glade_forms/src/src.dart'; +import 'package:glade_forms/src/validator/validator_result.dart'; + +abstract class GladeModelBase extends ChangeNotifier { + List> lastUpdates = []; + final List _bindedComposeModels = []; + + bool get isValid; + + bool get isValidWithoutWarnings; + + bool get isPure; + + bool get isUnchanged; + + List> get validatorResults; + + bool get isNotValid => !isValid; + + bool get isDirty => !isPure; + + List get lastUpdatedInputKeys => lastUpdates.map((e) => e.inputKey).toList(); + + /// Binds current model to compose model. + void bindToComposedModel(GladeComposedModel model) { + _bindedComposeModels.add(model); + } + + /// Unbinds current model from compose model. + bool unbindFromComposedModel(GladeComposedModel model) { + return _bindedComposeModels.remove(model); + } + + @override + void dispose() { + for (final composeModel in _bindedComposeModels) { + composeModel.removeModel(this); + } + super.dispose(); + } +} diff --git a/glade_forms/lib/src/model/model.dart b/glade_forms/lib/src/model/model.dart index 8deec86..21137eb 100644 --- a/glade_forms/lib/src/model/model.dart +++ b/glade_forms/lib/src/model/model.dart @@ -1,2 +1,4 @@ +export 'glade_composed_model.dart'; export 'glade_metadata.dart'; export 'glade_model.dart'; +export 'glade_model_base.dart'; diff --git a/glade_forms/lib/src/widgets/glade_composed_list_builder.dart b/glade_forms/lib/src/widgets/glade_composed_list_builder.dart new file mode 100644 index 0000000..3a0fd54 --- /dev/null +++ b/glade_forms/lib/src/widgets/glade_composed_list_builder.dart @@ -0,0 +1,181 @@ +import 'package:flutter/material.dart'; +import 'package:glade_forms/src/src.dart'; +import 'package:provider/provider.dart'; + +typedef GladeComposedListItemBuilder, M extends GladeModelBase> = Widget Function( + BuildContext context, + C composedModel, + M itemModel, + int index, +); + +typedef Builder = Widget Function(BuildContext context); + +class GladeComposedListBuilder, M extends GladeModelBase> extends StatelessWidget { + // ignore: prefer-correct-callback-field-name, ok name + final CreateModelFunction? create; + final C? value; + final GladeComposedListItemBuilder itemBuilder; + final Widget? child; + final ScrollPhysics? physics; + final Axis scrollDirection; + final bool shrinkWrap; + + factory GladeComposedListBuilder({ + required GladeComposedListItemBuilder itemBuilder, + Key? key, + ScrollPhysics? physics, + Axis scrollDirection = Axis.vertical, + bool shrinkWrap = false, + Widget? child, + }) => + GladeComposedListBuilder._( + itemBuilder: itemBuilder, + key: key, + physics: physics, + scrollDirection: scrollDirection, + shrinkWrap: shrinkWrap, + child: child, + ); + + factory GladeComposedListBuilder.create({ + required CreateModelFunction create, + required GladeComposedListItemBuilder itemBuilder, + Widget? child, + ScrollPhysics? physics, + Axis scrollDirection = Axis.vertical, + bool shrinkWrap = false, + Key? key, + }) { + return GladeComposedListBuilder._( + create: create, + itemBuilder: itemBuilder, + physics: physics, + scrollDirection: scrollDirection, + shrinkWrap: shrinkWrap, + key: key, + child: child, + ); + } + + factory GladeComposedListBuilder.value({ + required C value, + required GladeComposedListItemBuilder itemBuilder, + Widget? child, + ScrollPhysics? physics, + Axis scrollDirection = Axis.vertical, + bool shrinkWrap = false, + Key? key, + }) { + return GladeComposedListBuilder._( + value: value, + itemBuilder: itemBuilder, + physics: physics, + scrollDirection: scrollDirection, + shrinkWrap: shrinkWrap, + key: key, + child: child, + ); + } + + const GladeComposedListBuilder._({ + required this.itemBuilder, + this.child, + this.create, + this.value, + this.physics, + this.scrollDirection = Axis.vertical, + this.shrinkWrap = false, + super.key, + }); + + @override + Widget build(BuildContext context) { + // choose provider style: create/value + if (create case final createFn?) { + return GladeModelProvider( + create: createFn, + child: _GladeComposedFormList( + scrollDirection: scrollDirection, + physics: physics, + shrinkWrap: shrinkWrap, + itemBuilder: itemBuilder, + child: child, + ), + ); + } + + if (value case final modelValue?) { + return GladeModelProvider.value( + value: modelValue, + child: _GladeComposedFormList( + scrollDirection: scrollDirection, + physics: physics, + shrinkWrap: shrinkWrap, + itemBuilder: itemBuilder, + child: child, + ), + ); + } + + return _GladeComposedFormList( + scrollDirection: scrollDirection, + physics: physics, + shrinkWrap: shrinkWrap, + itemBuilder: itemBuilder, + child: child, + ); + } +} + +class _GladeComposedFormList, M extends GladeModelBase> extends StatelessWidget { + final Axis scrollDirection; + final ScrollPhysics? physics; + final bool shrinkWrap; + final GladeComposedListItemBuilder itemBuilder; + final Widget? child; + + const _GladeComposedFormList({ + required this.itemBuilder, + super.key, + this.scrollDirection = Axis.vertical, + this.physics, + this.shrinkWrap = false, + this.child, + }); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, composedModel, _) { + final models = composedModel.models; + + return CustomScrollView( + physics: physics, + scrollDirection: scrollDirection, + shrinkWrap: shrinkWrap, + slivers: [ + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final itemModel = models.elementAtOrNull(index); + + if (itemModel == null) return const SizedBox.shrink(); + + return GladeModelProvider.value( + value: itemModel, + child: Consumer( + builder: (context, model, _) => itemBuilder(context, composedModel, model, index), + ), + ); + }, + childCount: models.length, + ), + ), + if (child != null) SliverToBoxAdapter(child: child), + ], + ); + }, + ); + } +} diff --git a/glade_forms/lib/src/widgets/glade_form_builder.dart b/glade_forms/lib/src/widgets/glade_form_builder.dart index 987209a..d78af76 100644 --- a/glade_forms/lib/src/widgets/glade_form_builder.dart +++ b/glade_forms/lib/src/widgets/glade_form_builder.dart @@ -1,11 +1,14 @@ import 'package:flutter/material.dart'; -import 'package:glade_forms/src/model/glade_model.dart'; -import 'package:glade_forms/src/widgets/glade_model_provider.dart'; +import 'package:glade_forms/src/src.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 { // ignore: prefer-correct-callback-field-name, ok name final CreateModelFunction? create; final M? value; diff --git a/glade_forms/lib/src/widgets/glade_form_consumer.dart b/glade_forms/lib/src/widgets/glade_form_consumer.dart index 5ea4deb..43e8049 100644 --- a/glade_forms/lib/src/widgets/glade_form_consumer.dart +++ b/glade_forms/lib/src/widgets/glade_form_consumer.dart @@ -1,9 +1,7 @@ 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'; +import 'package:glade_forms/src/src.dart'; -class GladeFormConsumer extends StatelessWidget { +class GladeFormConsumer extends StatelessWidget { final GladeFormWidgetBuilder builder; // ignore: prefer-correct-callback-field-name, ok name final GladeFormListenerFn? listener; diff --git a/glade_forms/lib/src/widgets/glade_form_listener.dart b/glade_forms/lib/src/widgets/glade_form_listener.dart index 84e6255..29efc92 100644 --- a/glade_forms/lib/src/widgets/glade_form_listener.dart +++ b/glade_forms/lib/src/widgets/glade_form_listener.dart @@ -1,14 +1,14 @@ import 'package:flutter/material.dart'; -import 'package:glade_forms/src/model/glade_model.dart'; +import 'package:glade_forms/src/src.dart'; import 'package:provider/provider.dart'; -typedef GladeFormListenerFn = void Function( +typedef GladeFormListenerFn = void Function( BuildContext context, M model, List lastUpdatedInputKey, ); -class GladeFormListener extends StatefulWidget { +class GladeFormListener extends StatefulWidget { final Widget child; // ignore: prefer-correct-callback-field-name, ok name final GladeFormListenerFn listener; @@ -23,7 +23,7 @@ class GladeFormListener extends StatefulWidget { State> createState() => _GladeFormListenerState(); } -class _GladeFormListenerState extends State> { +class _GladeFormListenerState extends State> { M? _model; @override diff --git a/glade_forms/lib/src/widgets/glade_model_provider.dart b/glade_forms/lib/src/widgets/glade_model_provider.dart index 6217156..19238d7 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/src.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 { // ignore: prefer-correct-callback-field-name, ok name final CreateModelFunction? create; final M? value; diff --git a/glade_forms/lib/src/widgets/widgets.dart b/glade_forms/lib/src/widgets/widgets.dart index 06aaff5..1ff6e7a 100644 --- a/glade_forms/lib/src/widgets/widgets.dart +++ b/glade_forms/lib/src/widgets/widgets.dart @@ -1,3 +1,4 @@ +export 'glade_composed_list_builder.dart'; export 'glade_form_builder.dart'; export 'glade_form_consumer.dart'; export 'glade_form_debug_info.dart'; diff --git a/storybook/lib/main.dart b/storybook/lib/main.dart index 4c577d2..7e30f1b 100644 --- a/storybook/lib/main.dart +++ b/storybook/lib/main.dart @@ -5,6 +5,8 @@ import 'package:flutter/material.dart'; 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/composed/composed_example.dart'; +import 'package:glade_forms_storybook/usecases/composed/nested_composed_example.dart'; import 'package:glade_forms_storybook/usecases/dependencies/checkbox_dependency_change.dart'; import 'package:glade_forms_storybook/usecases/metadata_descriptor_example.dart'; import 'package:glade_forms_storybook/usecases/onchange/one_checkbox_deps_validation.dart'; @@ -97,6 +99,19 @@ class App extends StatelessWidget { ), ], ), + WidgetbookCategory( + name: 'Composed forms', + children: [ + WidgetbookUseCase( + name: 'Adding, removing forms', + builder: (context) => const ComposedExample(), + ), + WidgetbookUseCase( + name: 'Nested composed forms', + builder: (context) => const NestedComposedExample(), + ), + ], + ), WidgetbookCategory( name: 'Issues', children: [ diff --git a/storybook/lib/usecases/composed/composed_example.dart b/storybook/lib/usecases/composed/composed_example.dart new file mode 100644 index 0000000..2b9e9af --- /dev/null +++ b/storybook/lib/usecases/composed/composed_example.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; +import 'package:glade_forms/glade_forms.dart'; +import 'package:glade_forms_storybook/shared/usecase_container.dart'; + +class _ComposedModel extends GladeComposedModel<_Model> { + _ComposedModel([super.initialModels]); +} + +class _Model extends GladeModel { + late GladeStringInput firstName; + late GladeStringInput lastName; + + @override + List> get inputs => [firstName, lastName]; + + @override + void initialize() { + firstName = GladeStringInput( + initialValue: '', + validator: (v) => (v..satisfy((value) => value.isNotEmpty)).build(), + validationTranslate: (_, __, ___, ____) => firstName.value.isEmpty ? 'First name cannot be empty' : '', + ); + lastName = GladeStringInput( + initialValue: '', + validator: (v) => (v..satisfy((value) => value.isNotEmpty)).build(), + validationTranslate: (_, __, ___, ____) => lastName.value.isEmpty ? 'Last name cannot be empty' : '', + ); + + super.initialize(); + } +} + +class ComposedExample extends StatelessWidget { + const ComposedExample({super.key}); + + @override + Widget build(BuildContext context) { + return UsecaseContainer( + shortDescription: 'Composed forms', + child: GladeModelProvider( + // ignore: avoid-undisposed-instances, handled pro provider + create: (context) => _ComposedModel([_Model()]), + child: Column( + children: [ + Flexible( + child: GladeComposedListBuilder<_ComposedModel, _Model>( + shrinkWrap: true, + itemBuilder: (context, composedModel, model, index) => _Form( + composedModel: composedModel, + model: model, + index: index, + ), + ), + ), + GladeFormConsumer<_ComposedModel>( + builder: (context, model, child) => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + children: [ + Text( + model.isValid ? 'Everything is filled' : 'Something is missing', + style: TextStyle( + color: model.isValid ? Colors.lightGreen : Colors.red, + ), + ), + FloatingActionButton( + heroTag: GlobalKey(), + tooltip: 'Add person', + // ignore: prefer-extracting-callbacks, ok here + onPressed: () { + // ignore: avoid-undisposed-instances, handled by GladeFormBuilder + model.addModel(_Model()); + }, + child: const Text('+', style: TextStyle(fontSize: 26)), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +class _Form extends StatelessWidget { + final _ComposedModel composedModel; + final _Model model; + final int index; + + const _Form({required this.composedModel, required this.model, required this.index}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + child: Card( + child: Padding( + padding: const EdgeInsets.all(8), + child: Form( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Person #${index + 1}'), + Row( + spacing: 16, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: model.firstName.controller, + validator: model.firstName.textFormFieldInputValidator, + decoration: const InputDecoration(labelText: 'First name'), + ), + Text( + model.firstName.translate() ?? '', + style: const TextStyle(color: Colors.red), + ), + ], + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: model.lastName.controller, + validator: model.lastName.textFormFieldInputValidator, + decoration: const InputDecoration(labelText: 'Last name'), + ), + Text( + model.lastName.translate() ?? '', + style: const TextStyle(color: Colors.red), + ), + ], + ), + ), + IconButton(onPressed: () => composedModel.removeModel(model), icon: const Icon(Icons.remove)), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/storybook/lib/usecases/composed/nested_composed_example.dart b/storybook/lib/usecases/composed/nested_composed_example.dart new file mode 100644 index 0000000..373483e --- /dev/null +++ b/storybook/lib/usecases/composed/nested_composed_example.dart @@ -0,0 +1,215 @@ +// ignore_for_file: prefer-trailing-comma, avoid-undisposed-instances + +import 'package:flutter/material.dart'; +import 'package:glade_forms/glade_forms.dart'; +import 'package:glade_forms_storybook/shared/usecase_container.dart'; + +class _ComposedModel extends GladeComposedModel<_NestedComposedModel> { + _ComposedModel([super.initialModels]); +} + +class _NestedComposedModel extends GladeComposedModel<_Model> { + _NestedComposedModel([super.initialModels]); +} + +class _Model extends GladeModel { + late GladeStringInput firstName; + late GladeStringInput lastName; + + @override + List> get inputs => [firstName, lastName]; + + @override + void initialize() { + firstName = GladeStringInput( + initialValue: '', + validator: (v) => (v..satisfy((value) => value.isNotEmpty)).build(), + validationTranslate: (_, __, ___, ____) => firstName.value.isEmpty ? 'First name cannot be empty' : '', + ); + lastName = GladeStringInput( + initialValue: '', + validator: (v) => (v..satisfy((value) => value.isNotEmpty)).build(), + validationTranslate: (_, __, ___, ____) => lastName.value.isEmpty ? 'Last name cannot be empty' : '', + ); + + super.initialize(); + } +} + +class NestedComposedExample extends StatelessWidget { + const NestedComposedExample({super.key}); + + @override + Widget build(BuildContext context) { + return UsecaseContainer( + shortDescription: 'Nested composed forms', + child: GladeModelProvider( + create: (context) => _ComposedModel([ + _NestedComposedModel([_Model(), _Model()]), + _NestedComposedModel([_Model(), _Model()]), + ]), + child: Column( + children: [ + Flexible( + child: GladeComposedListBuilder<_ComposedModel, _NestedComposedModel>( + shrinkWrap: true, + itemBuilder: (context, model, nestedModel, index) => _GroupContainer( + composedModel: model, + nestedComposedModel: nestedModel, + groupIndex: index, + ), + ), + ), + GladeFormBuilder<_ComposedModel>( + builder: (context, model, child) => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + children: [ + Text( + model.isValid ? 'Everything is filled' : 'Something is missing', + style: TextStyle( + color: model.isValid ? Colors.lightGreen : Colors.red, + ), + ), + FloatingActionButton( + heroTag: GlobalKey(), + tooltip: 'Add new group', + // ignore: prefer-extracting-callbacks, ok here + onPressed: () { + model.addModel(_NestedComposedModel([_Model()])); + }, + child: const Text('+', style: TextStyle(fontSize: 26)), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +class _GroupContainer extends StatelessWidget { + final _ComposedModel composedModel; + final _NestedComposedModel nestedComposedModel; + final int groupIndex; + + const _GroupContainer({required this.composedModel, required this.nestedComposedModel, required this.groupIndex}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + child: Card( + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const SizedBox.shrink(), + Text('Group #${groupIndex + 1}'), + IconButton( + onPressed: () => composedModel.removeModel(nestedComposedModel), + icon: const Icon(Icons.remove), + ), + ], + ), + GladeComposedListBuilder<_NestedComposedModel, _Model>( + shrinkWrap: true, + itemBuilder: (context, model, nestedModel, personIndex) => _Form( + composedModel: model, + model: nestedModel, + index: personIndex, + ), + ), + Text( + nestedComposedModel.isValid ? 'Group is valid' : 'Group is invalid', + style: TextStyle( + color: nestedComposedModel.isValid ? Colors.lightGreen : Colors.red, + ), + ), + TextButton( + // ignore: prefer-extracting-callbacks, ok here + onPressed: () { + nestedComposedModel.addModel(_Model()); + }, + child: const Text('Add person', style: TextStyle(fontSize: 15)), + ), + ], + ), + ), + ), + ); + } +} + +class _Form extends StatelessWidget { + final _NestedComposedModel composedModel; + final _Model model; + final int index; + + const _Form({required this.composedModel, required this.model, required this.index}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.all(8), + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + decoration: const BoxDecoration( + border: Border.fromBorderSide(BorderSide()), + borderRadius: BorderRadius.all(Radius.circular(16)), + color: Colors.white, + ), + child: Form( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Person #${index + 1}'), + Row( + spacing: 16, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: model.firstName.controller, + validator: model.firstName.textFormFieldInputValidator, + decoration: const InputDecoration(labelText: 'First name'), + ), + Text( + model.firstName.translate() ?? '', + style: const TextStyle(color: Colors.red), + ), + ], + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: model.lastName.controller, + validator: model.lastName.textFormFieldInputValidator, + decoration: const InputDecoration(labelText: 'Last name'), + ), + Text( + model.lastName.translate() ?? '', + style: const TextStyle(color: Colors.red), + ), + ], + ), + ), + IconButton(onPressed: () => composedModel.removeModel(model), icon: const Icon(Icons.remove)), + ], + ), + ], + ), + ), + ); + } +}