# FANUZU 앱 골격 (Flutter) > 2주차 산출물 · 발주사 (주)팬케이크 / 수행사 스위치 주식회사(Sweech) > 별첨2 2주차 「첫 빌드(앱 골격)」의 빌드 포함 범위 = **Flutter 앱 골격 · 5탭 네비게이션 · 소셜 로그인 · 디자인시스템** ## 1. 이 골격의 범위 | | 내용 | |---|---| | **포함** | 앱 기동 · 코스믹 다크 테마(design-tokens.md 생성) · 탭 셸 네비게이션 · 로그인 화면(S-A-LOGIN) 소셜 4종 버튼 · 인증 인터페이스와 Google/Apple 실 코드 경로 · 계정 연결(linkWithCredential) · Firebase 초기화(미설정 시 목 모드) | | **미포함** | 각 탭의 실제 화면(3주차 이후) · 온보딩 나머지 6화면(S-A-INTRO · S-A-TERMS · S-A-PROFILE · S-A-ARTIST · S-A-PLANET-NEW · S-A-PERMS) · 도메인 로직(POP·FPI·퀘스트·이클립스) · 상태관리 라이브러리 | 미포함 항목을 지금 만들지 않은 이유: **RN/Flutter 승인이 아직 없다(2주차_확인요청항목 Q1).** 미승인 시 폐기할 수 있도록 골격과 도메인 로직을 섞지 않았다. 5탭 라벨·화면 ID·토큰처럼 언어와 무관한 계약 정보만 코드에 들어가 있다. ## 2. 빌드·실행 전제: Flutter stable 3.41.7 / Dart 3.11.5 (검증에 쓴 버전), Android SDK 36, Xcode 26.6 ```bash cd app flutter pub get flutter run # 실기기·시뮬레이터 flutter analyze # 정적 분석 flutter test # 스모크·인증 상태 전이·회귀 테스트 19건 flutter build apk --debug # Android 디버그 APK ``` 빌드 산출물은 `build/app/outputs/flutter-apk/app-debug.apk` 에 떨어진다. > ⚠️ **`pubspec.yaml` 의 의존성을 추가·변경·업그레이드하면 최소 지원 OS 하한을 재계산할 것.** > 절차: `주간보고/2주차/src/2주차_플러그인_선검증리포트.md` §3.5 > > 최소 지원 OS 는 **별첨1 v1.0 에 계약값으로 박힌다**(견적서 1228행). 지킬 수 없는 하한을 > 계약에 적으면 그 아래 단말의 장애가 개발사 책임이 된다. 선검증 리포트의 값은 > 2026-08-08 시점 측정치이므로, 이 앱의 의존성이 바뀌면 그 값은 그 순간 거짓이 된다. > > 2026-08-08 기준 합집합: **Android API 29 · iOS 15.0** > (이 프로젝트 단독값은 Android API 24 · iOS 15.0 — Android 29 는 `geofence_foreground_service`, > iOS 15.0 은 `firebase_auth` 가 올린 값이다. 퀘스트 플러그인이 들어오는 6주차 전에 `minSdk` 를 29 로 올려야 한다.) ## 3. 디자인 토큰 — 손으로 옮기지 않는다 `lib/core/theme/tokens.dart` 는 **생성물**이다. 직접 고치면 시안(`시안/assets/ds.css`)과 어긋난다. ```bash cd app python3 tool/gen_tokens.py # PyYAML 필요: pip install pyyaml ``` * 입력: `design/design-tokens.md` 의 `` 아래 yaml 블록 (SoT) * 출력: `lib/core/theme/tokens.dart` * 부수 검증: `design-tokens.md` 의 `contrast_checks` 22쌍을 WCAG 로 재계산해 표로 출력하고, **AA(4.5:1) 미달이 하나라도 있으면 exit 1**. 테마가 대비를 깎았는지 여기서 걸린다. * `lib/core/theme/app_theme.dart` 가 이 토큰으로 `ThemeData` 를 조립한다. 색·크기를 여기서 새로 만들지 마라. **생성하지 않는 토큰** — CSS 전용 표현이라 Flutter 등가물이 없다: `cosmic_fx.glow-*` · `grad-*` · `nebula` · `starfield` · `elevation.*`. 필요해지는 주차에 `tool/gen_tokens.py` 헤더의 목록에서 하나씩 내려서 추가한다. ## 4. Firebase — 지금은 목(mock) 모드 `google-services.json` / `GoogleService-Info.plist` 가 아직 없다. **가짜 파일을 만들어 두지 않았다.** `lib/core/firebase/firebase_boot.dart` 가 `Firebase.initializeApp()` 을 시도하고, 실패하면 `FirebaseMode.mock` 으로 내려간 뒤 사유를 로그(`[FirebaseBoot] mode=mock :: ...`)로 남긴다. 앱은 정상적으로 뜬다. 파일이 투입되면 **코드 수정 없이** `FirebaseMode.live` 로 올라가고, 목 모드 전용 UI는 자동으로 사라진다. ### 목 모드에서의 로그인 Firebase 설정이 없으면 실 소셜 로그인이 물리적으로 불가능하다. 그런데 별첨2 2주차 발주사 확인 항목에 「앱 설치·**로그인**·탭 이동 확인」이 있다. 그래서 로그인 화면 하단에 **「목 세션으로 탭 둘러보기」** 입구를 목 모드에서만 노출한다. * 화면에 목 모드임과 그 사유가 함께 표시된다 — 실 로그인으로 오인할 여지가 없다. * `FirebaseBoot.reason` 은 사용자에게 보이는 문자열이라 **내부 파일 경로를 넣지 않는다.** 경로와 원시 예외는 `FirebaseBoot.detail` 로 분리해 로그에만 남긴다. **가드가 두 겹인 이유 (교차검증 A-01 반영)** ```dart bool get isMockSessionAvailable => !FirebaseBoot.isLive && !_releaseMode; ``` 처음에는 `!FirebaseBoot.isLive` 한 겹만 두고 "설정 파일이 들어오면 자동으로 막힌다" 고 적었는데, **그 설명은 틀렸다.** `FirebaseBoot.init()` 은 모든 예외를 삼키고 목 모드로 내려가므로, 설정 파일이 정상 투입된 릴리스 빌드라도 초기화가 어떤 이유로든 실패하면(플랫폼 초기화 실패, 리소스 패키징 오류, 기기 환경) 목 모드가 되고 우회 입구가 배포 앱에 노출된다. 그래서 빌드 모드로 한 겹 더 막았다. **릴리스 빌드에서는 어떤 경우에도 열리지 않는다.** `kReleaseMode` 는 컴파일 상수라 테스트에서 뒤집을 수 없어, `AuthRepository({bool? releaseMode})` 로 주입 지점을 열어 두고 릴리스 동작을 테스트로 고정했다(회귀 가드 4종). 가드를 지우면 테스트 3개가 깨진다 — 확인했다. ## 5. 미투입 항목 ### 5-1. 스위치(개발사) 내부 조치 — 발주사 요청 사항 아님 | ID | 항목 | 투입 위치 | 없으면 | |---|---|---|---| | S1 | Firebase 프로젝트(dev/prod) 개설 | — | 아래 S2 를 만들 수 없다 | | S2 | `google-services.json` | `app/android/app/google-services.json` | 목 모드로 기동. Google 로그인 차단 | | S2 | `GoogleService-Info.plist` | `app/ios/Runner/GoogleService-Info.plist` | 목 모드로 기동. Google·Apple 로그인 차단 | > S2 투입 시 Android 는 `android/settings.gradle.kts` 에 `com.google.gms.google-services` 플러그인 > 추가가 함께 필요하다. 지금은 설정 파일이 없어 플러그인을 적용하지 않았다 — > 적용한 채로 파일이 없으면 Gradle 빌드 자체가 실패한다. ### 5-2. 발주사 확인 필요 | ID | 항목 | 투입 위치 | 없으면 | |---|---|---|---| | Q3 | 카카오 네이티브 앱 키 | 미정(플러그인 도입 시 확정) | 카카오 버튼이 「키 대기」 안내 다이얼로그를 띄운다 | | Q3 | LINE Login 채널 ID / 시크릿 | 미정 | LINE 버튼이 「키 대기」 안내 다이얼로그를 띄운다 | | Q5 | 스토어 등록 명의 | — | 3주차 TestFlight·Play 내부트랙 제출 불가 | | ~~Q14~~ | 앱 식별자 확정 | `android/app/build.gradle.kts` · `ios/Runner.xcodeproj` | **2026-09-11 확정 — 별첨 C 4번 `kr.co.fanuzu.app`.** Android applicationId·namespace·Kotlin 패키지, iOS bundle id 를 모두 옮겼다. **스토어 등록 후에는 변경 불가** | | ~~Q6~~ | 공식 FANUZU 심볼 원본 | `assets/brand/` | **2026-08-13 반영 완료.** 제공본이 심볼 전용이라 앱 아이콘은 공식 자산으로 생성했고 로그인 워드마크는 `FANUZU` 텍스트를 유지 | | Q15 | 하단 탭 아이콘 자산 | `assets/icons/` | Material 기본 아이콘 5종으로 잠정 | ### 5-3. 폰트 폰트 파일이 리포에 없다. `pubspec.yaml` 의 `fonts:` 블록이 **주석 처리된 채로** 투입 위치만 표시돼 있다. | 용도 | 패밀리 | 투입 위치 | 출처 | |---|---|---|---| | 제목 | Gmarket Sans | `assets/fonts/GmarketSansTTF{Light,Medium,Bold}.ttf` | webfontworld/gmarket | | 본문 | Pretendard Variable | `assets/fonts/PretendardVariable.ttf` | orioncactus/pretendard | | 수치 | JetBrains Mono | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains | 미투입 상태에서는 **시스템 폰트로 폴백**된다. 크기·굵기·행간·자간은 토큰 값 그대로 적용되므로 투입 전후 레이아웃 차이는 자소 폭 수준에 그친다. ## 6. 로그인 — 별첨 B v1.4 확정 4종 > 1차 로그인은 Google / Apple / Kakao / LINE 4종입니다. 이메일/비밀번호 가입 및 X 로그인은 > 제외합니다. 하나의 FANUZU UID 에 복수 소셜 Provider 연결은 허용합니다. > — 별첨B_정책설계서_요구사항정의서_반영_회신표_v1.4 버튼 순서는 이 회신 문구 순서다(`AuthRepository._defaultProviders()`). | 제공자 | 상태 | 구현 | 막고 있는 것 | |---|---|---|---| | Google | 실 코드 경로 | `GoogleSignInProvider` | **Firebase 콘솔에서 Google 제공자 미활성** + Android OAuth 클라이언트(SHA-1) 미등록 | | Apple | 실 코드 경로 | `AppleSignInProvider` | Apple 개발자 키 + Xcode Capability + Android Service ID/redirect URI | | 카카오 | 키 대기 | `KeylessSocialProvider` | 카카오 네이티브 앱 키(Q3) | | LINE | 키 대기 | `KeylessSocialProvider` | LINE Login 채널 ID/시크릿(Q3) | 키가 없는 제공자의 버튼에는 **「키 대기」 배지**가 붙고, 누르면 무엇이 없어서 못 쓰는지 **안내 다이얼로그**가 뜬다. 버튼을 회색으로 잠그지 않는 것은 회색이 사유를 말해 주지 않기 때문이다. ### 이메일 가입 — 사용자 노출 없음 별첨 B v1.4 가 이메일/비밀번호 가입을 1차 범위에서 뺐다. 로그인 화면에 이메일 버튼도, 「회원가입」도, 「비밀번호 재설정」도 없다. 닫은 방법은 **기능 플래그가 아니라 목록에서 빼는 것**이다 — `AuthRepository.providers` 에 없으면 화면이 버튼을 그리지 않고, 버튼이 없으면 닿는 경로가 없다. 발주사 QA 계정(`qa-test@fanuzu.co.kr`)용 이메일 로그인 코드는 남아 있으나 입구가 하나뿐이다: * **디버그 빌드에서 로그인 화면의 FANUZU 로고를 길게 누른다.** * 릴리스 빌드에서는 `AuthRepository.qaEmailProvider` 가 `null` 이라 폼이 열리지 않고 `signInWithEmail` 도 실패로 닫힌다. 회귀 가드 4건이 이걸 고정한다. * 보이는 「QA 로그인」 버튼을 두지 않은 이유: **디버그 APK 도 발주사가 설치해 보는 산출물**이라 그 버튼 자체가 「이메일 가입이 살아 있다」로 읽힌다. ### 계정 연결 — 하나의 UID 에 복수 Provider `FirebaseAccountLinker` 가 `User.linkWithCredential` / `User.unlink` 를 쓴다. `signInWithCredential` 이 아니라 **링크 API 만** 쓰는 것이 「UID 는 하나」를 지키는 지점이다 — 전자는 해당 계정이 없으면 새로 만들어 버린다. * 로그인과 연결은 자격증명을 얻는 데까지 완전히 같아서 그 공통 부분을 `CredentialProvider` 하나로 뺐다. 두 벌로 적으면 Apple nonce 같은 보안 절차가 한쪽에서만 고쳐진다. * 명세 `554:22797`(*동일 이메일만 연동 가능*)은 **붙이기 전에** 판정한다. 붙인 뒤 되돌리는 방식은 `unlink` 가 실패하면 남의 계정이 붙은 채로 남는다. 단 Apple 은 **최초 인가 때만** 이메일을 주므로 재연동 시에는 판정할 값이 없어 통과한다 — 서버에서 다시 보려면 Functions 층(fn-account-link)에 같은 판정을 둔다. * **마지막 하나 남은 Provider 는 뗄 수 없다.** 떼는 순간 로그인 수단이 0개가 되어 계정에 다시 들어올 방법이 사라지는데, Firebase 는 이걸 막아 주지 않는다. 설계 원칙: **「키 미투입」은 예외가 아니라 상태다.** 예외를 던지지 않고 `AuthNotConfigured(provider, missing)` 를 반환해 "무엇이 없어서 못 쓰는지"를 화면에 그대로 보여 준다. Apple은 로그인 시도마다 암호학적 nonce를 만들고 SHA-256 해시를 Apple에, 원문 nonce를 Firebase에 전달한다. Android에서는 Service ID와 redirect URI가 없으면 SDK를 호출하지 않는다. Firebase가 live가 되면 보존된 `currentUser`를 앱 시작 시 복원하고 `authStateChanges`를 계속 들어 만료·원격 로그아웃도 라우터에 반영한다. 이 내부 계약은 fake 세션·provider로 자동 검증한다. **이메일 가입은 구현하지 않았다.** CD-18 미확정(Q2)이라 `SocialAuthProvider` 인터페이스에 구현체 하나가 더 붙을 자리만 비워 뒀다. **기능 플래그 상수는 두지 않았다** — 읽는 분기가 없는 플래그는 "false 라서 닫혀 있다" 는 착각만 만들고, 누가 true 로 바꿔도 아무 일이 일어나지 않는다 (교차검증 A-02). 지금 이메일을 닫고 있는 것은 `AuthRepository._defaultProviders()` 에 구현체가 없다는 사실 하나다. ## 7. 계약 고정 표기 — 5탭 ``` HOME · INSIDE · GALAXY · STATION · MY ``` 근거 3개가 서로 일치한다: | 근거 | 위치 | |---|---| | 견적서 v2.1 「5탭 네비게이션」 | `Docs/Fancake_팬덤플랫폼_견적서.html` | | 크래프트 게이트 C15 | `design/craft-allowlist.yaml` → `APP_TABS` | | 시안 하단 탭바 | `시안/screens/S-{H-HOME,I-LOUNGE,G-PLANET,S-HUB,M-HOME}.html` 의 `