diff --git a/.fvmrc b/.fvmrc index 00359a4..bd157de 100644 --- a/.fvmrc +++ b/.fvmrc @@ -1,3 +1,3 @@ { - "flutter": "3.35.1" + "flutter": "3.38.7" } \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index a083c13..32165b9 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -19,6 +19,16 @@ "--dart-define", "DEBUG_MODEL=true" ] + }, + { + "name": "devtools_extension (DEBUG MODE)", + "cwd": "glade_forms_devtools_extension", + "request": "launch", + "type": "dart", + "args": [ + "--dart-define=DEBUG_MODE=true", + "--dart-define=use_simulated_environment=true" + ] } ] } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 813e06b..c5dda0d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { - "dart.flutterSdkPath": ".fvm/versions/3.35.1", + "dart.flutterSdkPath": ".fvm/versions/3.38.7", "dart.lineLength": 120, "yaml.schemaStore.enable": false } \ No newline at end of file diff --git a/BUILD_EXTENSION.md b/BUILD_EXTENSION.md new file mode 100644 index 0000000..50b0098 --- /dev/null +++ b/BUILD_EXTENSION.md @@ -0,0 +1,104 @@ +# DevTools Extension - Build Instructions + +## Overview + +The glade_forms DevTools extension is maintained as a separate package (`glade_forms_devtools_extension`) and the build output is copied to `glade_forms/extension/devtools/build/`. + +## Building Locally + +### Prerequisites +- Flutter SDK (3.6.0 or higher) +- Melos (for workspace management) + +### Build Steps + +1. From the repository root: +```bash +melos run build:extension +``` + +This will: +- Navigate to `glade_forms_devtools_extension` +- Run `flutter build web --wasm --release` +- Copy `build/web/` to `glade_forms/extension/devtools/build/` + +2. Or manually: +```bash +cd glade_forms_devtools_extension +flutter pub get +flutter build web --wasm --release +rm -rf ../glade_forms/extension/devtools/build +cp -r build/web ../glade_forms/extension/devtools/build +``` + +### Development Mode + +To develop the extension: +```bash +cd glade_forms_devtools_extension +flutter pub get +flutter run -d chrome +``` + +## CI/CD Integration + +The extension should be built as part of the release process: + +1. When releasing a new version of glade_forms +2. Run `melos run build:extension` +3. Commit the updated build output in `glade_forms/extension/devtools/build/` +4. Publish the package + +## Testing the Extension + +1. Create a Flutter app that uses glade_forms +2. Run the app in debug mode +3. Open Flutter DevTools +4. The "Glade Forms" tab should appear in DevTools +5. Interact with forms in your app to see live updates + +## Structure + +``` +glade_forms_workspace/ +├── glade_forms/ +│ ├── lib/ +│ ├── extension/ +│ │ ├── config.yaml # Extension configuration +│ │ └── devtools/ +│ │ └── build/ # Built extension output (copied from extension package) +│ └── pubspec.yaml +└── glade_forms_devtools_extension/ + ├── lib/ + │ ├── main.dart # Extension entry point + │ └── src/ + │ └── glade_forms_extension.dart + ├── web/ + │ └── index.html + └── pubspec.yaml +``` + +## Updating the Extension + +1. Make changes in `glade_forms_devtools_extension/` +2. Test with `flutter run -d chrome` +3. Build with `melos run build:extension` +4. Commit both source and build output changes +5. The updated extension will be included in the next package release + +## Troubleshooting + +**Extension doesn't appear in DevTools:** +- Ensure `glade_forms/extension/config.yaml` exists +- Verify build output exists in `glade_forms/extension/devtools/build/` +- Check that the app imports `glade_forms` + +**Build fails:** +- Ensure Flutter SDK is up to date +- Run `flutter pub get` in the extension directory +- Check for dependency conflicts + +**Extension shows blank page:** +- Verify `web/index.html` exists +- Check browser console for errors +- Ensure `main.dart` is compiled correctly diff --git a/DEVTOOLS_IMPLEMENTATION.md b/DEVTOOLS_IMPLEMENTATION.md new file mode 100644 index 0000000..744310f --- /dev/null +++ b/DEVTOOLS_IMPLEMENTATION.md @@ -0,0 +1,169 @@ +# DevTools Integration - Implementation Summary + +## Overview + +This implementation adds Flutter DevTools extension support to the glade_forms package, following industry-standard patterns used by packages like `drift` and `provider`. + +## Architectural Decisions + +### Separate Extension Package +The extension is maintained as a **separate package** (`glade_forms_devtools_extension`) rather than being embedded in the main package. + +**Why?** +1. **Independent versioning** - Extension updates don't require main package releases +2. **Smaller package size** - Main package only includes build output (~100KB), not source code +3. **Cleaner dependencies** - `devtools_extensions` dependency doesn't affect main package users +4. **Better maintainability** - Clear separation of concerns +5. **Standard pattern** - Matches approach used by other major Flutter packages + +### Build Output Location +Built extension is copied to: `glade_forms/extension/devtools/build/` + +This allows Flutter DevTools to discover the extension automatically when the main package is used. + +## Structure + +``` +glade_forms_workspace/ +├── glade_forms/ # Main package +│ ├── lib/ # Package source code +│ ├── extension/ +│ │ ├── config.yaml # DevTools extension config +│ │ ├── README.md # Extension documentation +│ │ └── devtools/ +│ │ └── build/ # Built extension (gitignored except README) +│ │ └── README.md +│ ├── devtools_options.yaml # DevTools settings +│ └── test/ +│ ├── devtools_extension_test.dart # Configuration tests +│ └── README_DEVTOOLS_TESTS.md +├── glade_forms_devtools_extension/ # Separate extension package +│ ├── lib/ +│ │ ├── main.dart # Extension entry point +│ │ └── src/ +│ │ └── glade_forms_extension.dart # Main UI +│ ├── web/ +│ │ └── index.html # Web entry point +│ ├── test/ +│ │ ├── extension_widget_test.dart # UI tests +│ │ └── package_config_test.dart # Config tests +│ └── pubspec.yaml # Extension dependencies +├── docs/ +│ └── devtools.mdx # User documentation +└── BUILD_EXTENSION.md # Build instructions +``` + +## Files Created/Modified + +### New Files +1. `glade_forms/extension/config.yaml` - Extension configuration +2. `glade_forms/extension/README.md` - Extension directory docs +3. `glade_forms/extension/devtools/build/README.md` - Build output placeholder +4. `glade_forms/extension/devtools/.gitignore` - Ignore build artifacts +5. `glade_forms/devtools_options.yaml` - DevTools settings +6. `glade_forms_devtools_extension/` - Complete extension package +7. `docs/devtools.mdx` - User-facing documentation +8. `BUILD_EXTENSION.md` - Build instructions +9. `glade_forms/test/devtools_extension_test.dart` - Configuration tests +10. `glade_forms/test/README_DEVTOOLS_TESTS.md` - Test documentation +11. `glade_forms_devtools_extension/test/` - Extension tests + +### Modified Files +1. `pubspec.yaml` - Added workspace member and build script +2. `glade_forms/README.md` - Added DevTools section +3. `glade_forms/.gitignore` - Exclude build output + +## Building the Extension + +```bash +# From repository root +melos run build:extension +``` + +This will: +1. Navigate to `glade_forms_devtools_extension` +2. Run `flutter build web --wasm --release` +3. Copy output to `glade_forms/extension/devtools/build/` + +## Test Coverage + +### Configuration Tests +- Validates YAML configuration files +- Checks directory structure +- Verifies required fields and paths +- Tests documentation exists + +### Widget Tests +- Extension app builds successfully +- UI displays correct information +- Branding is correct + +### Package Tests +- Dependencies are correct +- SDK constraints are appropriate +- Entry points exist + +**Run tests:** +```bash +# Main package tests +cd glade_forms && flutter test test/devtools_extension_test.dart + +# Extension package tests +cd glade_forms_devtools_extension && flutter test + +# All tests via melos +melos run test +``` + +## User Experience + +Once built, the extension provides: +1. Automatic discovery in Flutter DevTools (no user setup needed) +2. "Glade Forms" tab in DevTools +3. Inspection of form state, inputs, and validation +4. Real-time updates as forms change + +## Future Enhancements + +The current implementation provides: +- ✅ Complete infrastructure and configuration +- ✅ Basic placeholder UI +- ✅ Comprehensive tests +- ✅ Documentation + +Future work could add: +- 🔲 Service extension protocol for data communication +- 🔲 Live inspection of GladeModel instances +- 🔲 Input value visualization +- 🔲 Validation state display +- 🔲 Form dirty/pure state tracking +- 🔲 Error highlighting and navigation + +## References + +- [Flutter DevTools Extensions Documentation](https://docs.flutter.dev/tools/devtools/extensions) +- [drift DevTools Extension](https://github.com/simolus3/drift/tree/develop/drift/extension) +- [provider DevTools Extension](https://github.com/rrousselGit/provider/tree/master/packages/provider_devtools_extension) +- [devtools_extensions package](https://pub.dev/packages/devtools_extensions) + +## Notes for Maintainers + +1. **Building for Release**: Always run `melos run build:extension` before releasing +2. **Testing**: Run extension tests before merging changes +3. **Documentation**: Update docs/devtools.mdx when adding features +4. **Dependencies**: Extension package can be updated independently +5. **Versioning**: Extension version in config.yaml can differ from main package + +## Known Limitations + +- Extension cannot be built in current CI environment (requires Flutter SDK) +- Full integration testing requires manual testing with real app +- Service extension protocol not yet implemented (needed for live data) + +## Completion Status + +✅ **Structure Complete** - All directories and configuration files in place +✅ **Tests Complete** - Comprehensive test coverage for configuration +✅ **Documentation Complete** - User and developer docs written +⏳ **Build Required** - Extension needs to be built with Flutter SDK +⏳ **Integration Testing** - Needs manual testing in real Flutter environment diff --git a/dcm_global.yaml b/dcm_global.yaml index b44467d..4108b6d 100644 --- a/dcm_global.yaml +++ b/dcm_global.yaml @@ -1 +1 @@ -version: "1.26.2" +version: "1.35.0" diff --git a/devtools_options.yaml b/devtools_options.yaml index fa0b357..d45d518 100644 --- a/devtools_options.yaml +++ b/devtools_options.yaml @@ -1,3 +1,6 @@ description: This file stores settings for Dart & Flutter DevTools. documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states extensions: + - provider: true + - shared_preferences: true + - glade_forms: true \ No newline at end of file diff --git a/docs/advanced/debugging.mdx b/docs/advanced/debugging.mdx index 1bc5a9e..1ac1eb3 100644 --- a/docs/advanced/debugging.mdx +++ b/docs/advanced/debugging.mdx @@ -3,4 +3,52 @@ title: Debugging tips description: Useful tips for debugging your GladeForms. --- -![UnderConstruction](https://pics.clipartpng.com/midle/Under_Construction_Warning_Sign_PNG_Clipart-839.png) \ No newline at end of file +# DevTools Extension + +The glade_forms package includes a Flutter DevTools extension that helps you inspect and debug your form models during development. + +## Features + +The glade_forms DevTools extension provides: + +- **View Active Models**: See all active `GladeModel` instances in your application +- **Inspect Inputs**: View the current values and validation states of all form inputs +- **Monitor State**: Track whether forms are dirty, pure, valid, or invalid in real-time +- **Real-time Updates**: Watch as forms change while you interact with your application + +## Installation + +The DevTools extension is automatically available when you add `glade_forms` to your project. + +Update devtools_options.yaml to include the Glade Forms extension: + +```yaml +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: + - glade_forms: true +``` + +# Widgets for Debugging +You can use the following debug widgets to help visualize and debug your forms during development: + +Use `GladeFormDebugInfo` widget to display properties of your model. It helps you to understand what is going on in your model. + +```dart +Column(children: [ + GladeFormDebugInfo(), + + // ... my form +]); +``` + +Note that you can customize which properties are displayed. + +![GladeModelDebugInfo](/assets/glade-model-debug.png) + +### GladeFormDebugInfo Modal +If you prefer to display debugging info as a modal use + +```dart +GladeFormDebugInfoModal.show(context, your_model); +``` \ No newline at end of file diff --git a/docs/widgets.mdx b/docs/widgets.mdx index e44538b..5b79e6a 100644 --- a/docs/widgets.mdx +++ b/docs/widgets.mdx @@ -62,27 +62,4 @@ GladeFormListener( ### GladeFormConsumer -Combines `GladeFormBuilder` and `GladeFormListener` to react to changes in the model and listen for updates. - -## Widgets for debugging - -Use `GladeFormDebugInfo` widget to display properties of your model. It helps you to understand what is going on in your model. - -```dart -Column(children: [ - GladeFormDebugInfo(), - - // ... my form -]); -``` - -Note that you can customize which properties are displayed. - -![GladeModelDebugInfo](/assets/glade-model-debug.png) - -### GladeFormDebugInfo Modal -If you prefer to display debugging info as a modal use - -```dart -GladeFormDebugInfoModal.show(context, your_model); -``` \ No newline at end of file +Combines `GladeFormBuilder` and `GladeFormListener` to react to changes in the model and listen for updates. \ No newline at end of file diff --git a/glade_forms/.gitignore b/glade_forms/.gitignore index 3cceda5..ea56c7f 100644 --- a/glade_forms/.gitignore +++ b/glade_forms/.gitignore @@ -5,3 +5,6 @@ # Avoid committing pubspec.lock for library packages; see # https://dart.dev/guides/libraries/private-files#pubspeclock. pubspec.lock + +# DevTools extension build output +extension/devtools/build/ diff --git a/glade_forms/CHANGELOG.md b/glade_forms/CHANGELOG.md index d8b63c2..6fc329e 100644 --- a/glade_forms/CHANGELOG.md +++ b/glade_forms/CHANGELOG.md @@ -1,3 +1,14 @@ +## 6.0.0 +- **Breaking**: Upgrade to Flutter SDK 3.38.0 + - Change constraint to Dart sdk: ">=3.8.0" +- **Breaking**: You need to call `GladeForms.initialize()` in order to use Glade Forms DevTools extension. + - This is required to setup DevTools extension properly. + - Can be called only in debug mode, but does nothing in release mode. +- **[Add]**: Add **DevTools** extension! + - Allows to inspect Glade Forms models in Flutter DevTools +- **[Add]**: Add `debugKey` property for developer friendly unique identification of models. + - `debugKey` is a string that identifies the model in a human-readable way. + ## 5.1.0 - **[Add]**: Add `GladeComposedModel` to allow multi-forms creation - **[Add]**: Add `ComposedExample` to demonstrate `GladeComposedModel` functionality diff --git a/glade_forms/README.md b/glade_forms/README.md index 9822bfa..c401b60 100644 --- a/glade_forms/README.md +++ b/glade_forms/README.md @@ -1,5 +1,5 @@ - netglade + netglade Developed with 💚 by [netglade][netglade_link] @@ -16,6 +16,7 @@ A universal way to define form validators with support of translations. - [👀 What is this?](#-what-is-this) - [🚀 Getting started](#-getting-started) +- [🔍 DevTools Extension](#-devtools-extension) - [📖 Documentation](#-documentation) ## 👀 What is this? @@ -60,7 +61,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, @@ -91,10 +92,41 @@ GladeFormBuilder( ) ``` -![quick_start_example](https://raw.githubusercontent.com/netglade/glade_forms/main/glade_forms/docs/assets/quickstart.gif) +![quick_start_example](https://raw.githubusercontent.com/netglade/glade_forms/main/docs/assets/quickstart.gif) Interactive examples can be found in [📖 Glade Forms Widgetbook][storybook_demo_link]. +## 🔍 DevTools Extension + +Glade Forms includes a Flutter DevTools extension to help you inspect and debug your forms during development. The extension shows: +- Active `GladeModel` instances +- Input values and validation states +- Form dirty/pure states +- Real-time updates as you interact with your app + +**Setup** + +To ensure the DevTools extension is available immediately when you open DevTools (even before creating any forms), add this to your app's `main()` function: + +```dart +import 'package:glade_forms/glade_forms.dart'; + +void main() { + // Initialize GladeForms to enable DevTools integration + GladeForms.initialize(); + + runApp(MyApp()); +} +``` + +**How to access:** +1. Run your app in debug mode: `flutter run` +2. Open DevTools (in VS Code: `Cmd+Shift+P` → "Dart: Open DevTools") +3. Navigate to the **"Glade Forms"** tab +4. Interact with your forms to see live updates! + +[Learn more about debugging with DevTools →][devtools_docs] + ## 📖 Documentation Want to learn more? @@ -113,4 +145,5 @@ Check out the [Glade Forms Documentation][docs_page]. [discord_badge_link]: https://discord.gg/sJfBBuDZy4 [storybook_demo_link]: https://netglade.github.io/glade_forms [docs_page]: https://docs.page/netglade/glade_forms +[devtools_docs]: https://docs.page/netglade/glade_forms/devtools [docs_badge]: docs/assets/icon.png \ No newline at end of file diff --git a/glade_forms/devtools_options.yaml b/glade_forms/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/glade_forms/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/glade_forms/example/lib/example.dart b/glade_forms/example/lib/example.dart index e0c3d08..5a73e8e 100644 --- a/glade_forms/example/lib/example.dart +++ b/glade_forms/example/lib/example.dart @@ -35,9 +35,9 @@ class Example extends StatelessWidget { // ignore: avoid-undisposed-instances, handled by GladeFormBuilder create: (context) => _Model(), builder: (context, model, _) => Padding( - padding: const EdgeInsets.all(32), + padding: const .all(32), child: Form( - autovalidateMode: AutovalidateMode.onUserInteraction, + autovalidateMode: .onUserInteraction, child: Column( children: [ TextFormField( diff --git a/glade_forms/example/pubspec.yaml b/glade_forms/example/pubspec.yaml index 8cf3803..8991749 100644 --- a/glade_forms/example/pubspec.yaml +++ b/glade_forms/example/pubspec.yaml @@ -1,11 +1,11 @@ name: glade_forms_example +resolution: workspace description: Example project how to use GladeForms -version: 1.0.1 publish_to: none +version: 1.0.1 environment: - sdk: ">=3.6.0 <4.0.0" -resolution: workspace + sdk: ">=3.10.0" dependencies: flutter: diff --git a/glade_forms/extension/README.md b/glade_forms/extension/README.md new file mode 100644 index 0000000..c096556 --- /dev/null +++ b/glade_forms/extension/README.md @@ -0,0 +1,30 @@ +# DevTools Extension + +This directory contains the configuration and build output for the glade_forms DevTools extension. + +## Structure + +- `devtools/config.yaml` - Extension configuration for Flutter DevTools +- `devtools/build/` - Built extension output (copied from glade_forms_devtools_extension package) +- `devtools/.pubignore` - Ensures build directory is included when publishing despite being gitignored + +## Building the Extension + +The extension source code is maintained in the separate `glade_forms_devtools_extension` package at the root of this repository. + +To build the extension: + +```bash +# From the repository root +melos run build:extension +``` + +This will: +1. Build the extension package +2. Copy the build output to `extension/devtools/build/` + +## Development + +For development of the extension itself, work in the `glade_forms_devtools_extension` package. + +See the [glade_forms_devtools_extension README](../../glade_forms_devtools_extension/README.md) for more information. diff --git a/glade_forms/extension/devtools/.gitignore b/glade_forms/extension/devtools/.gitignore new file mode 100644 index 0000000..517d9e3 --- /dev/null +++ b/glade_forms/extension/devtools/.gitignore @@ -0,0 +1,10 @@ +# This directory contains the built DevTools extension +# The build output is generated by the build:extension melos script +# and copied from the glade_forms_devtools_extension package. + +# Ignore all files in this directory except this .gitignore and README.md +* +!.gitignore +!README.md +!config.yaml +!.pubignore \ No newline at end of file diff --git a/glade_forms/extension/devtools/.pubignore b/glade_forms/extension/devtools/.pubignore new file mode 100644 index 0000000..894371d --- /dev/null +++ b/glade_forms/extension/devtools/.pubignore @@ -0,0 +1 @@ +!devtools/build/ diff --git a/glade_forms/extension/devtools/config.yaml b/glade_forms/extension/devtools/config.yaml new file mode 100644 index 0000000..c6c21e6 --- /dev/null +++ b/glade_forms/extension/devtools/config.yaml @@ -0,0 +1,8 @@ +# Configuration for the glade_forms DevTools extension. +# Learn more: https://docs.flutter.dev/tools/devtools/extensions + +name: glade_forms +issueTracker: https://github.com/netglade/glade_forms/issues +version: 1.0.0 +materialIconCodePoint: "0xe146" +requiresConnection: true diff --git a/glade_forms/lib/src/core/changes_info.dart b/glade_forms/lib/src/core/changes_info.dart index b791614..210557a 100644 --- a/glade_forms/lib/src/core/changes_info.dart +++ b/glade_forms/lib/src/core/changes_info.dart @@ -2,7 +2,7 @@ import 'package:equatable/equatable.dart'; import 'package:glade_forms/src/validator/validator_result.dart'; /// Information about changes in the input. -class ChangesInfo extends Equatable { +class ChangesInfo with EquatableMixin { /// Key of the input. final String inputKey; diff --git a/glade_forms/lib/src/core/error/convert_error.dart b/glade_forms/lib/src/core/error/convert_error.dart index 45d5e0b..531c2c8 100644 --- a/glade_forms/lib/src/core/error/convert_error.dart +++ b/glade_forms/lib/src/core/error/convert_error.dart @@ -25,15 +25,15 @@ class ConvertError extends GladeInputValidation with EquatableMixin implem Object get result => _convertError; @override - ValidationSeverity get severity => ValidationSeverity.error; + ValidationSeverity get severity => .error; ConvertError({ required Object error, required this.input, super.key, OnConvertError? formatError, - }) : _convertError = error, - onConvertErrorMessage = formatError ?? ((rawValue, {key}) => 'Conversion error: $error'); + }) : _convertError = error, + onConvertErrorMessage = formatError ?? ((rawValue, {key}) => 'Conversion error: $error'); @override String toString() => onConvertErrorMessage(input, key: key); diff --git a/glade_forms/lib/src/core/error/glade_input_validation.dart b/glade_forms/lib/src/core/error/glade_input_validation.dart index b43e110..8407676 100644 --- a/glade_forms/lib/src/core/error/glade_input_validation.dart +++ b/glade_forms/lib/src/core/error/glade_input_validation.dart @@ -21,6 +21,10 @@ abstract class GladeInputValidation { key == GladeValidationsKeys.valueIsNull || key == GladeValidationsKeys.valueIsEmpty; const GladeInputValidation({this.key}); + + @override + // ignore: avoid-nullable-interpolation, key can be null + String toString() => 'GladeInputValidation(key: $key, result: $result, severity: $severity)'; } // ignore: prefer-single-declaration-per-file, keep here. diff --git a/glade_forms/lib/src/core/input/glade_input.dart b/glade_forms/lib/src/core/input/glade_input.dart index cef99c7..131f557 100644 --- a/glade_forms/lib/src/core/input/glade_input.dart +++ b/glade_forms/lib/src/core/input/glade_input.dart @@ -66,7 +66,7 @@ class GladeInput { TextEditingController? _textEditingController; - final StringToTypeConverter _defaultConverter = StringToTypeConverter(converter: (x, _) => x as T); + final StringToTypeConverter _defaultConverter = StringToTypeConverter(converter: (x, _) => x as T); /// Current input's value. T _value; @@ -170,21 +170,22 @@ class GladeInput { ValueTransform? valueTransform, this.defaultValidationTranslations, this.trackUnchanged = true, - }) : assert( - value != null || initialValue != null || TypeHelper.typeIsNullable(), - 'If type is not nullable, at least one of value or initialValue must be set (affected input: $inputKey)', - ), - _isPure = isPure, - _value = (value ?? initialValue) as T, - _initialValue = initialValue, - dependenciesFactory = dependencies ?? (() => []), - inputKey = inputKey ?? '__${T.runtimeType}__${Random().nextInt(100_000_000)}', - _valueTransform = valueTransform, - - // ignore: avoid_bool_literals_in_conditional_expressions, cant be simplified. - _useTextEditingController = textEditingController != null ? true : useTextEditingController { + }) : assert( + value != null || initialValue != null || TypeHelper.typeIsNullable(), + 'If type is not nullable, at least one of value or initialValue must be set (affected input: $inputKey)', + ), + _isPure = isPure, + _value = (value ?? initialValue) as T, + _initialValue = initialValue, + dependenciesFactory = dependencies ?? (() => []), + inputKey = inputKey ?? '__${T.runtimeType}__${Random().nextInt(100_000_000)}', + _valueTransform = valueTransform, + + // ignore: avoid_bool_literals_in_conditional_expressions, cant be simplified. + _useTextEditingController = textEditingController != null ? true : useTextEditingController { final defaultValue = (value ?? initialValue) as T; - _textEditingController = textEditingController ?? + _textEditingController = + textEditingController ?? (useTextEditingController ? TextEditingController( text: switch (defaultValue) { @@ -220,25 +221,24 @@ class GladeInput { ValueTransform? valueTransform, DefaultValidationTranslations? defaultTranslations, bool trackUnchanged = true, - }) => - GladeInput.internalCreate( - validatorInstance: validator?.call(GladeValidator()) ?? GladeValidator().build(), - inputKey: inputKey, - value: value, - initialValue: initialValue, - isPure: isPure, - validationTranslate: validationTranslate, - valueComparator: valueComparator, - stringToValueConverter: stringToValueConverter, - dependencies: dependencies, - onChange: onChange, - onDependencyChange: onDependencyChange, - textEditingController: textEditingController, - useTextEditingController: useTextEditingController, - valueTransform: valueTransform, - defaultValidationTranslations: defaultTranslations, - trackUnchanged: trackUnchanged, - ); + }) => GladeInput.internalCreate( + validatorInstance: validator?.call(GladeValidator()) ?? GladeValidator().build(), + inputKey: inputKey, + value: value, + initialValue: initialValue, + isPure: isPure, + validationTranslate: validationTranslate, + valueComparator: valueComparator, + stringToValueConverter: stringToValueConverter, + dependencies: dependencies, + onChange: onChange, + onDependencyChange: onDependencyChange, + textEditingController: textEditingController, + useTextEditingController: useTextEditingController, + valueTransform: valueTransform, + defaultValidationTranslations: defaultTranslations, + trackUnchanged: trackUnchanged, + ); /// /// Useful for input which allows null value without additional validations. @@ -260,25 +260,24 @@ class GladeInput { bool useTextEditingController = false, ValueTransform? valueTransform, bool trackUnchanged = true, - }) => - GladeInput.create( - validator: (v) => v.build(), - value: value ?? initialValue, - initialValue: initialValue, - validationTranslate: validationTranslate, - defaultTranslations: defaultTranslations, - valueComparator: valueComparator, - stringToValueConverter: stringToValueConverter, - inputKey: inputKey, - isPure: pure, - dependencies: dependencies, - onChange: onChange, - onDependencyChange: onDependencyChange, - textEditingController: textEditingController, - useTextEditingController: useTextEditingController, - valueTransform: valueTransform, - trackUnchanged: trackUnchanged, - ); + }) => GladeInput.create( + validator: (v) => v.build(), + value: value ?? initialValue, + initialValue: initialValue, + validationTranslate: validationTranslate, + defaultTranslations: defaultTranslations, + valueComparator: valueComparator, + stringToValueConverter: stringToValueConverter, + inputKey: inputKey, + isPure: pure, + dependencies: dependencies, + onChange: onChange, + onDependencyChange: onDependencyChange, + textEditingController: textEditingController, + useTextEditingController: useTextEditingController, + valueTransform: valueTransform, + trackUnchanged: trackUnchanged, + ); /// Predefined GenericInput with predefined `notNull` validation. /// @@ -299,25 +298,24 @@ class GladeInput { bool useTextEditingController = false, ValueTransform? valueTransform, bool trackUnchanged = true, - }) => - GladeInput.create( - validator: (v) => (v..notNull()).build(), - value: value, - initialValue: initialValue, - validationTranslate: validationTranslate, - defaultTranslations: defaultTranslations, - valueComparator: valueComparator, - stringToValueConverter: stringToValueConverter, - inputKey: inputKey, - isPure: pure, - dependencies: dependencies, - onChange: onChange, - onDependencyChange: onDependencyChange, - textEditingController: textEditingController, - useTextEditingController: useTextEditingController, - valueTransform: valueTransform, - trackUnchanged: trackUnchanged, - ); + }) => GladeInput.create( + validator: (v) => (v..notNull()).build(), + value: value, + initialValue: initialValue, + validationTranslate: validationTranslate, + defaultTranslations: defaultTranslations, + valueComparator: valueComparator, + stringToValueConverter: stringToValueConverter, + inputKey: inputKey, + isPure: pure, + dependencies: dependencies, + onChange: onChange, + onDependencyChange: onDependencyChange, + textEditingController: textEditingController, + useTextEditingController: useTextEditingController, + valueTransform: valueTransform, + trackUnchanged: trackUnchanged, + ); @internal // ignore: use_setters_to_change_properties, as method. @@ -342,7 +340,7 @@ class GladeInput { // ignore: avoid-non-null-assertion, it is not null if (hasConversionError) return _translateConversionError(__conversionError!); - return _translate(severity: ValidationSeverity.warning, delimiter: delimiter) ?? ''; + return _translate(severity: .warning, delimiter: delimiter) ?? ''; } /// Shorthand validator for TextFieldForm inputs. @@ -352,7 +350,7 @@ class GladeInput { String? textFormFieldInputValidatorCustom( String? value, { String delimiter = '.', - ValidationSeverity severity = ValidationSeverity.error, + ValidationSeverity severity = .error, }) { assert( TypeHelper.typesEqual() || TypeHelper.typesEqual() || stringToValueConverter != null, @@ -377,17 +375,16 @@ class GladeInput { /// Returns translated validation message. String? textFormFieldInputValidator( String? value, { - ValidationSeverity severity = ValidationSeverity.error, + ValidationSeverity severity = .error, String delimiter = '.', - }) => - textFormFieldInputValidatorCustom(value, severity: severity, delimiter: delimiter); + }) => textFormFieldInputValidatorCustom(value, severity: severity, delimiter: delimiter); /// Shorthand validator for Form field input. /// /// Returns translated validation message. String? formFieldValidator( T value, { - ValidationSeverity severity = ValidationSeverity.error, + ValidationSeverity severity = .error, String delimiter = '.', }) { final convertedError = validatorInstance.validate(value); @@ -605,7 +602,7 @@ class GladeInput { String? _translate({ String delimiter = '.', Object? customError, - ValidationSeverity severity = ValidationSeverity.error, + ValidationSeverity severity = .error, }) { final err = customError ?? validatorResult; @@ -646,14 +643,14 @@ class GladeInput { String _translateGenericValidation( ValidatorResult validatorResult, String delimiter, { - ValidationSeverity severity = ValidationSeverity.error, + ValidationSeverity severity = .error, }) { final translateTmp = validationTranslate; final results = switch (severity) { - ValidationSeverity.error => validatorResult.errors, + .error => validatorResult.errors, // * Warning + Error - ValidationSeverity.warning => validatorResult.all, + .warning => validatorResult.all, }; final defaultTranslationsTmp = defaultValidationTranslations; @@ -661,15 +658,17 @@ class GladeInput { return results.map((e) => translateTmp(e, e.key, e.devValidationMessage, dependenciesFactory())).join(delimiter); } - return results.map((e) { - if (defaultTranslationsTmp != null && - (e.isNullError || e.hasStringEmptyOrNullErrorKey || e.hasNullValueOrEmptyValueKey)) { - return defaultTranslationsTmp.defaultValueIsNullOrEmptyMessage ?? e.toString(); - } else if (_bindedModel case final model?) { - return model.defaultValidationTranslate(e, e.key, e.devValidationMessage, dependenciesFactory()); - } - - return e.toString(); - }).join(delimiter); + return results + .map((e) { + if (defaultTranslationsTmp != null && + (e.isNullError || e.hasStringEmptyOrNullErrorKey || e.hasNullValueOrEmptyValueKey)) { + return defaultTranslationsTmp.defaultValueIsNullOrEmptyMessage ?? e.toString(); + } else if (_bindedModel case final model?) { + return model.defaultValidationTranslate(e, e.key, e.devValidationMessage, dependenciesFactory()); + } + + return e.toString(); + }) + .join(delimiter); } } diff --git a/glade_forms/lib/src/core/string_to_type_converter.dart b/glade_forms/lib/src/core/string_to_type_converter.dart index f7e4fc3..bfe11ad 100644 --- a/glade_forms/lib/src/core/string_to_type_converter.dart +++ b/glade_forms/lib/src/core/string_to_type_converter.dart @@ -3,15 +3,17 @@ import 'package:glade_forms/src/core/error/glade_validations_keys.dart'; typedef OnErrorCallback = ConvertError Function(String? rawValue, Object error); -typedef ConverterToType = T Function( - String? rawInput, - T Function( - Object error, { - required String? rawValue, - Object? key, - OnConvertError? onError, - }) cantConvert, -); +typedef ConverterToType = + T Function( + String? rawInput, + T Function( + Object error, { + required String? rawValue, + Object? key, + OnConvertError? onError, + }) + cantConvert, + ); typedef TypeConverterToString = String? Function(T rawInput); @@ -31,8 +33,8 @@ class StringToTypeConverter { /// Converts [T] back to string. TypeConverterToString? converterBack, //OnErrorCallback? onError, - }) : _converterBack = converterBack ?? ((rawInput) => rawInput?.toString() ?? ''), - onError = ((rawValue, error) => ConvertError(input: rawValue, error: error)); + }) : _converterBack = converterBack ?? ((rawInput) => rawInput?.toString() ?? ''), + onError = ((rawValue, error) => ConvertError(input: rawValue, error: error)); /// Converts string input into `T` value. T convert(String? input) { @@ -54,12 +56,12 @@ class StringToTypeConverter { Object error, { required String? rawValue, Object? key, + // ignore: avoid-never-passed-parameters, keep for signature consistency OnConvertError? onError, - }) => - throw ConvertError( - input: rawValue, - formatError: onError, - error: error, - key: key ?? GladeValidationsKeys.conversionError, - ); + }) => throw ConvertError( + input: rawValue, + formatError: onError, + error: error, + key: key ?? GladeValidationsKeys.conversionError, + ); } diff --git a/glade_forms/lib/src/devtools/devtools_registry.dart b/glade_forms/lib/src/devtools/devtools_registry.dart new file mode 100644 index 0000000..c1ff21e --- /dev/null +++ b/glade_forms/lib/src/devtools/devtools_registry.dart @@ -0,0 +1,115 @@ +import 'dart:convert'; +import 'dart:developer' as developer; + +import 'package:flutter/foundation.dart'; +import 'package:glade_forms/src/devtools/glade_model_devtools_serialization.dart'; +import 'package:glade_forms/src/model/glade_composed_model.dart'; +import 'package:glade_forms/src/model/glade_model_base.dart'; + +/// Registry to track active GladeModel instances for DevTools inspection. +// ignore: prefer-match-file-name, keep name as is +class GladeFormsDevToolsRegistry { + static GladeFormsDevToolsRegistry? _instance; + + final Map _models = {}; + final Set _childModelIds = {}; + + /// Get all registered models (excluding child models of composed models). + Map get models => .unmodifiable( + Map.fromEntries(_models.entries.where((e) => !_childModelIds.contains(e.key))), + ); + + factory GladeFormsDevToolsRegistry() { + if (_instance == null) { + throw StateError( + 'GladeForms.initialize() must be called before using GladeModel. Add GladeForms.initialize() in your main() method.', + ); + } + + // ignore: avoid-non-null-assertion, checked above + return _instance!; + } + + GladeFormsDevToolsRegistry._() { + _registerServiceExtension(); + } + + /// Initialize the registry and register DevTools service extension. + static void initialize() { + _instance ??= GladeFormsDevToolsRegistry._(); + } + + /// Register a GladeModel instance. + void registerModel(String id, GladeModelBase model) { + _models[id] = model; + } + + /// Unregister a GladeModel instance. + void unregisterModel(String id) { + final _ = _models.remove(id); + } + + void _registerServiceExtension() { + if (kReleaseMode) { + return; // DevTools integration only available in debug mode. + } + + developer.registerExtension( + 'ext.glade_forms.inspector', + (method, parameters) async { + final methodParam = parameters['method']; + + if (methodParam == 'ping') { + return developer.ServiceExtensionResponse.result( + json.encode({'status': 'ok'}), + ); + } + + if (methodParam == 'getModels') { + // Clear and rebuild child model IDs + _childModelIds.clear(); + + // First pass: serialize all models to populate _childModelIds + final allModelsData = _models.entries.map((entry) => _serializeModel(entry.key, entry.value)).toList(); + + // Second pass: filter out child models + final modelsData = allModelsData.where((modelData) => !_childModelIds.contains(modelData['id'])).toList(); + + return developer.ServiceExtensionResponse.result( + json.encode({'models': modelsData}), + ); + } + + return developer.ServiceExtensionResponse.error( + developer.ServiceExtensionResponse.invalidParams, + 'Unknown method: ${methodParam ?? "null"}', + ); + }, + ); + } + + Map _serializeModel(String id, GladeModelBase model) { + final isComposed = model is GladeComposedModel; + final baseData = model.toDevToolsJson(); + + return { + ...baseData, + 'id': id, + 'isComposed': isComposed, + 'childModels': isComposed + ? model.models.asMap().entries.map((entry) { + return _serializeChildModel(entry.key, entry.value); + }).toList() + : >[], + }; + } + + Map _serializeChildModel(int index, GladeModelBase model) { + final childId = '${model.runtimeType}_${identityHashCode(model)}'; + final _ = _childModelIds.add(childId); + + final baseData = model.toDevToolsJson(); + + return {...baseData, 'id': childId, 'index': index}; + } +} diff --git a/glade_forms/lib/src/devtools/devtools_serialization.dart b/glade_forms/lib/src/devtools/devtools_serialization.dart new file mode 100644 index 0000000..cc44e2f --- /dev/null +++ b/glade_forms/lib/src/devtools/devtools_serialization.dart @@ -0,0 +1,22 @@ +import 'package:glade_forms/src/devtools/glade_input_dev_tools_serialization.dart'; +import 'package:glade_forms/src/model/glade_model.dart'; +import 'package:glade_forms/src/model/glade_model_base.dart'; + +/// Extension providing DevTools serialization for GladeModelBase. +extension GladeModelBaseDevToolsSerialization on GladeModelBase { + /// Serialize this model to a JSON map for DevTools. + Map toDevToolsJson() { + return { + 'debugKey': debugKey, + 'formattedErrors': this is GladeModel ? (this as GladeModel).formattedValidationErrors : '', + 'inputs': this is GladeModel + ? (this as GladeModel).inputs.map((input) => input.toDevToolsJson()).toList() + : >[], + 'isDirty': isDirty, + 'isPure': isPure, + 'isUnchanged': isUnchanged, + 'isValid': isValid, + 'type': runtimeType.toString(), + }; + } +} diff --git a/glade_forms/lib/src/devtools/glade_input_dev_tools_serialization.dart b/glade_forms/lib/src/devtools/glade_input_dev_tools_serialization.dart new file mode 100644 index 0000000..f9bdc85 --- /dev/null +++ b/glade_forms/lib/src/devtools/glade_input_dev_tools_serialization.dart @@ -0,0 +1,40 @@ +import 'package:glade_forms/src/core/input/glade_input.dart'; + +/// Extension providing DevTools serialization for GladeInput. +extension GladeInputDevToolsSerialization on GladeInput { + /// Serialize this input to a JSON map for DevTools. + Map toDevToolsJson() { + // Explicitly convert to string to ensure type safety (generics can be tricky) + final strVal = stringValue; + + return { + 'depedencies': dependencies.map((d) => d.inputKey).toList(), + 'errors': validationErrors.map((e) => e.toString()).toList(), + 'hasConversionError': hasConversionError, + 'initialValue': initialValue, + 'isPure': isPure, + 'isUnchanged': isUnchanged, + 'isValid': isValid, + 'key': inputKey, + 'strValue': strVal, + // ignore: no_runtimetype_tostring, keep as is. + 'type': runtimeType.toString(), + 'value': _encodeValue(value), // Smart encoding for JSON + 'warnings': validationWarnings.map((e) => e.toString()).toList(), + }; + } + + /// Encode value for JSON - keep primitives as-is, convert complex objects to strings. + // ignore: no-object-declaration, keep object, avoid-unnecessary-nullable-parameters + Object? _encodeValue(T? val) { + if (val == null) return null; + + // Keep JSON-primitive types as-is for proper UI rendering + if (val is bool || val is int || val is double || val is String) { + return val; + } + + // Convert complex objects (Lists, Maps, custom classes) to strings + return val.toString(); + } +} diff --git a/glade_forms/lib/src/devtools/glade_model_devtools_serialization.dart b/glade_forms/lib/src/devtools/glade_model_devtools_serialization.dart new file mode 100644 index 0000000..e963ce9 --- /dev/null +++ b/glade_forms/lib/src/devtools/glade_model_devtools_serialization.dart @@ -0,0 +1,23 @@ +import 'package:glade_forms/src/devtools/glade_input_dev_tools_serialization.dart'; +import 'package:glade_forms/src/model/glade_model.dart'; +import 'package:glade_forms/src/model/glade_model_base.dart'; + +/// Extension providing DevTools serialization for GladeModelBase. +// ignore: prefer-match-file-name, keep in same file. +extension GladeModelBaseDevToolsSerialization on GladeModelBase { + /// Serialize this model to a JSON map for DevTools. + Map toDevToolsJson() { + return { + 'debugKey': debugKey, + 'formattedErrors': this is GladeModel ? (this as GladeModel).formattedValidationErrors : '', + 'inputs': this is GladeModel + ? (this as GladeModel).inputs.map((input) => input.toDevToolsJson()).toList() + : >[], + 'isDirty': isDirty, + 'isPure': isPure, + 'isUnchanged': isUnchanged, + 'isValid': isValid, + 'type': runtimeType.toString(), + }; + } +} diff --git a/glade_forms/lib/src/glade_forms_api.dart b/glade_forms/lib/src/glade_forms_api.dart new file mode 100644 index 0000000..f8d7494 --- /dev/null +++ b/glade_forms/lib/src/glade_forms_api.dart @@ -0,0 +1,22 @@ +import 'package:flutter/foundation.dart'; +import 'package:glade_forms/src/devtools/devtools_registry.dart'; + +/// Main API for Glade Forms package. +// ignore: prefer-match-file-name, keep name as is +abstract final class GladeForms { + /// Initialize the Glade Forms package. + /// + /// This must be called in your app's main() method before creating any GladeModel instances. + /// It sets up DevTools integration so the Glade Forms Inspector is available immediately. + /// + /// Example: + /// ```dart + /// void main() { + /// GladeForms.initialize(); + /// runApp(MyApp()); + /// } + /// ``` + static void initialize() { + if (kDebugMode) GladeFormsDevToolsRegistry.initialize(); + } +} diff --git a/glade_forms/lib/src/model/glade_composed_model.dart b/glade_forms/lib/src/model/glade_composed_model.dart index 4a74125..5d6c091 100644 --- a/glade_forms/lib/src/model/glade_composed_model.dart +++ b/glade_forms/lib/src/model/glade_composed_model.dart @@ -1,5 +1,4 @@ import 'package:glade_forms/src/src.dart'; - import 'package:glade_forms/src/validator/validator_result.dart'; abstract class GladeComposedModel extends GladeModelBase { @@ -40,6 +39,7 @@ abstract class GladeComposedModel extends GladeModelBa addModel(model); } } + registerWithDevTools(); } /// Adds model to `models` list. @@ -61,4 +61,16 @@ abstract class GladeComposedModel extends GladeModelBa ..unbindFromComposedModel(this); notifyListeners(); } + + @override + void dispose() { + // Iterate over a copy to avoid concurrent modification + for (final model in _models.toList()) { + model + ..removeListener(notifyListeners) + ..dispose(); + } + _models.clear(); + super.dispose(); + } } diff --git a/glade_forms/lib/src/model/glade_model.dart b/glade_forms/lib/src/model/glade_model.dart index 1c0130a..87ab3bc 100644 --- a/glade_forms/lib/src/model/glade_model.dart +++ b/glade_forms/lib/src/model/glade_model.dart @@ -72,6 +72,7 @@ abstract class GladeModel extends GladeModelBase { GladeModel() { initialize(); + registerWithDevTools(); } /// Initialize model's inputs. diff --git a/glade_forms/lib/src/model/glade_model_base.dart b/glade_forms/lib/src/model/glade_model_base.dart index 7fc0b45..3c75830 100644 --- a/glade_forms/lib/src/model/glade_model_base.dart +++ b/glade_forms/lib/src/model/glade_model_base.dart @@ -1,10 +1,17 @@ import 'package:flutter/foundation.dart'; +import 'package:glade_forms/src/devtools/devtools_registry.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 = []; + String? _devtoolsId; + + /// Unique identifier for the model instance. + /// + /// Used for DevTools inspection. + String get debugKey => runtimeType.toString(); bool get isValid; @@ -32,11 +39,26 @@ abstract class GladeModelBase extends ChangeNotifier { return _bindedComposeModels.remove(model); } + /// Registers this model with DevTools for inspection. + void registerWithDevTools() { + if (kReleaseMode) return; + + final devtoolsId = '${runtimeType}_${identityHashCode(this)}'; + _devtoolsId = devtoolsId; + GladeFormsDevToolsRegistry().registerModel(devtoolsId, this); + } + @override void dispose() { - for (final composeModel in _bindedComposeModels) { + if (_devtoolsId != null) { + GladeFormsDevToolsRegistry().unregisterModel(_devtoolsId!); + } + + // Iterate over a copy to avoid concurrent modification + for (final composeModel in _bindedComposeModels.toList()) { composeModel.removeModel(this); } + super.dispose(); } } diff --git a/glade_forms/lib/src/src.dart b/glade_forms/lib/src/src.dart index 483c7d4..7a156db 100644 --- a/glade_forms/lib/src/src.dart +++ b/glade_forms/lib/src/src.dart @@ -1,5 +1,7 @@ export 'converters/converters.dart'; export 'core/core.dart'; +export 'devtools/glade_model_devtools_serialization.dart'; +export 'glade_forms_api.dart'; export 'model/model.dart'; export 'validator/validator.dart'; export 'widgets/widgets.dart'; diff --git a/glade_forms/lib/src/validator/glade_validator.dart b/glade_forms/lib/src/validator/glade_validator.dart index 13d7783..c24a0e5 100644 --- a/glade_forms/lib/src/validator/glade_validator.dart +++ b/glade_forms/lib/src/validator/glade_validator.dart @@ -22,12 +22,11 @@ class GladeValidator { /// /// Beware that some validators assume non-null value. bool stopOnFirstErrorOrWarning = false, - }) => - ValidatorInstance( - parts: parts, - stopOnFirstError: stopOnFirstError, - stopOnFirstErrorOrWarning: stopOnFirstErrorOrWarning, - ); + }) => .new( + parts: parts, + stopOnFirstError: stopOnFirstError, + stopOnFirstErrorOrWarning: stopOnFirstErrorOrWarning, + ); /// Clears all validation parts. void clear() => parts = []; @@ -37,7 +36,7 @@ class GladeValidator { ValidateFunctionWithKey onValidate, { Object? key, ShouldValidateCallback? shouldValidate, - ValidationSeverity severity = ValidationSeverity.error, + ValidationSeverity severity = .error, }) { parts.add( CustomValidationPart( @@ -57,20 +56,19 @@ class GladeValidator { OnValidate? devMessage, Object? key, ShouldValidateCallback? shouldValidate, - ValidationSeverity severity = ValidationSeverity.error, - }) => - _customInternal( - (value) => value == null - ? ValueNullError( - value: value, - devMessage: devMessage, - key: key ?? GladeValidationsKeys.valueIsNull, - errorServerity: severity, - ) - : null, - shouldValidate: shouldValidate, - severity: severity, - ); + ValidationSeverity severity = .error, + }) => _customInternal( + (value) => value == null + ? ValueNullError( + value: value, + devMessage: devMessage, + key: key ?? GladeValidationsKeys.valueIsNull, + errorServerity: severity, + ) + : null, + shouldValidate: shouldValidate, + severity: severity, + ); /// Value must satisfy given [predicate]. Returns [ValueSatisfyPredicateError]. void satisfy( @@ -79,32 +77,31 @@ class GladeValidator { Object? key, ShouldValidateCallback? shouldValidate, Object? metaData, - ValidationSeverity severity = ValidationSeverity.error, - }) => - parts.add( - SatisfyPredicatePart( - predicate: predicate, - devMessage: devMessage ?? (value) => 'Value ${value ?? 'NULL'} does not satisfy given predicate.', - key: key, - shouldValidate: shouldValidate, - metaData: metaData, - serverity: severity, - ), - ); + ValidationSeverity severity = .error, + }) => parts.add( + SatisfyPredicatePart( + predicate: predicate, + devMessage: devMessage ?? (value) => 'Value ${value ?? 'NULL'} does not satisfy given predicate.', + key: key, + shouldValidate: shouldValidate, + metaData: metaData, + serverity: severity, + ), + ); /// Checks value with custom validation function. void _customInternal( ValidateFunction onValidate, { required ValidationSeverity severity, + // ignore: avoid-never-passed-parameters, keep for consistency Object? key, ShouldValidateCallback? shouldValidate, - }) => - parts.add( - CustomValidationPart( - customValidator: onValidate, - key: key, - shouldValidate: shouldValidate, - serverity: severity, - ), - ); + }) => parts.add( + CustomValidationPart( + customValidator: onValidate, + key: key, + shouldValidate: shouldValidate, + serverity: severity, + ), + ); } 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 091acea..a308f95 100644 --- a/glade_forms/lib/src/validator/part/input_validator_part.dart +++ b/glade_forms/lib/src/validator/part/input_validator_part.dart @@ -4,7 +4,7 @@ import 'package:glade_forms/src/validator/validator_result/glade_validator_resul typedef ShouldValidateCallback = bool Function(T value); -abstract class InputValidatorPart extends Equatable { +abstract class InputValidatorPart with EquatableMixin { // ignore: no-object-declaration, key can be any object final Object? key; diff --git a/glade_forms/lib/src/validator/specialized/date_time_validator.dart b/glade_forms/lib/src/validator/specialized/date_time_validator.dart index a9ef85a..27f1aa4 100644 --- a/glade_forms/lib/src/validator/specialized/date_time_validator.dart +++ b/glade_forms/lib/src/validator/specialized/date_time_validator.dart @@ -23,25 +23,25 @@ class DateTimeValidator extends GladeValidator { ShouldValidateCallback? shouldValidate, bool inclusiveInterval = true, bool includeTime = true, - ValidationSeverity severity = ValidationSeverity.error, - }) => - satisfy( - (value) { - final comparedValue = includeTime ? value : value.withoutTime; - final startValue = includeTime ? start : start.withoutTime; - final endValue = includeTime ? end : end.withoutTime; - - return inclusiveInterval - ? comparedValue.isAfterOrSame(startValue, includeTime: includeTime) && - comparedValue.isBeforeOrSame(endValue, includeTime: includeTime) - : comparedValue.isAfter(startValue) && value.isBefore(endValue); - }, - devMessage: devMessage ?? - (value) => 'Value $value (inclusiveInterval: $inclusiveInterval) is not in between $start and $end.', - key: key ?? GladeValidationsKeys.dateTimeIsBetweenError, - shouldValidate: shouldValidate, - severity: severity, - ); + ValidationSeverity severity = .error, + }) => satisfy( + (value) { + final comparedValue = includeTime ? value : value.withoutTime; + final startValue = includeTime ? start : start.withoutTime; + final endValue = includeTime ? end : end.withoutTime; + + return inclusiveInterval + ? comparedValue.isAfterOrSame(startValue, includeTime: includeTime) && + comparedValue.isBeforeOrSame(endValue, includeTime: includeTime) + : comparedValue.isAfter(startValue) && value.isBefore(endValue); + }, + devMessage: + devMessage ?? + (value) => 'Value $value (inclusiveInterval: $inclusiveInterval) is not in between $start and $end.', + key: key ?? GladeValidationsKeys.dateTimeIsBetweenError, + shouldValidate: shouldValidate, + severity: severity, + ); /// Compares given value with [start] value if it is after. /// @@ -57,22 +57,21 @@ class DateTimeValidator extends GladeValidator { ShouldValidateCallback? shouldValidate, bool inclusiveInterval = true, bool includeTime = true, - ValidationSeverity severity = ValidationSeverity.error, - }) => - satisfy( - (value) { - final comparedValue = includeTime ? value : value.withoutTime; - final startValue = includeTime ? start : start.withoutTime; - - return inclusiveInterval - ? comparedValue.isAfterOrSame(startValue, includeTime: includeTime) - : comparedValue.isAfter(startValue); - }, - devMessage: devMessage ?? (value) => 'Value $value (inclusiveInterval: $inclusiveInterval) is not after $start', - key: key ?? GladeValidationsKeys.dateTimeIsAfterError, - shouldValidate: shouldValidate, - severity: severity, - ); + ValidationSeverity severity = .error, + }) => satisfy( + (value) { + final comparedValue = includeTime ? value : value.withoutTime; + final startValue = includeTime ? start : start.withoutTime; + + return inclusiveInterval + ? comparedValue.isAfterOrSame(startValue, includeTime: includeTime) + : comparedValue.isAfter(startValue); + }, + devMessage: devMessage ?? (value) => 'Value $value (inclusiveInterval: $inclusiveInterval) is not after $start', + key: key ?? GladeValidationsKeys.dateTimeIsAfterError, + shouldValidate: shouldValidate, + severity: severity, + ); /// Compares given value with [end] value if it is before. /// @@ -88,22 +87,21 @@ class DateTimeValidator extends GladeValidator { ShouldValidateCallback? shouldValidate, bool inclusiveInterval = true, bool includeTime = true, - ValidationSeverity severity = ValidationSeverity.error, - }) => - satisfy( - (value) { - final comparedValue = includeTime ? value : value.withoutTime; - final endValue = includeTime ? end : end.withoutTime; - - return inclusiveInterval - ? comparedValue.isBeforeOrSame(endValue, includeTime: includeTime) - : comparedValue.isBefore(endValue); - }, - devMessage: devMessage ?? (value) => 'Value $value (inclusiveInterval: $inclusiveInterval) is not before $end', - key: key ?? GladeValidationsKeys.dateTimeIsBeforeError, - shouldValidate: shouldValidate, - severity: severity, - ); + ValidationSeverity severity = .error, + }) => satisfy( + (value) { + final comparedValue = includeTime ? value : value.withoutTime; + final endValue = includeTime ? end : end.withoutTime; + + return inclusiveInterval + ? comparedValue.isBeforeOrSame(endValue, includeTime: includeTime) + : comparedValue.isBefore(endValue); + }, + devMessage: devMessage ?? (value) => 'Value $value (inclusiveInterval: $inclusiveInterval) is not before $end', + key: key ?? GladeValidationsKeys.dateTimeIsBeforeError, + shouldValidate: shouldValidate, + severity: severity, + ); } class DateTimeValidatorNullable extends GladeValidator { @@ -122,28 +120,28 @@ class DateTimeValidatorNullable extends GladeValidator { ShouldValidateCallback? shouldValidate, bool inclusiveInterval = true, bool includeTime = true, - ValidationSeverity severity = ValidationSeverity.error, - }) => - satisfy( - (value) { - if (value == null) return false; - - final comparedValue = includeTime ? value : value.withoutTime; - final startValue = includeTime ? start : start.withoutTime; - final endValue = includeTime ? end : end.withoutTime; - - return inclusiveInterval - ? comparedValue.isAfterOrSame(startValue, includeTime: includeTime) && - comparedValue.isBeforeOrSame(endValue, includeTime: includeTime) - : comparedValue.isAfter(startValue) && value.isBefore(endValue); - }, - devMessage: devMessage ?? - (value) => - 'Value ${value ?? 'NULL'} (inclusiveInterval: $inclusiveInterval) is not in between $start and $end.', - key: key ?? GladeValidationsKeys.dateTimeIsBetweenError, - shouldValidate: shouldValidate, - severity: severity, - ); + ValidationSeverity severity = .error, + }) => satisfy( + (value) { + if (value == null) return false; + + final comparedValue = includeTime ? value : value.withoutTime; + final startValue = includeTime ? start : start.withoutTime; + final endValue = includeTime ? end : end.withoutTime; + + return inclusiveInterval + ? comparedValue.isAfterOrSame(startValue, includeTime: includeTime) && + comparedValue.isBeforeOrSame(endValue, includeTime: includeTime) + : comparedValue.isAfter(startValue) && value.isBefore(endValue); + }, + devMessage: + devMessage ?? + (value) => + 'Value ${value ?? 'NULL'} (inclusiveInterval: $inclusiveInterval) is not in between $start and $end.', + key: key ?? GladeValidationsKeys.dateTimeIsBetweenError, + shouldValidate: shouldValidate, + severity: severity, + ); /// Compares given value with [start] value if it is after. /// @@ -159,25 +157,24 @@ class DateTimeValidatorNullable extends GladeValidator { ShouldValidateCallback? shouldValidate, bool inclusiveInterval = true, bool includeTime = true, - ValidationSeverity severity = ValidationSeverity.error, - }) => - satisfy( - (value) { - if (value == null) return false; - - final comparedValue = includeTime ? value : value.withoutTime; - final startValue = includeTime ? start : start.withoutTime; - - return inclusiveInterval - ? comparedValue.isAfterOrSame(startValue, includeTime: includeTime) - : comparedValue.isAfter(startValue); - }, - devMessage: devMessage ?? - (value) => 'Value ${value ?? 'NULL'} (inclusiveInterval: $inclusiveInterval) is not after $start', - key: key ?? GladeValidationsKeys.dateTimeIsAfterError, - shouldValidate: shouldValidate, - severity: severity, - ); + ValidationSeverity severity = .error, + }) => satisfy( + (value) { + if (value == null) return false; + + final comparedValue = includeTime ? value : value.withoutTime; + final startValue = includeTime ? start : start.withoutTime; + + return inclusiveInterval + ? comparedValue.isAfterOrSame(startValue, includeTime: includeTime) + : comparedValue.isAfter(startValue); + }, + devMessage: + devMessage ?? (value) => 'Value ${value ?? 'NULL'} (inclusiveInterval: $inclusiveInterval) is not after $start', + key: key ?? GladeValidationsKeys.dateTimeIsAfterError, + shouldValidate: shouldValidate, + severity: severity, + ); /// Compares given value with [end] value if it is before. /// @@ -191,23 +188,22 @@ class DateTimeValidatorNullable extends GladeValidator { ShouldValidateCallback? shouldValidate, bool inclusiveInterval = true, bool includeTime = true, - ValidationSeverity severity = ValidationSeverity.error, - }) => - satisfy( - (value) { - if (value == null) return false; - - final comparedValue = includeTime ? value : value.withoutTime; - final endValue = includeTime ? end : end.withoutTime; - - return inclusiveInterval - ? comparedValue.isBeforeOrSame(endValue, includeTime: includeTime) - : comparedValue.isBefore(endValue); - }, - devMessage: devMessage ?? - (value) => 'Value ${value ?? 'NULL'} (inclusiveInterval: $inclusiveInterval) is not before $end', - key: key ?? GladeValidationsKeys.dateTimeIsBeforeError, - shouldValidate: shouldValidate, - severity: severity, - ); + ValidationSeverity severity = .error, + }) => satisfy( + (value) { + if (value == null) return false; + + final comparedValue = includeTime ? value : value.withoutTime; + final endValue = includeTime ? end : end.withoutTime; + + return inclusiveInterval + ? comparedValue.isBeforeOrSame(endValue, includeTime: includeTime) + : comparedValue.isBefore(endValue); + }, + devMessage: + devMessage ?? (value) => 'Value ${value ?? 'NULL'} (inclusiveInterval: $inclusiveInterval) is not before $end', + key: key ?? GladeValidationsKeys.dateTimeIsBeforeError, + shouldValidate: shouldValidate, + severity: severity, + ); } diff --git a/glade_forms/lib/src/validator/specialized/int_validator.dart b/glade_forms/lib/src/validator/specialized/int_validator.dart index 23b079b..fc96507 100644 --- a/glade_forms/lib/src/validator/specialized/int_validator.dart +++ b/glade_forms/lib/src/validator/specialized/int_validator.dart @@ -19,16 +19,16 @@ class IntValidator extends GladeValidator { Object? key, ShouldValidateCallback? shouldValidate, bool inclusiveInterval = true, - ValidationSeverity severity = ValidationSeverity.error, - }) => - satisfy( - (value) => inclusiveInterval ? value >= min && value <= max : value > min && value < max, - devMessage: devMessage ?? - (value) => 'Value $value (inclusiveInterval: $inclusiveInterval) is not in between $min and $max.', - key: key ?? GladeValidationsKeys.intCompareError, - shouldValidate: shouldValidate, - severity: severity, - ); + ValidationSeverity severity = .error, + }) => satisfy( + (value) => inclusiveInterval ? value >= min && value <= max : value > min && value < max, + devMessage: + devMessage ?? + (value) => 'Value $value (inclusiveInterval: $inclusiveInterval) is not in between $min and $max.', + key: key ?? GladeValidationsKeys.intCompareError, + shouldValidate: shouldValidate, + severity: severity, + ); /// Compares given value with [min] value. /// @@ -41,15 +41,14 @@ class IntValidator extends GladeValidator { OnValidate? devMessage, Object? key, ShouldValidateCallback? shouldValidate, - ValidationSeverity severity = ValidationSeverity.error, - }) => - satisfy( - (value) => isInclusive ? value >= min : value > min, - devMessage: devMessage ?? (value) => 'Value $value is less than $min.', - key: key ?? GladeValidationsKeys.intCompareMinError, - shouldValidate: shouldValidate, - severity: severity, - ); + ValidationSeverity severity = .error, + }) => satisfy( + (value) => isInclusive ? value >= min : value > min, + devMessage: devMessage ?? (value) => 'Value $value is less than $min.', + key: key ?? GladeValidationsKeys.intCompareMinError, + shouldValidate: shouldValidate, + severity: severity, + ); /// Compares given value with [max] value. /// @@ -62,15 +61,14 @@ class IntValidator extends GladeValidator { OnValidate? devMessage, Object? key, ShouldValidateCallback? shouldValidate, - ValidationSeverity severity = ValidationSeverity.error, - }) => - satisfy( - (value) => isInclusive ? value <= max : value < max, - devMessage: devMessage ?? (value) => 'Value $value is bigger than $max.', - key: key ?? GladeValidationsKeys.intCompareMaxError, - shouldValidate: shouldValidate, - severity: severity, - ); + ValidationSeverity severity = .error, + }) => satisfy( + (value) => isInclusive ? value <= max : value < max, + devMessage: devMessage ?? (value) => 'Value $value is bigger than $max.', + key: key ?? GladeValidationsKeys.intCompareMaxError, + shouldValidate: shouldValidate, + severity: severity, + ); /// Checks if the given value is positive. /// @@ -82,16 +80,15 @@ class IntValidator extends GladeValidator { OnValidate? devMessage, Object? key, ShouldValidateCallback? shouldValidate, - ValidationSeverity severity = ValidationSeverity.error, - }) => - isMin( - min: 0, - isInclusive: includeZero, - devMessage: devMessage, - key: key ?? GladeValidationsKeys.intCompareIsPositiveError, - shouldValidate: shouldValidate, - severity: severity, - ); + ValidationSeverity severity = .error, + }) => isMin( + min: 0, + isInclusive: includeZero, + devMessage: devMessage, + key: key ?? GladeValidationsKeys.intCompareIsPositiveError, + shouldValidate: shouldValidate, + severity: severity, + ); /// Checks if the given value is negative. /// @@ -103,16 +100,15 @@ class IntValidator extends GladeValidator { OnValidate? devMessage, Object? key, ShouldValidateCallback? shouldValidate, - ValidationSeverity severity = ValidationSeverity.error, - }) => - isMax( - max: 0, - isInclusive: includeZero, - devMessage: devMessage, - key: key ?? GladeValidationsKeys.intCompareIsNegativeError, - shouldValidate: shouldValidate, - severity: severity, - ); + ValidationSeverity severity = .error, + }) => isMax( + max: 0, + isInclusive: includeZero, + devMessage: devMessage, + key: key ?? GladeValidationsKeys.intCompareIsNegativeError, + shouldValidate: shouldValidate, + severity: severity, + ); } /// Nullable version of [IntValidator]. @@ -129,21 +125,20 @@ class IntValidatorNullable extends GladeValidator { Object? key, ShouldValidateCallback? shouldValidate, bool inclusiveInterval = true, - ValidationSeverity severity = ValidationSeverity.error, - }) => - satisfy( - (value) { - if (value == null) return false; + ValidationSeverity severity = .error, + }) => satisfy( + (value) { + if (value == null) return false; - return inclusiveInterval ? value >= min && value <= max : value > min && value < max; - }, - devMessage: devMessage ?? - (value) => - 'Value ${value ?? 'NULL'} (inclusiveInterval: $inclusiveInterval) is not in between $min and $max.', - key: key ?? GladeValidationsKeys.intCompareError, - shouldValidate: shouldValidate, - severity: severity, - ); + return inclusiveInterval ? value >= min && value <= max : value > min && value < max; + }, + devMessage: + devMessage ?? + (value) => 'Value ${value ?? 'NULL'} (inclusiveInterval: $inclusiveInterval) is not in between $min and $max.', + key: key ?? GladeValidationsKeys.intCompareError, + shouldValidate: shouldValidate, + severity: severity, + ); /// Compares given value with [min] value. /// @@ -156,19 +151,18 @@ class IntValidatorNullable extends GladeValidator { OnValidate? devMessage, Object? key, ShouldValidateCallback? shouldValidate, - ValidationSeverity severity = ValidationSeverity.error, - }) => - satisfy( - (value) { - if (value == null) return false; + ValidationSeverity severity = .error, + }) => satisfy( + (value) { + if (value == null) return false; - return isInclusive ? value >= min : value > min; - }, - devMessage: devMessage ?? (value) => 'Value ${value ?? 'NULL'} is less than $min.', - key: key ?? GladeValidationsKeys.intCompareMinError, - shouldValidate: shouldValidate, - severity: severity, - ); + return isInclusive ? value >= min : value > min; + }, + devMessage: devMessage ?? (value) => 'Value ${value ?? 'NULL'} is less than $min.', + key: key ?? GladeValidationsKeys.intCompareMinError, + shouldValidate: shouldValidate, + severity: severity, + ); /// Compares given value with [max] value. /// @@ -181,19 +175,18 @@ class IntValidatorNullable extends GladeValidator { OnValidate? devMessage, Object? key, ShouldValidateCallback? shouldValidate, - ValidationSeverity severity = ValidationSeverity.error, - }) => - satisfy( - (value) { - if (value == null) return false; + ValidationSeverity severity = .error, + }) => satisfy( + (value) { + if (value == null) return false; - return isInclusive ? value <= max : value < max; - }, - devMessage: devMessage ?? (value) => 'Value ${value ?? 'NULL'} is bigger than $max.', - key: key ?? GladeValidationsKeys.intCompareMaxError, - shouldValidate: shouldValidate, - severity: severity, - ); + return isInclusive ? value <= max : value < max; + }, + devMessage: devMessage ?? (value) => 'Value ${value ?? 'NULL'} is bigger than $max.', + key: key ?? GladeValidationsKeys.intCompareMaxError, + shouldValidate: shouldValidate, + severity: severity, + ); /// Checks if the given value is positive. /// @@ -205,16 +198,15 @@ class IntValidatorNullable extends GladeValidator { OnValidate? devMessage, Object? key, ShouldValidateCallback? shouldValidate, - ValidationSeverity severity = ValidationSeverity.error, - }) => - isMin( - min: 0, - isInclusive: includeZero, - devMessage: devMessage, - key: key ?? GladeValidationsKeys.intCompareIsPositiveError, - shouldValidate: shouldValidate, - severity: severity, - ); + ValidationSeverity severity = .error, + }) => isMin( + min: 0, + isInclusive: includeZero, + devMessage: devMessage, + key: key ?? GladeValidationsKeys.intCompareIsPositiveError, + shouldValidate: shouldValidate, + severity: severity, + ); /// Checks if the given value is negative. /// @@ -226,14 +218,13 @@ class IntValidatorNullable extends GladeValidator { OnValidate? devMessage, Object? key, ShouldValidateCallback? shouldValidate, - ValidationSeverity severity = ValidationSeverity.error, - }) => - isMax( - max: 0, - isInclusive: includeZero, - devMessage: devMessage, - key: key ?? GladeValidationsKeys.intCompareIsNegativeError, - shouldValidate: shouldValidate, - severity: severity, - ); + ValidationSeverity severity = .error, + }) => isMax( + max: 0, + isInclusive: includeZero, + devMessage: devMessage, + key: key ?? GladeValidationsKeys.intCompareIsNegativeError, + shouldValidate: shouldValidate, + severity: severity, + ); } diff --git a/glade_forms/lib/src/validator/specialized/string_validator.dart b/glade_forms/lib/src/validator/specialized/string_validator.dart index e74450e..bd73c32 100644 --- a/glade_forms/lib/src/validator/specialized/string_validator.dart +++ b/glade_forms/lib/src/validator/specialized/string_validator.dart @@ -15,15 +15,14 @@ class StringValidator extends GladeValidator { Object? key, ShouldValidateCallback? shouldValidate, bool allowBlank = true, - ValidationSeverity severity = ValidationSeverity.error, - }) => - satisfy( - (input) => allowBlank ? input.isNotEmpty : input.trim().isNotEmpty, - devMessage: devMessage ?? (_) => "Value can't be empty", - key: key ?? GladeValidationsKeys.stringEmpty, - shouldValidate: shouldValidate, - severity: severity, - ); + ValidationSeverity severity = .error, + }) => satisfy( + (input) => allowBlank ? input.isNotEmpty : input.trim().isNotEmpty, + devMessage: devMessage ?? (_) => "Value can't be empty", + key: key ?? GladeValidationsKeys.stringEmpty, + shouldValidate: shouldValidate, + severity: severity, + ); /// Checks that value is valid email address. /// @@ -37,23 +36,22 @@ class StringValidator extends GladeValidator { bool allowEmpty = false, Object? key, ShouldValidateCallback? shouldValidate, - ValidationSeverity severity = ValidationSeverity.error, - }) => - satisfy( - (x) { - if (x.isEmpty) { - return allowEmpty; - } + ValidationSeverity severity = .error, + }) => satisfy( + (x) { + if (x.isEmpty) { + return allowEmpty; + } - final regExp = RegExp(RegexPatterns.email); + final regExp = RegExp(RegexPatterns.email); - return regExp.hasMatch(x); - }, - devMessage: devMessage ?? (value) => 'Value "$value" is not in e-mail format', - key: key ?? GladeValidationsKeys.stringNotEmail, - shouldValidate: shouldValidate, - severity: severity, - ); + return regExp.hasMatch(x); + }, + devMessage: devMessage ?? (value) => 'Value "$value" is not in e-mail format', + key: key ?? GladeValidationsKeys.stringNotEmail, + shouldValidate: shouldValidate, + severity: severity, + ); /// Checks that value is valid URL address. /// @@ -66,30 +64,29 @@ class StringValidator extends GladeValidator { bool requiresScheme = false, Object key = GladeValidationsKeys.stringNotUrl, ShouldValidateCallback? shouldValidate, - ValidationSeverity severity = ValidationSeverity.error, - }) => - satisfy( - (value) { - if (value.isEmpty) { - return allowEmpty; - } - - final quantifier = requiresScheme ? '{1}' : '?'; + ValidationSeverity severity = .error, + }) => satisfy( + (value) { + if (value.isEmpty) { + return allowEmpty; + } - final exp = RegExp( - r'^(?:https?:\/\/)' + - quantifier + - r'(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/=]*)$', - ); + final quantifier = requiresScheme ? '{1}' : '?'; - return exp.hasMatch(value); - }, - devMessage: devMessage ?? (value) => 'Value "$value" is not valid URL address', - key: key, - shouldValidate: shouldValidate, - severity: severity, + final exp = RegExp( + r'^(?:https?:\/\/)' + + quantifier + + r'(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/=]*)$', ); + return exp.hasMatch(value); + }, + devMessage: devMessage ?? (value) => 'Value "$value" is not valid URL address', + key: key, + shouldValidate: shouldValidate, + severity: severity, + ); + /// Matches provided regex [pattern]. /// /// [multiline] - if true, the pattern will match line terminators. @@ -110,21 +107,25 @@ class StringValidator extends GladeValidator { OnValidate? devMessage, Object? key, ShouldValidateCallback? shouldValidate, - ValidationSeverity severity = ValidationSeverity.error, - }) => - satisfy( - (value) { - final regex = - RegExp(pattern, multiLine: multiline, caseSensitive: caseSensitive, dotAll: dotAll, unicode: unicode); - - return regex.hasMatch(value); - }, - devMessage: devMessage ?? (value) => 'Value "$value" does not match regex', - key: key ?? GladeValidationsKeys.stringPatternMatch, - shouldValidate: shouldValidate, - severity: severity, + ValidationSeverity severity = .error, + }) => satisfy( + (value) { + final regex = RegExp( + pattern, + multiLine: multiline, + caseSensitive: caseSensitive, + dotAll: dotAll, + unicode: unicode, ); + return regex.hasMatch(value); + }, + devMessage: devMessage ?? (value) => 'Value "$value" does not match regex', + key: key ?? GladeValidationsKeys.stringPatternMatch, + shouldValidate: shouldValidate, + severity: severity, + ); + /// String's length has to be greater or equal to provided [length]. /// /// Default [key] is [ GladeValidationsKeys.stringMinLength]. @@ -133,17 +134,16 @@ class StringValidator extends GladeValidator { OnValidate? devMessage, Object? key, ShouldValidateCallback? shouldValidate, - ValidationSeverity severity = ValidationSeverity.error, - }) => - satisfy( - (value) { - return value.length >= length; - }, - devMessage: devMessage ?? (value) => 'Value "$value" is shorter than allowed length $length', - key: key ?? GladeValidationsKeys.stringMinLength, - shouldValidate: shouldValidate, - severity: severity, - ); + ValidationSeverity severity = .error, + }) => satisfy( + (value) { + return value.length >= length; + }, + devMessage: devMessage ?? (value) => 'Value "$value" is shorter than allowed length $length', + key: key ?? GladeValidationsKeys.stringMinLength, + shouldValidate: shouldValidate, + severity: severity, + ); /// String's length has to be less or equal to provided [length]. /// @@ -153,14 +153,13 @@ class StringValidator extends GladeValidator { OnValidate? devMessage, Object? key, ShouldValidateCallback? shouldValidate, - }) => - satisfy( - (value) => value.length < length, - devMessage: devMessage ?? (value) => 'Value "$value " is longer than allowed length $length', - key: key ?? GladeValidationsKeys.stringMaxLength, - shouldValidate: shouldValidate, - metaData: length, - ); + }) => satisfy( + (value) => value.length < length, + devMessage: devMessage ?? (value) => 'Value "$value " is longer than allowed length $length', + key: key ?? GladeValidationsKeys.stringMaxLength, + shouldValidate: shouldValidate, + metaData: length, + ); /// String's length has to be equal to provided [length]. /// @@ -170,13 +169,12 @@ class StringValidator extends GladeValidator { OnValidate? devMessage, Object? key, ShouldValidateCallback? shouldValidate, - ValidationSeverity severity = ValidationSeverity.error, - }) => - satisfy( - (value) => value.length == length, - devMessage: devMessage ?? (value) => 'Value "$value " has to be $length long characters.', - key: key ?? GladeValidationsKeys.stringExactLength, - shouldValidate: shouldValidate, - severity: severity, - ); + ValidationSeverity severity = .error, + }) => satisfy( + (value) => value.length == length, + devMessage: devMessage ?? (value) => 'Value "$value " has to be $length long characters.', + key: key ?? GladeValidationsKeys.stringExactLength, + shouldValidate: shouldValidate, + severity: severity, + ); } diff --git a/glade_forms/lib/src/validator/validator_instance.dart b/glade_forms/lib/src/validator/validator_instance.dart index 7b66221..68d9d7f 100644 --- a/glade_forms/lib/src/validator/validator_instance.dart +++ b/glade_forms/lib/src/validator/validator_instance.dart @@ -40,7 +40,7 @@ class ValidatorInstance { final result = part.validate(value); if (result != null) { - final isError = result.severity == ValidationSeverity.error; + final isError = result.severity == .error; combined.add(result); diff --git a/glade_forms/lib/src/validator/validator_result.dart b/glade_forms/lib/src/validator/validator_result.dart index 4990851..ba81147 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_result/glade_validator_result.dart'; /// Complete result of validation process. -class ValidatorResult extends Equatable { +class ValidatorResult with EquatableMixin { /// Input associated with this validation result. final GladeInput? associatedInput; @@ -39,8 +39,8 @@ class ValidatorResult extends Equatable { bool isValidWithSeverity(ValidationSeverity severity) { return switch (severity) { - ValidationSeverity.error => isValid, - ValidationSeverity.warning => isValidWithoutWarnings, + .error => isValid, + .warning => isValidWithoutWarnings, }; } } diff --git a/glade_forms/lib/src/validator/validator_result/glade_validator_result.dart b/glade_forms/lib/src/validator/validator_result/glade_validator_result.dart index 4231984..6bd9361 100644 --- a/glade_forms/lib/src/validator/validator_result/glade_validator_result.dart +++ b/glade_forms/lib/src/validator/validator_result/glade_validator_result.dart @@ -24,18 +24,27 @@ abstract class GladeValidatorResult extends GladeInputValidation with Equa String get devValidationMessage => onValidateMessage(value); @override - List get props => - [value, onValidateMessage, key, result, isConversionError, isNullError, severity, _errorServerity]; + List get props => [ + value, + onValidateMessage, + key, + result, + isConversionError, + isNullError, + severity, + _errorServerity, + ]; GladeValidatorResult({ required this.value, required super.key, OnValidate? devMessage, - ValidationSeverity errorServerity = ValidationSeverity.error, - }) : onValidateMessage = devMessage ?? - ((v) => - 'Value "${v ?? 'NULL'}" does not satisfy validation. [This is default validation meessage. Use `onValidateMessage` to customize validation errors]'), - _errorServerity = errorServerity; + ValidationSeverity errorServerity = .error, + }) : onValidateMessage = + devMessage ?? + ((v) => + 'Value "${v ?? 'NULL'}" does not satisfy validation. [This is default validation meessage. Use `onValidateMessage` to customize validation errors]'), + _errorServerity = errorServerity; @override String toString() { diff --git a/glade_forms/lib/src/widgets/glade_composed_list_builder.dart b/glade_forms/lib/src/widgets/glade_composed_list_builder.dart index 3a0fd54..501bd94 100644 --- a/glade_forms/lib/src/widgets/glade_composed_list_builder.dart +++ b/glade_forms/lib/src/widgets/glade_composed_list_builder.dart @@ -2,12 +2,13 @@ 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 GladeComposedListItemBuilder, M extends GladeModelBase> = + Widget Function( + BuildContext context, + C composedModel, + M itemModel, + int index, + ); typedef Builder = Widget Function(BuildContext context); @@ -25,25 +26,24 @@ class GladeComposedListBuilder, M extends GladeM required GladeComposedListItemBuilder itemBuilder, Key? key, ScrollPhysics? physics, - Axis scrollDirection = Axis.vertical, + Axis scrollDirection = .vertical, bool shrinkWrap = false, Widget? child, - }) => - GladeComposedListBuilder._( - itemBuilder: itemBuilder, - key: key, - physics: physics, - scrollDirection: scrollDirection, - shrinkWrap: shrinkWrap, - child: 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, + Axis scrollDirection = .vertical, bool shrinkWrap = false, Key? key, }) { @@ -63,7 +63,7 @@ class GladeComposedListBuilder, M extends GladeM required GladeComposedListItemBuilder itemBuilder, Widget? child, ScrollPhysics? physics, - Axis scrollDirection = Axis.vertical, + Axis scrollDirection = .vertical, bool shrinkWrap = false, Key? key, }) { @@ -93,7 +93,7 @@ class GladeComposedListBuilder, M extends GladeM Widget build(BuildContext context) { // choose provider style: create/value if (create case final createFn?) { - return GladeModelProvider( + return GladeModelProvider( create: createFn, child: _GladeComposedFormList( scrollDirection: scrollDirection, diff --git a/glade_forms/lib/src/widgets/glade_form_debug_info.dart b/glade_forms/lib/src/widgets/glade_form_debug_info.dart index 7ef8752..dfe44e9 100644 --- a/glade_forms/lib/src/widgets/glade_form_debug_info.dart +++ b/glade_forms/lib/src/widgets/glade_form_debug_info.dart @@ -76,16 +76,16 @@ class _GladeFormDebugInfoState extends State extends State setState(() => _showMetadata = value), ), ), @@ -172,12 +172,12 @@ class _GladeFormDebugInfoState extends State SingleChildScrollView( - scrollDirection: Axis.horizontal, + scrollDirection: .horizontal, child: child, ), child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: .min, + crossAxisAlignment: .start, children: [ _GladeInputsTable( scrollable: widget.scrollable, @@ -242,8 +242,9 @@ class _GladeInputsTable extends StatelessWidget { Widget build(BuildContext context) { final inputs = model.inputs.where((element) => !hiddenKeys.contains(element.inputKey)); final rowColor = Theme.of(context).colorScheme.surface; - final alternativeRowColor = - MediaQuery.platformBrightnessOf(context) == Brightness.dark ? rowColor.lighten() : rowColor.darken(); + final alternativeRowColor = MediaQuery.platformBrightnessOf(context) == .dark + ? rowColor.lighten() + : rowColor.darken(); return Table( defaultColumnWidth: scrollable ? const IntrinsicColumnWidth() : const FlexColumnWidth(), @@ -251,7 +252,10 @@ class _GladeInputsTable extends StatelessWidget { children: [ // Columns header. TableRow( - decoration: BoxDecoration(color: Theme.of(context).canvasColor, border: const Border(bottom: BorderSide())), + decoration: BoxDecoration( + color: Theme.of(context).canvasColor, + border: const Border(bottom: BorderSide()), + ), children: [ const _ColumnHeader('Input'), if (showIsUnchanged) const _ColumnHeader('isUnchanged'), @@ -272,7 +276,7 @@ class _GladeInputsTable extends StatelessWidget { ), children: [ Padding( - padding: const EdgeInsets.only(left: 5), + padding: const .only(left: 5), child: _RowValue(value: x.inputKey, wrap: true, center: false), ), if (showIsUnchanged) _RowValue(value: x.isUnchanged, tracked: x.trackUnchanged), @@ -305,8 +309,9 @@ class _GladeModelMetadataTable extends StatelessWidget { @override Widget build(BuildContext context) { final rowColor = Theme.of(context).colorScheme.surface; - final alternativeRowColor = - MediaQuery.platformBrightnessOf(context) == Brightness.dark ? rowColor.lighten() : rowColor.darken(); + final alternativeRowColor = MediaQuery.platformBrightnessOf(context) == .dark + ? rowColor.lighten() + : rowColor.darken(); return Column( children: [ @@ -316,8 +321,10 @@ class _GladeModelMetadataTable extends StatelessWidget { border: const TableBorder.symmetric(outside: BorderSide()), children: [ TableRow( - decoration: - BoxDecoration(color: Theme.of(context).canvasColor, border: const Border(bottom: BorderSide())), + decoration: BoxDecoration( + color: Theme.of(context).canvasColor, + border: const Border(bottom: BorderSide()), + ), children: const [_ColumnHeader('Key'), _ColumnHeader('Value'), _ColumnHeader('')], ), for (final (index, entry) in model.fillDebugMetadata().entries.indexed) @@ -327,12 +334,13 @@ class _GladeModelMetadataTable extends StatelessWidget { ), children: [ Padding( - padding: const EdgeInsets.only(left: 5), + padding: const .only(left: 5), child: _RowValue(value: entry.key), ), _RowValue( value: entry.value, - colorizedValue: entry.value is String || + colorizedValue: + entry.value is String || (entry.value is GladeMetaData && (entry.value as GladeMetaData).shouldIndicateStringValue), ), const _RowValue(value: ''), @@ -352,7 +360,10 @@ class _ColumnHeader extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding(padding: const EdgeInsets.only(left: 10), child: Center(child: Text(label))); + return Padding( + padding: const .only(left: 10), + child: Center(child: Text(label)), + ); } } @@ -402,7 +413,7 @@ class _RowValue extends StatelessWidget { child: Text( value?.toString() ?? '', softWrap: wrap, - overflow: wrap ? null : TextOverflow.ellipsis, + overflow: wrap ? null : .ellipsis, ), ), ), @@ -418,7 +429,7 @@ class _StringValue extends StatelessWidget { @override Widget build(BuildContext context) { return ColoredBox( - color: Theme.of(context).brightness == Brightness.light + color: Theme.of(context).brightness == .light ? const Color.fromARGB(255, 196, 222, 184) : const Color.fromARGB(255, 15, 46, 0), child: Text( @@ -473,6 +484,7 @@ class _DangerStrips extends StatelessWidget { final Color color1; final Color color2; final double gap; + const _DangerStrips({ required this.color1, required this.color2, @@ -483,7 +495,7 @@ class _DangerStrips extends StatelessWidget { Widget build(BuildContext context) { return SizedBox( height: 5, - width: double.infinity, + width: .infinity, child: LayoutBuilder( builder: (context, constraints) { 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 19238d7..c40c68b 100644 --- a/glade_forms/lib/src/widgets/glade_model_provider.dart +++ b/glade_forms/lib/src/widgets/glade_model_provider.dart @@ -30,6 +30,6 @@ class GladeModelProvider extends StatelessWidget { } // ignore: avoid-non-null-assertion, when value is null, it is guaranteed that create is not null. - return ChangeNotifierProvider(create: create!, child: child); + return ChangeNotifierProvider(create: create!, child: child); } } diff --git a/glade_forms/pubspec.yaml b/glade_forms/pubspec.yaml index e4728bc..912301d 100644 --- a/glade_forms/pubspec.yaml +++ b/glade_forms/pubspec.yaml @@ -1,15 +1,12 @@ name: glade_forms +resolution: workspace description: A universal way to define form validators with support of translations. -version: 5.1.0 +version: 6.0.0 repository: https://github.com/netglade/glade_forms issue_tracker: https://github.com/netglade/glade_forms/issues -screenshots: - - description: The glade_forms package logo. - path: doc/icon.png environment: - sdk: ^3.6.0 -resolution: workspace + sdk: ^3.10.0 # Add regular dependencies here. dependencies: @@ -24,5 +21,10 @@ dependencies: provider: ^6.0.5 dev_dependencies: - netglade_analysis: ^18.0.0 + netglade_analysis: ^21.0.0 test: ^1.25.8 + yaml: ^3.1.2 + +screenshots: + - description: The glade_forms package logo. + path: doc/icon.png diff --git a/glade_forms/test/README_DEVTOOLS_TESTS.md b/glade_forms/test/README_DEVTOOLS_TESTS.md new file mode 100644 index 0000000..1312f8f --- /dev/null +++ b/glade_forms/test/README_DEVTOOLS_TESTS.md @@ -0,0 +1,63 @@ +# DevTools Extension Tests + +This directory contains tests for the DevTools extension integration. + +## Test Coverage + +### Configuration Tests (`devtools_extension_test.dart`) +Tests the configuration files and directory structure: +- Verifies `devtools_options.yaml` exists and is valid +- Validates `extension/config.yaml` structure and required fields +- Checks that extension paths are correctly configured +- Ensures directory structure is in place +- Validates documentation exists + +### Widget Tests (`extension_widget_test.dart`) - Extension Package +Tests the extension UI: +- Verifies the extension app builds successfully +- Tests that feature descriptions are displayed +- Validates branding and messaging + +### Package Configuration Tests (`package_config_test.dart`) - Extension Package +Tests the extension package setup: +- Validates pubspec.yaml configuration +- Checks required dependencies are present +- Verifies SDK constraints are correct +- Ensures entry points exist (main.dart, index.html) +- Validates documentation + +## Running Tests + +### Main Package Tests +```bash +cd glade_forms +flutter test test/devtools_extension_test.dart +``` + +### Extension Package Tests +```bash +cd glade_forms_devtools_extension +flutter test +``` + +### All Tests (via Melos) +```bash +# From repository root +melos run test +``` + +## Test Philosophy + +These tests focus on: +1. **Configuration validation** - Ensuring all config files are correct +2. **Structure validation** - Verifying directory structure is as expected +3. **Basic UI tests** - Ensuring the extension app builds and displays correctly + +Note: Full integration testing of the extension with DevTools and a running app requires manual testing in a real Flutter development environment. + +## Adding New Tests + +When adding features to the extension: +1. Add widget tests for new UI components +2. Update configuration tests if structure changes +3. Document test coverage in this file diff --git a/glade_forms/test/date_time_validator_test.dart b/glade_forms/test/date_time_validator_test.dart index d9a7207..c73b31a 100644 --- a/glade_forms/test/date_time_validator_test.dart +++ b/glade_forms/test/date_time_validator_test.dart @@ -71,13 +71,17 @@ void main() { test( '($index): $testDate should be valid between $start - $end when testing inclusive and with included time', () { - final validator = (DateTimeValidator() - // ignore: avoid_redundant_argument_values, be explicit in tests. - ..isBetween(start: start, end: end, includeTime: true, inclusiveInterval: true)) - .build(); + // arrange + final validator = + (DateTimeValidator() + // ignore: avoid_redundant_argument_values, be explicit in tests. + ..isBetween(start: start, end: end, includeTime: true, inclusiveInterval: true)) + .build(); + // act final result = validator.validate(testDate); + // assert expect(result.isValid, equals(isValid)); expect(result.isNotValid, equals(!isValid)); }, @@ -150,10 +154,13 @@ void main() { test( '($index): $testDate should be valid between $start - $end when testing inclusive and but without included time', () { + // arrange final validator = (DateTimeValidator()..isBetween(start: start, end: end, includeTime: false)).build(); + // act final result = validator.validate(testDate); + // assert expect(result.isValid, equals(isValid)); expect(result.isNotValid, equals(!isValid)); }, @@ -226,18 +233,22 @@ void main() { test( '($index): $testDate should be valid between $start - $end when testing without inclusive and without included time', () { - final validator = (DateTimeValidator() - // ignore: be explicit in tests. - ..isBetween( - start: start, - end: end, - includeTime: false, - inclusiveInterval: false, - )) - .build(); - + // arrange + final validator = + (DateTimeValidator() + // ignore: be explicit in tests. + ..isBetween( + start: start, + end: end, + includeTime: false, + inclusiveInterval: false, + )) + .build(); + + // act final result = validator.validate(testDate); + // assert expect(result.isValid, equals(isValid)); expect(result.isNotValid, equals(!isValid)); }, @@ -310,18 +321,21 @@ void main() { test( '($index): $testDate should be valid between $start - $end when testing without inclusive but with included time', () { - final validator = (DateTimeValidator() - ..isBetween( - start: start, - end: end, - inclusiveInterval: false, - // ignore: avoid_redundant_argument_values, be explicit - includeTime: true, - )) - .build(); - + // arrange + final validator = + (DateTimeValidator()..isBetween( + start: start, + end: end, + inclusiveInterval: false, + // ignore: avoid_redundant_argument_values, be explicit + includeTime: true, + )) + .build(); + + // act final result = validator.validate(testDate); + // assert expect(result.isValid, equals(isValid)); expect(result.isNotValid, equals(!isValid)); }, @@ -366,10 +380,13 @@ void main() { test( '($index): $testDate should be valid after $start when testing inclusive and with included time', () { + // arrange final validator = (DateTimeValidator()..isAfter(start: start)).build(); + // act final result = validator.validate(testDate); + // assert expect(result.isValid, equals(isValid)); expect(result.isNotValid, equals(!isValid)); }, @@ -412,10 +429,13 @@ void main() { test( '($index): $testDate should be valid after $start when testing exclusive and with included time', () { + // arrange final validator = (DateTimeValidator()..isAfter(start: start, inclusiveInterval: false)).build(); + // act final result = validator.validate(testDate); + // assert expect(result.isValid, equals(isValid)); expect(result.isNotValid, equals(!isValid)); }, @@ -454,10 +474,13 @@ void main() { test( '($index): $testDate should be valid after $start when testing inclusive and without included time', () { + // arrange final validator = (DateTimeValidator()..isAfter(start: start, includeTime: false)).build(); + // act final result = validator.validate(testDate); + // assert expect(result.isValid, equals(isValid)); expect(result.isNotValid, equals(!isValid)); }, @@ -496,11 +519,14 @@ void main() { test( '($index): $testDate should be valid after $start when testing exclusive and without included time', () { - final validator = - (DateTimeValidator()..isAfter(start: start, includeTime: false, inclusiveInterval: false)).build(); + // arrange + final validator = (DateTimeValidator()..isAfter(start: start, includeTime: false, inclusiveInterval: false)) + .build(); + // act final result = validator.validate(testDate); + // assert expect(result.isValid, equals(isValid)); expect(result.isNotValid, equals(!isValid)); }, @@ -545,10 +571,13 @@ void main() { test( '($index): $testDate should be valid before $end when testing inclusive and with included time', () { + // arrange final validator = (DateTimeValidator()..isBefore(end: end)).build(); + // act final result = validator.validate(testDate); + // assert expect(result.isValid, equals(isValid)); expect(result.isNotValid, equals(!isValid)); }, @@ -591,10 +620,13 @@ void main() { test( '($index): $testDate should be valid before $end when testing exclusive and with included time', () { + // arrange final validator = (DateTimeValidator()..isBefore(end: end, inclusiveInterval: false)).build(); + // act final result = validator.validate(testDate); + // assert expect(result.isValid, equals(isValid)); expect(result.isNotValid, equals(!isValid)); }, @@ -625,10 +657,13 @@ void main() { test( '($index): $testDate should be valid before $end when testing inclusive and without included time', () { + // arrange final validator = (DateTimeValidator()..isBefore(end: end, includeTime: false)).build(); + // act final result = validator.validate(testDate); + // assert expect(result.isValid, equals(isValid)); expect(result.isNotValid, equals(!isValid)); }, @@ -663,11 +698,14 @@ void main() { test( '($index): $testDate should be valid before $end when testing exclusive and without included time', () { - final validator = - (DateTimeValidator()..isBefore(end: end, includeTime: false, inclusiveInterval: false)).build(); + // arrange + final validator = (DateTimeValidator()..isBefore(end: end, includeTime: false, inclusiveInterval: false)) + .build(); + // act final result = validator.validate(testDate); + // assert expect(result.isValid, equals(isValid)); expect(result.isNotValid, equals(!isValid)); }, diff --git a/glade_forms/test/erorr_warning_test.dart b/glade_forms/test/erorr_warning_test.dart index 38bf6ba..1e56679 100644 --- a/glade_forms/test/erorr_warning_test.dart +++ b/glade_forms/test/erorr_warning_test.dart @@ -3,18 +3,21 @@ import 'package:test/test.dart'; void main() { test('Warning populates proper result value', () { + // arrange final input = GladeIntInput( - validator: (v) => (v - ..isBetween(min: 25, max: 50, severity: ValidationSeverity.warning, key: 'between') - ..isMin(min: 10, key: 'min') - ..isMax(max: 100, key: 'max')) - .build(), + validator: (v) => + (v + ..isBetween(min: 25, max: 50, severity: .warning, key: 'between') + ..isMin(min: 10, key: 'min') + ..isMax(max: 100, key: 'max')) + .build(), value: 15, ); - // Act + // act final result = input.validate(); + // assert expect(result.errors, isEmpty, reason: 'Errors should be empty'); expect(result.warnings, isNotEmpty, reason: 'Warnings should not be empty'); expect(result.isValid, isTrue, reason: 'Input should be valid'); @@ -26,18 +29,21 @@ void main() { }); test('Warning does not stop validation by default', () { + // arrange final input = GladeIntInput( - validator: (v) => (v - ..isBetween(min: 25, max: 50, severity: ValidationSeverity.warning, key: 'between') - ..isMin(min: 20, key: 'min') // * triggers - ..isMax(max: 100, key: 'max')) - .build(), + validator: (v) => + (v + ..isBetween(min: 25, max: 50, severity: .warning, key: 'between') + ..isMin(min: 20, key: 'min') // * triggers + ..isMax(max: 100, key: 'max')) + .build(), value: 15, ); - // Act + // act final result = input.validate(); + // assert expect(result.errors, isNotEmpty, reason: 'Errors should not be empty'); expect(result.warnings, isNotEmpty, reason: 'Warnings should not be empty'); expect(result.isValid, isFalse, reason: 'Input should not be valid'); @@ -53,18 +59,21 @@ void main() { }); test('Warning does not stop validation even when stopOnFirstError is false', () { + // arrange final input = GladeIntInput( - validator: (v) => (v - ..isBetween(min: 25, max: 50, severity: ValidationSeverity.warning, key: 'between') - ..isMin(min: 20, key: 'min') // * triggers - ..isMax(max: 10, key: 'max')) // * also triggers, although in reality does not make sense - .build(stopOnFirstError: false), + validator: (v) => + (v + ..isBetween(min: 25, max: 50, severity: .warning, key: 'between') + ..isMin(min: 20, key: 'min') // * triggers + ..isMax(max: 10, key: 'max')) // * also triggers, although in reality does not make sense + .build(stopOnFirstError: false), value: 15, ); - // Act + // act final result = input.validate(); + // assert expect(result.errors, isNotEmpty, reason: 'Errors should not be empty'); expect(result.warnings, isNotEmpty, reason: 'Warnings should not be empty'); expect(result.isValid, isFalse, reason: 'Input should not be valid'); @@ -82,18 +91,21 @@ void main() { }); test('Warning stops validation when stopOnFirstErrorOrWarning is true', () { + // arrange final input = GladeIntInput( - validator: (v) => (v - ..isBetween(min: 25, max: 50, severity: ValidationSeverity.warning, key: 'between') - ..isMin(min: 20, key: 'min') // * triggers - ..isMax(max: 10, key: 'max')) // * also triggers, although in reality does not make sense - .build(stopOnFirstError: false, stopOnFirstErrorOrWarning: true), + validator: (v) => + (v + ..isBetween(min: 25, max: 50, severity: .warning, key: 'between') + ..isMin(min: 20, key: 'min') // * triggers + ..isMax(max: 10, key: 'max')) // * also triggers, although in reality does not make sense + .build(stopOnFirstError: false, stopOnFirstErrorOrWarning: true), value: 15, ); - // Act + // act final result = input.validate(); + // assert expect(result.errors, isEmpty, reason: 'Errors should be empty'); expect(result.warnings, isNotEmpty, reason: 'Warnings should not be empty'); expect(result.isValid, isTrue, reason: 'Input should not be valid'); @@ -106,19 +118,22 @@ void main() { }); test('By default multiple warnigns are collected and do not stop validation', () { + // arrange final input = GladeIntInput( - validator: (v) => (v - ..isBetween(min: 25, max: 50, severity: ValidationSeverity.warning, key: 'between') - ..isMin(min: 10, key: 'min') - ..isBetween(min: 23, max: 24, severity: ValidationSeverity.warning, key: 'between2') - ..isMax(max: 18, key: 'max')) // * triggers, although in reality does not make sense - .build(), + validator: (v) => + (v + ..isBetween(min: 25, max: 50, severity: .warning, key: 'between') + ..isMin(min: 10, key: 'min') + ..isBetween(min: 23, max: 24, severity: .warning, key: 'between2') + ..isMax(max: 18, key: 'max')) // * triggers, although in reality does not make sense + .build(), value: 20, ); - // Act + // act final result = input.validate(); + // assert expect(result.errors, isNotEmpty, reason: 'Errors should not be empty'); expect(result.warnings, isNotEmpty, reason: 'Warnings should not be empty'); expect(result.isValid, isFalse, reason: 'Input should not be valid'); diff --git a/glade_forms/test/glade_input_test.dart b/glade_forms/test/glade_input_test.dart index caa2ed0..fa6e260 100644 --- a/glade_forms/test/glade_input_test.dart +++ b/glade_forms/test/glade_input_test.dart @@ -4,18 +4,25 @@ import 'package:glade_forms/glade_forms.dart'; import 'package:test/test.dart'; void main() { + setUp(GladeForms.initialize); + test('GladeInput with non-nullable type', () { + // arrange final input = GladeInput.create( validator: (v) => v.build(), value: 0, inputKey: 'a', ); + // act + + // assert expect(input.isValid, isTrue); }); group('onChange tests', () { test('Setter always trigger change', () { + // arrange final dependentInput = GladeIntInput(value: -1); final input = GladeIntInput( value: 0, @@ -27,13 +34,16 @@ void main() { expect(dependentInput.value, equals(-1)); expect(input.value, equals(0)); + // act input.value = 100; + // assert expect(dependentInput.value, equals(100)); expect(input.value, equals(100)); }); test('updateValue by default trigger change', () { + // arrange final dependentInput = GladeIntInput(value: -1); final input = GladeIntInput( value: 0, @@ -45,13 +55,16 @@ void main() { expect(dependentInput.value, equals(-1)); expect(input.value, equals(0)); + // act input.updateValue(100); + // assert expect(dependentInput.value, equals(100)); expect(input.value, equals(100)); }); test('updateValue disables trigger onChange', () { + // arrange final dependentInput = GladeIntInput(value: -1); final input = GladeIntInput( value: 0, @@ -63,8 +76,10 @@ void main() { expect(dependentInput.value, equals(-1)); expect(input.value, equals(0)); + // act input.updateValue(100, shouldTriggerOnChange: false); + // assert // ignore: avoid-duplicate-test-assertions, it controlls that onChange wasnt called. expect(dependentInput.value, equals(-1)); expect(input.value, equals(100)); @@ -73,6 +88,7 @@ void main() { group('onChange with Controller tests', () { test('Controller always trigger change', () { + // arrange final dependentInput = GladeIntInput(value: -1); final input = GladeIntInput( value: 0, @@ -85,13 +101,16 @@ void main() { expect(dependentInput.value, equals(-1)); expect(input.value, equals(0)); + // act input.controller?.text = '100'; + // assert expect(dependentInput.value, equals(100)); expect(input.value, equals(100)); }); test('Setter always trigger change', () { + // arrange final dependentInput = GladeIntInput(value: -1); final input = GladeIntInput( value: 0, @@ -104,13 +123,16 @@ void main() { expect(dependentInput.value, equals(-1)); expect(input.value, equals(0)); + // act input.value = 100; + // assert expect(dependentInput.value, equals(100)); expect(input.value, equals(100)); }); test('updateValue by default trigger change', () { + // arrange final dependentInput = GladeIntInput(value: -1); final input = GladeIntInput( value: 0, @@ -123,13 +145,16 @@ void main() { expect(dependentInput.value, equals(-1)); expect(input.value, equals(0)); + // act input.updateValue(100); + // assert expect(dependentInput.value, equals(100)); expect(input.value, equals(100)); }); test('updateValue disables trigger onChange', () { + // arrange final dependentInput = GladeIntInput(value: -1); final input = GladeIntInput( value: 0, @@ -142,8 +167,10 @@ void main() { expect(dependentInput.value, equals(-1)); expect(input.value, equals(0)); + // act input.updateValue(100, shouldTriggerOnChange: false); + // assert // ignore: avoid-duplicate-test-assertions, it controlls that onChange wasnt called. expect(dependentInput.value, equals(-1)); expect(input.value, equals(100)); @@ -152,7 +179,7 @@ void main() { group('input with controller triggers onChange correctly', () { test('when controller text changes then onChange is called', () { - // Arrange + // arrange final changes = []; final input = GladeInput.create( value: 'a', @@ -160,16 +187,16 @@ void main() { onChange: (info) => changes.add(info.value), ); - // Act + // act input.controller?.text = 'b'; - // Assert + // assert expect(input.value, equals('b')); expect(changes, equals(['b'])); }); test('when updateValue is called then onChange is called', () { - // Arrange + // arrange final changes = []; final input = GladeInput.create( value: 'a', @@ -177,10 +204,10 @@ void main() { onChange: (info) => changes.add(info.value), ); - // Act + // act input.updateValue('b'); - // Assert + // assert expect(input.value, equals('b')); expect(changes, equals(['b'])); }); @@ -188,7 +215,7 @@ void main() { test( 'when updateValue is called with shouldTriggerOnChange: false then onChange is not called', () { - // Arrange + // arrange final changes = []; final input = GladeInput.create( value: 'a', @@ -196,10 +223,10 @@ void main() { onChange: (info) => changes.add(info.value), ); - // Act + // act input.updateValue('b', shouldTriggerOnChange: false); - // Assert + // assert expect(input.value, equals('b')); expect(changes, equals([])); }, @@ -208,7 +235,7 @@ void main() { test( 'when updateValue is called with shouldTriggerOnChange: false and second updateValue is called without it then only the second call triggers onChange', () { - // Arrange + // arrange final changes = []; final input = GladeInput.create( value: 'a', @@ -216,13 +243,14 @@ void main() { onChange: (info) => changes.add(info.value), ); - // Act + // act + // Won't trigger onChange. input.updateValue('b', shouldTriggerOnChange: false); // Should trigger onChange. input.updateValue('c'); - // Assert + // assert expect(input.value, equals('c')); expect(changes, equals(['c'])); }, @@ -231,6 +259,7 @@ void main() { group('Pure test', () { test('ResetToPure', () { + // arrange final input = GladeIntInput( value: 100, initialValue: 10, @@ -240,17 +269,20 @@ void main() { expect(input.value, equals(100)); expect(input.initialValue, equals(10)); + // act input.updateValue(0, shouldTriggerOnChange: true); expect(input.value, equals(0)); input.resetToInitialValue(); + // assert expect(input.isPure, isTrue); expect(input.value, equals(input.initialValue)); }); test('SetAsNewPure', () { + // arrange final input = GladeIntInput( initialValue: 20, value: 100, @@ -262,12 +294,14 @@ void main() { expect(input.initialValue, equals(20)); expect(input.isPure, isTrue); + // act // Set as new pure with a new value input.setNewInitialValue( initialValue: () => 10, shouldResetToInitialValue: false, ); + // assert // Check the new state // ignore: avoid-duplicate-test-assertions, check again expect(input.value, equals(100)); @@ -279,56 +313,66 @@ void main() { group('TransformValue', () { test('No transformValue sets updated value', () { - final input = GladeInput.create(value: 0); + // arrange + final input = GladeInput.create(value: 0); - // Act + // act input.updateValue(5); + // assert expect(input.value, equals(5)); }); test('No transformValue in nullable type sets updated value to null', () { + // arrange final input = GladeInput.create(value: 2); - // Act + // act input.updateValue(null); + // assert expect(input.value, isNull); }); test('With non-nullable type returns transformed value', () { + // arrange final input = GladeInput.create( value: 0, valueTransform: (value) => value * 2, ); - // Act + // act input.updateValue(5); + // assert expect(input.value, equals(10)); }); test('With nullable type and passed null return null', () { + // arrange final input = GladeInput.create( value: 2, valueTransform: (value) => value == 4 ? null : 20, ); - // Act + // act input.updateValue(4); + // assert expect(input.value, equals(null)); }); test('With nullable type and passed value returns transformed value', () { + // arrange final input = GladeInput.create( value: 2, valueTransform: (value) => value == 4 ? null : 20, ); - // Act + // act input.updateValue(10); + // assert expect(input.value, equals(20)); }); }); diff --git a/glade_forms/test/glade_validator_test.dart b/glade_forms/test/glade_validator_test.dart index deba4e8..3dee645 100644 --- a/glade_forms/test/glade_validator_test.dart +++ b/glade_forms/test/glade_validator_test.dart @@ -6,19 +6,25 @@ import 'package:test/test.dart'; void main() { group('notNull', () { test('success', () { + // arrange final validator = (GladeValidator()..notNull()).build(); + // act final result = validator.validate(1); + // assert expect(result.isValid, isTrue); expect(result.isNotValid, isFalse); }); test('fails', () { + // arrange final validator = (GladeValidator()..notNull(key: 'not-null')).build(); + // act final result = validator.validate(null); + // assert expect(result.isValid, isFalse); expect(result.isNotValid, isTrue); expect(result.errors, isNotEmpty); @@ -31,19 +37,25 @@ void main() { group('satisfy', () { test('success', () { + // arrange final validator = (GladeValidator()..satisfy((v) => v > 5)).build(); + // act final result = validator.validate(6); + // assert expect(result.isValid, isTrue); expect(result.isNotValid, isFalse); }); test('fails', () { + // arrange final validator = (GladeValidator()..satisfy((v) => v > 5, key: 'custom-key')).build(); + // act final result = validator.validate(5); + // assert expect(result.isValid, isFalse); expect(result.isNotValid, isTrue); expect(result.errors, isNotEmpty); @@ -56,33 +68,39 @@ void main() { group('custom', () { test('success', () { - final validator = (GladeValidator() - ..custom( - (v, key) => v > 5 - ? null - : ValueError(value: v, devMessage: (value) => 'Value has to be greater than 5', key: key), - key: 'custom-key', - )) - .build(); - + // arrange + final validator = + (GladeValidator()..custom( + (v, key) => v > 5 + ? null + : ValueError(value: v, devMessage: (value) => 'Value has to be greater than 5', key: key), + key: 'custom-key', + )) + .build(); + + // act final result = validator.validate(6); + // assert expect(result.isValid, isTrue); expect(result.isNotValid, isFalse); }); test('fails', () { - final validator = (GladeValidator() - ..custom( - (v, key) => v > 5 - ? null - : ValueError(value: v, devMessage: (value) => 'Value has to be greater than 5', key: key), - key: 'custom-key', - )) - .build(); - + // arrange + final validator = + (GladeValidator()..custom( + (v, key) => v > 5 + ? null + : ValueError(value: v, devMessage: (value) => 'Value has to be greater than 5', key: key), + key: 'custom-key', + )) + .build(); + + // act final result = validator.validate(5); + // assert expect(result.isValid, isFalse); expect(result.isNotValid, isTrue); expect(result.errors, isNotEmpty); diff --git a/glade_forms/test/int_validator_test.dart b/glade_forms/test/int_validator_test.dart index 34b389a..d3ec7fc 100644 --- a/glade_forms/test/int_validator_test.dart +++ b/glade_forms/test/int_validator_test.dart @@ -6,55 +6,73 @@ import 'package:test/test.dart'; void main() { group('inBetween', () { test('success', () { + // arrange final validator = (IntValidator()..isBetween(min: 5, max: 10)).build(); + // act final result = validator.validate(6); + // assert expect(result.isValid, isTrue); expect(result.isNotValid, isFalse); }); test('success inclusive max', () { + // arrange final validator = (IntValidator()..isBetween(min: 5, max: 10)).build(); + // act final result = validator.validate(10); + // assert expect(result.isValid, isTrue); expect(result.isNotValid, isFalse); }); test('success inclusive min', () { + // arrange final validator = (IntValidator()..isBetween(min: 5, max: 10)).build(); + // act final result = validator.validate(5); + // assert expect(result.isValid, isTrue); expect(result.isNotValid, isFalse); }); test('fails non-inclusive max', () { + // arrange final validator = (IntValidator()..isBetween(min: 5, max: 10, inclusiveInterval: false)).build(); + // act final result = validator.validate(10); + // assert expect(result.isValid, isFalse); expect(result.isNotValid, isTrue); }); test('fails non-inclusive min', () { + // arrange final validator = (IntValidator()..isBetween(min: 5, max: 10, inclusiveInterval: false)).build(); + // act final result = validator.validate(5); + // assert expect(result.isValid, isFalse); expect(result.isNotValid, isTrue); }); test('fails', () { + // arrange final validator = (IntValidator()..isBetween(min: 5, max: 10)).build(); + // act final result = validator.validate(1); + // assert expect(result.isValid, isFalse); expect(result.isNotValid, isTrue); }); @@ -62,19 +80,25 @@ void main() { group('isMax', () { test('success', () { + // arrange final validator = (IntValidator()..isMax(max: 10)).build(); + // act final result = validator.validate(5); + // assert expect(result.isValid, isTrue); expect(result.isNotValid, isFalse); }); test('fails', () { + // arrange final validator = (IntValidator()..isMax(max: 10)).build(); + // act final result = validator.validate(25); + // assert expect(result.isValid, isFalse); expect(result.isNotValid, isTrue); }); @@ -82,19 +106,25 @@ void main() { group('isMin', () { test('success', () { + // arrange final validator = (IntValidator()..isMin(min: 1)).build(); + // act final result = validator.validate(5); + // assert expect(result.isValid, isTrue); expect(result.isNotValid, isFalse); }); test('fails', () { + // arrange final validator = (IntValidator()..isMin(min: 10)).build(); + // act final result = validator.validate(5); + // assert expect(result.isValid, isFalse); expect(result.isNotValid, isTrue); }); diff --git a/glade_forms/test/model/glade_model_test.dart b/glade_forms/test/model/glade_model_test.dart index 8005dad..16c6405 100644 --- a/glade_forms/test/model/glade_model_test.dart +++ b/glade_forms/test/model/glade_model_test.dart @@ -42,10 +42,16 @@ class _ModeWithDependencies extends GladeModel { } void main() { + setUp(GladeForms.initialize); + test('When updating [a] onDependencyChange is called for a', () { + // arrange final model = _ModeWithDependencies(); + // act model.a.value = 5; + + // assert expect(model.a.value, equals(5)); expect(model.onDepenencyCalledCount, equals(1)); expect(model.lastUpdatedInputKeys, equals(['a'])); @@ -53,9 +59,13 @@ void main() { }); test('When updating [b] onDependencyChange is called for b', () { + // arrange final model = _ModeWithDependencies(); + // act model.b.value = 5; + + // assert expect(model.b.value, equals(5)); expect(model.onDepenencyCalledCount, equals(1)); expect(model.lastUpdatedInputKeys, equals(['b'])); @@ -63,12 +73,16 @@ void main() { }); test('When updating [a, b] together onDependencyChange is called for [a,b]', () { + // arrange final model = _ModeWithDependencies(); + + // act model.groupEdit(() { model.b.value = 5; model.a.value = 5; }); + // assert expect(model.a.value, equals(5)); expect(model.b.value, equals(5)); expect(model.onDepenencyCalledCount, equals(1)); @@ -77,9 +91,13 @@ void main() { }); test('When updating [c] onDependencyChange is not called', () { + // arrange final model = _ModeWithDependencies(); + + // act model.c.value = 5; + // assert expect(model.c.value, equals(5)); expect(model.lastUpdatedInputKeys.toSet().difference({'c'}), equals({})); expect(model.onDepenencyCalledCount, isZero); diff --git a/glade_forms/test/model/group_edit_test.dart b/glade_forms/test/model/group_edit_test.dart index 95bfa52..7b46e3d 100644 --- a/glade_forms/test/model/group_edit_test.dart +++ b/glade_forms/test/model/group_edit_test.dart @@ -55,39 +55,54 @@ class _ModeWithDependencies extends GladeModel { } void main() { + setUp(GladeForms.initialize); + test('When updating [a] listener is called', () { + // arrange final model = _Model(); var listenerCount = 0; + // ignore: avoid-unremovable-callbacks-in-listeners, under test ok. model.addListener(() { listenerCount++; }); + // act model.a.value = 5; + + // assert expect(model.a.value, equals(5)); expect(model.lastUpdatedInputKeys, equals(['a'])); expect(listenerCount, equals(1), reason: 'Should be called'); }); test('When updating [b] listeners is called', () { + // arrange final model = _Model(); var listenerCount = 0; + // ignore: avoid-unremovable-callbacks-in-listeners, under test ok. model.addListener(() { listenerCount++; }); + // act model.b.value = 5; + + // assert expect(model.b.value, equals(5)); expect(model.lastUpdatedInputKeys, equals(['b'])); expect(listenerCount, equals(1), reason: 'Should be called'); }); test('When updating [a] and [b] listeners are called two times', () { + // arrange final model = _Model(); var listenerCount = 0; + // ignore: avoid-unremovable-callbacks-in-listeners, under test ok. model.addListener(() { listenerCount++; }); + // act model.a.value = 3; expect(model.a.value, equals(3)); @@ -95,24 +110,29 @@ void main() { model.b.value = 5; + // assert expect(model.b.value, equals(5)); expect(model.lastUpdatedInputKeys, equals(['b'])); expect(listenerCount, equals(2), reason: 'Should be called'); }); test('When updating [a] and [b] at once listener is called once', () { + // arrange final model = _Model(); var listenerCount = 0; + // ignore: avoid-unremovable-callbacks-in-listeners, under test ok. model.addListener(() { listenerCount++; }); + // act // ignore: cascade_invocations, under test ok. model.groupEdit(() { model.a.value = 3; model.b.value = 5; }); + // assert expect(model.a.value, equals(3)); expect(model.b.value, equals(5)); @@ -122,6 +142,7 @@ void main() { group('notify dependencies after edit', () { test('When updating only [a] then [c] is notified', () { + // arrange final model = _ModeWithDependencies(); expect(model.aUpdated, equals(0)); @@ -129,8 +150,10 @@ void main() { expect(model.cUpdated, equals(0)); expect(model.onDepenencyCalledCount, equals(0)); + // act model.a.value = 1; + // assert expect(model.aUpdated, equals(1)); expect(model.bUpdated, equals(0)); expect(model.cUpdated, equals(0)); @@ -138,14 +161,17 @@ void main() { }); test('When updating only [b] then [c] is notified', () { + // arrange final model = _ModeWithDependencies(); expect(model.aUpdated, equals(0)); expect(model.bUpdated, equals(0)); expect(model.cUpdated, equals(0)); + // act model.b.value = 1; + // assert expect(model.aUpdated, equals(0)); expect(model.bUpdated, equals(1)); expect(model.cUpdated, equals(0)); @@ -153,6 +179,7 @@ void main() { }); test('When updating [a] and [b] individually then [c] is notified twice', () { + // arrange final model = _ModeWithDependencies(); expect(model.aUpdated, equals(0)); @@ -160,9 +187,11 @@ void main() { expect(model.cUpdated, equals(0)); expect(model.onDepenencyCalledCount, equals(0)); + // act model.a.value = 1; model.b.value = 1; + // assert expect(model.aUpdated, equals(1), reason: '[a] only once updated'); expect(model.bUpdated, equals(1), reason: '[b] only once updated'); expect(model.cUpdated, equals(0), reason: '[c] never updated'); @@ -170,6 +199,7 @@ void main() { }); test('When updating [a] and [b] via groupEdit then [c] is notified once', () { + // arrange final model = _ModeWithDependencies(); expect(model.aUpdated, equals(0)); @@ -177,11 +207,13 @@ void main() { expect(model.cUpdated, equals(0)); expect(model.onDepenencyCalledCount, equals(0)); + // act model.groupEdit(() { model.a.value = 1; model.b.value = 1; }); + // assert expect(model.aUpdated, equals(1), reason: '[a] only once updated'); expect(model.bUpdated, equals(1), reason: '[b] only once updated'); expect(model.cUpdated, equals(0), reason: '[c] never updated'); diff --git a/glade_forms/test/string_validator_test.dart b/glade_forms/test/string_validator_test.dart index d222dae..b7a1b8b 100644 --- a/glade_forms/test/string_validator_test.dart +++ b/glade_forms/test/string_validator_test.dart @@ -6,29 +6,38 @@ import 'package:test/test.dart'; void main() { group('notEmpty', () { test('When value is empty, notEmpty fails', () { + // arrange final validator = (StringValidator()..notEmpty()).build(); + // act final result = validator.validate(''); + // assert expect(result.isValid, isFalse); expect(result.errors.firstOrNull?.key, equals(GladeValidationsKeys.stringEmpty)); }); test('When value is not empty or null, notEmpty pass', () { + // arrange final validator = (StringValidator()..notEmpty()).build(); + // act final result = validator.validate('test x'); + // assert expect(result.isValid, isTrue); }); }); group('isEmail', () { test('When value is empty, isEmail() fails', () { + // arrange final validator = (StringValidator()..isEmail()).build(); + // act final result = validator.validate(''); + // assert expect(result.isValid, isFalse); expect(result.errors.firstOrNull?.key, equals(GladeValidationsKeys.stringNotEmail)); }); @@ -42,10 +51,13 @@ void main() { ('a.213.czgmail.com', false), ]) { test('When email is ${testCase.$1}, isEmail() ${testCase.$2 ? 'pass' : 'fails'}', () { + // arrange final validator = (StringValidator()..isEmail()).build(); + // act final result = validator.validate(testCase.$1); + // assert expect(result.isValid, equals(testCase.$2)); }); } @@ -53,10 +65,13 @@ void main() { group('isUrl', () { test('When value is empty, isUrl() fails', () { + // arrange final validator = (StringValidator()..isUrl()).build(); + // act final result = validator.validate(''); + // assert expect(result.isValid, isFalse); expect(result.errors.firstOrNull?.key, equals(GladeValidationsKeys.stringNotUrl)); }); @@ -75,10 +90,13 @@ void main() { ('noturl', false, false), ]) { test('When URL is ${testCase.$1}, isUrl(http: ${testCase.$2}) ${testCase.$3 ? 'pass' : 'fails'}', () { + // arrange final validator = (StringValidator()..isUrl(requiresScheme: testCase.$2)).build(); + // act final result = validator.validate(testCase.$1); + // assert expect(result.isValid, equals(testCase.$3)); }); } @@ -86,27 +104,36 @@ void main() { group('exactLength()', () { test('exactLength() pass', () { + // arrange final validator = (StringValidator()..exactLength(length: 4)).build(); + // act final result = validator.validate('abcd'); + // assert expect(result.isValid, isTrue); }); test('exactLength(), empty value fails', () { + // arrange final validator = (StringValidator()..exactLength(length: 4)).build(); + // act final result = validator.validate(''); + // assert expect(result.isValid, isFalse); expect(result.errors.firstOrNull?.key, equals(GladeValidationsKeys.stringExactLength)); }); test('exactLength() fails', () { + // arrange final validator = (StringValidator()..exactLength(length: 4)).build(); + // act final result = validator.validate('asdasd'); + // assert expect(result.isValid, isFalse); expect(result.errors.firstOrNull?.key, equals(GladeValidationsKeys.stringExactLength)); }); @@ -114,26 +141,35 @@ void main() { group('maxLength()', () { test('maxLength() pass', () { + // arrange final validator = (StringValidator()..maxLength(length: 4)).build(); + // act final result = validator.validate('134'); + // assert expect(result.isValid, isTrue); }); test('maxLength(), empty value pass', () { + // arrange final validator = (StringValidator()..maxLength(length: 4)).build(); + // act final result = validator.validate(''); + // assert expect(result.isValid, isTrue); }); test('maxLength() fails', () { + // arrange final validator = (StringValidator()..maxLength(length: 4)).build(); + // act final result = validator.validate('asdasd'); + // assert expect(result.isValid, isFalse); expect(result.errors.firstOrNull?.key, equals(GladeValidationsKeys.stringMaxLength)); }); @@ -141,27 +177,36 @@ void main() { group('minLength()', () { test('minLength() pass', () { + // arrange final validator = (StringValidator()..minLength(length: 4)).build(); + // act final result = validator.validate('abcd'); + // assert expect(result.isValid, isTrue); }); test('minLength(), empty value fails', () { + // arrange final validator = (StringValidator()..minLength(length: 4)).build(); + // act final result = validator.validate(''); + // assert expect(result.isValid, isFalse); expect(result.errors.firstOrNull?.key, equals(GladeValidationsKeys.stringMinLength)); }); test('minLength() fails', () { + // arrange final validator = (StringValidator()..minLength(length: 4)).build(); + // act final result = validator.validate('a'); + // assert expect(result.isValid, isFalse); expect(result.errors.firstOrNull?.key, equals(GladeValidationsKeys.stringMinLength)); }); @@ -169,45 +214,63 @@ void main() { group('conditional validation', () { test('no validator is skipped', () { - final validator = (StringValidator() - ..minLength(length: 2) - ..maxLength(length: 6)) - .build(); - + // arrange + final validator = + (StringValidator() + ..minLength(length: 2) + ..maxLength(length: 6)) + .build(); + + // act final result = validator.validate('a'); + // assert expect(result.isValid, isFalse); expect( result.errors.first, - isA>() - .having((x) => x.key, 'Has proper key', equals(GladeValidationsKeys.stringMinLength)), + isA>().having( + (x) => x.key, + 'Has proper key', + equals(GladeValidationsKeys.stringMinLength), + ), ); }); test('Min length is skipped', () { - final validator = (StringValidator() - ..minLength(length: 2, shouldValidate: (_) => false) - ..maxLength(length: 6)) - .build(); - + // arrange + final validator = + (StringValidator() + ..minLength(length: 2, shouldValidate: (_) => false) + ..maxLength(length: 6)) + .build(); + + // act final result = validator.validate('This string is too long'); + // assert expect(result.isValid, isFalse); expect( result.errors.first, - isA>() - .having((x) => x.key, 'Has proper key', equals(GladeValidationsKeys.stringMaxLength)), + isA>().having( + (x) => x.key, + 'Has proper key', + equals(GladeValidationsKeys.stringMaxLength), + ), ); }); test('Max length is skipped', () { - final validator = (StringValidator() - ..minLength(length: 2) - ..maxLength(length: 6, shouldValidate: (_) => false)) - .build(); - + // arrange + final validator = + (StringValidator() + ..minLength(length: 2) + ..maxLength(length: 6, shouldValidate: (_) => false)) + .build(); + + // act final result = validator.validate('This string is too long, but it will pass'); + // assert expect(result.isValid, isTrue); }); }); diff --git a/glade_forms_devtools_extension/.gitignore b/glade_forms_devtools_extension/.gitignore new file mode 100644 index 0000000..89ca5a4 --- /dev/null +++ b/glade_forms_devtools_extension/.gitignore @@ -0,0 +1,30 @@ +# Build outputs +build/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +pubspec.lock + +# IDE +.idea/ +*.iml +*.ipr +*.iws +.vscode/ + +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ diff --git a/glade_forms_devtools_extension/.metadata b/glade_forms_devtools_extension/.metadata new file mode 100644 index 0000000..c4c59c9 --- /dev/null +++ b/glade_forms_devtools_extension/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "stable" + channel: "stable" + +project_type: app diff --git a/glade_forms_devtools_extension/CHANGELOG.md b/glade_forms_devtools_extension/CHANGELOG.md new file mode 100644 index 0000000..f088500 --- /dev/null +++ b/glade_forms_devtools_extension/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +Initial release of the DevTools extension for glade_forms. \ No newline at end of file diff --git a/glade_forms_devtools_extension/README.md b/glade_forms_devtools_extension/README.md new file mode 100644 index 0000000..b94a615 --- /dev/null +++ b/glade_forms_devtools_extension/README.md @@ -0,0 +1,41 @@ +# Glade Forms DevTools Extension + +This is a Flutter DevTools extension for the `glade_forms` package. + +This is a separate package that gets built and copied into the `glade_forms/extension/devtools/build/` directory. + +## Features + +- View active GladeModel instances in your application +- Inspect input values and validation states +- Monitor form dirty/pure states +- Real-time updates as forms change + +## Building + +To build the extension: + +```bash +# From the repository root +melos run build:extension +``` + +Or manually: + +```bash +# From this directory +flutter build web --wasm --release +# Then copy build/web/ to ../glade_forms/extension/devtools/build/ +``` + +The built extension will be output to `build/web/` and should be copied to the main package's extension directory. + +## Development + +For development, you can run: + +```bash +flutter run -d chrome +``` + +or run devtools_extension (DEBUG MODE) launch configuration in VS Code. \ No newline at end of file diff --git a/glade_forms_devtools_extension/analysis_options.yaml b/glade_forms_devtools_extension/analysis_options.yaml new file mode 100644 index 0000000..e68db70 --- /dev/null +++ b/glade_forms_devtools_extension/analysis_options.yaml @@ -0,0 +1,9 @@ +include: package:netglade_analysis/lints.yaml + +dart_code_metrics: + extends: + - package:netglade_analysis/dcm.yaml + pubspec-rules: + prefer-publish-to-none: false + rules: + avoid-commented-out-code: false # code examples in comments diff --git a/glade_forms_devtools_extension/lib/main.dart b/glade_forms_devtools_extension/lib/main.dart new file mode 100644 index 0000000..df621ac --- /dev/null +++ b/glade_forms_devtools_extension/lib/main.dart @@ -0,0 +1,22 @@ +import 'package:devtools_extensions/devtools_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:glade_forms_devtools_extension/src/glade_forms_extension.dart'; + +void main() { + runApp(const GladeFormsDevToolsExtension()); +} + +class GladeFormsDevToolsExtension extends StatelessWidget { + const GladeFormsDevToolsExtension({super.key}); + + @override + Widget build(BuildContext context) { + return DevToolsExtension( + child: MaterialApp( + theme: ThemeData.light(), + darkTheme: ThemeData.dark(), + home: const GladeFormsExtensionScreen(), + ), + ); + } +} diff --git a/glade_forms_devtools_extension/lib/src/constants.dart b/glade_forms_devtools_extension/lib/src/constants.dart new file mode 100644 index 0000000..6758cf9 --- /dev/null +++ b/glade_forms_devtools_extension/lib/src/constants.dart @@ -0,0 +1,53 @@ +// ignore_for_file: avoid-adjacent-strings + +import 'dart:ui'; + +/// Constants used throughout the GladeForms DevTools extension. +abstract final class Constants { + // Layout + static const double sidebarWidth = 300; + + static const double iconSize = 64; + static const double smallIconSize = 20; // Spacing + static const double spacing4 = 4; + + static const double spacing8 = 8; + static const double spacing16 = 16; + static const double spacing32 = 32; // Font sizes + static const double debugBadgeFontSize = 12; + + // Timing + static const Duration refreshInterval = Duration(seconds: 2); + + // Colors - State indicators + static const Color validColor = Color(0xFF4CAF50); // Green + + static const Color invalidColor = Color(0xFFF44336); // Red + static const Color pureColor = Color(0xFF2196F3); // Blue + static const Color dirtyColor = Color(0xFFFF9800); // Orange + static const Color unchangedColor = Color(0xFF9E9E9E); // Grey + static const Color modifiedColor = Color(0xFF9C27B0); // Purple + static const Color errorColor = Color(0xFFF44336); // Red + static const Color warningColor = Color(0xFFFF9800); // Orange + static const Color successColor = Color(0xFF4CAF50); // Green + + static const String appTitle = 'Glade Forms Inspector'; + + static const String debugBadge = 'DEBUG'; + static const String noModelsTitle = 'No Active Forms'; + static const String noModelsMessage = + 'No GladeModel instances are currently active in your app.\n\n' + 'Navigate to a screen with forms to see them here.'; + static const String serviceUnavailableTitle = 'Service Not Available'; + static const String serviceUnavailableMessage = + 'The glade_forms DevTools service is not available.\n\n' + 'Make sure your app is running and using glade_forms.'; + static const String selectModelMessage = 'Select a model to view details'; + static const String errorTitle = 'Error'; + static const String retryButton = 'Retry'; + static const String loadingMessage = 'Loading...'; + static const String refreshTooltip = 'Refresh'; + static const String scenarioTooltip = 'Select Mock Scenario'; + + const Constants._(); +} diff --git a/glade_forms_devtools_extension/lib/src/debug/mock_data.dart b/glade_forms_devtools_extension/lib/src/debug/mock_data.dart new file mode 100644 index 0000000..ebf535f --- /dev/null +++ b/glade_forms_devtools_extension/lib/src/debug/mock_data.dart @@ -0,0 +1,332 @@ +import 'package:glade_forms_devtools_extension/src/models/child_glade_model_description.dart'; +import 'package:glade_forms_devtools_extension/src/models/glade_input_description.dart'; +import 'package:glade_forms_devtools_extension/src/models/glade_model_description.dart'; + +/// Mock data scenarios for debugging the extension UI. +// ignore: prefer-match-file-name, keep in same file +enum MockScenario { + composedModel, + multipleModels, + noModels, + singleModel, +} + +abstract final class MockDataProvider { + static List getModelsForScenario(MockScenario scenario) { + return switch (scenario) { + .noModels => [], + .singleModel => [_createSingleModel()], + .composedModel => [_createComposedModel()], + .multipleModels => [ + _createSingleModel(id: 'model-1', type: 'LoginForm'), + _createSingleModel( + id: 'model-2', + type: 'RegistrationForm', + isDirty: true, + isValid: false, + ), + _createComposedModel(), + ], + }; + } + + static GladeModelDescription _createSingleModel({ + String? id, + String? type, + bool isValid = true, + bool isDirty = false, + }) { + return GladeModelDescription( + id: id ?? 'single-model-1', + type: type ?? 'UserProfileForm', + debugKey: type ?? 'UserProfileForm', + isValid: isValid, + isPure: !isDirty, + isDirty: isDirty, + isUnchanged: !isDirty, + inputs: [ + GladeInputDescription( + key: 'email', + type: 'GladeInput', + strValue: 'user@example.com', + value: 'user@example.com', + initialValue: 'user@example.com', + isValid: true, + isPure: !isDirty, + isUnchanged: !isDirty, + hasConversionError: false, + errors: const [], + warnings: const [], + dependencies: [], + ), + GladeInputDescription( + key: 'username', + type: 'GladeInput', + strValue: 'john_doe', + value: 'john_doe', + initialValue: 'john_doe', + isValid: true, + isPure: !isDirty, + isUnchanged: !isDirty, + hasConversionError: false, + errors: const [], + warnings: const [], + dependencies: ['email'], + ), + GladeInputDescription( + key: 'age', + type: 'GladeInput', + strValue: '25', + value: 25, + initialValue: 25, + isValid: isValid, + isPure: !isDirty, + isUnchanged: !isDirty, + hasConversionError: false, + errors: isValid ? [] : ['Age must be between 18 and 100'], + warnings: const [], + dependencies: ['email', 'username'], + ), + const GladeInputDescription( + key: 'newsletter', + type: 'GladeInput', + strValue: 'true', + value: true, + initialValue: false, + isValid: true, + isPure: false, + isUnchanged: false, + hasConversionError: false, + errors: [], + warnings: [], + dependencies: [], + ), + ], + formattedErrors: isValid ? '' : 'Age must be between 18 and 100', + ); + } + + static GladeModelDescription _createComposedModel() { + return const GladeModelDescription( + id: 'composed-model-1', + type: 'OrderCheckoutForm', + debugKey: 'OrderCheckoutForm', + isValid: false, + isPure: false, + isDirty: true, + isUnchanged: false, + inputs: [ + GladeInputDescription( + key: 'orderNumber', + type: 'GladeInput', + value: 'ORD-12345', + strValue: 'ORD-12345', + initialValue: 'ORD-12345', + isValid: true, + isPure: true, + isUnchanged: true, + hasConversionError: false, + errors: [], + warnings: [], + dependencies: [], + ), + GladeInputDescription( + key: 'totalAmount', + type: 'GladeInput', + strValue: '299.99', + value: 299.99, + initialValue: 299.99, + isValid: true, + isPure: true, + isUnchanged: true, + hasConversionError: false, + errors: [], + warnings: [], + dependencies: [], + ), + ], + formattedErrors: 'Shipping address is required', + isComposed: true, + childModels: [ + ChildGladeModelDescription( + id: 'child-1', + index: 0, + type: 'BillingAddressForm', + debugKey: 'BillingAddressForm', + isValid: true, + isPure: false, + isDirty: true, + isUnchanged: false, + inputs: [ + GladeInputDescription( + key: 'street', + type: 'GladeInput', + strValue: '123 Main St', + value: '123 Main St', + initialValue: '123 Main St', + isValid: true, + isPure: true, + isUnchanged: true, + hasConversionError: false, + errors: [], + warnings: [], + dependencies: [], + ), + GladeInputDescription( + key: 'city', + type: 'GladeInput', + value: 'New York', + strValue: 'New York', + initialValue: 'Boston', + isValid: true, + isPure: false, + isUnchanged: false, + hasConversionError: false, + errors: [], + dependencies: ['street'], + warnings: [], + ), + GladeInputDescription( + key: 'zipCode', + type: 'GladeInput', + strValue: '10001', + value: '10001', + initialValue: '10001', + isValid: true, + isPure: true, + isUnchanged: true, + hasConversionError: false, + errors: [], + dependencies: ['street', 'city'], + warnings: [], + ), + ], + formattedErrors: '', + ), + ChildGladeModelDescription( + id: 'child-2', + index: 1, + type: 'ShippingAddressForm', + debugKey: 'ShippingAddressForm', + isValid: false, + isPure: false, + isDirty: true, + isUnchanged: false, + inputs: [ + GladeInputDescription( + key: 'street', + type: 'GladeInput', + strValue: '', + value: null, + isValid: false, + isPure: true, + isUnchanged: true, + hasConversionError: false, + errors: ['Street is required'], + warnings: [], + dependencies: [], + ), + GladeInputDescription( + key: 'street-empty', + type: 'GladeInput', + strValue: '', + value: '', + initialValue: '', + isValid: false, + isPure: true, + isUnchanged: true, + hasConversionError: false, + errors: ['Street is required'], + warnings: [], + dependencies: [], + ), + GladeInputDescription( + key: 'city', + type: 'GladeInput', + strValue: 'San Francisco', + value: 'San Francisco', + initialValue: 'San Francisco', + isValid: true, + isPure: true, + isUnchanged: true, + hasConversionError: false, + errors: [], + warnings: [], + dependencies: [], + ), + GladeInputDescription( + key: 'zipCode', + type: 'GladeInput', + strValue: '94102', + value: 94102, + initialValue: 94102, + isValid: true, + isPure: true, + isUnchanged: true, + hasConversionError: false, + errors: [], + warnings: [], + dependencies: [], + ), + ], + formattedErrors: 'Street is required', + ), + ChildGladeModelDescription( + id: 'child-3', + index: 2, + type: 'PaymentMethodForm', + debugKey: 'PaymentMethodForm', + isValid: true, + isPure: true, + isDirty: false, + isUnchanged: true, + inputs: [ + GladeInputDescription( + key: 'cardNumber', + type: 'GladeInput', + strValue: '**** **** **** 1234', + value: '**** **** **** 1234', + initialValue: '**** **** **** 1234', + isValid: true, + isPure: true, + isUnchanged: true, + hasConversionError: false, + errors: [], + warnings: [], + dependencies: [], + ), + GladeInputDescription( + key: 'expiryDate', + type: 'GladeInput', + strValue: '12/25', + value: '12/25', + initialValue: '12/25', + isValid: true, + isPure: true, + isUnchanged: true, + hasConversionError: false, + dependencies: ['cardNumber'], + errors: [], + warnings: [], + ), + GladeInputDescription( + key: 'cvv', + type: 'GladeInput', + strValue: '***', + value: '***', + initialValue: '***', + isValid: true, + isPure: true, + isUnchanged: true, + hasConversionError: false, + dependencies: ['cardNumber', 'expiryDate'], + errors: [], + warnings: [], + ), + ], + formattedErrors: '', + ), + ], + ); + } +} diff --git a/glade_forms_devtools_extension/lib/src/glade_forms_extension.dart b/glade_forms_devtools_extension/lib/src/glade_forms_extension.dart new file mode 100644 index 0000000..ff84fc2 --- /dev/null +++ b/glade_forms_devtools_extension/lib/src/glade_forms_extension.dart @@ -0,0 +1,371 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:glade_forms_devtools_extension/src/constants.dart'; +import 'package:glade_forms_devtools_extension/src/debug/mock_data.dart'; +import 'package:glade_forms_devtools_extension/src/models/glade_model_description.dart'; +import 'package:glade_forms_devtools_extension/src/services/glade_forms_service.dart'; +import 'package:glade_forms_devtools_extension/src/widgets/detail/composed/composed_model_view.dart'; +import 'package:glade_forms_devtools_extension/src/widgets/detail/model_detail_view.dart'; +import 'package:glade_forms_devtools_extension/src/widgets/list/models_sidebar.dart'; +import 'package:glade_forms_devtools_extension/src/widgets/views/empty_models_view.dart'; +import 'package:glade_forms_devtools_extension/src/widgets/views/error_view.dart'; +import 'package:glade_forms_devtools_extension/src/widgets/views/loading_view.dart'; +import 'package:glade_forms_devtools_extension/src/widgets/views/service_unavailable_view.dart'; +import 'package:multi_split_view/multi_split_view.dart'; + +/// Debug mode flag - set via --dart-define. +// ignore: prefer-boolean-prefixes, keep name +const bool _kDebugMode = bool.fromEnvironment('DEBUG_MODE'); + +typedef OnChildTapCallback = void Function(GladeModelDescription model, String childId); + +// ignore: prefer-match-file-name, keep in same file +class GladeFormsExtensionScreen extends StatefulWidget { + const GladeFormsExtensionScreen({super.key}); + + @override + State createState() => _GladeFormsExtensionScreenState(); +} + +class _GladeFormsExtensionScreenState extends State { + final _service = GladeFormsService(); + List _models = []; + GladeModelDescription? _selectedModel; + String? _selectedChildId; // Track selected child model ID + bool _isLoading = false; + bool _isServiceAvailable = false; + Timer? _refreshTimer; + String? _error; + + // Debug mode state + MockScenario _currentScenario = .composedModel; + final bool _isDebugModeEnabled = _kDebugMode; + + @override + void initState() { + super.initState(); + if (_isDebugModeEnabled) { + // ignore: avoid-unnecessary-setstate, it is ok + _loadMockData(); + } else { + // ignore: avoid-async-call-in-sync-function, it is ok + _checkServiceAndLoadData(); + // ignore: prefer-extracting-callbacks, keep inline + _refreshTimer = Timer.periodic(Constants.refreshInterval, (_) { + // ignore: avoid-async-call-in-sync-function, it is ok + _checkServiceAndLoadData(shouldForceRefresh: false); + }); + } + } + + @override + void dispose() { + _refreshTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Row( + children: [ + const Text('Glade Forms Inspector'), + if (_isDebugModeEnabled) ...[ + const SizedBox(width: Constants.spacing8), + Container( + padding: const .symmetric( + horizontal: Constants.spacing8, + vertical: Constants.spacing4, + ), + decoration: const BoxDecoration( + color: Colors.orange, + borderRadius: .all(.circular(4)), + ), + child: const Text( + Constants.debugBadge, + style: TextStyle( + fontSize: Constants.debugBadgeFontSize, + fontWeight: .bold, + ), + ), + ), + ], + ], + ), + actions: [ + if (_isDebugModeEnabled) + PopupMenuButton( + icon: const Icon(Icons.science), + tooltip: 'Select Mock Scenario', + onSelected: _switchMockScenario, + itemBuilder: (context) => [ + const PopupMenuItem( + value: MockScenario.noModels, + child: Text('No Models'), + ), + const PopupMenuItem( + value: MockScenario.singleModel, + child: Text('Single Model'), + ), + const PopupMenuItem( + value: MockScenario.composedModel, + child: Text('Composed Model'), + ), + const PopupMenuItem( + value: MockScenario.multipleModels, + child: Text('Multiple Models'), + ), + ], + ) + else + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _isLoading ? null : () => unawaited(_checkServiceAndLoadData()), + tooltip: 'Refresh', + ), + ], + ), + body: _ExtensionBody( + isLoading: _isLoading, + isServiceAvailable: _isServiceAvailable, + error: _error, + models: _models, + selectedModel: _selectedModel, + selectedChildId: _selectedChildId, + onRetry: () => unawaited(_checkServiceAndLoadData()), + // ignore: prefer-extracting-callbacks, keep inline + onModelSelected: (model) { + setState(() { + _selectedModel = model; + _selectedChildId = null; + }); + }, + // ignore: prefer-extracting-callbacks, keep inline + onChildSelected: (model, childId) { + setState(() { + _selectedModel = model; + _selectedChildId = childId; + }); + }, + ), + ); + } + + Future _checkServiceAndLoadData({bool shouldForceRefresh = true}) async { + if (shouldForceRefresh) { + setState(() { + _isLoading = true; + _error = null; + }); + } + + try { + final isAvailable = await _service.isServiceAvailable(); + + if (!context.mounted) return; + + setState(() { + _isServiceAvailable = isAvailable; + }); + + if (isAvailable) { + await _loadModels(); + } + // ignore: avoid_catches_without_on_clauses, gotta catch them all + } catch (e) { + setState(() { + _error = 'Error checking service: $e'; + _isServiceAvailable = false; + }); + } finally { + setState(() { + _isLoading = false; + }); + } + } + + Future _loadModels() async { + try { + final models = await _service.fetchModels(); + if (mounted) { + setState(() { + _models = models; + _error = null; + // Update selected model if it's still in the list + if (_selectedModel case final model?) { + _selectedModel = models.firstWhere( + (m) => m.id == model.id, + // ignore: avoid-unsafe-collection-methods, checked by condition + orElse: () => models.isNotEmpty ? models.first : model, + ); + } + }); + } + // ignore: avoid_catches_without_on_clauses, gotta catch them all + } catch (e) { + if (mounted) { + setState(() { + _error = 'Error loading models: $e'; + }); + } + } + } + + void _loadMockData() { + setState(() { + _models = MockDataProvider.getModelsForScenario(_currentScenario); + _isServiceAvailable = true; + _isLoading = false; + _error = null; + _selectedModel = _models.firstOrNull; + _selectedChildId = null; + }); + } + + void _switchMockScenario(MockScenario scenario) { + setState(() { + _currentScenario = scenario; + }); + _loadMockData(); + } +} + +// Private widgets + +class _ExtensionBody extends StatefulWidget { + final bool isLoading; + + final bool isServiceAvailable; + final String? error; + final List models; + final GladeModelDescription? selectedModel; + final String? selectedChildId; + final VoidCallback onRetry; + final ValueChanged onModelSelected; + final OnChildTapCallback onChildSelected; + + const _ExtensionBody({ + required this.isLoading, + required this.isServiceAvailable, + required this.error, + required this.models, + required this.selectedModel, + required this.selectedChildId, + required this.onRetry, + required this.onModelSelected, + required this.onChildSelected, + }); + + @override + State<_ExtensionBody> createState() => _ExtensionBodyState(); +} + +class _ExtensionBodyState extends State<_ExtensionBody> { + final MultiSplitViewController _controller = MultiSplitViewController( + areas: [ + // ignore: avoid-undisposed-instances, it is ok + Area(data: 'sidebar', min: 300, max: 500, size: 350), + // ignore: avoid-undisposed-instances, it is ok + Area(data: 'detail', flex: 1), + ], + ); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (widget.isLoading) { + return const LoadingView(); + } + + if (!widget.isServiceAvailable) { + return ServiceUnavailableView(onRetry: widget.onRetry); + } + + if (widget.error != null) { + return ErrorView(errorMessage: widget.error!, onRetry: widget.onRetry); + } + + if (widget.models.isEmpty) { + return const EmptyModelsView(); + } + + return MultiSplitViewTheme( + data: MultiSplitViewThemeData(dividerThickness: 2), + child: MultiSplitView( + controller: _controller, + builder: (context, area) => switch (area.data) { + 'sidebar' => ModelsSidebar( + models: widget.models, + selectedModel: widget.selectedModel, + selectedChildId: widget.selectedChildId, + onModelTap: widget.onModelSelected, + onChildTap: widget.onChildSelected, + ), + 'detail' => _DetailPane( + selectedModel: widget.selectedModel, + selectedChildId: widget.selectedChildId, + ), + _ => const SizedBox.shrink(), + }, + sizeUnderflowPolicy: .stretchFirst, + // ignore: prefer-extracting-callbacks, keep inline, prefer-boolean-prefixes + dividerBuilder: (axis, index, resizable, dragging, highlighted, themeData) { + return Container( + color: dragging ? Theme.of(context).primaryColor : Theme.of(context).dividerColor, + ); + }, + ), + ); + } +} + +class _DetailPane extends StatelessWidget { + final GladeModelDescription? selectedModel; + + final String? selectedChildId; + + const _DetailPane({ + required this.selectedModel, + required this.selectedChildId, + }); + + @override + Widget build(BuildContext context) { + final model = selectedModel; + if (model == null) { + return Center( + child: Text( + Constants.selectModelMessage, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); + } + + // If a child model is selected, show its details + if (selectedChildId != null && model.isComposed) { + // ignore: avoid-unsafe-collection-methods, checked by condition + final childModel = model.childModels.firstWhere( + (child) => child.id == selectedChildId, + ); + + return ModelDetailView(model: childModel); + } + + // If the parent composed model is selected, show composed view + if (model.isComposed) { + return ComposedModelView(model: model); + } + + // For regular models, show detail view + return ModelDetailView(model: model); + } +} diff --git a/glade_forms_devtools_extension/lib/src/models/child_glade_model_description.dart b/glade_forms_devtools_extension/lib/src/models/child_glade_model_description.dart new file mode 100644 index 0000000..741a81c --- /dev/null +++ b/glade_forms_devtools_extension/lib/src/models/child_glade_model_description.dart @@ -0,0 +1,55 @@ +import 'package:glade_forms_devtools_extension/src/models/glade_base_model_description.dart'; +import 'package:glade_forms_devtools_extension/src/models/glade_input_description.dart'; + +/// Data model representing a child model within a GladeComposedModel. +class ChildGladeModelDescription extends GladeBaseModelDescription { + final int index; + + @override + bool get isComposed => false; + + const ChildGladeModelDescription({ + required super.id, + required this.index, + required super.debugKey, + required super.type, + required super.isValid, + required super.isPure, + required super.isDirty, + required super.isUnchanged, + required super.inputs, + required super.formattedErrors, + }); + + factory ChildGladeModelDescription.fromJson(Map json) { + return ChildGladeModelDescription( + id: json['id'] as String, + debugKey: json['debugKey'] as String, + index: json['index'] as int, + type: json['type'] as String, + isValid: json['isValid'] as bool, + isPure: json['isPure'] as bool, + isDirty: json['isDirty'] as bool, + isUnchanged: json['isUnchanged'] as bool, + // ignore: avoid-dynamic, can be anything + inputs: (json['inputs'] as List) + .map((e) => GladeInputDescription.fromJson(e as Map)) + .toList(), + formattedErrors: json['formattedErrors'] as String? ?? '', + ); + } + + Map toJson() { + return { + 'formattedErrors': formattedErrors, + 'id': id, + 'index': index, + 'inputs': inputs.map((e) => e.toJson()).toList(), + 'isDirty': isDirty, + 'isPure': isPure, + 'isUnchanged': isUnchanged, + 'isValid': isValid, + 'type': type, + }; + } +} diff --git a/glade_forms_devtools_extension/lib/src/models/glade_base_model_description.dart b/glade_forms_devtools_extension/lib/src/models/glade_base_model_description.dart new file mode 100644 index 0000000..b8f2f9f --- /dev/null +++ b/glade_forms_devtools_extension/lib/src/models/glade_base_model_description.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:glade_forms_devtools_extension/src/constants.dart'; +import 'package:glade_forms_devtools_extension/src/models/glade_input_description.dart'; + +/// Base class for model data with common properties and UI logic. +abstract class GladeBaseModelDescription { + final String id; + final String debugKey; + final String type; + final bool isValid; + final bool isPure; + final bool isDirty; + final bool isUnchanged; + final List inputs; + final String formattedErrors; + + bool get isComposed; + + // UI Domain Logic + + /// Label for validation state. + String get validityLabel => isValid ? 'Valid' : 'Invalid'; + + /// Color for validation state chip. + Color get validityColor => isValid ? Constants.validColor : Constants.invalidColor; + + IconData get validityIcon => isValid ? Icons.check_circle_outline : Icons.error_outline; + + /// Label for purity state. + String get purityLabel => isPure ? 'Pure' : 'Dirty'; + + /// Color for purity state chip. + Color get purityColor => isPure ? Constants.pureColor : Constants.dirtyColor; + + IconData get purityStateIcon => isPure ? Icons.clean_hands_outlined : Icons.warning_amber_outlined; + + /// Label for change state. + String get changeLabel => isUnchanged ? 'Unchanged' : 'Modified'; + + /// Color for change state chip. + Color get changeColor => isUnchanged ? Constants.unchangedColor : Constants.modifiedColor; + + IconData get changeIcon => isUnchanged ? Icons.check_circle_outline : Icons.edit; + + /// Overall state description for display. + String get stateDescription { + final parts = [validityLabel, purityLabel, changeLabel]; + + // ignore: avoid-non-ascii-symbols, for better readability + return parts.join(' • '); + } + + /// Returns true if the model has any errors. + bool get hasErrors => !isValid; + + /// Returns true if the model has been modified. + bool get isModified => !isUnchanged; + + const GladeBaseModelDescription({ + required this.id, + required this.debugKey, + required this.type, + required this.isValid, + required this.isPure, + required this.isDirty, + required this.isUnchanged, + required this.inputs, + required this.formattedErrors, + }); +} diff --git a/glade_forms_devtools_extension/lib/src/models/glade_input_description.dart b/glade_forms_devtools_extension/lib/src/models/glade_input_description.dart new file mode 100644 index 0000000..fed357d --- /dev/null +++ b/glade_forms_devtools_extension/lib/src/models/glade_input_description.dart @@ -0,0 +1,126 @@ +import 'dart:ui'; + +import 'package:glade_forms_devtools_extension/src/constants.dart'; + +/// Data model representing a GladeInput for DevTools display. +class GladeInputDescription { + final String key; + final String type; + final String strValue; + // ignore: no-object-declaration, can be anything + final Object? value; + // ignore: no-object-declaration, can be anything + final Object? initialValue; + final bool isValid; + final bool isPure; + final bool isUnchanged; + final bool hasConversionError; + final List errors; + final List warnings; + final List dependencies; + + // UI Domain Logic + + /// Label for validation state. + String get validityLabel => isValid ? 'Valid' : 'Invalid'; + + /// Color for validation state. + Color get validityColor => isValid ? Constants.validColor : Constants.invalidColor; + + /// Label for purity state. + String get purityLabel => isPure ? 'Pure' : 'Dirty'; + + /// Color for purity state. + Color get purityColor => isPure ? Constants.pureColor : Constants.dirtyColor; + + /// Label for change state. + String get changeLabel => isUnchanged ? 'Unchanged' : 'Modified'; + + /// Color for change state. + Color get changeColor => isUnchanged ? Constants.unchangedColor : Constants.modifiedColor; + + /// Label for conversion error state. + String get conversionLabel => hasConversionError ? 'Conversion Error' : 'OK'; + + /// Color for conversion error state. + Color get conversionColor => hasConversionError ? Constants.errorColor : Constants.successColor; + + /// Returns true if the input has any errors. + bool get hasErrors => errors.isNotEmpty || hasConversionError; + + /// Returns true if the input has any warnings. + bool get hasWarnings => warnings.isNotEmpty; + + /// Returns true if the input has been modified. + bool get isModified => !isUnchanged; + + /// Badge text for errors and warnings count. + String? get issuesBadge { + final errorCount = errors.length; + final warningCount = warnings.length; + + if (errorCount == 0 && warningCount == 0) return null; + + final parts = []; + if (errorCount > 0) parts.add('$errorCount error${errorCount > 1 ? 's' : ''}'); + if (warningCount > 0) parts.add('$warningCount warning${warningCount > 1 ? 's' : ''}'); + + return parts.join(', '); + } + + /// Color for issues badge. + Color get issuesBadgeColor { + if (errors.isNotEmpty) return Constants.errorColor; + if (warnings.isNotEmpty) return Constants.warningColor; + + return Constants.successColor; + } + + const GladeInputDescription({ + required this.key, + required this.type, + required this.strValue, + required this.value, + required this.isValid, + required this.isPure, + required this.isUnchanged, + required this.hasConversionError, + required this.errors, + required this.warnings, + required this.dependencies, + this.initialValue, + }); + + factory GladeInputDescription.fromJson(Map json) { + return GladeInputDescription( + key: json['key'] as String, + type: json['type'] as String, + strValue: json['strValue']?.toString() ?? '', + value: json['value'] as Object?, + initialValue: json['initialValue'] as Object?, + isValid: json['isValid'] as bool, + isPure: json['isPure'] as bool, + isUnchanged: json['isUnchanged'] as bool, + hasConversionError: json['hasConversionError'] as bool, + dependencies: json['dependencies'] != null ? (json['dependencies'] as List) : [], + errors: json['errors'] as List, + warnings: json['warnings'] as List, + ); + } + + Map toJson() { + return { + 'dependencies': dependencies, + 'errors': errors, + 'hasConversionError': hasConversionError, + 'initialValue': initialValue, + 'isPure': isPure, + 'isUnchanged': isUnchanged, + 'isValid': isValid, + 'key': key, + 'type': type, + 'value': value, + 'warnings': warnings, + }; + } +} diff --git a/glade_forms_devtools_extension/lib/src/models/glade_model_description.dart b/glade_forms_devtools_extension/lib/src/models/glade_model_description.dart new file mode 100644 index 0000000..cc39c0a --- /dev/null +++ b/glade_forms_devtools_extension/lib/src/models/glade_model_description.dart @@ -0,0 +1,67 @@ +import 'package:glade_forms_devtools_extension/src/models/child_glade_model_description.dart'; +import 'package:glade_forms_devtools_extension/src/models/glade_base_model_description.dart'; +import 'package:glade_forms_devtools_extension/src/models/glade_input_description.dart'; + +/// Data model representing a GladeModel instance for DevTools display. +class GladeModelDescription extends GladeBaseModelDescription { + @override + final bool isComposed; + final List childModels; + + const GladeModelDescription({ + required super.id, + required super.debugKey, + required super.type, + required super.isValid, + required super.isPure, + required super.isDirty, + required super.isUnchanged, + required super.inputs, + required super.formattedErrors, + this.isComposed = false, + this.childModels = const [], + }); + + factory GladeModelDescription.fromJson(Map json) { + return GladeModelDescription( + id: json['id'] as String, + debugKey: json['debugKey'] as String, + type: json['type'] as String, + isValid: json['isValid'] as bool, + isPure: json['isPure'] as bool, + isDirty: json['isDirty'] as bool, + isUnchanged: json['isUnchanged'] as bool, + // ignore: avoid-dynamic, can be anything + inputs: (json['inputs'] as List) + .map((e) => GladeInputDescription.fromJson(e as Map)) + .toList(), + formattedErrors: json['formattedErrors'] as String? ?? '', + isComposed: json['isComposed'] as bool? ?? false, + childModels: + // ignore: avoid-dynamic, can be anything + (json['childModels'] as List?) + ?.map( + (e) => ChildGladeModelDescription.fromJson( + e as Map, + ), + ) + .toList() ?? + const [], + ); + } + + Map toJson() { + return { + 'childModels': childModels.map((e) => e.toJson()).toList(), + 'formattedErrors': formattedErrors, + 'id': id, + 'inputs': inputs.map((e) => e.toJson()).toList(), + 'isComposed': isComposed, + 'isDirty': isDirty, + 'isPure': isPure, + 'isUnchanged': isUnchanged, + 'isValid': isValid, + 'type': type, + }; + } +} diff --git a/glade_forms_devtools_extension/lib/src/services/glade_forms_service.dart b/glade_forms_devtools_extension/lib/src/services/glade_forms_service.dart new file mode 100644 index 0000000..93ac817 --- /dev/null +++ b/glade_forms_devtools_extension/lib/src/services/glade_forms_service.dart @@ -0,0 +1,46 @@ +import 'dart:async'; + +import 'package:devtools_extensions/devtools_extensions.dart' as service_connection; +import 'package:glade_forms_devtools_extension/src/models/glade_model_description.dart'; + +/// Service to communicate with the running Flutter app to get glade_forms data. +class GladeFormsService { + final serviceManager = service_connection.serviceManager; + static const String _extensionName = 'ext.glade_forms.inspector'; + + static const String _getModelsMethod = 'getModels'; + + /// Fetch all GladeModel instances from the running app + Future> fetchModels() async { + final response = await serviceManager.callServiceExtensionOnMainIsolate( + _extensionName, + args: {'method': _getModelsMethod}, + ); + if (response.json == null) { + return []; + } + + // ignore: avoid-dynamic, avoid-non-null-assertion, checked by condition and can be anything, + final data = response.json!['models'] as List?; + if (data == null) { + return []; + } + + return data.map((e) => GladeModelDescription.fromJson(e as Map)).toList(); + } + + /// Check if the service extension is available. + Future isServiceAvailable() async { + try { + final _ = await serviceManager.callServiceExtensionOnMainIsolate( + _extensionName, + args: {'method': 'ping'}, + ); + + return true; + // ignore: avoid_catches_without_on_clauses, since we want to catch all errors + } catch (e) { + return false; + } + } +} diff --git a/glade_forms_devtools_extension/lib/src/widgets/common/input_value.dart b/glade_forms_devtools_extension/lib/src/widgets/common/input_value.dart new file mode 100644 index 0000000..3bb2133 --- /dev/null +++ b/glade_forms_devtools_extension/lib/src/widgets/common/input_value.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:glade_forms_devtools_extension/src/constants.dart'; +import 'package:glade_forms_devtools_extension/src/widgets/common/input_value_text.dart'; +import 'package:netglade_flutter_utils/netglade_flutter_utils.dart'; + +class InputValue extends StatelessWidget { + // ignore: no-object-declaration, value can be of any type + final Object? value; + final bool shouldInverseBoolColors; + final bool hasBackgroundWhitespaceIndicator; + + const InputValue({ + required this.value, + super.key, + this.shouldInverseBoolColors = false, + this.hasBackgroundWhitespaceIndicator = false, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + + if (value == null) { + return Text('null', style: textTheme.bodySmall?.semiBold); + } + + // ignore: prefer-boolean-prefixes, ok naming + if (value case final bool boolValue) { + final color = shouldInverseBoolColors + ? (boolValue ? Constants.invalidColor : Constants.validColor) + : (boolValue ? Constants.validColor : Constants.invalidColor); + + return Text( + boolValue.toString(), + style: textTheme.bodySmall?.withColor(color), + ); + } + + if (value case final String stringValue) { + return InputValueText( + stringValue: stringValue, + hasBackgroundWhitespaceIndicator: hasBackgroundWhitespaceIndicator, + ); + } + + // ignore: avoid-non-null-assertion, checked above + return Text(value!.toString()); + } +} diff --git a/glade_forms_devtools_extension/lib/src/widgets/common/input_value_text.dart b/glade_forms_devtools_extension/lib/src/widgets/common/input_value_text.dart new file mode 100644 index 0000000..8c10aec --- /dev/null +++ b/glade_forms_devtools_extension/lib/src/widgets/common/input_value_text.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:netglade_flutter_utils/netglade_flutter_utils.dart'; + +class InputValueText extends StatelessWidget { + final String stringValue; + final bool hasBackgroundWhitespaceIndicator; + + const InputValueText({ + required this.stringValue, + super.key, + this.hasBackgroundWhitespaceIndicator = true, + }); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + + if (stringValue.isEmpty) { + return Row( + mainAxisSize: .min, + children: [ + ColoredBox( + color: hasBackgroundWhitespaceIndicator ? Colors.yellow.withAlpha(77) : Colors.transparent, + child: Text('""', style: textTheme.bodySmall), + ), + Text(' (string is empty)', style: textTheme.bodySmall?.italic), + ], + ); + } + + return ColoredBox( + color: hasBackgroundWhitespaceIndicator ? Colors.yellow.withAlpha(77) : Colors.transparent, + child: Text(stringValue, style: textTheme.bodySmall), + ); + } +} diff --git a/glade_forms_devtools_extension/lib/src/widgets/common/state_chip.dart b/glade_forms_devtools_extension/lib/src/widgets/common/state_chip.dart new file mode 100644 index 0000000..6919390 --- /dev/null +++ b/glade_forms_devtools_extension/lib/src/widgets/common/state_chip.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +/// A universal chip widget for displaying state labels with colors. +/// Can be styled as a badge, mini chip, or bordered chip. +class StateChip extends StatelessWidget { + final String label; + final Color color; + + const StateChip({required this.label, required this.color, super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + padding: const .symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: color.withAlpha(38), + borderRadius: const .all(.circular(12)), + border: .all(color: color.withAlpha(77)), + ), + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: color, + fontWeight: .w600, + ), + ), + ); + } +} diff --git a/glade_forms_devtools_extension/lib/src/widgets/debug_badge.dart b/glade_forms_devtools_extension/lib/src/widgets/debug_badge.dart new file mode 100644 index 0000000..8b5b97d --- /dev/null +++ b/glade_forms_devtools_extension/lib/src/widgets/debug_badge.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:glade_forms_devtools_extension/src/constants.dart'; + +/// Badge shown in debug mode. +class DebugBadge extends StatelessWidget { + const DebugBadge({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const .symmetric( + horizontal: Constants.spacing8, + vertical: Constants.spacing4, + ), + decoration: const BoxDecoration( + color: Colors.orange, + borderRadius: .all(.circular(Constants.spacing4)), + ), + child: const Text( + Constants.debugBadge, + style: TextStyle( + fontSize: Constants.debugBadgeFontSize, + fontWeight: .bold, + ), + ), + ); + } +} diff --git a/glade_forms_devtools_extension/lib/src/widgets/detail/composed/composed_child_model_card.dart b/glade_forms_devtools_extension/lib/src/widgets/detail/composed/composed_child_model_card.dart new file mode 100644 index 0000000..692d91c --- /dev/null +++ b/glade_forms_devtools_extension/lib/src/widgets/detail/composed/composed_child_model_card.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; +import 'package:glade_forms_devtools_extension/src/constants.dart'; +import 'package:glade_forms_devtools_extension/src/models/child_glade_model_description.dart'; +import 'package:glade_forms_devtools_extension/src/widgets/common/state_chip.dart'; +import 'package:glade_forms_devtools_extension/src/widgets/detail/composed/composed_child_model_input_summary.dart'; +import 'package:glade_forms_devtools_extension/src/widgets/detail/composed/state_badge.dart'; +import 'package:netglade_flutter_utils/netglade_flutter_utils.dart'; + +class ComposedChildModelCard extends StatefulWidget { + final ChildGladeModelDescription child; + + const ComposedChildModelCard({required this.child, super.key}); + + @override + State createState() => _ComposedChildModelCardState(); +} + +class _ComposedChildModelCardState extends State { + bool _isExpanded = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final child = widget.child; + + return Card( + margin: const .only(bottom: 8), + child: Column( + children: [ + ListTile( + leading: CircleAvatar( + backgroundColor: child.isValid + ? Constants.validColor.withValues(alpha: 0.2) + : Constants.invalidColor.withValues(alpha: 0.2), + child: Text( + '${child.index + 1}', + style: TextStyle( + color: child.isValid ? Constants.validColor : Constants.invalidColor, + fontWeight: .bold, + ), + ), + ), + title: Text( + child.debugKey, + style: theme.textTheme.bodyMedium?.bold, + ), + subtitle: Wrap( + spacing: 4, + runSpacing: 4, + children: [ + StateChip( + label: child.validityLabel, + color: child.validityColor, + ), + StateChip( + label: child.purityLabel, + color: child.purityColor, + ), + StateChip( + label: '${child.inputs.length} inputs', + color: Colors.grey, + ), + ], + ), + trailing: IconButton( + icon: Icon(_isExpanded ? Icons.expand_less : Icons.expand_more), + onPressed: () => setState(() => _isExpanded = !_isExpanded), + ), + ), + if (_isExpanded) ...[ + const Divider(height: 1), + Padding( + padding: const .all(16), + child: Column( + crossAxisAlignment: .start, + children: [ + // Model state + Text( + 'State', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: .bold, + ), + ), + const SizedBox(height: 8), + StateBadge( + label: 'Valid', + value: child.isValid, + icon: child.validityIcon, + color: child.validityColor, + ), + const Divider(height: 16), + StateBadge( + label: 'Pure', + value: child.isPure, + icon: child.purityStateIcon, + color: child.purityColor, + ), + const Divider(height: 16), + StateBadge( + label: 'Unchanged', + value: child.isUnchanged, + icon: child.changeIcon, + color: child.changeColor, + ), + + if (child.formattedErrors.isNotEmpty) ...[ + const SizedBox(height: 12), + Container( + padding: const .all(12), + decoration: BoxDecoration( + color: child.validityColor.withValues(alpha: 0.2), + borderRadius: const .all(.circular(8)), + border: .all( + color: child.validityColor.withValues(alpha: 0.2), + ), + ), + child: Row( + crossAxisAlignment: .start, + spacing: 8, + children: [ + Icon(Icons.error_outline, size: 20, color: child.validityColor), + Expanded( + child: Text( + child.formattedErrors, + style: theme.textTheme.bodySmall?.copyWith( + color: child.validityColor, + ), + ), + ), + ], + ), + ), + ], + + if (child.inputs.isNotEmpty) ...[ + const SizedBox(height: Constants.spacing16), + Text( + 'Inputs (${child.inputs.length})', + style: theme.textTheme.titleSmall?.bold, + ), + const SizedBox(height: 8), + for (final input in child.inputs) ComposedChildModelInputSummary(input: input), + ], + ], + ), + ), + ], + ], + ), + ); + } +} diff --git a/glade_forms_devtools_extension/lib/src/widgets/detail/composed/composed_child_model_input_summary.dart b/glade_forms_devtools_extension/lib/src/widgets/detail/composed/composed_child_model_input_summary.dart new file mode 100644 index 0000000..9bf6dfc --- /dev/null +++ b/glade_forms_devtools_extension/lib/src/widgets/detail/composed/composed_child_model_input_summary.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:glade_forms_devtools_extension/src/constants.dart'; +import 'package:glade_forms_devtools_extension/src/models/glade_input_description.dart'; +import 'package:glade_forms_devtools_extension/src/widgets/common/input_value.dart'; +import 'package:netglade_flutter_utils/netglade_flutter_utils.dart'; + +class ComposedChildModelInputSummary extends StatelessWidget { + final GladeInputDescription input; + + const ComposedChildModelInputSummary({required this.input, super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + margin: const .only(bottom: 4), + padding: const .all(8), + decoration: BoxDecoration( + color: input.isValid + ? Constants.validColor.withAlpha((0.2 * 255).toInt()) + : Constants.invalidColor.withAlpha((0.2 * 255).toInt()), + borderRadius: const .all(.circular(4)), + border: .all( + color: input.isValid + ? Constants.validColor.withAlpha((0.2 * 255).toInt()) + : Constants.invalidColor.withAlpha((0.2 * 255).toInt()), + ), + ), + child: Row( + spacing: Constants.spacing8, + children: [ + Icon( + input.isValid ? Icons.check_circle_outline : Icons.error_outline, + size: 16, + color: input.isValid ? Constants.validColor : Constants.invalidColor, + ), + Expanded( + child: Column( + crossAxisAlignment: .start, + children: [ + Text(input.key, style: theme.textTheme.bodySmall?.bold), + InputValue( + value: input.value, + hasBackgroundWhitespaceIndicator: true, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/glade_forms_devtools_extension/lib/src/widgets/detail/composed/composed_model_state_card.dart b/glade_forms_devtools_extension/lib/src/widgets/detail/composed/composed_model_state_card.dart new file mode 100644 index 0000000..f718687 --- /dev/null +++ b/glade_forms_devtools_extension/lib/src/widgets/detail/composed/composed_model_state_card.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:glade_forms_devtools_extension/src/constants.dart'; +import 'package:glade_forms_devtools_extension/src/models/glade_model_description.dart'; +import 'package:glade_forms_devtools_extension/src/widgets/detail/composed/state_badge.dart'; +import 'package:netglade_flutter_utils/netglade_flutter_utils.dart'; + +class ComposedModelStateCard extends StatelessWidget { + final GladeModelDescription model; + + const ComposedModelStateCard({required this.model, super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Card( + child: Padding( + padding: const .all(16), + child: Column( + crossAxisAlignment: .start, + children: [ + Row( + spacing: Constants.spacing8, + children: [ + const Icon(Icons.info_outline, size: 20), + Text( + 'Overall State', + style: theme.textTheme.titleMedium?.bold, + ), + ], + ), + const SizedBox(height: 12), + StateBadge( + label: 'All Models Valid', + value: model.isValid, + icon: model.isValid ? Icons.check_circle : Icons.cancel, + color: model.validityColor, + ), + const Divider(height: 16), + StateBadge( + label: 'All Models Pure', + value: model.isPure, + icon: model.isPure ? Icons.check_circle : Icons.cancel, + color: model.purityColor, + ), + const Divider(height: 16), + StateBadge( + label: 'All Models Unchanged', + value: model.isUnchanged, + icon: model.isUnchanged ? Icons.check : Icons.edit, + color: model.changeColor, + ), + ], + ), + ), + ); + } +} diff --git a/glade_forms_devtools_extension/lib/src/widgets/detail/composed/composed_model_view.dart b/glade_forms_devtools_extension/lib/src/widgets/detail/composed/composed_model_view.dart new file mode 100644 index 0000000..95f704d --- /dev/null +++ b/glade_forms_devtools_extension/lib/src/widgets/detail/composed/composed_model_view.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:glade_forms_devtools_extension/src/constants.dart'; +import 'package:glade_forms_devtools_extension/src/models/glade_model_description.dart'; +import 'package:glade_forms_devtools_extension/src/widgets/detail/composed/composed_child_model_card.dart'; +import 'package:glade_forms_devtools_extension/src/widgets/detail/composed/composed_model_state_card.dart'; + +/// Widget to display a composed model with its child models. +class ComposedModelView extends StatelessWidget { + final GladeModelDescription model; + + const ComposedModelView({required this.model, super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return SingleChildScrollView( + padding: const .all(16), + child: Column( + crossAxisAlignment: .start, + children: [ + // Composed Model header + Row( + spacing: Constants.spacing16, + children: [ + const Icon(Icons.account_tree, size: 32), + Expanded( + child: Column( + crossAxisAlignment: .start, + children: [ + Text( + model.type, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: .bold, + ), + ), + Text( + 'Composed Model', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: .w500, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'ID: ${model.id}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + + // Composed Model state card + ComposedModelStateCard(model: model), + const SizedBox(height: 16), + + // Child Models section + Row( + spacing: Constants.spacing8, + children: [ + const Icon(Icons.list, size: 20), + Text( + 'Child Models (${model.childModels.length})', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: .bold, + ), + ), + ], + ), + const SizedBox(height: 12), + + if (model.childModels.isEmpty) + Card( + child: Padding( + padding: const .all(24), + child: Center( + child: Column( + spacing: Constants.spacing8, + children: [ + Icon( + Icons.inbox_outlined, + size: 48, + color: theme.colorScheme.onSurfaceVariant.withAlpha((0.5 * 255).toInt()), + ), + Text( + 'No child models', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ) + else + ...model.childModels.map((child) => ComposedChildModelCard(child: child)), + ], + ), + ); + } +} diff --git a/glade_forms_devtools_extension/lib/src/widgets/detail/composed/state_badge.dart b/glade_forms_devtools_extension/lib/src/widgets/detail/composed/state_badge.dart new file mode 100644 index 0000000..a386098 --- /dev/null +++ b/glade_forms_devtools_extension/lib/src/widgets/detail/composed/state_badge.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +class StateBadge extends StatelessWidget { + final String label; + final bool value; + final IconData icon; + final Color color; + + const StateBadge({ + required this.label, + required this.value, + required this.icon, + required this.color, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(icon, size: 20, color: color), + const SizedBox(width: 8), + Expanded( + child: Text(label, style: Theme.of(context).textTheme.bodyMedium), + ), + Text( + value.toString(), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: .bold, + color: color, + ), + ), + ], + ); + } +} diff --git a/glade_forms_devtools_extension/lib/src/widgets/detail/glade_input_card.dart b/glade_forms_devtools_extension/lib/src/widgets/detail/glade_input_card.dart new file mode 100644 index 0000000..eb2e0c2 --- /dev/null +++ b/glade_forms_devtools_extension/lib/src/widgets/detail/glade_input_card.dart @@ -0,0 +1,166 @@ +import 'package:flutter/material.dart'; +import 'package:glade_forms_devtools_extension/src/constants.dart'; +import 'package:glade_forms_devtools_extension/src/models/glade_input_description.dart'; +import 'package:glade_forms_devtools_extension/src/widgets/common/input_value.dart'; +import 'package:glade_forms_devtools_extension/src/widgets/detail/info_row.dart'; + +class GladeInputCard extends StatelessWidget { + final GladeInputDescription input; + + const GladeInputCard({required this.input, super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final hasErrors = input.errors.isNotEmpty; + final hasWarnings = input.warnings.isNotEmpty; + + return Card( + margin: const .only(bottom: 8), + child: ExpansionTile( + leading: Icon( + input.isValid ? Icons.check_circle : Icons.error, + color: input.isValid ? Colors.green : Colors.red, + ), + title: Text(input.key, style: const TextStyle(fontWeight: .bold)), + subtitle: Row( + children: [ + const Text('Value: '), + InputValue( + value: input.value, + hasBackgroundWhitespaceIndicator: true, + ), + ], + ), + children: [ + Padding( + padding: const .all(16), + child: Column( + crossAxisAlignment: .start, + children: [ + InfoRow(label: 'Type', value: input.type), + InfoRow(label: 'Input key', value: input.key), + InfoRow( + label: 'Dependencies', + value: input.dependencies.isNotEmpty ? '[${input.dependencies.join(', ')}] ' : 'None', + ), + const Divider(height: 16), + InfoRow( + label: 'Current Value', + value: input.value, + hasBackgroundWhitespaceIndicator: true, + ), + InfoRow( + label: 'Initial Value', + value: input.initialValue, + hasBackgroundWhitespaceIndicator: true, + ), + const Divider(height: 16), + InfoRow(label: 'Valid', value: input.isValid), + Row( + children: [ + Expanded( + child: InfoRow( + label: 'Has Validation error', + value: input.hasErrors, + hasInverseBoolColors: true, + ), + ), + Expanded( + child: InfoRow( + label: 'Has Conversion Error', + value: input.hasConversionError, + hasInverseBoolColors: true, + ), + ), + ], + ), + Row( + children: [ + Expanded( + child: InfoRow( + label: 'Unchanged', + value: input.isUnchanged, + ), + ), + Expanded( + child: InfoRow(label: 'Pure', value: input.isPure), + ), + ], + ), + if (hasErrors) ...[ + const Divider(height: 16), + Text( + 'Errors:', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: .bold, + color: Colors.red, + ), + ), + const SizedBox(height: 8), + for (final error in input.errors) + _ValidationMessage( + message: error, + icon: Icons.error_outline, + color: Colors.red, + ), + ], + if (hasWarnings) ...[ + const Divider(height: 16), + Text( + 'Warnings:', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: .bold, + color: Colors.orange, + ), + ), + const SizedBox(height: 8), + for (final warning in input.warnings) + _ValidationMessage( + message: warning, + icon: Icons.warning_amber, + color: Colors.orange, + ), + ], + ], + ), + ), + ], + ), + ); + } +} + +class _ValidationMessage extends StatelessWidget { + final String message; + final IconData icon; + final Color color; + + const _ValidationMessage({ + required this.message, + required this.icon, + required this.color, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Padding( + padding: const .only(bottom: 4), + child: Row( + crossAxisAlignment: .start, + spacing: Constants.spacing8, + children: [ + Icon(icon, size: 16, color: color), + Expanded( + child: Text( + message, + style: theme.textTheme.bodySmall?.copyWith(color: color), + ), + ), + ], + ), + ); + } +} diff --git a/glade_forms_devtools_extension/lib/src/widgets/detail/info_row.dart b/glade_forms_devtools_extension/lib/src/widgets/detail/info_row.dart new file mode 100644 index 0000000..d5ef03a --- /dev/null +++ b/glade_forms_devtools_extension/lib/src/widgets/detail/info_row.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:glade_forms_devtools_extension/src/constants.dart'; +import 'package:glade_forms_devtools_extension/src/widgets/common/input_value.dart'; + +class InfoRow extends StatelessWidget { + final String label; + // ignore: no-object-declaration, value can be of any type + final Object? value; + final bool hasInverseBoolColors; + final bool hasBackgroundWhitespaceIndicator; + + const InfoRow({ + required this.label, + required this.value, + super.key, + this.hasInverseBoolColors = false, + this.hasBackgroundWhitespaceIndicator = false, + }); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: .start, + spacing: Constants.spacing8, + children: [ + Text( + '$label:', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 8), + InputValue( + value: value, + shouldInverseBoolColors: hasInverseBoolColors, + hasBackgroundWhitespaceIndicator: hasBackgroundWhitespaceIndicator, + ), + ], + ); + } +} diff --git a/glade_forms_devtools_extension/lib/src/widgets/detail/model_detail_view.dart b/glade_forms_devtools_extension/lib/src/widgets/detail/model_detail_view.dart new file mode 100644 index 0000000..6d1f2fa --- /dev/null +++ b/glade_forms_devtools_extension/lib/src/widgets/detail/model_detail_view.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:glade_forms_devtools_extension/src/constants.dart'; +import 'package:glade_forms_devtools_extension/src/models/glade_base_model_description.dart'; +import 'package:glade_forms_devtools_extension/src/widgets/detail/glade_input_card.dart'; +import 'package:glade_forms_devtools_extension/src/widgets/detail/model_state_card.dart'; + +/// Widget to display detailed information about a GladeModel. +class ModelDetailView extends StatelessWidget { + final GladeBaseModelDescription model; + + const ModelDetailView({required this.model, super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return SingleChildScrollView( + padding: const .all(16), + child: Column( + crossAxisAlignment: .start, + children: [ + // Model header + Text( + model.type, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: .bold, + ), + ), + const SizedBox(height: 8), + Text( + 'ID: ${model.id}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + + // Model state card + ModelStateCard(model: model), + const SizedBox(height: Constants.spacing16), + + // Inputs section + Text( + 'Inputs (${model.inputs.length})', + style: theme.textTheme.titleMedium?.copyWith(fontWeight: .bold), + ), + const SizedBox(height: Constants.spacing8), + for (final input in model.inputs) GladeInputCard(input: input), + ], + ), + ); + } +} diff --git a/glade_forms_devtools_extension/lib/src/widgets/detail/model_state_card.dart b/glade_forms_devtools_extension/lib/src/widgets/detail/model_state_card.dart new file mode 100644 index 0000000..a9baada --- /dev/null +++ b/glade_forms_devtools_extension/lib/src/widgets/detail/model_state_card.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:glade_forms_devtools_extension/src/constants.dart'; +import 'package:glade_forms_devtools_extension/src/models/glade_base_model_description.dart'; +import 'package:glade_forms_devtools_extension/src/widgets/common/state_chip.dart'; +import 'package:glade_forms_devtools_extension/src/widgets/detail/state_row.dart'; + +class ModelStateCard extends StatelessWidget { + final GladeBaseModelDescription model; + + const ModelStateCard({required this.model, super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Card( + child: Padding( + padding: const .all(16), + child: Column( + crossAxisAlignment: .start, + spacing: Constants.spacing16, + children: [ + Text( + 'Model State', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: .bold, + ), + ), + Wrap( + spacing: 4, + children: [ + StateChip( + label: model.validityLabel, + color: model.validityColor, + ), + StateChip(label: model.purityLabel, color: model.purityColor), + StateChip(label: model.changeLabel, color: model.changeColor), + ], + ), + StateRow( + label: 'Is Valid', + value: model.isValid, + icon: model.validityIcon, + ), + StateRow( + label: 'Is Pure', + value: model.isPure, + icon: Icons.clean_hands_outlined, + ), + StateRow( + label: 'Is Unchanged', + value: model.isUnchanged, + icon: model.changeIcon, + ), + ], + ), + ), + ); + } +} diff --git a/glade_forms_devtools_extension/lib/src/widgets/detail/state_row.dart b/glade_forms_devtools_extension/lib/src/widgets/detail/state_row.dart new file mode 100644 index 0000000..7d04a44 --- /dev/null +++ b/glade_forms_devtools_extension/lib/src/widgets/detail/state_row.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:glade_forms_devtools_extension/src/constants.dart'; +import 'package:glade_forms_devtools_extension/src/widgets/common/input_value.dart'; + +class StateRow extends StatelessWidget { + final String label; + final bool value; + final IconData icon; + + const StateRow({ + required this.label, + required this.value, + required this.icon, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon( + icon, + size: 20, + color: value ? Constants.validColor : Constants.invalidColor, + ), + const SizedBox(width: 8), + Text(label, style: Theme.of(context).textTheme.bodyMedium), + const Spacer(), + InputValue(value: value), + ], + ); + } +} diff --git a/glade_forms_devtools_extension/lib/src/widgets/list/model_list_item.dart b/glade_forms_devtools_extension/lib/src/widgets/list/model_list_item.dart new file mode 100644 index 0000000..8952058 --- /dev/null +++ b/glade_forms_devtools_extension/lib/src/widgets/list/model_list_item.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:glade_forms_devtools_extension/src/constants.dart'; +import 'package:glade_forms_devtools_extension/src/models/glade_base_model_description.dart'; +import 'package:glade_forms_devtools_extension/src/models/glade_model_description.dart'; +import 'package:glade_forms_devtools_extension/src/widgets/common/state_chip.dart'; + +/// A list item that represents a GladeModel in the sidebar. +/// For composed models, this shows an expandable tree view of child models. +class ModelListItem extends StatefulWidget { + final GladeModelDescription model; + final bool isSelected; + final String? selectedChildId; + final VoidCallback onTap; + final ValueChanged? onChildTap; + + const ModelListItem({ + required this.model, + required this.isSelected, + required this.onTap, + this.selectedChildId, + this.onChildTap, + super.key, + }); + + @override + State createState() => _ModelListItemState(); +} + +class _ModelListItemState extends State { + bool _isExpanded = true; + + @override + Widget build(BuildContext context) { + // For non-composed models, show a simple card + if (!widget.model.isComposed) { + return _ModelCard( + model: widget.model, + isSelected: widget.isSelected, + onTap: widget.onTap, + modelsCount: widget.model.childModels.length, + ); + } + + // For composed models, show an expandable tree + return Column( + mainAxisSize: .min, + children: [ + // Parent model card with expand/collapse + _ModelCard( + model: widget.model, + isSelected: widget.isSelected && widget.selectedChildId == null, + onTap: widget.onTap, + modelsCount: widget.model.childModels.length, + trailing: IconButton( + icon: Icon( + _isExpanded ? Icons.expand_less : Icons.expand_more, + size: 20, + ), + onPressed: () => setState(() { + _isExpanded = !_isExpanded; + }), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ), + + // Child models (shown when expanded) + if (_isExpanded) + for (final child in widget.model.childModels) + Padding( + padding: const .only(left: 24), + child: _ModelCard( + model: child, + isSelected: widget.selectedChildId == child.id, + onTap: () => widget.onChildTap?.call(child.id), + isChild: true, + ), + ), + ], + ); + } +} + +/// A card widget displaying a model with its state and selection status. +class _ModelCard extends StatelessWidget { + final GladeBaseModelDescription model; + final bool isSelected; + final VoidCallback onTap; + final bool isChild; + final int modelsCount; + + final Widget? trailing; + + const _ModelCard({ + required this.model, + required this.isSelected, + required this.onTap, + this.isChild = false, + this.modelsCount = 0, + this.trailing, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Card( + margin: const .all(Constants.spacing4), + color: isSelected ? colorScheme.primaryContainer : null, + child: InkWell( + onTap: onTap, + borderRadius: const .all(.circular(12)), + child: Padding( + padding: const .all(12), + child: Row( + children: [ + Icon( + model.isComposed ? Icons.account_tree : Icons.assignment, + size: isChild ? 18 : 20, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: .start, + mainAxisSize: .min, + children: [ + Text( + model.debugKey, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: isSelected ? .w600 : .w500, + ), + overflow: .ellipsis, + ), + const SizedBox(height: 4), + Wrap( + spacing: 4, + runSpacing: 4, + children: [ + StateChip( + label: model.validityLabel, + color: model.validityColor, + ), + StateChip( + label: model.purityLabel, + color: model.purityColor, + ), + StateChip( + label: model.changeLabel, + color: model.changeColor, + ), + ], + ), + if (modelsCount > 0) + Padding( + padding: const .only(top: 4), + child: Text( + '$modelsCount models', + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ?trailing, + ], + ), + ), + ), + ); + } +} diff --git a/glade_forms_devtools_extension/lib/src/widgets/list/models_sidebar.dart b/glade_forms_devtools_extension/lib/src/widgets/list/models_sidebar.dart new file mode 100644 index 0000000..84ebf49 --- /dev/null +++ b/glade_forms_devtools_extension/lib/src/widgets/list/models_sidebar.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:glade_forms_devtools_extension/src/constants.dart'; +import 'package:glade_forms_devtools_extension/src/models/glade_model_description.dart'; +import 'package:glade_forms_devtools_extension/src/widgets/list/model_list_item.dart'; +import 'package:netglade_flutter_utils/netglade_flutter_utils.dart'; + +typedef OnChildTapCallback = void Function(GladeModelDescription model, String childId); + +/// Sidebar showing the list of active models. +class ModelsSidebar extends StatelessWidget { + final List models; + final GladeModelDescription? selectedModel; + final String? selectedChildId; + final ValueChanged onModelTap; + final OnChildTapCallback onChildTap; + + const ModelsSidebar({ + required this.models, + required this.selectedModel, + required this.selectedChildId, + required this.onModelTap, + required this.onChildTap, + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return SizedBox( + width: Constants.sidebarWidth, + child: Column( + crossAxisAlignment: .start, + children: [ + Padding( + padding: const .all(Constants.spacing16), + child: Text( + 'Active Models', + style: theme.textTheme.titleMedium?.bold, + ), + ), + Expanded( + child: ListView.builder( + itemCount: models.length, + itemBuilder: (context, index) { + final model = models[index]; + + return ModelListItem( + model: model, + isSelected: selectedModel?.id == model.id, + selectedChildId: selectedChildId, + onTap: () => onModelTap(model), + onChildTap: (id) => onChildTap(model, id), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/glade_forms_devtools_extension/lib/src/widgets/model_card.dart b/glade_forms_devtools_extension/lib/src/widgets/model_card.dart new file mode 100644 index 0000000..afb3f72 --- /dev/null +++ b/glade_forms_devtools_extension/lib/src/widgets/model_card.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:glade_forms_devtools_extension/src/models/glade_model_description.dart'; +import 'package:netglade_flutter_utils/netglade_flutter_utils.dart'; + +/// Widget to display a single GladeModel instance. +class ModelCard extends StatelessWidget { + final GladeModelDescription model; + final VoidCallback? onTap; + final bool isSelected; + + const ModelCard({ + required this.model, + this.onTap, + this.isSelected = false, + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Card( + elevation: isSelected ? 4 : 1, + color: isSelected ? theme.colorScheme.primaryContainer : null, + margin: const .symmetric(horizontal: 8, vertical: 4), + child: InkWell( + onTap: onTap, + child: Padding( + padding: const .all(12), + child: Column( + crossAxisAlignment: .start, + children: [ + Row( + children: [ + Icon( + model.isComposed ? Icons.account_tree : Icons.assignment, + size: 20, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: .start, + children: [ + Text( + model.type, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: .bold, + ), + overflow: .ellipsis, + ), + if (model.isComposed) + Text( + 'Composed Model', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.primary, + fontSize: 11, + ), + ), + ], + ), + ), + _StatusChip( + label: model.isValid ? 'Valid' : 'Invalid', + color: model.isValid ? Colors.green : Colors.red, + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 4, + runSpacing: 4, + children: [ + _StatusChip( + label: model.isPure ? 'Pure' : 'Dirty', + color: model.isPure ? Colors.blue : Colors.orange, + isSmall: true, + ), + if (model.isUnchanged) + const _StatusChip( + label: 'Unchanged', + color: Colors.grey, + isSmall: true, + ), + if (model.isComposed) + _StatusChip( + label: '${model.childModels.length} models', + color: theme.colorScheme.primary, + isSmall: true, + ) + else + _StatusChip( + label: '${model.inputs.length} inputs', + color: Colors.grey, + isSmall: true, + ), + ], + ), + if (model.formattedErrors.isNotEmpty) ...[ + const SizedBox(height: 8), + Container( + padding: const .all(8), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + borderRadius: const .all(.circular(4)), + ), + child: Row( + spacing: 4, + children: [ + const Icon(Icons.error_outline, size: 16, color: Colors.red), + Expanded( + child: Text( + model.formattedErrors, + style: theme.textTheme.bodySmall?.withColor(Colors.red), + maxLines: 2, + overflow: .ellipsis, + ), + ), + ], + ), + ), + ], + ], + ), + ), + ), + ); + } +} + +class _StatusChip extends StatelessWidget { + final String label; + final Color color; + final bool isSmall; + + const _StatusChip({ + required this.label, + required this.color, + this.isSmall = false, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: .symmetric( + horizontal: isSmall ? 6 : 8, + vertical: isSmall ? 2 : 4, + ), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.2), + borderRadius: const .all(.circular(12)), + border: .all(color: color.withValues(alpha: 0.5)), + ), + child: Text( + label, + style: TextStyle( + color: color.withValues(alpha: 0.9), + fontSize: isSmall ? 11 : 12, + fontWeight: .w500, + ), + ), + ); + } +} diff --git a/glade_forms_devtools_extension/lib/src/widgets/views/empty_models_view.dart b/glade_forms_devtools_extension/lib/src/widgets/views/empty_models_view.dart new file mode 100644 index 0000000..1d8c67d --- /dev/null +++ b/glade_forms_devtools_extension/lib/src/widgets/views/empty_models_view.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:glade_forms_devtools_extension/src/constants.dart'; + +/// View shown when no models are active. +class EmptyModelsView extends StatelessWidget { + const EmptyModelsView({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Center( + child: Column( + mainAxisAlignment: .center, + children: [ + Icon( + Icons.assignment_outlined, + size: Constants.iconSize, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: Constants.spacing16), + Text( + Constants.noModelsTitle, + style: theme.textTheme.headlineSmall, + ), + const SizedBox(height: Constants.spacing8), + const Padding( + padding: .symmetric(horizontal: Constants.spacing32), + child: Text(Constants.noModelsMessage, textAlign: .center), + ), + ], + ), + ); + } +} diff --git a/glade_forms_devtools_extension/lib/src/widgets/views/error_view.dart b/glade_forms_devtools_extension/lib/src/widgets/views/error_view.dart new file mode 100644 index 0000000..b56b66e --- /dev/null +++ b/glade_forms_devtools_extension/lib/src/widgets/views/error_view.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:glade_forms_devtools_extension/src/constants.dart'; + +/// View shown when an error occurs. +class ErrorView extends StatelessWidget { + final String errorMessage; + final VoidCallback onRetry; + + const ErrorView({ + required this.errorMessage, + required this.onRetry, + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Center( + child: Column( + mainAxisAlignment: .center, + children: [ + const Icon( + Icons.error_outline, + size: Constants.iconSize, + color: Colors.red, + ), + const SizedBox(height: Constants.spacing16), + Text(Constants.errorTitle, style: theme.textTheme.headlineSmall), + const SizedBox(height: Constants.spacing8), + Padding( + padding: const .symmetric(horizontal: Constants.spacing32), + child: Text(errorMessage, textAlign: .center), + ), + const SizedBox(height: Constants.spacing16), + ElevatedButton( + onPressed: onRetry, + child: const Text(Constants.retryButton), + ), + ], + ), + ); + } +} diff --git a/glade_forms_devtools_extension/lib/src/widgets/views/loading_view.dart b/glade_forms_devtools_extension/lib/src/widgets/views/loading_view.dart new file mode 100644 index 0000000..fef2e07 --- /dev/null +++ b/glade_forms_devtools_extension/lib/src/widgets/views/loading_view.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:glade_forms_devtools_extension/src/constants.dart'; + +/// Loading indicator shown while fetching models. +class LoadingView extends StatelessWidget { + const LoadingView({super.key}); + + @override + Widget build(BuildContext context) { + return const Center( + child: Column( + mainAxisAlignment: .center, + spacing: Constants.spacing16, + children: [ + CircularProgressIndicator(), + Text(Constants.loadingMessage), + ], + ), + ); + } +} diff --git a/glade_forms_devtools_extension/lib/src/widgets/views/no_selection_view.dart b/glade_forms_devtools_extension/lib/src/widgets/views/no_selection_view.dart new file mode 100644 index 0000000..375c6f0 --- /dev/null +++ b/glade_forms_devtools_extension/lib/src/widgets/views/no_selection_view.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:glade_forms_devtools_extension/src/constants.dart'; + +/// Message shown when no model is selected. +class NoSelectionView extends StatelessWidget { + const NoSelectionView({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Center( + child: Text( + Constants.selectModelMessage, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ); + } +} diff --git a/glade_forms_devtools_extension/lib/src/widgets/views/service_unavailable_view.dart b/glade_forms_devtools_extension/lib/src/widgets/views/service_unavailable_view.dart new file mode 100644 index 0000000..d61888c --- /dev/null +++ b/glade_forms_devtools_extension/lib/src/widgets/views/service_unavailable_view.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:glade_forms_devtools_extension/src/constants.dart'; + +/// View shown when the DevTools service is not available. +class ServiceUnavailableView extends StatelessWidget { + final VoidCallback onRetry; + + const ServiceUnavailableView({required this.onRetry, super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Center( + child: Column( + mainAxisAlignment: .center, + children: [ + Icon( + Icons.cloud_off, + size: Constants.iconSize, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: Constants.spacing16), + Text( + Constants.serviceUnavailableTitle, + style: theme.textTheme.headlineSmall, + ), + const SizedBox(height: Constants.spacing8), + const Padding( + padding: .symmetric(horizontal: Constants.spacing32), + child: Text( + Constants.serviceUnavailableMessage, + textAlign: .center, + ), + ), + const SizedBox(height: Constants.spacing16), + ElevatedButton( + onPressed: onRetry, + child: const Text(Constants.retryButton), + ), + ], + ), + ); + } +} diff --git a/glade_forms_devtools_extension/pubspec.yaml b/glade_forms_devtools_extension/pubspec.yaml new file mode 100644 index 0000000..3428919 --- /dev/null +++ b/glade_forms_devtools_extension/pubspec.yaml @@ -0,0 +1,30 @@ +name: glade_forms_devtools_extension +resolution: workspace +description: A DevTools extension for glade_forms package +publish_to: none +version: 1.0.0 + +environment: + sdk: ">=3.10.0" + +dependencies: + devtools_extensions: ^0.2.2 + flutter: + sdk: flutter + flutter_web_plugins: + sdk: flutter + glade_forms: + path: ../glade_forms + multi_split_view: ^3.6.1 + netglade_flutter_utils: ^1.3.0 + provider: ^6.0.5 + +dev_dependencies: + flutter_test: + sdk: flutter + netglade_analysis: ^21.0.0 + test: ^1.25.8 + yaml: ^3.1.2 + +flutter: + uses-material-design: true diff --git a/glade_forms_devtools_extension/web/index.html b/glade_forms_devtools_extension/web/index.html new file mode 100644 index 0000000..e24fdc1 --- /dev/null +++ b/glade_forms_devtools_extension/web/index.html @@ -0,0 +1,15 @@ + + + + + + + + + Glade Forms DevTools Extension + + + + + + diff --git a/glade_forms_devtools_extension/web/manifest.json b/glade_forms_devtools_extension/web/manifest.json new file mode 100644 index 0000000..b180d9e --- /dev/null +++ b/glade_forms_devtools_extension/web/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "glade_forms_devtools_extension", + "short_name": "glade_forms", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "DevTools extension for glade_forms", + "orientation": "portrait-primary", + "prefer_related_applications": false +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 2ff3bdc..88ff1ff 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,11 +2,12 @@ name: glade_forms_workspace publish_to: "none" environment: - sdk: ">=3.8.0 <4.0.0" + sdk: ">=3.10.0" workspace: - glade_forms - glade_forms/example + - glade_forms_devtools_extension - storybook dev_dependencies: @@ -63,3 +64,29 @@ melos: melos exec -- fvm flutter pub run easy_localization:generate -S "assets/translations" --skip-unnecessary-keys -f keys -o locale_keys.g.dart packageFilters: dependsOn: easy_localization + + # DevTools Extension + build:extension: + run: | + cd glade_forms_devtools_extension + flutter build web --wasm --release + # Ensure parent directory exists, then clean and copy build output + mkdir -p ../glade_forms/extension/devtools + rm -rf ../glade_forms/extension/devtools/build + cp -r build/web ../glade_forms/extension/devtools/build + description: Build the DevTools extension for glade_forms and copy to extension directory. + + # Publishing + publish: + run: | + melos run build:extension + cd glade_forms + flutter pub publish + description: Build extension and publish glade_forms package to pub.dev. + + publish:dry-run: + run: | + melos run build:extension + cd glade_forms + flutter pub publish --dry-run + description: Build extension and perform dry-run of glade_forms package publication. diff --git a/storybook/devtools_options.yaml b/storybook/devtools_options.yaml new file mode 100644 index 0000000..213d462 --- /dev/null +++ b/storybook/devtools_options.yaml @@ -0,0 +1,4 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: + glade_forms: true diff --git a/storybook/lib/localization_addon_custom.dart b/storybook/lib/localization_addon_custom.dart index 9bade75..e4d9299 100644 --- a/storybook/lib/localization_addon_custom.dart +++ b/storybook/lib/localization_addon_custom.dart @@ -17,7 +17,7 @@ class LocalizationAddonCustom extends WidgetbookAddon { name: 'name', values: locales, // TODO(widgetbook): update deprecated - // ignore: deprecated_member_use, ok for now + // ignore: deprecated_member_use, ok for now, avoid-deprecated-usage initialValue: initialSetting, labelBuilder: (locale) => locale.toLanguageTag(), // TODO(widgetbook): update deprecated @@ -32,20 +32,20 @@ class LocalizationAddonCustom extends WidgetbookAddon { required this.localizationsDelegates, required this.onChange, Locale? initialLocale, - }) : assert( - locales.isNotEmpty, - 'locales cannot be empty', - ), - assert( - initialLocale == null || locales.contains(initialLocale), - 'initialLocale must be in locales', - ), - super( - name: 'Locale', - // TODO(widgetbook): update deprecated - // ignore: deprecated_member_use, ok for now, avoid-unsafe-collection-methods, ok here, avoid-deprecated-usage - initialSetting: initialLocale ?? locales.first, - ); + }) : assert( + locales.isNotEmpty, + 'locales cannot be empty', + ), + assert( + initialLocale == null || locales.contains(initialLocale), + 'initialLocale must be in locales', + ), + super( + name: 'Locale', + // TODO(widgetbook): update deprecated + // ignore: deprecated_member_use, ok for now, avoid-unsafe-collection-methods, ok here, avoid-deprecated-usage + initialSetting: initialLocale ?? locales.first, + ); @override Locale valueFromQueryGroup(Map group) { diff --git a/storybook/lib/main.dart b/storybook/lib/main.dart index 7e30f1b..2b0d97c 100644 --- a/storybook/lib/main.dart +++ b/storybook/lib/main.dart @@ -2,6 +2,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:glade_forms/glade_forms.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'; @@ -21,6 +22,7 @@ final GlobalKey storyNavigatorKey = GlobalKey(debugLabel: 'story void main() async { final _ = WidgetsFlutterBinding.ensureInitialized(); + GladeForms.initialize(); await EasyLocalization.ensureInitialized(); const isDebugModel = String.fromEnvironment('DEBUG_MODEL', defaultValue: 'false') == 'true'; diff --git a/storybook/lib/shared/usecase_container.dart b/storybook/lib/shared/usecase_container.dart index 6523eac..455a047 100644 --- a/storybook/lib/shared/usecase_container.dart +++ b/storybook/lib/shared/usecase_container.dart @@ -50,6 +50,7 @@ class UsecaseContainer extends StatelessWidget { class _CodeSample extends HookWidget { final String fileName; + const _CodeSample({required this.fileName}); @override @@ -57,7 +58,7 @@ class _CodeSample extends HookWidget { final getFileContentFutureMemo = useMemoized(_getFileContent); final getFileFuture = useFuture(getFileContentFutureMemo); - if (getFileFuture.connectionState == ConnectionState.waiting) { + if (getFileFuture.connectionState == .waiting) { return const Center(child: CircularProgressIndicator()); } @@ -75,7 +76,7 @@ class _CodeSample extends HookWidget { child: HighlightView( // ignore: avoid-non-null-assertion, ok here getFileFuture.data!, - padding: const EdgeInsets.all(10), + padding: const .all(10), language: 'dart', theme: githubTheme, ), @@ -84,9 +85,9 @@ class _CodeSample extends HookWidget { ), Container( alignment: Alignment.topRight, - padding: const EdgeInsets.all(8), + padding: const .all(8), child: Padding( - padding: const EdgeInsets.all(8), + padding: const .all(8), child: SizedBox( width: 100, height: 40, @@ -95,8 +96,9 @@ class _CodeSample extends HookWidget { onPressed: () { // ignore: avoid-async-call-in-sync-function, avoid-non-null-assertion , ok here Clipboard.setData(ClipboardData(text: getFileFuture.data!)); - final _ = ScaffoldMessenger.of(context) - .showSnackBar(const SnackBar(content: Text('Code copied to clipboad'))); + final _ = ScaffoldMessenger.of( + context, // ignore: unnecessary-trailing-comma, ok here + ).showSnackBar(const SnackBar(content: Text('Code copied to clipboad'))); }, icon: const Icon(Icons.code), label: const Text('Copy code'), diff --git a/storybook/lib/usecases/complex_object_mapping_example.dart b/storybook/lib/usecases/complex_object_mapping_example.dart index 89ebe54..d487a84 100644 --- a/storybook/lib/usecases/complex_object_mapping_example.dart +++ b/storybook/lib/usecases/complex_object_mapping_example.dart @@ -7,6 +7,7 @@ import 'package:glade_forms_storybook/shared/usecase_container.dart'; class _Item { final int id; final String name; + const _Item({required this.id, required this.name}); } @@ -72,12 +73,12 @@ class ComplexObjectMappingExample extends StatelessWidget { create: (context) => _Model(), builder: (context, model, _) { return Form( - autovalidateMode: AutovalidateMode.always, + autovalidateMode: .always, child: Row( children: [ Expanded( child: Padding( - padding: const EdgeInsets.all(8), + padding: const .all(8), child: Column( children: [ TextFormField( @@ -116,7 +117,7 @@ class ComplexObjectMappingExample extends StatelessWidget { children: [ Text( 'Character stats', - style: Theme.of(context).textTheme.bodyLarge?.copyWith(decoration: TextDecoration.underline), + style: Theme.of(context).textTheme.bodyLarge?.copyWith(decoration: .underline), ), if (model.availableStats.value.isEmpty) const Text('No stats') @@ -124,7 +125,7 @@ class ComplexObjectMappingExample extends StatelessWidget { ...model.availableStats.value.map((e) => Text(e.toString())), Text( 'Selected item', - style: Theme.of(context).textTheme.bodyLarge?.copyWith(decoration: TextDecoration.underline), + style: Theme.of(context).textTheme.bodyLarge?.copyWith(decoration: .underline), ), Text(model.selectedItem.value?.name ?? 'No item selected'), ], diff --git a/storybook/lib/usecases/composed/composed_example.dart b/storybook/lib/usecases/composed/composed_example.dart index 2b9e9af..98c0f23 100644 --- a/storybook/lib/usecases/composed/composed_example.dart +++ b/storybook/lib/usecases/composed/composed_example.dart @@ -18,12 +18,12 @@ class _Model extends GladeModel { firstName = GladeStringInput( initialValue: '', validator: (v) => (v..satisfy((value) => value.isNotEmpty)).build(), - validationTranslate: (_, __, ___, ____) => firstName.value.isEmpty ? 'First name cannot be empty' : '', + 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' : '', + validationTranslate: (_, _, _, _) => lastName.value.isEmpty ? 'Last name cannot be empty' : '', ); super.initialize(); @@ -54,7 +54,7 @@ class ComposedExample extends StatelessWidget { ), GladeFormConsumer<_ComposedModel>( builder: (context, model, child) => Padding( - padding: const EdgeInsets.only(bottom: 16), + padding: const .only(bottom: 16), child: Column( children: [ Text( @@ -94,13 +94,13 @@ class _Form extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + padding: const .symmetric(vertical: 8, horizontal: 16), child: Card( child: Padding( - padding: const EdgeInsets.all(8), + padding: const .all(8), child: Form( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Text('Person #${index + 1}'), Row( @@ -108,7 +108,7 @@ class _Form extends StatelessWidget { children: [ Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ TextFormField( controller: model.firstName.controller, @@ -124,7 +124,7 @@ class _Form extends StatelessWidget { ), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ TextFormField( controller: model.lastName.controller, diff --git a/storybook/lib/usecases/composed/nested_composed_example.dart b/storybook/lib/usecases/composed/nested_composed_example.dart index 373483e..b55fb56 100644 --- a/storybook/lib/usecases/composed/nested_composed_example.dart +++ b/storybook/lib/usecases/composed/nested_composed_example.dart @@ -24,12 +24,12 @@ class _Model extends GladeModel { firstName = GladeStringInput( initialValue: '', validator: (v) => (v..satisfy((value) => value.isNotEmpty)).build(), - validationTranslate: (_, __, ___, ____) => firstName.value.isEmpty ? 'First name cannot be empty' : '', + 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' : '', + validationTranslate: (_, _, _, _) => lastName.value.isEmpty ? 'Last name cannot be empty' : '', ); super.initialize(); @@ -62,7 +62,7 @@ class NestedComposedExample extends StatelessWidget { ), GladeFormBuilder<_ComposedModel>( builder: (context, model, child) => Padding( - padding: const EdgeInsets.only(bottom: 16), + padding: const .only(bottom: 16), child: Column( children: [ Text( @@ -101,14 +101,14 @@ class _GroupContainer extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + padding: const .symmetric(vertical: 8, horizontal: 16), child: Card( child: Padding( - padding: const EdgeInsets.all(8), + padding: const .all(8), child: Column( children: [ Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisAlignment: .spaceBetween, children: [ const SizedBox.shrink(), Text('Group #${groupIndex + 1}'), @@ -157,16 +157,16 @@ class _Form extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - margin: const EdgeInsets.all(8), - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + margin: const .all(8), + padding: const .symmetric(vertical: 8, horizontal: 16), decoration: const BoxDecoration( - border: Border.fromBorderSide(BorderSide()), - borderRadius: BorderRadius.all(Radius.circular(16)), + border: .fromBorderSide(BorderSide()), + borderRadius: .all(.circular(16)), color: Colors.white, ), child: Form( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Text('Person #${index + 1}'), Row( @@ -174,7 +174,7 @@ class _Form extends StatelessWidget { children: [ Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ TextFormField( controller: model.firstName.controller, @@ -190,7 +190,7 @@ class _Form extends StatelessWidget { ), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ TextFormField( controller: model.lastName.controller, diff --git a/storybook/lib/usecases/dependencies/checkbox_dependency_change.dart b/storybook/lib/usecases/dependencies/checkbox_dependency_change.dart index 49f36d4..36ec521 100644 --- a/storybook/lib/usecases/dependencies/checkbox_dependency_change.dart +++ b/storybook/lib/usecases/dependencies/checkbox_dependency_change.dart @@ -27,14 +27,15 @@ class AgeRestrictedModel extends GladeModel { ), ); ageInput = GladeIntInput( - validator: (v) => (v - ..notNull() - ..isMin( - min: 18, - shouldValidate: (_) => vipInput.value, - key: _ErrorKeys.ageRestriction, - )) - .build(), + validator: (v) => + (v + ..notNull() + ..isMin( + min: 18, + shouldValidate: (_) => vipInput.value, + key: _ErrorKeys.ageRestriction, + )) + .build(), value: 0, inputKey: 'age-input', validationTranslate: (error, key, devMessage, dependencies) { @@ -88,9 +89,9 @@ In this example both bussiness rules controll checkbox via onChange and onDepend // ignore: avoid-undisposed-instances, handled by GladeFormBuilder create: (context) => AgeRestrictedModel(), builder: (context, formModel, _) => Padding( - padding: const EdgeInsets.all(8), + padding: const .all(8), child: Form( - autovalidateMode: AutovalidateMode.always, + autovalidateMode: .always, child: ListView( children: [ TextFormField( diff --git a/storybook/lib/usecases/metadata_descriptor_example.dart b/storybook/lib/usecases/metadata_descriptor_example.dart index b77406b..054cb42 100644 --- a/storybook/lib/usecases/metadata_descriptor_example.dart +++ b/storybook/lib/usecases/metadata_descriptor_example.dart @@ -54,9 +54,9 @@ class MetadataDescriptorExample extends StatelessWidget { // ignore: avoid-undisposed-instances, handled by GladeFormBuilder create: (context) => _Model(), builder: (context, model, _) => Padding( - padding: const EdgeInsets.all(32), + padding: const .all(32), child: Form( - autovalidateMode: AutovalidateMode.onUserInteraction, + autovalidateMode: .onUserInteraction, child: Column( children: [ TextFormField( diff --git a/storybook/lib/usecases/onchange/one_checkbox_deps_validation.dart b/storybook/lib/usecases/onchange/one_checkbox_deps_validation.dart index 7dabe63..4d9f995 100644 --- a/storybook/lib/usecases/onchange/one_checkbox_deps_validation.dart +++ b/storybook/lib/usecases/onchange/one_checkbox_deps_validation.dart @@ -27,10 +27,11 @@ class AgeRestrictedModel extends GladeModel { ), ); ageInput = GladeIntInput( - validator: (v) => (v - ..notNull() - ..isMin(min: 18, shouldValidate: (_) => vipInput.value, key: _ErrorKeys.ageRestriction)) - .build(), + validator: (v) => + (v + ..notNull() + ..isMin(min: 18, shouldValidate: (_) => vipInput.value, key: _ErrorKeys.ageRestriction)) + .build(), value: 0, inputKey: 'age-input', validationTranslate: (error, key, devMessage, dependencies) { @@ -68,9 +69,9 @@ If *VIP content* is checked, **age** must be over 18. // ignore: avoid-undisposed-instances, handled by GladeFormBuilder create: (context) => AgeRestrictedModel(), builder: (context, formModel, _) => Padding( - padding: const EdgeInsets.all(8), + padding: const .all(8), child: Form( - autovalidateMode: AutovalidateMode.always, + autovalidateMode: .always, child: ListView( children: [ TextFormField( diff --git a/storybook/lib/usecases/onchange/two_way_checkbox_change.dart b/storybook/lib/usecases/onchange/two_way_checkbox_change.dart index e897709..1d7879b 100644 --- a/storybook/lib/usecases/onchange/two_way_checkbox_change.dart +++ b/storybook/lib/usecases/onchange/two_way_checkbox_change.dart @@ -27,10 +27,11 @@ class AgeRestrictedModel extends GladeModel { ), ); ageInput = GladeIntInput( - validator: (v) => (v - ..notNull() - ..isMin(min: 18, shouldValidate: (_) => vipInput.value, key: _ErrorKeys.ageRestriction)) - .build(), + validator: (v) => + (v + ..notNull() + ..isMin(min: 18, shouldValidate: (_) => vipInput.value, key: _ErrorKeys.ageRestriction)) + .build(), value: 0, inputKey: 'age-input', validationTranslate: (error, key, devMessage, dependencies) { @@ -82,9 +83,9 @@ If *age* is changed to value under 18, *vip content* is unchecked and vice-versa // ignore: avoid-undisposed-instances, handled by GladeFormBuilder create: (context) => AgeRestrictedModel(), builder: (context, formModel, _) => Padding( - padding: const EdgeInsets.all(8), + padding: const .all(8), child: Form( - autovalidateMode: AutovalidateMode.always, + autovalidateMode: .always, child: ListView( children: [ TextFormField( diff --git a/storybook/lib/usecases/quickstart_example.dart b/storybook/lib/usecases/quickstart_example.dart index 06e7784..e818080 100644 --- a/storybook/lib/usecases/quickstart_example.dart +++ b/storybook/lib/usecases/quickstart_example.dart @@ -37,9 +37,9 @@ class QuickStartExample extends StatelessWidget { // ignore: avoid-undisposed-instances, handled by GladeFormBuilder create: (context) => _Model(), builder: (context, model, _) => Padding( - padding: const EdgeInsets.all(32), + padding: const .all(32), child: Form( - autovalidateMode: AutovalidateMode.onUserInteraction, + autovalidateMode: .onUserInteraction, child: Column( children: [ TextFormField( diff --git a/storybook/lib/usecases/regress/issue48_text_controller_example.dart b/storybook/lib/usecases/regress/issue48_text_controller_example.dart index fb92eea..a7f56f0 100644 --- a/storybook/lib/usecases/regress/issue48_text_controller_example.dart +++ b/storybook/lib/usecases/regress/issue48_text_controller_example.dart @@ -55,7 +55,7 @@ class HookExample extends HookWidget { children: [ Text(numb.value.toString()), Form( - autovalidateMode: AutovalidateMode.onUserInteraction, + autovalidateMode: .onUserInteraction, child: TextField( controller: wrongField, onChanged: (v) => numb.value = v.length, @@ -75,9 +75,9 @@ class ModelB extends HookWidget { return GladeFormBuilder<_Model>( builder: (context, model, _) => Padding( - padding: const EdgeInsets.all(32), + padding: const .all(32), child: Form( - autovalidateMode: AutovalidateMode.onUserInteraction, + autovalidateMode: .onUserInteraction, child: Column( children: [ Text(model.nameWithController.value), diff --git a/storybook/lib/usecases/warning_input_example.dart b/storybook/lib/usecases/warning_input_example.dart index f3688d5..eb2be7f 100644 --- a/storybook/lib/usecases/warning_input_example.dart +++ b/storybook/lib/usecases/warning_input_example.dart @@ -15,16 +15,17 @@ class _Model extends GladeModel { value: 0, inputKey: 'age', useTextEditingController: true, - validator: (v) => (v..isMin(min: 18, severity: ValidationSeverity.warning)).build(), + validator: (v) => (v..isMin(min: 18, severity: .warning)).build(), ); income = GladeIntInput( value: 0, inputKey: 'income', useTextEditingController: true, - validator: (v) => (v - ..isMin(min: 18, severity: ValidationSeverity.warning) - ..isMin(min: 25, severity: ValidationSeverity.warning)) - .build(), + validator: (v) => + (v + ..isMin(min: 18, severity: .warning) + ..isMin(min: 25, severity: .warning)) + .build(), ); super.initialize(); @@ -43,9 +44,9 @@ class WarningInputExample extends StatelessWidget { // ignore: avoid-undisposed-instances, handled by GladeFormBuilder create: (context) => _Model(), builder: (context, model, _) => Padding( - padding: const EdgeInsets.all(32), + padding: const .all(32), child: Form( - autovalidateMode: AutovalidateMode.onUserInteraction, + autovalidateMode: .onUserInteraction, child: Column( children: [ const Text( @@ -53,8 +54,8 @@ class WarningInputExample extends StatelessWidget { ), TextFormField( controller: model.income.controller, - validator: (value) => model.income - .textFormFieldInputValidator(value, severity: ValidationSeverity.warning, delimiter: '\n'), + validator: (value) => + model.income.textFormFieldInputValidator(value, severity: .warning, delimiter: '\n'), decoration: const InputDecoration(labelText: 'Income'), ), const SizedBox(height: 10), diff --git a/storybook/macos/Flutter/ephemeral/Flutter-Generated.xcconfig b/storybook/macos/Flutter/ephemeral/Flutter-Generated.xcconfig new file mode 100644 index 0000000..fd9e7c3 --- /dev/null +++ b/storybook/macos/Flutter/ephemeral/Flutter-Generated.xcconfig @@ -0,0 +1,11 @@ +// This is a generated file; do not edit or check into version control. +FLUTTER_ROOT=/Users/petrnymsa/fvm/versions/3.38.7 +FLUTTER_APPLICATION_PATH=/Users/petrnymsa/dev/github/glade_forms/storybook +COCOAPODS_PARALLEL_CODE_SIGN=true +FLUTTER_BUILD_DIR=build +FLUTTER_BUILD_NAME=1.0.0 +FLUTTER_BUILD_NUMBER=1.0.0 +DART_OBFUSCATION=false +TRACK_WIDGET_CREATION=true +TREE_SHAKE_ICONS=false +PACKAGE_CONFIG=.dart_tool/package_config.json diff --git a/storybook/macos/Flutter/ephemeral/flutter_export_environment.sh b/storybook/macos/Flutter/ephemeral/flutter_export_environment.sh new file mode 100755 index 0000000..23587aa --- /dev/null +++ b/storybook/macos/Flutter/ephemeral/flutter_export_environment.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# This is a generated file; do not edit or check into version control. +export "FLUTTER_ROOT=/Users/petrnymsa/fvm/versions/3.38.7" +export "FLUTTER_APPLICATION_PATH=/Users/petrnymsa/dev/github/glade_forms/storybook" +export "COCOAPODS_PARALLEL_CODE_SIGN=true" +export "FLUTTER_BUILD_DIR=build" +export "FLUTTER_BUILD_NAME=1.0.0" +export "FLUTTER_BUILD_NUMBER=1.0.0" +export "DART_OBFUSCATION=false" +export "TRACK_WIDGET_CREATION=true" +export "TREE_SHAKE_ICONS=false" +export "PACKAGE_CONFIG=.dart_tool/package_config.json" diff --git a/storybook/macos/Podfile b/storybook/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/storybook/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/storybook/pubspec.yaml b/storybook/pubspec.yaml index 24fa8ea..88dc75d 100644 --- a/storybook/pubspec.yaml +++ b/storybook/pubspec.yaml @@ -1,11 +1,11 @@ name: glade_forms_storybook +resolution: workspace description: Glade Forms - Interactive example -version: 1.0.0 publish_to: none +version: 1.0.0 environment: - sdk: ^3.6.0 -resolution: workspace + sdk: ^3.10.0 dependencies: easy_localization: ^3.0.7 @@ -22,7 +22,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - netglade_analysis: ^18.0.0 + netglade_analysis: ^21.0.0 flutter: uses-material-design: true