TpRouter
| Package | Version |
|---|---|
| tp_router | |
| tp_router_annotation | |
| tp_router_generator |
A simplified, type-safe, and annotation-driven routing library for Flutter.
TpRouter is built on top of go_router—the official Flutter routing package. This means you get all the battle-tested features (deep linking, web support, nested navigation) without worrying about core stability. TpRouter simply provides a more ergonomic, annotation-based API that eliminates boilerplate and enables type-safe navigation.
Table of Contents
- Features
- Core Concepts
- Installation
- Quick Start
- Navigation
- Parameters
- Guards & Redirects
- Route Lifecycle
- Deep Linking
- Page Transitions
- Page Configuration
- Configuration
✨ Features
- 🗝️ NavKey-Driven Linking: No more nesting hell. Just tell a route "My parent is
MainNavKey", and they are automatically linked. - 📐 Type-Safe Navigation:
UserRoute(id: 1).tp()instead of string manipulation. - 🐚 Simple Shells: Define app layouts (BottomNav, Drawers) purely through annotations.
- 🛡️ Type-Safe Guards: Strongly-typed
TpRedirect<T>for route protection. - 🔄 Reactive Routing: Use
refreshListenableto auto-redirect on state changes. - 🧩 Smart Code Gen: Automatically handles parameters, return values, and deep linking.
🧩 Core Concepts
Understanding how TpRouter works helps you leverage its full power.
1. The Triad of Navigation
TpRouter connects three key pieces:
- Routes (
@TpRoute): Static configuration of what screens you have. - Generator: Converts annotations into strongly-typed classes (
UserRoute,HomeRoute). - Router (
TpRouter): The runtime engine that manages the navigation stack usinggo_router.
2. TpNavKey: The Bridge
TpNavKey is more than just a GlobalKey. It is the binding agent that connects:
- A Shell (UI container)
- A Navigator (Flutter's navigation stack)
- An Observer (TpRouter's tracking system)
When you define class MainKey extends TpNavKey, you are creating a unique identifier that ensures your ShellRoute uses the exact same navigator instance that your routes are trying to navigate into.
3. Smart Observation
TpRouter automatically injects TpRouteObserver into every navigator managed by a TpNavKey (especially in ShellRoutes). This observer tracks the live route stack, enabling advanced features like:
popUntil(predicate)popToInitial()removeWhere()
Normal go_router doesn't easily support these because it manages URLs, not Flutter Route objects. TpRouter bridges this gap by watching the actual Navigator activities.
4. Architecture Deep Dive
TpRouter is designed as a compile-time abstraction layer over go_router.
| Layer | Component | Role |
|---|---|---|
| User Code | Annotations (@TpRoute) |
Define the navigation structure and parameters declaratively. |
| Build System | tp_router_generator |
Analyzes code and generates type-safe Route classes. |
| Runtime | TpRouteData |
The common interface for all routes. It unifies parameters (path, query, extra) into a single API. |
| Core | TpRouter |
A singleton wrapper adjusting go_router configuration and managing global state. |
| Engine | go_router |
Handles URL parsing, deep linking, and low-level navigation. |
Data Flow:
- Code Gen: Annotated
UserPage(id)becomesUserRoute(id). - Navigation: Calling
UserRoute(id: 123).tp()converts the object into a URL path (/user/123) and extra data. - Routing:
TpRoutertellsgo_routerto navigate. - Reconstruction: When the page builds,
TpRouterusesTpRouteData.of(context)to parse the URL/Web State back into usable data.
Installation
Add the following to your pubspec.yaml:
dependencies:
tp_router: ^0.5.1
tp_router_annotation: ^0.5.0
dev_dependencies:
build_runner: ^2.4.0
tp_router_generator: ^0.5.0
Run the generator:
dart run build_runner build
🚀 Quick Start
1. Define NavKeys
NavKeys are the heart of TpRouter. They act as unique identifiers for your navigators and bridges between parents and children.
Create a file lib/routes/nav_keys.dart:
import 'package:tp_router/tp_router.dart';
// Key for the main application shell (e.g. BottomNavigationBar)
class MainNavKey extends TpNavKey {
const MainNavKey() : super('main');
}
// Sub-keys for branches if you use IndexedStack (optional but recommended)
class HomeNavKey extends TpNavKey {
const HomeNavKey() : super('main', branch: 0);
}
class SettingsNavKey extends TpNavKey {
const SettingsNavKey() : super('main', branch: 1);
}
2. Define Shells
Mark your container widget (e.g., a page with BottomNavigationBar) with @TpShellRoute.
Link it to a key (MainNavKey).
@TpShellRoute(
navigatorKey: MainNavKey, // <--- Identified by this Key
isIndexedStack: true, // Enable stateful nested navigation
branchKeys: [HomeNavKey, SettingsNavKey], // <--- Define branch key types (not instances)
)
class MainShellPage extends StatelessWidget {
final TpStatefulNavigationShell navigationShell;
const MainShellPage({required this.navigationShell, super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: navigationShell,
bottomNavigationBar: BottomNavigationBar(
currentIndex: navigationShell.currentIndex,
onTap: (index) => navigationShell.tp(index),
items: [/* ... */],
),
);
}
}
3. Define Routes
Just annotate your pages.
- To nest a page, simply set
parentNavigatorKeyto the Shell's key. - No nesting? Omit the key.
// Standard Route (not nested)
@TpRoute(path: '/login')
class LoginPage extends StatelessWidget { ... }
// Nested Route (Child of MainShellPage)
@TpRoute(
path: '/home',
isInitial: true,
parentNavigatorKey: HomeNavKey, // <--- Linked to MainShell's branch 0 automatically!
)
class HomePage extends StatelessWidget { ... }
// Another Nested Route
@TpRoute(
path: '/settings',
parentNavigatorKey: SettingsNavKey, // <--- Linked to MainShell's branch 1
)
class SettingsPage extends StatelessWidget { ... }
4. Initialize Router
Pass the generated tpRoutes to TpRouter.
import 'routes/route.gr.dart';
void main() {
final router = TpRouter(
routes: tpRoutes, // Generated by build_runner
);
runApp(MaterialApp.router(
routerConfig: router.routerConfig,
));
}
🧭 Navigation
Type-Safe Navigation
The generator creates a Route class for every annotated widget.
// Push a new route
UserRoute(id: 123).tp();
// Await a result
final result = await SelectProfileRoute().tp<String>();
// Replace current route (no back navigation)
LoginRoute().tp(replacement: true);
// Clear history and go (like go_router's `go`)
HomeRoute().tp(clearHistory: true);
Pop & Stack Control
// Pop topmost route
context.pop();
// or with result
context.pop(result: 'selected_item');
// --- Using context extensions (Recommended) ---
// Access TpRouter helper via context
context.tpRouter.popTo(HomeRoute());
context.tpRouter.popToInitial();
// --- Using Static Instance (Global) ---
// Pop until a specific route
TpRouter.instance.popTo(HomeRoute());
// Pop to initial route
TpRouter.instance.popToInitial();
// Remove a specific route from stack (without navigating)
TpRouter.instance.removeRoute(SomeRoute());
// Remove routes matching condition
TpRouter.instance.removeWhere((data) => data.fullPath.contains('/temp'));
📦 Parameters
TpRouter provides powerful, type-safe parameter parsing.
Path Parameters
Use @Path() to extract values from the URL path.
@TpRoute(path: '/user/:id')
class UserPage extends StatelessWidget {
const UserPage({required this.userId});
@Path('id')
final String userId;
}
// Navigation
UserRoute(userId: '123').tp(); // -> /user/123
Query Parameters
Use @Query() to extract values from the query string.
@TpRoute(path: '/search')
class SearchPage extends StatelessWidget {
const SearchPage({this.query, this.page});
@Query('q')
final String? query;
@Query('page')
final int? page; // Auto-parsed to int
}
// Navigation
SearchRoute(query: 'flutter', page: 2).tp(); // -> /search?q=flutter&page=2
Extra (Complex Objects)
For non-serializable objects (like models), they are passed via memory.
@TpRoute(path: '/profile')
class ProfilePage extends StatelessWidget {
const ProfilePage({required this.user});
final User user; // Complex object, not in URL
}
// Navigation
ProfileRoute(user: currentUser).tp();
⚠️ Note: Extra objects are NOT preserved during:
- Browser Refresh
- Direct URL entry (e.g. typing URL in address bar)
- App kill/restart
For persistent data, use path/query params or state management services.
Combined Example
@TpRoute(path: '/order/:orderId/detail')
class OrderDetailPage extends StatelessWidget {
const OrderDetailPage({
required this.orderId,
this.highlightItem,
required this.orderData,
});
@Path('orderId')
final String orderId;
@Query('highlight')
final String? highlightItem;
final Order orderData; // Extra (passed via memory)
}
// Navigation
OrderDetailRoute(
orderId: 'ORD-123',
highlightItem: 'item-5',
orderData: order,
).tp();
// URL: /order/ORD-123/detail?highlight=item-5
// orderData passed via memory
🛡️ Guards & Redirects
Route-Level Redirect
Protect specific routes. The redirect parameter accepts a TpRedirect<T> class.
// 1. Define the guard
class AuthGuard extends TpRedirect<ProtectedRoute> {
@override
FutureOr<TpRouteData?> handle(BuildContext context, ProtectedRoute route) {
// Access the typed route object!
if (!AuthService.instance.isLoggedIn) {
return const LoginRoute(); // Redirect to login
}
return null; // Proceed (allow access)
}
}
// 2. Apply to route
@TpRoute(path: '/protected', redirect: AuthGuard)
class ProtectedPage extends StatelessWidget { ... }
Global Redirect
For app-wide rules (e.g., onboarding check, maintenance mode).
final router = TpRouter(
routes: tpRoutes,
redirect: (context, state) {
// state.fullPath is the target URL
if (needsOnboarding && state.fullPath != '/onboarding') {
return OnboardingRoute();
}
if (isLoggedIn && state.fullPath == '/login') {
return HomeRoute(); // Already logged in, skip login
}
return null; // Allow
},
);
Reactive Routing (refreshListenable)
This is how you make guards respond to state changes (e.g., login/logout).
Without refreshListenable, the router doesn't know when to re-evaluate guards. After login, you'd be stuck on the login page even if the guard logic allows access.
// 1. Create a listenable auth service
class AuthService extends ChangeNotifier {
static final instance = AuthService();
bool _isLoggedIn = false;
bool get isLoggedIn => _isLoggedIn;
void login() {
_isLoggedIn = true;
notifyListeners(); // 🔔 Signal the router!
}
void logout() {
_isLoggedIn = false;
notifyListeners(); // 🔔 Signal the router!
}
}
// 2. Pass to TpRouter
final router = TpRouter(
routes: tpRoutes,
refreshListenable: AuthService.instance, // <-- KEY!
redirect: (context, state) {
final loggedIn = AuthService.instance.isLoggedIn;
final isOnLogin = state.fullPath == '/login';
if (!loggedIn && !isOnLogin) return LoginRoute();
if (loggedIn && isOnLogin) return HomeRoute();
return null;
},
);
// 3. Now when you call login()...
AuthService.instance.login();
// ...the router automatically re-evaluates and redirects!
How it works:
- User navigates to
/protected. - Guard runs,
isLoggedInisfalse→ redirect to/login. - User logs in →
AuthService.login()callsnotifyListeners(). TpRouter(listening torefreshListenable) re-runs the redirect logic.- Now
isLoggedInistrue→ user is allowed through (or redirected to home if on login page).
🔄 Route Lifecycle
OnExit Guard
Intercept back navigation (e.g., unsaved changes confirmation).
// 1. Define the exit guard
class UnsavedChangesGuard extends TpOnExit<EditorRoute> {
@override
FutureOr<bool> onExit(BuildContext context, EditorRoute route) async {
final shouldExit = await showDialog<bool>(
context: context,
builder: (c) => AlertDialog(
title: Text('Unsaved Changes'),
content: Text('Discard changes?'),
actions: [
TextButton(onPressed: () => Navigator.pop(c, false), child: Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(c, true), child: Text('Discard')),
],
),
);
return shouldExit ?? false; // true = allow exit, false = block
}
}
// 2. Apply to route
@TpRoute(path: '/edit', onExit: UnsavedChangesGuard)
class EditorPage extends StatelessWidget { ... }
🔗 Deep Linking
TpRouter fully supports deep linking out of the box. All path and query parameters are automatically parsed.
How it works:
- Define your route with path parameters:
@TpRoute(path: '/product/:id'). - When a deep link like
yourapp://product/123?ref=emailis opened:idis extracted as'123'.refis extracted as'email'.
- Your page receives fully typed parameters.
Example:
@TpRoute(path: '/product/:productId')
class ProductPage extends StatelessWidget {
const ProductPage({required this.productId, this.referrer});
@Path('productId')
final String productId;
@Query('ref')
final String? referrer;
}
Deep link URL: https://example.com/product/abc123?ref=instagram
Platform Setup:
- iOS: Configure
Associated Domainsin Xcode. - Android: Add
intent-filtertoAndroidManifest.xml. - Web: Works automatically.
See go_router deep linking guide for platform-specific setup (TpRouter uses go_router internally).
🎨 Page Transitions
Built-in Transitions
@TpRoute(
path: '/details',
transition: TpSlideTransition(), // TpSlideTransition, TpFadeTransition, etc.
transitionDuration: 300, // milliseconds
reverseTransitionDuration: 200, // milliseconds (optional)
)
class DetailsPage extends StatelessWidget { ... }
Available transitions:
| Transition | Description |
|---|---|
TpSlideTransition |
Slide from right |
TpFadeTransition |
Fade in/out |
TpScaleTransition |
Scale up/down |
TpNoTransition |
No animation |
TpCupertinoPageTransition |
iOS-style slide |
Custom Transitions
Implement TpTransitionsBuilder:
class MyCustomTransition extends TpTransitionsBuilder {
const MyCustomTransition();
@override
Widget buildTransitions(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return RotationTransition(
turns: animation,
child: child,
);
}
}
// Apply globally
final router = TpRouter(
routes: tpRoutes,
defaultTransition: MyCustomTransition(),
);
// Or per-route (via annotation - requires custom setup)
Swipe Back
Enable full-screen swipe-to-go-back gesture.
// Global default
final router = TpRouter(
routes: tpRoutes,
defaultPageType: TpPageType.swipeBack,
);
// Or use Cupertino-style (edge swipe only)
defaultPageType: TpPageType.cupertino,
📄 Page Configuration
The @TpRoute annotation supports rich page configuration options for dialogs, modals, transparency, and more.
Page Type (TpPageType)
Control how the page is rendered:
@TpRoute(
path: '/settings',
type: TpPageType.cupertino, // Force iOS-style page
)
class SettingsPage extends StatelessWidget { ... }
| Type | Description |
|---|---|
TpPageType.auto |
Platform-adaptive (default). Material on Android, Cupertino on iOS. |
TpPageType.material |
Force MaterialPage (Android-style). |
TpPageType.cupertino |
Force CupertinoPage (iOS-style with edge swipe). |
TpPageType.swipeBack |
Full-screen swipe-to-dismiss gesture. |
TpPageType.custom |
Use with pageBuilder for fully custom pages. |
Note on
TpPageType.auto:
- Android: Uses
ZoomPageTransitionsBuilder(Android 10+) or standard slide up/fade.- iOS: Uses
CupertinoPageTransition(slide from right with swipe-back).- macOS/Linux/Windows: Uses
ZoomPageTransitionsBuilderor standard fade.This ensures your app feels native on every platform without manual configuration.
Dialog & Modal Options
Create fullscreen dialogs (iOS modal sheets):
@TpRoute(
path: '/create-post',
fullscreenDialog: true, // Shows close button instead of back arrow on iOS
)
class CreatePostPage extends StatelessWidget { ... }
Transparent Pages
Create transparent overlays, bottom sheets, or custom modals:
@TpRoute(
path: '/overlay',
opaque: false, // Page is transparent
barrierColor: Color(0x80000000), // Semi-transparent black barrier
barrierDismissible: true, // Tap barrier to close
barrierLabel: 'Dismiss overlay', // Accessibility label
)
class OverlayPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.bottomCenter,
child: Container(
height: 300,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
child: Text('Bottom Sheet Content'),
),
);
}
}
Full @TpRoute Reference
All available @TpRoute parameters:
@TpRoute(
// === Core ===
path: '/user/:id', // URL path pattern
isInitial: false, // Mark as initial route
parentNavigatorKey: SomeNavKey, // Nest under a shell
// === Guards ===
redirect: AuthGuard, // Redirect logic (TpRedirect<T>)
onExit: UnsavedChangesGuard, // Exit interception (TpOnExit<T>)
// === Page Type ===
type: TpPageType.auto, // auto, material, cupertino, swipeBack, custom
pageBuilder: MyCustomPage, // Custom Page factory (overrides type)
// === Transitions ===
transition: TpSlideTransition(), // Custom transition builder
transitionDuration: Duration(milliseconds: 300),
reverseTransitionDuration: Duration(milliseconds: 300),
// === Dialog/Modal ===
fullscreenDialog: false, // iOS modal style (close button)
opaque: true, // false = transparent page
barrierDismissible: false, // Tap outside to dismiss
barrierColor: null, // Barrier color (e.g. Color(0x80000000))
barrierLabel: null, // Accessibility label for barrier
// === State ===
maintainState: true, // Keep state when inactive
)
class MyPage extends StatelessWidget { ... }
Full @TpShellRoute Reference
@TpShellRoute supports page configuration plus shell-specific options:
@TpShellRoute(
// === Core ===
navigatorKey: MainNavKey, // Required: Shell identifier
parentNavigatorKey: RootNavKey, // Optional: Nest shells
isIndexedStack: true, // Use StatefulShellRoute (preserves tab state)
branchKeys: [HomeNavKey, ProfileNavKey], // Branch identifiers for IndexedStack
// === Observers ===
observers: [MyNavigatorObserver, AnalyticsObserver], // NavigatorObservers for this shell
// === Page Configuration (same as TpRoute) ===
type: TpPageType.material,
fullscreenDialog: false,
opaque: true,
barrierDismissible: false,
barrierColor: null,
barrierLabel: null,
maintainState: true,
pageBuilder: MyShellPageBuilder, // Custom Page factory
)
class MainShell extends StatelessWidget { ... }
Observer Example:
class AnalyticsObserver extends NavigatorObserver {
@override
void didPush(Route route, Route? previousRoute) {
analytics.logPageView(route.settings.name);
}
}
@TpShellRoute(
navigatorKey: MainNavKey,
observers: [AnalyticsObserver], // Attach to shell's navigator
)
class MainShell extends StatelessWidget { ... }
⚙️ Configuration
TpRouter Options
TpRouter(
routes: tpRoutes,
// Initial location (auto-detected from isInitial if not set)
initialLocation: '/home',
// Global redirect
redirect: (context, state) => null,
// Reactive routing trigger
refreshListenable: authNotifier,
// Error page
errorBuilder: (context, state) => ErrorPage(error: state.error),
// Debug logging
debugLogDiagnostics: true,
// Transition defaults
defaultTransition: TpSlideTransition(),
defaultTransitionDuration: Duration(milliseconds: 300),
defaultReverseTransitionDuration: Duration(milliseconds: 200),
// Page type: auto, material, cupertino, swipeBack
defaultPageType: TpPageType.auto,
// Custom navigator key (must be a specific key instance)
navigatorKey: const RootNavKey(),
// Restoration for state persistence
restorationScopeId: 'app_router',
// Redirect limit to prevent infinite loops
redirectLimit: 5,
);
build.yaml Options
Customize generator output:
targets:
$default:
builders:
tp_router_generator:
options:
output: lib/routes/app_routes.dart # Custom output path
📝 License
MIT License. See LICENSE for details.
Libraries
- tp_router
- Export all public APIs from tp_router.