import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../config/feature_flags.dart'; import '../theme/tokens.dart'; import '../ui/app_icons.dart'; import '../../features/auth/auth_repository.dart'; import '../../features/auth/login_screen.dart'; import '../../features/galaxy/galaxy_screen.dart'; import '../../features/home/home_screen.dart'; import '../../features/inside/inside_screen.dart'; import '../../features/my/my_screen.dart'; import '../../features/station/station_screen.dart'; /// 하단 탭 하나. class AppTab { const AppTab({ required this.label, required this.path, required this.icon, this.gatedBy, }); /// **계약 고정 문자열.** 한글화·치환·순서 변경 금지. final String label; final String path; /// Figma Bottom Navigation 아이콘. 색은 NavigationBar 테마가 입힌다. final Widget icon; /// 이 탭을 여는 원격 플래그. null 이면 항상 보인다. /// /// 플래그가 붙어도 [appTabs]·라우트·저장소는 그대로 남는다 — 별첨 B v1.4 가 /// STATION 에 대해 「Route/데이터 구조만 유지」를 요구했기 때문이다. final bool Function(FeatureFlags flags)? gatedBy; } /// 계약 고정 5탭. /// /// 근거 2개가 서로 일치한다: /// - 견적서 v2.1 「5탭 네비게이션」 /// - `design/craft-allowlist.yaml` → `APP_TABS: [HOME, INSIDE, GALAXY, STATION, MY]` /// /// 라벨 문자열과 순서는 스모크 테스트가 정확 일치로 검증한다. /// 아이콘은 Figma Iconset(house · message-square · badge · circle-user-round)이다. /// STATION 은 Figma 하단 탭에 없어(1차 비노출) Material 아이콘을 둔다. /// /// **이 목록은 계약이지 화면이 아니다.** 지금 하단 탭에 실제로 그려지는 것은 /// [visibleTabs] 이고 STATION 은 원격 플래그가 켜질 때까지 빠진다 — 목록에서 /// 지우면 `design/craft-allowlist.yaml` 과 어긋나고, 켜려면 재배포가 필요해진다. const List appTabs = [ AppTab(label: 'HOME', path: '/home', icon: ImageIcon(AppIcons.house)), AppTab( label: 'INSIDE', path: '/inside', icon: ImageIcon(AppIcons.messageSquare), ), AppTab(label: 'GALAXY', path: '/galaxy', icon: ImageIcon(AppIcons.badge)), // 별첨 B v1.4 — *1차에서는 사용자 탭과 Coming Soon 을 모두 비노출하고 // Route/데이터 구조만 유지해 향후 앱 재배포 없이 활성화합니다.* AppTab( label: 'STATION', path: '/station', icon: Icon(Icons.storefront_outlined), gatedBy: _stationGate, ), AppTab(label: 'MY', path: '/my', icon: ImageIcon(AppIcons.circleUserRound)), ]; bool _stationGate(FeatureFlags flags) => flags.stationTab; /// 지금 하단 탭에 그릴 탭들. 계약 순서는 그대로고 닫힌 탭만 빠진다. List visibleTabs(FeatureFlags flags) => [ for (final tab in appTabs) if (tab.gatedBy?.call(flags) ?? true) tab, ]; const String loginPath = '/login'; /// 5탭 셸 라우트 + 온보딩(로그인) 라우트. /// /// 온보딩 나머지 화면(S-A-INTRO · S-A-TERMS · S-A-PROFILE · S-A-ARTIST · /// S-A-PLANET-NEW · S-A-PERMS)은 3주차 범위라 아직 라우트가 없다. GoRouter buildRouter(AuthRepository auth, {FeatureFlags? flags}) { final featureFlags = flags ?? FeatureFlags(); return GoRouter( initialLocation: appTabs.first.path, refreshListenable: auth, redirect: (context, state) { final atLogin = state.matchedLocation == loginPath; if (!auth.isSignedIn) return atLogin ? null : loginPath; if (atLogin) return appTabs.first.path; return null; }, routes: [ GoRoute( path: loginPath, builder: (_, _) => LoginScreen(auth: auth), ), StatefulShellRoute.indexedStack( builder: (_, _, shell) => AppShell(shell: shell, flags: featureFlags), branches: [ StatefulShellBranch( routes: [ GoRoute( path: appTabs[0].path, builder: (_, _) => const HomeScreen(), ), ], ), StatefulShellBranch( routes: [ GoRoute( path: appTabs[1].path, builder: (_, _) => const InsideScreen(), ), ], ), StatefulShellBranch( routes: [ GoRoute( path: appTabs[2].path, builder: (_, _) => const GalaxyScreen(), ), ], ), StatefulShellBranch( routes: [ GoRoute( path: appTabs[3].path, builder: (_, _) => const StationScreen(), ), ], ), StatefulShellBranch( routes: [ GoRoute( path: appTabs[4].path, builder: (_, _) => MyScreen(auth: auth), ), ], ), ], ), ], ); } /// 탭 셸. 탭별 상태는 [StatefulNavigationShell] 이 유지한다. /// /// **브랜치는 언제나 [appTabs] 5개 전부다.** 숨긴 탭의 브랜치를 빼면 라우트가 /// 사라져 딥링크·기존 경로가 죽고, 플래그를 켤 때 재배포가 필요해진다. 여기서는 /// 하단 바에 그리는 목적지만 줄이고, 눌린 위치를 원래 브랜치 번호로 되돌린다. class AppShell extends StatelessWidget { const AppShell({super.key, required this.shell, required this.flags}); final StatefulNavigationShell shell; final FeatureFlags flags; @override Widget build(BuildContext context) => ListenableBuilder( listenable: flags, builder: (context, _) { final tabs = visibleTabs(flags); // 보이는 목적지 번호 ↔ 셸 브랜치 번호. 숨긴 탭 때문에 어긋나는 지점이다. final branches = [for (final tab in tabs) appTabs.indexOf(tab)]; final selected = branches.indexOf(shell.currentIndex); return Scaffold( body: shell, // Figma 탭 줄은 좌우 16 을 비우고 칸을 나눈다(350:215853). bottomNavigationBar: ColoredBox( color: AppColors.cosmicVoid, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: NavigationBar( // 숨긴 탭에 머무는 동안(딥링크 등)에는 선택 표시를 첫 탭으로 둔다. selectedIndex: selected < 0 ? 0 : selected, onDestinationSelected: (i) => shell.goBranch( branches[i], // initialLocation: true 를 주면 이미 열려 있는 탭을 다시 누를 때 루트로 되돌아간다. initialLocation: branches[i] == shell.currentIndex, ), destinations: [ for (final tab in tabs) NavigationDestination(icon: tab.icon, label: tab.label), ], ), ), ), ); }, ); }