Flutter Data is organized around the concept of models which are data classes extending DataModel
.
@DataRepository([TaskAdapter])
class Task extends DataModel<Task> {
@override
final int? id;
final String title;
final bool completed;
Task({this.id, required this.title, this.completed = false});
// ...
}
When annotated with @DataRepository
(and adapters as arguments, as we’ll see later) a model gets its own fully-fledged repository.
Repository
is the API used to interact with models, whether local or remote.
Assuming a Task
model and its corresponding Repository<Task>
, let’s see how to retrieve such resources from an API.
Using ref.tasks
(short for ref.watch(tasksRepositoryProvider)
) to obtain a repository we can find all resources in the collection.
Repository<Task> repository = ref.tasks;
final tasks = await repository.findAll();
// GET http://base.url/tasks
This async call triggered a request to GET http://base.url/tasks
.
How exactly does Flutter Data resolve the http://base.url/tasks
URL?
Flutter Data adapters define functions and getters such as urlForFindAll
, baseUrl
and type
among many others.
In this case, findAll
will look up information in baseUrl
and urlForFindAll
(which defaults to type
, and type
defaults to tasks
).
Result? http://base.url/tasks
.
And, how exactly does Flutter Data instantiate Task
models?
Flutter Data ships with a built-in serializer/deserializer for classic JSON. It means that the default serialized form of a Task
instance looks like:
{
"id": 1,
"title": "delectus aut autem",
"completed": false
}
Of course, this too can be overridden like the JSON API Adapter does.
Method signature:
Future<List<T>?> findAll({
bool? remote,
bool? background,
Map<String, dynamic>? params,
Map<String, String>? headers,
bool? syncLocal,
OnSuccessAll<T>? onSuccess,
OnErrorAll<T>? onError,
DataRequestLabel? label,
});
Further information on the remote
, background
, params
, headers
, onSuccess
, onError
and label
arguments available in common arguments below.
The syncLocal
argument instructs local storage to synchronize the exact resources returned from the remote source (for example, to reflect server-side deletions).
final tasks = await ref.tasks.findAll(syncLocal: true);
Consider this example:
If a first call to findAll
returns data for task IDs 1
, 2
, 3
and a second call updated data for 2
, 3
, 4
you will end up in your local storage with: 1
, 2
(updated), 3
(updated) and 4
.
Passing syncLocal: true
to the second call will leave the local storage state with 2
, 3
and 4
.
Finds a resource by ID and saves it in local storage.
final task = await ref.tasks.findOne(1);
// GET http://base.url/tasks/1
Similar to what’s shown above in findAll, Flutter Data resolves the URL by using the urlForFindOne
function. We can override this in an adapter.
For example, use path /tasks/something/1
:
mixin TaskURLAdapter on RemoteAdapter<Task> {
@override
String urlForFindOne(id, params) => '$type/something/$id';
}
// would result in GET http://base.url/tasks/something/1
It can also take a T
with an ID:
final task = await ref.tasks.findOne(anotherTaskWithId3);
// GET http://base.url/tasks/3
Method signature:
Future<T?> findOne(
Object id, {
bool? remote,
bool? background,
Map<String, dynamic>? params,
Map<String, String>? headers,
OnSuccessOne<T>? onSuccess,
OnErrorOne<T>? onError,
DataRequestLabel? label,
});
The remote
, background
, params
, headers
, onSuccess
, onError
and label
arguments are detailed in common arguments below.
Persists a model to local storage and remote.
final savedTask = await repository.save(task);
Want to use the PUT
verb instead of PATCH
? Use this adapter:
mixin TaskURLAdapter on RemoteAdapter<Task> {
@override
String methodForSave(id, params) => id != null ? DataRequestMethod.PUT : DataRequestMethod.POST;
}
Method signature:
Future<T> save(
T model, {
bool? remote,
Map<String, dynamic>? params,
Map<String, String>? headers,
OnSuccessOne<T>? onSuccess,
OnErrorOne<T>? onError,
DataRequestLabel? label,
});
The remote
, params
, headers
, onSuccess
, onError
and label
arguments are detailed in common arguments below.
Deletes a model from local storage and sends a DELETE
HTTP request.
await repository.delete(model);
Method signature:
Future<T?> delete(
Object model, {
bool? remote,
Map<String, dynamic>? params,
Map<String, String>? headers,
OnSuccessOne<T>? onSuccess,
OnErrorOne<T>? onError,
DataRequestLabel? label,
});
The remote
, params
, headers
, onSuccess
, onError
and label
arguments are detailed in common arguments below.
DataState
is a class that holds state related to resource fetching and is practical in UI applications. It is returned in watchAll
and watchOne
.
class DataState<T> with EquatableMixin {
T model;
bool isLoading;
DataException? exception;
StackTrace? stackTrace;
// ...
}
It’s typically used in a widget’s build
method like:
class MyApp extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.tasks.watchAll();
if (state.isLoading) {
return CircularProgressIndicator();
}
if (state.hasException) {
return ErrorScreen(state.exception, state.stackTrace);
}
return ListView(
children: [
for (final task in state.model)
Text(task.title),
// ...
}
}
Why not used a Freezed union instead?
Because without forcing to branch, DataState
easily allows rebuilding widgets when multiple substates happen simultaneously – a very common pattern. The tradeoff is having to remember to check for the loading
and error
substates.
Watches all models of a given type in local storage (through ref.watch
and watchAllNotifier
).
For updates to any model of type Task
to prompt a rebuild we can use:
class TasksScreen extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.tasks.watchAll();
if (state.isLoading) {
return CircularProgressIndicator();
}
// use state.model which is a List<Task>
}
);
By default when first rendered it triggers a background findAll
call with remote
, params
, headers
, syncLocal
and label
arguments. See common arguments.
Method signature:
DataState<List<T>?> watchAll({
bool? remote,
Map<String, dynamic>? params,
Map<String, String>? headers,
bool? syncLocal,
String? finder,
DataRequestLabel? label,
});
But this can easily be overridden. Any method in the adapter with the exact findAll
method signature and annotated with @DataFinder()
will be available to supply to the finder
argument as a string (method name).
Pass remote: false
to prevent any remote fetch at all.
watchAllProvider
and watchAllNotifier
are also available.Watches a model of a given type in local storage (through ref.watch
and watchOneNotifier
).
For updates to a given model of type Task
to prompt a rebuild we can use:
class TaskScreen extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.tasks.watchOne(1);
if (state.isLoading) {
return CircularProgressIndicator();
}
// use state.model which is a Task
}
);
By default when first rendered it triggers a background findOne
call with model
, remote
, params
, headers
and label
arguments. See common arguments.
Method signature:
DataState<T?> watchOne(
Object model, {
bool? remote,
Map<String, dynamic>? params,
Map<String, String>? headers,
AlsoWatch<T>? alsoWatch,
String? finder,
DataRequestLabel? label,
});
But this can easily be overridden. Any method in the adapter with the exact findOne
method signature and annotated with @DataFinder()
will be available to supply to the finder
argument as a string (method name).
Pass remote: false
to prevent any remote fetch at all.
In addition, this watcher can react to relationships via alsoWatch
:
watchOneNotifier(3, alsoWatch: (task) => [task.user]);
This feature is extremely powerful, actually any number of relationships can be watched:
watchOneNotifier(3, alsoWatch: (task) => [task.reminders, task.user, task.user.profile, task.user.profile.comments]);
watchOneProvider
and watchOneNotifier
are also available.This method takes a DataModel
and watches its local changes.
class TaskScreen extends HookConsumerWidget {
final Task model;
@override
Widget build(BuildContext context, WidgetRef ref) {
final task = ref.tasks.watch(model);
return Text(task.title);
}
);
Note that it returns a model, not a DataState
.
Obtain the notifier for a model. Does not trigger a remote request.
final notifier = ref.tasks.notifierFor(task);
// equivalent to
ref.tasks.watchOneNotifier(task, remote: false);
By default, changes will be notified immediately and trigger widget rebuilds.
For performance improvements, they can be throttled by overriding graphNotifierThrottleDurationProvider
.
Request only models in local storage:
final tasks = await ref.tasks.findAll(remote: false);
Argument is of type bool
and the default is true
.
In addition to adapters, the @DataRepository
annotation can take a remote
boolean argument which will make it the default for the repository.
@DataRepository([TaskAdapter], remote: false)
class Task extends DataModel<Task> {
// by default no operation hits the remote endpoint
}
Default false
. Calling a finder with background = true
will make it return immediately with the current value in local storage while triggering a remote request in the background. This is typically useful when using this primitive in certain adapter customizations.
Include query parameters (of type Map<String, dynamic>
, in this case used for pagination and resource inclusion):
final tasks = await ref.tasks.findAll(
params: {'include': 'comments', 'page': { 'limit': 20 }}
);
// GET http://base.url/tasks?include=comments&page[limit]=20
Include HTTP headers as a Map<String, String>
:
final tasks = await ref.tasks.findAll(
headers: { 'Authorization': 't0k3n' }
);
Overrides the handler for the success state, useful when requiring a specific transformation of raw response data.
await ref.tasks.save(
task,
onSuccess: (data, label, adapter) async {
final model = await adapter.onSuccess(data, label);
return model as Task;
},
);
RemoteAdapter#onSuccess
is the default (overridable, of course). It esentially boils down to calls to deserialize
.
Overrides the error handler:
await ref.tasks.save(
task,
onError: (error, label, adapter) async {
throw WrappedException(error);
},
);
RemoteAdapter#onError
is the default (overridable, of course). It essentially rethrows the error except if it is due to loss of connectivity or the remote resource was not found (HTTP 404).
Optional argument of type DataRequestLabel
. See below.
Labels are used in Flutter Data to easily track requests in logs. They carry an auto-generated requestId
along with type and ID. They are provided by default and also by default finders log different events.
Some examples:
findAll/tasks@b5d14c
findOne/users#3@c4a1bb
findAll/tasks@b5d14c<c4a1bb
Request IDs can be nested like the last one above, where the b5d14c
call happened inside c4a1bb
. In other words, during the request for User
with ID 3
, a collection of tasks was also requested (presumably for that same user).
In the console these would be logged as:
flutter: 05:961 [findAll/tasks@b5d14c] requesting
flutter: 05:973 [findOne/users#3@c4a1bb] requesting
flutter: 05:974 [findAll/tasks@b5d14c<c4a1bb] requesting
with the nested labels properly indented.
Watchers also output useful logs in level 1 and 2.
Log levels can be set via the logLevel
argument in log
, and to adjust the global level:
ref.tasks.logLevel = 2;
In order to log custom information use something like:
final label = DataRequestLabel('save', type: 'users', id: '3');
ref.tasks.log(label, 'testing labels');
// or
final nestedLabel1 = DataRequestLabel('findAll',
type: 'other', requestId: 'ff01b1', withParent: label);
ref.tasks.log(label, 'testing nested labels', logLevel: 2);
See Local Adapters.
Flutter Data uses a reactive bidirectional graph data structure to keep track of relationships and key-ID mappings. Changes on this graph will trigger updates to the watchers, by default once per change but a throttle can be configured to prevent superfluous re-renders.
graphNotifierThrottleDurationProvider
.overrideWithValue(Duration(milliseconds: 100)),
This is the dependency graph for an app with models User
and Task
:
Clients should only interact with repositories and adapters, while using the Adapter API to customize behavior.