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.redirect runs before every navigation; AdaptiveRoute.redirect runs for one route. Both are (context, state) => FutureOr<String?>: return a location to go there instead, or null to continue.
  • Redirect chains stop after redirectLimit (5) and show errorBuilder — 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.

dart lib/main.dart
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%2Fprofile and 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.