Tutorial
Redirects, Sign-in & onExit
Guard a page behind sign-in with redirect, return the user afterwards, and confirm before leaving unsaved edits.
redirect
-
AdaptiveRouter.redirectruns before every navigation;AdaptiveRoute.redirectruns for one route. Both are(context, state) => FutureOr<String?>: return a location to go there instead, ornullto continue. -
Redirect chains stop after
redirectLimit(5) and showerrorBuilder— the same page used for URLs that match no route. -
After your auth state changes, call
router.refresh()to re-run redirects for the current location.
A full-screen login route
Routes declared outside the shell (or with fullscreen: true) are shown on the root
Navigator, covering the tabs at every width — right for login, splash or onboarding.
onExit
AdaptiveRoute.onExit is asked before the page is left by maybePop, the back button, system back or browser back. Return
false to stay. pop, pushReplacementNamed
and pushNamedAndRemoveUntil do not ask — like Navigator.
Inside a column, show the dialog on the root navigator so it is not clipped to the column: useRootNavigator: AdaptivePaneScope.maybeOf(context) != null.
The complete file
New: two signals for app state, a top-level redirect, a /login
route outside the shell, and a profile child route with onExit.
import 'package:material_ui/material_ui.dart';
import 'package:signals_flutter/signals_flutter.dart';
import 'package:xue_hua_adaptive_sliding_layout/xue_hua_adaptive_sliding_layout.dart';
// App state kept outside the widget tree (signals_flutter is already a dependency).
final signedIn = signal(false);
final profileDirty = signal(false);
class Product {
const Product(this.id, this.name, this.price);
final int id;
final String name;
final double price;
}
const products = [
Product(1, 'Coffee mug', 12),
Product(2, 'Notebook', 8),
Product(3, 'Desk lamp', 39),
];
Product? findProduct(String? id) {
final value = int.tryParse(id ?? '');
for (final product in products) {
if (product.id == value) return product;
}
return null;
}
final router = AdaptiveRouter(
initialLocation: '/products',
// Runs before every navigation. Return a location to go there instead.
redirect: (context, state) {
if (state.uri.path.startsWith('/account/profile') && !signedIn.value) {
return '/login?from=${Uri.encodeComponent(state.uri.toString())}';
}
return null;
},
routes: [
// Outside the shell and fullscreen: covers the tabs at every width.
AdaptiveRoute(
path: '/login',
fullscreen: true,
builder: (context, state) => LoginPage(from: state.queryParameters['from']),
),
// One shell per app, at the top level. Each branch is a tab with its own stack.
AdaptiveShellRoute(
// Two columns from 840 logical pixels wide (this is the default; change it here).
showBreadcrumbs: true, // default: a crumb strip above the two columns
breakpoints: const LayoutBreakpoints(expandedMinWidth: 840),
// Shown in the right column while only the list is open.
placeholder: (context) => const Center(child: Text('Pick a product')),
builder: (context, shell, child) => AppShell(shell: shell, child: child),
branches: [
AdaptiveBranch(
routes: [
AdaptiveRoute(
path: '/products',
title: (state) => 'Products',
builder: (context, state) => const ProductsPage(),
routes: [
// Child paths are relative: this matches /products/1, /products/2 ...
AdaptiveRoute(
path: ':id',
name: 'product', // lets you build locations with namedLocation
// Evaluated once at match time, without a BuildContext.
title: (state) => findProduct(state.pathParameters['id'])?.name ?? 'Product',
builder: (context, state) => ProductDetailPage(id: state.pathParameters['id']),
routes: [
AdaptiveRoute(
path: 'reviews', // /products/:id/reviews
title: (state) => 'Rate',
builder: (context, state) => const ReviewsPage(),
),
],
),
],
),
],
),
AdaptiveBranch(
routes: [
AdaptiveRoute(
path: '/account',
title: (state) => 'Account',
builder: (context, state) => const AccountPage(),
routes: [
AdaptiveRoute(
path: 'profile',
title: (state) => 'Profile',
// Return false to cancel leaving (back button, system back, browser back).
onExit: (context, state) async {
if (!profileDirty.value) return true;
final leave = await showDialog<bool>(
context: context,
// Inside a pane, use the root navigator so the dialog is not clipped.
useRootNavigator: AdaptivePaneScope.maybeOf(context) != null,
builder: (dialogContext) => AlertDialog(
title: const Text('Discard changes?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Stay'),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Discard'),
),
],
),
);
if (leave == true) profileDirty.value = false;
return leave == true;
},
builder: (context, state) => const ProfilePage(),
),
],
),
],
),
],
),
],
);
void main() => runApp(const ShopApp());
class ShopApp extends StatelessWidget {
const ShopApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(title: 'Shop', routerConfig: router);
}
}
/// The host chrome: a bottom bar on phones, a rail on wider windows.
class AppShell extends StatelessWidget {
const AppShell({super.key, required this.shell, required this.child});
final AdaptiveShellState shell;
final Widget child;
void _select(int index) {
// Tapping the current tab again returns to that tab's first page.
shell.goBranch(index, initialLocation: index == shell.currentIndex);
}
@override
Widget build(BuildContext context) {
if (shell.isCompact) {
// AdaptiveShellChrome puts the bar inside the branch page, so pushed pages can cover it.
return AdaptiveShellChrome(
bottomNavigationBar: (context) => NavigationBar(
selectedIndex: shell.currentIndex,
onDestinationSelected: _select,
destinations: const [
NavigationDestination(icon: Icon(Icons.storefront), label: 'Products'),
NavigationDestination(icon: Icon(Icons.person), label: 'Account'),
],
),
child: child,
);
}
return Scaffold(
body: Row(
children: [
NavigationRail(
selectedIndex: shell.currentIndex,
onDestinationSelected: _select,
labelType: NavigationRailLabelType.all,
destinations: const [
NavigationRailDestination(icon: Icon(Icons.storefront), label: Text('Products')),
NavigationRailDestination(icon: Icon(Icons.person), label: Text('Account')),
],
),
Expanded(child: child),
],
),
);
}
}
class ProductsPage extends StatelessWidget {
const ProductsPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Products')),
body: ListView(
children: [
for (final product in products)
ListTile(
title: Text(product.name),
subtitle: Text('\$${product.price}'),
onTap: () => AdaptiveRouter.of(context).pushNamed('/products/${product.id}'),
),
],
),
);
}
}
class ProductDetailPage extends StatefulWidget {
const ProductDetailPage({super.key, required this.id});
final String? id;
@override
State<ProductDetailPage> createState() => _ProductDetailPageState();
}
class _ProductDetailPageState extends State<ProductDetailPage> {
int? _stars;
@override
Widget build(BuildContext context) {
final product = findProduct(widget.id);
if (product == null) {
return Scaffold(
appBar: AppBar(),
body: Center(child: Text('No product ${widget.id}')),
);
}
final router = AdaptiveRouter.of(context);
final next = products[product.id % products.length]; // wraps around
return Scaffold(
appBar: AppBar(
leading: BackButton(onPressed: () => router.maybePop()),
title: Text(product.name),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Text('\$${product.price}', style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 16),
// pushNamed returns a Future that completes with the value passed to pop.
FilledButton(
onPressed: () async {
final stars = await router.pushNamed<int>('/products/${product.id}/reviews');
if (stars != null && mounted) setState(() => _stars = stars);
},
child: Text(_stars == null ? 'Rate this product' : 'Your rating: $_stars stars'),
),
// pushReplacementNamed swaps the top page: back still returns to the list.
OutlinedButton(
onPressed: () => router.pushReplacementNamed(
router.namedLocation('product', pathParameters: {'id': '${next.id}'}),
),
child: Text('Next: ${next.name}'),
),
// pushNamedAndRemoveUntil with (_) => false rebuilds the whole stack from a URL.
TextButton(
onPressed: () => router.pushNamedAndRemoveUntil('/products', (_) => false),
child: const Text('Back to the list'),
),
],
),
);
}
}
class ReviewsPage extends StatelessWidget {
const ReviewsPage({super.key});
@override
Widget build(BuildContext context) {
final router = AdaptiveRouter.of(context);
return Scaffold(
appBar: AppBar(
leading: BackButton(onPressed: () => router.maybePop()),
title: const Text('Rate'),
),
body: ListView(
children: [
for (var stars = 5; stars >= 1; stars--)
ListTile(
title: Text('$stars stars'),
// pop(result) completes the pushNamed Future on the previous page.
onTap: () => router.pop(stars),
),
],
),
);
}
}
class AccountPage extends StatelessWidget {
const AccountPage({super.key});
@override
Widget build(BuildContext context) {
final router = AdaptiveRouter.of(context);
return Scaffold(
appBar: AppBar(title: const Text('Account')),
body: SignalBuilder(
builder: (context) => ListView(
children: [
ListTile(title: Text(signedIn.value ? 'Signed in' : 'Signed out')),
ListTile(
leading: const Icon(Icons.edit),
title: const Text('Edit profile'),
// Signed out? The redirect sends you to /login?from=/account/profile.
onTap: () => router.pushNamed('/account/profile'),
),
if (signedIn.value)
ListTile(
leading: const Icon(Icons.logout),
title: const Text('Sign out'),
onTap: () {
signedIn.value = false;
router.refresh(); // re-run redirect for the current location
},
),
],
),
),
);
}
}
class LoginPage extends StatelessWidget {
const LoginPage({super.key, this.from});
final String? from;
@override
Widget build(BuildContext context) {
final router = AdaptiveRouter.of(context);
return Scaffold(
appBar: AppBar(
leading: BackButton(onPressed: () => router.maybePop()),
title: const Text('Sign in'),
),
body: Center(
child: FilledButton(
onPressed: () {
signedIn.value = true;
// Rebuild the stack from the URL the user originally asked for.
router.pushNamedAndRemoveUntil(from ?? '/account', (_) => false);
},
child: const Text('Sign in'),
),
),
);
}
}
class ProfilePage extends StatelessWidget {
const ProfilePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: BackButton(onPressed: () => AdaptiveRouter.of(context).maybePop()),
title: const Text('Profile'),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: TextField(
decoration: const InputDecoration(labelText: 'Display name'),
onChanged: (_) => profileDirty.value = true,
),
),
);
}
}
What you will see
-
Account → Edit profile while signed out: the URL becomes
/#/login?from=%2Faccount%2Fprofileand the login page covers everything. -
Sign in:
pushNamedAndRemoveUntil(from, (_) => false)rebuilds the stack[account, profile]. - Type in the field, then press back: “Discard changes?”. Stay keeps you on the page; Discard leaves.
-
Sign out +
refresh(): the guard applies again on the next visit.
This is how the package example guards its account page — compare with example/lib/router/router_pages.dart.
Next: Customizing the UI.