Exemples

Étude de cas : Application Todo offline-first

Une application de todos qui continue de fonctionner sans réseau : données sauvegardées au démarrage, requêtes en pause hors-ligne, écritures mises en file et rejouées dans l'ordre à la reconnexion.

Objectif

La connexion mobile coupe sans arrêt : tunnels, ascenseurs, mode avion. Cet exemple montre une application de todos qui reste utilisable malgré tout :

  • la liste s'affiche depuis le cache, et depuis le stockage au démarrage à froid ;
  • l'UI sait quand elle est hors-ligne, au lieu de tourner indéfiniment ;
  • les todos créés, cochés ou supprimés hors-ligne apparaissent immédiatement et sont envoyés dans l'ordre au retour du réseau.

Aucun code de synchronisation maison : tout ce qui suit est de la configuration Qora et deux widgets.

Testez sans toucher à votre Wi-Fi. L'exemple fournit un SimulatedConnectivityManager : le bouton antenne de l'app bar bascule l'application entre en ligne et hors-ligne, et la fausse API échoue tant que vous êtes hors-ligne.

Fonctionnalités démontrées

  • PersistQoraClient : les fetchs réussis sont écrits dans un StorageAdapter et restaurés par hydrate() avant la première frame
  • NetworkMode.offlineFirst : les données en cache sont servies immédiatement ; les fetchs reprennent à la reconnexion
  • File de mutations hors-ligne : offlineQueue: true met en file les écritures faites hors-ligne et les rejoue dans l'ordre FIFO à la reconnexion
  • onMutate + onError : le nouveau todo est écrit tout de suite dans le cache de la liste, puis retiré si le serveur le rejette
  • FetchStatus.paused : permet à l'UI de distinguer « pas de réseau » de « chargement »
  • NetworkStatusIndicator : bandeau hors-ligne global
  • ReconnectStrategy : limite les refetchs simultanés et ajoute du jitter au retour du réseau

Architecture

PersistQoraClient étend QoraClient : fetchQuery, watchQuery, invalidate et les widgets fonctionnent sans changement. Le reste de l'application ignore que la persistance existe.

main.dart
├── InMemoryStorageAdapter().init()    ← backend de stockage (à remplacer par votre adaptateur)
├── SimulatedConnectivityManager(api)  ← bascule en ligne / hors-ligne pour la démo
├── PersistQoraClient(storage, ...)    ← remplace QoraClient sans autre changement
├── registerAllSerializers(client)     ← tous les types écrits dans le stockage
├── client.hydrate()                   ← restaure les données avant runApp
└── QoraScope(
      client: client,
      lifecycleManager: FlutterLifecycleManager(qoraClient: client),
      connectivityManager: connectivity,
      child: App(),                    ← OfflineBannerWrapper dans MaterialApp.builder
    )
lib/main.dart
final storage = InMemoryStorageAdapter();
await storage.init();

final api = TodoApi();
final connectivity = SimulatedConnectivityManager(api);

final client = PersistQoraClient(
  storage: storage,
  persistDuration: const Duration(days: 30),
  config: QoraClientConfig(
    defaultOptions: const QoraOptions(
      staleTime: Duration(minutes: 5),
      networkMode: NetworkMode.offlineFirst,
    ),
    // Au plus 3 refetchs simultanés à la reconnexion, 150 ms de jitter entre eux.
    reconnectStrategy: const ReconnectStrategy(
      maxConcurrent: 3,
      jitter: Duration(milliseconds: 150),
    ),
    debugMode: kDebugMode,
  ),
);

registerAllSerializers(client);
await client.hydrate(); // les todos sauvegardés sont en cache avant la première frame

Dans une vraie application, remplacez SimulatedConnectivityManager par FlutterConnectivityManager() (ou utilisez QoraScope.auto), et InMemoryStorageAdapter par un adaptateur adossé à un vrai stockage. StorageAdapter a sept méthodes (init, read, write, delete, keys, clear, dispose) ; voir la recette Persistance.

Les serializers sont regroupés dans une seule fonction, qui sert aussi de liste de tous les types écrits dans le stockage :

lib/core/persistence/serializers.dart
void registerAllSerializers(PersistQoraClient client) {
  client.registerSerializer<Todo>(
    QoraSerializer(toJson: (t) => t.toJson(), fromJson: Todo.fromJson),
    name: 'Todo', // nom explicite : stable avec --obfuscate et sur le web
  );
  client.registerSerializer<List<Todo>>(
    QoraSerializer(
      toJson: (list) => list.map((t) => t.toJson()).toList(),
      fromJson: (json) => (json as List).map(Todo.fromJson).toList(),
    ),
    name: 'List<Todo>',
  );
}

Détails d'implémentation

Créer un todo hors-ligne

onMutate s'exécute immédiatement, même hors-ligne. Il écrit un todo temporaire dans le cache ['todos'], ce qui met la liste à jour tout de suite, et renvoie la liste précédente comme contexte de rollback. La mutation elle-même attend en file que l'appareil se reconnecte.

lib/features/todos/ui/todo_screen.dart
QoraMutationBuilder<Todo, CreateTodoInput, List<Todo>?>(
  queryKey: const ['todos'],
  mutator: api.createTodo,
  options: MutationOptions(
    offlineQueue: true,
    onMutate: (input) async {
      final prev = context.qora.getQueryData<List<Todo>>(const ['todos']) ?? [];
      context.qora.setQueryData<List<Todo>>(const ['todos'], [
        ...prev,
        Todo(
          id: 'temp-${DateTime.now().millisecondsSinceEpoch}',
          title: input.title,
          completed: false,
          isPending: true, // flag local, jamais persisté
        ),
      ]);
      return prev;
    },
    // Le serveur a rejeté le todo : on remet la liste précédente.
    onError: (_, __, prev) async =>
        context.qora.restoreQueryData(const ['todos'], prev),
    // Le serveur l'a accepté : refetch pour remplacer l'id temporaire.
    onSuccess: (_, __, ___) async => context.qora.invalidate(const ['todos']),
  ),
  builder: (context, state, mutate) => FloatingActionButton.extended(
    onPressed: state.isPending ? null : () => _showAddDialog(context, mutate),
    icon: Icon(state.isPending ? Icons.schedule : Icons.add),
    label: Text(state.isPending ? 'En file, synchro bientôt' : 'Ajouter'),
  ),
)

optimisticResponse est l'alternative quand seul l'état de la mutation compte (par exemple une confirmation « envoyé ») plutôt qu'une liste mise à jour : le builder reçoit immédiatement MutationSuccess(isOptimistic: true).

Afficher les todos en attente

TodoTile lit le flag local isPending pour montrer qu'un élément n'a pas encore atteint le serveur :

lib/features/todos/ui/todo_tile.dart
ListTile(
  leading: todo.isPending
      ? const SizedBox(
          width: 24,
          height: 24,
          child: CircularProgressIndicator(strokeWidth: 2),
        )
      : Checkbox(
          value: todo.completed,
          onChanged: (_) => onToggle(
            ToggleTodoInput(id: todo.id, completed: !todo.completed),
          ),
        ),
  title: Text(
    todo.title,
    style: TextStyle(
      color: todo.isPending ? Colors.grey.shade500 : null,
      fontStyle: todo.isPending ? FontStyle.italic : FontStyle.normal,
    ),
  ),
  trailing: todo.isPending
      ? Icon(Icons.cloud_off, size: 16, color: Colors.grey.shade400)
      : null,
)

Pourquoi l'ordre de rejeu compte

1. Créer « Acheter du lait » hors-ligne → mis en file comme mutation #1
2. Le cocher                           → mis en file comme mutation #2
3. Le réseau revient
4. #1 est rejouée → le serveur crée le todo
5. #2 est rejouée → le serveur le marque comme fait

Le rejeu est séquentiel : #2 n'est envoyée qu'une fois #1 terminée. Si le serveur rejette #1, son onError s'exécute avec le snapshot pris par onMutate, ce qui retire le todo optimiste, et le rejeu passe à #2. Une écriture en file est envoyée une seule fois : elle n'est pas retentée à une reconnexion suivante, donc c'est dans onError que l'utilisateur apprend qu'elle n'est pas passée.

Hors-ligne sans rien en cache

Quand l'appareil est hors-ligne et que rien n'est en cache, le fetch se met en pause au lieu de s'exécuter : l'état est Loading sans donnée précédente, et fetchStatus vaut FetchStatus.paused. Un spinner tournerait jusqu'au retour du réseau, donc ce cas mérite son propre écran :

lib/features/todos/ui/todo_screen.dart
QoraBuilder<List<Todo>>(
  queryKey: const ['todos'],
  fetcher: api.getTodos,
  builder: (context, state, fetchStatus) {
    if (state.dataOrNull == null && fetchStatus == FetchStatus.paused) {
      return const OfflineEmptyState(); // « Pas de connexion : connectez-vous pour charger vos todos »
    }
    return switch (state) {
      Initial() || Loading(previousData: null) =>
        const Center(child: CircularProgressIndicator()),
      Failure(:final error, previousData: null) => ErrorView(error),
      // Données disponibles : Success, ou Loading/Failure avec previousData
      _ => TodoList(
          todos: state.dataOrNull ?? const [],
          isRefreshing: fetchStatus == FetchStatus.fetching,
          isOffline: fetchStatus == FetchStatus.paused,
        ),
    };
  },
)

Limites à connaître

  • La file de mutations vit en mémoire. Elle survit aux changements de connectivité, pas à un redémarrage de l'application : les écritures encore en file quand l'app est tuée sont perdues. Persister la file est l'objectif de Qora 1.4.
  • Cette démo utilise InMemoryStorageAdapter : les données sauvegardées sont donc aussi perdues au redémarrage du processus. Avec un adaptateur sur disque, hydrate() restaure la liste au démarrage à froid.

Code source

examples/ex_04_offline_first/
├── lib/
│   ├── main.dart                                   ← PersistQoraClient, hydrate(), QoraScope
│   ├── core/persistence/serializers.dart           ← registerAllSerializers()
│   ├── features/todos/
│   │   ├── models/todo.dart                        ← Todo avec toJson / fromJson
│   │   ├── data/todo_api.dart                      ← fausse API qui échoue hors-ligne
│   │   └── ui/
│   │       ├── todo_screen.dart                    ← requêtes, mutations, gestion de FetchStatus
│   │       └── todo_tile.dart                      ← affichage des todos en attente
│   └── shared/
│       ├── connectivity/simulated_connectivity_manager.dart ← bascule en ligne / hors-ligne
│       └── widgets/offline_banner.dart             ← wrapper de NetworkStatusIndicator
└── pubspec.yaml

Voir le code source complet


Guides liés

Copyright © 2026