Working with Data
Before You Begin
This guide builds upon the knowledge and project structure established in the previous guides, particularly the User Authentication guide. If you haven't completed the previous guides, you can start with the repository that ended off at the last guide by cloning https://github.com/JLogical-Apps/flood-template/tree/auth (opens in a new tab).
Domain-Driven Design (DDD)
Before we dive into the specifics of working with data in Flood, it's important to understand the concept of Domain-Driven Design (DDD) and how it's incorporated into the Drop module.
Domain-Driven Design is an approach to software development that focuses on creating a shared understanding of the problem domain between developers and domain experts. It emphasizes modeling the domain (the specific problem area) in a way that reflects the real-world concepts and relationships within that domain.
The Drop module in Flood incorporates several key concepts from DDD:
-
Entities: Objects that have a distinct identity that runs through time and different representations. In Flood, these are represented by the
Entity
class. -
Value Objects: Objects that describe some characteristic or attribute but lack a distinct identity. In Flood, these are represented by the
ValueObject
class. -
Repositories: Objects that handle the storage, retrieval, and searching of entities. In Flood, these are represented by the
Repository
class.
By leveraging these DDD concepts, Flood helps you create a more maintainable and understandable codebase that closely mirrors your application's domain.
The flood_feature
Mason Brick
To streamline the process of creating new features in your Flood project, we'll use the flood_feature
Mason brick. This brick generates the necessary files for a new feature, including a ValueObject, Entity, and Repository.
Installing Mason and the flood_feature Brick
If you haven't installed Mason yet, you can do so by running the following command:
dart pub global activate mason_cli
Once Mason is installed, add the flood_feature
brick globally:
mason add -g flood_feature
This will make the flood_feature
brick available for use in any of your Flood projects.
Using the flood_feature
Brick
To generate a new feature using the flood_feature
brick, follow these steps:
-
Navigate to your core project's features directory. For example,
example_core/lib/features
-
Run the following command:
mason make flood_feature
-
When prompted, enter the name of your feature (e.g., "user" or "todo").
This will generate three files in your
core
project under thefeatures
directory:[feature_name].dart
: Contains the ValueObject definition[feature_name]_entity.dart
: Contains the Entity definition[feature_name]_repository.dart
: Contains the Repository definition
Let's use this brick to create our User and Todo features.
Setting Up User and Todo Features
User Feature
First, let's create the User feature:
mason make flood_feature
When prompted, enter "user" as the feature name.
Now, let's modify the generated files to include the fields we need:
import 'package:flood_core/flood_core.dart';
class User extends ValueObject {
static const nameField = 'name';
late final nameProperty = field<String>(name: nameField).withDisplayName('Name').isNotBlank();
static const emailField = 'email';
late final emailProperty = field<String>(name: emailField).withDisplayName('Email').isNotBlank().isEmail();
@override
late final List<ValueObjectBehavior> behaviors = [
nameProperty,
emailProperty,
creationTime(),
];
}
Let's break down the fields in the User ValueObject:
-
nameProperty
:- Type: String
- Display Name: "Name"
- Validation: Must not be blank
- Purpose: Stores the user's name
-
emailProperty
:- Type: String
- Display Name: "Email"
- Validation: Must not be blank and must be a valid email address
- Purpose: Stores the user's email address
-
creationTime()
:- Type: Timestamp
- Purpose: Stores the time the ValueObject was created.
These fields define the structure of our User data. The isNotBlank()
and isEmail()
modifiers add validation rules to ensure data integrity.
The Entity file (user_entity.dart
) can remain empty for now, as it extends Entity<User>
which provides all the necessary functionality.
For a more detailed explanation of ValueObjects and Entities, their fields, and available options, please refer to the ValueObjects and Entities documentation.
For the Repository, we'll use a simple adapting repository without any security rules for now:
import 'package:flood_core/flood_core.dart';
import 'user.dart';
import 'user_entity.dart';
class UserRepository with IsRepositoryWrapper {
@override
late final repository = Repository.forType<UserEntity, User>(
UserEntity.new,
User.new,
entityTypeName: 'UserEntity',
valueObjectTypeName: 'User',
).adapting('user');
}
Todo Feature
Now, let's create the Todo feature:
mason make flood_feature
When prompted, enter "todo" as the feature name.
Modify the generated files as follows:
import 'package:flood_core/flood_core.dart';
import '../user/user_entity.dart';
class Todo extends ValueObject {
static const nameField = 'name';
late final nameProperty = field<String>(name: nameField).withDisplayName('Name').isNotBlank();
static const ownerField = 'owner';
late final ownerProperty = reference<UserEntity>(name: ownerField).required().hidden();
static const completedField = 'completed';
late final completedProperty = field<bool>(name: completedField).withFallback(() => false).hidden();
@override
late final List<ValueObjectBehavior> behaviors = [
nameProperty,
ownerProperty,
completedProperty,
creationTime(),
];
}
Let's break down the fields in the Todo ValueObject:
-
nameProperty
:- Type: String
- Display Name: "Name"
- Validation: Must not be blank
- Purpose: Stores the name or title of the todo item
-
ownerProperty
:- Type: Reference to UserEntity
- Validation: Required (must not be null)
- Purpose: Stores a reference to the User who owns this todo item
-
completedProperty
:- Type: Boolean
- Default Value: false (set by
withFallback
) - Purpose: Indicates whether the todo item has been completed
-
creationTime()
:- Type: Timestamp
- Purpose: Stores the time the ValueObject was created.
These fields define the structure of our Todo data. The reference<UserEntity>
type for the ownerProperty
creates a relationship between the Todo and User entities, allowing us to associate each todo item with a specific user. The hidden()
modifier prevents the field from being edited in the auto-generated Port UI.
Again, the Entity file (todo_entity.dart
) can remain empty.
For the Repository:
import 'package:flood/flood.dart';
import 'todo.dart';
import 'todo_entity.dart';
class TodoRepository with IsRepositoryWrapper {
@override
late final repository = Repository.forType<TodoEntity, Todo>(
TodoEntity.new,
Todo.new,
entityTypeName: 'TodoEntity',
valueObjectTypeName: 'Todo',
).adapting('todo');
}
Registering Repositories
Now that we have defined our User and Todo repositories, we need to register them with the CorePondContext. This step is crucial as it makes these repositories available for use throughout our application.
Open the example_core/lib/pond.dart
file and update it as follows:
...
Future<CorePondContext> getCorePondContext({...}) async {
final corePondContext = CorePondContext();
await corePondContext.register(FloodCoreComponent(
// ... existing configuration ...
));
await corePondContext.register(UserRepository());
await corePondContext.register(TodoRepository());
return corePondContext;
}
By registering these repositories, we're telling the Flood toolkit about our data models and where to store/retrieve the data. This registration process is what allows us to use these repositories throughout our application, including in our queries and data manipulation operations.
Remember to import the repository files at the top of pond.dart
as shown in the example above.
With these repositories registered, we can now use them to query and manipulate User and Todo data throughout our application.
Updating Data
Now that we have our User and Todo features set up, let's look at how to update data using the Drop Module.
Creating a User on Signup
First, we'll create a User
whenever a new user signs up. We'll update the signup port to include a name field and create the user entity with the same ID as the account ID. Modify your SignupPage
in example/lib/presentation/pages/signup_page.dart
:
import 'package:example_core/features/user/user.dart';
import 'package:example_core/features/user/user_entity.dart';
// ... existing imports and code ...
class SignupPage with IsAppPage<SignupRoute> {
@override
Widget onBuild(BuildContext context, SignupRoute route) {
final signupPort = useMemoized(() => Port.of({
'name': PortField.string().withDisplayName('Name').isNotBlank().isName(),
'email': PortField.string().withDisplayName('Email').isNotBlank().isEmail(),
'password': PortField.string().withDisplayName('Password').isNotBlank().isPassword(),
'confirmPassword': PortField.string()
.withDisplayName('Confirm Password')
.isNotBlank()
.isConfirmPassword(passwordField: 'password'),
}));
return StyledPage(
titleText: 'Signup',
body: StyledList.column.centered.withScrollbar(
children: [
...
StyledButton.strong(
labelText: 'Sign Up',
onPressed: () async {
final result = await signupPort.submit();
if (!result.isValid) {
return;
}
final data = result.data;
try {
final account = await context.authCoreComponent
.signup(AuthCredentials.email(email: data['email'], password: data['password']));
await context.dropCoreComponent.updateEntity(UserEntity()
..id = account.accountId
..set(User()
..nameProperty.set(data['name'])
..emailProperty.set(data['email'])));
context.warpTo(HomeRoute());
} catch (e, stackTrace) {
final errorText = e.as<SignupFailure>()?.displayText ?? e.toString();
signupPort.setError(path: 'email', error: errorText);
context.logError(e, stackTrace);
}
},
),
],
),
);
}
}
In this updated version:
- We've added a 'name' field to the signup port, using
isName()
to indicate it should be treated as a name input. - When creating the user entity, we now set its ID to be the same as the account ID. This allows us to easily retrieve the UserEntity based on the logged-in user ID later.
- We use the
set()
method on the UserEntity to update its properties with the User ValueObject.
This approach ensures that our User data is correctly associated with the authenticated account and includes the user's name.
Validating User Creation
After successfully signing up and restarting the app, you can validate that the UserEntity was created correctly in two ways:
-
Check the logs for an update entry: You should see a log entry similar to this:
Called [Update] with [State({someId}, UserEntity, {name: {name}, email: {someEmail}})]: [State({someId}, UserEntity, {name: {name}, email: {someEmail}})]
This log confirms that the UserEntity was updated in the repository.
-
Use the Debug Drop page: You can visually inspect the created UserEntity using the Drop debug page. To access this, navigate to
/_debug/drop
using the URL Bar Module. On this page, you can view the contents of the UserRepository and confirm that your user was created successfully.
In this screenshot, you can see the UserRepository and its contents, including the newly created UserEntity.
These validation steps help ensure that the user signup process is working correctly and that the UserEntity is being properly stored in the repository.
Adding a "Create Todo" Button
Let's add a "Create Todo" button to the home page. Modify your HomePage
in example/lib/presentation/pages/home_page.dart
:
...
return StyledPage(
titleText: 'Todos',
...,
body: StyledList.column.centered.withScrollbar(
children: [
StyledButton.strong(
labelText: 'Create Todo',
onPressed: loggedInUserId == null
? null
: () async {
await context.showStyledDialog(StyledPortDialog(
titleText: 'Create Todo',
port: (Todo()..ownerProperty.set(loggedInUserId)).asPort(context.corePondContext),
onAccept: (Todo todo) async {
await context.dropCoreComponent.updateEntity(TodoEntity()..set(todo));
},
));
},
),
],
),
);
Let's break down what's happening in this code:
-
We create a
StyledButton
labeled "Create Todo". -
The
onPressed
callback is set tonull
ifloggedInUserId
isnull
, effectively disabling the button when no user is logged in. -
When a logged-in user presses the button, we show a
StyledPortDialog
:- The dialog's title is set to "Create Todo".
- We create a new
Todo
instance and immediately set itsownerProperty
to theloggedInUserId
. This is crucial because theownerProperty
is required, and we need to set it before creating the Port. - We convert this pre-filled
Todo
into a Port usingasPort(context.corePondContext)
. This creates a form based on theTodo
's properties, but with theownerProperty
already set and hidden from the user.
-
The
onAccept
callback is triggered when the user submits the form in the dialog:- It receives a
Todo
object (todo
) that contains all the values the user entered in the form. - We then create a new
TodoEntity
and immediately set its value to thetodo
object using..set(todo)
. This copies all the properties from theTodo
to theTodoEntity
. - Finally, we call
context.dropCoreComponent.updateEntity()
to save this newTodoEntity
to the repository.
- It receives a
This approach ensures that the owner
is always set correctly (to the current user) and allows the user to fill in the other details of the Todo. It also demonstrates how to use StyledPortDialog
to create a form dialog based on a ValueObject, and how to save the resulting entity to the repository.
Querying Data
Now that we can create Todos, let's display them on the home page using the useQuery
hook.
Update your HomePage
as follows:
...
class HomePage with IsAppPageWrapper<HomeRoute> {
@override
Widget onBuild(BuildContext context, HomeRoute route) {
final loggedInUserId = useLoggedInUserIdOrNull();
final todosModel = useQuery(Query.from<TodoEntity>()
.where(Todo.ownerField)
.isEqualTo(loggedInUserId)
.orderByAscending(CreationTimeProperty.field)
.all());
return StyledPage(
titleText: 'Home',
actions: [
...
],
body: StyledList.column.centered.withScrollbar(
children: [
...
ModelBuilder(
model: todosModel,
builder: (List<TodoEntity> todoEntities) {
return StyledList.column(
ifEmptyText: 'You have no Todos!',
children: todoEntities
.map((todoEntity) => StyledCard(
titleText: todoEntity.value.nameProperty.value,
leading: StyledCheckbox(
value: todoEntity.value.completedProperty.value,
onChanged: (value) {
context.dropCoreComponent
.updateEntity(todoEntity, (Todo todo) => todo..completedProperty.set(value));
},
),
actions: ActionItem.static.entityCrudActions(context,
entity: todoEntity,
duplicator: (Todo todo) => todo..nameProperty.update((name) => '$name - Copy')),
))
.toList(),
);
},
),
],
),
);
}
}
Let's break down the key components of this code:
Querying Todos
final todosModel = useQuery(Query.from<TodoEntity>()
.where(Todo.ownerField)
.isEqualTo(loggedInUserId)
.orderByAscending(CreationTimeProperty.field)
.all());
This query does the following:
- Queries from the
TodoEntity
repository (TodoRepository
) - Filters todos where the
ownerField
matches theloggedInUserId
- Orders the results by the creation time in ascending order (oldest first)
- Retrieves all matching todos
The useQuery
hook returns a Model
that we can use in a ModelBuilder
to handle loading and error states.
Displaying Todos
ModelBuilder(
model: todosModel,
builder: (List<TodoEntity> todos) {
return StyledList.column(
ifEmptyText: 'You have no Todos!',
children: todos
.map((todoEntity) => StyledCard(
titleText: todoEntity.value.nameProperty.value,
leading: StyledCheckbox(
value: todoEntity.value.completedProperty.value,
onChanged: (value) {
context.dropCoreComponent
.updateEntity(todoEntity, (Todo todo) => todo..completedProperty.set(value));
},
),
actions: ActionItem.static.entityCrudActions(context,
entity: todoEntity,
duplicator: (Todo todo) => todo..nameProperty.update((name) => '$name - Copy')),
))
.toList(),
);
},
)
This code uses ModelBuilder
to handle the state of the todosModel
:
- If there are no todos, it displays "You have no Todos!"
- For each todo, it creates a
StyledCard
with:- The todo's name as the title
- A checkbox indicating the completed status
- Action items for editing, duplicating, and deleting the todo
CRUD Actions
The ActionItem.static.entityCrudActions
method automatically generates action items for editing, duplicating, and deleting the todo. It handles showing the Port form and updating the Drop repositories automatically, making it easy to modify entities.
The duplicator
parameter specifies how to modify a todo when duplicating. In this case, it appends " - Copy" to the name of the duplicated todo.
Updating Todo Status
To allow users to update the completed status of a todo by clicking the checkbox, we've added an onChanged
callback to the StyledCheckbox
:
StyledCheckbox(
value: todoEntity.value.completedProperty.value,
onChanged: (value) {
context.dropCoreComponent
.updateEntity(todoEntity, (Todo todo) => todo..completedProperty.set(value));
},
),
This code does the following:
- When the checkbox is toggled, the
onChanged
callback is triggered with the new value. - We use
context.dropCoreComponent.updateEntity
to update the todo entity. - The update function sets the
completedProperty
of the todo to the new value.
By using updateEntity
, we ensure that the change is immediately reflected in the UI and persisted to the repository.
With these updates, users can now view their todos, create new ones, edit existing ones, mark them as completed or incomplete, and delete them. The UI will automatically update to reflect any changes, providing a smooth and responsive user experience.
Setting up Test Data
To make testing easier, let's modify the _setupTesting
function in example/lib/main.dart
to create a user account and some test Todos:
...
Future<void> _setupTesting(CorePondContext corePondContext) async {
final account = await corePondContext.authCoreComponent
.signup(AuthCredentials.email(email: 'test@test.com', password: 'password'));
await corePondContext.dropCoreComponent.updateEntity(
UserEntity()
..id = account.accountId
..set(User()
..nameProperty.set('Test')
..emailProperty.set('test@test.com')),
);
// Create some test Todos
final todoNames = ['Buy groceries', 'Finish project', 'Call mom', 'Go for a run', 'Read a book'];
for (var i = 0; i < todoNames.length; i++) {
await corePondContext.dropCoreComponent.updateEntity(TodoEntity()
..set(
Todo()
..nameProperty.set(todoNames[i])
..ownerProperty.set(account.accountId)
..completedProperty.set(i == 0), // Only the first Todo should be completed
));
}
}
This setup does the following:
-
We use
signup
instead ofcreateAccount
. This change is important becausesignup
not only creates the account but also logs the user in. This means that when the app is restarted in the testing environment, the test user will already be logged in. -
We create a
UserEntity
for the test account, setting the name and email properties. -
We create five test Todos with different names. Each Todo is associated with the test user's account ID, and we alternate between setting them as completed and not completed.
By using signup
, we ensure that the test user is logged in when the app starts in the testing environment. This allows you to immediately see the list of Todos on the home page without having to manually log in each time you restart the app for testing.
Remember that this test data setup only runs in the testing
environment. In production or when users are actually using your app, they would go through the normal signup process and create their own Todos.
This approach provides a consistent starting point for testing your app's functionality, allowing you to verify that the Todo list, creation, editing, and completion features are working correctly each time you run the app in the testing environment.
Next Steps
Congratulations! You've now learned the basics of working with data in Flood. You can create, update, query, and display entities using the Drop module. With this knowledge, you can start building more complex applications.
In the upcoming guides, we'll explore more advanced topics to help you fine-tune your applications and add security.