Wallet, une application de paiement mobile
Une application de portefeuille électronique construite chapitre après chapitre, contre un petit backend local. Elle couvre ce qu'un entretien senior aborde : authentification, réseau, état, hors ligne, sécurité, tests et livraison.
Pourquoi ce projet
- Il ressemble aux missions des grands comptes. Un wallet combine argent, sécurité, API d'une gateway et exigences de fiabilité. Ce sont les sujets des entretiens dans la banque et les télécoms.
- Il force les bons réflexes. Montants en entiers, refresh token concurrent, idempotence des paiements, cache hors ligne : chaque étape correspond à une question d'entretien classique.
- Il vous laisse un code à montrer. Publiez-le sur GitHub. Quand le code de vos clients est confidentiel, un projet propre, testé et intégré en continu répond à la demande « avez-vous un exemple de code ? ».
Le projet se code sans assistant IA. Son but est de vous réentraîner à écrire, pas d'obtenir l'application. Une fois une étape terminée, vous pouvez demander une revue à un assistant, comme à un collègue.
Les écrans
| Écran | Contenu | Ce qu'il exerce |
|---|---|---|
| Connexion | Téléphone, mot de passe, erreurs | Formulaire, validation, stockage sécurisé |
| Accueil | Solde, 5 dernières transactions, actions rapides | AsyncValue, pull-to-refresh, composition de providers |
| Historique | Liste paginée par curseur, filtre par type | Pagination infinie, slivers, cache hors ligne |
| Détail | Une transaction, ouvrable par deep link | go_router, paramètres de route, family |
| Transfert | Bénéficiaire, montant, note, confirmation, résultat | Mutation, double soumission, idempotence, outbox |
| Bénéficiaires | Liste, ajout, recherche | Debounce, formulaire, invalidation |
| Profil | Infos, type de compte, déconnexion | Nettoyage de l'état, redirection |
Le contrat d'API
Le backend local est dans le dossier backend/ du dépôt (voir Lancer le backend). Pour les premiers chapitres, vous pouvez aussi simuler les réponses avec un faux repository : c'est une bonne pratique quand le back-end d'un client n'est pas prêt.
- Base :
http://localhost:8080/api/v1. Depuis l'émulateur Android :http://10.0.2.2:8080/api/v1. - Montants en entiers, en centimes, devise
MAD. Jamais dedoublepour de l'argent. - Dates en ISO 8601, UTC.
- Comptes de démo :
0600000001/demo1234(particulier),0600000002/demo1234(pro).
| Méthode | Route | Entrée | Sortie |
|---|---|---|---|
| POST | /auth/login | { phone, password } | { accessToken, refreshToken, expiresIn: 300 } |
| POST | /auth/refresh | { refreshToken } | Nouvelle paire. L'ancien refresh token est invalidé (rotation). Le rejouer révoque toute la session. |
| POST | /auth/logout | { refreshToken } | 204 |
| GET | /me | - | { id, fullName, phone, accountType, kycStatus } |
| GET | /wallet | - | { balance: 125000, currency: "MAD" } |
| GET | /transactions | ?cursor=&limit=20&type= | { items: [Transaction], nextCursor } |
| GET | /transactions/{id} | - | Transaction |
| GET | /beneficiaries | - | [{ id, name, phone }] |
| POST | /beneficiaries | { name, phone } | 201 et le bénéficiaire |
| POST | /transfers | En-tête Idempotency-Key, corps { beneficiaryId, amount, note? } | 201 et la Transaction |
Une transaction
{
"id": "tx_01J9Z3",
"type": "transfer_out",
"amount": 15000,
"direction": "debit",
"counterparty": "Salma B.",
"note": "Loyer octobre",
"status": "completed",
"createdAt": "2026-10-21T14:32:05Z"
}
Types : transfer_in, transfer_out, payment, cash_in, cash_out, bill. Statuts : completed, pending, failed. Votre modèle doit tolérer un type inconnu ajouté plus tard par le serveur.
Les erreurs
{ "error": { "code": "INSUFFICIENT_FUNDS", "message": "Solde insuffisant" } }
Pour VALIDATION_ERROR, un objet fields donne le message de chaque champ en erreur, à afficher sous le champ du formulaire :
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Certains champs sont invalides",
"fields": { "amount": "Montant entre 1 et 500000 centimes" }
}
}
| Code | HTTP | Comportement attendu de l'app |
|---|---|---|
INVALID_CREDENTIALS | 401 | Message sur l'écran de connexion |
TOKEN_EXPIRED | 401 | Refresh silencieux, puis rejeu de la requête |
REFRESH_INVALID | 401 | Déconnexion, retour à la connexion |
UNAUTHENTICATED | 401 | Token absent, invalide ou session révoquée : déconnexion, sans tenter de refresh |
VALIDATION_ERROR | 422 | Erreurs de champ dans le formulaire |
INSUFFICIENT_FUNDS | 422 | Message métier clair, pas de nouvel essai |
NOT_FOUND | 404 | Écran vide explicite |
RATE_LIMITED | 429 | Attendre la durée de l'en-tête Retry-After (en secondes), puis réessayer |
SERVER_ERROR | 500 | Message générique, nouvel essai possible si la requête est idempotente |
Le mode chaos
Pour tester la résilience, le backend accepte deux en-têtes : X-Chaos-Latency: 2000 ajoute 2 secondes de latence, X-Chaos-Fail-Rate: 0.3 fait échouer 30 % des requêtes en 500.
Sur POST /transfers, l'échec simulé arrive après l'enregistrement : le débit a eu lieu mais la réponse est perdue, comme lors d'une coupure réseau au mauvais moment. Rejouer le transfert avec le même Idempotency-Key renvoie la même transaction, avec l'en-tête Idempotent-Replayed: true, sans débiter deux fois. La même clé avec un corps différent est refusée en 422.
Lancer le backend
Un serveur Dart (shelf) qui garde tout en mémoire : pas de base de données, pas de Docker. Chaque redémarrage remet les données de démo, et les tokens émis avant deviennent invalides.
cd backend
dart pub get
dart run bin/server.dart # port 8080, access token de 300 s
dart run bin/server.dart --port 8081 # si le port 8080 est déjà pris
dart run bin/server.dart --access-ttl 20 # pour tester le refresh sans attendre 5 min
dart test # tests du contrat
- Le serveur écoute sur
0.0.0.0: un téléphone sur le même réseau Wi-Fi l'atteint avec l'adresse IP de votre machine. - Cinq mots de passe faux en une minute sur le même numéro déclenchent un
429avecRetry-After. - L'access token est un JWT : l'application peut lire
exppour rafraîchir à l'avance, mais seul le serveur fait foi. - Un transfert vers
Atelier Nour SARLcrédite le compte pro : connectez-vous avec les deux comptes pour voir les deux côtés. POST /api/v1/dev/resetremet les données de départ, utile avant un test d'intégration.
Si cinq requêtes reçoivent TOKEN_EXPIRED en même temps et que votre intercepteur lance cinq refresh, le deuxième rejoue un refresh token déjà échangé : le serveur révoque la session et l'utilisateur est déconnecté. Le backend reproduit ce comportement exprès. Un seul refresh doit partir, les autres requêtes l'attendent.
L'arborescence cible
Les étapes
| Chapitre | Étape | Fini quand |
|---|---|---|
| 01 | Créer le projet, modéliser Transaction, le type, et un extension type Money | Un switch exhaustif sur le type compile, un type inconnu ne fait pas planter |
| 02 | Recherche de bénéficiaires avec debounce, parsing d'une grosse réponse dans un isolate | Une seule requête part après 300 ms sans frappe |
| 03 et 04 | Écrans Accueil et Historique avec des données factices | Liste fluide en mode profile, en-têtes par date, RTL correct en arabe |
| 05 | go_router : redirection selon l'auth, trois onglets, deep link wallet://transactions/:id | Un utilisateur déconnecté ne voit jamais un écran protégé |
| 06 et 07 | Providers du solde, de l'historique paginé, Notifier du transfert | Pas de double soumission, pull-to-refresh garde les données affichées |
| 08 | Couches, repositories, Result et Failure, flavors dev et prod | Aucun widget n'importe Dio |
| 09 | Dio, intercepteur d'auth avec refresh concurrent, pagination par curseur | 5 requêtes en 401 simultanées déclenchent un seul refresh |
| 10 | Stockage sécurisé, déconnexion, verrouillage biométrique, pinning | Aucun token dans les logs ni dans shared_preferences |
| 11 | Cache drift de l'historique, outbox des transferts | Un transfert fait en mode avion part une seule fois au retour du réseau |
| 12 | Tests unitaires, widget, golden, intégration | Le Notifier du transfert est couvert, un test d'intégration passe le login |
| 13 | CI GitHub Actions : analyse, format, tests, build | Une pull request rouge ne peut pas être fusionnée |
Les dépendances
Ajoutez-les au fil des chapitres, pas toutes au début. Prenez les dernières versions stables sur pub.dev au moment où vous les ajoutez.
# state, navigation, réseau
flutter pub add flutter_riverpod riverpod_annotation go_router dio
# modèles
flutter pub add freezed_annotation json_annotation
flutter pub add dev:build_runner dev:freezed dev:json_serializable dev:riverpod_generator
# stockage
flutter pub add flutter_secure_storage drift drift_flutter
flutter pub add dev:drift_dev
# tests
flutter pub add dev:mocktail
flutter pub add 'dev:integration_test:{"sdk":"flutter"}'