navhost_typed 0.1.0 copy "navhost_typed: ^0.1.0" to clipboard
navhost_typed: ^0.1.0 copied to clipboard

Type-safe destination objects and navigation helpers for navhost.

example/lib/main.dart

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'),
        ),
      ],
    );
  }
}
0
likes
160
points
62
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Type-safe destination objects and navigation helpers for navhost.

Repository (GitHub)
View/report issues

Topics

#navigation #router #typed-routes #navhost

License

MIT (license)

Dependencies

flutter, navhost

More

Packages that depend on navhost_typed