Étude de cas : Application Todo complète
Objectif
Contexte : Les vraies apps utilisent rarement une fonctionnalité de façon isolée. L'authentification conditionne l'accès aux données, le défilement infini fetche des données paginées depuis le serveur, les mutations nécessitent un retour optimiste avec rollback, et la connectivité est peu fiable. Chaque préoccupation est simple en isolation; le défi est de les composer sans couplage.
Cet exemple combine toutes les capacités Qora de niveau production dans une seule app connectée à l'API live JSONPlaceholder. Le résultat est une implémentation de référence : chaque pattern est autonome et extractible vers un projet réel.
Fonctionnalités démontrées
PersistQoraClient+HiveStorageAdapter: persistance write-through ;hydrate()chauffe le cache avant la première frameAuthService(ValueNotifier): état de session utilisateur indépendant de Qora; conditionne quelle liste de todos est fetchéeInfiniteQueryBuilder<TodosPage, int>: pagination par curseur avec déclencheur de scroll par élément sentinelleInfiniteQueryController.refetch(): pull-to-refresh qui réinitialise la pagination et refetche depuis la page 1- Création optimiste avec file hors ligne:
QoraMutationBuilderavecofflineQueue: true,optimisticResponseet une liste locale_pendingTodospour un retour UI instantané - Mutations toggle et delete par tile:
QoraMutationBuilderindépendant par tile; basculer un élément ne bloque jamais un autre SimulatedConnectivityManager: implémentation personnalisée deConnectivityManagerpour démo hors ligne autonome via une bascule dans l'AppBarNetworkMode.offlineFirst: cache périmé servi instantanément à la reconnexion; revalidation en arrière-plan déclenchée automatiquementNetworkStatusIndicator: bannière hors ligne sans boilerplate utilisant le widget intégré
Points d'architecture
L'app est organisée en modules de fonctionnalités avec une infrastructure partagée. Les APIs Qora n'apparaissent qu'à la couche UI; les modèles, clients API et sérialiseurs n'ont aucune dépendance Qora:
ex_07_complete_todo_app/lib/
├── main.dart ← bootstrap : PersistQoraClient, hydrate, QoraScope
├── core/
│ ├── auth/
│ │ └── auth_service.dart ← ValueNotifier<AuthUser?>, login/logout
│ ├── connectivity/
│ │ └── simulated_connectivity_manager.dart ← implémentation ConnectivityManager (bouton bascule)
│ └── persistence/
│ ├── hive_storage_adapter.dart ← StorageAdapter sur une boîte Hive
│ └── serializers.dart ← registerAllSerializers()
├── features/
│ ├── auth/ui/login_screen.dart ← formulaire, appelle AuthService.login()
│ └── todos/
│ ├── data/todo_api.dart ← client HTTP JSONPlaceholder
│ ├── models/todo.dart ← Todo, TodosPage, CreateTodoInput, ToggleTodoInput
│ └── ui/
│ ├── todo_list_screen.dart ← tous les patterns Qora composés ensemble
│ └── todo_tile.dart ← widget de présentation, découplé des mutations
└── shared/widgets/offline_banner.dart ← OfflineBannerWrapper avec NetworkStatusIndicator
Séquence de démarrage
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final storage = await HiveStorageAdapter.open('qora_cache');
final connectivity = SimulatedConnectivityManager();
final client = PersistQoraClient(
storage: storage,
persistDuration: const Duration(days: 30),
config: QoraClientConfig(
defaultOptions: const QoraOptions(
staleTime: Duration(minutes: 5),
networkMode: NetworkMode.offlineFirst,
),
reconnectStrategy: const ReconnectStrategy(
maxConcurrent: 3,
jitter: Duration(milliseconds: 150),
),
debugMode: kDebugMode,
),
);
registerAllSerializers(client);
await client.hydrate(); // ← chauffer le cache avant la première frame
runApp(
QoraScope(
client: client,
connectivityManager: connectivity,
lifecycleManager: FlutterLifecycleManager(qoraClient: client),
child: OfflineBannerWrapper(child: _App(auth: AuthService(), connectivity: connectivity)),
),
);
}
AuthService étend ValueNotifier<AuthUser?>. _App l'écoute avec ValueListenableBuilder et navigue entre LoginScreen et TodoListScreen de façon réactive, sans bibliothèque de routage, sans couche de gestion d'état supplémentaire.
Détails d'implémentation
Défilement infini avec pattern sentinelle
InfiniteQueryBuilder fetche les todos paginés depuis JSONPlaceholder. Le pattern sentinelle évite un ScrollController séparé et notificationPredicate:
InfiniteQueryBuilder<TodosPage, int>(
queryKey: ['todos', auth.id],
fetcher: (page) => api.getTodosPage(auth.id, page),
options: InfiniteQueryOptions(
initialPageParam: 1,
getNextPageParam: (last, all) => last.hasMore ? all.length + 1 : null,
),
builder: (context, state, controller) {
// ... gestion de l'état
final allItems = [
..._pendingTodos, // éléments optimistes en premier
...state.data!.flatten((p) => p.todos),
];
return RefreshIndicator(
onRefresh: () => controller.refetch(),
child: ListView.builder(
itemCount: allItems.length + 1, // +1 pour la sentinelle
itemBuilder: (context, index) {
if (index == allItems.length) {
// Sentinelle: fetcher la page suivante automatiquement quand défilée en vue
if (state.hasNextPage && !state.isFetchingNextPage) {
controller.fetchNextPage();
}
return state.isFetchingNextPage
? const _PageLoader()
: const SizedBox.shrink();
}
final todo = allItems[index];
return todo.isOptimistic
? TodoTile(todo: todo) // optimiste: sans callbacks de mutation
: _MutableTodoTile(todo: todo, api: api, auth: auth);
},
),
);
},
)
_pendingTodos est une List<Todo> locale dans le State de l'écran. Elle contient les éléments optimistes créés pendant qu'une mutation est en vol ou en file hors ligne. Ces éléments sont préposés à la liste et rendus avec le style visuel isOptimistic dans TodoTile. Quand la mutation réussit, la liste est invalidée et _pendingTodos est vidée.
Création optimiste avec file hors ligne
La mutation de création utilise un snapshot TContext pour activer le rollback:
QoraMutationBuilder<Todo, CreateTodoInput, List<Todo>>(
mutator: api.createTodo,
options: MutationOptions(
offlineQueue: true,
optimisticResponse: (input) => Todo.optimistic(title: input.title),
onMutate: (input) async {
// Snapshot de la liste en attente courante pour le rollback
final snapshot = List<Todo>.from(_pendingTodos);
setState(() => _pendingTodos.add(Todo.optimistic(title: input.title)));
return snapshot;
},
onSuccess: (_, __, ___) async {
setState(() => _pendingTodos.clear());
context.qora.invalidate(['todos', auth.id]);
},
onError: (_, __, snapshot) async {
// Restaurer la liste en attente pré-mutation
setState(() => _pendingTodos = snapshot ?? []);
},
),
builder: (context, state, mutate) {
return FloatingActionButton(
onPressed: state.isPending ? null : () => _showCreateDialog(mutate),
child: state.isPending
? const CircularProgressIndicator(strokeWidth: 2)
: const Icon(Icons.add),
);
},
)
Hors ligne, offlineQueue: true fait émettre à la mutation MutationSuccess(isOptimistic: true) immédiatement. Le todo apparaît dans la liste avec le style en attente. À la reconnexion, la file rejoue la mutation et onSuccess se déclenche, vidant _pendingTodos et invalidant le cache pour fetcher l'élément confirmé par le serveur.
Toggle et suppression par tile
Chaque tile encapsule deux instances QoraMutationBuilder indépendantes. Elles sont imbriquées dans _MutableTodoTile, pas dans l'écran de liste, donc leur état est limité au cycle de vie du widget tile :
class _MutableTodoTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return QoraMutationBuilder<Todo, ToggleTodoInput, void>(
mutator: api.toggleTodo,
options: MutationOptions(
onSuccess: (_, __, ___) async =>
context.qora.invalidate(['todos', auth.id]),
),
builder: (context, toggleState, toggle) {
return QoraMutationBuilder<void, int, void>(
mutator: api.deleteTodo,
options: MutationOptions(
onSuccess: (_, __, ___) async =>
context.qora.invalidate(['todos', auth.id]),
),
builder: (context, deleteState, delete) {
return TodoTile(
todo: todo,
onToggle: toggleState.isPending ? null
: () => toggle(ToggleTodoInput(id: todo.id, completed: !todo.completed)),
onDelete: deleteState.isPending ? null
: () => delete(todo.id),
);
},
);
},
);
}
}
Basculer l'élément A et supprimer l'élément B sont des opérations complètement indépendantes. Chaque QoraMutationBuilder a son propre cycle de vie MutationController. Une erreur serveur sur l'élément A n'affecte pas l'état de l'élément B.
Dégradation gracieuse en cas d'échec de requête infinie
InfiniteFailure avec previousData garde le feed visible quand le chargement d'une page échoue :
case InfiniteFailure(:final error, :final previousData):
if (previousData != null) {
// Afficher les données périmées + bannière d'erreur en haut
return Column(
children: [
_ErrorBanner(error: error, onRetry: controller.fetchNextPage),
Expanded(child: _buildList(previousData, controller)),
],
);
}
// Aucune donnée du tout : écran d'erreur complet avec réessai
return _ErrorView(error: error, onRetry: controller.refetch);
SimulatedConnectivityManager
Un ConnectivityManager personnalisé qui bascule entre en ligne et hors ligne via un bouton dans l'AppBar. Cela supprime toute dépendance runtime sur connectivity_plus pour la démo :
class SimulatedConnectivityManager implements ConnectivityManager {
final _controller = StreamController<NetworkStatus>.broadcast();
NetworkStatus _status = NetworkStatus.online;
@override
Stream<NetworkStatus> get statusStream => _controller.stream;
@override
NetworkStatus get currentStatus => _status;
void toggle() {
_status = _status == NetworkStatus.online
? NetworkStatus.offline
: NetworkStatus.online;
_controller.add(_status);
}
@override
void dispose() => _controller.close();
}
Le bouton AppBar appelle connectivity.toggle(). Toutes les requêtes actives se mettent en pause immédiatement (FetchStatus.paused), et toutes les mutations déclenchées hors ligne sont mises en file. Au retour en ligne, la file rejoue automatiquement.
Étapes de validation
- Se connecter avec n'importe quel ID utilisateur (1–10) → les todos se chargent depuis JSONPlaceholder
- Défiler jusqu'en bas → la page suivante se charge automatiquement via le déclencheur sentinelle
- Pull-to-refresh → la liste se réinitialise et refetche depuis la page 1
- Ajouter un todo → apparaît instantanément dans la liste avec le style en attente
- Basculer l'icône wifi dans l'AppBar → bannière hors ligne apparaît; les todos existants restent visibles
- Ajouter un autre todo hors ligne → apparaît avec style en attente; état "en file" sur le FAB
- Rebrancher le wifi → la mutation en file rejoue; la liste se rafraîchit avec les données confirmées par le serveur
- Basculer un todo → coché/décoché immédiatement; synchronisation serveur en arrière-plan
- Supprimer un todo → supprimé immédiatement; confirmation serveur en arrière-plan
- Forcer la fermeture et rouvrir l'app → l'auth et les todos en cache se restaurent à la première frame (dans la fenêtre de fraîcheur)
Code source
examples/ex_07_complete_todo_app/
├── lib/
│ ├── main.dart
│ ├── core/
│ │ ├── auth/auth_service.dart
│ │ ├── connectivity/simulated_connectivity_manager.dart
│ │ └── persistence/
│ │ ├── hive_storage_adapter.dart
│ │ └── serializers.dart
│ ├── features/
│ │ ├── auth/ui/login_screen.dart
│ │ └── todos/
│ │ ├── data/todo_api.dart
│ │ ├── models/todo.dart
│ │ └── ui/
│ │ ├── todo_list_screen.dart
│ │ └── todo_tile.dart
│ └── shared/widgets/offline_banner.dart
└── pubspec.yaml
Patterns récapitulatifs
| Pattern | Fichier | API Qora |
|---|---|---|
| Bootstrap + persistance | main.dart | PersistQoraClient, QoraScope, .hydrate() |
| Clé de requête conditionnée par l'auth | todo_list_screen.dart | Clé ['todos', auth.id] |
| Sentinelle de défilement infini | todo_list_screen.dart | InfiniteQueryBuilder, InfiniteQueryController |
| Pull-to-refresh | todo_list_screen.dart | controller.refetch() |
| Création optimiste + file hors ligne | todo_list_screen.dart | QoraMutationBuilder, offlineQueue, optimisticResponse, onMutate |
| Mutations indépendantes par tile | todo_list_screen.dart | QoraMutationBuilder imbriqué |
| Bascule hors ligne simulée | simulated_connectivity_manager.dart | Interface ConnectivityManager |
| Bannière hors ligne | offline_banner.dart | NetworkStatusIndicator |
Voir aussi
- Étude de cas Offline-First: approfondissement de
PersistQoraClient - Étude de cas Défilement infini: patterns
InfiniteQueryBuilder - Étude de cas Mutations optimistes: protocole de rollback
- Étude de cas Conscience réseau:
NetworkMode,ReconnectStrategy
Étude de cas : Intégration des hooks
Remplacer l'imbrication des builders QoraBuilder par des appels de hooks plats et composables : useQuery, useMutation, useInfiniteQuery et indicateurs de fetch globaux dans un HookWidget.
FAQ
Réponses aux questions fréquentes sur l'API Qora, la sémantique du cache et l'intégration Flutter.