Guides Pratiques

Réseau adaptatif

Gérer le mode hors-ligne, les requêtes en pause, la file de mutations hors-ligne et la relecture à la reconnexion avec la couche réseau de Qora.

Qora fournit un comportement adaptatif au réseau pour les requêtes et les mutations. Quand la connectivité est perdue, Qora peut suspendre les fetchs, mettre en file les mutations et rejouer les opérations après la reconnexion.


Vue d'ensemble

La conscience du réseau dans Qora repose sur trois primitives orthogonales :

PrimitiveCe qu'elle contrôle
NetworkModeComment une requête ou mutation se comporte quand hors-ligne
FetchStatusSi un fetch est activement en cours, en pause ou inactif
OfflineMutationQueueFile FIFO qui rejoue les écritures en file à la reconnexion

Elles sont entièrement indépendantes. NetworkMode.online (pause des requêtes) peut être utilisé sans activer la file de mutations hors-ligne, ou la file peut être activée sur les mutations pendant que les requêtes utilisent NetworkMode.always.


NetworkMode

NetworkMode est un enum par requête / par mutation qui indique à Qora que faire quand l'appareil n'a pas de connectivité :

lib/core/qora.dart
enum NetworkMode {
  /// Par défaut. Suspend le fetch hors-ligne et le rejoue à la reconnexion.
  /// L'état devient Loading(previousData: <donnée en cache ou null>) et
  /// FetchStatus passe à paused jusqu'au retour de la connectivité.
  online,

  /// Toujours exécuter quelle que soit la connectivité.
  /// Utile quand le fetcher utilise une base de données locale ou un cache.
  always,

  /// Servir les données en cache immédiatement, puis planifier une revalidation
  /// en arrière-plan quand l'appareil est de nouveau en ligne.
  offlineFirst,
}

Définir networkMode par requête

lib/data/user_repository.dart
final user = await client.fetchQuery<User>(
  key: ['users', userId],
  fetcher: () => api.getUser(userId),
  options: const QoraOptions(networkMode: NetworkMode.online), // défaut
);

Définir networkMode globalement

lib/core/qora.dart
final client = QoraClient(
  config: const QoraClientConfig(
    defaultOptions: QoraOptions(
      networkMode: NetworkMode.online,
    ),
  ),
);

Comportement par mode

ModeComportement hors-ligneÀ la reconnexion
online (défaut)Fetch suspendu, FetchStatus.paused émisRejoué automatiquement
alwaysFetch s'exécute : échoue sans connectivitéPas de retry à la reconnexion
offlineFirstDonnées en cache retournées immédiatementRevalidation en arrière-plan mise en file
Utiliser NetworkMode.always pour les requêtes soutenues par une base de données SQLite locale ou une box Hive : elles n'ont jamais besoin de connectivité et ne devraient jamais être suspendues.

FetchStatus : le second axe

QoraState<T> décrit ce que vous avez (initial / loading / success / failure). FetchStatus décrit ce que Qora est en train de faire pour l'obtenir :

enum FetchStatus {
  /// Une requête réseau est en cours.
  fetching,

  /// En file pour le fetch mais hors-ligne : reprendra à la reconnexion.
  paused,

  /// Pas de fetch actif (les données sont fraîches ou aucun fetch n'a démarré).
  idle,
}

Ces deux valeurs forment une grille bidimensionnelle. Les combinaisons les plus utiles sont :

QoraStateFetchStatusSignification
LoadingfetchingPremier fetch en cours
Loading (sans previousData)pausedHors-ligne, rien en cache : le fetch attend le réseau
Loading (avec previousData)pausedHors-ligne, mode online : refetch en attente, donnée en cache toujours disponible
SuccessfetchingRevalidation en arrière-plan en cours
SuccesspausedHors-ligne, mode offlineFirst : donnée en cache servie, refetch en file
SuccessidleDonnées fraîches : rien en cours
FailureidleDernier fetch échoué, pas de retry

Observer FetchStatus en Dart

lib/data/user_repository.dart
client.watchFetchStatus(['users', userId]).listen((status) {
  switch (status) {
    case FetchStatus.fetching:
      print('Récupération…');
    case FetchStatus.paused:
      print('Hors-ligne : réessaiera en ligne');
    case FetchStatus.idle:
      print('Terminé');
  }
});

Observer FetchStatus dans Flutter

QoraBuilder reçoit FetchStatus comme troisième argument du builder :

lib/ui/screens/user_screen.dart
QoraBuilder<User>(
  queryKey: ['users', userId],
  fetcher: () => api.getUser(userId),
  builder: (context, state, fetchStatus) {
    // Afficher un chip "en pause" quand hors-ligne et en attente de fetch
    final isPaused = fetchStatus == FetchStatus.paused;

    return switch (state) {
      Initial() => const SizedBox.shrink(),
      Loading()  => isPaused
          ? const OfflineWaitingIndicator()
          : const CircularProgressIndicator(),
      Success(:final data) => Column(
          children: [
            UserCard(data),
            if (isPaused) const RefreshingOfflineChip(),
          ],
        ),
      Failure(:final error) => ErrorBanner(error),
    };
  },
)

File de mutations hors-ligne

L'OfflineMutationQueue maintient les opérations d'écriture qui n'ont pas pu être envoyées hors-ligne et les rejoue dans l'ordre FIFO quand l'appareil se reconnecte.

Activer la file

La file est automatiquement créée et attachée à QoraClient. Dans Flutter, QoraMutationBuilder l'injecte automatiquement. Il suffit d'opter par mutation via MutationOptions :

lib/ui/todo_screen.dart
QoraMutationBuilder<void, Todo, void>(
  mutator: (todo) => api.createTodo(todo),
  options: MutationOptions(
    // Opter cette mutation dans la file hors-ligne
    offlineQueue: true,

    // Fournir une réponse optimiste pour que l'UI se mette à jour immédiatement
    optimisticResponse: (todo) => todo,
  ),
  builder: (context, state, mutate) {
    return AddTodoButton(
      onTap: () => mutate(newTodo),
    );
  },
)

Flux hors-ligne : étape par étape

Quand offlineQueue: true et que l'appareil est hors-ligne :

mutate(variables)
      │
      ├── offlineQueue: true & hors-ligne ?
      │       OUI
      │       ├── Mettre en file PendingMutation (FIFO)
      │       ├── optimisticResponse fourni ?
      │       │       OUI → émettre MutationSuccess(isOptimistic: true)
      │       │       NON → rester MutationPending
      │       └── ← retourner à l'appelant
      │
      └── NON (en ligne) → exécuter normalement → MutationSuccess(isOptimistic: false)

À la reconnexion, QoraClient rejoue la file :

reconnexion détectée
      │
      ├── Rejouer les requêtes en pause (par lots, avec jitter)
      └── Rejouer OfflineMutationQueue (FIFO)
              │
              ├── chaque mutation s'exécute une fois, dans l'ordre
              ├── succès → onSuccess, invalidates, onSettled
              └── échec → onError (rollback), onSettled, mutation suivante

isOptimistic : styliser les mutations non confirmées

Quand optimisticResponse est fourni, la mutation émet MutationSuccess(isOptimistic: true). Utiliser ce flag dans l'UI pour afficher un état visuel "en attente" (grisé, icône d'horloge, barré) pour les éléments qui n'ont pas encore été confirmés par le serveur :

lib/ui/todo_list.dart
QoraMutationBuilder<void, Todo, void>(
  mutator: api.createTodo,
  options: MutationOptions(
    offlineQueue: true,
    optimisticResponse: (todo) => todo,

    onSuccess: (_, todo, __) async {
      // S'exécute pour un succès RÉEL (isOptimistic: false) uniquement après confirmation serveur
      context.qora.invalidate(['todos']);
    },
  ),
  builder: (context, state, mutate) {
    // Vérifier isOptimistic sur l'état MutationSuccess
    final isOptimistic = switch (state) {
      MutationSuccess(:final isOptimistic) => isOptimistic,
      _ => false,
    };

    return Column(
      children: [
        if (isOptimistic)
          const Row(
            children: [
              Icon(Icons.schedule, size: 14),
              SizedBox(width: 4),
              Text('En file : synchronisation quand en ligne'),
            ],
          ),
        SubmitButton(
          onTap: state.isPending ? null : () => mutate(newTodo),
        ),
      ],
    );
  },
)

Accéder directement à la file

lib/ui/offline_indicator.dart
// Combien de mutations attendent la relecture ?
final count = context.qora.offlineMutationQueue.length;

// Inspecter les mutations en attente
for (final pending in context.qora.offlineMutationQueue.snapshot) {
  print('${pending.mutatorId} mis en file à ${pending.enqueuedAt}');
}

// Déclencher manuellement une relecture (normalement automatique à la reconnexion)
final result = await context.qora.offlineMutationQueue.replay();
print('Succès : ${result.succeeded}, Échecs : ${result.failed}');

Quand une mutation rejouée échoue

Chaque mutation en file est envoyée une fois, dans l'ordre, par le même chemin qu'un appel en ligne. Quand le serveur en rejette une, onError reçoit le contexte renvoyé par onMutate au moment de la mise en file : le rollback écrit pour le cas en ligne couvre donc aussi le cas hors-ligne. Le rejeu continue ensuite avec la mutation suivante.

Un rejeu en échec n'est pas remis en file. Signalez-le depuis onError (un snackbar, un bouton réessayer) plutôt que de compter sur la prochaine reconnexion.

Gardez les mutations en file indépendantes quand c'est possible. Quand l'une dépend d'une autre (créer un todo, puis le cocher), la seconde échoue si la première a été rejetée, et son propre onError l'annule.


ReconnectStrategy : prévention du thundering herd

Quand la connectivité revient et que des dizaines de requêtes sont suspendues, les rejouer simultanément inonde le serveur. ReconnectStrategy introduit une concurrence contrôlée et du jitter :

lib/core/qora.dart
final client = QoraClient(
  config: QoraClientConfig(
    reconnectStrategy: const ReconnectStrategy(
      maxConcurrent: 5,                         // Rejouer 5 requêtes à la fois
      jitter: Duration(milliseconds: 200),      // Délai aléatoire entre les lots
    ),
  ),
);

Constructeurs nommés

// Défaut : 5 concurrent, 100 ms jitter
const ReconnectStrategy()

// Toutes les requêtes rejouées simultanément (utile pour les tests)
ReconnectStrategy.instant()

// Conservateur : 2 concurrent, 500 ms jitter
ReconnectStrategy.conservative()

Configuration Flutter

1. Installer connectivity_plus

pubspec.yaml
dependencies:
  connectivity_plus: ^6.0.0

2. Câbler QoraScope

lib/main.dart
import 'package:flutter/foundation.dart';
import 'package:qora_flutter/qora_flutter.dart';

void main() {
  final client = QoraClient(
    config: const QoraClientConfig(
      defaultOptions: QoraOptions(
        staleTime: Duration(minutes: 5),
        networkMode: NetworkMode.online,
      ),
      reconnectStrategy: ReconnectStrategy(
        maxConcurrent: 5,
        jitter: Duration(milliseconds: 100),
      ),
      debugMode: kDebugMode,
    ),
  );

  runApp(
    QoraScope(
      client: client,
      lifecycleManager: FlutterLifecycleManager(qoraClient: client),
      // Pas de référence QoraClient requise : fournisseur de signal pur
      connectivityManager: FlutterConnectivityManager(),
      child: const MyApp(),
    ),
  );
}

3. Ajouter NetworkStatusIndicator

Encapsuler l'application (ou tout sous-arbre) avec NetworkStatusIndicator pour afficher une bannière hors-ligne automatiquement :

lib/main.dart
QoraScope(
  client: client,
  connectivityManager: FlutterConnectivityManager(),
  child: NetworkStatusIndicator(
    child: const MyApp(),
  ),
)

Affiche une fine barre sombre en bas de l'écran indiquant "Mode hors-ligne" quand NetworkStatus.offline est détecté.

Bannière hors-ligne personnalisée

lib/main.dart
NetworkStatusIndicator(
  offlineBanner: (context) => Container(
    color: Colors.orange,
    padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
    child: const Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Icon(Icons.wifi_off, color: Colors.white, size: 16),
        SizedBox(width: 8),
        Text('Pas de connexion', style: TextStyle(color: Colors.white)),
      ],
    ),
  ),
  child: const MyApp(),
)

Contrôle total via builder

Utiliser builder quand une réaction à NetworkStatus.online ou NetworkStatus.unknown est nécessaire :

lib/main.dart
NetworkStatusIndicator(
  builder: (context, status, child) {
    return Stack(
      children: [
        child!,
        if (status == NetworkStatus.offline)
          const Positioned(
            bottom: 0, left: 0, right: 0,
            child: MyOfflineBanner(),
          ),
        if (status == NetworkStatus.unknown)
          const Positioned(
            bottom: 0, left: 0, right: 0,
            child: MyCheckingBanner(),
          ),
      ],
    );
  },
  child: const MyApp(),
)

Exemple complet : application Todo offline-first

lib/ui/todo_screen.dart
import 'package:flutter/material.dart';
import 'package:qora_flutter/qora_flutter.dart';

class TodoScreen extends StatelessWidget {
  const TodoScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: QoraBuilder<List<Todo>>(
        queryKey: const ['todos'],
        fetcher: api.getTodos,
        builder: (context, state, fetchStatus) {
          // En pause → hors-ligne, en attente de charger pour la première fois
          if (fetchStatus == FetchStatus.paused && state.dataOrNull == null) {
            return const Center(child: Text('Hors-ligne : connectez-vous pour charger les todos'));
          }

          return switch (state) {
            Initial()  => const SizedBox.shrink(),
            Loading()  => const CircularProgressIndicator(),
            Failure(:final error) => ErrorScreen(error),
            Success(:final data)  => TodoList(
                todos: data,
                isRefreshing: fetchStatus == FetchStatus.fetching,
                isStale: fetchStatus == FetchStatus.paused,
              ),
          };
        },
      ),
      floatingActionButton: QoraMutationBuilder<void, String, void>(
        mutator: (title) => api.createTodo(title),
        options: MutationOptions(
          offlineQueue: true,
          optimisticResponse: (title) => null,
          onSuccess: (_, __, ___) async =>
              context.qora.invalidate(const ['todos']),
        ),
        builder: (context, state, mutate) {
          final isOptimistic = state is MutationSuccess && state.isOptimistic;
          return FloatingActionButton.extended(
            onPressed: state.isPending ? null : () => mutate('Nouveau Todo'),
            icon: isOptimistic
                ? const Icon(Icons.schedule)
                : const Icon(Icons.add),
            label: Text(
              isOptimistic ? 'En file hors-ligne' : 'Ajouter un Todo',
            ),
          );
        },
      ),
    );
  }
}

Étapes suivantes

Copyright © 2026