Simplifying State Management in Flutter Using ValueNotifier and ValueListenableBuilder
State management is a crucial aspect of building Flutter applications.
In this article, we'll explore how to use ValueNotifier and ValueListenableBuilder for effective state management, from simple use cases to complex scenarios.
We'll build an e-commerce app to demonstrate these concepts in action.
Basic Concepts
ValueNotifier
ValueNotifier is a simple way to wrap a value and notify listeners when that value changes. It's part of Flutter's foundation library.
final counter = ValueNotifier<int>(0);
In this example, we create a ValueNotifier
that holds an integer value, initially set to 0.
ValueListenableBuilder
ValueListenableBuilder is a widget that rebuilds its child when the value in a ValueNotifier changes.
ValueListenableBuilder<int>(
valueListenable: counter,
builder: (context, value, child) {
return Text('Count: $value');
},
)
You might ask, what is the relationship between ValueNotifier and ValueListenableBuilder?
ValueNotifier
is a way to wrap a value that can change over time. It's part of the data/state management side of your application.
In the example above, the counter
is a ValueNotifier that holds an integer value. You can change this value like this:
counter.value = 1;
However, just changing the value doesn't automatically update your UI. This is where ValueListenableBuilder comes in. It's a widget that listens to a ValueNotifier and rebuilds its child whenever the notifier's value changes. In the example above, the builder listens to the counter notifier to rebuild it’s child when the value changes.
The ValueListenableBuilder is the bridge between your data (ValueNotifier) and your UI. It's responsible for:
Listening to changes in the ValueNotifier
Rebuilding the widget tree when the value changes
Providing the current value to its builder function
Without ValueListenableBuilder:
Your UI wouldn't know when to update in response to changes in the ValueNotifier.
You'd have to manually trigger rebuilds of your widget tree every time the value changes, which would be inefficient and error-prone.
Combining them together:
class CounterPage extends StatelessWidget {
final counter = ValueNotifier<int>(0);
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: ValueListenableBuilder<int>(
valueListenable: counter,
builder: (context, value, child) {
return Text('Count: $value');
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => counter.value++,
child: Icon(Icons.add),
),
);
}
}
In this example:
The ValueNotifier (
counter
) holds the state.The ValueListenableBuilder listens for changes to
counter
and updates the Text widget.When the FloatingActionButton is pressed, it increments
counter.value
.The ValueListenableBuilder detects this change and rebuilds the Text widget with the new value.
Without the ValueListenableBuilder, the Text widget wouldn't update when counter.value
changes.
Now that we have got the basics out of the way, let’s deep dive some advanced concepts.
MultiValueListenableBuilder
When dealing with multiple ValueNotifier
s, we can create a custom MultiValueListenableBuilder
to simplify our code:
class MultiValueListenableBuilder extends StatelessWidget {
final List<ValueListenable> valueListenables;
final Widget Function(BuildContext context, List<dynamic> values, Widget? child) builder;
final Widget? child;
const MultiValueListenableBuilder({
Key? key,
required this.valueListenables,
required this.builder,
this.child,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<dynamic>(
valueListenable: valueListenables[0],
builder: (context, value, _) {
return _buildNested(context, [value], 1);
},
);
}
Widget _buildNested(BuildContext context, List<dynamic> values, int index) {
if (index >= valueListenables.length) {
return builder(context, values, child);
}
return ValueListenableBuilder<dynamic>(
valueListenable: valueListenables[index],
builder: (context, value, _) {
return _buildNested(context, [...values, value], index + 1);
},
);
}
}
Let's break down this MultiValueListenableBuilder
class:
This class is designed to listen to multiple ValueNotifiers at once and rebuild a widget when any of them change.
It's a StatelessWidget, meaning it doesn't maintain its own state. It takes a list of ValueListenables, a builder function, and an optional child widget. It starts with the first ValueListenable
. It then creates nested ValueListenableBuilders
for each subsequent ValueListenable
. At the innermost level, when all values are collected, it calls the provided builder function. If any ValueListenable
changes, it triggers a rebuild of its corresponding ValueListenableBuilder
and all inner ones. This creates a widget that rebuilds whenever any of the provided ValueListenables
change. The builder function receives a list of all current values, allowing you to build your UI based on all of them.
In essence, this class is a more flexible version of the standard ValueListenableBuilder, allowing you to react to changes in multiple values at once, which is often needed in more complex state management scenarios.
Listenable.merge
An alternative to having a custom MultiValueListenableBuilder is to use the Listenable.merge
constructor: https://api.flutter.dev/flutter/foundation/Listenable/Listenable.merge.html
With this, it returns a Listenable that triggers when any of the given Listenables themselves trigger.
E-Commerce App Implementation
Let's build an e-commerce app using ValueNotifier and ValueListenableBuilder. We'll implement product listing, a shopping cart, and checkout functionality.
1. Models
First, let's define our data models:
class Product {
final int id;
final String name;
final double price;
final String imageUrl;
Product({required this.id, required this.name, required this.price, required this.imageUrl});
}
class CartItem {
final Product product;
final ValueNotifier<int> quantity;
CartItem({required this.product, int initialQuantity = 1})
: quantity = ValueNotifier(initialQuantity);
}
The Product
class represents a product in our catalog. The CartItem
class represents a product in the shopping cart, with a ValueNotifier
for the quantity to allow for easy updates and UI rebuilds.
2. Services
Let's simulate a backend service:
class ApiService {
Future<List<Product>> fetchProducts() async {
// Simulate API delay
await Future.delayed(Duration(seconds: 1));
return [
Product(id: 1, name: 'Laptop', price: 999.99, imageUrl: 'https://example.com/laptop.jpg'),
Product(id: 2, name: 'Smartphone', price: 499.99, imageUrl: 'https://example.com/smartphone.jpg'),
Product(id: 3, name: 'Headphones', price: 99.99, imageUrl: 'https://example.com/headphones.jpg'),
];
}
Future<bool> submitOrder(List<CartItem> items, double total) async {
// Simulate API delay and always return success for this example
await Future.delayed(Duration(seconds: 1));
return true;
}
}
This ApiService
class simulates API calls to fetch products and submit orders.
3. State Management
Now, let's implement our state management classes:
class ProductCatalogNotifier {
final ApiService _apiService;
final ValueNotifier<List<Product>> _products = ValueNotifier([]);
final ValueNotifier<bool> _isLoading = ValueNotifier(false);
final ValueNotifier<String?> _error = ValueNotifier(null);
ProductCatalogNotifier(this._apiService);
ValueNotifier<List<Product>> get products => _products;
ValueNotifier<bool> get isLoading => _isLoading;
ValueNotifier<String?> get error => _error;
Future<void> fetchProducts() async {
_isLoading.value = true;
_error.value = null;
try {
final fetchedProducts = await _apiService.fetchProducts();
_products.value = fetchedProducts;
} catch (e) {
_error.value = 'Failed to fetch products: ${e.toString()}';
} finally {
_isLoading.value = false;
}
}
void dispose() {
_products.dispose();
_isLoading.dispose();
_error.dispose();
}
}
This class encapsulates all the logic for managing product catalog state. By using ValueNotifiers
, it can notify listeners (like UI widgets) whenever the products, loading state, or error state changes, allowing for reactive updates to the user interface.
The ProductCatalogNotifier
manages the state of our product catalog. It uses separate ValueNotifier
s for the product list, loading state, and error state. The fetchProducts method updates these states as it fetches products from the API.
Now, we need a cart to store our products before we submit them.
class CartNotifier {
final ValueNotifier<List<CartItem>> _items = ValueNotifier([]);
final ValueNotifier<double> _totalPrice = ValueNotifier(0.0);
ValueNotifier<List<CartItem>> get items => _items;
ValueNotifier<double> get totalPrice => _totalPrice;
void addItem(Product product) {
final existingItem = _items.value.firstWhere(
(item) => item.product.id == product.id,
orElse: () => CartItem(product: product, initialQuantity: 0),
);
if (existingItem.quantity.value == 0) {
_items.value = [..._items.value, existingItem];
}
existingItem.quantity.value++;
_updateTotalPrice();
}
void removeItem(Product product) {
_items.value = _items.value.where((item) => item.product.id != product.id).toList();
_updateTotalPrice();
}
void _updateTotalPrice() {
_totalPrice.value = _items.value.fold(
0.0,
(total, item) => total + (item.product.price * item.quantity.value),
);
}
void dispose() {
for (var item in _items.value) {
item.quantity.dispose();
}
_items.dispose();
_totalPrice.dispose();
}
}
Purpose:
This class manages the state of a shopping cart, including the items in the cart and the total price.
Properties:
_items
: A ValueNotifier holding a list of CartItem objects. It represents the items in the cart._totalPrice
: A ValueNotifier holding a double. It represents the total price of all items in the cart.Getters:
Provide access to the ValueNotifiers, allowing other parts of the app to listen to changes in the cart items and total price.
addItem
method:Checks if the product already exists in the cart.
If it doesn't exist or has quantity 0, adds it to the cart.
Increments the quantity of the item.
Updates the total price.
removeItem
method:Removes an item from the cart based on the product ID.
Updates the total price.
_updateTotalPrice
method:Calculates the total price of all items in the cart.
Uses the fold method to sum up the prices of all items, considering their quantities.
dispose
method:- Cleans up resources by disposing of all ValueNotifiers, including those in individual CartItems.
This class encapsulates all the logic for managing the shopping cart state. By using ValueNotifiers, it can notify listeners (like UI widgets) whenever the cart items or total price changes, allowing for reactive updates to the user interface.
4. UI Implementation
Now, let's implement the UI for our product list page:
class ProductListPage extends StatefulWidget {
@override
_ProductListPageState createState() => _ProductListPageState();
}
class _ProductListPageState extends State<ProductListPage> {
late final ProductCatalogNotifier _productCatalog;
late final CartNotifier _cart;
@override
void initState() {
super.initState();
_productCatalog = ProductCatalogNotifier(ApiService());
_cart = CartNotifier();
WidgetsBinding.instance.addPostFrameCallback((_) {
_productCatalog.fetchProducts();
});
}
@override
void dispose() {
_productCatalog.dispose();
_cart.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Product Catalog')),
body: MultiValueListenableBuilder(
valueListenables: [
_productCatalog.isLoading,
_productCatalog.error,
_productCatalog.products,
],
builder: (context, (bool isLoading, String? error, List<Product> products), _) {
return switch ((isLoading, error, products)) {
(true, _, _) => Center(child: CircularProgressIndicator()),
(_, String error, _) => _buildErrorWidget(error),
(_, _, []) => Center(child: Text('No products available')),
(_, _, List<Product> products) => _buildProductList(products),
};
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => CartPage(_cart)),
),
child: Icon(Icons.shopping_cart),
),
);
}
Widget _buildErrorWidget(String error) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(error),
ElevatedButton(
onPressed: () => _productCatalog.fetchProducts(),
child: Text('Retry'),
),
],
),
);
}
Widget _buildProductList(List<Product> products) {
return RefreshIndicator(
onRefresh: () => _productCatalog.fetchProducts(),
child: ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return ListTile(
title: Text(product.name),
subtitle: Text('\$${product.price.toStringAsFixed(2)}'),
trailing: IconButton(
icon: Icon(Icons.add_shopping_cart),
onPressed: () => _cart.addItem(product),
),
);
},
),
);
}
}
Let's break down this implementation:
We use a StatefulWidget to manage the lifecycle of our notifiers.
In
initState
, we initialize our notifiers and fetch products after the widget is built.In
dispose
, we make sure to dispose of our notifiers to prevent memory leaks.We use
MultiValueListenableBuilder
to listen to multiple ValueNotifiers simultaneously.We use Dart's pattern matching feature to handle different states elegantly: Remember that this pattern matching syntax requires Dart 3.0 or later. If you're using an earlier version of Dart, you'll need to stick with the if-else approach or update your Dart SDK.
(true, , )
matches when loading.(_, String error, _)
matches when there's an error.(_, _, [])
matches when the product list is empty.(_, _, List<Product> products)
matches when we have products to display.
We extract the error widget and product list widget into separate methods for better organization.
We use a
RefreshIndicator
to allow users to manually refresh the product list.
Using Listenable.merge
constructor:
ListenableBuilder(
listenable: Listenable.merge([
_productCatalog.loadingStatus,
_productCatalog.errorMessage,
_productCatalog.products,
]),
builder: (context, _) {
final status = _productCatalog.loadingStatus.value;
final errorMessage = _productCatalog.errorMessage.value;
final products = _productCatalog.products.value;
return switch ((status, errorMessage, products)) {
(LoadingStatus.loading, _, _) =>
Center(child: CircularProgressIndicator()),
(LoadingStatus.error, String error, _) =>
Center(child: Text(error)),
(_, _, List<Product> products) => ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return ListTile(
title: Text(product.name),
subtitle: Text('\$${product.price.toStringAsFixed(2)}'),
);
},
),
_ => Center(child: Text('Unexpected state')),
};
},
)
Now for the CartPage
which will show our shopping cart.
class CartPage extends StatelessWidget {
final CartNotifier cart;
const CartPage({Key? key, required this.cart}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Shopping Cart'),
),
body: ValueListenableBuilder<List<CartItem>>(
valueListenable: cart.items,
builder: (context, items, child) {
if (items.isEmpty) {
return Center(child: Text('Your cart is empty'));
}
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return ListTile(
title: Text(item.product.name),
subtitle: Text('\$${item.product.price.toStringAsFixed(2)}'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(Icons.remove),
onPressed: () => cart.decreaseQuantity(item.product),
),
ValueListenableBuilder<int>(
valueListenable: item.quantity,
builder: (context, quantity, child) {
return Text('$quantity');
},
),
IconButton(
icon: Icon(Icons.add),
onPressed: () => cart.addItem(item.product),
),
],
),
);
},
);
},
),
bottomNavigationBar: ValueListenableBuilder<double>(
valueListenable: cart.totalPrice,
builder: (context, totalPrice, child) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'Total: \$${totalPrice.toStringAsFixed(2)}',
style: Theme.of(context).textTheme.headline6,
textAlign: TextAlign.center,
),
);
},
),
);
}
}
5. Unit Tests
You might be wondering how to test your ValueNotifier
.
Let’s test the ProductCatalogNotifier
.
Our tests should cover:
Initial state of the product catalog
Successful fetching of products
Handling of errors during fetching
Loading state during fetching
Clearing of previous errors on new fetch
Replacement of previous products on new fetch
To write our test, we will use Mockito
. Mockito
is a popular mocking framework for Dart and Flutter. It allows you to create mock objects for your tests, which is particularly useful when you want to test classes that depend on other classes or services.
- Add
Mockito
andbuild_runner
to yourpubspec.yaml
file underdev_dependencies
to your project:
dev_dependencies:
flutter_test:
sdk: flutter
mockito: ^5.0.0
build_runner: ^2.0.0
Then run flutter pub get
to install these packages.
- Create a test file:
Create a new file for your tests, e.g., test/product_catalog_notifier_test.dart
.
We’ll use GenerateNiceMocks
to generate mocks for our ApiService
class. GenerateNiceMocks
is a newer and often more convenient way to generate mocks using Mockito. It creates "nice" mocks by default, which means they return null for unexpected method calls instead of throwing exceptions. This can be helpful in many testing scenarios.
@GenerateNiceMocks([MockSpec<ApiService>()])
import 'product_catalog_notifier_test.mocks.dart';
void main() {
late ProductCatalogNotifier productCatalog;
late MockApiService mockApiService;
setUp(() {
mockApiService = MockApiService();
productCatalog = ProductCatalogNotifier(mockApiService);
});
tearDown(() {
productCatalog.dispose();
});
group('ProductCatalogNotifier', () {
test('initial state is empty', () {
expect(productCatalog.products.value, isEmpty);
expect(productCatalog.isLoading.value, false);
expect(productCatalog.error.value, isNull);
});
test('fetchProducts updates products on success', () async {
final mockProducts = [
Product(id: 1, name: 'Test Product 1', price: 10.0, imageUrl: 'test1.jpg'),
Product(id: 2, name: 'Test Product 2', price: 20.0, imageUrl: 'test2.jpg'),
];
when(mockApiService.fetchProducts()).thenAnswer((_) async => mockProducts);
await productCatalog.fetchProducts();
expect(productCatalog.products.value, equals(mockProducts));
expect(productCatalog.isLoading.value, false);
expect(productCatalog.error.value, isNull);
});
test('fetchProducts updates error on failure', () async {
when(mockApiService.fetchProducts()).thenThrow(Exception('API error'));
await productCatalog.fetchProducts();
expect(productCatalog.products.value, isEmpty);
expect(productCatalog.isLoading.value, false);
expect(productCatalog.error.value, contains('Failed to fetch products'));
});
test('isLoading is true while fetching products', () async {
when(mockApiService.fetchProducts()).thenAnswer((_) async {
await Future.delayed(Duration(milliseconds: 100));
return [];
});
final future = productCatalog.fetchProducts();
expect(productCatalog.isLoading.value, true);
await future;
expect(productCatalog.isLoading.value, false);
});
test('fetchProducts clears previous error', () async {
// First, simulate an error
when(mockApiService.fetchProducts()).thenThrow(Exception('API error'));
await productCatalog.fetchProducts();
expect(productCatalog.error.value, isNotNull);
// Then, simulate a successful fetch
when(mockApiService.fetchProducts()).thenAnswer((_) async => []);
await productCatalog.fetchProducts();
expect(productCatalog.error.value, isNull);
});
test('fetchProducts replaces previous products', () async {
final mockProducts1 = [
Product(id: 1, name: 'Test Product 1', price: 10.0, imageUrl: 'test1.jpg'),
];
final mockProducts2 = [
Product(id: 2, name: 'Test Product 2', price: 20.0, imageUrl: 'test2.jpg'),
];
when(mockApiService.fetchProducts()).thenAnswer((_) async => mockProducts1);
await productCatalog.fetchProducts();
expect(productCatalog.products.value, equals(mockProducts1));
when(mockApiService.fetchProducts()).thenAnswer((_) async => mockProducts2);
await productCatalog.fetchProducts();
expect(productCatalog.products.value, equals(mockProducts2));
});
});
}
Remember to run flutter pub run build_runner build
Let’s test our CartNotifier
.
We’ll have a test suite that covers
Initial state of the cart
Adding a new item to an empty cart
Increasing quantity of an existing item
Adding different items separately
Removing an item from the cart
Attempting to remove a non-existent item
Updating total price with multiple operations
Edge case of adding an item with quantity 0 and then incrementing
Removing the last item and ensuring total price is 0
Handling high-priced items without precision errors
void main() {
late CartNotifier cartNotifier;
late Product product1;
late Product product2;
setUp(() {
cartNotifier = CartNotifier();
product1 = Product(id: 1, name: 'Test Product 1', price: 10.0, imageUrl: 'test1.jpg');
product2 = Product(id: 2, name: 'Test Product 2', price: 20.0, imageUrl: 'test2.jpg');
});
tearDown(() {
cartNotifier.dispose();
});
group('CartNotifier', () {
test('initial state is empty', () {
expect(cartNotifier.items.value, isEmpty);
expect(cartNotifier.totalPrice.value, 0.0);
});
test('addItem adds new item to empty cart', () {
cartNotifier.addItem(product1);
expect(cartNotifier.items.value.length, 1);
expect(cartNotifier.items.value[0].product, product1);
expect(cartNotifier.items.value[0].quantity.value, 1);
expect(cartNotifier.totalPrice.value, 10.0);
});
test('addItem increases quantity for existing item', () {
cartNotifier.addItem(product1);
cartNotifier.addItem(product1);
expect(cartNotifier.items.value.length, 1);
expect(cartNotifier.items.value[0].quantity.value, 2);
expect(cartNotifier.totalPrice.value, 20.0);
});
test('addItem adds different items separately', () {
cartNotifier.addItem(product1);
cartNotifier.addItem(product2);
expect(cartNotifier.items.value.length, 2);
expect(cartNotifier.totalPrice.value, 30.0);
});
test('removeItem removes the item from the cart', () {
cartNotifier.addItem(product1);
cartNotifier.addItem(product2);
cartNotifier.removeItem(product1);
expect(cartNotifier.items.value.length, 1);
expect(cartNotifier.items.value[0].product, product2);
expect(cartNotifier.totalPrice.value, 20.0);
});
test('removeItem does nothing if item not in cart', () {
cartNotifier.addItem(product1);
cartNotifier.removeItem(product2);
expect(cartNotifier.items.value.length, 1);
expect(cartNotifier.items.value[0].product, product1);
expect(cartNotifier.totalPrice.value, 10.0);
});
test('totalPrice updates correctly with multiple operations', () {
cartNotifier.addItem(product1);
cartNotifier.addItem(product2);
cartNotifier.addItem(product1);
expect(cartNotifier.totalPrice.value, 40.0);
cartNotifier.removeItem(product2);
expect(cartNotifier.totalPrice.value, 20.0);
});
test('adding item with quantity 0 and then incrementing works correctly', () {
cartNotifier.addItem(product1);
cartNotifier.items.value[0].quantity.value = 0;
cartNotifier.addItem(product1);
expect(cartNotifier.items.value.length, 1);
expect(cartNotifier.items.value[0].quantity.value, 1);
expect(cartNotifier.totalPrice.value, 10.0);
});
test('removing last item sets total price to 0', () {
cartNotifier.addItem(product1);
cartNotifier.removeItem(product1);
expect(cartNotifier.items.value, isEmpty);
expect(cartNotifier.totalPrice.value, 0.0);
});
test('adding item with very high price doesn\'t cause precision errors', () {
final expensiveProduct = Product(id: 3, name: 'Expensive', price: 1000000.01, imageUrl: 'expensive.jpg');
cartNotifier.addItem(expensiveProduct);
expect(cartNotifier.totalPrice.value, 1000000.01);
});
});
}
Next, let’s add a widget test our ProductListPage
. We are testing several scenarios:
Loading state when fetching products
Error state when fetching fails
Successful display of products
Adding a product to the cart
Navigating to the cart page
@GenerateNiceMocks([MockSpec<ApiService>(), MockSpec<CartNotifier>()])
import 'product_list_page_test.mocks.dart';
void main() {
late MockApiService mockApiService;
late MockCartNotifier mockCartNotifier;
late ProductCatalogNotifier productCatalogNotifier;
setUp(() {
mockApiService = MockApiService();
mockCartNotifier = MockCartNotifier();
productCatalogNotifier = ProductCatalogNotifier(mockApiService);
});
Widget createWidgetUnderTest() {
return MaterialApp(
home: ProductListPage(
productCatalog: productCatalogNotifier,
cart: mockCartNotifier,
),
);
}
group('ProductListPage', () {
testWidgets('displays loading indicator when fetching products', (WidgetTester tester) async {
when(mockApiService.fetchProducts()).thenAnswer((_) async {
await Future.delayed(Duration(seconds: 1));
return [];
});
await tester.pumpWidget(createWidgetUnderTest());
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('displays error message when fetch fails', (WidgetTester tester) async {
when(mockApiService.fetchProducts()).thenThrow(Exception('Failed to fetch products'));
await tester.pumpWidget(createWidgetUnderTest());
await tester.pumpAndSettle();
expect(find.text('Error: Failed to fetch products'), findsOneWidget);
});
testWidgets('displays list of products when fetch succeeds', (WidgetTester tester) async {
final mockProducts = [
Product(id: 1, name: 'Test Product 1', price: 10.0, imageUrl: 'test1.jpg'),
Product(id: 2, name: 'Test Product 2', price: 20.0, imageUrl: 'test2.jpg'),
];
when(mockApiService.fetchProducts()).thenAnswer((_) async => mockProducts);
await tester.pumpWidget(createWidgetUnderTest());
await tester.pumpAndSettle();
expect(find.text('Test Product 1'), findsOneWidget);
expect(find.text('Test Product 2'), findsOneWidget);
expect(find.text('\$10.00'), findsOneWidget);
expect(find.text('\$20.00'), findsOneWidget);
});
testWidgets('adds product to cart when add button is tapped', (WidgetTester tester) async {
final mockProducts = [
Product(id: 1, name: 'Test Product', price: 10.0, imageUrl: 'test.jpg'),
];
when(mockApiService.fetchProducts()).thenAnswer((_) async => mockProducts);
await tester.pumpWidget(createWidgetUnderTest());
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.add_shopping_cart));
await tester.pump();
verify(mockCartNotifier.addItem(mockProducts[0])).called(1);
});
testWidgets('navigates to cart page when cart icon is tapped', (WidgetTester tester) async {
when(mockApiService.fetchProducts()).thenAnswer((_) async => []);
await tester.pumpWidget(createWidgetUnderTest());
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.shopping_cart));
await tester.pumpAndSettle();
// Verify navigation to CartPage
// This depends on how you've set up navigation. You might need to adjust this.
expect(find.byType(CartPage), findsOneWidget);
});
});
}
We're using tester.pumpAndSettle()
to wait for all animations to complete and the widget tree to settle. These tests cover the main functionality of the ProductListPage
, including loading states, error handling, displaying products, adding to cart, and navigation. They help ensure that your UI behaves correctly under various conditions and that user interactions produce the expected results.
Remember to update these tests as you add new features or change the behavior of your ProductListPage
. Widget tests are a powerful tool for maintaining the quality and reliability of your Flutter application's UI.
Finally, let’s add a widget test for our CartPage
@GenerateNiceMocks([MockSpec<CartNotifier>()])
import 'cart_page_test.mocks.dart';
void main() {
late MockCartNotifier mockCartNotifier;
setUp(() {
mockCartNotifier = MockCartNotifier();
});
Widget createWidgetUnderTest() {
return MaterialApp(
home: CartPage(cart: mockCartNotifier),
);
}
group('CartPage', () {
testWidgets('displays empty cart message when cart is empty', (WidgetTester tester) async {
when(mockCartNotifier.items).thenReturn(ValueNotifier([]));
when(mockCartNotifier.totalPrice).thenReturn(ValueNotifier(0.0));
await tester.pumpWidget(createWidgetUnderTest());
expect(find.text('Your cart is empty'), findsOneWidget);
});
testWidgets('displays cart items when cart is not empty', (WidgetTester tester) async {
final product = Product(id: 1, name: 'Test Product', price: 10.0, imageUrl: 'test.jpg');
final cartItem = CartItem(product: product, quantity: ValueNotifier(2));
when(mockCartNotifier.items).thenReturn(ValueNotifier([cartItem]));
when(mockCartNotifier.totalPrice).thenReturn(ValueNotifier(20.0));
await tester.pumpWidget(createWidgetUnderTest());
expect(find.text('Test Product'), findsOneWidget);
expect(find.text('\$10.00'), findsOneWidget);
expect(find.text('2'), findsOneWidget);
expect(find.text('Total: \$20.00'), findsOneWidget);
});
testWidgets('increases quantity when add button is tapped', (WidgetTester tester) async {
final product = Product(id: 1, name: 'Test Product', price: 10.0, imageUrl: 'test.jpg');
final cartItem = CartItem(product: product, quantity: ValueNotifier(1));
when(mockCartNotifier.items).thenReturn(ValueNotifier([cartItem]));
when(mockCartNotifier.totalPrice).thenReturn(ValueNotifier(10.0));
await tester.pumpWidget(createWidgetUnderTest());
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
verify(mockCartNotifier.addItem(product)).called(1);
});
testWidgets('decreases quantity when remove button is tapped', (WidgetTester tester) async {
final product = Product(id: 1, name: 'Test Product', price: 10.0, imageUrl: 'test.jpg');
final cartItem = CartItem(product: product, quantity: ValueNotifier(2));
when(mockCartNotifier.items).thenReturn(ValueNotifier([cartItem]));
when(mockCartNotifier.totalPrice).thenReturn(ValueNotifier(20.0));
await tester.pumpWidget(createWidgetUnderTest());
await tester.tap(find.byIcon(Icons.remove));
await tester.pump();
verify(mockCartNotifier.decreaseQuantity(product)).called(1);
});
});
}
These tests cover the main functionality of the CartPage
, including displaying cart items, showing the total price, and handling quantity changes.
Remember to run flutter pub run build_runner build
to generate the mock classes before running the tests.
Let’s Wrap Up
ValueNotifier
and ValueListenableBuilder
provide a powerful yet simple way to manage state in Flutter applications. By using these tools along with custom widgets like MultiValueListenableBuilder
and Dart's pattern matching feature, we can create responsive, efficient, and easy-to-maintain applications.
Remember to always dispose of your ValueNotifier
s when they're no longer needed, and consider breaking down complex widgets into smaller, more manageable pieces. With these techniques, you can handle even complex state management scenarios with ease in your Flutter applications.
NOTE:
There are scenarios where ChangeNotifier might be more appropriate:
Complex State: When your state becomes more complex with multiple interdependent properties, ChangeNotifier can provide a more organized way to manage and update this state.
Multiple Listeners: If you need to notify multiple widgets about state changes and these changes might affect multiple properties at once, ChangeNotifier can be more efficient.
Business Logic: When you have significant business logic associated with your state updates, ChangeNotifier allows you to encapsulate this logic within the notifier class.
Computed Properties: If you need computed properties that depend on multiple pieces of state, ChangeNotifier can handle this more elegantly.
State Persistence: For cases where you need to persist state across app restarts or sync with external storage, ChangeNotifier can provide a centralized place to manage this.
Larger Applications: In larger applications with more complex state management needs, ChangeNotifier (often used with Provider) can offer a more scalable solution.
That said, it's important to note that you don't always need to jump to ChangeNotifier
. For many cases, especially in smaller widgets or components, ValueNotifier
and ValueListenableBuilder
(or MultiValueListenableBuilder
) can provide a simpler and more focused solution. The key is to choose the right tool for the job based on the complexity of your state management needs.