Navigation
GoRouter, the bottom menu, where the app opens in debug vs production, and transitions.
Where does the app open? (debug vs production)
In debug (kasy run) the app may go back to the last screen you were on (e.g., /admin, /components). This is intentional: it helps with day-to-day work and hot restart (R), which restarts the app from scratch without losing context.
In production (a release build published to the store or the web) that behavior doesn't exist. The app doesn't "remember" the last screen between sessions.
| Situation | What happens |
|---|---|
| Native in production (closes and reopens) | Always starts at /. If logged in, goes to Home. If not, login or onboarding |
Web in production (yourdomain.com/) | Opens at the URL the user typed. Root / → Home (after auth). /settings → Settings |
Debug (kasy run, hot restart R) | May resume the last visited route, including internal screens like admin |
Summary: debug and production don't behave the same on the initial screen. If you ran kasy run --web, opened localhost:5555, and landed on admin, that doesn't mean the end user will see that when they open the app.
Web: the URL is the screen's address
On the published web, the address bar rules. Bookmarks, F5, and shared links work because every route has a URL (/premium, /admin/kanban, etc.). Protected routes (e.g., admin) still go through the auth redirect: whoever isn't an admin gets sent to Home.
Deep links
To open a specific screen in production:
- Web: use the direct URL (
https://yourapp.com/settings) - Push: send the
routefield in the notification payload (see Push) - Native (external link): configure universal links / app links on iOS and Android so the OS hands the URL to the app
How to test as production on your Mac
flutter run --release # native
flutter run --release -d chrome # web in releaseIn release, the app always cold-starts at / (native) or at the opened URL (web), just like the end user will see it.
Three navigation layers
| Layer | File | Usage |
|---|---|---|
| Global app | lib/router.dart | context.go('/premium'), context.push('/feedback') |
| Bottom tabs | lib/core/bottom_menu/ | Home, Wishlist, Notifications, Settings |
| Onboarding | onboarding_page.dart | Internal Navigator (feature_1 → paywall) |
Without BuildContext (notifiers)
ref.read(goRouterProvider).go('/signin');
ref.read(goRouterProvider).push('/premium');Changing animations for the whole app
One file controls the defaults:
lib/core/navigation/kasy_navigation_config.dart
class KasyNavigationConfig {
static KasyTransitionKind push = KasyTransitionKind.fade; // most screens
static KasyTransitionKind replace = KasyTransitionKind.fade;
static KasyTransitionKind authPeer = KasyTransitionKind.sharedAxisScaled; // sign in and sign up
static KasyTransitionKind bottomTab = KasyTransitionKind.fade; // bottom menu tabs
static KasyTransitionKind onboardingStep = KasyTransitionKind.fade; // onboarding steps
static const Duration duration = Duration(milliseconds: 250);
static const Curve curve = Curves.easeOutCubic;
}Available types: fade, fadeThrough, sharedAxisScaled, sharedAxisHorizontal, none.
| What you want to change | Where |
|---|---|
| The app's default animation | push |
| Sign in to sign up | authPeer |
| Bottom menu tab switching | bottomTab |
| Onboarding steps | onboardingStep |
| Speed or curve of everything | duration and curve |
A new screen with the kit's transition
On a new route always use pageBuilder with kasyTransitionPage, never builder alone:
GoRoute(
path: '/my_screen',
pageBuilder: (context, state) => kasyTransitionPage(
key: state.pageKey,
child: const MyScreenPage(),
),
),To break the default on one specific route, pass transition::
pageBuilder: (context, state) => kasyTransitionPage(
key: state.pageKey,
transition: KasyTransitionKind.none,
child: const MyTechnicalRoute(),
),What stays out of the standard (on purpose)
| Type | Examples | Why |
|---|---|---|
| Dialogs | showAppDialog, showGeneralDialog | Native system animation |
| Bottom sheets | showModalBottomSheet | System behavior |
| In-screen animation | AnimatedSwitcher, flutter_animate | Local UX, not a route change |
Standardizing pays off on full screens. Don't force kasyTransitionPage into a dialog or sheet.
Sub-screens inside a tab
Screens that open inside a bottom menu tab (Settings and Reminders, for example) are not standalone routes: they use the tab's inner path, with bartInnerPath('settings', 'reminder') or the settingsInnerPath('reminder') shortcut. The first segment must be the tab id (home, wishlist, notifications, settings), otherwise the tab bar loses its reference and breaks. Global screens (Premium, Feedback, Assistant) still use context.push('/route') on GoRouter.
What decides where you go
Redirects and guards don't define a visual effect, only a destination:
| File | Role |
|---|---|
lib/core/navigation/auth_redirect.dart | GoRouter's global redirect: sends you to sign-in, onboarding or Home depending on the session |
lib/core/navigation/auth_route_policy.dart | The rules that redirect consults (onboarding done, guest session, and so on) |
lib/core/guards/guard.dart | Widget guard: waits on an async check and falls back to a route |
lib/core/security/biometric_guard.dart | Requires Face ID / biometrics before showing the screen |
lib/environments.dart and lib/core/config/features.dart | Rules per platform and per enabled feature |
Bars that hide on scroll
On phones, scrolling down hides the app bar (top) and the bottom menu together; scrolling back up, or reaching the top, brings them back. It's the Instagram and Facebook behavior, and it gives more room for content. On tablet and desktop navigation is the sidebar, which never hides.
By default the app shows a toggle to the end user under Settings → Preferences → "Hide bars when scrolling". Whoever builds the project controls it with two constants in lib/core/chrome/chrome_visibility.dart:
/// Shows (true) or hides (false) the toggle on the Settings screen.
const bool kShowHideChromeOnScrollSetting = true;
/// Default behavior: true = hide on scroll, false = fixed bars.
const bool kHideChromeOnScrollDefault = true;| Goal | What to do |
|---|---|
| Let the end user choose (default) | kShowHideChromeOnScrollSetting = true |
| Remove the option from Settings | kShowHideChromeOnScrollSetting = false |
| Default to "hide on scroll" | kHideChromeOnScrollDefault = true |
| Default to "bars always fixed" | kHideChromeOnScrollDefault = false |
With the toggle hidden, the behavior is locked to kHideChromeOnScrollDefault and the end user cannot change it.
Android, iOS, and web alike
Transitions are centralized. You won't end up with Zoom on Android and Cupertino on iOS by accident.
Last updated on 08/23/2026

