raku_router 0.2.0
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.
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')),
],
),
),
);
}
}