diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c03df1..7ab7171 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: - name: Setup Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '3.24.5' + flutter-version: '3.27.1' channel: 'stable' - name: Install dependencies diff --git a/.gitignore b/.gitignore index ac5aa98..0ec8206 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ migrate_working_dir/ **/doc/api/ .dart_tool/ build/ +/example/app/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 99b17fc..291c2d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +# 2.4.0 + +### Breaking Changes 🚨 + +- Introducing `transformState` function for model editing, allowing state modifications at any nesting level. This function supports implementations like `copyWith`, enabling selective value updates in your models. + +- Simplified state management: unified `notifier` and VM into a single approach using `ReactiveBuilder`, `ReactiveAsync`, and `ReactiveStream`. Access functions directly through notifier reference (e.g., `instance.notifier.replaceData(...)`). Access `ReactiveAsync` data via `notifier.data`. + +- Removed `ValueNotifier` value dependency, eliminating nested state update issues (previously `instance.value.value`, now `instance.data`). + +- Protected internal builder functions for improved encapsulation. + +- Maintained compatibility with `ListenableBuilder` for `ReactiveNotifier`. + +- Removed `context` dependency from builder as `ReactiveNotifier` doesn't require it. + +### Best Practices +- Recommend using mixins to store related Notifiers, avoiding global variables and maintaining proper context encapsulation. + + # 2.3.1 * Update documentation. * Protected value for NotifierImpl. @@ -12,7 +32,7 @@ * For ViewModels/Complex States: ```dart ReactiveBuilder( - valueListenable: stateConnection.value, + notifier: stateConnection.value, builder: (context, state, keep) => YourWidget() ) ``` diff --git a/README.md b/README.md index c039b28..9ee67cc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ReactiveNotifier -A flexible, elegant, and secure tool for state management in Flutter. Designed to easily integrate with architectural patterns like MVVM, it guarantees full independence from BuildContext and is suitable for projects of any scale. +A flexible, elegant, and secure tool for state management in Flutter. Designed with fine-grained state control in mind, it easily integrates with architectural patterns like MVVM, guarantees full independence from BuildContext, and is suitable for projects of any scale. ![reactive_notifier](https://github.com/user-attachments/assets/ca97c7e6-a254-4b19-b58d-fd07206ff6ee) @@ -34,291 +34,138 @@ Add this to your package's `pubspec.yaml` file: ```yaml dependencies: - reactive_notifier: ^2.3.1 + reactive_notifier: ^2.4.0 ``` ## Quick Start -### **Basic Usage with ReactiveNotifier and `ReactiveBuilder.notifier`** +### **Usage with ReactiveNotifier -#### **Example: Handling a Simple State** - -This example demonstrates how to manage a basic state that stores a `String`. It shows how to declare the state, create a widget to display it, and update its value: +Learn how to implement ReactiveNotifier across different use cases - from basic data types (`String`, `bool`) to complex classes (`ViewModels`). These examples showcase global state management patterns that maintain accessibility across your application. +#### With Classes, Viewmodel, etc. ```dart -import 'package:flutter/material.dart'; -import 'package:reactive_notifier/reactive_notifier.dart'; -// Declare a simple state -final messageState = ReactiveNotifier(() => "Hello, world!"); +/// It is recommended to use mixin to save your notifiers, from a static variable. +/// +mixin ConnectionService{ + static final ReactiveNotifier instance = ReactiveNotifier(() => ConnectionManager()); +} + +class ConnectionStateWidget extends StatelessWidget { + const ConnectionStateWidget({super.key}); -class SimpleExample extends StatelessWidget { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: Text("ReactiveNotifier Example")), - body: Center( - child: ReactiveBuilder.notifier( - notifier: messageState, - builder: (context, value, keep) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, + + /// It is recommended to set the data type in the builder. + return ReactiveBuilder( + + notifier: ConnectionService.instance, + + builder: ( service, keep) { + + /// Notifier is used to access your model's data. + final state = service.notifier; + + return Card( + elevation: 4, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 10, children: [ + CircleAvatar( + radius: 30, + backgroundColor: state.color.withValues(alpha: 255 * 0.2), + child: Icon( + state.icon, + color: state.color, + size: 35, + ), + ), + Text( - value, // Display the current state - style: TextStyle(fontSize: 20), + state.message, + style: Theme.of(context).textTheme.titleMedium, ), - SizedBox(height: 20), - keep( - ElevatedButton( - onPressed: () => messageState.updateState("New message!"), - child: Text("Update Message"), + + if (state.isError || state == ConnectionState.disconnected) + keep( + ElevatedButton.icon( + + /// If you don't use notifier, access the functions of your Viewmodel that contains your model. + onPressed: () => service.manualReconnect(), + + icon: const Icon(Icons.refresh), + label: const Text('Retry Connection'), + ), ), - ), + if (state.isSyncing) const LinearProgressIndicator(), ], - ); - }, - ), - ), + ), + ), + ); + }, ); } } ``` ---- - -### **Example Description** - -1. **State Declaration:** - - `ReactiveNotifier` is used to manage a state of type `String`. - - You can use other primitive types such as `int`, `double`, `bool`, `enum`, etc. - -2. **ReactiveBuilder.notifier:** - - Observes the state and automatically updates the UI when its value changes. - - Accepts three parameters in the `builder` method: - - `context`: The widget's context. - - `value`: The current value of the state. - - `keep`: Prevents uncontrolled widget rebuilds. -3. **State Update:** - - The `updateState` method is used to change the state value. - - Whenever the state is updated, the UI dependent on it automatically rebuilds. - -## **1. Model Class Definition** - -We'll create a `MyClass` model with some properties such as `String` and `int`. +#### With simple values. ```dart -class MyClass { - final String name; - final int value; - - MyClass({required this.name, required this.value}); - - // Method to create an empty instance - MyClass.empty() : name = '', value = 0; - // Method to clone and update values - MyClass copyWith({String? name, int? value}) { - return MyClass( - name: name ?? this.name, - value: value ?? this.value, - ); - } +mixin ConnectionService{ + static final ReactiveNotifier instance = ReactiveNotifier(() => "N/A"); } -``` - - -## **2. Create and Update State with `ReactiveNotifier`** - -Now, we'll use `ReactiveNotifier` to manage the state of `MyClass`. We'll start with an empty state and later update it using the `updateState` method. - -```dart -final myReactive = ReactiveNotifier(() => MyClass.empty()); -``` - -Here, `myReactive` is a `ReactiveNotifier` managing the state of `MyClass`. - ---- -## **3. Display and Update State with `ReactiveBuilder.notifier`** - -We'll use `ReactiveBuilder.notifier` to display the current state of `MyClass` and update it when needed. - -```dart import 'package:flutter/material.dart'; import 'package:reactive_notifier/reactive_notifier.dart'; -class MyApp extends StatelessWidget { +// Declare a simple state +class ConnectionStateWidget extends StatelessWidget { + const ConnectionStateWidget({super.key}); + @override Widget build(BuildContext context) { - return MaterialApp( - home: Scaffold( - appBar: AppBar(title: Text("Direct ReactiveNotifier")), - body: Center( - child: ReactiveBuilder.notifier( - notifier: myReactive, - builder: (context, state, keep) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text("Name: ${state.name}"), - Text("Value: ${state.value}"), - SizedBox(height: 20), - ElevatedButton( - onPressed: () { - // Update the state with new values - myReactive.updateState(state.copyWith(name: "New Name", value: 42)); - }, - child: Text("Update State"), - ), - ], - ); - }, - ), - ), - ), + return ReactiveBuilder( + notifier: ConnectionService.instance, + builder: ( state, keep) => Text(state), ); } } -``` - -### **Advantages of This Approach** -- **Simplicity**: This example is straightforward and easy to understand, making it ideal for handling simple states without requiring a `ViewModel` or repository. -- **Reactivity**: With `ReactiveNotifier` and `ReactiveBuilder.notifier`, the UI automatically updates whenever the state changes. -- **Immutability**: `MyClass` follows the immutability pattern, simplifying state management and preventing unexpected side effects. +class OtherWidget extends StatelessWidget { + const OtherWidget({super.key}); -## **Shopping Cart with `ViewModelStateImpl`** - -### **1. Defining the Model** - -The `CartModel` class represents the shopping cart's state. It contains the data and includes a `copyWith` method for creating a new instance with updated values. - -```dart -class CartModel { - final List items; - final double total; - - CartModel({this.items = const [], this.total = 0.0}); - - // Method to clone the model with updated values - CartModel copyWith({List? items, double? total}) { - return CartModel( - items: items ?? this.items, - total: total ?? this.total, + @override + Widget build(BuildContext context) { + return Column( + children: [ + OutlinedButton(onPressed: (){ + ConnectionService.instance.updateState("New value"); + }, child: Text('Edit String')) + ], ); } } -``` -### **2. ViewModel for Managing Cart Logic** +class OtherViewModel{ -The `ViewModel` contains the logic for modifying the cart's state. Instead of using `state.copyWith`, use `value.copyWith` to access the current state and update it. + void otherFunction(){ + ///Come code..... -```dart -class CartViewModel extends ViewModelStateImpl { - CartViewModel() : super(CartModel()); - - // Function to add a product to the cart and update the total - void addProduct(String item, double price) { - final updatedItems = List.from(value.items)..add(item); - final updatedTotal = value.total + price; - updateState(value.copyWith(items: updatedItems, total: updatedTotal)); - } - - // Function to empty the cart - void clearCart() { - updateState(CartModel()); + ConnectionService.instance.updateState("Value from other viewmodel"); } } -``` - ---- - -### **3. Creating the ViewModel Instance** - -Create the `ViewModel` instance that will manage the cart's state. - -```dart -final cartViewModel = ReactiveNotifier(() => CartViewModel()); -``` - ---- - -### **4. Widget to Display Cart State** -Finally, create a widget that observes the cart's state and updates the UI whenever necessary. - -```dart -import 'package:flutter/material.dart'; -import 'package:reactive_notifier/reactive_notifier.dart'; - -class CartScreen extends StatelessWidget { - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: Text("Shopping Cart")), - body: Center( - child: ReactiveBuilder( - valueListenable: cartViewModel.value, - builder: (context, viewModel, keep) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text("Products in Cart:"), - ...viewModel.items.map((item) => Text(item)).toList(), - SizedBox(height: 20), - Text("Total: \$${viewModel.total.toStringAsFixed(2)}"), - SizedBox(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - keep( - ElevatedButton( - onPressed: () { - // Add a new product - cartViewModel.value.addProduct("Product A", 19.99); - }, - child: Text("Add Product A"), - ), - ), - SizedBox(width: 10), - keep( - ElevatedButton( - onPressed: () { - // Clear the cart - cartViewModel.value.clearCart(); - }, - child: Text("Clear Cart"), - ), - ), - ], - ), - ], - ); - }, - ), - ), - ); - } -} +/// This example above applies to any Reactive Notifier, no matter how complex, it can be used from anywhere without the need for a reference or handler. ``` - -### **Explanation** - -1. **Model (`CartModel`)**: - - `CartModel` is a class that holds the shopping cart data (`items` and `total`). - - The `copyWith` method allows creating a new instance of the model with modified values while maintaining immutability. - -2. **ViewModel (`CartViewModel`)**: - - `CartViewModel` extends `ViewModelStateImpl` and handles business logic. - - Instead of `state.copyWith`, `value.copyWith` is used to access and update the current state. - - The methods `addProduct` and `clearCart` update the state using `updateState`. - -3. **UI with `ReactiveBuilder`**: - - `ReactiveBuilder` observes the `ViewModel` state and rebuilds the UI when the state changes. - - The `keep` function is used to prevent unnecessary button rebuilds, improving performance. +Both simple and complex values can be modified from anywhere in the application without modifying the structure of your widget. +You also don't need to instantiate variables in your widget build, you just call the mixin directly where you want to use it, this helps with less coupling, being able to replace all functions from the mixin and not fight with extensive migrations. --- @@ -337,44 +184,16 @@ import 'package:reactive_notifier/reactive_notifier.dart'; class CartRepository extends RepositoryImpl { // We simulate the loading of a shopping cart - Future fetchData() async { await Future.delayed(Duration(seconds: 2)); return CartModel( - items: ['Producto A', 'Producto B'], + items: ['Product A', 'Product B'], total: 39.98, ); } + + /// More functions ..... - // Method to add a product to the cart - Future agregarProducto(CartModel carrito, String item, double price) async { - await Future.delayed(Duration(seconds: 1)); - carrito.items.add(item); - carrito.total += price; - } -} -``` - ---- - -## **2. Cart Model (`CartModel`)** - -The model remains the same: - -```dart -class CartModel { - final List items; - final double total; - - CartModel({this.items = const [], this.total = 0.0}); - - // Method to clone the model with new values - CartModel copyWith({List? items, double? total}) { - return CartModel( - items: items ?? this.items, - total: total ?? this.total, - ); - } } ``` @@ -391,26 +210,38 @@ class CartViewModel extends ViewModelImpl { CartViewModel(this.repository) : super(CartModel()); // Function to load the cart from the repository - Future cargarCarrito() async { + Future loadShoppingCart() async { try { // We get the cart from the repository - final carrito = await repository.fetchData(); - setState(carrito); // We update the status with the cart loaded + final shoppingCart = await repository.fetchData(); + updateState(carrito); // We update the status with the cart loaded } catch (e) { // Error handling - print("Error al cargar el carrito: $e"); + print("Error: $e"); } } // Function to add a product to the cart - Future agregarProducto(String item, double price) async { + Future addProduct(String item, double price) async { try { - await repository.agregarProducto(value, item, price); + + await repository.addProduct(value, item, price); + // We update the status after adding the product - updateState(value.copyWith(items: value.items, total: value.total)); + updateState(notifier.copyWith(items: value.items, total: value.total)); + + // Or + transformState((state) => state.copyWith(items: value.items, total: value.total)); + + // Or + updateState(yourModelWithData); + } catch (e) { + // Error handling - print("Error al agregar el producto: $e"); + print("Error $e"); + + } } } @@ -435,100 +266,69 @@ 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 -class CartScreen extends StatelessWidget { - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: Text("Carrito de Compras")), - body: Center( - child: ReactiveBuilder( - valueListenable: cartViewModel.value, - builder: (context, viewModel, keep) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (viewModel.items.isEmpty) - keep(Text("Loading cart...")), - if (viewModel.items.isNotEmpty) ...[ - keep(Text("Products in cart:")), - ...viewModel.items.map((item) => Text(item)).toList(), - keep(const SizedBox(height: 20)), - Text("Total: \$${viewModel.total.toStringAsFixed(2)}"), - keep(const SizedBox(height: 20)), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - keep( - ElevatedButton( - onPressed: () { - // Add a new product - cartViewModel.value.agregarProducto("Producto C", 29.99); - }, - child: Text("Agregar Producto C"), - ), - ), - keep(const SizedBox(width: 10)), - keep( - ElevatedButton( - onPressed: () { - // Empty cart - cartViewModel.value.setState(CartModel()); - }, - child: Text("Vaciar Carrito"), - ), - ), - ], - ), - ], - ], - ); - }, - ), - ), +ReactiveBuilder( + notifier: cartViewModel, + builder: ( viewModel, keep) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (viewModel.data.isEmpty) + keep(Text("Loading cart...")), + if (viewModel.data.isNotEmpty) ...[ + keep(Text("Products in cart:")), + ...viewModel.data.map((item) => Text(item)).toList(), + keep(const SizedBox(height: 20)), + Text("Total: \$${viewModel.total.toStringAsFixed(2)}"), + keep(const SizedBox(height: 20)), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + keep( + ElevatedButton( + onPressed: () { + // Add a new product + cartViewModel.notifier.agregarProducto("Producto C", 29.99); + }, + child: Text("Agregar Producto C"), + ), + ), + keep(const SizedBox(width: 10)), + keep( + ElevatedButton( + onPressed: () { + // Empty cart + cartViewModel.notifier.myCleaningCarFunction(); + + }, + child: Text("Vaciar Carrito"), + ), + ), + ], + ), + ], + ], ); - } -} - + }, +) ``` -### **Explanation of the Example** - -1. **Repository (`CartRepository`)**: -- The repository extends `RepositoryImpl`, allowing you to interact with the cart data model. -- The `fetchData` function simulates fetching the cart data, and `addProduct` simulates adding products to the cart. - -2. **Model (`CartModel`)**: -- `CartModel` contains the cart data (items and total). -- The `copyWith` method is used to create a new instance with modified values, maintaining immutability. - -3. **`ViewModelImpl` (`CartViewModel`)**: -- `CartViewModel` extends `ViewModelImpl`, allowing you to handle the cart business logic. -- The `loadCart` and `addProduct` methods interact with the repository and update the state of the cart. - -4. **UI with `ReactiveBuilder`**: -- `ReactiveBuilder` observes the state of the `ViewModel` and updates the UI automatically when the state changes. -- `keep` is used to avoid unnecessary button rebuilds. - - ---- - +# ⚑️ **Essential: `ReactiveNotifier` Core Concepts** ## **Documentation for `related` in `ReactiveNotifier`** The `related` attribute in `ReactiveNotifier` allows you to efficiently manage interdependent states. They can be used in different ways depending on the structure and complexity of the state you need to handle. -### **Types of `related` Usage** - -1. **Direct Relationship between Simple Notifiers** -- This is the case where you have multiple independent `ReactiveNotifier`s (of simple type like `int`, `String`, `bool`, etc.) and you want any change in one of these notifiers to trigger an update in a `ReactiveBuilder` that is watched by a combined `ReactiveNotifier`. +### **Using `related` in ReactiveNotifier** -2. **Relationship between a Main `ReactiveNotifier` and Other Complementary Notifiers** -- In this approach, a main `ReactiveNotifier` handles a complex class (e.g. `UserInfo`), and other notifiers complement this state, such as `Settings`. The companion states are managed separately, but are related to the main state via `related`. Changes to any of the related notifiers cause the `ReactiveBuilder` to be updated. +**Establishing Relationships Between Notifiers** +- `ReactiveNotifier` can manage any type of data (simple or complex). Through the `related` property, you can establish connections between different notifiers, where changes in any related notifier will trigger updates in the `ReactiveBuilder` that's watching them. +**Example Scenario: Managing Connected States** +- A primary notifier might handle a complex `UserInfo` class, while other notifiers manage related states like `Settings` or `Preferences`. Using `related`, any changes in these interconnected states will trigger the appropriate UI updates through `ReactiveBuilder`. --- -### **1. Direct Relationship between Simple Notifiers** +### **Direct Relationship between Simple Notifiers** In this approach, you have several simple `ReactiveNotifier`s, and you use them together to notify state changes when any of these notifiers changes. The `ReactiveNotifier`s are related to each other using the `related` attribute, and you see a combined `ReactiveBuilder`. @@ -550,17 +350,15 @@ final combinedNotifier = ReactiveNotifier( - **Explanation**: Here, `combinedNotifier` is a `ReactiveNotifier` that updates when any of the three notifiers (`timeHoursNotifier`, `routeNotifier`, `statusNotifier`) changes. This is useful when you have several simple states and you want them all to be connected to trigger an update in the UI together. -#### **Using with `ReactiveBuilder`** - ```dart -ReactiveBuilder.notifier( +ReactiveBuilder( notifier: combinedNotifier, - builder: (context, _, keep) { + builder: (state, keep) { return Column( children: [ - Text("Horas: ${timeHoursNotifier.value}"), - Text("Ruta: ${routeNotifier.value}"), - Text("Estado: ${statusNotifier.value ? 'Activo' : 'Inactivo'}"), + Text("Hours: ${timeHoursNotifier.value}"), + Text("Route: ${routeNotifier.value}"), + Text("State: ${statusNotifier.value ? 'Active' : 'Inactive'}"), ], ); }, @@ -572,7 +370,7 @@ ReactiveBuilder.notifier( --- -### **2. Relationship between a Main `ReactiveNotifier` and Other Complementary Notifiers** +### ** Relationship between a Main `ReactiveNotifier` and Other Complementary Notifiers** In this approach, you have a main `ReactiveNotifier` that handles a more complex class, such as a `UserInfo` object, and other complementary `ReactiveNotifier`s are related through `related`. These complementary notifiers do not need to be declared inside the main object class, but are integrated with it through the `related` attribute. @@ -613,17 +411,15 @@ final userStateNotifier = ReactiveNotifier( - **Explanation**: In this example, `userStateNotifier` is the main `ReactiveNotifier` that handles the state of `UserInfo`. `settingsNotifier` and `notificationsEnabledNotifier` are companion notifiers that handle user settings such as dark mode and enabling notifications. While they are not declared within `UserInfo`, they are related to it via `related`. -#### **Using with `ReactiveBuilder`** - ```dart ReactiveBuilder( - valueListenable: userStateNotifier.value, - builder: (context, userInfo, keep) { + notifier: userStateNotifier, + builder: ( userInfo, keep) { return Column( children: [ - Text("Usuario: ${userInfo.name}, Edad: ${userInfo.age}"), - Text("ConfiguraciΓ³n: ${settingsNotifier.value}"), - Text("Notificaciones: ${notificationsEnabledNotifier.value ? 'Habilitadas' : 'Deshabilitadas'}"), + Text("User: ${userInfo.name}, Age: ${userInfo.age}"), + Text("Configuration: ${settingsNotifier.notifier}"), + Text("Notifications: ${notificationsEnabledNotifier.notifier ? 'Active' : 'Inactive'}"), ], ); }, @@ -636,9 +432,9 @@ ReactiveBuilder( #### **Usage with `ReactiveBuilder.notifier`** ```dart -ReactiveBuilder.notifier( +ReactiveBuilder( notifier: userStateNotifier, - builder: (context, userInfo, keep) { + builder: (userInfo, keep) { return Column( children: [ ElevatedButton( @@ -652,16 +448,12 @@ ReactiveBuilder.notifier( }, ); ``` - -- **Explanation**: - We use `ReactiveBuilder.notifier` to directly observe and update the `userStateNotifier`. When the user's name or any other value changes, it is automatically updated in the UI. - --- ### **Advantages of Using `related` in `ReactiveNotifier`** 1. **Flexibility**: - You can relate simple and complex notifiers without the need to involve additional classes. This is useful for handling states that depend on multiple values ​​without overcomplicating the structure. + You can relate simple and complex notifiers without the need to involve additional classes. This is useful for handling states that depend on multiple values without overcomplicating the structure. 2. **Optimization**: When related notifiers change, the UI is automatically updated without the need to manually manage dependencies. This streamlines the workflow and improves the performance of the application. @@ -713,12 +505,12 @@ class AppDashboard extends StatelessWidget { @override Widget build(BuildContext context) { return ReactiveBuilder( - valueListenable: appState, - builder: (context, state, keep) { + notifier: appState, + builder: (state, keep) { - final user = userState.value; - final cart = cartState.value; - final settings = settingsState.value; + final user = userState.notifier.data; + final cart = cartState.notifier.data; + final settings = settingsState.notifier.data; return Column( children: [ @@ -735,8 +527,8 @@ class AppDashboard extends StatelessWidget { ``` - **Explanation**: -- Here, we directly access the values ​​of `userState`, `cartState`, and `settingsState` using `.value`. -- **Pros**: It's a quick and straightforward way to access the values ​​if you don't need to perform any extra logic on them. +- Here, we directly access the values of `userState`, `cartState`, and `settingsState` using `.notifier.data`. +- **Pros**: It's a quick and straightforward way to access the values if you don't need to perform any extra logic on them. - **Cons**: If you need to access a specific value of a related `ReactiveNotifier` and it's not directly in the `builder`, you might need something more organized, like using `keyNotifier` or the `from()` method. --- @@ -752,8 +544,8 @@ class AppDashboard extends StatelessWidget { @override Widget build(BuildContext context) { return ReactiveBuilder( - valueListenable: appState, - builder: (context, state, keep) { + notifier: appState, + builder: (state, keep) { final user = appState.from(); final cart = appState.from(); @@ -782,7 +574,7 @@ class AppDashboard extends StatelessWidget { ### **3. Using `keyNotifier` to Access Specific Notifiers** -The `keyNotifier` is useful when you want to access a related state that has a unique key within the `related` relationship. This is especially useful when you have multiple notifiers of the same type (for example, multiple `cartState`s) and you need to distinguish between them. +The `keyNotifier` is useful when you want to access a related state that has a unique key within the `related` relationship. This is especially useful when you have multiple notifiers of the same type (for example, multiple `cartState's`) and you need to distinguish between them. #### **Using `keyNotifier`** @@ -791,12 +583,12 @@ class AppDashboard extends StatelessWidget { @override Widget build(BuildContext context) { return ReactiveBuilder( - valueListenable: appState, - builder: (context, state, keep) { + notifier: appState, + builder: (state, keep) { - final user = appState.from(userState.keyNotifier); - final cart = appState.from(cartState.keyNotifier); - final settings = appState.from(settingsState.keyNotifier); + final user = appState.from(userState.keyNotifier); /// Or appState.from(userState.keyNotifier) + final cart = appState.from(cartState.keyNotifier); /// .... + final settings = appState.from(settingsState.keyNotifier); /// .... return Column( children: [ @@ -819,21 +611,6 @@ class AppDashboard extends StatelessWidget { --- -### **Summary of Ways to Access Related States** - -1. **Direct Access to `ReactiveNotifier`**: -- **Simplest way**: `userState.value or final user = userState.value;` -- **Ideal for simple, straightforward states**. - -2. **Using `from()`**: -- **Explicit access to a related state**: `final user = appState.from();` -- **Ideal for handling more complex relationships between notifiers and extracting values ​​from a specific state**. - -3. **Using `keyNotifier`**: -- **Access to a related state with a unique identifier**: `final cart = appState.from(cartState.keyNotifier);` -- **Ideal for handling notifiers of the same type and differentiating between them**. - - ### What to Avoid ```dart @@ -873,11 +650,11 @@ class ProductsScreen extends StatelessWidget { @override Widget build(BuildContext context) { return ReactiveAsyncBuilder>( - viewModel: productViewModel, - buildSuccess: (products) => ProductGrid(products), - buildLoading: () => const LoadingSpinner(), - buildError: (error, stack) => ErrorWidget(error), - buildInitial: () => const InitialView(), + notifier: productViewModel, + onSuccess: (products) => ProductGrid(products), + onLoading: () => const LoadingSpinner(), + onError: (error, stack) => ErrorWidget(error), + onInitial: () => const InitialView(), ); } } @@ -896,12 +673,12 @@ class ChatScreen extends StatelessWidget { @override Widget build(BuildContext context) { return ReactiveStreamBuilder( - streamNotifier: messagesStream, - buildData: (message) => MessageBubble(message), - buildLoading: () => const LoadingIndicator(), - buildError: (error) => ErrorMessage(error), - buildEmpty: () => const NoMessages(), - buildDone: () => const StreamComplete(), + notifier: messagesStream, + onData: (message) => MessageBubble(message), + onLoading: () => const LoadingIndicator(), + onError: (error) => ErrorMessage(error), + onEmpty: () => const NoMessages(), + onDone: () => const StreamComplete(), ); } } diff --git a/example/reactive_notifier_example.dart b/example/reactive_notifier_example.dart index 018e58f..dac1c1a 100644 --- a/example/reactive_notifier_example.dart +++ b/example/reactive_notifier_example.dart @@ -1,143 +1,18 @@ -import 'dart:async'; -import 'dart:math'; - -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide ConnectionState; import 'package:reactive_notifier/reactive_notifier.dart'; -enum ConnectionState { - connected, - disconnected, - connecting, - error, - uploading, - waiting, - offline, - syncError, - syncing, - synced, - pendingSync -} - -extension ConnectionStateX on ConnectionState { - bool get isConnected => this == ConnectionState.connected; - bool get isError => - this == ConnectionState.error || this == ConnectionState.syncError; - bool get isSyncing => - this == ConnectionState.syncing || this == ConnectionState.pendingSync; - - IconData get icon { - return switch (this) { - ConnectionState.connected => Icons.cloud_done, - ConnectionState.disconnected => Icons.cloud_off, - ConnectionState.connecting => Icons.cloud_sync, - ConnectionState.error => Icons.error_outline, - ConnectionState.uploading => Icons.upload, - ConnectionState.waiting => Icons.hourglass_empty, - ConnectionState.offline => Icons.signal_wifi_off, - ConnectionState.syncError => Icons.sync_problem, - ConnectionState.syncing => Icons.sync, - ConnectionState.synced => Icons.sync_alt, - ConnectionState.pendingSync => Icons.pending, - }; - } - - Color get color { - return switch (this) { - ConnectionState.connected => Colors.green, - ConnectionState.synced => Colors.lightGreen, - ConnectionState.uploading || - ConnectionState.syncing || - ConnectionState.connecting => - Colors.blue, - ConnectionState.waiting || ConnectionState.pendingSync => Colors.orange, - _ => Colors.red, - }; - } - - String get message { - return switch (this) { - ConnectionState.connected => 'Connected to server', - ConnectionState.disconnected => 'Connection lost', - ConnectionState.connecting => 'Establishing connection...', - ConnectionState.error => 'Connection error', - ConnectionState.uploading => 'Uploading data...', - ConnectionState.waiting => 'Waiting for connection', - ConnectionState.offline => 'Device offline', - ConnectionState.syncError => 'Sync failed', - ConnectionState.syncing => 'Syncing data...', - ConnectionState.synced => 'Data synchronized', - ConnectionState.pendingSync => 'Pending sync', - }; - } -} - -class ConnectionManager extends ViewModelStateImpl { - ConnectionManager() : super(ConnectionState.offline); - - Timer? _reconnectTimer; - bool _isReconnecting = false; - - @override - void init() { - simulateNetworkConditions(); - } - - void simulateNetworkConditions() { - _reconnectTimer?.cancel(); - _reconnectTimer = Timer.periodic(const Duration(seconds: 3), (timer) { - if (_isReconnecting) return; - _simulateStateChange(); - }); - } - - Future _simulateStateChange() async { - _isReconnecting = true; - - try { - updateState(ConnectionState.connecting); - await Future.delayed(const Duration(seconds: 1)); - - if (Random().nextDouble() < 0.7) { - updateState(ConnectionState.connected); - await Future.delayed(const Duration(seconds: 1)); - updateState(ConnectionState.syncing); - await Future.delayed(const Duration(seconds: 1)); - updateState(ConnectionState.synced); - } else { - if (Random().nextBool()) { - updateState(ConnectionState.error); - } else { - updateState(ConnectionState.syncError); - } - } - } finally { - _isReconnecting = false; - } - } - - void manualReconnect() { - if (!_isReconnecting) _simulateStateChange(); - } - - @override - void dispose() { - _reconnectTimer?.cancel(); - super.dispose(); - } -} - -final connectionManager = - ReactiveNotifier(() => ConnectionManager()); +import 'service/connection_service.dart'; +import 'viewmodel/connection_state_viewmodel.dart'; class ConnectionStateWidget extends StatelessWidget { const ConnectionStateWidget({super.key}); @override Widget build(BuildContext context) { - return ReactiveBuilder( - valueListenable: connectionManager.value, - builder: (context, manager, keep) { - final state = manager; + return ReactiveBuilder( + notifier: ConnectionService.instance, + builder: (service, keep) { + final state = service.notifier; return Card( elevation: 4, @@ -148,7 +23,7 @@ class ConnectionStateWidget extends StatelessWidget { children: [ CircleAvatar( radius: 30, - backgroundColor: state.color.withOpacity(0.2), + backgroundColor: state.color.withValues(alpha: 255 * 0.2), child: Icon( state.icon, color: state.color, @@ -164,8 +39,7 @@ class ConnectionStateWidget extends StatelessWidget { if (state.isError || state == ConnectionState.disconnected) keep( ElevatedButton.icon( - onPressed: () => - connectionManager.value.manualReconnect(), + onPressed: () => service.manualReconnect(), icon: const Icon(Icons.refresh), label: const Text('Retry Connection'), ), diff --git a/example/service/connection_service.dart b/example/service/connection_service.dart new file mode 100644 index 0000000..47214f7 --- /dev/null +++ b/example/service/connection_service.dart @@ -0,0 +1,8 @@ +import 'package:reactive_notifier/reactive_notifier.dart'; + +import '../viewmodel/connection_state_viewmodel.dart'; + +mixin ConnectionService { + static final ReactiveNotifier instance = + ReactiveNotifier(() => ConnectionManagerVM()); +} diff --git a/example/viewmodel/connection_state_viewmodel.dart b/example/viewmodel/connection_state_viewmodel.dart new file mode 100644 index 0000000..ad0e007 --- /dev/null +++ b/example/viewmodel/connection_state_viewmodel.dart @@ -0,0 +1,127 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:reactive_notifier/reactive_notifier.dart'; + +class ConnectionManagerVM extends ViewModelStateImpl { + ConnectionManagerVM() : super(ConnectionState.offline); + + Timer? _reconnectTimer; + bool _isReconnecting = false; + + @override + void init() { + simulateNetworkConditions(); + } + + void simulateNetworkConditions() { + _reconnectTimer?.cancel(); + _reconnectTimer = Timer.periodic(const Duration(seconds: 3), (timer) { + if (_isReconnecting) return; + _simulateStateChange(); + }); + } + + Future _simulateStateChange() async { + _isReconnecting = true; + + try { + updateState(ConnectionState.connecting); + await Future.delayed(const Duration(seconds: 1)); + + if (Random().nextDouble() < 0.7) { + updateState(ConnectionState.connected); + await Future.delayed(const Duration(seconds: 1)); + updateState(ConnectionState.syncing); + await Future.delayed(const Duration(seconds: 1)); + updateState(ConnectionState.synced); + } else { + if (Random().nextBool()) { + updateState(ConnectionState.error); + } else { + updateState(ConnectionState.syncError); + } + } + } finally { + _isReconnecting = false; + } + } + + void manualReconnect() { + if (!_isReconnecting) _simulateStateChange(); + } + + @override + void dispose() { + _reconnectTimer?.cancel(); + super.dispose(); + } +} + +enum ConnectionState { + connected, + disconnected, + connecting, + error, + uploading, + waiting, + offline, + syncError, + syncing, + synced, + pendingSync +} + +extension ConnectionStateX on ConnectionState { + bool get isConnected => this == ConnectionState.connected; + bool get isError => + this == ConnectionState.error || this == ConnectionState.syncError; + bool get isSyncing => + this == ConnectionState.syncing || this == ConnectionState.pendingSync; + + IconData get icon { + return switch (this) { + ConnectionState.connected => Icons.cloud_done, + ConnectionState.disconnected => Icons.cloud_off, + ConnectionState.connecting => Icons.cloud_sync, + ConnectionState.error => Icons.error_outline, + ConnectionState.uploading => Icons.upload, + ConnectionState.waiting => Icons.hourglass_empty, + ConnectionState.offline => Icons.signal_wifi_off, + ConnectionState.syncError => Icons.sync_problem, + ConnectionState.syncing => Icons.sync, + ConnectionState.synced => Icons.sync_alt, + ConnectionState.pendingSync => Icons.pending, + }; + } + + Color get color { + return switch (this) { + ConnectionState.connected => Colors.green, + ConnectionState.synced => Colors.lightGreen, + ConnectionState.uploading || + ConnectionState.syncing || + ConnectionState.connecting => + Colors.blue, + ConnectionState.waiting || ConnectionState.pendingSync => Colors.orange, + _ => Colors.red, + }; + } + + String get message { + return switch (this) { + ConnectionState.connected => 'Connected to server', + ConnectionState.disconnected => 'Connection lost', + ConnectionState.connecting => 'Establishing connection...', + ConnectionState.error => 'Connection error', + ConnectionState.uploading => 'Uploading data...', + ConnectionState.waiting => 'Waiting for connection', + ConnectionState.offline => 'Device offline', + ConnectionState.syncError => 'Sync failed', + ConnectionState.syncing => 'Syncing data...', + ConnectionState.synced => 'Data synchronized', + ConnectionState.pendingSync => 'Pending sync', + }; + } +} diff --git a/lib/reactive_notifier.dart b/lib/reactive_notifier.dart index cb59aa6..c5c1da9 100644 --- a/lib/reactive_notifier.dart +++ b/lib/reactive_notifier.dart @@ -21,7 +21,6 @@ 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/async_viewmodel_impl.dart'; export 'package:reactive_notifier/src/viewmodel/viewmodel_impl.dart'; /// Export RepositoryImpl diff --git a/lib/src/builder/reactive_async_builder.dart b/lib/src/builder/reactive_async_builder.dart index 1dbd21c..38d42d9 100644 --- a/lib/src/builder/reactive_async_builder.dart +++ b/lib/src/builder/reactive_async_builder.dart @@ -1,38 +1,145 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:reactive_notifier/src/viewmodel/async_viewmodel_impl.dart'; +import 'package:reactive_notifier/src/handler/async_state.dart'; class ReactiveAsyncBuilder extends StatelessWidget { - final AsyncViewModelImpl viewModel; - final Widget Function(T data) buildSuccess; - final Widget Function()? buildLoading; - final Widget Function(Object? error, StackTrace? stackTrace)? buildError; - final Widget Function()? buildInitial; + final AsyncViewModelImpl notifier; + final Widget Function(T data) onSuccess; + final Widget Function()? onLoading; + final Widget Function(Object? error, StackTrace? stackTrace)? onError; + final Widget Function()? onInitial; const ReactiveAsyncBuilder({ super.key, - required this.viewModel, - required this.buildSuccess, - this.buildLoading, - this.buildError, - this.buildInitial, + required this.notifier, + required this.onSuccess, + this.onLoading, + this.onError, + this.onInitial, }); @override Widget build(BuildContext context) { return AnimatedBuilder( - animation: viewModel, + animation: notifier, builder: (context, _) { - return viewModel.value.when( - initial: () => buildInitial?.call() ?? const SizedBox.shrink(), + return notifier.when( + initial: () => onInitial?.call() ?? const SizedBox.shrink(), loading: () => - buildLoading?.call() ?? + onLoading?.call() ?? const Center(child: CircularProgressIndicator.adaptive()), - success: (data) => buildSuccess(data), - error: (error, stackTrace) => - buildError?.call(error, stackTrace) ?? - Center(child: Text('Error: $error')), + success: (data) => onSuccess(data), + error: (error, stackTrace) => onError != null + ? onError!(error, stackTrace) + : Center(child: Text('Error: $error')), ); }, ); } } + +/// Base ViewModel implementation for handling asynchronous operations with state management. +/// +/// Provides a standardized way to handle loading, success, and error states for async data. + +/// Base ViewModel implementation for handling asynchronous operations with state management. +@protected +abstract class AsyncViewModelImpl extends ChangeNotifier { + late AsyncState _state; + late bool loadOnInit; + + AsyncViewModelImpl(this._state, {this.loadOnInit = true}) : super() { + if (kFlutterMemoryAllocationsEnabled) { + ChangeNotifier.maybeDispatchObjectCreation(this); + } + + if (loadOnInit) { + _initializeAsync(); + } + } + + /// Internal initialization method that properly handles async initialization + Future _initializeAsync() async { + try { + await reload(); + } catch (error, stackTrace) { + errorState(error, stackTrace); + } + } + + /// Public method to reload data + @protected + Future reload() async { + loadOnInit = false; + if (_state.isLoading) return; + + loadingState(); + try { + final result = await loadData(); + updateState(result); + } catch (error, stackTrace) { + errorState(error, stackTrace); + } + } + + /// Override this method to provide the async data loading logic + @protected + Future loadData(); + + /// Update data directly + + @protected + void updateState(T data) { + _state = AsyncState.success(data); + notifyListeners(); + } + + @protected + void loadingState() { + _state = AsyncState.loading(); + notifyListeners(); + } + + /// Set error state + + @protected + void errorState(Object error, [StackTrace? stackTrace]) { + _state = AsyncState.error(error, stackTrace); + notifyListeners(); + } + + @protected + void cleanState() { + _state = AsyncState.initial(); + } + + /// Check if any operation is in progress + bool get isLoading => _state.isLoading; + + /// Get current error if any + Object? get error => _state.error; + + /// Get current stack trace if there's an error + StackTrace? get stackTrace => _state.stackTrace; + + /// Check if the state contains valid data + bool get hasData => _state.isSuccess; + + /// Get the current data (may be null if not in success state) + T? get data => _state.isSuccess ? _state.data : null; + + @protected + R when({ + required R Function() initial, + required R Function() loading, + required R Function(T data) success, + required R Function(Object? err, StackTrace? stackTrace) error, + }) { + return _state.when( + initial: initial, + loading: loading, + success: (infoData) => success(infoData), + error: error, + ); + } +} diff --git a/lib/src/builder/reactive_builder.dart b/lib/src/builder/reactive_builder.dart index d64904d..bfa2c11 100644 --- a/lib/src/builder/reactive_builder.dart +++ b/lib/src/builder/reactive_builder.dart @@ -1,38 +1,21 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:reactive_notifier/src/reactive_notifier.dart'; +import 'package:reactive_notifier/src/implements/notifier_impl.dart'; class ReactiveBuilder extends StatefulWidget { - final ValueListenable valueListenable; + final NotifierImpl notifier; final Widget Function( - BuildContext context, - T value, + T state, Widget Function(Widget child) keep, ) builder; const ReactiveBuilder({ super.key, - required this.valueListenable, + required this.notifier, required this.builder, }); - /// Recommended constructor for handling simple states. - const ReactiveBuilder.notifier({ - Key? key, - required ReactiveNotifier notifier, - required Widget Function( - BuildContext context, - T value, - Widget Function(Widget child) keep, - ) builder, - }) : this( - key: key, - valueListenable: notifier, - builder: builder, - ); - @override State> createState() => _ReactiveBuilderState(); } @@ -45,23 +28,23 @@ class _ReactiveBuilderState extends State> { @override void initState() { super.initState(); - value = widget.valueListenable.value; - widget.valueListenable.addListener(_valueChanged); + value = widget.notifier.notifier; + widget.notifier.addListener(_valueChanged); } @override void didUpdateWidget(ReactiveBuilder oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.valueListenable != widget.valueListenable) { - oldWidget.valueListenable.removeListener(_valueChanged); - value = widget.valueListenable.value; - widget.valueListenable.addListener(_valueChanged); + if (oldWidget.notifier != widget.notifier) { + oldWidget.notifier.removeListener(_valueChanged); + value = widget.notifier.notifier; + widget.notifier.addListener(_valueChanged); } } @override void dispose() { - widget.valueListenable.removeListener(_valueChanged); + widget.notifier.removeListener(_valueChanged); debounceTimer?.cancel(); super.dispose(); } @@ -74,12 +57,12 @@ class _ReactiveBuilderState extends State> { if (!isTesting) { debounceTimer = Timer(const Duration(milliseconds: 100), () { setState(() { - value = widget.valueListenable.value; + value = widget.notifier.notifier; }); }); } else { setState(() { - value = widget.valueListenable.value; + value = widget.notifier.notifier; }); } } @@ -94,7 +77,7 @@ class _ReactiveBuilderState extends State> { @override Widget build(BuildContext context) { - return widget.builder(context, value, _noRebuild); + return widget.builder(value, _noRebuild); } } diff --git a/lib/src/builder/reactive_future_builder.dart b/lib/src/builder/reactive_future_builder.dart deleted file mode 100644 index 2013b84..0000000 --- a/lib/src/builder/reactive_future_builder.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:reactive_notifier/reactive_notifier.dart'; - -class ReactiveFutureBuilder extends StatefulWidget { - final Future Function() futureBuilder; - final Widget Function(T data)? onData; - final Widget Function(Object error, StackTrace? stackTrace) onError; - final Widget Function()? onLoading; - final bool autoLoad; - - const ReactiveFutureBuilder({ - super.key, - required this.futureBuilder, - this.onData, - required this.onError, - this.onLoading, - this.autoLoad = true, - }); - - @override - _ReactiveFutureBuilderState createState() => _ReactiveFutureBuilderState(); -} - -class _ReactiveFutureBuilderState extends State> { - AsyncState _state = AsyncState.initial(); - bool _isMounted = false; - - @override - void initState() { - super.initState(); - _isMounted = true; - if (widget.autoLoad) { - _loadData(); - } - } - - @override - void dispose() { - _isMounted = false; - super.dispose(); - } - - Future _loadData() async { - if (_state.isLoading) return; - - setState(() { - _state = AsyncState.loading(); - }); - - try { - final result = await widget.futureBuilder(); - if (!_isMounted) return; - - setState(() { - _state = AsyncState.success(result); - }); - } catch (error, stackTrace) { - if (!_isMounted) return; - - setState(() { - _state = AsyncState.error(error, stackTrace); - }); - } - } - - @override - Widget build(BuildContext context) { - return _state.when( - initial: () => widget.onLoading?.call() ?? const SizedBox.shrink(), - loading: () => widget.onLoading?.call() ?? - const Center(child: CircularProgressIndicator.adaptive()), - success: (data) => widget.onData?.call(data) ?? const SizedBox.shrink(), - error: (error, stackTrace) { - - if(error != null) return widget.onError(error, stackTrace); - - return const SizedBox.shrink(); - }, - ); - } - - void reload() { - _loadData(); - } -} \ No newline at end of file diff --git a/lib/src/builder/reactive_stream_builder.dart b/lib/src/builder/reactive_stream_builder.dart index ce00921..cd8f087 100644 --- a/lib/src/builder/reactive_stream_builder.dart +++ b/lib/src/builder/reactive_stream_builder.dart @@ -5,21 +5,21 @@ import 'package:reactive_notifier/src/handler/stream_state.dart'; import 'package:reactive_notifier/src/reactive_notifier.dart'; class ReactiveStreamBuilder extends StatefulWidget { - final ReactiveNotifier> streamNotifier; - final Widget Function(T data) buildData; - final Widget Function()? buildLoading; - final Widget Function(Object error)? buildError; - final Widget Function()? buildEmpty; - final Widget Function()? buildDone; + final ReactiveNotifier> notifier; + final Widget Function(T data) onData; + final Widget Function()? onLoading; + final Widget Function(Object error)? onError; + final Widget Function()? onEmpty; + final Widget Function()? onDone; const ReactiveStreamBuilder({ super.key, - required this.streamNotifier, - required this.buildData, - this.buildLoading, - this.buildError, - this.buildEmpty, - this.buildDone, + required this.notifier, + required this.onData, + this.onLoading, + this.onError, + this.onEmpty, + this.onDone, }); @override @@ -34,20 +34,20 @@ class _ReactiveStreamBuilderState extends State> { @override void initState() { super.initState(); - widget.streamNotifier.addListener(_onStreamChanged); - _subscribe(widget.streamNotifier.value); + widget.notifier.addListener(_onStreamChanged); + _subscribe(widget.notifier.notifier); } @override void dispose() { - widget.streamNotifier.removeListener(_onStreamChanged); + widget.notifier.removeListener(_onStreamChanged); _unsubscribe(); super.dispose(); } void _onStreamChanged() { _unsubscribe(); - _subscribe(widget.streamNotifier.value); + _subscribe(widget.notifier.notifier); } void _subscribe(Stream stream) { @@ -68,15 +68,14 @@ class _ReactiveStreamBuilderState extends State> { @override Widget build(BuildContext context) { return _state.when( - initial: () => widget.buildEmpty?.call() ?? const SizedBox.shrink(), + initial: () => widget.onEmpty?.call() ?? const SizedBox.shrink(), loading: () => - widget.buildLoading?.call() ?? + widget.onLoading?.call() ?? const Center(child: CircularProgressIndicator.adaptive()), - data: (data) => widget.buildData(data), + data: (data) => widget.onData(data), error: (error) => - widget.buildError?.call(error) ?? - Center(child: Text('Error: $error')), - done: () => widget.buildDone?.call() ?? const SizedBox.shrink(), + widget.onError?.call(error) ?? Center(child: Text('Error: $error')), + done: () => widget.onDone?.call() ?? const SizedBox.shrink(), ); } } diff --git a/lib/src/handler/async_state.dart b/lib/src/handler/async_state.dart index 39d16c0..931cf28 100644 --- a/lib/src/handler/async_state.dart +++ b/lib/src/handler/async_state.dart @@ -26,7 +26,7 @@ class AsyncState { required R Function() initial, required R Function() loading, required R Function(T data) success, - required R Function(Object? error, StackTrace? stackTrace) error, + required R Function(Object? err, StackTrace? stackTrace) error, }) { switch (status) { case AsyncStatus.initial: diff --git a/lib/src/implements/notifier_impl.dart b/lib/src/implements/notifier_impl.dart index 6ad7ccc..c31e09b 100644 --- a/lib/src/implements/notifier_impl.dart +++ b/lib/src/implements/notifier_impl.dart @@ -1,30 +1,27 @@ import 'package:flutter/foundation.dart'; -@protected - /// value return. -abstract class NotifierImpl extends ChangeNotifier - implements ValueListenable { - T _value; - NotifierImpl(this._value) { +@protected +abstract class NotifierImpl extends ChangeNotifier { + T _notifier; + NotifierImpl(this._notifier) { if (kFlutterMemoryAllocationsEnabled) { ChangeNotifier.maybeDispatchObjectCreation(this); } } - @override - T get value => _value; + T get notifier => _notifier; /// [updateState] /// Updates the state and notifies listeners if the value has changed. /// @protected void updateState(T newState) { - if (_value == newState) { + if (_notifier == newState) { return; } - _value = newState; + _notifier = newState; notifyListeners(); } @@ -33,60 +30,53 @@ abstract class NotifierImpl extends ChangeNotifier /// @protected void updateSilently(T newState) { - _value = newState; + _notifier = newState; } - @protected - @override - String toString() => '${describeIdentity(this)}($value)'; - @protected - @override - void addListener(VoidCallback listener) { - super.addListener(listener); + void transformState(T Function(T data) data) { + _notifier = data(_notifier); + notifyListeners(); } @protected @override - void removeListener(VoidCallback listener) => super.removeListener(listener); - - @protected - @override - void dispose() => super.dispose(); - - @immutable - @protected - @override - void notifyListeners() => super.notifyListeners(); + String toString() => '${describeIdentity(this)}($_notifier)'; @immutable - @protected @override bool get hasListeners => super.hasListeners; } @protected -abstract class StateNotifierImpl extends ChangeNotifier - implements ValueListenable { - T _value; - StateNotifierImpl(this._value) { +abstract class StateNotifierImpl extends ChangeNotifier { + T _notifier; + StateNotifierImpl(this._notifier) { if (kFlutterMemoryAllocationsEnabled) { ChangeNotifier.maybeDispatchObjectCreation(this); } } @protected - @override - T get value => _value; + T get notifier => _notifier; /// [updateState] /// Updates the state and notifies listeners if the value has changed. - @protected + void updateState(T newState) { - if (_value == newState) { + if (_notifier.hashCode == newState.hashCode) { return; } - _value = newState; + _notifier = newState; + notifyListeners(); + } + + void transformState(T Function(T data) data) { + final dataNotifier = data(_notifier); + if (dataNotifier.hashCode == _notifier.hashCode) { + return; + } + _notifier = data(_notifier); notifyListeners(); } @@ -94,12 +84,12 @@ abstract class StateNotifierImpl extends ChangeNotifier /// Updates the value silently without notifying listeners. @protected void updateSilently(T newState) { - _value = newState; + _notifier = newState; } @protected @override - String toString() => '${describeIdentity(this)}($value)'; + String toString() => '${describeIdentity(this)}($notifier)'; @protected @override diff --git a/lib/src/reactive_notifier.dart b/lib/src/reactive_notifier.dart index ec1c37e..8498da5 100644 --- a/lib/src/reactive_notifier.dart +++ b/lib/src/reactive_notifier.dart @@ -38,7 +38,7 @@ class ReactiveNotifier extends NotifierImpl { related?.forEach((child) { child._parents.add(this); assert(() { - log('βž• Added parent-child relation: $T -> ${child.value.runtimeType}', + log('βž• Added parent-child relation: $T -> ${child.notifier.runtimeType}', level: 10); return true; }()); @@ -59,7 +59,7 @@ class ReactiveNotifier extends NotifierImpl { assert(() { log(''' πŸ“¦ Creating ReactiveNotifier<$T> -${related != null ? 'πŸ”— With related types: ${related.map((r) => r.value.runtimeType).join(', ')}' : ''} +${related != null ? 'πŸ”— With related types: ${related.map((r) => r.notifier.runtimeType).join(', ')}' : ''} ''', level: 5); return true; }()); @@ -105,7 +105,7 @@ Location: $trace @override void updateState(T newState) { - if (value != newState) { + if (notifier != newState) { // Prevent circular update if (_updatingNotifiers.contains(this)) { return; @@ -115,7 +115,8 @@ Location: $trace _checkNotificationOverflow(); assert(() { - log('πŸ“ Updating state for $T: $value -> ${newState.runtimeType}', level: 10); + log('πŸ“ Updating state for $T: $notifier -> ${newState.runtimeType}', + level: 10); return true; }()); @@ -163,7 +164,7 @@ Location: $trace ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Notifier: ${describeIdentity(this)} Type: $T -Current Value: $value +Current Value: $notifier Location: ${StackTrace.current} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ $_notificationCount notifications in ${_thresholdTimeWindow.inMilliseconds}ms @@ -224,8 +225,8 @@ $_notificationCount notifications in ${_thresholdTimeWindow.inMilliseconds}ms String _formatNotifierInfo(ReactiveNotifier notifier) { return ''' - Type: ${notifier.value.runtimeType} - Value: ${notifier.value} + Type: ${notifier.notifier.runtimeType} + Value: ${notifier.notifier} Key: ${notifier.keyNotifier}'''; } @@ -265,7 +266,7 @@ $_notificationCount notifications in ${_thresholdTimeWindow.inMilliseconds}ms Set pathKeys, ) { final cycle = [...pathKeys, child.keyNotifier] - .map((key) => '${_instances[key]?.value.runtimeType}($key)') + .map((key) => '${_instances[key]?.notifier.runtimeType}($key)') .join(' -> '); throw StateError(''' @@ -376,29 +377,29 @@ Requested type: $R${key != null ? '\nRequested key: $key' : ''} final result = key != null ? related!.firstWhere( - (n) => n.value is R && n.keyNotifier == key, + (n) => n.notifier is R && n.keyNotifier == key, orElse: () => throw StateError(''' ❌ Related State Not Found ━━━━━━━━━━━━━━━━━━━━━ Looking for: $R with key: $key Parent type: $T -Available types: ${related!.map((r) => '${r.value.runtimeType}(${r.keyNotifier})').join(', ')} +Available types: ${related!.map((r) => '${r.notifier.runtimeType}(${r.keyNotifier})').join(', ')} ━━━━━━━━━━━━━━━━━━━━━ '''), ) : related!.firstWhere( - (n) => n.value is R, + (n) => n.notifier is R, orElse: () => throw StateError(''' ❌ Related State Not Found ━━━━━━━━━━━━━━━━━━━━━ Looking for: $R Parent type: $T -Available types: ${related!.map((r) => '${r.value.runtimeType}(${r.keyNotifier})').join(', ')} +Available types: ${related!.map((r) => '${r.notifier.runtimeType}(${r.keyNotifier})').join(', ')} ━━━━━━━━━━━━━━━━━━━━━ '''), ); - return result.value as R; + return result.notifier as R; } /// Utility methods @@ -414,5 +415,5 @@ Available types: ${related!.map((r) => '${r.value.runtimeType}(${r.keyNotifier}) } @override - String toString() => '${describeIdentity(this)}($value)'; + String toString() => '${describeIdentity(this)}($notifier)'; } diff --git a/lib/src/viewmodel/async_viewmodel_impl.dart b/lib/src/viewmodel/async_viewmodel_impl.dart deleted file mode 100644 index 0029194..0000000 --- a/lib/src/viewmodel/async_viewmodel_impl.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:reactive_notifier/src/handler/async_state.dart'; -import 'package:reactive_notifier/src/implements/notifier_impl.dart'; - -/// Base ViewModel implementation for handling asynchronous operations with state management. -/// -/// Provides a standardized way to handle loading, success, and error states for async data. -import 'package:flutter/foundation.dart'; - -/// Base ViewModel implementation for handling asynchronous operations with state management. -abstract class AsyncViewModelImpl extends NotifierImpl> { - AsyncViewModelImpl({ - bool loadOnInit = true, - T? initialData, - }) : super(initialData != null ? AsyncState.success(initialData) : AsyncState.initial()) { - if (loadOnInit) { - _initializeAsync(); - } - } - - /// Internal initialization method that properly handles async initialization - Future _initializeAsync() async { - try { - await reload(); - } catch (error, stackTrace) { - setError(error, stackTrace); - } - } - - /// Public method to reload data - @protected - Future reload() async { - if (value.isLoading) return; - - updateState(AsyncState.loading()); - try { - final result = await loadData(); - updateState(AsyncState.success(result)); - } catch (error, stackTrace) { - setError(error, stackTrace); - } - } - - /// Override this method to provide the async data loading logic - @protected - Future loadData(); - - /// Update data directly - @protected - void updateData(T data) { - updateState(AsyncState.success(data)); - } - - /// Set error state - @protected - void setError(Object error, [StackTrace? stackTrace]) { - updateState(AsyncState.error(error, stackTrace)); - } - - /// Check if any operation is in progress - bool get isLoading => value.isLoading; - - /// Get current error if any - Object? get error => value.error; - - /// Get current stack trace if there's an error - StackTrace? get stackTrace => value.stackTrace; - - /// Check if the state contains valid data - bool get hasData => value.isSuccess; - - /// Get the current data (may be null if not in success state) - T? get data => value.isSuccess ? value.data : null; -} diff --git a/lib/src/viewmodel/viewmodel_impl.dart b/lib/src/viewmodel/viewmodel_impl.dart index 5796180..bfb5b1c 100644 --- a/lib/src/viewmodel/viewmodel_impl.dart +++ b/lib/src/viewmodel/viewmodel_impl.dart @@ -26,6 +26,7 @@ abstract class ViewModelImpl extends StateNotifierImpl { } } + @protected void init(); bool _initialized = false; @@ -93,4 +94,7 @@ abstract class ViewModelStateImpl extends StateNotifierImpl { StateTracker.trackStateChange(_id); } } + + @override + T get notifier => this.notifier; } diff --git a/pubspec.yaml b/pubspec.yaml index fda4404..497b9f6 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.3.1 +version: 2.4.0 homepage: https://github.com/JhonaCodes/reactive_notifier.git environment: @@ -21,7 +21,7 @@ dev_dependencies: alchemist: ^0.7.0 flutter: - + uses-material-design: true # To add assets to your package, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg diff --git a/test/circular_references_test.dart b/test/circular_references_test.dart index 787726f..3ae338c 100644 --- a/test/circular_references_test.dart +++ b/test/circular_references_test.dart @@ -323,10 +323,10 @@ void main() { reason: 'Should create all instances without nesting', ); - expect(firstNotifier2.value, equals('Level -1')); - expect(firstNotifier.value, equals('Level 0')); - expect(secondNotifier.value, equals('Level 1')); - expect(thirdNotifier.value, equals('Level 2')); + expect(firstNotifier2.notifier, equals('Level -1')); + expect(firstNotifier.notifier, equals('Level 0')); + expect(secondNotifier.notifier, equals('Level 1')); + expect(thirdNotifier.notifier, equals('Level 2')); }); }); } diff --git a/test/golden_reactive_builder.test.dart b/test/golden_reactive_builder.test.dart index c90c9f4..1013dd6 100644 --- a/test/golden_reactive_builder.test.dart +++ b/test/golden_reactive_builder.test.dart @@ -18,8 +18,8 @@ void main() { GoldenTestScenario( name: 'Change value 0', child: ReactiveBuilder( - valueListenable: state, - builder: (context, value, child) { + notifier: state, + builder: (value, child) { return Column( children: [ Text("Widget que se recontruye $value"), @@ -45,8 +45,8 @@ void main() { GoldenTestScenario( name: 'Change value 200', child: ReactiveBuilder( - valueListenable: state, - builder: (context, value, child) { + notifier: state, + builder: (value, child) { if (value == 0) { state.updateState(200); } @@ -68,8 +68,8 @@ void main() { MaterialApp( home: Scaffold( body: ReactiveBuilder( - valueListenable: valueNotifier, - builder: (context, value, noRebuildable) { + notifier: valueNotifier, + builder: (value, noRebuildable) { rebuildCount++; // Contador para verificar reconstrucciones log("Widget que se reconstruye: $value"); log("Widget que se reconstruye: $value"); diff --git a/test/reactive_notifier_test.dart b/test/reactive_notifier_test.dart index a0c33bc..f3beedc 100644 --- a/test/reactive_notifier_test.dart +++ b/test/reactive_notifier_test.dart @@ -14,22 +14,22 @@ void main() { group('Initialization and Basic Functionality', () { test('should initialize with default value', () { final state = ReactiveNotifier(() => 0); - expect(state.value, 0); + expect(state.notifier, 0); expect(ReactiveNotifier.instanceCount, 1); }); test('should create separate instances for each call', () { final state1 = ReactiveNotifier(() => 0); final state2 = ReactiveNotifier(() => 1); - expect(state1.value, 0); - expect(state2.value, 1); + expect(state1.notifier, 0); + expect(state2.notifier, 1); expect(ReactiveNotifier.instanceCount, 2); }); test('should update value with setState', () { final state = ReactiveNotifier(() => 0); state.updateState(10); - expect(state.value, 10); + expect(state.notifier, 10); }); }); @@ -39,7 +39,7 @@ void main() { int? notifiedValue; notify.addListener(() { - notifiedValue = notify.value; + notifiedValue = notify.notifier; }); notify.updateState(5); @@ -53,10 +53,10 @@ void main() { int? listener2Value; notify.addListener(() { - listener1Value = notify.value; + listener1Value = notify.notifier; }); notify.addListener(() { - listener2Value = notify.value; + listener2Value = notify.notifier; }); notify.updateState(10); @@ -70,7 +70,7 @@ void main() { int? listenerValue; void listener() { - listenerValue = notify.value; + listenerValue = notify.notifier; } notify.addListener(listener); @@ -121,16 +121,16 @@ void main() { final isEvenNotifier = ReactiveNotifier(() => true); countNotifier.addListener(() { - isEvenNotifier.updateState(countNotifier.value % 2 == 0); + isEvenNotifier.updateState(countNotifier.notifier % 2 == 0); }); countNotifier.updateState(1); - expect(countNotifier.value, 1); - expect(isEvenNotifier.value, false); + expect(countNotifier.notifier, 1); + expect(isEvenNotifier.notifier, false); countNotifier.updateState(2); - expect(countNotifier.value, 2); - expect(isEvenNotifier.value, true); + expect(countNotifier.notifier, 2); + expect(isEvenNotifier.notifier, true); }); test('should handle cascading updates', () { @@ -140,15 +140,15 @@ void main() { temperatureCelsius.addListener(() { temperatureFahrenheit - .updateState(temperatureCelsius.value * 9 / 5 + 32); + .updateState(temperatureCelsius.notifier * 9 / 5 + 32); }); temperatureFahrenheit.addListener(() { - if (temperatureFahrenheit.value < 32) { + if (temperatureFahrenheit.notifier < 32) { weatherDescription.updateState('Freezing'); - } else if (temperatureFahrenheit.value < 65) { + } else if (temperatureFahrenheit.notifier < 65) { weatherDescription.updateState('Cold'); - } else if (temperatureFahrenheit.value < 80) { + } else if (temperatureFahrenheit.notifier < 80) { weatherDescription.updateState('Comfortable'); } else { weatherDescription.updateState('Hot'); @@ -156,14 +156,14 @@ void main() { }); temperatureCelsius.updateState(25); // 77Β°F - expect(temperatureCelsius.value, 25); - expect(temperatureFahrenheit.value, closeTo(77, 0.1)); - expect(weatherDescription.value, 'Comfortable'); + expect(temperatureCelsius.notifier, 25); + expect(temperatureFahrenheit.notifier, closeTo(77, 0.1)); + expect(weatherDescription.notifier, 'Comfortable'); temperatureCelsius.updateState(35); // 95Β°F - expect(temperatureCelsius.value, 35); - expect(temperatureFahrenheit.value, closeTo(95, 0.1)); - expect(weatherDescription.value, 'Hot'); + expect(temperatureCelsius.notifier, 35); + expect(temperatureFahrenheit.notifier, closeTo(95, 0.1)); + expect(weatherDescription.notifier, 'Hot'); }); test('should handle circular dependencies without infinite updates', () { @@ -177,21 +177,21 @@ void main() { notifierA.addListener(() { updateCountA++; - notifierB.updateState(notifierA.value + 1); + notifierB.updateState(notifierA.notifier + 1); }); notifierB.addListener(() { updateCountB++; - notifierA.updateState(notifierB.value + 1); + notifierA.updateState(notifierB.notifier + 1); }); notifierA.updateState(1); expect(updateCountA, equals(1), reason: 'notifierA should update once'); expect(updateCountB, equals(1), reason: 'notifierB should update once'); - expect(notifierA.value, equals(1), + expect(notifierA.notifier, equals(1), reason: 'notifierA state should remain 1'); - expect(notifierB.value, equals(2), + expect(notifierB.notifier, equals(2), reason: 'notifierB state should be updated to 2'); ReactiveNotifier.cleanup(); @@ -250,14 +250,14 @@ void main() { final complexState = ReactiveNotifier>( () => {'count': 0, 'name': 'Test'}); complexState.updateState({'count': 1, 'name': 'Updated'}); - expect(complexState.value, {'count': 1, 'name': 'Updated'}); + expect(complexState.notifier, {'count': 1, 'name': 'Updated'}); }); test('should handle null states', () { final nullableState = ReactiveNotifier(() => null); - expect(nullableState.value, isNull); + expect(nullableState.notifier, isNull); nullableState.updateState(5); - expect(nullableState.value, 5); + expect(nullableState.notifier, 5); }); test('should handle state transitions', () { @@ -281,54 +281,55 @@ void main() { } updateStateAsync(); - expect(asyncState.value, 'initial'); + expect(asyncState.notifier, 'initial'); await Future.delayed(const Duration(milliseconds: 150)); - expect(asyncState.value, 'updated'); + expect(asyncState.notifier, 'updated'); }); test('should manage concurrent async updates', () async { final concurrentState = ReactiveNotifier(() => 0); Future incrementAsync() async { await Future.delayed(const Duration(milliseconds: 50)); - concurrentState.updateState(concurrentState.value + 1); + concurrentState.updateState(concurrentState.notifier + 1); } await Future.wait( [incrementAsync(), incrementAsync(), incrementAsync()]); - expect(concurrentState.value, 3); + expect(concurrentState.notifier, 3); }); }); group('Computed States', () { test('should handle computed states', () { final baseState = ReactiveNotifier(() => 1); - final computedState = ReactiveNotifier(() => baseState.value * 2); - baseState - .addListener(() => computedState.updateState(baseState.value * 2)); + final computedState = + ReactiveNotifier(() => baseState.notifier * 2); + baseState.addListener( + () => computedState.updateState(baseState.notifier * 2)); baseState.updateState(5); - expect(computedState.value, 10); + expect(computedState.notifier, 10); }); test('should efficiently update multiple dependent states', () { final rootState = ReactiveNotifier(() => 0); - final computed1 = ReactiveNotifier(() => rootState.value + 1); - final computed2 = ReactiveNotifier(() => rootState.value * 2); - final computed3 = - ReactiveNotifier(() => computed1.value + computed2.value); + final computed1 = ReactiveNotifier(() => rootState.notifier + 1); + final computed2 = ReactiveNotifier(() => rootState.notifier * 2); + final computed3 = ReactiveNotifier( + () => computed1.notifier + computed2.notifier); rootState.addListener(() { - computed1.updateState(rootState.value + 1); - computed2.updateState(rootState.value * 2); + computed1.updateState(rootState.notifier + 1); + computed2.updateState(rootState.notifier * 2); }); - computed1.addListener( - () => computed3.updateState(computed1.value + computed2.value)); - computed2.addListener( - () => computed3.updateState(computed1.value + computed2.value)); + computed1.addListener(() => + computed3.updateState(computed1.notifier + computed2.notifier)); + computed2.addListener(() => + computed3.updateState(computed1.notifier + computed2.notifier)); rootState.updateState(5); - expect(computed1.value, 6); - expect(computed2.value, 10); - expect(computed3.value, 16); + expect(computed1.notifier, 6); + expect(computed2.notifier, 10); + expect(computed3.notifier, 16); }); }); @@ -336,7 +337,8 @@ void main() { test('should maintain state history', () { final historicalState = ReactiveNotifier(() => 0); final history = []; - historicalState.addListener(() => history.add(historicalState.value)); + historicalState + .addListener(() => history.add(historicalState.notifier)); historicalState.updateState(1); historicalState.updateState(2); historicalState.updateState(3); @@ -346,11 +348,11 @@ void main() { test('should support undo operations', () { final undoableState = ReactiveNotifier(() => 0); final history = [0]; - undoableState.addListener(() => history.add(undoableState.value)); + undoableState.addListener(() => history.add(undoableState.notifier)); undoableState.updateState(1); undoableState.updateState(2); undoableState.updateState(history[history.length - 2]); // Undo - expect(undoableState.value, 1); + expect(undoableState.notifier, 1); }); }); @@ -359,8 +361,8 @@ void main() { final customState = ReactiveNotifier(() => CustomObject(1, 'initial')); customState.updateState(CustomObject(2, 'updated')); - expect(customState.value.id, 2); - expect(customState.value.name, 'updated'); + expect(customState.notifier.id, 2); + expect(customState.notifier.name, 'updated'); }); }); @@ -385,7 +387,7 @@ void main() { // Actualizar el estado en el isolate principal isolateState.updateState(updatedState as int); - expect(isolateState.value, 42); + expect(isolateState.notifier, 42); }); }); @@ -406,9 +408,9 @@ void main() { test('should support dependency injection', () { const injectedDependency = 'Injected Value'; final dependentState = ReactiveNotifier(() => 'Initial'); - expect(dependentState.value, 'Initial'); + expect(dependentState.notifier, 'Initial'); dependentState.updateState('Updated with $injectedDependency'); - expect(dependentState.value, 'Updated with Injected Value'); + expect(dependentState.notifier, 'Updated with Injected Value'); }); }); }); @@ -436,7 +438,7 @@ void main() { state.updateState(42); //42; expect(notifications, 1); - expect(state.value, 42); + expect(state.notifier, 42); }); test('does not notify if value is the same', () { @@ -479,21 +481,21 @@ void main() { ReactiveNotifier(() => 'combined', related: [stateA, stateB]); stateA.addListener(() => updates.add('A')); - expect(stateA.value, 'A'); + expect(stateA.notifier, 'A'); expect(combined.from(stateA.keyNotifier), 'A'); stateB.addListener(() => updates.add('B')); - expect(stateB.value, 'B'); + expect(stateB.notifier, 'B'); expect(combined.from(stateB.keyNotifier), 'B'); combined.addListener(() => updates.add('combined')); stateA.updateState('A2'); - expect(stateA.value, 'A2'); + expect(stateA.notifier, 'A2'); expect(combined.from(stateA.keyNotifier), 'A2'); stateB.updateState('B2'); - expect(stateB.value, 'B2'); + expect(stateB.notifier, 'B2'); expect(combined.from(stateB.keyNotifier), 'B2'); expect(updates.length, 4);