From f1417d27d16c15010c0f38bebb5189596d5d7d8a Mon Sep 17 00:00:00 2001 From: Jhonacode Date: Tue, 4 Feb 2025 01:06:26 +1300 Subject: [PATCH] Reactive view model --- CHANGELOG.md | 3 + README.md | 20 +-- lib/reactive_notifier.dart | 18 +- lib/src/builder/reactive_builder.dart | 142 ++++++++++++++++ lib/src/implements/notifier_impl.dart | 10 -- lib/src/reactive_notifier.dart | 4 +- lib/src/viewmodel/viewmodel_impl.dart | 2 +- pubspec.yaml | 32 +--- test/reactive_builder_viewmodel_test.dart | 190 ++++++++++++++++++++++ 9 files changed, 359 insertions(+), 62 deletions(-) create mode 100644 test/reactive_builder_viewmodel_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 3427fc7..85de931 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +# 2.5.0 +- Implement `ReactiveViewModelBuilder` for complex state management. + # 2.4.2 - Some dart format. diff --git a/README.md b/README.md index c5c54ab..fa0c8b0 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Add this to your package's `pubspec.yaml` file: ```yaml dependencies: - reactive_notifier: ^2.4.2 + reactive_notifier: ^2.5.0 ``` ## Quick Start @@ -270,7 +270,7 @@ class CartViewModel extends ViewModelImpl { Here we create the repository instance and the `ViewModelImpl`: ```dart -final cartViewModel = ReactiveNotifier((){ +final cartViewModelNotifier = ReactiveNotifier((){ final cartRepository = CartRepository(); return CartViewModel(cartRepository); }); @@ -283,17 +283,17 @@ final cartViewModel = ReactiveNotifier((){ Finally, we are going to display the cart status in the UI using `ReactiveBuilder`, which will automatically update when the status changes. ```dart -ReactiveBuilder( - notifier: cartViewModel, - builder: ( viewModel, keep) { +ReactiveViewModelBuilder( + notifier: cartViewModelNotifier.notifier, + builder: ( carModel, keep) { return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - if (viewModel.data.isEmpty) + if (carModel.isEmpty) keep(Text("Loading cart...")), - if (viewModel.data.isNotEmpty) ...[ + if (carModel.isNotEmpty) ...[ keep(Text("Products in cart:")), - ...viewModel.data.map((item) => Text(item)).toList(), + ...carModel.map((item) => Text(item)).toList(), keep(const SizedBox(height: 20)), Text("Total: \$${viewModel.total.toStringAsFixed(2)}"), keep(const SizedBox(height: 20)), @@ -304,7 +304,7 @@ ReactiveBuilder( ElevatedButton( onPressed: () { // Add a new product - cartViewModel.notifier.agregarProducto("Producto C", 29.99); + cartViewModelNotifier.notifier.agregarProducto("Producto C", 29.99); }, child: Text("Agregar Producto C"), ), @@ -314,7 +314,7 @@ ReactiveBuilder( ElevatedButton( onPressed: () { // Empty cart - cartViewModel.notifier.myCleaningCarFunction(); + cartViewModelNotifier.notifier.myCleaningCarFunction(); }, child: Text("Vaciar Carrito"), diff --git a/lib/reactive_notifier.dart b/lib/reactive_notifier.dart index c5c1da9..7009eb8 100644 --- a/lib/reactive_notifier.dart +++ b/lib/reactive_notifier.dart @@ -6,25 +6,25 @@ library reactive_notifier; -/// Export the base [ReactiveNotifier] class which provides basic state management functionality. -export 'package:reactive_notifier/src/reactive_notifier.dart'; +/// Export [ReactiveAsyncBuilder] and [ReactiveStreamBuilder] +export 'package:reactive_notifier/src/builder/reactive_async_builder.dart'; /// Export the [ReactiveBuilder] widget which listens to a [ReactiveNotifier] and rebuilds /// itself whenever the value changes. export 'package:reactive_notifier/src/builder/reactive_builder.dart'; +export 'package:reactive_notifier/src/builder/reactive_stream_builder.dart'; /// Export the [AsyncState] export 'package:reactive_notifier/src/handler/async_state.dart'; -/// Export [ReactiveAsyncBuilder] and [ReactiveStreamBuilder] -export 'package:reactive_notifier/src/builder/reactive_async_builder.dart'; -export 'package:reactive_notifier/src/builder/reactive_stream_builder.dart'; - -/// Export ViewModelImpl -export 'package:reactive_notifier/src/viewmodel/viewmodel_impl.dart'; - /// Export RepositoryImpl export 'package:reactive_notifier/src/implements/repository_impl.dart'; /// Export ServiceImpl export 'package:reactive_notifier/src/implements/service_impl.dart'; + +/// Export the base [ReactiveNotifier] class which provides basic state management functionality. +export 'package:reactive_notifier/src/reactive_notifier.dart'; + +/// Export ViewModelImpl +export 'package:reactive_notifier/src/viewmodel/viewmodel_impl.dart'; diff --git a/lib/src/builder/reactive_builder.dart b/lib/src/builder/reactive_builder.dart index bfa2c11..d48b10f 100644 --- a/lib/src/builder/reactive_builder.dart +++ b/lib/src/builder/reactive_builder.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:reactive_notifier/src/implements/notifier_impl.dart'; +/// Reactive Builder for simple state or direct model state. class ReactiveBuilder extends StatefulWidget { final NotifierImpl notifier; final Widget Function( @@ -103,4 +104,145 @@ class _NoRebuildWrapperState extends State<_NoRebuildWrapper> { Widget build(BuildContext context) => child; } +/// [ReactiveViewModelBuilder] +/// ReactiveViewModelBuilder is a specialized widget for handling ViewModel states +/// It's designed to work specifically with StateNotifierImpl implementations +/// and provides efficient state management and rebuilding mechanisms +/// +class ReactiveViewModelBuilder extends StatefulWidget { + /// [StateNotifierImpl] + /// The notifier should be a StateNotifierImpl that manages the ViewModel's data + /// T represents the data type being managed, not the ViewModel class itself + /// + final StateNotifierImpl notifier; + + /// Builder function that creates the widget tree + /// Takes two parameters: + /// - state: Current state of type T + /// - keep: Function to prevent unnecessary rebuilds of child widgets + /// + final Widget Function( + T state, + Widget Function(Widget child) keep, + ) builder; + + const ReactiveViewModelBuilder({ + super.key, + required this.notifier, + required this.builder, + }); + + @override + State> createState() => + _ReactiveBuilderStateViewModel(); +} + +/// State class for ReactiveViewModelBuilder +/// Handles state management and widget rebuilding +class _ReactiveBuilderStateViewModel + extends State> { + /// Current value of the state + late T value; + + /// Cache for widgets that shouldn't rebuild + final Map _noRebuildWidgets = {}; + + /// Timer for debouncing updates + Timer? debounceTimer; + + @override + void initState() { + super.initState(); + // Initialize with current data from notifier + value = widget.notifier.data; + // Subscribe to changes + widget.notifier.addListener(_valueChanged); + } + + @override + void didUpdateWidget(ReactiveViewModelBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + // Handle notifier changes by updating subscriptions + if (oldWidget.notifier != widget.notifier) { + oldWidget.notifier.removeListener(_valueChanged); + value = widget.notifier.data; + widget.notifier.addListener(_valueChanged); + } + } + + @override + void dispose() { + // Cleanup subscriptions and timer + widget.notifier.removeListener(_valueChanged); + debounceTimer?.cancel(); + super.dispose(); + } + + /// Handles state changes from the notifier + /// Implements debouncing to prevent too frequent updates + void _valueChanged() { + // Cancel existing debounce timer + debounceTimer?.cancel(); + + // Debounce updates with 100ms delay + if (!isTesting) { + debounceTimer = Timer(const Duration(milliseconds: 100), () { + setState(() { + value = widget.notifier.data; + }); + }); + } else { + // Immediate update during testing + setState(() { + value = widget.notifier.data; + }); + } + } + + /// Creates or retrieves a cached widget that shouldn't rebuild + Widget _noRebuild(Widget keep) { + final key = keep.hashCode.toString(); + if (!_noRebuildWidgets.containsKey(key)) { + _noRebuildWidgets[key] = _NoRebuildWrapperViewModel(builder: keep); + } + return _noRebuildWidgets[key]!; + } + + @override + Widget build(BuildContext context) { + return widget.builder(value, _noRebuild); + } +} + +/// Widget wrapper that prevents rebuilds of its children +/// Used by the _noRebuild function to optimize performance +class _NoRebuildWrapperViewModel extends StatefulWidget { + /// The widget to be wrapped and prevented from rebuilding + final Widget builder; + + const _NoRebuildWrapperViewModel({required this.builder}); + + @override + _NoRebuildWrapperStateViewModel createState() => + _NoRebuildWrapperStateViewModel(); +} + +/// State for _NoRebuildWrapperViewModel +/// Maintains a single instance of the child widget +class _NoRebuildWrapperStateViewModel + extends State<_NoRebuildWrapperViewModel> { + /// Cached instance of the child widget + late Widget child; + + @override + void initState() { + super.initState(); + // Store the initial widget + child = widget.builder; + } + + @override + Widget build(BuildContext context) => child; +} + bool get isTesting => const bool.fromEnvironment('dart.vm.product') == true; diff --git a/lib/src/implements/notifier_impl.dart b/lib/src/implements/notifier_impl.dart index a05ee68..d77965d 100644 --- a/lib/src/implements/notifier_impl.dart +++ b/lib/src/implements/notifier_impl.dart @@ -98,16 +98,6 @@ abstract class StateNotifierImpl extends ChangeNotifier { @override String toString() => '${describeIdentity(this)}($data)'; - @protected - @override - void addListener(VoidCallback listener) { - super.addListener(listener); - } - - @protected - @override - void removeListener(VoidCallback listener) => super.removeListener(listener); - @protected @override void dispose() => super.dispose(); diff --git a/lib/src/reactive_notifier.dart b/lib/src/reactive_notifier.dart index 8498da5..2ffe0e6 100644 --- a/lib/src/reactive_notifier.dart +++ b/lib/src/reactive_notifier.dart @@ -1,7 +1,9 @@ import 'dart:developer'; -import 'implements/notifier_impl.dart'; + import 'package:flutter/foundation.dart'; +import 'implements/notifier_impl.dart'; + /// A reactive state management solution that supports: /// - Singleton instances with key-based identity /// - Related states management diff --git a/lib/src/viewmodel/viewmodel_impl.dart b/lib/src/viewmodel/viewmodel_impl.dart index c5a58ad..e761c08 100644 --- a/lib/src/viewmodel/viewmodel_impl.dart +++ b/lib/src/viewmodel/viewmodel_impl.dart @@ -2,9 +2,9 @@ import 'dart:developer'; import 'package:flutter/foundation.dart'; import 'package:reactive_notifier/src/tracker/state_tracker.dart'; -import '../implements/repository_impl.dart'; import '../implements/notifier_impl.dart'; +import '../implements/repository_impl.dart'; /// [ViewModelImpl] /// Base ViewModel implementation with repository integration for domain logic and data handling. diff --git a/pubspec.yaml b/pubspec.yaml index f129d30..628374e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: reactive_notifier description: A Dart library for managing reactive state efficiently, supporting multiples related state. -version: 2.4.2 +version: 2.5.0 homepage: https://github.com/JhonaCodes/reactive_notifier.git environment: @@ -22,33 +22,3 @@ dev_dependencies: flutter: uses-material-design: true - # To add assets to your package, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - # - # For details regarding assets in packages, see - # https://flutter.dev/to/asset-from-package - # - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # To add custom fonts to your package, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts in packages, see - # https://flutter.dev/to/font-from-package diff --git a/test/reactive_builder_viewmodel_test.dart b/test/reactive_builder_viewmodel_test.dart new file mode 100644 index 0000000..ac29a76 --- /dev/null +++ b/test/reactive_builder_viewmodel_test.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reactive_notifier/reactive_notifier.dart'; +import 'package:reactive_notifier/src/implements/notifier_impl.dart'; + +// Mock de un StateNotifierImpl simple para testing +class MockStateNotifier extends StateNotifierImpl { + MockStateNotifier() : super('initial'); + + void updateValue(String newValue) { + updateState(newValue); + } +} + +void main() { + group('ReactiveViewModelBuilder Tests', () { + late MockStateNotifier mockNotifier; + + setUp(() { + mockNotifier = MockStateNotifier(); + }); + + testWidgets('should build with initial state', (WidgetTester tester) async { + // Arrange + String? capturedState; + + await tester.pumpWidget( + MaterialApp( + home: ReactiveViewModelBuilder( + notifier: mockNotifier, + builder: (state, keep) { + capturedState = state; + return Text(state); + }, + ), + ), + ); + + // Assert + expect(find.text('initial'), findsOneWidget); + expect(capturedState, equals('initial')); + }); + + testWidgets('should update when state changes', + (WidgetTester tester) async { + // Arrange + await tester.pumpWidget( + MaterialApp( + home: ReactiveViewModelBuilder( + notifier: mockNotifier, + builder: (state, keep) => Text(state), + ), + ), + ); + + // Act + mockNotifier.updateValue('updated'); + // Esperamos el debounce + await tester.pump(const Duration(milliseconds: 100)); + + // Assert + expect(find.text('updated'), findsOneWidget); + }); + + testWidgets('should not rebuild kept widgets', (WidgetTester tester) async { + // Arrange + int buildCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: ReactiveViewModelBuilder( + notifier: mockNotifier, + builder: (state, keep) { + return Column( + children: [ + Text(state), + keep( + Builder( + builder: (context) { + buildCount++; + return const Text('Kept Widget'); + }, + ), + ), + ], + ); + }, + ), + ), + ); + + // Initial build count + final initialBuildCount = buildCount; + + // Act + mockNotifier.updateValue('updated'); + await tester.pump(const Duration(milliseconds: 100)); + + // Assert + expect(buildCount, equals(initialBuildCount)); + expect(find.text('Kept Widget'), findsOneWidget); + }); + + testWidgets('should handle rapid updates with debouncing', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: ReactiveViewModelBuilder( + notifier: mockNotifier, + builder: (state, keep) => Text(state), + ), + ), + ); + + // Act - múltiples actualizaciones rápidas + mockNotifier.updateValue('update1'); + mockNotifier.updateValue('update2'); + mockNotifier.updateValue('update3'); + + // Esperamos menos que el tiempo de debounce + await tester.pump(const Duration(milliseconds: 50)); + + // No debería haber actualizado aún + expect(find.text('initial'), findsOneWidget); + + // Esperamos que complete el debounce + await tester.pump(const Duration(milliseconds: 50)); + + // Assert - debería tener solo la última actualización + expect(find.text('update3'), findsOneWidget); + }); + + testWidgets('should cleanup properly when disposed', + (WidgetTester tester) async { + // Arrange + final key = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: ReactiveViewModelBuilder( + key: key, + notifier: mockNotifier, + builder: (state, keep) => Text(state), + ), + ), + ); + + // Act + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + + // Intentamos actualizar después de dispose + mockNotifier.updateValue('after dispose'); + await tester.pump(const Duration(milliseconds: 100)); + + // No debería causar errores + expect(tester.takeException(), isNull); + }); + + testWidgets('should handle notifier changes', (WidgetTester tester) async { + // Arrange + final newNotifier = MockStateNotifier(); + + await tester.pumpWidget( + MaterialApp( + home: ReactiveViewModelBuilder( + notifier: mockNotifier, + builder: (state, keep) => Text(state), + ), + ), + ); + + // Act - cambiar el notifier + await tester.pumpWidget( + MaterialApp( + home: ReactiveViewModelBuilder( + notifier: newNotifier, + builder: (state, keep) => Text(state), + ), + ), + ); + + // El nuevo notifier debería funcionar + newNotifier.updateValue('new notifier value'); + await tester.pump(const Duration(milliseconds: 100)); + + // Assert + expect(find.text('new notifier value'), findsOneWidget); + }); + }); +}