Production-grade Flutter navigation mastery - Navigator 2.0, GoRouter, deep linking, nested navigation, route guards, web URL handling with comprehensive code examples
From custom-plugin-flutternpx claudepluginhub pluginagentmarketplace/custom-plugin-flutter --plugin custom-plugin-flutterThis skill uses the workspace's default tool permissions.
assets/config.yamlassets/schema.jsonreferences/GUIDE.mdreferences/PATTERNS.mdscripts/validate.pySearches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Ingests video/audio from files, URLs, RTSP, desktop; indexes/searches moments with timestamps/clips; transcodes/edits timelines (subtitles/overlays/dubbing); generates assets and live alerts.
// router.dart
final router = GoRouter(
initialLocation: '/',
debugLogDiagnostics: true,
refreshListenable: authNotifier,
redirect: (context, state) {
final isLoggedIn = authNotifier.isLoggedIn;
final isLoggingIn = state.matchedLocation == '/login';
if (!isLoggedIn && !isLoggingIn) return '/login';
if (isLoggedIn && isLoggingIn) return '/';
return null;
},
errorBuilder: (context, state) => ErrorPage(error: state.error),
routes: [
GoRoute(
path: '/',
name: 'home',
builder: (context, state) => const HomePage(),
),
GoRoute(
path: '/login',
name: 'login',
builder: (context, state) => const LoginPage(),
),
GoRoute(
path: '/products/:id',
name: 'product',
builder: (context, state) {
final id = state.pathParameters['id']!;
return ProductPage(productId: id);
},
),
ShellRoute(
builder: (context, state, child) => MainShell(child: child),
routes: [
GoRoute(path: '/dashboard', builder: (_, __) => DashboardPage()),
GoRoute(path: '/settings', builder: (_, __) => SettingsPage()),
],
),
],
);
// main.dart
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: router,
);
}
}
// Navigation usage
context.go('/products/123'); // Replace
context.push('/products/123'); // Push
context.goNamed('product', pathParameters: {'id': '123'});
context.pop(); // Go back
// Push new route
Navigator.push(
context,
MaterialPageRoute(builder: (context) => DetailPage()),
);
// Pop current route
Navigator.pop(context);
// Pop with result
Navigator.pop(context, 'result_data');
// Push and remove until
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (_) => HomePage()),
(route) => false, // Remove all
);
// Push replacement
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => NewPage()),
);
// Define routes
MaterialApp(
initialRoute: '/',
routes: {
'/': (context) => HomePage(),
'/detail': (context) => DetailPage(),
'/settings': (context) => SettingsPage(),
},
onGenerateRoute: (settings) {
if (settings.name?.startsWith('/product/') ?? false) {
final id = settings.name!.split('/').last;
return MaterialPageRoute(
builder: (_) => ProductPage(id: id),
);
}
return null;
},
onUnknownRoute: (settings) {
return MaterialPageRoute(builder: (_) => NotFoundPage());
},
);
// Navigate with arguments
Navigator.pushNamed(
context,
'/detail',
arguments: {'id': 123, 'title': 'Product'},
);
// Retrieve arguments
final args = ModalRoute.of(context)?.settings.arguments as Map?;
class AppRouterDelegate extends RouterDelegate<AppRoutePath>
with ChangeNotifier, PopNavigatorRouterDelegateMixin<AppRoutePath> {
@override
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
AppRoutePath _currentPath = AppRoutePath.home();
@override
AppRoutePath get currentConfiguration => _currentPath;
@override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
pages: [
MaterialPage(
key: ValueKey('home'),
child: HomePage(),
),
if (_currentPath.isProductPage)
MaterialPage(
key: ValueKey('product-${_currentPath.productId}'),
child: ProductPage(id: _currentPath.productId!),
),
],
onPopPage: (route, result) {
if (!route.didPop(result)) return false;
_currentPath = AppRoutePath.home();
notifyListeners();
return true;
},
);
}
@override
Future<void> setNewRoutePath(AppRoutePath path) async {
_currentPath = path;
notifyListeners();
}
void goToProduct(String id) {
_currentPath = AppRoutePath.product(id);
notifyListeners();
}
}
class AppRouteInformationParser extends RouteInformationParser<AppRoutePath> {
@override
Future<AppRoutePath> parseRouteInformation(
RouteInformation routeInformation,
) async {
final uri = routeInformation.uri;
if (uri.pathSegments.isEmpty) {
return AppRoutePath.home();
}
if (uri.pathSegments.length == 2 && uri.pathSegments[0] == 'product') {
return AppRoutePath.product(uri.pathSegments[1]);
}
return AppRoutePath.home();
}
@override
RouteInformation restoreRouteInformation(AppRoutePath path) {
if (path.isHomePage) {
return RouteInformation(uri: Uri.parse('/'));
}
if (path.isProductPage) {
return RouteInformation(uri: Uri.parse('/product/${path.productId}'));
}
return RouteInformation(uri: Uri.parse('/'));
}
}
final router = GoRouter(
routes: [
ShellRoute(
navigatorKey: _shellNavigatorKey,
builder: (context, state, child) {
return ScaffoldWithNavBar(child: child);
},
routes: [
GoRoute(
path: '/home',
pageBuilder: (context, state) => NoTransitionPage(
child: HomePage(),
),
),
GoRoute(
path: '/search',
pageBuilder: (context, state) => NoTransitionPage(
child: SearchPage(),
),
),
GoRoute(
path: '/profile',
pageBuilder: (context, state) => NoTransitionPage(
child: ProfilePage(),
),
),
],
),
// Routes outside shell (full screen)
GoRoute(
path: '/product/:id',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => ProductPage(
id: state.pathParameters['id']!,
),
),
],
);
class ScaffoldWithNavBar extends StatelessWidget {
final Widget child;
const ScaffoldWithNavBar({required this.child});
@override
Widget build(BuildContext context) {
return Scaffold(
body: child,
bottomNavigationBar: NavigationBar(
selectedIndex: _calculateSelectedIndex(context),
onDestinationSelected: (index) => _onItemTapped(index, context),
destinations: [
NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
NavigationDestination(icon: Icon(Icons.search), label: 'Search'),
NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
],
),
);
}
int _calculateSelectedIndex(BuildContext context) {
final location = GoRouterState.of(context).matchedLocation;
if (location.startsWith('/home')) return 0;
if (location.startsWith('/search')) return 1;
if (location.startsWith('/profile')) return 2;
return 0;
}
void _onItemTapped(int index, BuildContext context) {
switch (index) {
case 0: context.go('/home');
case 1: context.go('/search');
case 2: context.go('/profile');
}
}
}
GoRouter(
redirect: (context, state) {
final isLoggedIn = ref.read(authProvider).isLoggedIn;
final isOnboarded = ref.read(onboardingProvider).completed;
final location = state.matchedLocation;
// Onboarding flow
if (!isOnboarded && !location.startsWith('/onboarding')) {
return '/onboarding';
}
// Auth flow
if (!isLoggedIn) {
final publicRoutes = ['/login', '/register', '/forgot-password'];
if (!publicRoutes.contains(location)) {
return '/login?redirect=${Uri.encodeComponent(location)}';
}
}
// Already logged in, redirect away from auth pages
if (isLoggedIn && location == '/login') {
final redirect = state.uri.queryParameters['redirect'];
return redirect ?? '/';
}
return null; // No redirect
},
)
<!-- ios/Runner/Runner.entitlements -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN">
<plist version="1.0">
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:example.com</string>
</array>
</dict>
</plist>
<!-- android/app/src/main/AndroidManifest.xml -->
<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="example.com"
android:pathPrefix="/product" />
</intent-filter>
// GoRouter handles automatically
GoRoute(
path: '/product/:id',
builder: (context, state) {
final id = state.pathParameters['id']!;
final source = state.uri.queryParameters['utm_source'];
return ProductPage(id: id, source: source);
},
)
// Manual handling
import 'package:app_links/app_links.dart';
final appLinks = AppLinks();
// Listen for links
appLinks.uriLinkStream.listen((uri) {
router.go(uri.path + (uri.query.isNotEmpty ? '?${uri.query}' : ''));
});
// Get initial link
final initialUri = await appLinks.getInitialAppLink();
if (initialUri != null) {
router.go(initialUri.path);
}
// Query parameters
GoRoute(
path: '/search',
builder: (context, state) {
final query = state.uri.queryParameters['q'] ?? '';
final page = int.tryParse(state.uri.queryParameters['page'] ?? '1') ?? 1;
return SearchPage(query: query, page: page);
},
)
// Navigate with query params
context.go('/search?q=flutter&page=1');
// Type-safe extras
context.push('/product/123', extra: Product(id: '123', name: 'Widget'));
// Retrieve extras
GoRoute(
path: '/product/:id',
builder: (context, state) {
final product = state.extra as Product?;
final id = state.pathParameters['id']!;
return ProductPage(product: product, id: id);
},
)
Issue: Deep link not working
1. Verify Associated Domains (iOS) / App Links (Android)
2. Check aasa file at .well-known/apple-app-site-association
3. Check assetlinks.json at .well-known/assetlinks.json
4. Test: adb shell am start -a android.intent.action.VIEW -d "https://..."
5. Verify intent-filter in AndroidManifest.xml
Issue: Route not found
1. Check path matches exactly (case-sensitive)
2. Verify route is registered in GoRouter
3. Check for typos in pathParameters
4. Enable debugLogDiagnostics: true
Issue: Back button doesn't work correctly
1. Use context.push() instead of context.go()
2. Check ShellRoute configuration
3. Verify parentNavigatorKey assignments
4. Handle PopScope for custom back behavior
Issue: State lost on navigation
1. Use ShellRoute to preserve state
2. Store state in provider/bloc above Navigator
3. Use PageStorageKey for scroll position
4. Consider using AutomaticKeepAliveClientMixin
| Scenario | Solution |
|---|---|
| Simple app | Navigator 1.0 |
| Web/Deep links | GoRouter |
| Complex flows | Navigator 2.0 |
| Tab navigation | ShellRoute |
| Auth guards | GoRouter redirect |
| Modal screens | showModalBottomSheet |
Master Flutter navigation for seamless user experiences.