Démarrage rapide
En dix minutes, vous aurez deux écrans qui chargent des utilisateurs depuis une vraie API, s'ouvrent instantanément à la deuxième visite, se rafraîchissent en arrière-plan et continuent d'afficher les données quand le réseau tombe.
Le code ci-dessous fonctionne tel quel avec JSONPlaceholder, une fausse API REST gratuite.
1. Installer et envelopper l'application
flutter pub add qora_flutter http
import 'package:flutter/material.dart';
import 'package:qora_flutter/qora_flutter.dart';
import 'user_list_screen.dart';
void main() {
final client = QoraClient(
config: const QoraClientConfig(
defaultOptions: QoraOptions(staleTime: Duration(minutes: 5)),
),
);
runApp(QoraScope.auto(client: client, child: const MyApp()));
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) =>
const MaterialApp(home: UserListScreen());
}
staleTime: 5 minutes signifie qu'une donnée récupérée reste fraîche cinq minutes : dans cette fenêtre, rouvrir un écran ne coûte aucun appel réseau.
2. Décrire les données et comment les récupérer
import 'dart:convert';
import 'package:http/http.dart' as http;
class User {
final int id;
final String name;
final String email;
const User({required this.id, required this.name, required this.email});
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'] as int,
name: json['name'] as String,
email: json['email'] as String,
);
}
class UserApi {
static const _base = 'https://jsonplaceholder.typicode.com';
static Future<List<User>> getUsers() async {
final res = await http.get(Uri.parse('$_base/users'));
if (res.statusCode != 200) throw Exception('HTTP ${res.statusCode}');
final list = jsonDecode(res.body) as List<dynamic>;
return list.map((j) => User.fromJson(j as Map<String, dynamic>)).toList();
}
static Future<User> getUser(int id) async {
final res = await http.get(Uri.parse('$_base/users/$id'));
if (res.statusCode != 200) throw Exception('HTTP ${res.statusCode}');
return User.fromJson(jsonDecode(res.body) as Map<String, dynamic>);
}
}
null ou avale l'erreur, Qora voit un succès : il ne relance jamais et n'affiche pas l'erreur.3. L'écran liste
QoraBuilder récupère les données au montage, met le résultat en cache sous sa clé et se reconstruit à chaque changement. Le builder reçoit l'état, que vous matchez de façon exhaustive, et un FetchStatus qui indique si une requête est en cours.
import 'package:flutter/material.dart';
import 'package:qora_flutter/qora_flutter.dart';
import 'user_api.dart';
import 'user_detail_screen.dart';
class UserListScreen extends StatelessWidget {
const UserListScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Users')),
body: QoraBuilder<List<User>>(
queryKey: const ['users'],
fetcher: UserApi.getUsers,
builder: (context, state, fetchStatus) => switch (state) {
Success(:final data) => _UserList(users: data),
// Un refetch ou une erreur conserve les données précédentes : on les affiche.
Loading(:final previousData?) => _UserList(users: previousData),
Failure(:final previousData?) => _UserList(users: previousData),
Failure(:final error) => Center(child: Text('Could not load users: $error')),
_ => const Center(child: CircularProgressIndicator()),
},
),
);
}
}
class _UserList extends StatelessWidget {
const _UserList({required this.users});
final List<User> users;
@override
Widget build(BuildContext context) {
return RefreshIndicator(
// Marque la liste comme périmée : le QoraBuilder monté la recharge.
onRefresh: () async => context.qora.invalidate(const ['users']),
child: ListView.builder(
itemCount: users.length,
itemBuilder: (context, i) => ListTile(
title: Text(users[i].name),
subtitle: Text(users[i].email),
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => UserDetailScreen(userId: users[i].id),
)),
),
),
);
}
}
4. L'écran détail
Chaque utilisateur a sa propre clé, ['users', id]. La clé est l'adresse dans le cache : même clé, mêmes données, partout où elle est utilisée.
import 'package:flutter/material.dart';
import 'package:qora_flutter/qora_flutter.dart';
import 'user_api.dart';
class UserDetailScreen extends StatelessWidget {
const UserDetailScreen({super.key, required this.userId});
final int userId;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('User #$userId')),
body: QoraBuilder<User>(
queryKey: ['users', userId],
fetcher: () => UserApi.getUser(userId),
builder: (context, state, fetchStatus) {
final user = state.dataOrNull; // conservé pendant les refetchs et les erreurs
if (user == null) {
return switch (state) {
Failure(:final error) => Center(child: Text('Error: $error')),
_ => const Center(child: CircularProgressIndicator()),
};
}
return ListTile(title: Text(user.name), subtitle: Text(user.email));
},
),
);
}
}
Lancez l'application. Ouvrez un utilisateur, revenez, rouvrez-le : la deuxième fois, il s'affiche instantanément, sans spinner. Il vient du cache.
5. Passer hors-ligne
QoraScope.auto suit déjà la connectivité. Quand l'appareil est hors-ligne, les requêtes n'échouent pas : elles se mettent en pause, et fetchStatus devient FetchStatus.paused. Affichez-le dans l'écran liste :
builder: (context, state, fetchStatus) => Column(
children: [
if (fetchStatus == FetchStatus.paused)
Container(
width: double.infinity,
padding: const EdgeInsets.all(8),
color: Colors.amber.shade100,
child: const Text('Vous êtes hors-ligne. Données sauvegardées affichées.'),
),
Expanded(
child: switch (state) {
Success(:final data) => _UserList(users: data),
Loading(:final previousData?) => _UserList(users: previousData),
Failure(:final previousData?) => _UserList(users: previousData),
Failure(:final error) => Center(child: Text('Could not load users: $error')),
_ => const Center(child: CircularProgressIndicator()),
},
),
],
),
Activez le mode avion et tirez pour rafraîchir : la liste reste, le bandeau apparaît. Réactivez le réseau : le fetch en pause s'exécute tout seul et le bandeau disparaît.
Ce que vous obtenez sans rien écrire
| Situation | Ce que fait Qora |
|---|---|
| Première visite | Loading, puis Success ; le résultat est mis en cache sous sa clé |
| Visite dans les 5 minutes | Données du cache dès la première frame, aucun appel réseau |
| Visite après 5 minutes | Données du cache dès la première frame, rafraîchies en arrière-plan |
| Tirer pour rafraîchir | invalidate marque la clé comme périmée ; le builder monté recharge |
| Deux widgets, même clé | Une seule requête, partagée |
| La requête échoue | 3 retries avec backoff, puis Failure avec les données précédentes |
| Appareil hors-ligne | Les fetchs se mettent en pause et reprennent à la reconnexion ; les données restent affichées |
| Retour d'arrière-plan | Les requêtes périmées affichées à l'écran se relancent |
Étapes suivantes
- Mutations : écrire des données et invalider ce qui a changé
- Persistance : conserver le cache entre deux lancements
- Clés de requête : structurer les clés pour garder l'invalidation simple
- Étude de cas offline-first : une application complète qui met les écritures en file hors-ligne