State Management with Riverpod: Building a Food Energy Tracker in Flutter
In our previous article, Flutter State Management Made Simple: From Local to Global with Provider, we explored how to manage app state effectively using Provider
. In this article, we’ll dive into Riverpod
, explore why it’s a powerful choice for state management in Flutter, and guide you through building a Food Energy Tracker app.
This series is designed for beginners, with examples that run seamlessly in DartPad. DartPad allows you to jump straight into coding without the need for setting up an IDE, making it perfect for learning Flutter’s fundamentals quickly and effectively.
What is Riverpod?
Riverpod
is a modern state management library for Flutter that offers several advantages over traditional methods like Provider
. It was created by the same author as Provider
, Remi Rousselet, to address some of the limitations and complexities developers faced with Provider
.
Why Choose Riverpod?
Here are some of the key benefits of using Riverpod:
- Compile-Time Safety: Riverpod provides compile-time safety, catching errors early in the development process, which reduces runtime crashes.
- No Dependency on
BuildContext
: UnlikeProvider
, Riverpod does not rely onBuildContext
, making it easier to manage state outside of the widget tree and ensuring that state is preserved even if the widget tree changes. - Scalability: Riverpod is highly scalable, making it suitable for both simple and complex applications. It supports advanced features like combining providers, managing state asynchronously, and more.
- Flexibility: Riverpod’s flexibility allows you to structure your state management in a way that best suits your app’s needs. Whether you need global state management or something more localized, Riverpod can handle it.
Understanding the Basics of Riverpod
Before we jump into the code, let’s cover some foundational concepts of Riverpod:
- Provider: In Riverpod, a
Provider
is a way to expose a piece of state or logic to the rest of your app. There are different types of providers, such asStateProvider
,FutureProvider
, andStreamProvider
, depending on the nature of the state you want to manage. - StateNotifier and StateNotifierProvider:
StateNotifier
is a class that helps manage complex state. It allows you to create state objects that notify listeners when the state changes.StateNotifierProvider
is used to expose this state to the rest of your app. - ConsumerWidget: A
ConsumerWidget
in Riverpod allows you to easily access and use the state provided by any provider. It’s a way to listen to changes in the state and rebuild parts of your UI accordingly.
Getting Started: Building a Food Energy Tracker
Let’s apply these concepts by building a Food Energy Tracker app. This app will allow users to:
- Add food entries with calories, proteins, fats, and carbohydrates, along with a timestamp.
- View a list of all entries, ordered by the timestamp.
- Delete entries using swipe-to-delete functionality.
We’ll build this app in DartPad, making it easy for you to follow along and experiment with the code.
Step 1: Setting Up the Project
If you’re using DartPad, you can skip the setup step since DartPad supports Riverpod out of the box. However, if you’re working in an IDE like Android Studio or VS Code, you’ll need to add the flutter_riverpod
and intl
packages to your pubspec.yaml
file:
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.5.1
intl: ^0.19.0 # For date formatting
Step 2: Setting Up the Application Structure
Before we dive into building the state management and UI components, let’s set up the basic structure of our Flutter application. This step involves creating the main entry point of the app and setting up the necessary dependencies for Riverpod and Flutter.
Here’s the code to get started:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
void main() {
runApp(ProviderScope(child: MyApp()));
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Food Energy Tracker',
theme: ThemeData(primarySwatch: Colors.blue),
home: FoodEnergyInputScreen(),
);
}
}
Explanation
- ProviderScope: This is a widget that wraps your entire app to provide the Riverpod state management functionality. It’s required to use Riverpod in your Flutter app.
- MyApp: The
MyApp
class is the root of our application. It sets up the basic theme and specifies the home screen asFoodEnergyInputScreen
, which we will build in the next steps. - MaterialApp: This is the core Flutter widget that provides the structure of your app, including routing, themes, and more.
With this structure in place, we’re ready to move on to defining our state model with Riverpod and building the UI.
Step 3: Defining the State Model with Riverpod
The state of our app will be a list of FoodEntry
objects. Each entry will track the date, time, calories, proteins, fats, and carbohydrates. We'll use a StateNotifier
to manage the list of entries and a StateNotifierProvider
to expose this state to our app.
Here’s how to define the state model:
class FoodEntry {
final DateTime dateTime;
final int calories;
final int proteins;
final int fats;
final int carbohydrates;
FoodEntry({
required this.dateTime,
required this.calories,
required this.proteins,
required this.fats,
required this.carbohydrates,
});
}
class FoodEntryList extends StateNotifier<List<FoodEntry>> {
FoodEntryList() : super([]);
void add(FoodEntry entry) {
state = [...state, entry]..sort((a, b) => b.dateTime.compareTo(a.dateTime));
}
void removeAt(int index) {
state = [...state]..removeAt(index);
}
}
final foodEntryListProvider = StateNotifierProvider<FoodEntryList, List<FoodEntry>>((ref) => FoodEntryList());
Step 4: Building the UI
Now, let’s create the user interface where users can add new food entries and view the list. We’ll use a ConsumerStatefulWidget
to manage the form inputs and display the list of entries.
class FoodEnergyInputScreen extends ConsumerStatefulWidget {
@override
_FoodEnergyInputScreenState createState() => _FoodEnergyInputScreenState();
}
class _FoodEnergyInputScreenState extends ConsumerState<FoodEnergyInputScreen> {
final DateFormat _formatter = DateFormat('yyyy-MM-dd HH:mm');
final TextEditingController _caloriesController = TextEditingController();
final TextEditingController _proteinsController = TextEditingController();
final TextEditingController _fatsController = TextEditingController();
final TextEditingController _carbohydratesController = TextEditingController();
final TextEditingController _dateTimeController = TextEditingController();
DateTime _selectedDateTime = DateTime.now();
@override
void initState() {
super.initState();
_dateTimeController.text = _formatter.format(_selectedDateTime);
}
void _addEntry() {
if (_caloriesController.text.isEmpty ||
_proteinsController.text.isEmpty ||
_fatsController.text.isEmpty ||
_carbohydratesController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Please fill in all fields')),
);
return;
}
ref.read(foodEntryListProvider.notifier).add(FoodEntry(
dateTime: _selectedDateTime,
calories: int.parse(_caloriesController.text),
proteins: int.parse(_proteinsController.text),
fats: int.parse(_fatsController.text),
carbohydrates: int.parse(_carbohydratesController.text),
));
_caloriesController.clear();
_proteinsController.clear();
_fatsController.clear();
_carbohydratesController.clear();
}
@override
void dispose() {
_caloriesController.dispose();
_proteinsController.dispose();
_fatsController.dispose();
_carbohydratesController.dispose();
_dateTimeController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final foodEntryList = ref.watch(foodEntryListProvider);
return Scaffold(
appBar: AppBar(
title: Text('Food Energy Tracker'),
),
body: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
TextField(
controller: _dateTimeController,
decoration: InputDecoration(labelText: 'Select Date & Time'),
readOnly: true,
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime(2101),
);
if (picked != null) {
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay.now(),
);
if (time != null) {
setState(() {
_selectedDateTime = DateTime(
picked.year,
picked.month,
picked.day,
time.hour,
time.minute,
);
_dateTimeController.text = _formatter.format(_selectedDateTime);
});
}
}
},
),
TextField(
controller: _caloriesController,
decoration: InputDecoration(labelText: 'Enter Calories'),
keyboardType: TextInputType.number,
),
TextField(
controller: _proteinsController,
decoration: InputDecoration(labelText: 'Enter Proteins'),
keyboardType: TextInputType.number,
),
TextField(
controller: _fatsController,
decoration: InputDecoration(labelText: 'Enter Fats'),
keyboardType: TextInputType.number,
),
TextField(
controller: _carbohydratesController,
decoration: InputDecoration(labelText: 'Enter Carbohydrates'),
keyboardType: TextInputType.number,
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 10.0),
child: ElevatedButton(
onPressed: _addEntry,
child: Text('Add Entry'),
),
),
Expanded(
child: ListView.builder(
itemCount: foodEntryList.length,
itemBuilder: (context, index) {
final entry = foodEntryList[index];
return Dismissible(
key: UniqueKey(), // Generate a unique key for each entry
direction: DismissDirection.endToStart,
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: EdgeInsets.symmetric(horizontal: 20),
child: Icon(Icons.delete, color: Colors.white),
),
onDismissed: (direction) {
setState(() {
ref.read(foodEntryListProvider.notifier).removeAt(index);
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Entry removed')),
);
},
child: Container(
padding: EdgeInsets.all(10),
color: index.isEven ? Colors.grey[300] : Colors.grey[100],
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_formatter.format(entry.dateTime),
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
SizedBox(height: 5),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Calories'),
Text('${entry.calories}'),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Proteins'),
Text('${entry.proteins}'),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Fats'),
Text('${entry.fats}'),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Carbohydrates'),
Text('${entry.carbohydrates}'),
],
),
],
),
),
);
},
),
),
],
),
),
);
}
}
Explaining setState
in the Context of Dismissible
In this example, we use setState
within the onDismissed
callback. This might seem counterintuitive when using a state management library like Riverpod, but it serves a specific purpose here.
The Dismissible
widget provides a built-in animation that needs to trigger an immediate rebuild of the widget tree once an item is dismissed. setState
forces Flutter to rebuild the tree, ensuring the dismissed widget is removed and the state reflects this change. This ensures a smooth animation and accurate state management without leaving behind any visual artifacts or causing errors.
Conclusion
In this article, we’ve explored the powerful capabilities of Riverpod for state management in Flutter. By walking through the process of building a Food Energy Tracker, we’ve demonstrated how to manage global state using StateNotifier and StateNotifierProvider, and how to handle user interactions like adding and deleting entries with the Dismissible widget.
Riverpod offers a more modern and robust approach to state management compared to older solutions like Provider, giving you compile-time safety, flexibility, and the ability to manage state outside the widget tree. Understanding these concepts and applying them in practical scenarios will help you build more scalable and maintainable Flutter applications.
As you continue your journey with Flutter, I encourage you to experiment with Riverpod further. Try adding new features to the Food Energy Tracker, such as data persistence or visualization, and see how Riverpod can simplify your code. Don’t hesitate to share your progress, thoughts, and questions in the comments. Your feedback is invaluable, and I’m excited to see what you create!
Keep coding, stay curious, and remember: every line of code you write is a step closer to mastering Flutter development.