Exemples

Étude de cas : Application Todo complète

Une app Flutter de niveau production combinant toutes les capacités Qora : PersistQoraClient, défilement infini avec pagination, create/toggle/delete optimistes, file de mutations hors ligne et bascule de connectivité simulée.

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 frame
  • AuthService (ValueNotifier): état de session utilisateur indépendant de Qora; conditionne quelle liste de todos est fetchée
  • InfiniteQueryBuilder<TodosPage, int>: pagination par curseur avec déclencheur de scroll par élément sentinelle
  • InfiniteQueryController.refetch(): pull-to-refresh qui réinitialise la pagination et refetche depuis la page 1
  • Création optimiste avec file hors ligne: QoraMutationBuilder avec offlineQueue: true, optimisticResponse et une liste locale _pendingTodos pour un retour UI instantané
  • Mutations toggle et delete par tile: QoraMutationBuilder indépendant par tile; basculer un élément ne bloque jamais un autre
  • SimulatedConnectivityManager: implémentation personnalisée de ConnectivityManager pour démo hors ligne autonome via une bascule dans l'AppBar
  • NetworkMode.offlineFirst: cache périmé servi instantanément à la reconnexion; revalidation en arrière-plan déclenchée automatiquement
  • NetworkStatusIndicator: 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

lib/main.dart
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:

lib/features/todos/ui/todo_list_screen.dart
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:

lib/features/todos/ui/todo_list_screen.dart
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 :

lib/features/todos/ui/todo_list_screen.dart
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 :

lib/features/todos/ui/todo_list_screen.dart
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 :

lib/core/connectivity/simulated_connectivity_manager.dart
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

  1. Se connecter avec n'importe quel ID utilisateur (1–10) → les todos se chargent depuis JSONPlaceholder
  2. Défiler jusqu'en bas → la page suivante se charge automatiquement via le déclencheur sentinelle
  3. Pull-to-refresh → la liste se réinitialise et refetche depuis la page 1
  4. Ajouter un todo → apparaît instantanément dans la liste avec le style en attente
  5. Basculer l'icône wifi dans l'AppBar → bannière hors ligne apparaît; les todos existants restent visibles
  6. Ajouter un autre todo hors ligne → apparaît avec style en attente; état "en file" sur le FAB
  7. Rebrancher le wifi → la mutation en file rejoue; la liste se rafraîchit avec les données confirmées par le serveur
  8. Basculer un todo → coché/décoché immédiatement; synchronisation serveur en arrière-plan
  9. Supprimer un todo → supprimé immédiatement; confirmation serveur en arrière-plan
  10. 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

Voir le code source complet


Patterns récapitulatifs

PatternFichierAPI Qora
Bootstrap + persistancemain.dartPersistQoraClient, QoraScope, .hydrate()
Clé de requête conditionnée par l'authtodo_list_screen.dartClé ['todos', auth.id]
Sentinelle de défilement infinitodo_list_screen.dartInfiniteQueryBuilder, InfiniteQueryController
Pull-to-refreshtodo_list_screen.dartcontroller.refetch()
Création optimiste + file hors lignetodo_list_screen.dartQoraMutationBuilder, offlineQueue, optimisticResponse, onMutate
Mutations indépendantes par tiletodo_list_screen.dartQoraMutationBuilder imbriqué
Bascule hors ligne simuléesimulated_connectivity_manager.dartInterface ConnectivityManager
Bannière hors ligneoffline_banner.dartNetworkStatusIndicator

Voir aussi

Copyright © 2026