EntityStore enhances Flutter application development by offering state management centered around entities. This library encapsulates the application's business logic within immutable entities and maintains UI consistency through centralized state management.
The following TodoTile component example demonstrates how EntityStore connects UI components with their state.
class TodoTile extends StatelessWidget {
// ...
@override
Widget build(BuildContext context) {
// Monitor a specific Todo
final todo = context.watchOne<int, Todo>(todoId)!;
// Toggle the completion state of Todo
return CheckboxListTile(
title: Text(todo.name),
value: todo.isDone,
onChanged: (bool? value) {
if (value != null) {
todoRepository.save(todo.toggle());
}
},
);
}
}
- Reactive UI Synchronization: EntityStore reflects state changes reactively on the UI. In the code example above, the
watchOne
method is used to monitor a specific Todo entity, and updates the checkbox display when its state changes. - Concise State Updates: The state of an entity is updated through a new instance only when necessary. Toggling the completion state of a Todo is easily done by calling
todo.toggle()
, and the result is persisted through the repository. - Central Handling of Entities: EntityStore focuses on the entities that form the core part of the application, allowing developers to concentrate on business logic.
- Flexible Database Integration: EntityStore facilitates the integration with external data sources. By swapping out repository implementations, it is possible to connect with Firebase Firestore, local databases, or other data storages.
- Boilerplate Reduction: The use of pre-prepared repository implementations eliminates the need for developers to write repetitive database operation code. This accelerates the development process and eases the maintenance of applications.
To introduce the EntityStore package into your Flutter project, add the following to your pubspec.yaml
file:
dependencies:
entity_store: latest_version
The following sample code demonstrates the implementation of a Todo application using EntityStore.
An entity encapsulates core business logic in your application and has a unique identifier (ID). This enables entities to be identifiable throughout the application, maintaining data integrity. In EntityStore, entities are designed to be immutable. This is important for detecting changes in entity states and ensuring they are appropriately reflected in the UI.
The Todo
class is an example that implements this concept. Each Todo
item has a unique ID, a name, and a completion status attribute. The create
factory method allows you to create a new Todo
instance with a random name. The toggle
method is used to toggle the completion status of a Todo
, returning a new Todo
instance with the updated status.
class Todo implements Entity<int> {
// Entity attributes must be immutable.
@override
final int id;
final String name;
final bool isDone;
Todo(this.id, this.name, this.isDone);
// Create a new Todo entity
factory Todo.create(int id) {
return Todo(
id,
getRandomTodoName(),
false,
);
}
// Toggle the completion status of a Todo entity
Todo toggle() {
return Todo(
id,
name,
!isDone,
);
}
}
This immutable design approach allows each instance of Todo
to be reusable, and state changes can only be made through the creation of new instances, preventing unexpected side effects and making state management more predictable. It also reduces complexity when detecting entity changes and updating the UI, improving the maintainability of the application.
One of the powerful features of EntityStore is the implementation of the repository pattern. Following this pattern, the TodoRepository
extends LocalStorageRepository
and defines its own handling of saving, reading, and deleting entities by overriding inherited methods.
The TodoRepository
is a specific implementation of the LocalStorageRepository
for Todo
entities. It overrides the fromJson
and toJson
methods for reading and saving JSON data, making it easy to convert between entities and data storage.
class TodoRepository extends LocalStorageRepository<int, Todo> {
TodoRepository(super.controller, super.localStorageHandler);
@override
Todo fromJson(Map<String, dynamic> json) {
// Create a Todo entity from JSON
}
@override
Map<String, dynamic> toJson(Todo entity) {
// Convert a Todo entity to JSON
}
}
The EntityStore package provides InMemoryStorageHandler
by default, which is a simple storage handler that stores data only in memory. This allows for easy state management during development and testing. However, in a real application, you can implement a custom LocalStorageHandler
to store data in the device's local storage.
class InMemoryStorageHandler extends LocalStorageHandler {
// Data operations in memory
}
To set up EntityStore, initialize EntityStoreNotifier
or EntityStoreController
, and associate them with the repository as follows:
final entityStoreNotifier = EntityStoreNotifier();
final entityStoreController = EntityStoreController(entityStoreNotifier);
final storageHandler = InMemoryStorageHandler();
final todoRepository = TodoRepository(entityStoreController, storageHandler);
While the code snippet above uses global variables for simplicity, it is recommended to use dependency injection (DI) using containers like Riverpod's Provider
or GetIt for a more robust design. By doing so, you can efficiently resolve dependencies needed by different parts of your application, improving testability and code reusability.
Example:
final entityStoreProvider = Provider((ref) => EntityStoreNotifier());
final entityStoreControllerProvider = Provider((ref) => EntityStoreController(ref.watch(entityStoreProvider)));
// ...
// In other parts of the application
final entityStoreNotifier = ref.watch(entityStoreProvider);
final entityStoreController = ref.watch(entityStoreControllerProvider);
By adopting this approach, you make dependencies required by various parts of your application more explicit and achieve a design that is resilient to changes and extensions.
The first step in using the EntityStore package is to set up an EntityStoreProviderScope
at the top level of your application. This creates a foundation for monitoring and sharing state changes throughout the entire application.
class MyApp extends StatelessWidget {
// ...
@override
Widget build(BuildContext context) {
// Place the EntityStoreProviderScope at the root of the app
return EntityStoreProviderScope(
entityStoreNotifier: entityStoreNotifier,
child: MaterialApp(
// ...
),
);
}
}
You can use selectAll
to retrieve the IDs of all Todo entities and display them as a list in the UI. This way, you monitor the entire Todo list and update the list whenever a new Todo is added.
context.watchOne
monitors specific entities. By utilizing these methods, changes in state are automatically reflected in the UI.
class MyHomePage extends StatefulWidget {
// ...
@override
Widget build(BuildContext context) {
// Get IDs of all Todos
final todoIds = context.selectAll<int, Todo, List<int>>(
(value) => value.ids.toList(),
);
// ...
}
}
When you manipulate entities through the repository, the state in EntityStore
is updated, and related UI components are automatically updated.
class TodoTile extends StatelessWidget {
// ...
@override
Widget build(BuildContext context) {
// Monitor a specific Todo
final todo = context.watchOne<int, Todo>(todoId);
// Toggle the completion status of Todo
return CheckboxListTile(
title: Text(todo.name),
value: todo.isDone,
onChanged: (bool? value) {
if (value != null) {
// Update the entity through the repository
todoRepository.save(todo.toggle());
}
},
);
}
}
The watch
, select
, and read
methods provided by EntityStore are essential tools for monitoring application state and updating the UI accordingly. These methods help maintain synchronization between the state managed by EntityStore and UI components.
You can use the watchAll
method to monitor only incomplete Todos in real-time and update the list whenever changes occur.
final activeTodos = context.watchAll<int, Todo>(
(todo) => !todo.isDone,
);
ListView.builder(
itemCount: activeTodos.length,
itemBuilder: (context, index) {
final todo = activeTodos.values.elementAt(index);
return ListTile(
title: Text(todo.name),
leading: Checkbox(
value: todo.isDone,
onChanged: (bool? checked) {
// Logic to update the completion status of Todo
},
),
);
},
);
You can use the watchOne
method to monitor changes in the state of a specific Todo entity and update only that Todo.
final todo = context.watchOne<int, Todo>(todoId);
if (todo != null) {
return CheckboxListTile(
title: Text(todo.name),
value: todo.isDone,
onChanged: (bool? newValue) {
// Logic to update the state of Todo
},
);
}
You can use the selectAll
method to retrieve specific data (e.g., names) from the entire Todo list and display only that data.
final todoNames = context.selectAll<int, Todo, List<String>>(
(todos) => todos.values.map((todo) => todo.name).toList(),
);
ListView(
children: todoNames.map((name) => Text(name)).toList(),
);
You can use the selectOne
method to retrieve specific data from a single Todo and update a widget that displays that data when the name of the Todo changes.
final todoName = context.selectOne<int, Todo, String>(
todoId,
(todo) => todo.name,
);
Text(todoName ?? 'No name');
You can use the readAll
method to retrieve all Todos once during the initial load of the screen and use the data without triggering a rebuild.
final allTodos = context.readAll<int, Todo>();
// Display all Todos during the initial load
ListView(
children: allTodos.values.map((todo) => Text(todo.name)).toList(),
);
You can use the readOne
method to perform a one-time data read based on a user action, such as displaying Todo details in a dialog.
final todo = context.readOne<int, Todo>(todoId);
// Display a dialog triggered by a user action
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text(todo?.name ?? 'No name'),
content: Text('Completed: ${todo?.isDone}'),
);
},
);
By using these methods, you can efficiently read and update state in EntityStore while ensuring that changes in the state of your application are reflected in the UI as needed.
Repositories are designed to abstract data source operations and separate business logic from data access code. In EntityStore, operations on entities performed through repositories are automatically reflected in the UI. This means that when you save, update, or delete entities through a repository, these changes are automatically propagated to the UI, allowing users to immediately see the latest state.
This behavior is achieved by using reactive methods like watch
, select
, etc., in combination with EntityStore, which ensures synchronization between the state managed by EntityStore and UI components. Below are basic repository operations and their usage examples.
Get an entity with the specified ID.
// Example: Finding a Todo by ID
var result = await todoRepository.findById(todoId);
if (result.isOk) {
var todo = result.ok;
// Perform Todo-related operations here
}
Retrieve all entities.
// Example: Get all Todos that meet specific criteria
var result = await todoRepository.query()
.where('isComplete', isEqualTo: false)
.findAll();
if (result.isOk) {
var todos = result.ok;
// Perform operations on the Todo list here
}
Retrieve the first entity that matches the specified criteria.
// Example: Find the first Todo that meets specific criteria
var result = await todoRepository.query()
.where('isComplete', isEqualTo: false)
.findOne();
if (result.isOk) {
var todo = result.ok;
// Perform Todo-related operations here
}
Count the number of entities that match the specified criteria.
// Example: Count the number of incomplete Todos
var result = await todoRepository.query()
.where('isComplete', isEqualTo: false)
.count();
if (result.isOk) {
var activeCount = result.ok;
// Perform operations using activeCount here
}
Save or update an entity.
// Example: Save a new Todo
var newTodo = Todo.create(name: 'New Task');
var result = await todoRepository.save(newTodo);
if (result.isOk) {
// Handle the success of the save operation here
}
Delete an entity.
// Example: Delete a Todo
var result = await todoRepository.delete(todoId);
if (result.isOk) {
// Handle the success of the delete operation here
}
Create a new entity if it doesn't exist or update it if it does.
// Example: Upsert a Todo
var result = await todoRepository.upsert(
todoId,
creater: () => Todo.create(name: 'New Task'),
updater: (existingTodo) => existingTodo.copyWith(isComplete: true),
);
if (result.isOk) {
// Handle the success of the upsert operation here
}
Through these operations, the application can efficiently manage data within EntityStore and maintain data consistency.
This project is released under the MIT license. Please refer to the LICENSE file for details.