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 vetoedRace-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.