Production-grade Flutter navigation mastery - Navigator 2.0, GoRouter, deep linking, nested navigation, route guards, web URL handling with comprehensive code examples
Provides production-ready Flutter navigation patterns using Navigator 1.0/2.0 and GoRouter with route guards, deep linking, and nested navigation. Claude will use this when implementing or debugging complex routing scenarios, authentication flows, or web URL handling in Flutter apps.
/plugin marketplace add pluginagentmarketplace/custom-plugin-flutter/plugin install custom-plugin-flutter@pluginagentmarketplace-flutterThis skill inherits all available tools. When active, it can use any tool Claude has access to.
assets/config.yamlassets/schema.jsonreferences/GUIDE.mdreferences/PATTERNS.mdscripts/validate.py// 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.
This skill should be used when the user asks to "create an agent", "add an agent", "write a subagent", "agent frontmatter", "when to use description", "agent examples", "agent tools", "agent colors", "autonomous agent", or needs guidance on agent structure, system prompts, triggering conditions, or agent development best practices for Claude Code plugins.
This skill should be used when the user asks to "create a slash command", "add a command", "write a custom command", "define command arguments", "use command frontmatter", "organize commands", "create command with file references", "interactive command", "use AskUserQuestion in command", or needs guidance on slash command structure, YAML frontmatter fields, dynamic arguments, bash execution in commands, user interaction patterns, or command development best practices for Claude Code.
This skill should be used when the user asks to "create a hook", "add a PreToolUse/PostToolUse/Stop hook", "validate tool use", "implement prompt-based hooks", "use ${CLAUDE_PLUGIN_ROOT}", "set up event-driven automation", "block dangerous commands", or mentions hook events (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, UserPromptSubmit, PreCompact, Notification). Provides comprehensive guidance for creating and implementing Claude Code plugin hooks with focus on advanced prompt-based hooks API.