Repositories

Flutter Data is organized around the concept of models which are data classes mixing in DataModel.

@DataRepository([TaskAdapter])
class Task with 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 they are local or remote.

Assuming a Task model and its corresponding Repository<Task>, let’s first see how to retrieve a collection of resources from an API.

findAll

Repository<Task> repository = ref.tasks;
final tasks = await repository.findAll();

// GET http://base.url/tasks

This async call triggered an HTTP request to GET http://base.url/tasks.

There are multiple ways to obtain a Repository: ref.tasks will work in a typical Flutter Riverpod app (essentially a shortcut to ref.watch(tasksRepositoryProvider)).

Understanding the magic ✨

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 fetch data from baseUrl + 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.

We can include query parameters (in this case used for pagination and resource inclusion):

final tasks = await repository.findAll(params: {'include': 'comments', 'page': { 'limit': 20 }});

// GET http://base.url/tasks?include=comments&page[limit]=20

Or headers:

final tasks = await repository.findAll(headers: { 'Authorization': 't0k3n' });

We can also request only for models in local storage:

final tasks = await repository.findAll(remote: false);

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 with DataModel<Task> {
  // by default no operation hits the remote endpoint
}

And syncLocal which synchronizes local storage with the exact resources returned from the remote source (for example, to reflect server-side resource deletions).

final tasks = await repository.findAll(syncLocal: true);

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.

watchAll

Watches all models of a given type in local storage through a Riverpod ref.watch, wrapping the 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>
  }
);

watchAllNotifier

Returns a DataState StateNotifier which notifies changes on all models of a given type in local storage.

Will invoke findAll in the background with remote, params, headers and syncLocal.

findOne

Finds a resource by ID and saves it in local storage.

Repository<Task> repository;
final task = await repository.findOne(1);

// GET http://base.url/tasks/1

As explained 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';
}

Takes remote, params and headers as named arguments.

watchOne

Watches a model of a given type in local storage through a Riverpod ref.watch, wrapping the 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(3);
    if (state.isLoading) {
      return CircularProgressIndicator();
    }
    // use state.model which is a Task
  }
);

watchOneNotifier

Returns a DataState StateNotifier which notifies changes on a model of a given type in local storage.

Will invoke findOne in the background with remote, params and headers.

It can additionally react to selected relationships of this model via alsoWatch:

watchOneNotifier(3, alsoWatch: (task) => [task.user]);

save

Saves a model to local storage and remote.

final savedTask = await repository.save(task);

Takes remote, params and headers as named arguments.

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;
}

delete

Deletes a model from local storage and sends a DELETE HTTP request.

await repository.delete(model);

Takes remote, params and headers as named arguments.

DataState

DataState is a class that holds state related to resource fetching and is practical in UI applications. It is returned in all Flutter Data watchers.

It has the following attributes:

T model;
bool isLoading;
DataException? exception;
StackTrace? stackTrace;

And it’s typically used in a Flutter 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),
    // ...
  }
}

Custom endpoints

Awful APIs also supported 😄

As we saw, CRUD endpoints can be customized via urlFor* methods.

But ad-hoc endpoints? They are not a rare finding in most REST APIs.

We’ll see how to to implement these in the next section, Adapters.

Architecture overview

This is the dependency graph for an app with models User and Task:

Clients will only interact with providers and repositories, while using the Adapter API to customize behavior. As a matter of fact, Flutter Data itself extends this API internally to add serialization, watchers and offline features.

LocalAdapter, GraphNotifier and the usage of Hive are internal concerns. Do not use LocalAdapters for local storage capabilities; these are all exposed via the Repository and RemoteAdapter APIs.

Need professional help with Flutter?

Describe your project in detail and include your e-mail and budget.