로그인 4종(Google·Apple·Kakao·LINE) 전환 · 이메일 가입 비노출 · 계정 연결 · STATION 탭 비노출 플래그

This commit is contained in:
Sn-Kinos
2026-09-11 02:56:41 +09:00
parent 4f2974b88c
commit d9b6b7c7a0
10 changed files with 910 additions and 259 deletions
+56
View File
@@ -0,0 +1,56 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
/// 원격 기능 플래그 — Firestore `config/features` 문서 한 장.
///
/// 별첨 B v1.4 (STATION) — *1차에서는 사용자 탭과 Coming Soon 을 모두 비노출하고
/// Route/데이터 구조만 유지해 향후 **앱 재배포 없이** 활성화합니다.*
///
/// 「재배포 없이」가 이 클래스가 존재하는 이유 전부다. 컴파일 상수나
/// `--dart-define` 은 값을 바꾸려면 새 빌드를 올려야 해 회신 문구를 만족하지 못한다.
/// 반대로 `firebase_remote_config` 는 신규 의존성이라 pubspec 머리말대로 최소 지원
/// OS 를 다시 계산해야 하는데, `firestore.rules` 의 `config/{id}` 가 이미
/// `allow read: if true` 로 열려 있고 `cloud_firestore` 는 이미 들어와 있다.
/// 그래서 이미 있는 것으로 끝낸다.
///
/// **기본값은 전부 꺼짐이다.** 문서가 없거나, 목 모드거나, 읽기가 실패해도 STATION
/// 은 나타나지 않는다 — 「원격 값을 못 읽었다」가 「켜졌다」가 되면 안 된다.
class FeatureFlags extends ChangeNotifier {
FeatureFlags({Stream<Map<String, Object?>>? source}) {
if (source != null) _subscription = source.listen(_apply, onError: _ignore);
}
/// Firestore 문서 ID. 서버 설계서에 넘기는 계약 이름이다.
static const String documentPath = 'config/features';
/// STATION 탭 노출 스위치. 문서의 `stationEnabled`(bool) 하나를 읽는다.
static const String stationKey = 'stationEnabled';
StreamSubscription<Map<String, Object?>>? _subscription;
bool _stationTab = false;
/// 하단 탭에 STATION 을 그릴 것인가. 기본 false.
bool get stationTab => _stationTab;
void _apply(Map<String, Object?> data) {
// 타입까지 확인한다. 누가 문자열 "true" 를 넣어도 켜지지 않는다.
final next = data[stationKey] == true;
if (next == _stationTab) return;
_stationTab = next;
notifyListeners();
}
/// 원격 읽기 실패는 꺼짐 상태를 유지하는 것으로 끝낸다. 화면에 띄울 것이 없다.
void _ignore(Object error) {
debugPrint('[FeatureFlags] read failed: ${error.runtimeType}');
}
@override
void dispose() {
unawaited(_subscription?.cancel());
_subscription = null;
super.dispose();
}
}
+128 -59
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../config/feature_flags.dart';
import '../../features/auth/auth_repository.dart';
import '../../features/auth/login_screen.dart';
import '../../features/galaxy/galaxy_screen.dart';
@@ -11,12 +12,23 @@ import '../../features/station/station_screen.dart';
/// 하단 탭 하나.
class AppTab {
const AppTab({required this.label, required this.path, required this.icon});
const AppTab({
required this.label,
required this.path,
required this.icon,
this.gatedBy,
});
/// **계약 고정 문자열.** 한글화·치환·순서 변경 금지.
final String label;
final String path;
final IconData icon;
/// 이 탭을 여는 원격 플래그. null 이면 항상 보인다.
///
/// 플래그가 붙어도 [appTabs]·라우트·저장소는 그대로 남는다 — 별첨 B v1.4 가
/// STATION 에 대해 「Route/데이터 구조만 유지」를 요구했기 때문이다.
final bool Function(FeatureFlags flags)? gatedBy;
}
/// 계약 고정 5탭.
@@ -28,82 +40,139 @@ class AppTab {
///
/// 라벨 문자열과 순서는 스모크 테스트가 정확 일치로 검증한다.
/// 아이콘은 아직 확정 자산이 없어 Material 기본 아이콘을 쓴다(README 「미투입 항목」).
///
/// **이 목록은 계약이지 화면이 아니다.** 지금 하단 탭에 실제로 그려지는 것은
/// [visibleTabs] 이고 STATION 은 원격 플래그가 켜질 때까지 빠진다 — 목록에서
/// 지우면 `design/craft-allowlist.yaml` 과 어긋나고, 켜려면 재배포가 필요해진다.
const List<AppTab> appTabs = [
AppTab(label: 'HOME', path: '/home', icon: Icons.home_outlined),
AppTab(label: 'INSIDE', path: '/inside', icon: Icons.chat_bubble_outline),
AppTab(label: 'GALAXY', path: '/galaxy', icon: Icons.auto_awesome_outlined),
AppTab(label: 'STATION', path: '/station', icon: Icons.storefront_outlined),
// 별첨 B v1.4 — *1차에서는 사용자 탭과 Coming Soon 을 모두 비노출하고
// Route/데이터 구조만 유지해 향후 앱 재배포 없이 활성화합니다.*
AppTab(
label: 'STATION',
path: '/station',
icon: Icons.storefront_outlined,
gatedBy: _stationGate,
),
AppTab(label: 'MY', path: '/my', icon: Icons.account_circle_outlined),
];
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';
/// 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) => 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),
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: (_, _) => const MyScreen()),
],
),
],
),
],
);
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: (_, _) => const MyScreen(),
),
],
),
],
),
],
);
}
/// 5탭 셸. 탭별 상태는 [StatefulNavigationShell] 이 유지한다.
/// 탭 셸. 탭별 상태는 [StatefulNavigationShell] 이 유지한다.
///
/// **브랜치는 언제나 [appTabs] 5개 전부다.** 숨긴 탭의 브랜치를 빼면 라우트가
/// 사라져 딥링크·기존 경로가 죽고, 플래그를 켤 때 재배포가 필요해진다. 여기서는
/// 하단 바에 그리는 목적지만 줄이고, 눌린 위치를 원래 브랜치 번호로 되돌린다.
class AppShell extends StatelessWidget {
const AppShell({super.key, required this.shell});
const AppShell({super.key, required this.shell, required this.flags});
final StatefulNavigationShell shell;
final FeatureFlags flags;
@override
Widget build(BuildContext context) => Scaffold(
body: shell,
bottomNavigationBar: NavigationBar(
selectedIndex: shell.currentIndex,
// initialLocation: true 를 주면 이미 열려 있는 탭을 다시 누를 때 루트로 되돌아간다.
onDestinationSelected: (i) =>
shell.goBranch(i, initialLocation: i == shell.currentIndex),
destinations: [
for (final tab in appTabs)
NavigationDestination(icon: Icon(tab.icon), label: tab.label),
],
),
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,
bottomNavigationBar: 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: Icon(tab.icon), label: tab.label),
],
),
);
},
);
}