DartWay Router

A powerful, type-safe navigation package for Flutter that wraps Go Router with an intuitive enum-based API. Build navigation systems with compile-time safety, route guards, and flexible page transitions.

Two full example projects are available in the package repository in the examples/ folder: change_notifier_example (state via ChangeNotifier) and riverpod_example (state via Riverpod).

Features

  • 🎯 Type-Safe Navigation: Enum-based routes with compile-time checking
  • 🔄 State Management Agnostic: Works with any Listenable (ChangeNotifier, ValueNotifier, Riverpod, etc.)
  • 🛡️ Route Guards: Protect routes with authentication/authorization guards
  • 📊 Type-Safe Parameters: Extract navigation parameters with full type safety
  • 🎭 Flexible Transitions: Built-in page transitions (material, fade, slide, scale)
  • 🏗️ Navigation Zones: Group routes into logical zones (authenticated, public, admin, etc.)
  • 🐚 Shell Routes: Easy shell route configuration for common UI patterns
  • ✅ Comprehensive Validation: Automatic validation of route configuration
  • 📱 Zero Dependencies: Only depends on Flutter and Go Router

Installation

flutter pub add dartway_router

Quick Start

1. Define Your Router State

Create a state class that extends Listenable (or use ChangeNotifier, ValueNotifier, etc.):

import 'package:flutter/material.dart';

class AppSession extends ChangeNotifier {
  bool _isAuthenticated = false;
  
  bool get isAuthenticated => _isAuthenticated;
  
  void login() {
    _isAuthenticated = true;
    notifyListeners();
  }
  
  void logout() {
    _isAuthenticated = false;
    notifyListeners();
  }
}

2. Define Navigation Parameters

Create an enum for your navigation parameters:

enum AppParams<T> with DwNavigationParamsMixin<T> {
  userId<int>(),
  userName<String>(),
  itemId<int>(),
}

3. Define Your Routes (Two Zones)

We use two zones: a public auth zone (login) and a protected app zone (home, profile, etc.). The app zone uses a guard that checks AppSession and redirects unauthenticated users to login.

Auth zone (e.g. login screen):

enum AuthRoutes implements DwNavigationRoute<AppSession> {
  login(
    DwNavigationRouteDescriptor.zoneRoot(pageWidget: LoginPage()),
  );

  const AuthRoutes(this.descriptor);

  @override
  final DwNavigationRouteDescriptor<AppSession> descriptor;

  @override
  String get zoneRoot => 'auth';

  @override
  DwShellRoutePageBuilder? get shellRouteBuilder => null;

  @override
  List<DwNavigationGuard<AppSession>> get zoneGuards => [];
}

App zone (protected; guard uses AppSession):

enum AppRoutes implements DwNavigationRoute<AppSession> {
  home(
    DwNavigationRouteDescriptor.zoneRoot(pageWidget: HomePage()),
  ),
  profile(
    DwNavigationRouteDescriptor.simple(pageWidget: ProfilePage()),
  ),
  userDetail(
    DwNavigationRouteDescriptor.parameterized(
      pageWidget: UserDetailPage(),
      parameter: AppParams.userId,
      parent: home,
      extraPathSegment: 'users',
    ),
  );

  const AppRoutes(this.descriptor);

  @override
  final DwNavigationRouteDescriptor<AppSession> descriptor;

  @override
  String get zoneRoot => '';

  @override
  DwShellRoutePageBuilder? get shellRouteBuilder => null;

  @override
  List<DwNavigationGuard<AppSession>> get zoneGuards => [
    (session) {
      if (!session.isAuthenticated) {
        return AuthRoutes.login.fullPath;
      }
      return null; // Allow navigation
    },
  ];
}

4. Create the Router

Pass both zones and routerState (required for guards). Start at login; the guard will redirect to login when the user is not authenticated.

final appSession = AppSession();

final router = DwRouter<AppSession>(
  routerState: appSession,
  navigationZones: [
    AuthRoutes.values,   // Auth zone (login)
    AppRoutes.values,    // App zone (protected by guard)
  ],
  pageBuilder: DwPageBuilder.material,
  options: DwGoRouterOptions(
    initialLocation: AuthRoutes.login.fullPath,
    debugLogDiagnostics: true,
  ),
);

5. Use in Your App

import 'package:flutter/material.dart';
import 'router/app_router.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'DartWay Router Example',
      theme: ThemeData(primarySwatch: Colors.blue, useMaterial3: true),
      routerConfig: router.router,
    );
  }
}

6. Navigate and Extract Parameters

Use AppSession on login/logout and navigate between zones:

// On login screen: after successful login
void onLoginPressed() {
  appSession.login();
  context.goNamed(AppRoutes.home.name);
}

// On profile/settings: logout
void onLogoutPressed() {
  appSession.logout();
  context.goNamed(AuthRoutes.login.name);
}

// Navigate within app zone
context.goNamed(AppRoutes.profile.name);

context.goNamed(
  AppRoutes.userDetail.name,
  pathParameters: AppParams.userId.set(123),
);

// Extract parameters in a widget
class UserDetailPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final userId = AppParams.userId.fromPath(context);
    return Text('User ID: $userId');
  }
}

Core Concepts

Navigation zones allow you to group related routes together. Each zone can have:

  • A shared shell route builder (e.g., bottom navigation bar)
  • Common route guards (e.g., authentication checks)
  • A zone root route

Example with multiple zones (e.g. auth zone + protected app zone, as in Quick Start):

final router = DwRouter<AppSession>(
  routerState: appSession,
  navigationZones: [
    AuthRoutes.values,   // Public/auth zone (login)
    AppRoutes.values,    // Authenticated zone (guard uses AppSession)
    AdminRoutes.values,  // Optional: admin zone
  ],
  pageBuilder: DwPageBuilder.material,
);

Route Types

Zone Root Route

The entry point of a navigation zone. Contributes an empty path segment.

home(
  DwNavigationRouteDescriptor.zoneRoot(pageWidget: HomePage()),
)

Accessible at / (assuming zoneRoot is empty).

Simple Route

A route without parameters. Contributes its enum name as the path.

profile(
  DwNavigationRouteDescriptor.simple(pageWidget: ProfilePage()),
)

Accessible at /profile.

Parameterized Route

A route with path parameters. Must have a parent route.

userDetail(
  DwNavigationRouteDescriptor.parameterized(
    pageWidget: UserDetailPage(),
    parameter: AppParams.userId,
    parent: home,
  ),
)

Accessible at /:userId (relative to parent).

With extraPathSegment:

userDetail(
  DwNavigationRouteDescriptor.parameterized(
    pageWidget: UserDetailPage(),
    parameter: AppParams.userId,
    parent: home,
    extraPathSegment: 'users',
  ),
)

Accessible at /users/:userId.

Route Guards

Guards allow you to protect routes with authentication or authorization checks. Guards are executed in order, and if any guard returns a redirect path, navigation is redirected.

enum AppRoutes implements DwNavigationRoute<AppSession> {
  // ... routes ...

  @override
  List<DwNavigationGuard<AppSession>> get zoneGuards => [
    (session) {
      if (!session.isAuthenticated) {
        return AuthRoutes.login.fullPath;
      }
      return null; // Allow navigation
    },
  ];
}

Important: When using guards, you must provide routerState to DwRouter:

final router = DwRouter<AppSession>(
  routerState: appSession, // Required when using guards
  navigationZones: [AppRoutes.values],
  pageBuilder: DwPageBuilder.material,
);

Shell Routes

There are two kinds of shell routes. Choose based on whether you need tab state preserved.

Each root route in the zone becomes an independent navigation branch. Scroll position, sub-routes, and widget state survive tab switches. The builder receives a StatefulNavigationShell that provides currentIndex and goBranch directly — no manual route-state lookups needed.

enum AppRoutes implements DwNavigationRoute<AppSession> {
  home(DwNavigationRouteDescriptor.zoneRoot(pageWidget: HomePage())),
  profile(DwNavigationRouteDescriptor.simple(pageWidget: ProfilePage()));

  // ... constructor, descriptor, zoneRoot, guards ...

  @override
  DwShellRoutePageBuilder? get shellRouteBuilder => null;

  @override
  DwStatefulShellRouteBuilder? get statefulShellRouteBuilder =>
      (context, state, navigationShell) => MaterialPage(
        child: Scaffold(
          body: navigationShell,
          bottomNavigationBar: BottomNavigationBar(
            currentIndex: navigationShell.currentIndex,
            onTap: (i) => navigationShell.goBranch(
              i,
              initialLocation: i == navigationShell.currentIndex,
            ),
            items: const [
              BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
              BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
            ],
          ),
        ),
      );
}

Tapping the active tab navigates to its initial location (standard mobile UX) because of initialLocation: i == navigationShell.currentIndex.

shellRouteBuilder — simple shell (state not preserved)

Use when you only need a persistent UI wrapper and don't care about preserving tab state (e.g., a sidebar layout without nested navigation).

@override
DwShellRoutePageBuilder? get shellRouteBuilder =>
    (context, state, child) => MaterialPage(
      child: Scaffold(
        body: child,
        bottomNavigationBar: MySimpleBottomNav(),
      ),
    );

@override
DwStatefulShellRouteBuilder? get statefulShellRouteBuilder => null;

Type-Safe Parameters

Navigation parameters are defined using enums with DwNavigationParamsMixin:

enum AppParams<T> with DwNavigationParamsMixin<T> {
  userId<int>(),
  userName<String>(),
  price<double>(),
  isActive<bool>(),
}

Extracting Parameters

// From path parameters (required)
final userId = AppParams.userId.fromPath(context);

// From path parameters (nullable)
final userId = AppParams.userId.fromPathOrNull(context);

// From query parameters (nullable)
final searchQuery = AppParams.userName.fromQueryOrNull(context);

// From query parameters (required)
final searchQuery = AppParams.userName.fromQuery(context);

// From extra data (nullable)
final data = AppParams.userId.fromExtra(context);

Setting Parameters for Navigation

// Navigate with path parameter
context.goNamed(
  AppRoutes.userDetail.name,
  pathParameters: AppParams.userId.set(123),
);

// Navigate with query parameter
context.go(
  '/search?${AppParams.userName.set('flutter').entries.first.key}=${AppParams.userName.set('flutter').entries.first.value}',
);

Page Transitions

Choose from built-in transitions or create custom ones:

// Material (no transition)
pageBuilder: DwPageBuilder.material

// Fade transition
pageBuilder: DwPageBuilder.fade

// Slide transition (from right by default)
pageBuilder: DwPageBuilder.slide

// Slide from bottom
pageBuilder: (context, key, child) =>
    DwPageBuilder.slide(context, key, child, from: AxisDirection.down)

// Scale transition
pageBuilder: DwPageBuilder.scale

// Custom transition
pageBuilder: (context, key, child) {
  return CustomTransitionPage(
    key: key,
    child: child,
    transitionDuration: Duration(milliseconds: 500),
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      return RotationTransition(
        turns: animation,
        child: child,
      );
    },
  );
}

Advanced Usage

Multiple Navigation Zones

Organize your app into multiple navigation zones:

enum AppRoutes implements DwNavigationRoute<AppSession> {
  home(DwNavigationRouteDescriptor.zoneRoot(pageWidget: HomePage())),
  profile(DwNavigationRouteDescriptor.simple(pageWidget: ProfilePage()));
  
  // ... implementation ...
}

enum AuthRoutes implements DwNavigationRoute<AppSession> {
  login(DwNavigationRouteDescriptor.simple(pageWidget: LoginPage())),
  signup(DwNavigationRouteDescriptor.simple(pageWidget: SignupPage()));
  
  // ... implementation ...
}

final router = DwRouter<AppSession>(
  routerState: appSession,
  navigationZones: [
    AppRoutes.values,   // Authenticated zone
    AuthRoutes.values,  // Public zone
  ],
  pageBuilder: DwPageBuilder.material,
);

Nested Routes

Create nested route hierarchies:

enum AppRoutes implements DwNavigationRoute<AppSession> {
  home(DwNavigationRouteDescriptor.zoneRoot(pageWidget: HomePage())),
  
  // Child route
  settings(
    DwNavigationRouteDescriptor.simple(
      pageWidget: SettingsPage(),
      parent: home,
    ),
  ),
  
  // Nested child route
  profileSettings(
    DwNavigationRouteDescriptor.simple(
      pageWidget: ProfileSettingsPage(),
      parent: settings,
    ),
  ),
}

Route Paths

Understanding route paths:

  • routePath: The path segment for this route

    • Root routes: Full path from zone root (e.g., /profile)
    • Child routes: Relative path segment only (e.g., :userId)
  • fullPath: The complete path from root

    • Always includes the full hierarchy (e.g., /users/:userId)
// For a child route with parent
print(AppRoutes.userDetail.routePath);  // ':userId'
print(AppRoutes.userDetail.fullPath);   // '/:userId' (includes parent)

Route Resolution

Get the current route from navigation state:

// Get the top route (the route at the top of the stack)
final topRoute = router.topRouteFromState(GoRouterState.of(context));

// Get the root route (the zone root)
final rootRoute = router.rootRouteFromState(GoRouterState.of(context));

// Check if a route is active
if (AppRoutes.profile.isActive(context)) {
  // Route is currently active
}

Custom Router Options

Configure GoRouter behavior:

final router = DwRouter<AppSession>(
  routerState: appSession,
  navigationZones: [AppRoutes.values],
  pageBuilder: DwPageBuilder.material,
  options: DwGoRouterOptions(
    initialLocation: '/home',
    debugLogDiagnostics: true,
    redirectLimit: 10,
    errorBuilder: (context, state) => ErrorPage(),
    redirect: (context, state) {
      return null; // custom global redirect
    },

    // Intercept navigation before route matching (go_router v14+)
    onEnter: (context, current, next, router) {
      analytics.track(next.uri.path);
      return Allow(); // or Block.stop() / Block.then(callback)
    },

    // Case-insensitive URL matching (default: true = case-sensitive)
    caseSensitive: false,

    // Suppress root navigator observer callbacks inside shell zones
    shellNotifyRootObserver: false,
  ),
);

API Reference

Core Classes

DwRouter<RouterState>

Main router class that wraps GoRouter.

Properties:

  • routerState - Optional router state for refresh notifications
  • navigationZones - List of navigation zone route lists
  • pageBuilder - Function to build pages with transitions
  • options - GoRouter configuration options
  • router - The underlying GoRouter instance

Methods:

  • topRouteFromState(state) - Get top route from navigation state
  • rootRouteFromState(state) - Get root route from navigation state

DwNavigationRoute<RouterState>

Abstract interface for navigation routes. Routes are defined as enums implementing this interface.

Required Properties:

  • descriptor - Route descriptor defining path and page
  • zoneRoot - Root path segment for the navigation zone
  • shellRouteBuilder - Simple shell builder (state not preserved across tabs)
  • statefulShellRouteBuilder - Stateful shell builder (state preserved per branch)
  • shellRouteBuilder - Optional shell route builder
  • zoneGuards - List of navigation guards

DwNavigationRouteDescriptor<RouterState>

Describes how a route contributes to the URL path.

Factory Constructors:

  • zoneRoot({required pageWidget}) - Zone root route
  • simple({required pageWidget, parent, extraPathSegment}) - Simple route
  • parameterized({required pageWidget, required parameter, required parent, extraPathSegment}) - Parameterized route

DwNavigationParamsMixin<T>

Mixin for type-safe navigation parameters.

Methods:

  • set(value) - Create parameter map for navigation
  • fromPath(context) - Extract from path parameters (required)
  • fromPathOrNull(context) - Extract from path parameters (nullable)
  • fromQuery(context) - Extract from query parameters (required)
  • fromQueryOrNull(context) - Extract from query parameters (nullable)
  • fromExtra(context) - Extract from extra data (nullable)

DwPageBuilder

Collection of predefined page builders.

Static Methods:

  • material(context, key, child) - Material page (no transition)
  • fade(context, key, child, {curve, duration}) - Fade transition
  • slide(context, key, child, {from, curve, duration}) - Slide transition
  • scale(context, key, child, {curve, duration}) - Scale transition

DwGoRouterOptions

Configuration options for GoRouter.

Properties:

  • navigatorKey - Navigator key
  • initialLocation - Initial route path
  • errorBuilder - Custom error page builder
  • redirect - Custom redirect function
  • debugLogDiagnostics - Enable debug logging
  • And more...

Extensions

DwNavigationRouteExtension

Extension on DwNavigationRoute providing:

  • routePath - Route path (relative for child routes)
  • fullPath - Full path from root
  • isActive(context) - Check if route is currently active

Examples

Complete working examples are available in the /examples directory:

  • change_notifier_example - Example using ChangeNotifier for state management
  • riverpod_example - Example using Riverpod for state management

Project Structure

Recommended project structure:

lib/
├── main.dart
├── router/
│   ├── app_router.dart          # Router configuration
│   └── zones/
│       ├── app_routes.dart      # App routes enum
│       └── auth_routes.dart     # Auth routes enum
├── pages/
│   ├── home_page.dart
│   ├── profile_page.dart
│   └── user_detail_page.dart
└── core/
    └── app_session.dart         # Router state (ChangeNotifier, etc.)

Best Practices

  1. Use enums for routes: Provides compile-time safety and autocomplete
  2. Group related routes: Use navigation zones to organize routes logically
  3. Use guards for protection: Protect routes with guards rather than checking in widgets
  4. Type-safe parameters: Always use DwNavigationParamsMixin for parameters
  5. Consistent naming: Use consistent naming conventions for routes and parameters
  6. Shell routes for common UI: Use shell routes for bottom nav, sidebars, etc.

Troubleshooting

Routes not found

  • Ensure all routes are included in navigationZones
  • Check that route names are unique
  • Verify that paths don't conflict

Guards not working

  • Ensure routerState is provided to DwRouter
  • Check that guards return null to allow navigation
  • Verify guard logic is correct

Parameters not extracted

  • Ensure parameter name matches the route path parameter
  • Check parameter type matches the mixin type
  • Use fromPathOrNull if parameter might be missing

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

License

This project is licensed under the Apache License 2.0 - see the LICENSE file for details.

Libraries

dartway_router
A Flutter package that provides a convenient wrapper around Go Router with easy route configuration and navigation utilities.