Flutter 앱 골격 · 5탭 네비게이션 · 로그인 화면 · Firestore 보안 규칙·인덱스
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'core/router/app_router.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'features/auth/auth_repository.dart';
|
||||
|
||||
/// MaterialApp + 라우터 + 테마 주입.
|
||||
class FancakeApp extends StatefulWidget {
|
||||
const FancakeApp({super.key, required this.auth});
|
||||
|
||||
final AuthRepository auth;
|
||||
|
||||
@override
|
||||
State<FancakeApp> createState() => _FancakeAppState();
|
||||
}
|
||||
|
||||
class _FancakeAppState extends State<FancakeApp> {
|
||||
late final GoRouter _router = buildRouter(widget.auth);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => MaterialApp.router(
|
||||
title: 'Fancake',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildAppTheme(),
|
||||
// 코스믹 다크가 유일한 서피스다(결정 D-19). 라이트 테마를 두지 않는다.
|
||||
themeMode: ThemeMode.dark,
|
||||
darkTheme: buildAppTheme(),
|
||||
routerConfig: _router,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Firebase 구동 모드.
|
||||
enum FirebaseMode {
|
||||
/// 설정 파일이 투입돼 Firebase 가 실제로 초기화된 상태.
|
||||
live,
|
||||
|
||||
/// 설정 파일이 없어 로컬 목(mock) 으로 동작하는 상태. 네트워크 호출을 하지 않는다.
|
||||
mock,
|
||||
}
|
||||
|
||||
/// Firebase 초기화 게이트.
|
||||
///
|
||||
/// `google-services.json`(Android) · `GoogleService-Info.plist`(iOS) 가 아직 리포에 없다.
|
||||
/// 두 파일은 **스위치 내부 조치 사항 S1·S2**(3주차 빌드 전)이며, 없는 동안에는
|
||||
/// 초기화가 실패하는 게 정상이다. 실패를 삼키지 않고 [mode] 를 mock 으로 내려
|
||||
/// 앱이 뜨게 하고, 사유를 로그로 남긴다.
|
||||
///
|
||||
/// 파일이 투입되면 이 클래스는 코드 수정 없이 [FirebaseMode.live] 로 올라간다.
|
||||
abstract final class FirebaseBoot {
|
||||
static FirebaseMode _mode = FirebaseMode.mock;
|
||||
static String _reason = '아직 init() 을 부르지 않았다.';
|
||||
static String? _detail;
|
||||
|
||||
static FirebaseMode get mode => _mode;
|
||||
static bool get isLive => _mode == FirebaseMode.live;
|
||||
|
||||
/// 현재 모드가 된 사유 — 화면에 그대로 띄운다.
|
||||
/// **내부 경로·예외 문자열을 넣지 마라.** 사용자에게 보이는 문자열이다.
|
||||
static String get reason => _reason;
|
||||
|
||||
/// 투입 대상 파일 경로와 원시 예외. 로그·진단용이고 화면에는 내보내지 않는다.
|
||||
static String? get detail => _detail;
|
||||
|
||||
/// 설정 파일이 없을 때 투입해야 하는 파일 목록.
|
||||
/// **리포 경로다 — 로그·문서 전용이고 화면에 내보내지 마라.** 화면에는 [missingLabels] 를 쓴다.
|
||||
static const List<String> requiredConfigFiles = [
|
||||
'app/android/app/google-services.json',
|
||||
'app/ios/Runner/GoogleService-Info.plist',
|
||||
];
|
||||
|
||||
/// 화면에 보여 줄 미투입 항목 이름. 내부 경로를 담지 않는다.
|
||||
static const List<String> missingLabels = ['Firebase 설정'];
|
||||
|
||||
static Future<void> init() async {
|
||||
try {
|
||||
await Firebase.initializeApp();
|
||||
_mode = FirebaseMode.live;
|
||||
_reason = 'Firebase 설정 파일이 확인되어 실 모드로 초기화했습니다.';
|
||||
_detail = null;
|
||||
} on Object catch (error) {
|
||||
// 설정 파일 미투입이면 여기로 떨어진다 — 예상된 경로다.
|
||||
_mode = FirebaseMode.mock;
|
||||
_reason = 'Firebase 설정이 아직 투입되지 않아 임시(목) 모드로 동작 중입니다.';
|
||||
_detail = '투입 대상: ${requiredConfigFiles.join(' · ')} / 원인: $error';
|
||||
}
|
||||
debugPrint('[FirebaseBoot] mode=${_mode.name} :: $_reason');
|
||||
if (_detail != null) debugPrint('[FirebaseBoot] detail :: $_detail');
|
||||
}
|
||||
|
||||
/// 테스트에서 모드를 고정할 때만 쓴다.
|
||||
@visibleForTesting
|
||||
static void debugSetMode(FirebaseMode mode, {String reason = '테스트 지정'}) {
|
||||
_mode = mode;
|
||||
_reason = reason;
|
||||
_detail = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.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});
|
||||
|
||||
/// **계약 고정 문자열.** 한글화·치환·순서 변경 금지.
|
||||
final String label;
|
||||
final String path;
|
||||
final IconData icon;
|
||||
}
|
||||
|
||||
/// 계약 고정 5탭.
|
||||
///
|
||||
/// 근거 3개가 서로 일치한다:
|
||||
/// - 견적서 v2.1 「5탭 네비게이션」
|
||||
/// - `design/craft-allowlist.yaml` → `APP_TABS: [HOME, INSIDE, GALAXY, STATION, MY]`
|
||||
/// - 시안 `시안/screens/S-*.html` 의 `<nav class="tabbar">` (5개 루트 화면 전부 동일)
|
||||
///
|
||||
/// 라벨 문자열과 순서는 스모크 테스트가 정확 일치로 검증한다.
|
||||
/// 아이콘은 아직 확정 자산이 없어 Material 기본 아이콘을 쓴다(README 「미투입 항목」).
|
||||
const List<AppTab> appTabs = [
|
||||
AppTab(label: 'HOME', path: '/home', icon: Icons.checklist_rtl),
|
||||
AppTab(label: 'INSIDE', path: '/inside', icon: Icons.forum_outlined),
|
||||
AppTab(label: 'GALAXY', path: '/galaxy', icon: Icons.public),
|
||||
AppTab(label: 'STATION', path: '/station', icon: Icons.storefront_outlined),
|
||||
AppTab(label: 'MY', path: '/my', icon: Icons.person_outline),
|
||||
];
|
||||
|
||||
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()),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
/// 5탭 셸. 탭별 상태는 [StatefulNavigationShell] 이 유지한다.
|
||||
class AppShell extends StatelessWidget {
|
||||
const AppShell({super.key, required this.shell});
|
||||
|
||||
final StatefulNavigationShell shell;
|
||||
|
||||
@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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'tokens.dart';
|
||||
|
||||
/// design-tokens.md → ThemeData 조립.
|
||||
///
|
||||
/// 값은 전부 [tokens.dart] 에서 온다. 여기서 색·크기를 새로 만들면 시안과 어긋난다.
|
||||
/// 서피스는 코스믹 보이드(순수 검정)다 — 결정 D-19, `design/아트디렉션-예외.md`.
|
||||
///
|
||||
/// 컬러 운용은 60-30-10 이고 악센트는 행동·선택·포커스에만 쓴다.
|
||||
/// ColorScheme 슬롯에 네온을 꽂아 뒀다고 해서 화면에서 네 색을 다 풀면 안 된다 —
|
||||
/// 화면당 네온은 하나를 주로 하고 나머지는 보조로만(design-tokens.md 「컬러 운용 규율」).
|
||||
ThemeData buildAppTheme() {
|
||||
const scheme = ColorScheme.dark(
|
||||
primary: AppColors.cosmicAccent,
|
||||
onPrimary: AppColors.onCosmicAccent,
|
||||
secondary: AppColors.neonViolet,
|
||||
onSecondary: AppColors.onNeonViolet,
|
||||
tertiary: AppColors.neonCyan,
|
||||
onTertiary: AppColors.onNeonCyan,
|
||||
error: AppColors.error,
|
||||
onError: AppColors.onError,
|
||||
surface: AppColors.cosmicVoid,
|
||||
onSurface: AppColors.onCosmic,
|
||||
onSurfaceVariant: AppColors.onCosmicVariant,
|
||||
surfaceContainerLowest: AppColors.cosmicVoid,
|
||||
surfaceContainerLow: AppColors.cosmicSurface,
|
||||
surfaceContainer: AppColors.cosmicSurfaceContainer,
|
||||
surfaceContainerHigh: AppColors.cosmicSurfaceRaised,
|
||||
surfaceContainerHighest: AppColors.cosmicSurfaceRaised,
|
||||
outline: AppColors.cosmicOutline,
|
||||
outlineVariant: AppColors.cosmicOutline,
|
||||
);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
scaffoldBackgroundColor: AppColors.cosmicVoid,
|
||||
textTheme: _textTheme(scheme),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: AppColors.cosmicVoid,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
centerTitle: true,
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
backgroundColor: AppColors.cosmicSurface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
indicatorColor: AppColors.cosmicSurfaceRaised,
|
||||
// 탭 항목 높이는 touch-min-app(48) 아래로 내리지 않는다.
|
||||
height: AppSizing.touchMinApp + AppSpace.s5,
|
||||
labelTextStyle: WidgetStateProperty.resolveWith(
|
||||
(states) => _style(
|
||||
AppType.caption,
|
||||
states.contains(WidgetState.selected)
|
||||
? AppColors.cosmicAccent
|
||||
: AppColors.onCosmicVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(color: AppColors.cosmicOutline, thickness: 1),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(AppSizing.touchMinApp),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
// 글자색은 여기서 정하지 않는다 — foregroundColor 가 결정한다.
|
||||
// textStyle 에 색을 넣으면 소셜 버튼처럼 제공자별 색을 쓰는 곳과 지정이 두 곳으로 갈린다.
|
||||
textStyle: _styleNoColor(AppType.titleM),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TextTheme _textTheme(ColorScheme scheme) => TextTheme(
|
||||
displayLarge: _style(AppType.displayL, scheme.onSurface),
|
||||
displayMedium: _style(AppType.displayM, scheme.onSurface),
|
||||
headlineSmall: _style(AppType.titleL, scheme.onSurface),
|
||||
titleLarge: _style(AppType.titleL, scheme.onSurface),
|
||||
titleMedium: _style(AppType.titleM, scheme.onSurface),
|
||||
bodyLarge: _style(AppType.bodyL, scheme.onSurface),
|
||||
bodyMedium: _style(AppType.bodyM, scheme.onSurface),
|
||||
bodySmall: _style(AppType.bodyS, scheme.onSurfaceVariant),
|
||||
labelLarge: _style(AppType.labelL, scheme.onSurface),
|
||||
labelMedium: _style(AppType.eyebrow, scheme.onSurfaceVariant),
|
||||
labelSmall: _style(AppType.caption, scheme.onSurfaceVariant),
|
||||
);
|
||||
|
||||
/// [TypeToken] → [TextStyle].
|
||||
///
|
||||
/// 폰트 파일 미투입 상태에서는 fontFamily 가 매칭되지 않아 시스템 폰트로 폴백된다.
|
||||
/// 크기·굵기·행간·자간은 그대로 적용되므로 레이아웃은 투입 전후가 거의 같다.
|
||||
TextStyle _style(TypeToken t, Color color) => _styleNoColor(t).copyWith(color: color);
|
||||
|
||||
/// 색 없는 타입 스타일. 색을 다른 속성(foregroundColor 등)이 정하는 자리에 쓴다.
|
||||
TextStyle _styleNoColor(TypeToken t) => TextStyle(
|
||||
fontFamily: t.family,
|
||||
fontSize: t.size,
|
||||
fontWeight: _weight(t.weight),
|
||||
height: t.height,
|
||||
letterSpacing: t.letterSpacing,
|
||||
);
|
||||
|
||||
FontWeight _weight(int value) =>
|
||||
FontWeight.values[(value ~/ 100 - 1).clamp(0, FontWeight.values.length - 1)];
|
||||
@@ -0,0 +1,169 @@
|
||||
// GENERATED FILE — 손으로 고치지 마라.
|
||||
// 원본: design/design-tokens.md (<!-- TOKENS --> 머신블록)
|
||||
// 재생성: cd app && python3 tool/gen_tokens.py
|
||||
|
||||
import 'dart:ui' show Color;
|
||||
|
||||
/// 컬러 토큰. app 은 cosmic* 계열이 기본 서피스다(결정 D-19).
|
||||
abstract final class AppColors {
|
||||
static const primary = Color(0xFFAC5992); // color-primary #ac5992
|
||||
static const onPrimary = Color(0xFFFFFFFF); // color-on-primary #ffffff
|
||||
static const primaryContainer = Color(0xFFF0E1EB); // color-primary-container #f0e1eb
|
||||
static const onPrimaryContainer = Color(0xFF924C7C); // color-on-primary-container #924c7c
|
||||
static const surface = Color(0xFFF8F5FA); // color-surface #f8f5fa
|
||||
static const surfaceContainer = Color(0xFFEBE7ED); // color-surface-container #ebe7ed
|
||||
static const onSurface = Color(0xFF1A1C1E); // color-on-surface #1a1c1e
|
||||
static const onSurfaceVariant = Color(0xFF4B4753); // color-on-surface-variant #4b4753
|
||||
static const outline = Color(0xFF7A7481); // color-outline #7a7481
|
||||
static const error = Color(0xFFC53030); // color-error #c53030
|
||||
static const onError = Color(0xFFFFFFFF); // color-on-error #ffffff
|
||||
static const webBackground = Color(0xFFFFFFFF); // web-background #ffffff
|
||||
static const webForeground = Color(0xFF1A1C1E); // web-foreground #1a1c1e
|
||||
static const webPrimary = Color(0xFFAC5992); // web-primary #ac5992
|
||||
static const webPrimaryForeground = Color(0xFFFFFFFF); // web-primary-foreground #ffffff
|
||||
static const webMuted = Color(0xFFF1EEF4); // web-muted #f1eef4
|
||||
static const webMutedForeground = Color(0xFF4B4753); // web-muted-foreground #4b4753
|
||||
static const webBorder = Color(0xFFDED7DF); // web-border #ded7df
|
||||
static const cosmicVoid = Color(0xFF000000); // color-cosmic-void #000000
|
||||
static const cosmicSurface = Color(0xFF05070F); // color-cosmic-surface #05070F
|
||||
static const cosmicSurfaceContainer = Color(0xFF0D1220); // color-cosmic-surface-container #0D1220
|
||||
static const cosmicSurfaceRaised = Color(0xFF141A2B); // color-cosmic-surface-raised #141A2B
|
||||
static const onCosmic = Color(0xFFF4F0F7); // color-on-cosmic #F4F0F7
|
||||
static const onCosmicVariant = Color(0xFFB4AEC0); // color-on-cosmic-variant #B4AEC0
|
||||
static const cosmicAccent = Color(0xFFFF85D9); // color-cosmic-accent #FF85D9
|
||||
static const onCosmicAccent = Color(0xFF05070F); // color-on-cosmic-accent #05070F
|
||||
static const neonCyan = Color(0xFF00F2FF); // color-neon-cyan #00F2FF
|
||||
static const onNeonCyan = Color(0xFF05070F); // color-on-neon-cyan #05070F
|
||||
static const neonEmerald = Color(0xFF34D399); // color-neon-emerald #34D399
|
||||
static const onNeonEmerald = Color(0xFF05070F); // color-on-neon-emerald #05070F
|
||||
static const neonMagenta = Color(0xFFFF007A); // color-neon-magenta #FF007A
|
||||
static const onNeonMagenta = Color(0xFF05070F); // color-on-neon-magenta #05070F
|
||||
static const neonViolet = Color(0xFFA855F7); // color-neon-violet #A855F7
|
||||
static const onNeonViolet = Color(0xFF05070F); // color-on-neon-violet #05070F
|
||||
static const cosmicMint = Color(0xFFC1FFED); // color-cosmic-mint #C1FFED
|
||||
static const onCosmicMint = Color(0xFF05070F); // color-on-cosmic-mint #05070F
|
||||
static const cosmicOutline = Color(0xFF2A3348); // color-cosmic-outline #2A3348
|
||||
}
|
||||
|
||||
/// 소셜 제공자 브랜드 색. 각 사 가이드라인 고정값이라 테마와 무관하게 그대로 쓴다.
|
||||
abstract final class BrandColors {
|
||||
static const kakao = Color(0xFFFEE500); // brand-kakao #FEE500
|
||||
static const kakaoLabel = Color(0xFF000000); // brand-kakao-label #000000
|
||||
static const naver = Color(0xFF03C75A); // brand-naver #03C75A
|
||||
static const naverLabel = Color(0xFFFFFFFF); // brand-naver-label #FFFFFF
|
||||
static const apple = Color(0xFF000000); // brand-apple #000000
|
||||
static const appleLabel = Color(0xFFFFFFFF); // brand-apple-label #FFFFFF
|
||||
static const appleOnlight = Color(0xFFFFFFFF); // brand-apple-onlight #FFFFFF
|
||||
static const googleFill = Color(0xFFFFFFFF); // brand-google-fill #FFFFFF
|
||||
static const googleStroke = Color(0xFF747775); // brand-google-stroke #747775
|
||||
static const googleLabel = Color(0xFF1F1F1F); // brand-google-label #1F1F1F
|
||||
}
|
||||
|
||||
/// 글래스 FX. CSS 전용 표현(glow-*/grad-*/nebula/starfield)은 생성하지 않는다 — gen_tokens.py 헤더 참고.
|
||||
abstract final class AppFx {
|
||||
static const glassFill = Color(0x0DFFFFFF); // glass-fill rgba(255,255,255,.05)
|
||||
static const glassFillStrong = Color(0x14FFFFFF); // glass-fill-strong rgba(255,255,255,.08)
|
||||
static const glassStroke = Color(0x1AFFFFFF); // glass-stroke rgba(255,255,255,.10)
|
||||
static const glassBlur = 16.0; // glass-blur 16px
|
||||
static const orbitStroke = Color(0x1AFFFFFF); // orbit-stroke rgba(255,255,255,.10)
|
||||
}
|
||||
|
||||
/// 여백 스케일. 임의값 금지 — 여기 있는 값에서만 고른다.
|
||||
abstract final class AppSpace {
|
||||
static const s0 = 0.0; // space-0
|
||||
static const s1 = 4.0; // space-1
|
||||
static const s2 = 8.0; // space-2
|
||||
static const s3 = 12.0; // space-3
|
||||
static const s4 = 16.0; // space-4
|
||||
static const s5 = 24.0; // space-5
|
||||
static const s6 = 32.0; // space-6
|
||||
static const s7 = 48.0; // space-7
|
||||
static const s8 = 64.0; // space-8
|
||||
static const s9 = 96.0; // space-9
|
||||
static const card = 24.0; // space-card
|
||||
static const sectionDesktop = 96.0; // space-section-desktop
|
||||
static const sectionMobile = 56.0; // space-section-mobile
|
||||
static const containerMax = 1200.0; // container-max
|
||||
static const gutterDesktop = 32.0; // gutter-desktop
|
||||
static const gutterMobile = 16.0; // gutter-mobile
|
||||
static const measureCh = 70.0; // measure-ch
|
||||
}
|
||||
|
||||
/// 코너 반경.
|
||||
abstract final class AppRadius {
|
||||
static const xs = 8.0; // corner-xs
|
||||
static const sm = 10.0; // corner-sm
|
||||
static const md = 14.0; // corner-md
|
||||
static const lg = 18.0; // corner-lg
|
||||
static const full = 9999.0; // corner-full
|
||||
}
|
||||
|
||||
/// 터치 타깃·본문 최소 크기. app 터치 타깃 48 미만으로 내리지 마라.
|
||||
abstract final class AppSizing {
|
||||
static const touchMinApp = 48.0; // touch-min-app
|
||||
static const touchMinWeb = 36.0; // touch-min-web
|
||||
static const bodyMinApp = 16.0; // body-min-app
|
||||
static const bodyMinWeb = 14.0; // body-min-web
|
||||
}
|
||||
|
||||
/// 오버레이 순서.
|
||||
abstract final class AppZ {
|
||||
static const base = 0; // z-base
|
||||
static const sticky = 1100; // z-sticky
|
||||
static const dropdown = 1200; // z-dropdown
|
||||
static const popover = 1300; // z-popover
|
||||
static const overlay = 1400; // z-overlay
|
||||
static const modal = 1500; // z-modal
|
||||
static const toast = 1700; // z-toast
|
||||
}
|
||||
|
||||
/// 모션 지속시간. 이징 곡선은 Cubic 리터럴로 아래 주석 값을 쓴다.
|
||||
abstract final class AppMotion {
|
||||
static const fast = Duration(milliseconds: 120); // motion-fast
|
||||
static const base = Duration(milliseconds: 180); // motion-base
|
||||
static const slow = Duration(milliseconds: 260); // motion-slow
|
||||
// motion-ease: cubic-bezier(.2,.8,.2,1) (cubic-bezier — Flutter 는 Cubic() 으로 화면에서 조립)
|
||||
// motion-ease-in-out: cubic-bezier(.4,0,.2,1) (cubic-bezier — Flutter 는 Cubic() 으로 화면에서 조립)
|
||||
}
|
||||
|
||||
/// 폰트 패밀리명. 폰트 파일은 리포에 없다 — 미투입 시 시스템 폰트로 폴백된다(README 「미투입 항목」).
|
||||
abstract final class AppFonts {
|
||||
static const display = 'Gmarket Sans'; // font-display = 'Gmarket Sans', Pretendard Variable, sans-serif
|
||||
static const body = 'Pretendard Variable'; // font-body = Pretendard Variable, system-ui, sans-serif
|
||||
static const mono = 'JetBrains Mono'; // font-mono = JetBrains Mono, IBM Plex Mono, ui-monospace, monospace
|
||||
// font-source: Gmarket Sans = webfontworld/jsdelivr · 본문 Pretendard = jsdelivr
|
||||
}
|
||||
|
||||
/// 타입 스케일. 값은 design-tokens.md 의 type 블록 그대로다.
|
||||
abstract final class AppType {
|
||||
static const displayL = TypeToken(family: AppFonts.display, size: 44.0, weight: 700, height: 1.06, letterSpacing: -0.44); // type-display-l
|
||||
static const displayM = TypeToken(family: AppFonts.display, size: 32.0, weight: 700, height: 1.11, letterSpacing: -0.32); // type-display-m
|
||||
static const titleL = TypeToken(family: AppFonts.display, size: 24.0, weight: 700, height: 1.2, letterSpacing: -0.24); // type-title-l
|
||||
static const titleM = TypeToken(family: AppFonts.body, size: 18.0, weight: 600, height: 1.3); // type-title-m
|
||||
static const bodyL = TypeToken(family: AppFonts.body, size: 18.0, weight: 400, height: 1.6); // type-body-l
|
||||
static const bodyM = TypeToken(family: AppFonts.body, size: 16.0, weight: 400, height: 1.6); // type-body-m
|
||||
static const bodyS = TypeToken(family: AppFonts.body, size: 14.0, weight: 400, height: 1.55); // type-body-s
|
||||
static const labelL = TypeToken(family: AppFonts.body, size: 14.0, weight: 500, height: 1.2); // type-label-l
|
||||
static const eyebrow = TypeToken(family: AppFonts.body, size: 12.0, weight: 600, letterSpacing: 0.72); // type-eyebrow
|
||||
static const caption = TypeToken(family: AppFonts.body, size: 12.0, weight: 500, height: 1.4); // type-caption
|
||||
// type-mono-num: {'family': 'font-mono', 'feature': 'tnum', 'note': '가격·지표·표는 tabular'} (크기 없는 지시자 — TextStyle 로 못 옮긴다)
|
||||
}
|
||||
|
||||
/// 타입 토큰 1개. app_theme.dart 가 TextStyle 로 변환한다.
|
||||
class TypeToken {
|
||||
const TypeToken({
|
||||
required this.family,
|
||||
required this.size,
|
||||
required this.weight,
|
||||
this.height,
|
||||
this.letterSpacing,
|
||||
});
|
||||
|
||||
final String family;
|
||||
final double size;
|
||||
final int weight;
|
||||
final double? height;
|
||||
final double? letterSpacing;
|
||||
}
|
||||
|
||||
// 생성 제외된 cosmic_fx: glow-sm, glow-md, glow-lg, glow-accent, glow-cyan, glow-emerald, glow-magenta, grad-action, grad-brand, grad-eclipse, grad-veil, nebula, starfield
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/tokens.dart';
|
||||
|
||||
/// 탭 루트 화면의 플레이스홀더.
|
||||
///
|
||||
/// 2주차 범위는 「앱 골격 · 5탭 네비게이션 · 소셜 로그인 · 디자인시스템」이다(별첨2).
|
||||
/// 각 탭의 실제 화면은 3주차 이후이므로, 여기서는 어느 화면이 들어올 자리인지와
|
||||
/// 근거 명세 경로만 보여 준다. 빈 화면을 두면 검수 때 무엇이 빠진 건지 알 수 없다.
|
||||
class TabPlaceholder extends StatelessWidget {
|
||||
const TabPlaceholder({
|
||||
super.key,
|
||||
required this.tab,
|
||||
required this.screenId,
|
||||
required this.screenName,
|
||||
required this.specPath,
|
||||
});
|
||||
|
||||
/// 계약 고정 탭 라벨(견적서 v2.1 5탭).
|
||||
final String tab;
|
||||
final String screenId;
|
||||
final String screenName;
|
||||
final String specPath;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final text = Theme.of(context).textTheme;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(screenName)),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpace.s5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(tab, style: text.labelMedium),
|
||||
const SizedBox(height: AppSpace.s2),
|
||||
Text(screenName, style: text.displayMedium),
|
||||
const SizedBox(height: AppSpace.s4),
|
||||
Text(screenId, style: text.titleMedium),
|
||||
const SizedBox(height: AppSpace.s5),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cosmicSurfaceContainer,
|
||||
border: Border.all(color: AppColors.cosmicOutline),
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('2주차 범위: 탭 골격까지', style: text.bodyMedium),
|
||||
const SizedBox(height: AppSpace.s2),
|
||||
Text('화면 구현은 3주차 이후', style: text.bodySmall),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
Text(specPath, style: text.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/// 인증 계약 — 제공자 인터페이스와 결과 타입.
|
||||
///
|
||||
/// 원칙: **"키 미투입"은 예외가 아니라 상태다.** 카카오·네이버는 발주사 사업자 정보로
|
||||
/// 개발자 콘솔에 앱을 등록해야 키가 나오고(Q3), Google·Apple 은 Firebase 설정 파일이
|
||||
/// 있어야 한다(S1·S2). 둘 다 "아직 준비가 안 됐다"이지 "지원하지 않는다"가 아니므로
|
||||
/// 예외를 던지지 않고 [AuthNotConfigured] 로 돌려준다. 화면은 이걸 안내로 보여 준다.
|
||||
library;
|
||||
|
||||
/// 소셜 제공자. **순서와 라벨은 시안 `시안/screens/S-A-LOGIN.html` 의 버튼 그대로다.**
|
||||
/// 화면명세 `wireframe/planning/screen-spec/onboard/S-A-LOGIN.md` 도 4종으로 같다.
|
||||
enum SocialProvider {
|
||||
kakao('카카오로 시작하기'),
|
||||
naver('네이버로 시작하기'),
|
||||
apple('Apple로 시작하기'),
|
||||
google('Google로 시작하기');
|
||||
|
||||
const SocialProvider(this.label);
|
||||
|
||||
/// 버튼 라벨. 임의로 바꾸지 마라 — 시안 대조 대상이다.
|
||||
final String label;
|
||||
}
|
||||
|
||||
// 이메일 가입에 대하여 — 기능 플래그를 두지 않았다.
|
||||
//
|
||||
// CD-18(가입 수단) 미확정(2주차_확인요청항목 Q2)이라 이메일 경로는 구현하지 않았다.
|
||||
// 플래그 상수를 하나 선언해 두는 방법도 있었지만, 그 값을 읽는 분기가 없으면
|
||||
// 「false 라서 닫혀 있다」는 착각만 만든다 — 누군가 true 로 바꿔도 아무 일이 일어나지 않는다.
|
||||
// 지금 이메일을 닫고 있는 것은 플래그가 아니라 AuthRepository 의 제공자 목록에
|
||||
// 이메일 구현체가 없다는 사실 하나다.
|
||||
//
|
||||
// Q2 가 「이메일 병행」으로 오면 할 일: [SocialAuthProvider] 를 구현한 이메일 제공자를 만들고
|
||||
// AuthRepository._defaultProviders() 에 추가한다. 화면 수·약관 동선도 함께 바뀐다.
|
||||
|
||||
/// 로그인된 계정.
|
||||
class AuthAccount {
|
||||
const AuthAccount({
|
||||
required this.uid,
|
||||
required this.provider,
|
||||
this.displayName,
|
||||
this.email,
|
||||
this.isMock = false,
|
||||
});
|
||||
|
||||
final String uid;
|
||||
final SocialProvider? provider;
|
||||
final String? displayName;
|
||||
final String? email;
|
||||
|
||||
/// Firebase 미설정 상태에서 만든 임시 세션인지. 실 세션과 절대 섞이면 안 된다.
|
||||
final bool isMock;
|
||||
}
|
||||
|
||||
/// 로그인 시도 결과.
|
||||
sealed class AuthOutcome {
|
||||
const AuthOutcome();
|
||||
}
|
||||
|
||||
final class AuthSucceeded extends AuthOutcome {
|
||||
const AuthSucceeded(this.account);
|
||||
|
||||
final AuthAccount account;
|
||||
}
|
||||
|
||||
/// 사용자가 취소함. 에러로 취급하지 않는다.
|
||||
final class AuthCancelled extends AuthOutcome {
|
||||
const AuthCancelled();
|
||||
}
|
||||
|
||||
/// 실패. [code] 는 `wireframe/planning/error-catalog.yaml` 의 ID 다(소셜 실패 = E-AUTH-03).
|
||||
final class AuthFailed extends AuthOutcome {
|
||||
const AuthFailed(this.code, this.message);
|
||||
|
||||
final String code;
|
||||
final String message;
|
||||
}
|
||||
|
||||
/// 아직 쓸 수 없음 — 키·설정 파일 미투입.
|
||||
final class AuthNotConfigured extends AuthOutcome {
|
||||
const AuthNotConfigured(this.provider, this.missing);
|
||||
|
||||
final SocialProvider provider;
|
||||
|
||||
/// 투입해야 하는 것들(파일명·키명). 화면과 README 에 그대로 노출한다.
|
||||
final List<String> missing;
|
||||
}
|
||||
|
||||
/// 소셜 로그인 제공자 인터페이스.
|
||||
///
|
||||
/// 이메일 가입이 확정되면(Q2) 같은 인터페이스로 구현체 하나가 더 붙는 자리다.
|
||||
abstract interface class SocialAuthProvider {
|
||||
SocialProvider get provider;
|
||||
|
||||
/// 실 연동 코드 경로를 가지고 있는지. 스텁이면 false.
|
||||
bool get isLive;
|
||||
|
||||
/// 비어 있지 않으면 지금은 로그인할 수 없다. [signIn] 은 [AuthNotConfigured] 를 낸다.
|
||||
List<String> get missingConfig;
|
||||
|
||||
Future<AuthOutcome> signIn();
|
||||
|
||||
Future<void> signOut();
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../core/firebase/firebase_boot.dart';
|
||||
import 'auth.dart';
|
||||
import 'social_providers.dart';
|
||||
|
||||
/// 로그인 상태 보관소. 라우터가 이걸 듣고 로그인/셸을 가른다.
|
||||
class AuthRepository extends ChangeNotifier {
|
||||
AuthRepository({List<SocialAuthProvider>? providers, bool? releaseMode})
|
||||
: providers = providers ?? _defaultProviders(),
|
||||
// 기본값은 실제 빌드 모드. 테스트에서만 주입해 릴리스 동작을 재현한다
|
||||
// (kReleaseMode 는 컴파일 상수라 테스트에서 뒤집을 수 없다).
|
||||
_releaseMode = releaseMode ?? kReleaseMode;
|
||||
|
||||
final bool _releaseMode;
|
||||
|
||||
/// 로그인 화면에 그릴 제공자 목록. 순서는 시안 S-A-LOGIN 버튼 순서다.
|
||||
final List<SocialAuthProvider> providers;
|
||||
|
||||
AuthAccount? _account;
|
||||
AuthAccount? get account => _account;
|
||||
bool get isSignedIn => _account != null;
|
||||
|
||||
static List<SocialAuthProvider> _defaultProviders() => const [
|
||||
KeylessSocialProvider(SocialProvider.kakao, [
|
||||
'카카오 네이티브 앱 키',
|
||||
'카카오 개발자 콘솔 앱 등록(사업자 정보 필요)',
|
||||
]),
|
||||
KeylessSocialProvider(SocialProvider.naver, [
|
||||
'네이버 client ID / client secret',
|
||||
'네이버 개발자센터 앱 등록(사업자 정보 필요)',
|
||||
]),
|
||||
AppleSignInProvider(),
|
||||
GoogleSignInProvider(),
|
||||
// 이메일 가입 구현체는 여기 없다. CD-18 미확정(Q2) — 회신이 오면 이 목록에 추가한다.
|
||||
// 자세한 사유는 auth.dart 상단 「이메일 가입에 대하여」 참고.
|
||||
];
|
||||
|
||||
Future<AuthOutcome> signIn(SocialProvider provider) async {
|
||||
final impl = providers.firstWhere((p) => p.provider == provider);
|
||||
final outcome = await impl.signIn();
|
||||
if (outcome is AuthSucceeded) {
|
||||
_account = outcome.account;
|
||||
notifyListeners();
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/// Firebase 미설정 상태에서 탭 이동을 시연하기 위한 임시 세션.
|
||||
///
|
||||
/// 별첨2 2주차 발주사 확인 항목에 「앱 설치·로그인·탭 이동 확인」이 있는데,
|
||||
/// 설정 파일이 없으면 실 로그인이 물리적으로 불가능하다. 그래서 목 모드에서만
|
||||
/// 이 경로를 열어 둔다.
|
||||
///
|
||||
/// **가드가 두 겹인 이유** — `!FirebaseBoot.isLive` 하나로는 부족하다.
|
||||
/// [FirebaseBoot.init] 은 모든 예외를 삼키고 목 모드로 내려가므로, 설정 파일이
|
||||
/// 정상 투입된 릴리스 빌드라도 초기화가 어떤 이유로든 실패하면(플랫폼 초기화 실패,
|
||||
/// 리소스 패키징 오류, 기기 환경) 이 우회 입구가 배포 앱에 노출된다.
|
||||
/// 그래서 빌드 모드로 한 겹 더 막는다 — **릴리스 빌드에서는 어떤 경우에도 열리지 않는다.**
|
||||
bool get isMockSessionAvailable => !FirebaseBoot.isLive && !_releaseMode;
|
||||
|
||||
void signInWithMockSession() {
|
||||
if (!isMockSessionAvailable) return;
|
||||
_account = const AuthAccount(
|
||||
uid: 'mock-local',
|
||||
provider: null,
|
||||
displayName: '목 세션',
|
||||
isMock: true,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
for (final p in providers) {
|
||||
await p.signOut();
|
||||
}
|
||||
_account = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/firebase/firebase_boot.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
import 'auth.dart';
|
||||
import 'auth_repository.dart';
|
||||
|
||||
/// S-A-LOGIN 로그인.
|
||||
///
|
||||
/// 시안: `시안/screens/S-A-LOGIN.html` · 명세: `wireframe/planning/screen-spec/onboard/S-A-LOGIN.md`
|
||||
/// 골격 범위라 4개 버튼과 결과 안내까지만 구현했다. 약관 동의(S-A-TERMS) 이후 동선은 3주차다.
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key, required this.auth});
|
||||
|
||||
final AuthRepository auth;
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
String? _notice;
|
||||
bool _busy = false;
|
||||
|
||||
Future<void> _signIn(SocialProvider provider) async {
|
||||
setState(() => _busy = true);
|
||||
final outcome = await widget.auth.signIn(provider);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_notice = switch (outcome) {
|
||||
AuthSucceeded() => null,
|
||||
AuthCancelled() => null,
|
||||
AuthFailed(:final code, :final message) => '[$code] $message',
|
||||
AuthNotConfigured(:final provider, :final missing) =>
|
||||
'${provider.label.replaceAll('로 시작하기', '')} 로그인은 아직 준비 중입니다.\n'
|
||||
'필요한 항목: ${missing.join(' · ')}',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final text = Theme.of(context).textTheme;
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppSpace.s5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: AppSpace.s6),
|
||||
// 로고 원본은 미확정(Q6)이라 워드마크를 텍스트로 둔다. 확정되면 이미지로 교체.
|
||||
Text('Fancake', style: text.displayMedium),
|
||||
const SizedBox(height: AppSpace.s4),
|
||||
Text('당신의 팬 활동을\n하나의 행성으로', style: text.displayLarge),
|
||||
const SizedBox(height: AppSpace.s4),
|
||||
Text(
|
||||
'로그인하면 이용약관과 개인정보 처리방침에 동의하는 절차로 이동합니다.',
|
||||
style: text.bodyMedium?.copyWith(color: AppColors.onCosmicVariant),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s7),
|
||||
Text('계정으로 시작하기', style: text.titleLarge),
|
||||
const SizedBox(height: AppSpace.s2),
|
||||
Text(
|
||||
'사용 중인 계정 하나를 선택해 주세요.',
|
||||
style: text.bodySmall,
|
||||
),
|
||||
const SizedBox(height: AppSpace.s4),
|
||||
for (final p in widget.auth.providers) ...[
|
||||
_ProviderButton(
|
||||
provider: p.provider,
|
||||
enabled: !_busy,
|
||||
onPressed: () => _signIn(p.provider),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
],
|
||||
if (_notice != null) ...[
|
||||
const SizedBox(height: AppSpace.s2),
|
||||
_Notice(_notice!),
|
||||
],
|
||||
if (widget.auth.isMockSessionAvailable) ...[
|
||||
const SizedBox(height: AppSpace.s5),
|
||||
const Divider(),
|
||||
const SizedBox(height: AppSpace.s4),
|
||||
_MockBanner(onEnter: widget.auth.signInWithMockSession),
|
||||
],
|
||||
const SizedBox(height: AppSpace.s6),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 소셜 버튼. 색과 라벨은 각 사 브랜드 가이드 고정값이라 테마를 타지 않는다.
|
||||
class _ProviderButton extends StatelessWidget {
|
||||
const _ProviderButton({
|
||||
required this.provider,
|
||||
required this.enabled,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final SocialProvider provider;
|
||||
final bool enabled;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (fill, label, stroke) = switch (provider) {
|
||||
SocialProvider.kakao => (BrandColors.kakao, BrandColors.kakaoLabel, null),
|
||||
SocialProvider.naver => (BrandColors.naver, BrandColors.naverLabel, null),
|
||||
SocialProvider.apple => (
|
||||
BrandColors.apple,
|
||||
BrandColors.appleLabel,
|
||||
BrandColors.appleOnlight,
|
||||
),
|
||||
SocialProvider.google => (
|
||||
BrandColors.googleFill,
|
||||
BrandColors.googleLabel,
|
||||
BrandColors.googleStroke,
|
||||
),
|
||||
};
|
||||
return FilledButton(
|
||||
onPressed: enabled ? onPressed : null,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: fill,
|
||||
foregroundColor: label,
|
||||
disabledBackgroundColor: fill.withValues(alpha: 0.4),
|
||||
side: stroke == null ? null : BorderSide(color: stroke),
|
||||
),
|
||||
child: Text(provider.label),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Notice extends StatelessWidget {
|
||||
const _Notice(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cosmicSurfaceContainer,
|
||||
border: Border.all(color: AppColors.cosmicOutline),
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
child: Text(message, style: Theme.of(context).textTheme.bodySmall),
|
||||
);
|
||||
}
|
||||
|
||||
/// Firebase 미설정 안내 + 탭 이동 시연 입구.
|
||||
class _MockBanner extends StatelessWidget {
|
||||
const _MockBanner({required this.onEnter});
|
||||
|
||||
final VoidCallback onEnter;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final text = Theme.of(context).textTheme;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('목(mock) 모드', style: text.labelMedium),
|
||||
const SizedBox(height: AppSpace.s2),
|
||||
Text(FirebaseBoot.reason, style: text.bodySmall),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
OutlinedButton(
|
||||
onPressed: onEnter,
|
||||
child: const Text('목 세션으로 탭 둘러보기'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import 'package:firebase_auth/firebase_auth.dart' as fb;
|
||||
import 'package:google_sign_in/google_sign_in.dart' as gsi;
|
||||
import 'package:sign_in_with_apple/sign_in_with_apple.dart' as apple;
|
||||
|
||||
import '../../core/firebase/firebase_boot.dart';
|
||||
import 'auth.dart';
|
||||
|
||||
/// Google 로그인 — 실 코드 경로.
|
||||
///
|
||||
/// Firebase 설정 파일이 없으면 SDK 를 부르기 전에 [AuthNotConfigured] 로 끊는다.
|
||||
/// 파일이 투입되면(S2) 이 클래스는 수정 없이 그대로 동작한다.
|
||||
final class GoogleSignInProvider implements SocialAuthProvider {
|
||||
const GoogleSignInProvider();
|
||||
|
||||
@override
|
||||
SocialProvider get provider => SocialProvider.google;
|
||||
|
||||
@override
|
||||
bool get isLive => true;
|
||||
|
||||
// 화면에 그대로 노출되는 목록이라 리포 경로가 아니라 사람이 읽는 이름을 쓴다.
|
||||
// 실제 파일 경로는 FirebaseBoot.detail 로 로그에만 남는다.
|
||||
@override
|
||||
List<String> get missingConfig =>
|
||||
FirebaseBoot.isLive ? const [] : FirebaseBoot.missingLabels;
|
||||
|
||||
@override
|
||||
Future<AuthOutcome> signIn() async {
|
||||
final missing = missingConfig;
|
||||
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
|
||||
|
||||
try {
|
||||
final signIn = gsi.GoogleSignIn.instance;
|
||||
// clientId/serverClientId 는 설정 파일에서 읽힌다 — 여기서 하드코딩하지 않는다.
|
||||
await signIn.initialize();
|
||||
final account = await signIn.authenticate();
|
||||
final idToken = account.authentication.idToken;
|
||||
if (idToken == null) {
|
||||
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
|
||||
}
|
||||
final credential = fb.GoogleAuthProvider.credential(idToken: idToken);
|
||||
final result = await fb.FirebaseAuth.instance.signInWithCredential(credential);
|
||||
return AuthSucceeded(_toAccount(result.user, provider));
|
||||
} on gsi.GoogleSignInException catch (error) {
|
||||
if (error.code == gsi.GoogleSignInExceptionCode.canceled) {
|
||||
return const AuthCancelled();
|
||||
}
|
||||
return AuthFailed('E-AUTH-03', error.description ?? error.code.name);
|
||||
} on fb.FirebaseAuthException catch (error) {
|
||||
return AuthFailed('E-AUTH-03', error.message ?? error.code);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
if (!FirebaseBoot.isLive) return;
|
||||
await gsi.GoogleSignIn.instance.signOut();
|
||||
await fb.FirebaseAuth.instance.signOut();
|
||||
}
|
||||
}
|
||||
|
||||
/// Apple 로그인 — 실 코드 경로.
|
||||
///
|
||||
/// iOS 는 Xcode 에서 「Sign in with Apple」 Capability 를, Android·Web 은
|
||||
/// Apple Developer 콘솔의 Service ID + redirect URI 를 각각 요구한다.
|
||||
/// 둘 다 스토어 계정 명의가 정해져야(Q5) 발급할 수 있어 지금은 설정 파일 게이트에서 끊긴다.
|
||||
final class AppleSignInProvider implements SocialAuthProvider {
|
||||
const AppleSignInProvider();
|
||||
|
||||
@override
|
||||
SocialProvider get provider => SocialProvider.apple;
|
||||
|
||||
@override
|
||||
bool get isLive => true;
|
||||
|
||||
@override
|
||||
List<String> get missingConfig => FirebaseBoot.isLive
|
||||
? const []
|
||||
: [...FirebaseBoot.missingLabels, 'Apple 개발자 설정'];
|
||||
|
||||
@override
|
||||
Future<AuthOutcome> signIn() async {
|
||||
final missing = missingConfig;
|
||||
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
|
||||
|
||||
if (!await apple.SignInWithApple.isAvailable()) {
|
||||
return const AuthFailed('E-AUTH-03', '이 기기에서는 Apple 로그인을 쓸 수 없습니다.');
|
||||
}
|
||||
try {
|
||||
final credential = await apple.SignInWithApple.getAppleIDCredential(
|
||||
scopes: const [
|
||||
apple.AppleIDAuthorizationScopes.email,
|
||||
apple.AppleIDAuthorizationScopes.fullName,
|
||||
],
|
||||
);
|
||||
final idToken = credential.identityToken;
|
||||
if (idToken == null) {
|
||||
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
|
||||
}
|
||||
final oauth = fb.OAuthProvider('apple.com').credential(
|
||||
idToken: idToken,
|
||||
accessToken: credential.authorizationCode,
|
||||
);
|
||||
final result = await fb.FirebaseAuth.instance.signInWithCredential(oauth);
|
||||
return AuthSucceeded(_toAccount(result.user, provider));
|
||||
} on apple.SignInWithAppleAuthorizationException catch (error) {
|
||||
if (error.code == apple.AuthorizationErrorCode.canceled) {
|
||||
return const AuthCancelled();
|
||||
}
|
||||
return AuthFailed('E-AUTH-03', error.message);
|
||||
} on fb.FirebaseAuthException catch (error) {
|
||||
return AuthFailed('E-AUTH-03', error.message ?? error.code);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
if (!FirebaseBoot.isLive) return;
|
||||
await fb.FirebaseAuth.instance.signOut();
|
||||
}
|
||||
}
|
||||
|
||||
/// 앱 키가 없어 아직 붙이지 못한 제공자 — 카카오·네이버.
|
||||
///
|
||||
/// 예외를 던지지 않는다. [AuthNotConfigured] 로 "무엇이 없어서 못 쓰는지"를 돌려준다.
|
||||
/// 키가 들어오면 이 자리에 실 구현체를 넣고 [AuthRepository] 목록만 바꾸면 된다.
|
||||
final class KeylessSocialProvider implements SocialAuthProvider {
|
||||
const KeylessSocialProvider(this.provider, this.missingConfig);
|
||||
|
||||
@override
|
||||
final SocialProvider provider;
|
||||
|
||||
@override
|
||||
final List<String> missingConfig;
|
||||
|
||||
@override
|
||||
bool get isLive => false;
|
||||
|
||||
@override
|
||||
Future<AuthOutcome> signIn() async => AuthNotConfigured(provider, missingConfig);
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {}
|
||||
}
|
||||
|
||||
AuthAccount _toAccount(fb.User? user, SocialProvider provider) => AuthAccount(
|
||||
uid: user?.uid ?? '',
|
||||
provider: provider,
|
||||
displayName: user?.displayName,
|
||||
email: user?.email,
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/ui/tab_placeholder.dart';
|
||||
|
||||
/// GALAXY 탭 루트 — S-G-PLANET 내 행성.
|
||||
class GalaxyScreen extends StatelessWidget {
|
||||
const GalaxyScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const TabPlaceholder(
|
||||
tab: 'GALAXY',
|
||||
screenId: 'S-G-PLANET',
|
||||
screenName: '내 행성',
|
||||
specPath: 'wireframe/planning/screen-spec/galaxy/S-G-PLANET.md',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/ui/tab_placeholder.dart';
|
||||
|
||||
/// HOME 탭 루트 — S-H-HOME 오늘의 퀘스트.
|
||||
class HomeScreen extends StatelessWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const TabPlaceholder(
|
||||
tab: 'HOME',
|
||||
screenId: 'S-H-HOME',
|
||||
screenName: '오늘의 퀘스트',
|
||||
specPath: 'wireframe/planning/screen-spec/home/S-H-HOME.md',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/ui/tab_placeholder.dart';
|
||||
|
||||
/// INSIDE 탭 루트 — S-I-LOUNGE 팬덤 라운지.
|
||||
class InsideScreen extends StatelessWidget {
|
||||
const InsideScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const TabPlaceholder(
|
||||
tab: 'INSIDE',
|
||||
screenId: 'S-I-LOUNGE',
|
||||
screenName: '팬덤 라운지',
|
||||
specPath: 'wireframe/planning/screen-spec/inside/S-I-LOUNGE.md',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/ui/tab_placeholder.dart';
|
||||
|
||||
/// MY 탭 루트 — S-M-HOME 마이 페이지.
|
||||
class MyScreen extends StatelessWidget {
|
||||
const MyScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const TabPlaceholder(
|
||||
tab: 'MY',
|
||||
screenId: 'S-M-HOME',
|
||||
screenName: '마이 페이지',
|
||||
specPath: 'wireframe/planning/screen-spec/my/S-M-HOME.md',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/ui/tab_placeholder.dart';
|
||||
|
||||
/// STATION 탭 루트 — S-S-HUB STATION 허브.
|
||||
class StationScreen extends StatelessWidget {
|
||||
const StationScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const TabPlaceholder(
|
||||
tab: 'STATION',
|
||||
screenId: 'S-S-HUB',
|
||||
screenName: 'STATION 허브',
|
||||
specPath: 'wireframe/planning/screen-spec/station/S-S-HUB.md',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'app.dart';
|
||||
import 'core/firebase/firebase_boot.dart';
|
||||
import 'features/auth/auth_repository.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
// 설정 파일이 없으면 목 모드로 내려간다. 여기서 throw 하지 않는다 — FirebaseBoot 참고.
|
||||
await FirebaseBoot.init();
|
||||
runApp(FancakeApp(auth: AuthRepository()));
|
||||
}
|
||||
Reference in New Issue
Block a user