Projet fil rouge

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.

Stack Flutter 3.47, Dart 3.13, Riverpod 3 Durée environ 60 min par séance, semaines 1 à 4

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 ? ».
Règle du projet

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

ÉcranContenuCe qu'il exerce
ConnexionTéléphone, mot de passe, erreursFormulaire, validation, stockage sécurisé
AccueilSolde, 5 dernières transactions, actions rapidesAsyncValue, pull-to-refresh, composition de providers
HistoriqueListe paginée par curseur, filtre par typePagination infinie, slivers, cache hors ligne
DétailUne transaction, ouvrable par deep linkgo_router, paramètres de route, family
TransfertBénéficiaire, montant, note, confirmation, résultatMutation, double soumission, idempotence, outbox
BénéficiairesListe, ajout, rechercheDebounce, formulaire, invalidation
ProfilInfos, type de compte, déconnexionNettoyage 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 de double pour de l'argent.
  • Dates en ISO 8601, UTC.
  • Comptes de démo : 0600000001 / demo1234 (particulier), 0600000002 / demo1234 (pro).
MéthodeRouteEntréeSortie
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/transfersEn-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" }
  }
}
CodeHTTPComportement attendu de l'app
INVALID_CREDENTIALS401Message sur l'écran de connexion
TOKEN_EXPIRED401Refresh silencieux, puis rejeu de la requête
REFRESH_INVALID401Déconnexion, retour à la connexion
UNAUTHENTICATED401Token absent, invalide ou session révoquée : déconnexion, sans tenter de refresh
VALIDATION_ERROR422Erreurs de champ dans le formulaire
INSUFFICIENT_FUNDS422Message métier clair, pas de nouvel essai
NOT_FOUND404Écran vide explicite
RATE_LIMITED429Attendre la durée de l'en-tête Retry-After (en secondes), puis réessayer
SERVER_ERROR500Message 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 429 avec Retry-After.
  • L'access token est un JWT : l'application peut lire exp pour rafraîchir à l'avance, mais seul le serveur fait foi.
  • Un transfert vers Atelier Nour SARL crédite le compte pro : connectez-vous avec les deux comptes pour voir les deux côtés.
  • POST /api/v1/dev/reset remet les données de départ, utile avant un test d'intégration.
Piège du chapitre 09

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

lib/ main_dev.dart, main_prod.dart app/ router.dart, theme.dart, env.dart core/ network/ dio_provider.dart, auth_interceptor.dart storage/ secure_storage.dart, database.dart errors/ failure.dart, result.dart features/ auth/ data/ domain/ presentation/ wallet/ data/ domain/ presentation/ transactions/ data/ domain/ presentation/ transfer/ data/ domain/ presentation/ beneficiaries/ test/ integration_test/

Les étapes

ChapitreÉtapeFini quand
01Créer le projet, modéliser Transaction, le type, et un extension type MoneyUn switch exhaustif sur le type compile, un type inconnu ne fait pas planter
02Recherche de bénéficiaires avec debounce, parsing d'une grosse réponse dans un isolateUne seule requête part après 300 ms sans frappe
03 et 04Écrans Accueil et Historique avec des données facticesListe fluide en mode profile, en-têtes par date, RTL correct en arabe
05go_router : redirection selon l'auth, trois onglets, deep link wallet://transactions/:idUn utilisateur déconnecté ne voit jamais un écran protégé
06 et 07Providers du solde, de l'historique paginé, Notifier du transfertPas de double soumission, pull-to-refresh garde les données affichées
08Couches, repositories, Result et Failure, flavors dev et prodAucun widget n'importe Dio
09Dio, intercepteur d'auth avec refresh concurrent, pagination par curseur5 requêtes en 401 simultanées déclenchent un seul refresh
10Stockage sécurisé, déconnexion, verrouillage biométrique, pinningAucun token dans les logs ni dans shared_preferences
11Cache drift de l'historique, outbox des transfertsUn transfert fait en mode avion part une seule fois au retour du réseau
12Tests unitaires, widget, golden, intégrationLe Notifier du transfert est couvert, un test d'intégration passe le login
13CI GitHub Actions : analyse, format, tests, buildUne 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"}'