Auth gating

Auth gating

back_stack splits gating into loop-proof pieces. No redirectLimit, no ping-ponging.

redirect — a pure transform

redirect maps the proposed stack to the one to actually show, applied once per change:

stack.redirect = (proposed) {
  final guarded = proposed.any((k) => k is Account);
  return (guarded && !isLoggedIn) ? [const Login()] : proposed;
};

Because it's a pure function applied once, it can't loop the way a URL-redirect engine can. Compose several independent gates with combineRedirects([...]), and veto a specific transition (rather than rewrite it) with guard.

refreshListenable — react to auth changes

Point it at your auth Listenable; when sign-in state changes, the current stack is re-evaluated and bounced if redirect now says so:

stack.refreshListenable = authNotifier;

Async gating

redirect is synchronous by design — that's what makes it loop-proof. When a gate needs to await (a permission call, a session refresh, a "does this deep-linked doc exist" check), use AsyncRedirect. One attach is the whole wiring:

final gate = AsyncRedirect<AppKey>(
  gates: (proposed) => proposed.any((k) => k is Admin), // what's protected
  check: (proposed) async =>
      await session.hasAdminAccess() ? null : [const Login()], // allow / deny
)..attach(stack);

gates is the cheap sync "is this stack protected?" predicate. Navigation matching it is held where it is while check decides — the protected screen is never flashed. Everything else navigates instantly and is never checked, so ordinary pops and pushForResult results are unaffected.

attach sets the stack's redirect and refreshListenable together — the two halves that must always go together (with only the redirect set, a resolved check would never re-run it and gating would silently hang). detach() undoes it; dispose() auto-detaches and is safe mid-check.

While a gated check runs, the stack stays put and gate.resolving (a ValueListenable<bool>) flips true — drive a loading overlay from it:

ValueListenableBuilder(
  valueListenable: gate.resolving,
  builder: (_, busy, __) => busy ? const LoadingScrim() : const SizedBox(),
);

Decisions are cached per proposed stack (bounded — tune with cacheSize:), so a check runs once, not on every rebuild. Call gate.invalidate() after login/logout to force a re-check. If check throws, the gate fails closed: the navigation is dropped, the user stays where they were, and the error is reported via FlutterError.reportError.

Blocking leaving a screen

popGuard vetoes a pop synchronously. For an async confirmation ("discard changes?"), set popGuardAsync and pop with tryPop:

stack.popGuardAsync = (top) async =>
    top is! Editor || await confirmDiscard();
 
final popped = await stack.tryPop(); // false if vetoed

Race-safe: if the stack changes while the guard is deciding, nothing is popped. For the system back gesture, wrap the screen in ConfirmPopScope.

Return to the intended destination

redirect receives the whole proposed stack — so a denial can carry it in a typed key, and login can put the user exactly where they were headed:

class Login extends AppKey {
  const Login({this.intended = const [Home()]});
  final List<AppKey> intended;
}
 
stack.redirect = (proposed) =>
    proposed.any((k) => k is Account) && !isLoggedIn
        ? [Login(intended: proposed)]
        : proposed;
 
// on the login screen, after a successful sign-in:
// (key is the Login — its intended field rode along, typed)
BackStack.of<AppKey>(context).replaceAll(key.intended);

No query-string ?from= round-trip — the intended stack is a real field.