navhost_typed 0.1.0
navhost_typed: ^0.1.0 copied to clipboard
Type-safe destination objects and navigation helpers for navhost.
import 'package:flutter/material.dart';
import 'package:navhost/navhost.dart';
import 'package:navhost_typed/navhost_typed.dart';
void main() => runApp(const ExampleApp());
class ExampleApp extends StatefulWidget {
const ExampleApp({super.key});
@override
State<ExampleApp> createState() => _ExampleAppState();
}
class _ExampleAppState extends State<ExampleApp> {
bool _isAuthenticated = false;
late final _navController = NavController(
initialRoute: const HomeDestination().location,
interceptors: [
AuthInterceptor(
isAuthenticated: () => _isAuthenticated,
onRedirectedToAuth: () => setState(() {}),
),
],
routes: [
HomeDestination.route(),
ProfileDestination.route(),
PostDestination.route(),
AuthDestination.route(),
CommentsDestination.route(),
ConfirmDialogDestination.route(),
],
);
void _signIn() {
setState(() => _isAuthenticated = true);
}
void _signOut() {
setState(() => _isAuthenticated = false);
_navController.switchTyped(const HomeDestination());
}
@override
Widget build(BuildContext context) {
return MaterialApp.router(
debugShowCheckedModeBanner: false,
routerDelegate: _navController.delegate,
routeInformationParser: _navController.parser,
builder: (context, child) {
return ExampleSession(
isAuthenticated: _isAuthenticated,
signIn: _signIn,
signOut: _signOut,
child: child ?? const SizedBox.shrink(),
);
},
);
}
}
class ExampleSession extends InheritedWidget {
final bool isAuthenticated;
final VoidCallback signIn;
final VoidCallback signOut;
const ExampleSession({
super.key,
required this.isAuthenticated,
required this.signIn,
required this.signOut,
required super.child,
});
static ExampleSession of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<ExampleSession>()!;
}
@override
bool updateShouldNotify(ExampleSession oldWidget) {
return oldWidget.isAuthenticated != isAuthenticated;
}
}
class HomeDestination extends TypedDestination {
static const pathTemplate = '/';
const HomeDestination();
@override
String get template => pathTemplate;
@override
Widget build(BuildContext context) => const HomePage();
static HomeDestination fromRoute(Map<String, String> _, Map<String, String> __) =>
const HomeDestination();
static NavRoute route() => typedRoute(pathTemplate, fromRoute);
}
class ProfileDestination extends TypedDestination {
static const pathTemplate = '/profile/:userId';
final String userId;
const ProfileDestination(this.userId);
@override
String get template => pathTemplate;
@override
Map<String, String> get pathParams => {'userId': userId};
@override
Widget build(BuildContext context) => ProfilePage(userId: userId);
static ProfileDestination fromRoute(
Map<String, String> params,
Map<String, String> _,
) =>
ProfileDestination(params['userId'] ?? '');
static NavRoute route() => typedRoute(pathTemplate, fromRoute);
}
class PostDestination extends TypedDestination {
static const pathTemplate = '/posts/:postId';
final String postId;
final String? source;
const PostDestination({required this.postId, this.source});
@override
String get template => pathTemplate;
@override
Map<String, String> get pathParams => {'postId': postId};
@override
Map<String, String?> get queryParams => {'source': source};
@override
Widget build(BuildContext context) => PostPage(postId: postId, source: source);
static PostDestination fromRoute(
Map<String, String> params,
Map<String, String> query,
) =>
PostDestination(postId: params['postId'] ?? '', source: query['source']);
static NavRoute route() => typedRoute(pathTemplate, fromRoute);
}
class CommentsDestination extends TypedDestination<int> {
static const pathTemplate = '/posts/:postId/comments';
final String postId;
const CommentsDestination(this.postId);
@override
String get template => pathTemplate;
@override
Map<String, String> get pathParams => {'postId': postId};
@override
Widget build(BuildContext context) => CommentsSheet(postId: postId);
static CommentsDestination fromRoute(
Map<String, String> params,
Map<String, String> _,
) =>
CommentsDestination(params['postId'] ?? '');
static NavRoute route() => typedRoute(pathTemplate, fromRoute);
}
class ConfirmDialogDestination extends TypedDestination<bool> {
static const pathTemplate = '/confirm';
final String title;
const ConfirmDialogDestination({required this.title});
@override
String get template => pathTemplate;
@override
Map<String, String?> get queryParams => {'title': title};
@override
Widget build(BuildContext context) => ConfirmDialog(title: title);
static ConfirmDialogDestination fromRoute(
Map<String, String> _,
Map<String, String> query,
) =>
ConfirmDialogDestination(title: query['title'] ?? 'Confirm?');
static NavRoute route() => typedRoute(pathTemplate, fromRoute);
}
class AuthDestination extends TypedDestination {
static const pathTemplate = '/auth';
final String? next;
const AuthDestination({this.next});
@override
String get template => pathTemplate;
@override
Map<String, String?> get queryParams => {'next': next};
@override
Widget build(BuildContext context) => AuthPage(next: next);
static AuthDestination fromRoute(
Map<String, String> _,
Map<String, String> query,
) =>
AuthDestination(next: query['next']);
static NavRoute route() => typedRoute(pathTemplate, fromRoute);
}
class AuthInterceptor extends TypedNavInterceptor {
final bool Function() isAuthenticated;
final VoidCallback onRedirectedToAuth;
AuthInterceptor({
required this.isAuthenticated,
required this.onRedirectedToAuth,
});
@override
TypedDestination? interceptTyped(String from, String to) {
final path = Uri.parse(to).path;
final protected = path.startsWith('/profile');
if (!protected || isAuthenticated()) return null;
onRedirectedToAuth();
return AuthDestination(next: to);
}
}
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
final session = ExampleSession.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('navhost_typed'),
actions: [
if (session.isAuthenticated)
IconButton(
tooltip: 'Sign out',
onPressed: session.signOut,
icon: const Icon(Icons.logout),
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
session.isAuthenticated ? 'Signed in' : 'Signed out',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
FilledButton(
onPressed: () => context.navigateTyped(
const PostDestination(postId: '42', source: 'home'),
),
child: const Text('Open public post'),
),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: () => context.navigateTyped(
const ProfileDestination('mathias'),
),
child: const Text('Open protected profile'),
),
],
),
);
}
}
class PostPage extends StatelessWidget {
final String postId;
final String? source;
const PostPage({super.key, required this.postId, this.source});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Post $postId')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Source: ${source ?? 'direct'}'),
const SizedBox(height: 16),
FilledButton(
onPressed: () async {
final count = await context.navController
.showBottomSheetTyped<int>(
CommentsDestination(postId),
config: const BottomSheetConfig(useSafeArea: true),
);
if (!context.mounted || count == null) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('$count comments read')),
);
},
child: const Text('Show comments sheet'),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: () async {
final confirmed = await context.navController
.showDialogTyped<bool>(
const ConfirmDialogDestination(title: 'Delete this post?'),
);
if (!context.mounted || confirmed != true) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Confirmed')),
);
},
child: const Text('Open confirm dialog'),
),
],
),
),
);
}
}
class ProfilePage extends StatelessWidget {
final String userId;
const ProfilePage({super.key, required this.userId});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('@$userId')),
body: Center(child: Text('Protected profile for $userId')),
);
}
}
class AuthPage extends StatelessWidget {
final String? next;
const AuthPage({super.key, this.next});
@override
Widget build(BuildContext context) {
final session = ExampleSession.of(context);
return Scaffold(
appBar: AppBar(title: const Text('Sign in')),
body: Center(
child: FilledButton(
onPressed: () {
session.signIn();
context.navController.navigate(
next ?? const HomeDestination().location,
popUpTo: const AuthDestination().template,
popUpToInclusive: true,
launchSingleTop:
Uri.parse(next ?? '').path == HomeDestination.pathTemplate,
);
},
child: const Text('Continue'),
),
),
);
}
}
class CommentsSheet extends StatelessWidget {
final String postId;
const CommentsSheet({super.key, required this.postId});
@override
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Comments for post $postId',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
const Text('Nice post.'),
const Text('Typed routes keep call sites readable.'),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerRight,
child: FilledButton(
onPressed: () => context.navController.pop(2),
child: const Text('Done'),
),
),
],
),
),
);
}
}
class ConfirmDialog extends StatelessWidget {
final String title;
const ConfirmDialog({super.key, required this.title});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(title),
actions: [
TextButton(
onPressed: () => context.navController.pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => context.navController.pop(true),
child: const Text('Confirm'),
),
],
);
}
}