240 lines
9.2 KiB
Dart
240 lines
9.2 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import '../config/feature_flags.dart';
|
|
import '../data/repositories.dart';
|
|
import '../theme/tokens.dart';
|
|
import '../ui/app_icons.dart';
|
|
import '../../features/auth/auth_repository.dart';
|
|
import '../../features/auth/login_screen.dart';
|
|
import '../../features/auth/signup_screens.dart';
|
|
import '../../features/galaxy/galaxy_home_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탭 — 화면 대조 원장 F0 · Figma `350:215853` 「Bottom Navigation」.
|
|
///
|
|
/// 근거 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<AppTab> 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<AppTab> visibleTabs(FeatureFlags flags) => [
|
|
for (final tab in appTabs)
|
|
if (tab.gatedBy?.call(flags) ?? true) tab,
|
|
];
|
|
|
|
const String loginPath = '/login';
|
|
const String signupCompletePath = '/welcome';
|
|
|
|
/// 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, {Repositories? repositories}) {
|
|
// 저장소 선택은 앱이 뜰 때 한 번만 한다. 탭을 옮길 때마다 다시 만들면
|
|
// Firestore 커서 같은 화면 상태가 매번 초기화된다.
|
|
final repos = repositories ?? Repositories();
|
|
// 한 번만 만든다. 라우트 빌더 안에서 만들면 탭을 옮길 때마다 연결 상태가 초기화된다.
|
|
final linker = repos.linker(auth.providers);
|
|
return GoRouter(
|
|
initialLocation: appTabs.first.path,
|
|
refreshListenable: auth,
|
|
redirect: (context, state) {
|
|
final at = state.matchedLocation;
|
|
if (!auth.isSignedIn) return at == loginPath ? null : loginPath;
|
|
if (auth.justSignedUp)
|
|
return at == signupCompletePath ? null : signupCompletePath;
|
|
if (at == loginPath || at == signupCompletePath)
|
|
return appTabs.first.path;
|
|
return null;
|
|
},
|
|
routes: [
|
|
GoRoute(
|
|
path: loginPath,
|
|
builder: (_, _) => LoginScreen(auth: auth),
|
|
),
|
|
GoRoute(
|
|
path: signupCompletePath,
|
|
builder: (_, _) => SignupCompleteScreen(onStart: auth.finishSignup),
|
|
),
|
|
StatefulShellRoute.indexedStack(
|
|
builder: (_, _, shell) => AppShell(shell: shell, flags: repos.flags),
|
|
branches: [
|
|
StatefulShellBranch(
|
|
routes: [
|
|
GoRoute(
|
|
path: appTabs[0].path,
|
|
builder: (_, _) => HomeScreen(
|
|
watchPlanet: repos.watchPlanet,
|
|
galaxyRepository: repos.galaxy,
|
|
planetRepository: repos.planet,
|
|
repository: repos.home,
|
|
// 홈의 「오늘의 퀘스트」에서 F4 로 들어간다. 저장소 선택은
|
|
// repositories.dart 한 곳이므로 화면이 표본을 직접 만들지 않는다.
|
|
questRepository: repos.quest,
|
|
campaignRepository: repos.campaign,
|
|
notifications: repos.notifications,
|
|
eclipse: repos.eclipse,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
StatefulShellBranch(
|
|
routes: [
|
|
GoRoute(
|
|
path: appTabs[1].path,
|
|
builder: (_, _) => InsideScreen(
|
|
repository: repos.inside,
|
|
translation: repos.translation,
|
|
notifications: repos.notifications,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
StatefulShellBranch(
|
|
routes: [
|
|
GoRoute(
|
|
path: appTabs[2].path,
|
|
// 탭 루트는 시안(354:220228)의 「내 행성」 그리드. 궤도 맵은 그 안에서 연다.
|
|
builder: (_, _) => GalaxyHomeScreen(
|
|
home: repos.home,
|
|
watchPlanet: repos.watchPlanet,
|
|
repository: repos.galaxy,
|
|
planetRepository: repos.planet,
|
|
notifications: repos.notifications,
|
|
eclipse: repos.eclipse,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
StatefulShellBranch(
|
|
routes: [
|
|
GoRoute(
|
|
path: appTabs[3].path,
|
|
builder: (_, _) => StationScreen(repository: repos.station),
|
|
),
|
|
],
|
|
),
|
|
StatefulShellBranch(
|
|
routes: [
|
|
GoRoute(
|
|
path: appTabs[4].path,
|
|
builder: (_, _) => MyScreen(
|
|
repository: repos.my,
|
|
linker: linker,
|
|
push: repos.push,
|
|
auth: auth,
|
|
campaigns: repos.campaign,
|
|
insides: repos.inside,
|
|
translation: repos.translation,
|
|
galaxy: repos.galaxy,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// 탭 셸. 탭별 상태는 [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),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|