canon 0.15.1 copy "canon: ^0.15.1" to clipboard
canon: ^0.15.1 copied to clipboard

Compile-safe Flutter navigation from a single grammar tree.

canon #

Compile-safe Flutter navigation generated from one grammar tree. The transitions you're allowed to make are the only methods that exist — an illegal route is a compile error, not a runtime crash.

Built for the AI-authorship era: a machine can only emit legal navigation, and a human audits the entire nav space at a glance in one small spec.

The whole app, on one screen #

import 'package:canon/canon.dart';
part 'screen.nav.dart';

@screens
enum _Screens with ScreenNode<_Screens> {
  home(HomeScreen()),
  search(SearchScreen()),
  messages(MessagesScreen()),
  profile(ProfileScreen()),
  user(UserScreen(), .uuid),
  post(PostScreen(), .uuid),
  editPost(EditPostScreen(), .uuid),
  comment(CommentScreen(), .uuid),
  thread(ThreadScreen(), .uuid),
  settings(SettingsScreen());

  const _Screens(this.widget, [this.id]);
  @override final Widget widget;
  @override final Codec? id;

  // A profile: this user's posts, and links to other users (followers).
  static _Screens _user() => user({
    post({ comment, user.cycled }),
    user.stacked,                    // tap a follower → another profile, fresh frame
  });

  static final graph = NavGraph<InitialScreen>({
    home.keep({ _user() }),
    search.keep({ _user(), comment }),
    messages.keep({ thread({ _user() }) }),
    profile.keep({
      post({ editPost.inherit(post), comment }),  // here, editPost's id is always this post's
      settings,
    }),
  }, initial: .home);
}

A row is name(WidgetConst()) or name(WidgetConst(), idCodec). One library-private @screens enum, one NavGraph, part 'screen.nav.dart'; — that's the whole grammar. Codegen turns this tree into a typed Screen facade whose methods are its edges. Read this section and you've read the app's navigation; everything below maps to a line in it.

Without canon — routes are strings, ids are stringly-typed map lookups:

context.go('/messages/thraed/$id');     // typo compiles, crashes at runtime
final id = state.params['treadId'];     // wrong key → null → blank screen

The typo, the wrong key, the wrong id type — all invisible until a user hits them.

With canon — the transition is a generated method that only exists where the edge exists:

Screen.goThread(id);                    // thread is single-placement → kick-start exists

The typo cannot exist: there is no goThraed. Screen.goThread() with no id is a compile error (thread is id-bearing). Screen.goThread(42) is a compile error (its id type is String). There is no Screen.goComment at all — comment has two parents, so it has no unambiguous kick-start. Illegal routes aren't caught; they're unrepresentable — not a string to mistype, not a map to mis-key.

And goX is non-destructive by construction — a deep link or push extends from your live position, it never nukes the back stack. Bug class deleted.

Two ways to move #

Kick-start — from anywhere; emitted only when the target is reachable single-placement with the ids you supply.

Screen.goHome();
Screen.goSettings();
Screen.goThread(threadId);

For a dynamic kick-start, Screen.go(Hop.x) takes a ternary and returns a KickstartNav.at narrows it to the exact screen it landed on (exhaustive switch).

Surgical-chain — continue from a live position; ids already on the stack are reused. Screen.on(.path) returns a placement (a typed …Nav) or null, each step offers only satisfiable children, and a whole path commits as one transition:

Screen.on(.search)?.goUser(id);                  // .user matches ANY live user
Screen.on(.user(id))?.goPost(postId);            // .user(id) pins one occurrence
Screen.goHome().goUser(authorId).goPost(postId); // chain disambiguates: post lands under THAT user

Broad reach — a kick-start's reach extends inward: a single-placement id screen gets its goX(id) on every ancestor whose path down to it crosses no unrelated id screen (id-free intermediates auto-fill), so an ancestor jumps straight to a descendant supplying just the one id it needs. (post is multi-placement, so there's no broad goPost — only the direct-child edge above; editPost below is the real broad-reach case.)

Inherit: an id that's provably the parent's #

editPost is an ordinary .uuid screen. Placing it as editPost.inherit(post) declares that in this placement its id is always post's — which buys two things at once:

  • Sugar — you never re-pass that id; it's taken from the live post (the edge verb has no id param).
  • Guarantee — there is no slot to inject a different id, so the compiler proves editPost's id is 100% this post's. The classic bug — opening an editor on the wrong entity — isn't caught at runtime, it's unrepresentable.

Inherit is per-placement: put editPost somewhere without post as an ancestor and it's just a normal id screen taking its own id. Transitive — it flattens to the ultimate id source.

Screen.goEditPost(postId);                          // kick-start: stamps post AND editPost
Screen.on(.post)?.goEditPost();                     // already on post → nothing to pass
Screen.on(.profile)?.goPost(postId).goEditPost();   // give post the id; editPost inherits
Screen.on(.profile)?.goEditPost(postId);            // broad reach: profile → editPost, one id, both pushed
assert(context.idOf(.editPost) == context.idOf(.post)); // inside editPost: always holds — the guarantee

parentOf: push onto whoever hosts you #

comment has two distinct parents (post, search). parentOf pushes onto whichever currently hosts you, with no branching on where you are:

Screen.on(.parentOf.comment)?.goComment(id);
Screen.on(.parentOf);                       // compile error — target mandatory
Screen.on(.parentOf.home);                  // compile error — home is a root, it has no parent

Recursion: stacked vs cycled #

A profile links to other profiles, and a post links back to its author. Two distinct recursions, both explicit in the tree:

  • user.stacked — drill-in: push a fresh instance, keep intermediate frames (follower → follower → follower).
  • user.cycled — exactly stacked, plus one rule: it won't stack a second identical copy of a cycle. If a move would repeat a run of frames (same screens and ids) back-to-back, canon drops the repeat and returns you to the existing run; any differing id breaks the match, so it just stacks.

Cyclic screens expose depth (live occurrence count); .depth(n) pins one:

if (Screen.at case HomeUserNav(:final depth)) { ... }   // how deep in the chain
Screen.on(.user.depth(2))?.popToUser();                 // act on a specific occurrence

Back #

Screen.canPop is null iff you're at a root. Screen.pop() is sugar for Screen.canPop?.pop() — it pops if it can and returns where you landed (or null at a root), never throws. That destination is a union over every non-leaf placement (anywhere you could land), so narrow it with .at:

final landed = Screen.pop();        // null at a root
switch (landed?.at) { /* one case per non-leaf placement — exhaustive */ }

Screen.on(...), Screen.at, and every move return a placement — a typed …Nav like ThreadNav or HomeUserNav. It's a transient cursor: each move returns the next placement and you step from that.

Screen.goThread(threadId).goUser(userId);   // chain off each returned placement

A placement is edge-required and single-use — a spent or stale one throws instead of acting from the wrong spot:

final thread = Screen.goThread(threadId);
thread.goUser(userId);                       // moved off thread
thread.pop();                                // throws — thread is already spent
final thread = Screen.goThread(threadId);
await save();                                // navigation may move on during the gap
thread.goUser(userId);                       // throws IF user is no longer a live edge from here

Where a screen has 2+ children or ancestors, go(Hop.x) / popTo(Pop.x) take a ternary for dynamic branching — go(busy ? Hop.user(a) : Hop.comment(c)) — returning the least-common placement type; chain off a named verb when you need a specific one.

Inspect position — typed, exhaustive #

The bug: "is route X active?" by string/regex compare.

Foreclosed: Screen.at is the exact current placement (never null). Screen.on(.user) is the union of user's placements, null when inactive; .at narrows it to a sealed switch — a new placement you forget to handle won't compile:

if (Screen.at case HomeUserNav n) ...           // exact current placement

switch (Screen.on(.user)?.at) {        // no default — add a placement and this won't compile
  case HomeUserNav():            ...
  case SearchUserNav():          ...
  case MessagesThreadUserNav():  ...
  case null:                     ...   // not on a user
}

.under steps one level outward; Screen.stack exposes .current, .currentId, .screens, .reachable, and .tab (the active root — the bottom of the stack).

Read a screen's own id #

The bug: a mirrored currentUserId provider, or id threaded through every constructor — two sources of truth that drift.

Foreclosed:

final userId = context.idOf(.user);   // typed, never null for an id-bearing screen
context.idOf(.home);                  // compile error — home has no id

No InheritedWidget, no mirror, no route-param threading. (When one widget backs 2+ id-bearing screens, codegen emits a sealed Screen.<widget>Id(context) resolver you switch exhaustively — not needed in this tree.)

State retention #

keep / forget decide whether a scope's stack survives leaving it for another root (a kick-start to a different family) and coming back — build-time checked to actually flip inherited state, so a no-op annotation won't compile:

home.keep({ _user() })   // jump to another root and back → home's stack is intact
child.forget()           // this subtree is dropped, rebuilt fresh on return

Retention applies only to that root switch — a popTo/go to an ancestor within the scope pops the screens above as normal; they're gone.

Codecs (id types) #

The id is a value-witness: write a codec and its T becomes the screen's static id type (.uuidString). Type safety is that T — the codec itself is for restoration and deep links, a strict string ↔ T round-trip whose decode returns null to reject malformed input (it validates the string form, it doesn't add a finer static type):

.string .raw .uuid .username .email .integer .number .date .enumValues(...) .record2/.record3(...) .csv(...) — or any const class implementing Codec<T>.

Host & lifecycle #

MaterialApp.router(routerDelegate: Screen.delegate)   // Router integration
MaterialApp(home: Screen.manager())                   // standalone: owns system back + auto restore

final off = Screen.observe((from, to) { ... });       // post-commit listener, no veto
final snap = Screen.snapshot();                        // manual snapshot
Screen.restore(snap);                                  // best-effort; truncates at first illegal edge

NavGraph takes a required typed initial: (e.g. initial: .profile.settings), optional pageOf (defaults to MaterialPage), and optional observers. Mount another enum's screen family with graft(Other.tree()).

Scope: canon owns an in-memory stack and system back — it's an app router. It does not sync the browser URL bar or history; inbound deep links come through canon_link, and web address-bar sync is out of scope for now.

Guarantees #

  • Compile-time: illegal targets, missing/mistyped ids, and back-at-root aren't expressible — the methods don't exist or don't type-check.
  • Build-time validation: one owner per screen name; every declaration of a name agrees on id type; inherit must target a real ancestor with a matching id type; keep/forget must genuinely flip retention.
  • Runtime: a placement's verbs are edge-required — they throw on a stale-invalid edge rather than silently teleporting. The engine's raw go/pop are @internal; the typed verbs are the only navigation surface.
  • Drift check: assert(Screen.isCodegenFresh) in a test fails if codegen and the live tree diverge.

The payoff: the spec at the top of this file is the complete, auditable nav space. A model can only emit legal navigation, and a human reviews every reachable route at a glance.

Install #

dependencies:
  canon: ^0.15.1            # runtime
dev_dependencies:
  canon_generator: ^0.19.0  # codegen — emits screen.nav.dart
  build_runner: any

dart run build_runner build generates the typed Screen facade. For deep links, add canon_link — a strict URL ↔ sealed Link codec built from the same grammar.

1
likes
0
points
1.75k
downloads

Publisher

unverified uploader

Weekly Downloads

Compile-safe Flutter navigation from a single grammar tree.

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

canon_codec, flutter, meta

More

Packages that depend on canon