Principles
- Route constants are gathered in one place.
lib/core/routing/route_paths.dart의RoutePathsDeclare them as string constants in . Never use raw strings directly anywhere in the code. - Router definition is
lib/core/routing/router.dartone.GoRouterThe instance is exposed as a globalroutervariable andmain.dart의MaterialApp.routerconsumes it. - The route builder creates only the Root widget. Since DI/events/state subscription are handled by Root, the router has no knowledge of Screens.
- Cross-feature navigation is handled with
context.go/context.pushcallbacks . It is this project's convention that Screen/Root may know route strings of other features —RoutePathsbecause they are referenced through constants, it is safe.
Route constants
// lib/core/routing/route_paths.dart
abstract class RoutePaths {
static const String splash = '/Splash';
static const String signIn = '/SingIn';
static const String signUp = '/SingUp';
static const String home = '/Home'; static const String savedRecipes = '/SavedRecipes'; static const String notifications = '/Notifications'; static const String profile = '/Profile';
static const String search = '/Home/Search'; static const String ingredient = '/Home/Ingredient/:recipeId'; }
**규칙**:
- 최상위 화면은 `/<Name>` 형태.
- 중첩(서브) 경로는 상위 경로를 접두어로 포함 (`/Home/Ingredient/:recipeId`).
- 경로 파라미터는 `:name` 토큰으로.
---
## GoRouter 정의
```dart
// lib/core/routing/router.dart
final router = GoRouter(
initialLocation: RoutePaths.splash,
routes: [
GoRoute(
path: RoutePaths.ingredient,
builder: (context, state) {
final recipeId = int.parse(state.pathParameters['recipeId']!);
return IngredientRoot(recipeId: recipeId);
},
),
GoRoute(
path: RoutePaths.splash,
builder: (context, state) => SplashScreen(
onTapStartCooking: () => context.go(RoutePaths.signIn),
),
),
// ... 기타 라우트
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) => MainScreen(
body: navigationShell,
currentPageIndex: navigationShell.currentIndex,
onChangeIndex: (index) => navigationShell.goBranch(
index,
initialLocation: index == navigationShell.currentIndex,
),
),
branches: [
StatefulShellBranch(routes: [
GoRoute(path: RoutePaths.home, builder: (c, s) => const HomeRoot()),
]),
StatefulShellBranch(routes: [
GoRoute(path: RoutePaths.savedRecipes, builder: (c, s) => const SavedRecipesRoot()),
]),
// notifications, profile ...
],
),
],
);
// lib/main.dart
return MaterialApp.router(
routerConfig: router,
// ...
);
Reading route parameters
Parameters come out as strings from state.pathParameters . Type conversion is performed inside the builder, and only type-matched values are passed to the Root widget.
GoRoute(
path: RoutePaths.ingredient, // '/Home/Ingredient/:recipeId'
builder: (context, state) {
final recipeId = int.parse(state.pathParameters['recipeId']!);
return IngredientRoot(recipeId: recipeId);
},
),
Root widget signature:
class IngredientRoot extends StatelessWidget {
final int recipeId;
const IngredientRoot({super.key, required this.recipeId});
}
IngredientRoot internally retrieves getIt<IngredientViewModel>() the ViewModel with and dispatches loadRecipe(recipeId) .
Screen navigation
context.go vs context.push
context.go(path)— replacethe stack. When there is no need to go back, such as login → home.context.push(path)— pushonto the stack. When you need to be able to go back, such as a detail screen.
// 교체: splash → sign in
context.go(RoutePaths.signIn);
// 푸시: 레시피 상세로 진입
context.push('/Home/Ingredient/${recipe.id}');
If the path has parameters, build it with interpolation. Even when assembling parts other than constants as strings, it is good to keep the parent path constants.
Typical pattern used in the Root widget
The Root widget receives an Action and branches appropriately (seesaved_recipes_root.dart ):
onAction: (action) {
if (action is OnTapRecipe) {
context.push('/Home/Ingredient/${action.recipe.id}');
return;
}
viewModel.onAction(action);
},
즉 Actions that change state 은 ViewModel 로, the effect of navigation는 context.push are separated into .
Bottom navigation — StatefulShellRoute
When each tab needs to maintain its own stack, use StatefulShellRoute.indexedStack . Switch tabs with navigationShell.goBranch(index, initialLocation: ...) . With initialLocation: index == currentIndex , the behavior of returning to the root when the same tab is pressed again is implemented.
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) => MainScreen(
body: navigationShell,
currentPageIndex: navigationShell.currentIndex,
onChangeIndex: (index) => navigationShell.goBranch(
index,
initialLocation: index == navigationShell.currentIndex,
),
),
branches: [ /* 탭마다 StatefulShellBranch */ ],
)
Loose coupling through callbacks
SplashScreen, SignInScreen, SignUpScreen Simple screens like are composed in a form that receives only callbacks without a ViewModel. The router injects the callback so that the Screen does not need to know route constants.
GoRoute(
path: RoutePaths.signUp,
builder: (context, state) => SignUpScreen(
onTapSignIn: () => context.go(RoutePaths.signIn),
),
),
This approach is testable Screen the key to making . The Screen does not directly import go_router .
Checklist — Adding a new screen route
-
RoutePathsAdd route constant to -
router.dartat the appropriate location in (if inside a tab,StatefulShellBranch안)에GoRouteadd - builder parses parameters and passes them to the Root widget
- The Root widget constructor receives parameters in a type-safe way
- Callers that need navigation
context.go/context.push+RoutePathsuse constants - A simple screen receives a callback (
onTap...) via a Screen parameter for loose coupling
Anti-pattern
- ❌ Hardcoding path strings all over the screen →
RoutePathsgather them into . - ❌ Inside the ViewModel,
context.go(...)call → the ViewModel does not knowBuildContext. - ❌ In the Screen, directly import
go_router→ change it to receive it as a callback parameter. - ❌ To navigate between different features,
Navigator.of(context).push(MaterialPageRoute(...))mixing → use only one consistentgo_routerAPI. - ❌ Path parameter
state.pathParameters['id']without!setting a default value hides errors → if the parameter is required, force-unwrap it likeint.parse(state.pathParameters['recipeId']!)so that route definition errors are immediately revealed.