Navigation
De Navigator.push à go_router : routes paramétrées, bottom navigation qui garde l'état des onglets, garde d'authentification branchée sur Riverpod, routes typées, deep links vérifiés. Dans une app bancaire, la navigation est aussi une question de sécurité : aucun écran protégé ne doit être atteignable sans session.
Objectifs
- Expliquer pourquoi la Router API existe et ce que go_router apporte au-dessus.
- Écrire sans IA une configuration go_router avec
StatefulShellRouteet une redirection d'authentification correcte en Riverpod 3. - Choisir entre
goetpush, et justifier le recours aux routes typées. - Configurer un deep link par schéma custom et un App Link / Universal Link vérifié, et savoir les tester.
Navigator 1.0 : la pile impérative
Le Navigator historique gère une pile de Route. On empile avec push, on dépile avec pop. Une route peut renvoyer un résultat : push retourne un Future complété par la valeur passée à pop. Ce modèle reste parfaitement valable pour des flux locaux : sélecteur, dialogue, assistant en plusieurs étapes.
// Écran de transfert : on ouvre le sélecteur et on attend le bénéficiaire choisi.
Future<void> pickBeneficiary(BuildContext context) async {
final selected = await Navigator.of(context).push<Beneficiary>(
MaterialPageRoute(builder: (_) => const BeneficiaryPickerScreen()),
);
// Après un await, le widget a pu être démonté : on vérifie avant d'utiliser context.
if (!context.mounted || selected == null) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Bénéficiaire : ${selected.name}')),
);
}
// Dans le sélecteur :
// Navigator.of(context).pop(beneficiary);
Routes nommées et leurs limites
Les routes nommées (routes: {'/transfer': ...}, Navigator.pushNamed) centralisent les écrans, mais la documentation officielle ne les recommande plus pour la plupart des apps. Les arguments passent en Object? non typé, les chemins paramétrés (/transactions/42) demandent un onGenerateRoute écrit à la main, le comportement des deep links n'est pas personnalisable, et sur le web le bouton « suivant » du navigateur n'est pas géré.
Flutter 3.41 ajoute Navigator.popUntilWithResult(context, predicate, result) : dépile jusqu'à la route qui satisfait le prédicat, et transmet result à la dernière route dépilée, donc au Future du push qui l'avait ouverte. Avant, il fallait enchaîner des pop ou faire transiter le résultat par un état partagé.
// Pile : Transfert ─push─▶ Montant ─push─▶ Confirmation.
// Depuis Confirmation, on revient à Transfert ; le Future du push(Montant),
// attendu dans l'écran Transfert, reçoit TransferOutcome.completed.
Navigator.popUntilWithResult<TransferOutcome>(
context,
ModalRoute.withName('/transfer'),
TransferOutcome.completed,
);
Navigator 2.0 et la Router API
La Router API (dite Navigator 2.0) est née de trois besoins que le modèle impératif couvre mal : le web (la barre d'adresse doit refléter l'écran et inversement), les deep links (ouvrir l'app directement sur /transactions/42 avec la bonne pile en dessous), et une navigation déclarative (la pile de pages est une fonction de l'état de l'app, comme l'UI est une fonction de l'état).
L'API est puissante mais verbeuse : écrire soi-même un RouterDelegate et un parser pour une vraie app représente beaucoup de code et de pièges. En pratique, on utilise un package au-dessus. go_router est maintenu par l'équipe Flutter et référencé dans la documentation officielle.
« Navigator 1.0 est impératif : je pousse et je dépile. La Router API rend la pile déclarative et synchronisée avec une URL, ce qui est indispensable pour le web et les deep links. Je ne l'utilise pas directement : go_router l'implémente pour moi, et je garde Navigator.push pour les flux locaux comme un sélecteur ou un dialogue. »
go_router en détail
La version stable en octobre 2026 est la 18.x. Depuis la 18.0, go_router dépend de material_ui et cupertino_ui et exige Flutter 3.44 minimum. Changements cassants à connaître des versions précédentes : URL sensibles à la casse par défaut (15.0, paramètre caseSensitive), onExit qui reçoit aussi le GoRouterState (14.0), observers notifiés pour les navigations dans les ShellRoute (17.0).
Routes, paramètres de chemin et de query
GoRoute(
path: '/transactions',
builder: (context, state) {
// Paramètre de query optionnel : /transactions?type=bill
final type = state.uri.queryParameters['type'];
return HistoryScreen(typeFilter: type);
},
routes: [
// Sous-route relative : /transactions/:id
GoRoute(
path: ':id',
builder: (context, state) =>
TransactionDetailScreen(id: state.pathParameters['id']!),
),
],
);
// Navigation :
// context.go('/transactions/tx_42');
// context.go(Uri(path: '/transactions', queryParameters: {'type': 'bill'}).toString());
Les sous-routes construisent la pile : aller sur /transactions/tx_42 affiche le détail au-dessus de l'historique, avec un bouton retour qui ramène à la liste, y compris quand on arrive par un deep link.
extra et ses limites
context.go('/transactions/tx_42', extra: transaction) passe un objet quelconque à l'écran cible. Pratique pour éviter un appel réseau, mais extra n'est pas dans l'URL : il est perdu sur un deep link, au rechargement d'une page web, et à la restauration d'état après la mort du processus, sauf à fournir un extraCodec pour le sérialiser. Règle : l'URL doit suffire à reconstruire l'écran (l'id), extra n'est qu'une optimisation facultative.
builder: (context, state) => TransactionDetailScreen(tx: state.extra! as Transaction),
// crash sur deep link : extra est null
builder: (context, state) => TransactionDetailScreen(
id: state.pathParameters['id']!,
initial: state.extra as Transaction?, // affichage immédiat si présent, sinon chargement par id
),
ShellRoute et StatefulShellRoute
Une ShellRoute enveloppe ses sous-routes dans un widget commun (scaffold avec bottom bar) et leur donne un Navigator imbriqué. Mais changer d'onglet remplace la pile : on perd la position de défilement et la sous-page ouverte. StatefulShellRoute donne à chaque onglet (branch) son propre navigator, conservé en mémoire. StatefulShellRoute.indexedStack les place dans un IndexedStack.
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) =>
AppShell(navigationShell: navigationShell),
branches: [
StatefulShellBranch(routes: [
GoRoute(path: '/home', builder: (context, state) => const HomeScreen()),
]),
StatefulShellBranch(routes: [
GoRoute(
path: '/transactions',
builder: (context, state) => const HistoryScreen(),
routes: [
GoRoute(
path: ':id',
builder: (context, state) =>
TransactionDetailScreen(id: state.pathParameters['id']!),
),
],
),
]),
StatefulShellBranch(routes: [
GoRoute(path: '/profile', builder: (context, state) => const ProfileScreen()),
]),
],
);
class AppShell extends StatelessWidget {
const AppShell({super.key, required this.navigationShell});
final StatefulNavigationShell navigationShell;
@override
Widget build(BuildContext context) {
return Scaffold(
body: navigationShell,
bottomNavigationBar: NavigationBar(
selectedIndex: navigationShell.currentIndex,
onDestinationSelected: (index) => navigationShell.goBranch(
index,
// Retaper l'onglet actif ramène à sa racine (convention mobile).
initialLocation: index == navigationShell.currentIndex,
),
destinations: const [
NavigationDestination(icon: Icon(Icons.home_outlined), label: 'Accueil'),
NavigationDestination(icon: Icon(Icons.receipt_long), label: 'Historique'),
NavigationDestination(icon: Icon(Icons.person_outline), label: 'Profil'),
],
),
);
}
}
Un écran qui doit masquer la bottom bar (le flux de transfert, par exemple) se déclare comme route de premier niveau, hors du shell : il s'affiche alors sur le navigator racine, par-dessus le scaffold. StatefulShellBranch(preload: true) construit une branche avant sa première visite, à réserver aux onglets dont le chargement initial est visible.
« Comment garder la position de scroll de l'historique quand l'utilisateur passe par l'onglet Profil et revient ? » Avec une ShellRoute simple, impossible sans sauvegarde manuelle : la pile est reconstruite. La réponse attendue est StatefulShellRoute, un navigator par branche, et goBranch pour changer d'onglet. Mentionnez le coût : les branches visitées restent en mémoire.
Garde d'authentification : redirect et refreshListenable
redirect est appelé avant chaque navigation : il renvoie null pour laisser passer, ou un autre chemin. Mais la navigation n'est pas le seul déclencheur : quand la session expire, ou quand l'utilisateur se connecte, il faut réévaluer les redirections sur l'écran courant. C'est le rôle de refreshListenable : chaque notification relance redirect.
Avec Riverpod 3, l'état d'auth vit dans un Notifier (chapitre 06). Il faut un pont entre ce provider et un Listenable. Le pattern correct :
sealed class AuthState {
const AuthState();
}
final class AuthUnknown extends AuthState {
const AuthUnknown(); // au démarrage, lecture du stockage sécurisé en cours
}
final class Authenticated extends AuthState {
const Authenticated(this.userId);
final String userId;
}
final class Unauthenticated extends AuthState {
const Unauthenticated();
}
final routerProvider = Provider<GoRouter>((ref) {
// Pont Riverpod ─▶ Listenable. On utilise listen, pas watch :
// watch recréerait le GoRouter (et perdrait la pile) à chaque changement d'auth.
final auth = ValueNotifier<AuthState>(ref.read(authProvider));
ref.listen<AuthState>(authProvider, (_, next) => auth.value = next);
final router = GoRouter(
initialLocation: '/home',
refreshListenable: auth,
redirect: (context, state) => authRedirect(auth.value, state.uri),
routes: appRoutes,
);
ref.onDispose(() {
router.dispose();
auth.dispose();
});
return router;
});
/// Fonction pure : testable sans widget ni router.
String? authRedirect(AuthState auth, Uri uri) {
final path = uri.path;
final onLogin = path == '/login';
final onSplash = path == '/splash';
switch (auth) {
case AuthUnknown():
return onSplash ? null : _withFrom('/splash', uri);
case Unauthenticated():
return onLogin ? null : _withFrom('/login', uri);
case Authenticated():
if (!onLogin && !onSplash) return null;
return _safeFrom(uri.queryParameters['from']) ?? '/home';
}
}
String _withFrom(String target, Uri uri) {
// On conserve la destination demandée (deep link compris) pour y revenir après login.
final from = uri.queryParameters['from'] ?? uri.toString();
if (from == '/' || from == target) return target;
return Uri(path: target, queryParameters: {'from': from}).toString();
}
String? _safeFrom(String? from) {
// Anti open-redirect : uniquement un chemin interne.
if (from == null || !from.startsWith('/') || from.startsWith('//')) return null;
return from;
}
// Dans l'app :
class WalletApp extends ConsumerWidget {
const WalletApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return MaterialApp.router(routerConfig: ref.watch(routerProvider));
}
}
« Pourquoi ne pas simplement faire ref.watch(authProvider) dans le provider du router ? » Parce que chaque changement d'auth recréerait une instance de GoRouter : MaterialApp.router reçoit une nouvelle configuration, la pile de navigation et l'état des onglets sont perdus, et l'ancien router n'est pas forcément libéré. Le router doit être créé une fois ; seul redirect est réévalué, déclenché par refreshListenable. Deuxième piège : une redirection qui renvoie toujours un chemin, même quand on y est déjà, provoque une boucle (go_router s'arrête après redirectLimit, 5 par défaut, avec une erreur).
Redirection et sécurité : la garde côté client améliore l'expérience mais ne protège rien à elle seule. Le backend refuse toute requête sans token valide. Côté app, l'écran protégé ne doit pas afficher de données en cache avant que la session soit confirmée, d'où l'état AuthUnknown et l'écran de splash.
go_router a ajouté un callback onEnter(context, currentState, nextState, router) qui renvoie Allow ou Block (Block.stop(), Block.then(...)). Il est évalué une fois par navigation, avant redirect, sur la cible littérale. La documentation qualifie désormais redirect de « legacy » mais le garde pleinement supporté. Pour une garde qui doit réagir à un changement d'état (déconnexion), redirect avec refreshListenable reste le pattern le plus direct ; onEnter sert plutôt à bloquer une navigation précise ou à journaliser les deep links entrants.
Routes typées avec go_router_builder
Les chemins en chaînes ('/transactions/$id') ont deux défauts : une faute de frappe ne se voit qu'à l'exécution, et les paramètres sont des String à parser à la main. go_router_builder génère, à partir de classes annotées, la configuration et des méthodes de navigation typées. Dans une équipe de dix développeurs, c'est la différence entre un renommage sûr par le compilateur et une recherche textuelle.
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
part 'routes.g.dart';
@TypedGoRoute<HistoryRoute>(
path: '/transactions',
routes: [TypedGoRoute<TransactionDetailRoute>(path: ':id')],
)
class HistoryRoute extends GoRouteData with $HistoryRoute {
const HistoryRoute({this.type});
final String? type; // champ hors du chemin : devient un paramètre de query
@override
Widget build(BuildContext context, GoRouterState state) =>
HistoryScreen(typeFilter: type);
}
class TransactionDetailRoute extends GoRouteData with $TransactionDetailRoute {
const TransactionDetailRoute({required this.id});
final String id; // lu depuis :id, typé
@override
Widget build(BuildContext context, GoRouterState state) =>
TransactionDetailScreen(id: id);
}
// Utilisation :
// const TransactionDetailRoute(id: 'tx_42').go(context);
// final ok = await const HistoryRoute(type: 'bill').push<bool>(context);
// Dans un redirect : return const HistoryRoute().location;
// Configuration : GoRouter(routes: $appRoutes, ...)
Le mixin généré ($HistoryRoute) fournit location, go, push, pushReplacement et replace (go_router 16+ et go_router_builder 3+). Les types simples (int, enum, DateTime...) sont convertis automatiquement ; un champ nommé $extra reçoit l'extra. Il existe aussi TypedShellRoute et TypedStatefulShellRoute. Génération : dart run build_runner build.
go ou push
context.go(location) | context.push(location) | |
|---|---|---|
| Effet | Remplace la pile par celle qui correspond à l'URL (sous-routes comprises) | Empile une page au-dessus de la pile actuelle |
| URL | Reflète la destination | Mise à jour, mais la pile ne correspond plus à une URL unique |
| Résultat | Aucun | Future<T?> complété par context.pop(result) |
| Usage typique | Changer de section, après login, deep link | Détail ouvert depuis un écran, sélecteur qui renvoie une valeur |
Dans un shell, go('/transactions/tx_42') depuis l'accueil bascule sur l'onglet Historique ; push empilerait le détail dans l'onglet Accueil. Les deux sont légitimes, mais le choix doit être volontaire et cohérent dans l'app.
« go est déclaratif : je dis où je veux être, go_router reconstruit la pile à partir des sous-routes. push est impératif : j'empile au-dessus de l'existant, et je peux attendre un résultat. Par défaut j'utilise go, ce qui garde la navigation reproductible depuis une URL ; j'utilise push quand j'ai besoin d'un retour vers l'écran appelant ou d'un résultat. »
Touche retour Android et predictive back
Depuis Android 14, le predictive back (retour prédictif) montre un aperçu de l'écran précédent pendant le geste. Cela impose que l'app déclare à l'avance si elle accepte le retour : l'ancien WillPopScope, qui décidait au moment du geste, est remplacé par PopScope. canPop: false bloque le retour (et désactive l'aperçu) ; onPopInvokedWithResult est appelé après la tentative, qu'elle ait abouti ou non.
class TransferScreen extends StatefulWidget {
const TransferScreen({super.key});
@override
State<TransferScreen> createState() => _TransferScreenState();
}
class _TransferScreenState extends State<TransferScreen> {
bool _dirty = false; // passe à true dès qu'un champ est rempli
@override
Widget build(BuildContext context) {
return PopScope<Object?>(
canPop: !_dirty,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return; // retour déjà effectué
final leave = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Abandonner le transfert ?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Rester')),
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Abandonner')),
],
),
);
if (leave == true && context.mounted) Navigator.of(context).pop();
},
child: TransferForm(onChanged: () => setState(() => _dirty = true)),
);
}
}
Côté Android, le retour prédictif est activé par android:enableOnBackInvokedCallback="true" dans le manifeste. Les transitions de page prédictives se règlent via PageTransitionsTheme (PredictiveBackPageTransitionsBuilder) ; selon la version de Flutter et de material_ui, elles sont activées par défaut ou à activer explicitement : vérifiez la page « Predictive back » de la documentation pour votre version. Avec go_router, GoRoute.onExit permet aussi de confirmer la sortie d'une route.
Deep links, App Links et Universal Links
Trois mécanismes, trois niveaux de confiance :
| Mécanisme | Exemple | Vérification | Limite |
|---|---|---|---|
| Schéma custom | wallet://transactions/42 | Aucune : n'importe quelle app peut déclarer le même schéma | Ne fonctionne pas si l'app n'est pas installée ; jamais de donnée sensible dans l'URL |
| Android App Links | https://wallet.example.com/transactions/42 | Fichier assetlinks.json sur le domaine, empreinte du certificat de signature | Sans app, l'URL ouvre le site web |
| iOS Universal Links | même URL https | Fichier apple-app-site-association + entitlement Associated Domains | Idem ; cache côté Apple |
<!-- Schéma custom -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="wallet" android:host="transactions" />
</intent-filter>
<!-- App Link vérifié -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="wallet.example.com" android:pathPrefix="/transactions" />
</intent-filter>
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.wallet",
"sha256_cert_fingerprints": ["14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5"]
}
}]
{
"applinks": {
"details": [{
"appIDs": ["ABCDE12345.com.example.wallet"],
"components": [{ "/": "/transactions/*" }]
}]
}
}
Côté iOS : capability Associated Domains avec applinks:wallet.example.com, et pour le schéma custom une entrée CFBundleURLTypes dans Info.plist. Le fichier AASA est servi en HTTPS, sans extension, en application/json, sans redirection.
« Les App Links marchent en debug mais pas pour l'app publiée sur le Play Store. » Cause classique : assetlinks.json contient l'empreinte du certificat de debug ou de la clé d'upload, alors que Google Play re-signe l'app avec la clé Play App Signing. Il faut l'empreinte affichée dans la Play Console (Intégrité de l'application). Côté iOS, l'équivalent est un AASA servi derrière une redirection ou avec le mauvais Team ID.
Le cas du schéma custom avec go_router
Flutter gère les deep links nativement (désactivable par flutter_deeplinking_enabled à false sur Android ou FlutterDeepLinkingEnabled sur iOS si un plugin s'en charge). Sur Android, le moteur transmet l'URI complète au router. Or dans wallet://transactions/42, transactions est l'hôte de l'URI et le chemin vaut /42 : go_router, qui fait la correspondance sur le chemin, ne trouvera pas la route. On normalise dans redirect, qui est évalué même quand aucune route ne correspond :
/// wallet://transactions/42 ─▶ /transactions/42. Renvoie null si rien à faire.
String? normalizeCustomScheme(Uri uri) {
if (uri.scheme != 'wallet') return null;
return Uri(
path: '/${uri.host}${uri.path}',
queryParameters: uri.queryParameters.isEmpty ? null : uri.queryParameters,
).toString();
}
// Dans le GoRouter :
// redirect: (context, state) =>
// normalizeCustomScheme(state.uri) ?? authRedirect(auth.value, state.uri),
Point à vérifier sur votre version : le comportement exact de l'embedder iOS pour les schémas custom. Loguez state.uri dans redirect et testez sur les deux plateformes.
# Android : app lancée ou non
adb shell am start -a android.intent.action.VIEW -d "wallet://transactions/tx_42"
adb shell am start -a android.intent.action.VIEW -d "https://wallet.example.com/transactions/tx_42"
# État de vérification des App Links
adb shell pm get-app-links com.example.wallet
# Simulateur iOS
xcrun simctl openurl booted "wallet://transactions/tx_42"
Un deep link vers un écran protégé passe par la même garde : l'utilisateur non connecté est envoyé sur /login?from=/transactions/tx_42, puis ramené sur le détail après connexion. Le détail recharge la transaction par son id ; il ne fait jamais confiance à un montant ou à un statut passé dans l'URL.
Navigation et tests
Trois niveaux, du plus rentable au plus coûteux. Premier niveau : la logique de redirection en fonction pure (authRedirect, normalizeCustomScheme) se teste en tests unitaires, sans widget. Deuxième niveau : un test de widget avec un vrai GoRouter et des écrans factices vérifie qu'un tap mène au bon écran. Troisième niveau : le deep link de bout en bout en test d'intégration. Le chapitre 12 détaille l'outillage.
test('un utilisateur non connecté est envoyé au login avec la destination', () {
final target = authRedirect(
const Unauthenticated(),
Uri.parse('/transactions/tx_42'),
);
expect(target, '/login?from=%2Ftransactions%2Ftx_42');
});
test('le paramètre from externe est ignoré après login', () {
final target = authRedirect(
const Authenticated('u1'),
Uri.parse('/login?from=https://evil.example'),
);
expect(target, '/home');
});
testWidgets('taper une transaction ouvre son détail', (tester) async {
final router = GoRouter(initialLocation: '/transactions', routes: testRoutes);
addTearDown(router.dispose);
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
await tester.tap(find.text('tx_42'));
await tester.pumpAndSettle();
expect(router.state.uri.path, '/transactions/tx_42');
expect(find.byType(TransactionDetailScreen), findsOneWidget);
});
Comparaison rapide avec auto_router
| Critère | go_router | auto_route |
|---|---|---|
| Maintenance | Équipe Flutter (flutter/packages) | Communauté, très utilisé |
| Typage | Chaînes par défaut, typé avec go_router_builder (optionnel) | Génération de code centrale, routes typées natives |
| Gardes | redirect, onEnter, refreshListenable | Classes AutoRouteGuard par route, réévaluation via reevaluateListenable |
| Onglets | StatefulShellRoute | AutoTabsRouter, routes imbriquées |
| Choix | Défaut raisonnable, documenté officiellement | Pertinent si l'équipe le maîtrise déjà ou veut des gardes par route très structurées |
En entretien, l'important n'est pas le package mais les concepts : pile déclarative, URL comme source de vérité, gardes réévaluées sur changement d'état, deep links vérifiés.
Questions d'entretien
Pourquoi la Router API (Navigator 2.0) existe-t-elle ?
Pour rendre la pile de pages déclarative et synchronisée avec une URL : support du web (barre d'adresse, boutons du navigateur), des deep links avec reconstruction de la pile, et d'une navigation dérivée de l'état de l'app. Navigator 1.0 reste utile pour les flux impératifs locaux.
Quelle différence entre go et push dans go_router ?
go remplace la pile par celle qui correspond à l'URL, sous-routes comprises. push empile une page au-dessus de l'existant et renvoie un Future pour récupérer un résultat via pop. go garde la navigation reproductible depuis une URL ; push sert aux retours vers l'appelant.
Comment implémentez-vous une garde d'authentification avec go_router et Riverpod ?
Un Provider<GoRouter> créé une fois. Un ValueNotifier alimenté par ref.listen(authProvider, ...) est passé en refreshListenable ; redirect lit son état et renvoie /login?from=..., la destination mémorisée ou null. Pas de ref.watch dans le provider du router, qui le recréerait. La logique de redirection est une fonction pure testée unitairement, et le paramètre from est validé contre l'open redirect.
Comment garder l'état de chaque onglet d'une bottom navigation ?
StatefulShellRoute.indexedStack : un navigator par branche, conservé dans un IndexedStack. Le shell reçoit un StatefulNavigationShell ; goBranch(index) change d'onglet, avec initialLocation: true pour revenir à la racine quand on retape l'onglet actif. Coût : les branches visitées restent en mémoire.
Quelles sont les limites de extra ?
Il n'est pas dans l'URL : il disparaît sur un deep link, un rechargement web et une restauration d'état, sauf avec un extraCodec. L'écran doit toujours pouvoir se reconstruire depuis les paramètres de l'URL ; extra ne sert qu'à afficher plus vite.
Pourquoi utiliser des routes typées ?
Le compilateur vérifie les chemins et les paramètres : plus de faute de frappe dans une chaîne, plus de parsing manuel, et un renommage sûr dans une grosse base de code. go_router_builder génère la configuration et des méthodes go / push / location typées. Le coût est la génération de code avec build_runner.
Différence entre schéma custom et App Links / Universal Links ?
Le schéma custom n'est pas vérifié : une autre app peut le revendiquer, et il ne fonctionne pas sans l'app. Les App Links et Universal Links utilisent des URL https dont le domaine prouve l'association via assetlinks.json ou apple-app-site-association ; sans l'app, le lien ouvre le site. Pour une app financière, on privilégie les liens vérifiés et on ne met jamais de donnée sensible dans l'URL.
Qu'est-ce qui remplace WillPopScope et pourquoi ?
PopScope, avec canPop et onPopInvokedWithResult. Le predictive back d'Android 14+ doit savoir avant le geste si le retour est autorisé, pour afficher l'aperçu. WillPopScope décidait de façon asynchrone pendant le geste, ce qui est incompatible.
Comment testez-vous la navigation ?
Logique de redirection en fonctions pures testées unitairement. Tests de widget avec un vrai GoRouter, des écrans simplifiés et des providers surchargés : tap, pumpAndSettle, vérification de router.state.uri et de l'écran affiché. Deep links de bout en bout en test d'intégration ou manuellement avec adb et simctl.
Un deep link ouvre l'app sur un écran protégé alors que l'utilisateur n'est pas connecté. Que doit-il se passer ?
La garde le redirige vers le login en conservant la destination (from), puis le ramène sur l'écran demandé après authentification. L'écran recharge les données par id depuis le backend et ne fait confiance à aucune donnée métier présente dans l'URL.
Exercices
Écrire de mémoire la configuration go_router d'une app à trois onglets (Accueil, Historique avec détail :id, Profil) avec StatefulShellRoute.indexedStack, plus une route /transfer hors du shell. Critères : la bottom bar disparaît sur /transfer ; retaper l'onglet actif revient à sa racine ; le scroll de l'historique est conservé après un passage par Profil ; le détail fonctionne sans extra.
Écrire la fonction pure authRedirect et ses tests unitaires : non connecté vers login avec from, connecté sur login vers from ou /home, état inconnu vers splash, from externe ignoré, aucune boucle possible. Critères : au moins six tests, tous verts, sans widget ni router.
Expliquer à voix haute, chronomètre en main (3 minutes), le chemin complet d'un App Link depuis le tap dans un SMS jusqu'à l'écran de détail : vérification du domaine par l'OS, transmission de l'URI au moteur, parsing par go_router, redirection d'auth, chargement par id. Critère : aucun trou dans la chaîne, et mention du piège de l'empreinte Play App Signing.
Ajouter le router de l'app Wallet (voir le projet) dans lib/app/router.dart : provider routerProvider créé une fois, refreshListenable branché sur l'état d'auth via ref.listen, redirection splash / login / accueil avec conservation de la destination, bottom navigation à 3 onglets (Accueil, Historique, Profil) en StatefulShellRoute, détail /transactions/:id, flux de transfert hors shell, et deep link wallet://transactions/:id normalisé. Bonus : routes typées avec go_router_builder. Validation : adb shell am start ... -d "wallet://transactions/<id>" app fermée et déconnectée mène au login puis au détail ; tests unitaires de la redirection.
Quiz
12 questions. Corrigez, relisez les explications, refaites-le 48 h plus tard.