raku_router 0.2.0 copy "raku_router: ^0.2.0" to clipboard
raku_router: ^0.2.0 copied to clipboard

A tiny, code-generation-free, UI-agnostic router for Flutter: type-safe sealed routes, declarative deep linking, and built-in nested tabs with per-branch back stacks.

example/lib/main.dart

import 'package:flutter/material.dart';
import 'package:raku_router/raku_router.dart';

void main() => runApp(const ExampleApp());

// ---------------------------------------------------------------------------
// Screens are plain data classes — no code generation. You navigate by passing
// the object (`context.push(const Note('1'))`), never a string.
// ---------------------------------------------------------------------------

sealed class AppRoute extends RakuRoute {
  const AppRoute();
}

class Feed extends AppRoute {
  const Feed();
}

class Note extends AppRoute {
  const Note(this.id);
  final String id;
  @override
  List<Object?> get props => [id];
}

class Settings extends AppRoute {
  const Settings();
}

/// A guarded screen: while [dirty] is on it refuses to pop (back — including the
/// predictive-back swipe — is blocked), and explains why.
class EditProfile extends AppRoute with RouteGuard {
  const EditProfile();
  static final ValueNotifier<bool> dirty = ValueNotifier<bool>(false);

  @override
  bool get canPop => !dirty.value;

  @override
  Listenable? get rebuildOn => dirty; // re-evaluate canPop when dirty toggles

  @override
  void onPopBlocked(BuildContext context) {
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('Discard unsaved changes first.')),
    );
  }
}

/// A full-page route: lives above the tab shell, so it covers the bar.
class Photo extends AppRoute {
  const Photo(this.id);
  final String id;
  @override
  List<Object?> get props => [id];
}

/// A typed catch-all: any URL no concrete route claims lands here, carrying the
/// unmatched tail so the 404 screen can show what was attempted.
class NotFound extends AppRoute {
  const NotFound(this.attempted);
  final String attempted;
  @override
  List<Object?> get props => [attempted];
}

// ---------------------------------------------------------------------------
// One declarative route TREE: each node is "a URL ↔ a typed screen". The URL's
// structure rebuilds the navigation stack, so a deep link to /feed/notes/2 opens
// the Feed tab on that note *with Feed underneath* (back returns to the feed).
// Nested routes are children; tabs are a node; a top-level route sits full-page
// above the shell. The premium slide is the default transition. No code-gen.
// ---------------------------------------------------------------------------

final router = raku(
  initial: const Feed(),
  routes: [
    tabs(
      shell: (context, tabs, child) => Scaffold(
        body: child,
        bottomNavigationBar: ListenableBuilder(
          listenable: tabs,
          builder: (context, _) => NavigationBar(
            selectedIndex: tabs.index,
            onDestinationSelected: tabs.go,
            destinations: const [
              NavigationDestination(icon: Icon(Icons.list), label: 'Feed'),
              NavigationDestination(
                icon: Icon(Icons.settings),
                label: 'Settings',
              ),
            ],
          ),
        ),
      ),
      branches: [
        [
          route(
            '/feed',
            (_) => const Feed(),
            (_) => const FeedScreen(),
            title: (_) => 'Feed · raku_router', // sets the browser tab title
            children: [
              // /feed/notes/:id — nested, so it stacks on top of Feed.
              route(
                'notes/:id',
                (p) => Note(p('id')),
                (n) => NoteScreen(id: n.id),
                title: (n) => 'Note ${n.id} · raku_router',
              ),
            ],
          ),
        ],
        [
          route(
            '/settings',
            (_) => const Settings(),
            (_) => const SettingsScreen(),
            title: (_) => 'Settings · raku_router',
            children: [
              route(
                'edit',
                (_) => const EditProfile(),
                (_) => const EditProfileScreen(),
                title: (_) => 'Edit profile · raku_router',
              ),
            ],
          ),
        ],
      ],
    ),
    // A top-level route is full-page — it sits above the shell and covers the bar.
    route(
      '/photo/:id',
      (p) => Photo(p('id')),
      (n) => PhotoScreen(id: n.id),
      title: (p) => 'Photo · ${p.id}',
    ),
    // A trailing `*` is a typed catch-all: any URL nothing else claims lands on
    // NotFound, with the unmatched tail available as `p.rest`. Full-page, above
    // the shell — and the address bar keeps the URL the user actually hit.
    route(
      '*',
      (p) => NotFound(p.rest),
      (n) => NotFoundScreen(attempted: n.attempted),
      title: (_) => 'Not found · raku_router',
    ),
  ],
);

class ExampleApp extends StatelessWidget {
  const ExampleApp({super.key});
  @override
  Widget build(BuildContext context) =>
      MaterialApp.router(title: 'raku_router example', routerConfig: router);
}

// ---------------------------------------------------------------------------
// Screens — every navigation is a typed object; nested pushes stay in the tab,
// the full-page Photo covers the bar.
// ---------------------------------------------------------------------------

class FeedScreen extends StatelessWidget {
  const FeedScreen({super.key});
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Feed')),
      body: ListView(
        children: [
          for (final id in ['1', '2', '3'])
            ListTile(
              title: Text('Note $id'),
              onTap: () => context.push(Note(id)), // nested — bar stays
            ),
          const Divider(),
          ListTile(
            leading: const Icon(Icons.photo),
            title: const Text('Open full-screen photo (covers the bar)'),
            onTap: () => context.push(const Photo('sunset')), // full-page
          ),
          ListTile(
            leading: const Icon(Icons.error_outline),
            title: const Text('Visit an unknown route (typed 404)'),
            subtitle: const Text('Or type a bad URL in the address bar'),
            onTap: () => context.push(const NotFound('feed/does-not-exist')),
          ),
        ],
      ),
    );
  }
}

class NoteScreen extends StatelessWidget {
  const NoteScreen({super.key, required this.id});
  final String id;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Note $id')),
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            FilledButton(
              onPressed: () => context.push(Note('$id-child')),
              child: const Text('Push a nested note'),
            ),
            TextButton(onPressed: context.pop, child: const Text('Back')),
          ],
        ),
      ),
    );
  }
}

class SettingsScreen extends StatelessWidget {
  const SettingsScreen({super.key});
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Settings')),
      body: Center(
        child: FilledButton(
          onPressed: () => context.push(const EditProfile()),
          child: const Text('Edit profile (guarded)'),
        ),
      ),
    );
  }
}

class EditProfileScreen extends StatelessWidget {
  const EditProfileScreen({super.key});
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Edit profile')),
      body: Center(
        child: ValueListenableBuilder<bool>(
          valueListenable: EditProfile.dirty,
          builder: (context, dirty, _) => Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              SwitchListTile(
                title: const Text('Unsaved changes'),
                subtitle: Text(
                  dirty ? 'Back is BLOCKED.' : 'Toggle on to block leaving.',
                ),
                value: dirty,
                onChanged: (v) => EditProfile.dirty.value = v,
              ),
              TextButton(
                onPressed: context.pop, // honours the guard
                child: const Text('Try to go back'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class PhotoScreen extends StatelessWidget {
  const PhotoScreen({super.key, required this.id});
  final String id;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Text(
              'Full-screen photo: $id',
              style: const TextStyle(color: Colors.white, fontSize: 20),
            ),
            TextButton(onPressed: context.pop, child: const Text('Close')),
          ],
        ),
      ),
    );
  }
}

class NotFoundScreen extends StatelessWidget {
  const NotFoundScreen({super.key, required this.attempted});

  /// The unmatched URL tail, from `RouteParams.rest`.
  final String attempted;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Not found')),
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            const Icon(Icons.explore_off, size: 48),
            const SizedBox(height: 12),
            Text("Couldn't find /$attempted"),
            const SizedBox(height: 12),
            TextButton(onPressed: context.pop, child: const Text('Go back')),
          ],
        ),
      ),
    );
  }
}
1
likes
160
points
90
downloads

Documentation

API reference

Publisher

verified publisherderlio.app

Weekly Downloads

A tiny, code-generation-free, UI-agnostic router for Flutter: type-safe sealed routes, declarative deep linking, and built-in nested tabs with per-branch back stacks.

Homepage
Repository (GitHub)
View/report issues

Topics

#router #navigation #deep-linking #tabs

License

MIT (license)

Dependencies

flutter

More

Packages that depend on raku_router