Flutter Data Documentation

repository·master·Indexed 19 days ago

https://github.com/flutterdata/flutter_data

A local-first data framework for Flutter and Dart built on Riverpod. It provides reactive models, customizable REST adapters, and relationship management (BelongsTo, HasMany) using code generation. Flutter Data manages data synchronization between local storage (such as SQLite3) and remote endpoints via generated Adapter<T> implementations.

Tokens
8.4K
Snippets
30
Records
33
Agent score
65%

What's inside Flutter Data

  1. Quick introduction to Flutter Data

    master

    Flutter Data is a local-first data framework built on Riverpod. It uses adapters to manage data synchronization between local storage (like SQLite3) and remote endpoints.

    Every model extends DataModel<T> and can be annotated with @DataAdapter to specify custom logic. After running code generation, an Adapter<T> is automatically created and made available via Riverpod providers (e.g., ref.users or container.users).

    Key workflows:

    • Watching data: Use ref.users.watchOne(id) to get a DataState<T?>. This triggers a background HTTP request and listens for local changes.
    • Updating data: Use ref.users.save(model) or the ActiveRecord-style extension method model.save() on the model instance itself.
    @JsonSerializable()
    @DataAdapter([MyJSONServerAdapter])
    class User extends DataModel<User> {
      @override
      final int? id;
      final String name;
      User({this.id, required this.name});
    }
    
    mixin MyJSONServerAdapter on RemoteAdapter<User> {
      @override
      String get baseUrl => "https://my-json-server.typicode.com/flutterdata/demo/";
    }
    
    // Usage in a Widget
    @override
    Widget build(BuildContext context, WidgetRef ref) {
      final state = ref.users.watchOne(1);
      if (state.isLoading) {
        return Center(child: const CircularProgressIndicator());
      }
      final user = state.model;
      return Text(user.name);
    }
  2. Initialize Flutter Data

    master

    To use Flutter Data, you must first configure a localStorageProvider in your ProviderScope and then call initializeFlutterData within your widget tree.

    1. Configure Local Storage: Provide a LocalStorage instance. You can use path_provider to determine the base directory.
    2. Initialize: Use the initializeFlutterData provider, passing in the adapterProvidersMap (which is automatically generated by code-gen in main.data.dart).
    // 1. Configure Local Storage in ProviderScope
    ProviderScope(
      overrides: [
        localStorageProvider.overrideWithValue(
          LocalStorage(
            baseDirFn: () async {
              return (await getApplicationSupportDirectory()).path;
            },
            busyTimeout: 5000,
            clear: LocalStorageClearStrategy.never,
          ),
        )
      ],
      child: MyApp(),
    )
    
    // 2. Initialize in your Widget tree
    @override
    Widget build(BuildContext context, WidgetRef ref) {
      return Scaffold(
        body: ref.watch(initializeFlutterData(adapterProvidersMap)).when(
          data: (_) => child,
          error: (e, _) => const Text('Error'),
          loading: () => const Center(child: CircularProgressIndicator()),
        ),
      );
    }
  3. Understand DataRequestLabel for request tracking

    master

    A DataRequestLabel is used to uniquely identify and track data requests within the framework. It encodes the request kind, the model type, the model ID, and a unique request ID. This is useful for debugging and logging the lifecycle of specific requests.

    Labels follow the format: kind/type#id@requestId.

    Examples:

    • findAll/reports@b5d14c
    • findOne/inspections#3@c4a1bb

    You can parse a label string back into an object using DataRequestLabel.parse(text).

    final label = DataRequestLabel.parse('findOne/inspections#3@c4a1bb');
    print(label.type); // inspections
    print(label.id);   // 3
  4. How Flutter Data code generation works

    master

    Flutter Data uses a build-time code generation process to create boilerplate-free extensions for your models. The generation process works in two stages:

    1. Intermediate Scanning: The dataExtensionIntermediateBuilder scans your library for classes annotated with @DataAdapter. It identifies these members and writes metadata into .flutter_data.info files.
    2. Final Code Generation: The dataExtensionBuilder collects all .flutter_data.info files across your project and generates a central file at lib/main.data.dart.

    This generated file provides:

    • A map of all adapter providers (adapterProvidersMap).
    • Extension methods on Ref (for Riverpod) or ProviderContainer (for vanilla Riverpod) to easily access adapters.
    • If using flutter_riverpod or hooks_riverpod, it also provides extensions on WidgetRef to allow watching adapters directly within widgets.
  5. Manage asynchronous data with DataState

    master

    DataState<T> is a container used to represent the current state of an asynchronous data operation. It encapsulates the data model, the loading status, and any potential errors.

    Key properties:

    • model: The actual data of type T.
    • isLoading: A boolean indicating if a data operation is currently in progress.
    • exception: A DataException if the operation failed.
    • stackTrace: The stack trace associated with an error.
    • message: An optional error or status message.

    Convenience getters:

    • hasException: Returns true if an exception is present.
    • hasModel: Returns true if a model is present.
    • hasMessage: Returns true if a message is present.

    You can use merge(DataState<T> value) to combine two states, where the new state's optional values (exception, stackTrace, message) will not overwrite existing values in the current state if they are null.

    const state = DataState<User>(
      user,
      isLoading: false,
      exception: myException,
    );
    
    if (state.isLoading) {
      // show spinner
    } else if (state.hasException) {
      // show error
    } else if (state.hasModel) {
      // show user data
    }
  6. Transform data states using map and where

    master

    You can derive new DataStateNotifier instances from an existing one using functional transformations. These transformations are reactive: when the source notifier's state changes, the derived notifier updates automatically.

    map(T Function(T) convert)

    Creates a new notifier where the model is transformed by the convert function.

    • If the source is a List<T>, it maps over the list.
    • If the source is a single T, it converts the object.

    where(bool Function(T) test)

    Creates a new notifier that filters the data based on a predicate.

    • If the source is a List<T>, it returns a list containing only elements that pass the test.
    • If the source is a single T, it returns the model if it passes the test, or null if it fails.

    Note: These transformations only apply to the model. The isLoading, exception, stackTrace, and message properties are passed through from the source state directly.

    // Mapping a single object
    final nameNotifier = userNotifier.map((user) => user.name);
    
    // Filtering a list
    final activeUsersNotifier = usersNotifier.where((user) => user.isActive);
    
    // Mapping a list
    final userNamesNotifier = usersNotifier.map((user) => user.name);
  7. Extend Adapters with Mixins via @DataAdapter

    master

    You can add custom functionality to your generated adapters by passing mixins to the @DataAdapter annotation. This is useful for adding custom finder methods or specialized logic.

    Rules for Mixins:

    • Each mixin must have at most one type argument (e.g., MyMixin<MyModel>).
    • Any field within the mixin annotated with @DataFinder will be automatically extracted and added to the adapter's internal finder registry.

    Generated Helpers:

    • The generator creates a Provider for the adapter (e.g., myModelAdapterProvider).
    • It also creates an extension on the Adapter<T> type that provides shortcuts to access the mixin (e.g., adapter.myMixin).
    // Define a mixin with a finder
    mixin UserFinders<T extends DataModel<T>> on DataModel<T> {
      @DataFinder
      String email;
    }
    
    // Apply it to the model
    @DataAdapter(adapters: [UserFinders])
    class User extends DataModel<User> {
      // ...
    }
  8. Generate custom DataAdapters using @DataAdapter

    master

    Flutter Data uses code generation to create specialized Adapter<T> implementations for your models. By annotating a class with @DataAdapter, the adapterBuilder generates a mixin and a class that handles serialization, deserialization, relationship metadata, and finder methods.

    Requirements for annotated classes:

    • The class must be annotated with @DataAdapter.
    • The id field must be final.
    • All fields used in @DataRelationship must be final.
    • If using json_serializable, the generator respects fieldRename settings (kebab, snake, pascal, or none) to determine JSON keys for relationships.

    Relationship Constraints:

    • Do not use @JsonKey(ignore: true) on relationship fields. Instead, use @DataRelationship(serialized: false) to prevent serialization.
    • If a relationship has multiple possible inverses in the target class, you must explicitly specify the inverse using @DataRelationship(inverse: 'fieldName').
    @DataAdapter(adapters: [MyCustomMixin<MyModel>])
    class MyModel extends DataModel<MyModel> {
      final String id;
      final String name;
    
      @DataRelationship(inverse: 'owner')
      final BelongsTo<User> owner;
    
      MyModel({required this.id, required this.name, required this.owner});
    }
  9. Access adapters via generated extensions

    master

    Once code generation is complete, you can access your model adapters using the generated extensions in lib/main.data.dart.

    Using Riverpod (Ref or ProviderContainer)

    You can access an adapter by calling the lowercase version of your class name on your Ref or ProviderContainer instance:

    // Example: If you have a class named 'User'
    final userAdapter = ref.user; 

    Using Flutter Riverpod (WidgetRef)

    If you are using flutter_riverpod, you can also access adapters directly within your widgets using WidgetRef:

    // Inside a ConsumerWidget
    @override
    Widget build(BuildContext context, WidgetRef ref) {
      final userAdapter = ref.watch(userAdapterProvider); // Standard Riverpod
      // OR via the generated extension:
      final userAdapterExt = ref.user;
      // ...
    }
  10. Breaking Changes in Flutter Data 2.0

    master

    If you are migrating from a version prior to 2.0, note the following changes:

    • Unified Adapter: There is no longer a distinction between Repository, RemoteAdapter, and LocalAdapter. All methods are now directly on the Adapter. For example, findAll from LocalAdapter is now findAllLocal.
    • Initialization: Instead of calling configure... methods on Riverpod overrides, you must use localStorageProvider.overrideWithValue with a LocalStorage instance. The actual initialization is performed via initializeFlutterData, which requires an adapterProvidersMap (generated in main.data.dart).
    • Offline Operations: While offline operations are still supported, automatic retries are no longer provided; clients are responsible for implementing retry logic.
  11. Reference: Serialization Adapter Methods

    master

    Methods for converting models to and from raw data formats.

    Future<Map<String, dynamic>> serialize(T model, {bool withRelationships = true});
    
    Future<DeserializedData<T>> deserialize(Object? data, {String? key, bool async = true});
    
    Future<DeserializedData<T>> deserializeAndSave(Object? data, {String? key, bool notify = true, bool ignoreReturn = false});
  12. Reference: Local Storage Adapter Methods

    master

    The Adapter provides methods for interacting directly with the local cache. All models are identified by keys in the format model#id (e.g., user#5).

    List<T> findAllLocal();
    List<T> findManyLocal(Iterable<String> keys);
    T? findOneLocal(String? key);
    T? findOneLocalById(Object id);
    bool exists(String key);
    T saveLocal(T model, {bool notify = true});
    Future<List<String>?> saveManyLocal(Iterable<DataModelMixin> models, {bool notify = true, bool async = true});
    void deleteLocal(T model, {bool notify = true});
    void deleteLocalById(Object id, {bool notify = true});
    void deleteLocalByKeys(Iterable<String> keys, {bool notify = true});
    Future<void> clearLocal({bool notify = true});
    int get countLocal;
    Set<String> get keys;