Étude de cas : Application Todo offline-first
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.
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 unStorageAdapteret restaurés parhydrate()avant la première frameNetworkMode.offlineFirst: les données en cache sont servies immédiatement ; les fetchs reprennent à la reconnexion- File de mutations hors-ligne :
offlineQueue: truemet 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 rejetteFetchStatus.paused: permet à l'UI de distinguer « pas de réseau » de « chargement »NetworkStatusIndicator: bandeau hors-ligne globalReconnectStrategy: 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
)
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 :
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.
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 :
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 :
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
Guides liés
- Persistance :
PersistQoraClient, serializers, adaptateurs de stockage, migrations de modèles - Réseau :
NetworkMode,FetchStatus, file de mutations hors-ligne,ReconnectStrategy - Mises à jour optimistes :
onMutate,setQueryData,restoreQueryData - API PersistQoraClient : référence complète des méthodes
Étude de cas : Feed à défilement infini
Construire un feed de type réseau social haute performance avec pagination par curseur, gestion mémoire fenêtrée et création de post optimiste instantanée.
Étude de cas : Conscience réseau
Les requêtes se mettent automatiquement en pause hors ligne, les mutations se mettent en file pour plus tard, et tout rejoue à la reconnexion : avec prévention du thundering-herd intégrée.