Model
When building a page in a Flutter application, you often need to load data from an API, show a loading indicator while the data is being fetched, display an error message if the request fails, and finally render the data once it's available. Additionally, if the data changes, you want your UI to reactively update to reflect those changes.
Handling all these scenarios manually can be tedious and error-prone. The Model
class in the Flood toolkit abstracts this process, making it extremely easy to work with asynchronous, changing, and reactive data.
Model
A Model
represents a ValueStream of states that can be "loaded". For example, you can create a Model
that loads data from an API:
final model = Model(loader: () => getApiData(...));
model.state.isEmpty; // true
model.getOrNull(); // null
You can subscribe to the Model
's state stream to observe how its state changes over time. Let's subscribe to it and see how it updates as we load the model:
final listener = model.stateX.listen((state) => print(state));
await model.load();
model.getOrNull(); // ApiData
// PRINTS
// FutureValue.empty()
// FutureValue.loading()
// FutureValue.loaded(ApiData)
Whenever you need to reload the data from the API (for example, when the user hits a "Refresh" button), you can simply call model.load()
to fetch the latest data.
Mapping Models
Sometimes you may have a Model
but need to transform its loaded value into something else. For example, if you have a function like:
Model<ApiData> getModelFromWebsite(String apiUrl) {...}
You may want to use the function's Model
as a base but only retrieve the response code. You can map the Model
like this:
final responseCodeModel = getModelFromWebsite('https://www.example.com').map((response) => response.code);
With responseCodeModel
, you get only the part of the Model
you need.
You can chain multiple map
calls together to compose Model
s exactly how you want them.
Loading
Consider the following example:
final model1 = Model(() => aRandomInt());
final model2 = model1.map((value) => value.toString());
model2
depends on model1
. If you call model1.load()
, it will set its value to a new random integer, which will also update the value of model2
to the string version of that integer. Similarly, calling model2.load()
will also reload model1
since model2
depends on model1
.
This can be useful if you have a base Model
that is used by many other Model
s. For example, a settingsModel
that returns a JSON object of the user's settings. You may want to create separate Model
s that map to specific attributes from the settingsModel
:
final settingsColorModel = settingsModel.map((settings) => settings['color']);
If you update the settingsModel
, all other Model
s that depend on it will be updated as well, which you can use to update your UI.
Transforming Models
Here are some other ways you can transform Model
s:
asyncMap
: Map aModel
to another value asynchronously. Loadingmodel.asyncMap((value) async => await myFuture(value))
will loadmodel
and use its value to compute the result ofawait myFuture(value)
. While theFuture
is being processed, the entireModel
is considered "loading" until it completes.flatMap
: Map aModel
to anotherModel
. Loadingmodel.flatMap((value) => someOtherModel(value))
will loadmodel
and use its value to create anotherModel
, and then load thatModel
. The entireModel
is considered "loading" until the generatedModel
completes loading.
ModelBuilder
Model
s can be in one of four states: empty, loading, loaded, or error. Rendering the result of a Model
can be tedious, as you need to listen to the value stream of the model, check its state, and render widgets accordingly. The ModelBuilder
widget simplifies the process of rendering a Model
.
Simply provide a Model
and a Builder
, and the ModelBuilder
will listen to the Model
, and when its state is loaded, it will use the Builder
to build the widget representing that value.
Widget build(BuildContext context) {
final settingsModel = useMemoized(() => getSettingsModel());
return ModelBuilder(
model: settingsModel,
builder: (Settings settings) {
return ...;
},
);
}
The ModelBuilder
will run the Builder
with the loaded value of settingsModel
. While the Model
is loading, it will render a StyledLoadingIndicator()
(this can be overridden by passing a loadingIndicator
to the ModelBuilder
). If the Model
has an error, it will print the error and render it using StyledText.body.error()
(this can be overridden by passing an errorBuilder
to the ModelBuilder
). If the Model
is loaded, it will use the Builder
to generate the child widget.
Hooks
The Flood toolkit provides a few hooks that can be helpful when using flutter_hooks (opens in a new tab):
useModel(Model model)
: Listens to the state of theModel
and returns its current state.useModels(List<Model> models)
: Listens to the state of all theModel
s and returns a list of their current states.useFutureModel(() async => await myFuture())
: Creates a newModel
based on the providedFuture
, listens to thatModel
, and returns the generatedModel
. You can callmodel.load()
to run theFuture
again and update the state of theModel
.
These hooks make it even easier to integrate Model
s into your Flutter application, allowing you to reactively update your UI based on the state of your data.