Flutter 앱 골격 · 5탭 네비게이션 · 로그인 화면 · Firestore 보안 규칙·인덱스

This commit is contained in:
Sn-Kinos
2026-08-10 01:02:03 +09:00
parent fbba4405c3
commit 233ef99837
113 changed files with 8549 additions and 2 deletions
+45
View File
@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
+33
View File
@@ -0,0 +1,33 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "cc0734ac716fbb8b90f3f9db8020958b1553afa7"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
base_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
- platform: android
create_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
base_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
- platform: ios
create_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
base_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+308
View File
@@ -0,0 +1,308 @@
# Fancake 앱 골격 (Flutter)
> 2주차 산출물 · 발주사 (주)팬케이크 / 수행사 스위치 주식회사(Sweech)
> 별첨2 2주차 「첫 빌드(앱 골격)」의 빌드 포함 범위 = **Flutter 앱 골격 · 5탭 네비게이션 · 소셜 로그인 · 디자인시스템**
## 1. 이 골격의 범위
| | 내용 |
|---|---|
| **포함** | 앱 기동 · 코스믹 다크 테마(design-tokens.md 생성) · 5탭 셸 네비게이션 · 로그인 화면(S-A-LOGIN) 4종 소셜 버튼 · 인증 인터페이스와 Google/Apple 실 코드 경로 · 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 # 스모크 테스트 3종
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` 의 `<!-- TOKENS -->` 아래 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 | 네이버 client ID / secret | 미정 | 네이버 버튼이 「키 미투입」 안내를 반환 |
| Q5 | 스토어 등록 명의 | — | 3주차 TestFlight·Play 내부트랙 제출 불가 |
| Q14 | 앱 식별자 확정 | `android/app/build.gradle.kts` · `ios/Runner.xcodeproj` | 현재 `kr.co.sweech.fancake` 로 잠정. **스토어 등록 후에는 변경 불가** |
| Q6 | 확정 로고 원본(벡터·알파 PNG) | `assets/brand/` | 로그인 화면 워드마크를 텍스트로 대체 중 |
| 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. 소셜 로그인 상태
화면명세 `S-A-LOGIN.md` 와 시안이 4종(카카오·네이버·Apple·Google)이고, 버튼 순서·라벨을 그대로 옮겼다.
| 제공자 | 상태 | 구현 | 막고 있는 것 |
|---|---|---|---|
| 카카오 | 스텁 | `KeylessSocialProvider` | 앱 키(Q3) |
| 네이버 | 스텁 | `KeylessSocialProvider` | 앱 키(Q3) |
| Apple | 실 코드 경로 | `AppleSignInProvider` | Firebase 설정(S2) + Xcode Capability |
| Google | 실 코드 경로 | `GoogleSignInProvider` | Firebase 설정(S2) |
설계 원칙: **「키 미투입」은 예외가 아니라 상태다.** 예외를 던지지 않고
`AuthNotConfigured(provider, missing)` 를 반환해 "무엇이 없어서 못 쓰는지"를 화면에 그대로 보여 준다.
**이메일 가입은 구현하지 않았다.** 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` 의 `<nav class="tabbar">` |
**한글화·치환·순서 변경 금지.** 스모크 테스트가 `design/craft-allowlist.yaml` 을 직접 읽어
문자열과 순서를 정확 일치로 검증한다 — 앱 코드만 고치면 테스트가 깨진다.
탭별 루트 화면:
| 탭 | 화면 ID | 화면명 |
|---|---|---|
| HOME | S-H-HOME | 오늘의 퀘스트 |
| INSIDE | S-I-LOUNGE | 팬덤 라운지 |
| GALAXY | S-G-PLANET | 내 행성 |
| STATION | S-S-HUB | STATION 허브 |
| MY | S-M-HOME | 마이 페이지 |
## 8. 폴더 구조
```
app/
├── pubspec.yaml
├── analysis_options.yaml flutter_lints + strict-casts/raw-types/inference
├── tool/gen_tokens.py design-tokens.md → tokens.dart 생성기
├── firebase/ T1(Firestore 설계) 영역 — 규칙·인덱스
├── test/app_smoke_test.dart 스모크 3종
└── lib/
├── main.dart FirebaseBoot.init() → runApp
├── app.dart MaterialApp.router + 테마 주입
├── core/
│ ├── theme/tokens.dart ← 생성물. 손대지 마라
│ ├── theme/app_theme.dart ThemeData 조립 (MD3 · ColorScheme.dark)
│ ├── router/app_router.dart 5탭 셸 + 로그인 라우트 + AppShell
│ ├── firebase/firebase_boot.dart
│ └── ui/tab_placeholder.dart
└── features/
├── auth/ 인터페이스 · 제공자 4종 · 저장소 · 로그인 화면
└── home/ inside/ galaxy/ station/ my/ 탭 루트 플레이스홀더
```
## 9. 검증 결과
<!-- VERIFY:BEGIN 실행 출력 그대로. 손으로 고치지 마라. -->
2026-08-08 실행. macOS 27.0 · Flutter 3.41.7 (stable) · Dart 3.11.5 · Android SDK 36 · Xcode 26.6
```
$ flutter analyze
Analyzing app...
No issues found! (ran in 1.3s)
$ flutter test
00:00 +0: loading /Users/Kinos/Projects/p-fancake/app/test/app_smoke_test.dart
00:00 +0: 1. 앱이 예외 없이 뜨고 로그인 화면이 나온다
00:00 +1: 2. 하단 탭 5개가 계약 표기·순서 그대로 렌더된다
00:00 +2: 릴리스 빌드에서는 목 우회 입구가 열리지 않는다 목 모드 + 릴리스면 isMockSessionAvailable 이 false 다
00:00 +3: 릴리스 빌드에서는 목 우회 입구가 열리지 않는다 릴리스에서 signInWithMockSession 을 불러도 로그인되지 않는다
00:00 +4: 릴리스 빌드에서는 목 우회 입구가 열리지 않는다 릴리스 빌드 로그인 화면에 목 세션 버튼이 없다
00:00 +5: 릴리스 빌드에서는 목 우회 입구가 열리지 않는다 디버그 빌드에서는 목 세션 버튼이 있다 (가드가 과하게 잠그지 않았는지)
00:00 +6: 화면에 띄우는 사유에 내부 파일 경로가 들어가지 않는다
00:00 +7: 3. 탭을 전환하면 각 탭 루트 화면이 바뀐다
00:00 +8: All tests passed!
$ flutter build apk --debug
Running Gradle task 'assembleDebug'... 219.5s (첫 빌드 — NDK 28.2 자동 설치 포함)
✓ Built build/app/outputs/flutter-apk/app-debug.apk (142.4 MB · debug)
10.6s (증분 재빌드)
$ flutter build ios --debug --no-codesign
Running pod install... 72.1s
Running Xcode build... 63.1s
✓ Built build/ios/iphoneos/Runner.app
$ python3 tool/gen_tokens.py
생성: lib/core/theme/tokens.dart (169줄)
… contrast_checks 25쌍 재계산 …
전 쌍 AA 통과. (최저 color-on-neon-violet / color-neon-violet = 5.09:1)
```
**테스트 8종은 Firebase 설정 파일이 없는 상태(목 모드)에서 통과한 결과다.**
음성 검증 2회 — 테스트가 실제로 게이트 역할을 하는지 직접 깨뜨려 확인했다.
| 변조 | 결과 |
|---|---|
| `appTabs` 의 `STATION` → `스테이션` | 2·3번 실패. 되돌린 뒤 재통과 |
| `isMockSessionAvailable` 에서 `&& !_releaseMode` 제거 | 릴리스 가드 3개 실패. 되돌린 뒤 재통과 |
`contrast_checks` 25쌍에는 앱 테마가 조립하며 새로 만드는 3쌍(선택 탭 라벨 9.21:1 ·
인디케이터 위 선택 라벨 7.93:1 · 인디케이터 위 비선택 라벨 8.05:1)이 포함돼 있다.
토큰 문서에만 있는 쌍을 검사하면 위젯 테마에서 대비가 무너져도 생성기가 통과를 찍는다(교차검증 A-04).
### 토큰 대조 — 시안 CSS ↔ 생성된 Dart
`시안/assets/ds.css` 의 `:root` 변수와 `tokens.dart` 의 색 토큰을 값 비교했다.
```
ds.css 변수 119개 · tokens.dart 색 토큰 51개 · 공통 51개
값 불일치: 0개
```
### 실기기 기동 확인 (Android 에뮬레이터 · API 36)
APK 를 설치·실행해 목 모드 기동과 탭 이동을 확인했다. `FATAL EXCEPTION` 0건.
```
$ adb install -r build/app/outputs/flutter-apk/app-debug.apk
Success
$ adb logcat | grep FirebaseBoot
I flutter : [FirebaseBoot] mode=mock :: Firebase 설정 파일이 없어 목(mock) 모드로 기동했습니다.
투입 대상: app/android/app/google-services.json
app/ios/Runner/GoogleService-Info.plist
I flutter : [FirebaseBoot] detail :: PlatformException(java.lang.Exception: Failed to load
FirebaseOptions from resource. Check that you have defined values.xml correctly. …)
```
확인한 동선: 로그인 화면 렌더(소셜 4버튼 라벨 일치) → 「목 세션으로 탭 둘러보기」 →
5탭 셸 진입 → STATION 탭 전환 시 `S-S-HUB` 로 화면 전환.
<!-- VERIFY:END -->
## 10. 알려진 제약
<!-- LIMITS:BEGIN -->
| # | 제약 | 영향 | 해소 시점 |
|---|---|---|---|
| L1 | Firebase 설정 파일 미투입 → 목 모드 | **실 소셜 로그인 불가.** 로그인 버튼은 「키 미투입」 안내를 반환 | S2 (3주차 빌드 전) |
| L2 | `com.google.gms.google-services` Gradle 플러그인 **미적용** | 지금은 이게 정상이다. 설정 파일 없이 플러그인만 적용하면 Gradle 빌드가 실패한다. 파일 투입과 **동시에** `android/settings.gradle.kts` · `android/app/build.gradle.kts` 에 추가해야 한다 | S2 |
| L3 | 카카오·네이버 SDK 미도입 | 스텁만 있다. 실 연동 시 `kakao_flutter_sdk_user` · 네이버 SDK 추가와 함께 `KeylessSocialProvider` 를 실 구현체로 교체 | Q3 회신 후 |
| L4 | 폰트 파일 미투입 | 시스템 폰트로 폴백. 크기·굵기·행간·자간은 토큰 값대로 적용됨 | 라이선스 원본 확보 시 |
| L5 | 로고 원본 미확정(Q6) | 로그인 화면 워드마크가 텍스트다 | 2주차 M1 |
| L6 | 탭 아이콘이 Material 기본(Q15) | 시안 탭바에는 아이콘이 없어 대조 대상이 없다 | 3주차 |
| L7 | **iOS 최소 지원 버전을 13.0 → 15.0 으로 올렸다** | `firebase_auth` 6.x 가 15.0 이상을 요구해 13.0 으로는 `pod install` 이 실패했다. 기준 단말 iPhone 11(S4)은 iOS 18까지 지원하므로 범위 영향 없음. `ios/Podfile` · `Runner.xcodeproj` 3곳 수정 | 반영 완료 |
| L8 | **릴리스 서명 키 없음 → `flutter build apk --release` 는 의도적으로 실패한다** | 서명 키는 스토어 등록 명의(Q5·S3)가 정해져야 만들 수 있다. Flutter 기본 템플릿은 이 상태에서 **debug 키로 조용히 서명**해 Play 가 거부하는 산출물을 "성공"으로 내놓는다 — 그 경로를 막고 사유를 띄우도록 `android/app/build.gradle.kts` 를 고쳤다. 키가 생기면 `android/key.properties`(storeFile·storePassword·keyAlias·keyPassword)만 놓으면 된다. 해당 파일과 `*.jks`/`*.keystore` 는 `app/android/.gitignore` 가 이미 막고 있다(확인함) | Q5·S3 |
| L8-1 | debug APK 142.4 MB | debug 빌드 특성(미난독화·전 ABI 포함). release + `--split-per-abi` 로 크게 줄어들지만 **L8 때문에 아직 실측하지 못했다** | Q5·S3 |
| L9 | 첫 Gradle 빌드에서 Kotlin 데몬 경고 1회 | `e: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0` — 자동 재시도로 성공했고 산출물에 영향 없음. 증분 빌드에서는 재현되지 않음 | — |
| L9-1 | iOS 런치 화면 색 변경은 **컴파일 검증만** 했다 | `LaunchScreen.storyboard` 배경을 흰색 → `color-cosmic-void`(#000)로 바꿨다. Android 는 에뮬레이터에서 눈으로 확인했지만 iOS 는 시뮬레이터 실행을 하지 않아 빌드 성공까지만 확인했다 | 3주차 실기기 빌드 |
| L10 | 온보딩 6화면 라우트 없음 | S-A-INTRO · S-A-TERMS · S-A-PROFILE · S-A-ARTIST · S-A-PLANET-NEW · S-A-PERMS | 3주차 |
| L11 | 상태관리 라이브러리 없음 | 골격 범위라 `ChangeNotifier` 하나로 충분하다. 도메인 로직이 들어오는 3주차에 도입 여부를 판단 | 3주차 |
| L12 | `app/ios/Podfile.lock` 이 리포 `.gitignore` 에 잡혀 있다 | Pod 버전이 고정되지 않아 빌드 재현성이 떨어진다. 커밋 대상으로 되돌릴지 리드 판단 필요 | 리드 확인 |
**시연 시 유의** — 실 소셜 로그인은 L1 때문에 동작하지 않는다. 「목 세션으로 탭 둘러보기」로
탭 이동을 확인하시고, 로그인 자체는 3주차 빌드에서 확인 부탁드립니다.
<!-- LIMITS:END -->
View File
+14
View File
@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
+91
View File
@@ -0,0 +1,91 @@
import java.util.Properties
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
// Firebase 설정 파일(google-services.json)이 투입되면(S2) 아래 한 줄을 함께 푼다.
// 파일 없이 플러그인만 적용하면 Gradle 빌드 자체가 실패한다.
// id("com.google.gms.google-services")
}
// 릴리스 서명 키는 `android/key.properties` 에서 읽는다. 파일은 저장소에 넣지 않는다.
// 형식: storeFile= / storePassword= / keyAlias= / keyPassword=
val keystoreProperties = Properties().apply {
val f = rootProject.file("key.properties")
if (f.exists()) f.inputStream().use { load(it) }
}
/// 이번 실행이 릴리스 산출물(assembleRelease · bundleRelease 등)을 요구하는가.
val wantsRelease = gradle.startParameter.taskNames.any { it.contains("Release", ignoreCase = true) }
// 앱 식별자 단일 출처. Q14(앱 식별자 확정) 회신이 오면 Android 쪽은 이 한 줄만 고친다.
// 주의: Kotlin 패키지(kr.co.sweech.fancake)와 소스 디렉터리는 바꿀 필요가 없다 —
// 스토어 식별자는 applicationId 뿐이고 namespace 는 R 클래스 패키지일 뿐이다.
val fancakeApplicationId = "kr.co.sweech.fancake"
android {
namespace = fancakeApplicationId
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// 잠정값. 스토어 등록 명의(2주차_확인요청항목 Q5)가 발주사 명의로 확정되면 교체한다(Q14).
// 스토어에 한 번 올리면 변경 불가 — 첫 제출 전에 확정해야 한다.
applicationId = fancakeApplicationId
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
create("release") {
if (keystoreProperties.isNotEmpty()) {
storeFile = file(keystoreProperties.getProperty("storeFile"))
storePassword = keystoreProperties.getProperty("storePassword")
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
}
}
}
buildTypes {
release {
// 키가 없으면 **릴리스 빌드를 실패시킨다.** 기본 템플릿은 debug 키로 조용히 서명하는데,
// 그렇게 만든 release 산출물은 Play 가 거부하고 배포도 못 하면서 겉보기엔 성공한다.
// 릴리스 서명 키는 스토어 등록 명의(OPEN-QUESTIONS Q5 · 내부 조치 S3)가 정해져야
// 만들 수 있다 — 지금은 없는 게 정상이고, 없다는 사실이 드러나야 한다.
//
// 검사는 릴리스 태스크를 실제로 요청했을 때만 한다. 설정 시점에 무조건 던지면
// `assembleDebug` 까지 같이 깨진다.
if (keystoreProperties.isEmpty()) {
if (wantsRelease) {
throw GradleException(
"릴리스 서명 키가 없습니다. android/key.properties 를 만들고 키스토어를 지정하세요. " +
"(미해결: OPEN-QUESTIONS Q5 스토어 등록 명의 · 내부 조치 S3) " +
"디버그 빌드는 `flutter build apk --debug` 로 그대로 됩니다."
)
}
} else {
signingConfig = signingConfigs.getByName("release")
}
}
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="Fancake"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,5 @@
package kr.co.sweech.fancake
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 런치 배경 = color-cosmic-void(#000). Flutter 첫 프레임 전 흰 화면이 번쩍이지 않게 한다. -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/cosmic_void" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 런치 배경 = color-cosmic-void(#000). Flutter 첫 프레임 전 흰 화면이 번쩍이지 않게 한다. -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/cosmic_void" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- design/design-tokens.md 의 color-cosmic-void. 네이티브 런치 화면이 Flutter 첫 프레임과
같은 캔버스에서 시작하도록 쓴다. 앱은 다크 전용이라 values-night 를 따로 두지 않는다. -->
<resources>
<color name="cosmic_void">#FF000000</color>
<color name="ic_launcher_background">#120E25</color>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off — 이 앱은 다크 전용(결정 D-19)이라 night 와 같은 값을 쓴다 -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+24
View File
@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+2
View File
@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
+5
View File
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
+30
View File
@@ -0,0 +1,30 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
// Firebase 설정 파일(google-services.json) 투입 시(S2) 아래 한 줄과
// app/build.gradle.kts 의 id("com.google.gms.google-services") 를 '함께' 푼다.
// 파일 없이 플러그인만 적용하면 Gradle 빌드가 실패한다.
// id("com.google.gms.google-services") version "4.4.4" apply false
}
include(":app")
Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 KiB

+11
View File
@@ -0,0 +1,11 @@
{
"firestore": {
"rules": "firestore.rules",
"indexes": "firestore.indexes.json"
},
"emulators": {
"firestore": { "port": 8080 },
"ui": { "enabled": false },
"singleProjectMode": true
}
}
+488
View File
@@ -0,0 +1,488 @@
{
"indexes": [
{
"collectionGroup": "posts",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "fandomId", "order": "ASCENDING" },
{ "fieldPath": "visibility", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "posts",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "fandomId", "order": "ASCENDING" },
{ "fieldPath": "visibility", "order": "ASCENDING" },
{ "fieldPath": "likeCount", "order": "DESCENDING" }
]
},
{
"collectionGroup": "posts",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "authorUid", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "posts",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "visibility", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "comments",
"queryScope": "COLLECTION_GROUP",
"fields": [
{ "fieldPath": "visibility", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "popTransactions",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "popType", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "popTransactions",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "reasonCode", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "popTransactions",
"queryScope": "COLLECTION_GROUP",
"fields": [
{ "fieldPath": "reasonCode", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "popTransactions",
"queryScope": "COLLECTION_GROUP",
"fields": [
{ "fieldPath": "popType", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "popLots",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "popType", "order": "ASCENDING" },
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "grantedAt", "order": "ASCENDING" }
]
},
{
"collectionGroup": "popLots",
"queryScope": "COLLECTION_GROUP",
"fields": [
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "expiresAt", "order": "ASCENDING" }
]
},
{
"collectionGroup": "activityLogs",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "type", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "activityLogs",
"queryScope": "COLLECTION_GROUP",
"fields": [
{ "fieldPath": "type", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "questProgress",
"queryScope": "COLLECTION_GROUP",
"fields": [
{ "fieldPath": "questId", "order": "ASCENDING" },
{ "fieldPath": "dayKey", "order": "ASCENDING" }
]
},
{
"collectionGroup": "campaigns",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "fandomId", "order": "ASCENDING" },
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "endsAt", "order": "ASCENDING" }
]
},
{
"collectionGroup": "campaigns",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "fandomId", "order": "ASCENDING" },
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "achievedRate", "order": "DESCENDING" }
]
},
{
"collectionGroup": "campaigns",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "fandomId", "order": "ASCENDING" },
{ "fieldPath": "campaignType", "order": "ASCENDING" },
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "campaigns",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "creatorUid", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "campaigns",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "ASCENDING" }
]
},
{
"collectionGroup": "donations",
"queryScope": "COLLECTION_GROUP",
"fields": [
{ "fieldPath": "donorUid", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "donations",
"queryScope": "COLLECTION_GROUP",
"fields": [
{ "fieldPath": "refundStatus", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "ASCENDING" }
]
},
{
"collectionGroup": "questions",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "fandomId", "order": "ASCENDING" },
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "questions",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "fandomId", "order": "ASCENDING" },
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "bountyPop", "order": "DESCENDING" }
]
},
{
"collectionGroup": "questions",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "category", "order": "ASCENDING" },
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "questions",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "bountyExpiresAt", "order": "ASCENDING" }
]
},
{
"collectionGroup": "answers",
"queryScope": "COLLECTION_GROUP",
"fields": [
{ "fieldPath": "authorUid", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "fandoms",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "countryCode", "order": "ASCENDING" },
{ "fieldPath": "fpiScore", "order": "DESCENDING" }
]
},
{
"collectionGroup": "fandoms",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "artistId", "order": "ASCENDING" },
{ "fieldPath": "fpiScore", "order": "DESCENDING" }
]
},
{
"collectionGroup": "fpiEvents",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "axis", "order": "ASCENDING" },
{ "fieldPath": "occurredAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "heroDrops",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "endsAt", "order": "ASCENDING" }
]
},
{
"collectionGroup": "users",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "campaigns",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "endsAt", "order": "ASCENDING" }
]
},
{
"collectionGroup": "translationEntries",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "ASCENDING" }
]
},
{
"collectionGroup": "preRegistrations",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "fandomId", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "ASCENDING" }
]
},
{
"collectionGroup": "preRegistrations",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "adRewardLogs",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "uid", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "adRewardLogs",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "network", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "adRewardLogs",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "fandomId", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "adminActionLogs",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "actorUid", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "adminActionLogs",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "targetType", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "translationEntries",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "locale", "order": "ASCENDING" },
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "translationEntries",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "authorUid", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "reports",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "ASCENDING" }
]
},
{
"collectionGroup": "reports",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "targetType", "order": "ASCENDING" },
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "inquiries",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "ASCENDING" }
]
},
{
"collectionGroup": "inquiries",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "authorUid", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "notifications",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "read", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "notices",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "active", "order": "ASCENDING" },
{ "fieldPath": "publishedAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "briefings",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "fandomId", "order": "ASCENDING" },
{ "fieldPath": "publishedAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "stationLinks",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "fandomId", "order": "ASCENDING" },
{ "fieldPath": "rank", "order": "ASCENDING" }
]
},
{
"collectionGroup": "stationSuggestions",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "ASCENDING" }
]
},
{
"collectionGroup": "inviteCodes",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "fandomId", "order": "ASCENDING" },
{ "fieldPath": "type", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "planetApplications",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "ASCENDING" }
]
}
],
"fieldOverrides": [
{
"collectionGroup": "popTransactions",
"fieldPath": "createdAt",
"indexes": [
{ "order": "ASCENDING", "queryScope": "COLLECTION" },
{ "order": "DESCENDING", "queryScope": "COLLECTION" },
{ "order": "ASCENDING", "queryScope": "COLLECTION_GROUP" },
{ "order": "DESCENDING", "queryScope": "COLLECTION_GROUP" }
]
},
{
"collectionGroup": "donations",
"fieldPath": "createdAt",
"indexes": [
{ "order": "ASCENDING", "queryScope": "COLLECTION" },
{ "order": "DESCENDING", "queryScope": "COLLECTION" },
{ "order": "ASCENDING", "queryScope": "COLLECTION_GROUP" },
{ "order": "DESCENDING", "queryScope": "COLLECTION_GROUP" }
]
},
{
"collectionGroup": "activityLogs",
"fieldPath": "createdAt",
"indexes": [
{ "order": "ASCENDING", "queryScope": "COLLECTION" },
{ "order": "DESCENDING", "queryScope": "COLLECTION" },
{ "order": "ASCENDING", "queryScope": "COLLECTION_GROUP" },
{ "order": "DESCENDING", "queryScope": "COLLECTION_GROUP" }
]
},
{
"collectionGroup": "posts",
"fieldPath": "body",
"indexes": []
},
{
"collectionGroup": "briefings",
"fieldPath": "body",
"indexes": []
},
{
"collectionGroup": "campaigns",
"fieldPath": "body",
"indexes": []
}
]
}
+630
View File
@@ -0,0 +1,630 @@
rules_version = '2';
// Fancake — Firestore 보안 규칙 (2주차 설계본 v0.1)
//
// 근거
// - wireframe/handoff/rbac-summary.md 역할 3종 · 운영자 등급 3단계(D-18)
// - Docs/별첨1_기능별_완료기준표.html 「전 기능 공통 완료 기준 — 서버검증 원칙」
// - 주간보고/1주차/src/1주차_정책설계서_초안.md P-02 원장 무결성 · P-06 어뷰징 방어
//
// 3대 원칙
// 1) POP · FPI · 등급 · 원장 필드는 클라이언트가 쓸 수 없다.
// 전부 Cloud Functions(Admin SDK)가 쓰며, Admin SDK는 본 규칙을 우회한다.
// → 별첨1 ② 판정 기준 「클라이언트 단독 지급 0건」을 규칙 층에서 물리적으로 보장.
// 2) 개인 리소스는 본인만 읽고, 쓰기는 화이트리스트 필드로만 허용한다.
// 3) 운영자 권한은 커스텀 클레임 role(master|ops|viewer)로만 판정한다.
// Firestore 문서 조회로 판정하지 않는다 — 판정 1회당 읽기 1회 비용이 붙고,
// 규칙 평가 중 문서 읽기는 우회 표면을 늘린다.
//
// 미구현 표기
// - 규칙은 거부만 할 수 있고 로그를 남기지 못한다. rbac-summary 「관리자 권한 없는 접근은
// E-PERM-01로 차단하고 시도 자체를 작업 로그에 남긴다」의 로그 적재는 Admin 콘솔이
// Cloud Functions를 경유하도록 설계해 Functions 층에서 처리한다(설계서 §8 fn-admin-*).
service cloud.firestore {
match /databases/{database}/documents {
// ───────────────────────── 공통 헬퍼 ─────────────────────────
function signedIn() { return request.auth != null; }
function isSelf(u) { return signedIn() && request.auth.uid == u; }
function role() { return signedIn() ? request.auth.token.get('role', '') : ''; }
function isViewer() { return role() == 'master' || role() == 'ops' || role() == 'viewer'; }
function isOps() { return role() == 'master' || role() == 'ops'; }
function isMaster() { return role() == 'master'; }
// 업데이트가 지정한 필드만 건드렸는지 검사 — 그 외 필드 위조를 차단한다.
function touchesOnly(fields) {
return request.resource.data.diff(resource.data).affectedKeys().hasOnly(fields);
}
// 생성 문서의 작성자 필드가 요청자 본인인지 검사 — 타인 사칭 작성 차단.
function authorIsSelf() {
return signedIn() && request.resource.data.authorUid == request.auth.uid;
}
// 요청자의 users 문서. 비정규화 복제 필드(표시명·팬덤)의 원본 대조에 쓴다.
// §7.1 원칙 3은 "역할 판정"에 get()을 쓰지 않는다는 뜻이고, 여기는 역할 판정이 아니다.
// 호출 지점은 전부 생성(create) 1건 단위라 목록 조회처럼 읽기가 곱해지지 않는다.
// 같은 평가 안에서 같은 경로의 get()은 1회로 합산된다.
function myUser() {
return get(/databases/$(database)/documents/users/$(request.auth.uid)).data;
}
// 작성자 표시명이 프로필 원본과 같은지 — 운영팀 사칭 차단 (교차검증 F-05)
function authorNameMatchesProfile() {
return request.resource.data.authorName == myUser().displayName;
}
// 소속 팬덤이 본인 소속과 같은지 — 타 팬덤 피드 삽입 차단 (교차검증 F-05)
function fandomIsMine() {
return request.resource.data.fandomId == myUser().fandomId;
}
// 무인증 열람을 허용하는 캠페인 상태 — draft·review 는 제외 (교차검증 F-08)
function campaignIsPublic() {
return resource.data.status in ['voting', 'funding', 'success', 'fail', 'report'];
}
// ───────────────────────── 1. 운영 파라미터 ─────────────────────────
// 미확정 수치 원격 설정. 하드코딩 금지 대상 전부가 여기 들어온다.
// 근거: 결정 D-07·D-13·D-14·D-21·D-22·D-23 · FPI 가중치(P-03) · 이클립스 수치(CD-91)
// 화면: S-AD-POP · S-AD-FPI · S-AD-QUEST · S-AD-ECLIPSE
match /config/{configId} {
allow read: if true; // 스플래시·로그인 전 화면도 읽어야 해 공개 읽기
allow write: if isMaster(); // rbac-summary 「재화 정책·FPI 가중치」 = 마스터 전용
}
// 어뷰징 탐지 임계치·판정 로직. 노출되면 우회가 가능해 클라이언트 접근을 전면 차단한다.
// 근거: 정책설계서 P-06 「규칙은 공개하지 않는 것을 제안」
match /configPrivate/{configId} {
allow read, write: if false; // 서버(Functions) 전용
}
// 앱 버전 정책 · 점검 모드 · 최소 지원 버전. 미로그인 스플래시가 읽는다.
// 화면: S-A-SPLASH · S-X-MAINT · S-AD-SYSTEM
match /appConfig/{docId} {
allow read: if true;
allow write: if isMaster(); // rbac-summary 「점검 모드·강제 업데이트」 = 마스터 전용
}
// 다국어 문자열 원격 배포 리소스. 화면: S-M-LANG · S-AD-I18N
match /i18n/{locale} {
allow read: if true;
allow write: if isMaster(); // rbac-summary 「다국어 문자열」 = 마스터 전용
}
// ───────────────────────── 2. 사용자 ─────────────────────────
// 계정·프로필. 등급(grade)·누적 기여(totalContributedPop)는 서버 산출값이라 쓰기 목록에서 뺀다.
// 화면: S-A-PROFILE · S-M-HOME · S-M-SETTINGS · S-AD-USER
match /users/{uid} {
allow get: if isSelf(uid) || isViewer();
allow list: if isViewer(); // 전체 사용자 목록은 운영자만(S-AD-USER)
// 교차검증 F-06 — 클라이언트 생성 경로를 닫는다.
// 기존에는 클라이언트 생성도 허용해 ① fn-auth-oncreate(§8.5)와 생성 경합이 생기고
// ② 클라이언트가 먼저 만들면 grade·totalContributedPop 이 허용 목록에 없어 필드가 빈
// 사용자 문서가 남았다(별첨1 조건부적합 제외 ④ 로그인 불가와 닿는 지점).
// 생성 주체를 Auth 트리거 하나로 고정한다.
allow create: if false;
// 클라이언트가 바꿀 수 있는 것은 프로필 표시 항목뿐이다.
allow update: if isSelf(uid)
&& touchesOnly(['displayName','photoUrl','countryCode','locale',
'representativeBadgeId','notificationPrefs','updatedAt']);
// 계정 상태 변경(정지·섀도우밴)은 운영자 권한. rbac-summary 「어뷰징 제재」 = 운영 이상
allow update: if isOps() && touchesOnly(['status','banReason','bannedUntil','updatedAt']);
allow delete: if false; // 탈퇴는 Functions 경유(원장 보존 · P-13)
}
// POP 지갑 요약(집계 필드). 읽기만 허용하고 쓰기는 전부 Functions.
// 화면: S-M-WALLET · S-H-HOME · S-C-FUND
match /users/{uid}/wallet/{docId} {
allow read: if isSelf(uid) || isViewer(); // S-AD-LEDGER 조회 등급 이상
allow write: if false; // 별첨1 ② 「클라이언트 단독 지급 0건」
}
// POP Lot — 획득 단위별 잔량·만료일. FIFO 차감의 계산 단위.
// 화면: S-M-WALLET(소멸 예정 POP)
match /users/{uid}/popLots/{lotId} {
allow read: if isSelf(uid) || isViewer();
allow write: if false; // 잔량·만료 조작 차단
}
// POP 원장. 지급·차감·환불·소멸 전건. 수정·삭제 불가(append-only)는 Functions에서도 지킨다.
// 화면: S-M-WALLET(거래 내역) · S-AD-LEDGER
match /users/{uid}/popTransactions/{txId} {
allow read: if isSelf(uid) || isViewer();
allow write: if false; // 별첨1 ② 원장 정합성 · 멱등키는 Functions 관리
}
// 활동 로그(걸음·스밍·위치·출석). 어뷰징 판정 입력이라 클라이언트 쓰기 금지.
// 화면: S-H-WALK · S-H-STREAM · S-H-PLACE · S-H-ATTEND · S-AD-ABUSE
match /users/{uid}/activityLogs/{logId} {
allow read: if isSelf(uid) || isViewer();
allow write: if false;
}
// 퀘스트 진행·일일 한도 카운터. 한도 우회를 막기 위해 서버 전용.
// 화면: S-H-HOME · S-X-AD(일일 한도)
match /users/{uid}/questProgress/{questId} {
allow read: if isSelf(uid) || isViewer();
allow write: if false;
}
// 보유 배지·등급 이력. 부여는 서버 판정.
// 화면: S-M-BADGE(본인 도감) · S-M-IDCARD(본인 카드)
//
// 교차검증 G-04 — 예전 주석은 「타인 프로필의 배지 노출 허용」이라 적고 signedIn() 으로 열어 뒀는데,
// **1차 범위에 타인 프로필 화면이 없다**(화면 95개 전수 확인). 주석이 사실과 달랐다.
// 타인 배지가 실제로 보이는 곳은 S-M-HERO 히어로 랭킹 하나뿐이고,
// 그것은 /fandoms/{f}/members/{uid}.honorBadgeIds 비정규화 필드로 읽는다(설계서 §2.3·§4.4).
// 랭킹 하나 보여주려고 타인 문서를 통째로 여는 경로를 닫는다.
match /users/{uid}/badges/{badgeId} {
allow read: if isSelf(uid) || isViewer();
allow write: if false;
}
// 구독(Focus) 상태. 영수증 검증 결과라 서버 전용.
// 화면: S-M-FOCUS
match /users/{uid}/subscription/{docId} {
allow read: if isSelf(uid) || isViewer();
allow write: if false;
}
// 알림함. 읽음 처리만 본인이 할 수 있다.
// 화면: S-X-NOTICE
match /users/{uid}/notifications/{notiId} {
allow read: if isSelf(uid);
allow update: if isSelf(uid) && touchesOnly(['read','readAt']);
allow create, delete: if false; // 발송은 Functions(FCM) 경유
}
// 약관 동의 이력. 개인정보 처리 근거의 입증 자료라 append-only 이고 클라이언트가 쓰지 못한다.
// 본인은 자기 동의 이력을 볼 수 있어야 한다(열람권). 화면: S-A-TERMS · S-M-SETTINGS
match /users/{uid}/consents/{consentId} {
allow read: if isSelf(uid) || isViewer();
allow write: if false; // 기록은 fn-consent-record 경유 (교차검증 F-04)
}
// 차단 목록. 본인만 읽고 쓴다.
// 화면: S-M-BLOCK · S-I-REPORT
match /users/{uid}/blocks/{targetUid} {
allow read, write: if isSelf(uid);
}
// ───────────────────────── 3. 아티스트 · 팬덤 · 행성 ─────────────────────────
// 아티스트 마스터. 온보딩에서 미인증 상태로도 목록을 본다.
// 화면: S-A-ARTIST · S-W-PREREG-NEW
match /artists/{artistId} {
allow read: if true;
allow write: if isOps();
}
// 팬덤. fpiScore·orbit 등 집계 필드가 붙어 있어 클라이언트 쓰기를 전면 금지한다.
// 화면: S-G-MAP · S-G-FPI · S-A-ARTIST
match /fandoms/{fandomId} {
allow read: if true; // 웹 브릿지(미로그인) 요약 노출 — D-16
allow write: if false; // 생성·집계 모두 Functions
}
// 팬덤 멤버십 + 개인 기여 집계(히어로 랭킹 원천).
// 화면: S-M-HERO · S-I-LOUNGE(내 기여도)
match /fandoms/{fandomId}/members/{uid} {
allow read: if signedIn();
allow write: if false; // 기여 POP 집계 = 서버 산출값
}
// 사용자 행성. 성장 단계·누적 기여 POP은 서버 산출이고, 외형 설정만 본인이 바꾼다.
// 화면: S-G-PLANET · S-G-THEME · S-A-PLANET-NEW
match /fandoms/{fandomId}/planets/{planetId} {
allow read: if signedIn(); // 타인 행성 열람 허용(갤럭시 맵 진입)
allow update: if isSelf(planetId)
&& touchesOnly(['planetType','colorKey','themeId','effectLevel','updatedAt']);
allow create, delete: if false; // 생성은 온보딩 Function(fn-planet-create)
}
// 성장 단계 정의(7단계). 마스터 데이터.
// 화면: S-G-STAGE
match /planetStages/{stageNo} {
allow read: if true;
allow write: if isMaster();
}
// FPI 이벤트(집계 입력). 클라이언트는 읽지도 쓰지도 않는다 — 가중치 역산 방지.
// 화면: S-AD-LEDGER(FPI 집계 로그)
match /fandoms/{fandomId}/fpiEvents/{eventId} {
allow read: if isViewer();
allow write: if false;
}
// FPI 사전 집계 스냅샷(축별 순위·등락). 랭킹 화면의 실제 읽기 대상.
// 화면: S-G-FPI · S-G-CONTRIB
match /fandoms/{fandomId}/fpiSnapshots/{periodKey} {
allow read: if signedIn();
allow write: if false;
}
// 이클립스 발동 이벤트.
// 화면: S-G-ECLIPSE · S-AD-ECLIPSE
match /fandoms/{fandomId}/eclipses/{eclipseId} {
allow read: if signedIn();
allow write: if false; // 발동·회복 수치는 서버 기준(별첨1 ① 판정 기준)
}
// 분산 카운터 샤드. 동시 방어 시 쓰기 핫스팟을 흡수한다.
// 클라이언트가 직접 증가시키면 침식률을 위조할 수 있어 Functions 경유로 고정한다.
// 화면: S-G-ECLIPSE · 근거: 견적서 4주차 「이클립스 동시 쓰기 분산 카운터·요약 캐시로 핫스팟 흡수」
match /fandoms/{fandomId}/eclipses/{eclipseId}/shards/{shardId} {
allow read: if signedIn(); // 합산은 클라이언트가 해도 무방
allow write: if false;
}
// 방어 로그(라이브 소셜 프루프 원천).
// 화면: S-G-ECLIPSE(실시간 방어 로그)
match /fandoms/{fandomId}/eclipses/{eclipseId}/defenseLogs/{logId} {
allow read: if signedIn();
allow write: if false;
}
// 영웅의 선물 발동 이벤트.
// 화면: S-X-HERODROP
match /fandoms/{fandomId}/heroDrops/{dropId} {
allow read: if signedIn();
allow write: if false;
}
// 수령 내역. 문서 ID = uid 로 중복 수령을 구조적으로 차단한다(E-POP-03).
// 화면: S-X-HERODROP
match /fandoms/{fandomId}/heroDrops/{dropId}/claims/{uid} {
allow read: if isSelf(uid) || isViewer();
allow write: if false; // 수령은 fn-herodrop-claim 경유
}
// ───────────────────────── 4. 퀘스트 · 장소 · 브리핑 ─────────────────────────
// 퀘스트 정의·보상 파라미터. 화면: S-H-HOME · S-AD-QUEST
match /quests/{questId} {
allow read: if signedIn();
allow write: if isMaster(); // rbac-summary 「퀘스트·이클립스 파라미터」
}
// 위치 퀘스트 장소. 화면: S-H-PLACE · S-H-PLACE-DETAIL
match /places/{placeId} {
allow read: if signedIn();
allow write: if isOps();
}
// AI Daily Briefing 카드(Functions 생성 → 캐시). 화면: S-H-BRIEF · S-H-BRIEF-DETAIL
match /briefings/{briefingId} {
allow read: if true; // 웹 브릿지 열람 — D-16
allow write: if isOps(); // 생성은 Functions, 수동 편집은 운영
}
// 배지 도감 마스터. 화면: S-M-BADGE
match /badges/{badgeId} {
allow read: if true;
allow write: if isMaster();
}
// ───────────────────────── 5. 캠페인 ─────────────────────────
// 캠페인. 목표 달성률·후원 합계는 집계 필드라 클라이언트가 못 쓴다.
// 화면: S-C-LIST · S-C-DETAIL · S-C-NEW · S-AD-CAMPAIGN
//
// ⚠️ 목록 쿼리에는 status 필터가 필수다 (교차검증 G-03).
// 아래 read 규칙이 공개 상태만 허용하므로, status 필터가 없는 목록 쿼리는
// draft·review 문서까지 매칭돼 **쿼리 전체가 거부**된다(Firestore 는 list 규칙을
// 반환 후보 전체에 적용한다). 인덱스 문제로 오인하기 쉬운 지점이다.
// 허용되는 목록 쿼리 형태는 설계서 §6.1 IDX-15·16·17·19 참조.
match /campaigns/{campaignId} {
// 교차검증 F-08 — 예전에는 read: if true 라 심사 중(review)·초안(draft) 캠페인 본문이
// URL만 알면 미로그인 상태로 읽혔다. FR-CMP-01「승인 대기 캠페인은 목록에 노출하지 않는다」(E-CMP-03)를
// 클라이언트 쿼리 필터가 아니라 규칙에서 강제한다. 공개 상태만 무인증 열람 대상이다.
allow read: if campaignIsPublic()
|| isOps()
|| (signedIn() && resource.data.creatorUid == request.auth.uid);
// 교차검증 F-02 — 화이트리스트 방식. 목록에 없는 필드가 하나라도 있으면 거부되므로
// 나중에 집계 필드가 추가돼도 자동으로 막힌다. 집계·분류 필드는 초기값을 못 벗어난다.
allow create: if signedIn()
&& request.resource.data.keys().hasOnly([
'fandomId','creatorUid','creatorName','title','summary','body',
'coverImageUrl','campaignType','targetPop','endsAt','status',
'raisedPop','achievedRate','donorCount','votedPop','isAiDraft',
'refundState','createdAt','updatedAt'
])
&& request.resource.data.creatorUid == request.auth.uid
&& request.resource.data.creatorName == myUser().displayName
&& fandomIsMine()
&& request.resource.data.status == 'draft'
&& request.resource.data.raisedPop == 0 // 개설 시 모금액 0 고정
&& request.resource.data.achievedRate == 0 // 달성률 위조 차단(웹 브릿지 노출 대상)
&& request.resource.data.donorCount == 0 // 후원자 수 위조 차단
&& request.resource.data.votedPop == 0 // D-22 승격 임계치 판정 입력
&& request.resource.data.isAiDraft == false // 「AI 초안」 배지 사칭 차단
&& request.resource.data.refundState == 'none';
// 개설자는 심사 전 초안만 고칠 수 있다.
allow update: if signedIn()
&& resource.data.creatorUid == request.auth.uid
&& resource.data.status == 'draft'
&& touchesOnly(['title','summary','body','coverImageUrl',
'targetPop','campaignType','endsAt','updatedAt']);
// 승인·반려·상태 전이. rbac-summary 「캠페인 승인·반려」 = 운영 이상
allow update: if isOps()
&& touchesOnly(['status','reviewNote','rejectReason','reviewedBy','updatedAt']);
allow delete: if false; // 원장 연결 문서라 삭제 대신 상태 전이
}
// 후원 내역. POP 차감과 1:1이라 전량 서버 경유.
// 화면: S-C-FUND · S-C-MINE · S-C-REFUND
match /campaigns/{campaignId}/donations/{donationId} {
allow read: if signedIn(); // 후원자 리더보드 노출(S-C-DETAIL)
allow write: if false; // 별첨1 ⑤ 환불 무결성
}
// 결과 보고. 성공 캠페인 개설자만 1회 제출하고 이후 운영이 관리한다.
// 화면: S-C-RESULT
match /campaigns/{campaignId}/reports/{reportId} {
allow read: if true;
// 교차검증 F-07 — §7.3 표는 「성공 캠페인 개설자」라고 적었는데 규칙은 creatorUid만 보고
// 상태를 확인하지 않아 실패·초안 캠페인에도 결과보고를 올릴 수 있었다.
// 별첨1 ⑤ 판정 기준 「결과보고 게시 확인」의 대상은 성공한 캠페인이다.
allow create: if signedIn()
&& get(/databases/$(database)/documents/campaigns/$(campaignId))
.data.creatorUid == request.auth.uid
&& get(/databases/$(database)/documents/campaigns/$(campaignId))
.data.status == 'success';
allow update: if isOps();
allow delete: if false;
}
// ───────────────────────── 6. INSIDE 커뮤니티 ─────────────────────────
// 라운지 게시물. 좋아요 수는 집계 필드라 본인 수정 목록에서 제외한다.
// 화면: S-I-LOUNGE · S-I-POST · S-I-POST-NEW
match /posts/{postId} {
allow read: if signedIn();
// 교차검증 F-05 — 예전에는 authorUid·likeCount·visibility 3개만 봐서
// ① authorName 을 "Fancake 운영팀"으로 적어 사칭 ② commentCount 위조
// ③ 소속하지 않은 팬덤 피드에 글 삽입이 가능했다. 화이트리스트 + 원본 대조로 닫는다.
allow create: if authorIsSelf()
&& request.resource.data.keys().hasOnly([
'fandomId','authorUid','authorName','body','imageUrls',
'likeCount','commentCount','visibility','createdAt','updatedAt'
])
&& authorNameMatchesProfile()
&& fandomIsMine()
&& request.resource.data.likeCount == 0
&& request.resource.data.commentCount == 0
&& request.resource.data.visibility == 'visible';
// 교차검증 F-14 — 'title' 을 뺐다. 설계서 §2.6 posts 스키마에 title 필드가 없다.
allow update: if signedIn() && resource.data.authorUid == request.auth.uid
&& touchesOnly(['body','imageUrls','updatedAt']);
// 숨김·복원. rbac-summary 「콘텐츠 숨김·복원」 = 운영 이상
allow update: if isOps() && touchesOnly(['visibility','moderationNote','updatedAt']);
allow delete: if signedIn() && resource.data.authorUid == request.auth.uid;
}
// 좋아요. 문서 ID = uid 로 중복 좋아요를 구조적으로 차단한다.
match /posts/{postId}/likes/{uid} {
allow read: if signedIn();
allow create, delete: if isSelf(uid); // 집계 반영은 Functions 트리거
allow update: if false;
}
// 댓글. 화면: S-I-POST
match /posts/{postId}/comments/{commentId} {
allow read: if signedIn();
// 교차검증 F-05 — 댓글에도 같은 표시명 사칭 경로가 있었다.
allow create: if authorIsSelf()
&& request.resource.data.keys().hasOnly([
'authorUid','authorName','body','visibility','createdAt','updatedAt'
])
&& authorNameMatchesProfile()
&& request.resource.data.visibility == 'visible';
allow update: if signedIn() && resource.data.authorUid == request.auth.uid
&& touchesOnly(['body','updatedAt']);
allow update: if isOps() && touchesOnly(['visibility','moderationNote','updatedAt']);
allow delete: if signedIn() && resource.data.authorUid == request.auth.uid;
}
// 현상금 Q&A 질문. 현상금 예치가 POP 차감이라 생성도 Functions 경유다.
// 화면: S-I-KNOW · S-I-QUESTION · S-I-QUESTION-NEW
match /questions/{questionId} {
allow read: if signedIn();
allow write: if false; // fn-question-create(예치) · fn-answer-accept(전송)
}
// 답변. 등록은 재화 이동이 없어 클라이언트 쓰기를 허용하되, 채택 필드는 잠근다.
// 화면: S-I-QUESTION · S-I-ANSWER-NEW
match /questions/{questionId}/answers/{answerId} {
allow read: if signedIn();
// 교차검증 F-05 — 답변에도 같은 표시명 사칭 경로가 있었다.
allow create: if authorIsSelf()
&& request.resource.data.keys().hasOnly([
'authorUid','authorName','body','sourceUrls','accepted',
'visibility','createdAt','updatedAt'
])
&& authorNameMatchesProfile()
&& request.resource.data.accepted == false;
allow update: if signedIn() && resource.data.authorUid == request.auth.uid
&& touchesOnly(['body','sourceUrls','updatedAt']);
allow update: if isOps() && touchesOnly(['visibility','moderationNote','updatedAt']);
allow delete: if false; // 채택 대상이라 삭제 대신 숨김
}
// 신고. 접수는 본인 명의로만, 처리 상태는 운영만 바꾼다.
// 화면: S-I-REPORT · S-AD-REPORT
match /reports/{reportId} {
allow get: if isOps() || (signedIn() && resource.data.reporterUid == request.auth.uid);
allow list: if isOps();
allow create: if signedIn()
&& request.resource.data.reporterUid == request.auth.uid
&& request.resource.data.status == 'pending';
allow update: if isOps() && touchesOnly(['status','handledBy','handleNote','updatedAt']);
allow delete: if false;
}
// 문의. 화면: S-M-INQUIRY · S-AD-REPORT
match /inquiries/{inquiryId} {
allow get: if isOps() || (signedIn() && resource.data.authorUid == request.auth.uid);
allow list: if isOps();
allow create: if authorIsSelf() && request.resource.data.status == 'pending';
allow update: if isOps() && touchesOnly(['status','answerBody','handledBy','updatedAt']);
allow delete: if false;
}
// ───────────────────────── 7. STATION · 광고 · 공지 ─────────────────────────
// 큐레이션 링크. 화면: S-S-HUB
match /stationLinks/{linkId} {
allow read: if signedIn();
allow write: if isOps();
}
// 사용자 제안 링크. 심사 상태는 운영만 바꾼다. 화면: S-S-SUGGEST
match /stationSuggestions/{suggestionId} {
allow get: if isOps() || (signedIn() && resource.data.authorUid == request.auth.uid);
allow list: if isOps();
allow create: if authorIsSelf() && request.resource.data.status == 'pending';
allow update: if isOps() && touchesOnly(['status','reviewNote','updatedAt']);
allow delete: if false;
}
// 광고 보상 로그. 보상 지급 근거라 서버 전용. 화면: S-X-AD · S-AD-ADREV
match /adRewardLogs/{logId} {
allow read: if isViewer(); // rbac-summary 「광고 보상 내역」 = 조회 이상
allow write: if false;
}
// 공지·배너. 화면: S-X-NOTICE · S-AD-NOTICE
match /notices/{noticeId} {
allow read: if true;
allow write: if isOps();
}
// 푸시 발송 이력·도달률. 운영자만 보고, 발송 집계는 Functions(FCM)가 쓴다.
// 화면: S-AD-PUSH · rbac-summary 「푸시 발송 관리」 = 운영 이상 (교차검증 F-04 #2)
match /pushCampaigns/{pushId} {
allow read: if isViewer();
allow write: if false; // 발송·집계는 fn-push-send 경유
}
// ───────────────────────── 8. 사전예약 · 초대 코드 ─────────────────────────
// 사전예약 신청. 미로그인 웹앱에서 접수되고 중복·봇 판정이 붙어 서버 전용으로 둔다.
// 화면: S-W-PREREG · S-W-PREREG-DONE · S-AD-PREREG
// 근거: 정책설계서 P-06 reCAPTCHA v3 · E-PRE-02 중복 차단 · E-PRE-03 보류
match /preRegistrations/{preRegId} {
allow read: if isOps(); // 개인정보(연락처) 포함 — 운영 이상만
allow write: if false; // 접수는 fn-prereg-submit 경유
}
// 초대 코드. 코드 유효성 확인을 위해 미로그인 읽기가 필요하다.
// 남용 방지를 위해 문서에는 사용 횟수 등 최소 필드만 두고 개인정보를 담지 않는다.
// 화면: S-W-PREREG(코드 입력) · E-PRE-01
match /inviteCodes/{code} {
allow get: if true; // 코드 직접 조회만 허용
allow list: if isOps(); // 전수 열거는 운영 이상(코드 수집 방지)
allow write: if false;
}
// 신규 행성 개설 신청. 화면: S-W-PREREG-NEW · S-AD-PREREG
match /planetApplications/{applicationId} {
allow read: if isOps();
allow write: if false; // 접수·심사 모두 Functions
}
// ───────────────────────── 9. 참여형 번역 ─────────────────────────
// 번역 제출. 보상이 걸려 있어 제출은 Functions 경유(도배 제한·중복 검사).
// 화면: S-M-TRANSLATE · S-AD-TRANSLATE
match /translationEntries/{entryId} {
allow read: if signedIn();
allow write: if false;
}
// 마킹. 문서 ID = uid 로 중복 마킹을 차단하고, 본인 번역 자기 마킹은 서버가 막는다(E-TRN-01).
match /translationEntries/{entryId}/markings/{uid} {
allow read: if signedIn();
allow create: if isSelf(uid)
&& get(/databases/$(database)/documents/translationEntries/$(entryId))
.data.authorUid != request.auth.uid;
allow update, delete: if false; // 번복 불가 — 어뷰징 가드
}
// ───────────────────────── 10. 랭킹 사전 집계 ─────────────────────────
// 글로벌·국가·아티스트 랭킹 사전 집계 문서. 랭킹 화면은 이 문서 1건만 읽는다.
// 화면: S-G-FPI · S-I-RANK · S-M-HERO
match /rankings/{rankingId} {
allow read: if signedIn();
allow write: if false;
}
// ───────────────────────── 11. 관리자 ─────────────────────────
// 관리자 계정 목록. 실제 권한 판정은 커스텀 클레임이고 이 문서는 목록·감사용이다.
// 화면: S-AD-ACCOUNT
match /admins/{uid} {
allow read: if isViewer();
allow write: if false; // 클레임 설정과 함께 fn-admin-set-role 경유
}
// 관리자 작업 로그. 전건 기록이 판정 기준이라 수정·삭제를 규칙 층에서 봉인한다.
// 화면: S-AD-LOG · 근거: 별첨1 ⑨ 판정 기준 「관리자 조작 전건이 작업 로그로 추적 가능」
match /adminActionLogs/{logId} {
allow read: if isViewer();
allow write: if false;
}
// ─────────────── 12. 컬렉션 그룹 쿼리 (교차검증 F-01) ───────────────
//
// Firestore에서 `match /users/{uid}/popTransactions/{txId}` 형태의 규칙은
// **단일 컬렉션 쿼리에만** 적용되고 collectionGroup('popTransactions') 에는 적용되지 않는다.
// 재귀 와일드카드 블록이 없으면 §1.4가 지정한 컬렉션 그룹 쿼리가 전부 아래 기본 거부에 걸린다.
// 실측으로 S-AD-LEDGER·S-C-MINE 등 7건이 403이었다.
//
// 원칙: **권한을 넓히지 않는다.** 각 블록의 조건은 위 단일 컬렉션 블록과 동일하게 유지한다.
// 쓰기는 선언하지 않으므로 그대로 거부된다(원장 append-only 유지).
//
// 주의: 규칙은 OR로 평가되므로 이 블록들이 기존 단일 컬렉션 읽기를 좁히지 않는다.
// S-AD-LEDGER 전체 원장 (IDX-08·09). 조회 등급 이상만.
// 본인 내역은 /users/{uid}/popTransactions 단일 컬렉션 경로를 쓰므로 여기서 열지 않는다.
match /{path=**}/popTransactions/{txId} {
allow read: if isViewer();
}
// S-AD-ABUSE 어뷰징 모니터링 (IDX-13). 운영자 전용.
match /{path=**}/activityLogs/{logId} {
allow read: if isViewer();
}
// S-AD-ABUSE 일일 한도 감사 (IDX-14). 운영자 전용.
match /{path=**}/questProgress/{questId} {
allow read: if isViewer();
}
// S-AD-CONTENT 댓글 전수 조회 (IDX-05). 운영자 전용.
match /{path=**}/comments/{commentId} {
allow read: if isViewer();
}
// S-C-MINE·S-C-REFUND 내 후원 내역 (IDX-20).
// 단일 컬렉션 규칙과 동일하게 로그인 사용자 읽기(후원자 리더보드가 이미 공개 범위).
match /{path=**}/donations/{donationId} {
allow read: if signedIn();
}
// S-M-HOME 내 답변 (IDX-26). 단일 컬렉션 규칙과 동일.
match /{path=**}/answers/{answerId} {
allow read: if signedIn();
}
// popLots(IDX-11)·환불 배치 donations(IDX-21)는 스케줄 Function 전용이다.
// Admin SDK는 규칙을 우회하므로 컬렉션 그룹 블록을 두지 않는다 — 열면 권한만 넓어진다.
// ───────────────────────── 13. 기본 거부 ─────────────────────────
// 위에서 명시하지 않은 모든 경로는 거부한다.
match /{document=**} {
allow read, write: if false;
}
}
}
+4
View File
@@ -0,0 +1,4 @@
node_modules/
coverage/
.firebase/
+28
View File
@@ -0,0 +1,28 @@
# Firestore 보안 규칙 회귀 테스트
Firebase 실제 프로젝트 없이 로컬 Firestore 에뮬레이터에서 `../firestore.rules`의 allow/deny 동작을 검증합니다.
## 실행
```bash
cd app/firebase/test
npm ci
npm run test:emulator
```
`test:emulator`는 다음 순서로 실행됩니다.
1. Firebase CLI가 `demo-fancake` 프로젝트 ID로 Firestore 에뮬레이터를 기동합니다.
2. `initializeTestEnvironment()`가 `../firestore.rules` 원문을 에뮬레이터 규칙 REST API로 적재합니다.
3. 음성 대조군이 로그인 사용자의 지갑 쓰기가 `permission-denied`인지 먼저 확인합니다.
4. 소유자 지갑 읽기 양성 대조군을 확인한 뒤 Vitest 회귀 테스트 전체를 실행합니다.
Firebase CLI의 `emulators:exec` 기동 성공만으로 규칙 적재·컴파일 성공을 판단하지 않습니다. 규칙 적재 실패 시 2단계에서 실행이 중단되고, 음성 대조군이 허용되면 3단계에서 실행이 중단됩니다.
## 복합 인덱스 검증 한계 실측
```bash
npm run probe:indexes:emulator
```
이 프로브는 `../firestore.indexes.json`에 없는 4필드 `posts` 복합 인덱스를 먼저 확인한 뒤, 그 인덱스가 필요한 유효 쿼리를 에뮬레이터에 보냅니다. 쿼리가 허용되면 에뮬레이터가 복합 인덱스를 강제하지 않는다는 뜻이므로, 인덱스 배포 유효성 검증에는 사용할 수 없습니다.
+2294
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "fancake-firestore-rules-tests",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=24"
},
"scripts": {
"test": "npm run test:control && npm run test:regression",
"test:control": "node scripts/negative-control.mjs",
"test:regression": "vitest run",
"test:emulator": "firebase emulators:exec --config ../firebase.json --only firestore --project demo-fancake \"npm test\"",
"probe:indexes": "node scripts/index-emulator-probe.mjs",
"probe:indexes:emulator": "firebase emulators:exec --config ../firebase.json --only firestore --project demo-fancake \"npm run probe:indexes\""
},
"dependencies": {
"@firebase/rules-unit-testing": "5.0.1",
"firebase": "12.17.1"
},
"devDependencies": {
"vitest": "4.1.10"
}
}
@@ -0,0 +1,74 @@
import { readFileSync } from 'node:fs';
import {
collection,
getDocs,
orderBy,
query,
where,
} from 'firebase/firestore';
import {
createTestEnvironment,
seedDocuments,
} from '../support/environment.mjs';
const manifest = JSON.parse(
readFileSync(new URL('../../firestore.indexes.json', import.meta.url), 'utf8'),
);
const deliberatelyMissingFields = [
['fandomId', 'ASCENDING'],
['visibility', 'ASCENDING'],
['createdAt', 'DESCENDING'],
['likeCount', 'DESCENDING'],
];
const hasDeliberatelyMissingIndex = manifest.indexes.some((index) => {
if (index.collectionGroup !== 'posts' || index.fields.length !== deliberatelyMissingFields.length) {
return false;
}
return deliberatelyMissingFields.every(
([fieldPath, order], position) =>
index.fields[position].fieldPath === fieldPath && index.fields[position].order === order,
);
});
if (hasDeliberatelyMissingIndex) {
throw new Error('Probe precondition failed: deliberately missing composite index exists');
}
console.log('INDEX MANIFEST CONTROL PASS: required 4-field posts composite index -> absent');
const testEnvironment = await createTestEnvironment();
try {
await testEnvironment.clearFirestore();
await seedDocuments(testEnvironment, {
'posts/post-1': {
fandomId: 'fandom-a',
visibility: 'visible',
createdAt: '2026-08-08T00:00:00Z',
likeCount: 1,
},
'posts/post-2': {
fandomId: 'fandom-a',
visibility: 'visible',
createdAt: '2026-08-08T01:00:00Z',
likeCount: 2,
},
});
const firestore = testEnvironment.authenticatedContext('alice').firestore();
const missingIndexQuery = query(
collection(firestore, 'posts'),
where('fandomId', '==', 'fandom-a'),
where('visibility', '==', 'visible'),
orderBy('createdAt', 'desc'),
orderBy('likeCount', 'desc'),
);
const result = await getDocs(missingIndexQuery);
console.log(`EMULATOR MISSING-INDEX QUERY RESULT: allowed (${result.size} documents)`);
console.log('INDEX VALIDATION VERDICT: unsupported — emulator does not enforce composite indexes');
} finally {
await testEnvironment.cleanup();
}
@@ -0,0 +1,41 @@
import { doc, getDoc, setDoc } from 'firebase/firestore';
import { assertSucceeds } from '@firebase/rules-unit-testing';
import {
createTestEnvironment,
seedDocuments,
userDocument,
} from '../support/environment.mjs';
const testEnvironment = await createTestEnvironment();
try {
await testEnvironment.clearFirestore();
const uid = 'negative-control-user';
const userDb = testEnvironment.authenticatedContext(uid).firestore();
let denial;
try {
await setDoc(doc(userDb, `users/${uid}/wallet/main`), { balance: 999_999 });
} catch (error) {
denial = error;
}
if (denial?.code !== 'permission-denied') {
throw new Error(
`Negative control failed: expected permission-denied, received ${denial?.code ?? 'success'}`,
);
}
console.log('NEGATIVE CONTROL PASS: authenticated wallet write -> permission-denied');
await seedDocuments(testEnvironment, {
[`users/${uid}`]: userDocument(uid),
[`users/${uid}/wallet/main`]: { balance: 100 },
});
await assertSucceeds(getDoc(doc(userDb, `users/${uid}/wallet/main`)));
console.log('REACHABILITY CONTROL PASS: owner wallet read -> allowed');
} finally {
await testEnvironment.cleanup();
}
+59
View File
@@ -0,0 +1,59 @@
import { readFileSync } from 'node:fs';
import { initializeTestEnvironment } from '@firebase/rules-unit-testing';
import { doc, setDoc } from 'firebase/firestore';
export const projectId = process.env.GCLOUD_PROJECT ?? 'demo-fancake';
function emulatorAddress() {
const value = process.env.FIRESTORE_EMULATOR_HOST ?? '127.0.0.1:8080';
const separator = value.lastIndexOf(':');
if (separator < 1) {
throw new Error(`Invalid FIRESTORE_EMULATOR_HOST: ${value}`);
}
const host = value.slice(0, separator);
const port = Number(value.slice(separator + 1));
if (!Number.isInteger(port)) {
throw new Error(`Invalid Firestore emulator port: ${value}`);
}
return { host, port };
}
export async function createTestEnvironment() {
const rules = readFileSync(new URL('../../firestore.rules', import.meta.url), 'utf8');
return initializeTestEnvironment({
projectId,
firestore: {
...emulatorAddress(),
rules,
},
});
}
export async function seedDocuments(testEnvironment, documents) {
await testEnvironment.withSecurityRulesDisabled(async (context) => {
const firestore = context.firestore();
await Promise.all(
Object.entries(documents).map(([path, data]) => setDoc(doc(firestore, path), data)),
);
});
}
export function userDocument(uid, overrides = {}) {
return {
uid,
displayName: uid,
fandomId: 'fandom-a',
grade: 'SILVER',
totalContributedPop: 0,
status: 'active',
updatedAt: '2026-08-08T00:00:00Z',
...overrides,
};
}
@@ -0,0 +1,700 @@
import {
assertFails,
assertSucceeds,
} from '@firebase/rules-unit-testing';
import {
collectionGroup,
deleteDoc,
doc,
getDoc,
getDocs,
setDoc,
updateDoc,
} from 'firebase/firestore';
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
test,
} from 'vitest';
import {
createTestEnvironment,
seedDocuments,
userDocument,
} from '../support/environment.mjs';
let testEnvironment;
const dbFor = (uid, claims = {}) =>
testEnvironment.authenticatedContext(uid, claims).firestore();
const anonymousDb = () => testEnvironment.unauthenticatedContext().firestore();
beforeAll(async () => {
testEnvironment = await createTestEnvironment();
});
beforeEach(async () => {
await testEnvironment.clearFirestore();
});
afterAll(async () => {
await testEnvironment.cleanup();
});
describe('원칙 1 — POP·FPI·등급·원장은 클라이언트 쓰기 불가', () => {
test('본인 지갑 잔액 직접 증액을 거부한다', async () => {
await seedDocuments(testEnvironment, {
'users/alice/wallet/main': { balance: 100 },
});
await assertFails(updateDoc(doc(dbFor('alice'), 'users/alice/wallet/main'), { balance: 999 }));
});
test('POP 원장 문서 생성을 거부한다', async () => {
await assertFails(
setDoc(doc(dbFor('alice'), 'users/alice/popTransactions/forged'), {
amount: 1000,
reason: 'QUEST_REWARD',
}),
);
});
test('POP Lot 만료일 연장을 거부한다', async () => {
await seedDocuments(testEnvironment, {
'users/alice/popLots/lot-1': { remaining: 100, expiresAt: '2026-08-09T00:00:00Z' },
});
await assertFails(
updateDoc(doc(dbFor('alice'), 'users/alice/popLots/lot-1'), {
expiresAt: '2099-12-31T00:00:00Z',
}),
);
});
test('FPI 이벤트 위조를 거부한다', async () => {
await assertFails(
setDoc(doc(dbFor('alice'), 'fandoms/fandom-a/fpiEvents/forged'), {
uid: 'alice',
score: 999_999,
}),
);
});
test('사용자 등급 직접 승격을 거부한다', async () => {
await seedDocuments(testEnvironment, {
'users/alice': userDocument('alice'),
});
await assertFails(updateDoc(doc(dbFor('alice'), 'users/alice'), { grade: 'DIAMOND' }));
});
test('다른 사용자의 지갑 쓰기를 거부한다', async () => {
await seedDocuments(testEnvironment, {
'users/bob/wallet/main': { balance: 100 },
});
await assertFails(updateDoc(doc(dbFor('alice'), 'users/bob/wallet/main'), { balance: 0 }));
});
test('행성 성장 단계 조작을 거부한다', async () => {
await seedDocuments(testEnvironment, {
'fandoms/fandom-a/planets/alice': { ownerUid: 'alice', stage: 1, colorKey: 'blue' },
});
await assertFails(
updateDoc(doc(dbFor('alice'), 'fandoms/fandom-a/planets/alice'), { stage: 7 }),
);
});
});
describe('원칙 2 — 개인 리소스와 쓰기 필드 제한', () => {
beforeEach(async () => {
await seedDocuments(testEnvironment, {
'users/alice': userDocument('alice', { displayName: 'Alice' }),
'users/bob': userDocument('bob', { displayName: 'Bob' }),
});
});
test('다른 사용자의 프로필 읽기를 거부한다', async () => {
await assertFails(getDoc(doc(dbFor('alice'), 'users/bob')));
});
test('다른 사용자의 프로필 쓰기를 거부한다', async () => {
await assertFails(updateDoc(doc(dbFor('alice'), 'users/bob'), { displayName: 'Forged' }));
});
test('허용 필드와 등급을 섞은 본인 업데이트를 전부 거부한다', async () => {
await assertFails(
updateDoc(doc(dbFor('alice'), 'users/alice'), {
displayName: 'New Alice',
grade: 'DIAMOND',
}),
);
});
test('미인증 프로필 접근을 거부한다', async () => {
await assertFails(getDoc(doc(anonymousDb(), 'users/alice')));
});
test('허용된 본인 프로필 필드만 바꾸면 허용한다', async () => {
await assertSucceeds(
updateDoc(doc(dbFor('alice'), 'users/alice'), {
displayName: 'New Alice',
locale: 'ko',
updatedAt: '2026-08-08T01:00:00Z',
}),
);
});
});
describe('G-02 — 약관 동의 이력 무결성 회귀', () => {
beforeEach(async () => {
await seedDocuments(testEnvironment, {
'users/alice/consents/terms-v1': {
termsVersion: '1.0',
agreedAt: '2026-08-08T00:00:00Z',
},
'users/bob/consents/terms-v1': {
termsVersion: '1.0',
agreedAt: '2026-08-08T00:00:00Z',
},
});
});
test.each([
[
'생성을',
() => setDoc(doc(dbFor('alice'), 'users/alice/consents/terms-v2'), {
termsVersion: '2.0',
agreedAt: '2026-08-08T01:00:00Z',
}),
],
[
'수정을',
() => updateDoc(doc(dbFor('alice'), 'users/alice/consents/terms-v1'), {
agreedAt: '2099-12-31T00:00:00Z',
}),
],
[
'삭제를',
() => deleteDoc(doc(dbFor('alice'), 'users/alice/consents/terms-v1')),
],
])('본인의 consents 문서 %s 거부한다', async (_operation, attempt) => {
await assertFails(attempt());
});
test('다른 사용자의 consents 문서 읽기를 거부한다', async () => {
await assertFails(getDoc(doc(dbFor('alice'), 'users/bob/consents/terms-v1')));
});
test('본인의 consents 문서 읽기를 허용한다', async () => {
await assertSucceeds(getDoc(doc(dbFor('alice'), 'users/alice/consents/terms-v1')));
});
});
describe('F-01 — 컬렉션 그룹 쿼리 회귀', () => {
test('로그인 사용자의 donations 컬렉션 그룹 조회를 허용한다', async () => {
await seedDocuments(testEnvironment, {
'campaigns/campaign-a/donations/donation-1': { donorUid: 'alice', amount: 10 },
});
await assertSucceeds(getDocs(collectionGroup(dbFor('alice'), 'donations')));
});
test('미인증 donations 컬렉션 그룹 조회를 거부한다', async () => {
await seedDocuments(testEnvironment, {
'campaigns/campaign-a/donations/donation-1': { donorUid: 'alice', amount: 10 },
});
await assertFails(getDocs(collectionGroup(anonymousDb(), 'donations')));
});
test('로그인 사용자의 answers 컬렉션 그룹 조회를 허용한다', async () => {
await seedDocuments(testEnvironment, {
'questions/question-1/answers/answer-1': { authorUid: 'alice', body: 'answer' },
});
await assertSucceeds(getDocs(collectionGroup(dbFor('alice'), 'answers')));
});
test.each([
['popTransactions', 'users/alice/popTransactions/tx-1'],
['activityLogs', 'users/alice/activityLogs/log-1'],
['questProgress', 'users/alice/questProgress/quest-1'],
['comments', 'posts/post-1/comments/comment-1'],
])('viewer의 %s 컬렉션 그룹 조회를 허용한다', async (group, path) => {
await seedDocuments(testEnvironment, { [path]: { ownerUid: 'alice' } });
await assertSucceeds(getDocs(collectionGroup(dbFor('viewer', { role: 'viewer' }), group)));
});
test.each([
['popTransactions', 'users/alice/popTransactions/tx-1'],
['activityLogs', 'users/alice/activityLogs/log-1'],
['questProgress', 'users/alice/questProgress/quest-1'],
['comments', 'posts/post-1/comments/comment-1'],
])('일반 사용자의 %s 컬렉션 그룹 조회를 계속 거부한다', async (group, path) => {
await seedDocuments(testEnvironment, { [path]: { ownerUid: 'alice' } });
await assertFails(getDocs(collectionGroup(dbFor('ordinary-user'), group)));
});
});
describe('F-02 — 캠페인 집계 필드 위조 회귀', () => {
const validCampaign = {
fandomId: 'fandom-a',
creatorUid: 'alice',
creatorName: 'Alice',
title: 'Campaign',
summary: 'Summary',
body: 'Body',
coverImageUrl: 'https://example.test/cover.png',
campaignType: 'support',
targetPop: 1000,
endsAt: '2026-09-01T00:00:00Z',
status: 'draft',
raisedPop: 0,
achievedRate: 0,
donorCount: 0,
votedPop: 0,
isAiDraft: false,
refundState: 'none',
createdAt: '2026-08-08T00:00:00Z',
updatedAt: '2026-08-08T00:00:00Z',
};
beforeEach(async () => {
await seedDocuments(testEnvironment, {
'users/alice': userDocument('alice', { displayName: 'Alice' }),
});
});
test('정상 초기값의 캠페인 생성을 허용한다', async () => {
await assertSucceeds(setDoc(doc(dbFor('alice'), 'campaigns/valid'), validCampaign));
});
test('집계·투표·AI 초안 필드 위조를 거부한다', async () => {
await assertFails(
setDoc(doc(dbFor('alice'), 'campaigns/forged'), {
...validCampaign,
achievedRate: 1,
donorCount: 99_999,
votedPop: 999_999,
isAiDraft: true,
}),
);
});
test('화이트리스트 밖 집계 필드 추가를 거부한다', async () => {
await assertFails(
setDoc(doc(dbFor('alice'), 'campaigns/extra-field'), {
...validCampaign,
serverAggregate: 999_999,
}),
);
});
});
describe('F-05 — 작성자 표시명 사칭 회귀', () => {
beforeEach(async () => {
await seedDocuments(testEnvironment, {
'users/alice': userDocument('alice', { displayName: 'Alice' }),
});
});
test('정상 게시물 생성을 허용한다', async () => {
await assertSucceeds(
setDoc(doc(dbFor('alice'), 'posts/valid'), {
fandomId: 'fandom-a',
authorUid: 'alice',
authorName: 'Alice',
body: 'hello',
imageUrls: [],
likeCount: 0,
commentCount: 0,
visibility: 'visible',
createdAt: '2026-08-08T00:00:00Z',
updatedAt: '2026-08-08T00:00:00Z',
}),
);
});
test('게시물 authorName 사칭을 거부한다', async () => {
await assertFails(
setDoc(doc(dbFor('alice'), 'posts/forged'), {
fandomId: 'fandom-a',
authorUid: 'alice',
authorName: 'Fancake 운영팀',
body: 'forged',
imageUrls: [],
likeCount: 0,
commentCount: 0,
visibility: 'visible',
createdAt: '2026-08-08T00:00:00Z',
updatedAt: '2026-08-08T00:00:00Z',
}),
);
});
test('댓글 authorName 사칭을 거부한다', async () => {
await assertFails(
setDoc(doc(dbFor('alice'), 'posts/post-1/comments/forged'), {
authorUid: 'alice',
authorName: 'Fancake 운영팀',
body: 'forged',
visibility: 'visible',
createdAt: '2026-08-08T00:00:00Z',
updatedAt: '2026-08-08T00:00:00Z',
}),
);
});
test('답변 authorName 사칭을 거부한다', async () => {
await assertFails(
setDoc(doc(dbFor('alice'), 'questions/question-1/answers/forged'), {
authorUid: 'alice',
authorName: 'Fancake 운영팀',
body: 'forged',
sourceUrls: [],
accepted: false,
visibility: 'visible',
createdAt: '2026-08-08T00:00:00Z',
updatedAt: '2026-08-08T00:00:00Z',
}),
);
});
});
describe('F-14 — posts 허용 필드 회귀', () => {
test('작성자도 스키마에 없는 title 필드를 추가할 수 없다', async () => {
await seedDocuments(testEnvironment, {
'posts/post-1': {
authorUid: 'alice',
body: 'before',
imageUrls: [],
updatedAt: '2026-08-08T00:00:00Z',
},
});
await assertFails(
updateDoc(doc(dbFor('alice'), 'posts/post-1'), {
title: '스키마 밖 필드',
}),
);
});
});
describe('F-06 — users 클라이언트 생성 경로 회귀', () => {
test('일반 사용자의 users 문서 생성을 거부한다', async () => {
await assertFails(
setDoc(doc(dbFor('alice'), 'users/alice'), userDocument('alice', { grade: 'DIAMOND' })),
);
});
test('master 클레임도 users 문서를 직접 생성할 수 없다', async () => {
await assertFails(
setDoc(
doc(dbFor('master-user', { role: 'master' }), 'users/new-user'),
userDocument('new-user'),
),
);
});
});
describe('F-07 — 결과보고 대상 상태 회귀', () => {
test('실패 캠페인 결과보고 생성을 거부한다', async () => {
await seedDocuments(testEnvironment, {
'campaigns/failed': { creatorUid: 'alice', status: 'fail' },
});
await assertFails(
setDoc(doc(dbFor('alice'), 'campaigns/failed/reports/report-1'), { body: 'report' }),
);
});
test('성공 캠페인 개설자의 결과보고 생성을 허용한다', async () => {
await seedDocuments(testEnvironment, {
'campaigns/success': { creatorUid: 'alice', status: 'success' },
});
await assertSucceeds(
setDoc(doc(dbFor('alice'), 'campaigns/success/reports/report-1'), { body: 'report' }),
);
});
});
describe('F-08 — 미승인 캠페인 열람 회귀', () => {
beforeEach(async () => {
await seedDocuments(testEnvironment, {
'campaigns/review': { creatorUid: 'alice', status: 'review' },
'campaigns/funding': { creatorUid: 'alice', status: 'funding' },
});
});
test('미인증 사용자의 review 캠페인 열람을 거부한다', async () => {
await assertFails(getDoc(doc(anonymousDb(), 'campaigns/review')));
});
test('일반 타 사용자의 review 캠페인 열람을 거부한다', async () => {
await assertFails(getDoc(doc(dbFor('bob'), 'campaigns/review')));
});
test('개설자와 ops의 review 캠페인 열람을 허용한다', async () => {
await assertSucceeds(getDoc(doc(dbFor('alice'), 'campaigns/review')));
await assertSucceeds(getDoc(doc(dbFor('ops-user', { role: 'ops' }), 'campaigns/review')));
});
test('미인증 사용자의 funding 캠페인 열람은 계속 허용한다', async () => {
await assertSucceeds(getDoc(doc(anonymousDb(), 'campaigns/funding')));
});
});
describe('원칙 3 — 권한은 커스텀 클레임 role로만 판정', () => {
test('users 문서에 role=master를 심어도 관리자 권한을 얻지 못한다', async () => {
await seedDocuments(testEnvironment, {
'users/forged-admin': userDocument('forged-admin', { role: 'master' }),
'adminActionLogs/log-1': { action: 'seed' },
'admins/real-admin': { role: 'master' },
});
const forgedDb = dbFor('forged-admin');
await assertFails(getDoc(doc(forgedDb, 'adminActionLogs/log-1')));
await assertFails(getDoc(doc(forgedDb, 'admins/real-admin')));
await assertFails(setDoc(doc(forgedDb, 'config/pop'), { reward: 999_999 }));
});
test('viewer는 관리자 조회 리소스를 읽을 수 있다', async () => {
await seedDocuments(testEnvironment, {
'users/alice': userDocument('alice'),
'adminActionLogs/log-1': { action: 'seed' },
'admins/admin-1': { role: 'master' },
'adRewardLogs/reward-1': { uid: 'alice' },
});
const viewerDb = dbFor('viewer-user', { role: 'viewer' });
await assertSucceeds(getDoc(doc(viewerDb, 'users/alice')));
await assertSucceeds(getDoc(doc(viewerDb, 'adminActionLogs/log-1')));
await assertSucceeds(getDoc(doc(viewerDb, 'admins/admin-1')));
await assertSucceeds(getDoc(doc(viewerDb, 'adRewardLogs/reward-1')));
});
test('viewer의 관리자 쓰기를 거부한다', async () => {
const viewerDb = dbFor('viewer-user', { role: 'viewer' });
await assertFails(setDoc(doc(viewerDb, 'notices/notice-1'), { title: 'forged' }));
await assertFails(setDoc(doc(viewerDb, 'config/pop'), { reward: 999_999 }));
});
const directAdminOperations = [
{
name: '사용자 제재',
minimumRole: 'ops',
seed: { 'users/alice': userDocument('alice') },
run: (db) => updateDoc(doc(db, 'users/alice'), {
status: 'suspended',
banReason: 'test',
updatedAt: '2026-08-08T01:00:00Z',
}),
},
{
name: '아티스트 관리',
minimumRole: 'ops',
run: (db) => setDoc(doc(db, 'artists/artist-1'), { name: 'Artist' }),
},
{
name: '장소 관리',
minimumRole: 'ops',
run: (db) => setDoc(doc(db, 'places/place-1'), { name: 'Place' }),
},
{
name: '브리핑 수동 편집',
minimumRole: 'ops',
run: (db) => setDoc(doc(db, 'briefings/briefing-1'), { body: 'Briefing' }),
},
{
name: '캠페인 승인',
minimumRole: 'ops',
seed: { 'campaigns/campaign-1': { creatorUid: 'alice', status: 'review' } },
run: (db) => updateDoc(doc(db, 'campaigns/campaign-1'), {
status: 'funding',
reviewNote: 'approved',
reviewedBy: 'admin',
updatedAt: '2026-08-08T01:00:00Z',
}),
},
{
name: '콘텐츠 숨김',
minimumRole: 'ops',
seed: { 'posts/post-1': { authorUid: 'alice', visibility: 'visible' } },
run: (db) => updateDoc(doc(db, 'posts/post-1'), {
visibility: 'hidden',
moderationNote: 'test',
updatedAt: '2026-08-08T01:00:00Z',
}),
},
{
name: '신고 처리',
minimumRole: 'ops',
seed: { 'reports/report-1': { reporterUid: 'alice', status: 'pending' } },
run: (db) => updateDoc(doc(db, 'reports/report-1'), {
status: 'resolved',
handledBy: 'admin',
handleNote: 'test',
updatedAt: '2026-08-08T01:00:00Z',
}),
},
{
name: '문의 처리',
minimumRole: 'ops',
seed: { 'inquiries/inquiry-1': { authorUid: 'alice', status: 'pending' } },
run: (db) => updateDoc(doc(db, 'inquiries/inquiry-1'), {
status: 'answered',
answerBody: 'answer',
handledBy: 'admin',
updatedAt: '2026-08-08T01:00:00Z',
}),
},
{
name: '스테이션 링크 관리',
minimumRole: 'ops',
run: (db) => setDoc(doc(db, 'stationLinks/link-1'), { url: 'https://example.test' }),
},
{
name: '제안 링크 심사',
minimumRole: 'ops',
seed: { 'stationSuggestions/suggestion-1': { authorUid: 'alice', status: 'pending' } },
run: (db) => updateDoc(doc(db, 'stationSuggestions/suggestion-1'), {
status: 'approved',
reviewNote: 'approved',
updatedAt: '2026-08-08T01:00:00Z',
}),
},
{
name: '공지 관리',
minimumRole: 'ops',
run: (db) => setDoc(doc(db, 'notices/notice-1'), { title: 'Notice' }),
},
{
name: '재화·FPI 설정',
minimumRole: 'master',
run: (db) => setDoc(doc(db, 'config/pop'), { reward: 10 }),
},
{
name: '점검·버전 설정',
minimumRole: 'master',
run: (db) => setDoc(doc(db, 'appConfig/current'), { maintenance: false }),
},
{
name: '다국어 문자열 관리',
minimumRole: 'master',
run: (db) => setDoc(doc(db, 'i18n/ko'), { hello: '안녕' }),
},
{
name: '행성 성장 단계 관리',
minimumRole: 'master',
run: (db) => setDoc(doc(db, 'planetStages/1'), { threshold: 100 }),
},
{
name: '퀘스트 파라미터 관리',
minimumRole: 'master',
run: (db) => setDoc(doc(db, 'quests/quest-1'), { reward: 10 }),
},
{
name: '배지 마스터 관리',
minimumRole: 'master',
run: (db) => setDoc(doc(db, 'badges/badge-1'), { name: 'Badge' }),
},
];
for (const operation of directAdminOperations) {
test(`RBAC: viewer는 ${operation.name}을 수행할 수 없다`, async () => {
if (operation.seed) await seedDocuments(testEnvironment, operation.seed);
await assertFails(operation.run(dbFor('viewer-user', { role: 'viewer' })));
});
test(`RBAC: ops의 ${operation.name} 권한이 매트릭스와 일치한다`, async () => {
if (operation.seed) await seedDocuments(testEnvironment, operation.seed);
const attempt = operation.run(dbFor('ops-user', { role: 'ops' }));
if (operation.minimumRole === 'ops') {
await assertSucceeds(attempt);
} else {
await assertFails(attempt);
}
});
test(`RBAC: master는 ${operation.name}을 수행할 수 있다`, async () => {
if (operation.seed) await seedDocuments(testEnvironment, operation.seed);
await assertSucceeds(operation.run(dbFor('master-user', { role: 'master' })));
});
}
test('운영 이상만 사전예약·행성개설 신청을 읽을 수 있다', async () => {
await seedDocuments(testEnvironment, {
'preRegistrations/pre-1': { socialUid: 'alice' },
'planetApplications/application-1': { applicantUid: 'alice' },
});
await assertFails(getDoc(doc(dbFor('viewer-user', { role: 'viewer' }), 'preRegistrations/pre-1')));
await assertFails(getDoc(doc(dbFor('viewer-user', { role: 'viewer' }), 'planetApplications/application-1')));
await assertSucceeds(getDoc(doc(dbFor('ops-user', { role: 'ops' }), 'preRegistrations/pre-1')));
await assertSucceeds(getDoc(doc(dbFor('ops-user', { role: 'ops' }), 'planetApplications/application-1')));
});
test.each([
['POP 지갑', 'users/alice/wallet/main', { balance: 1 }],
['POP 원장', 'users/alice/popTransactions/tx-1', { amount: 1 }],
['FPI 이벤트', 'fandoms/fandom-a/fpiEvents/event-1', { score: 1 }],
['캠페인 후원·환불', 'campaigns/campaign-1/donations/donation-1', { amount: 1 }],
['사전예약', 'preRegistrations/pre-1', { socialUid: 'alice' }],
['행성 개설 신청', 'planetApplications/application-1', { applicantUid: 'alice' }],
['번역 검수 원천', 'translationEntries/entry-1', { body: 'translation' }],
['관리자 계정', 'admins/admin-1', { role: 'master' }],
['관리자 작업 로그', 'adminActionLogs/log-1', { action: 'forged' }],
])('Functions 전용 경계: master도 %s 직접 쓰기가 거부된다', async (_name, path, data) => {
await assertFails(setDoc(doc(dbFor('master-user', { role: 'master' }), path), data));
});
});
describe('캠페인 후원·환불과 기본 거부', () => {
test('일반 사용자의 캠페인 후원 문서 생성·수정·삭제를 모두 거부한다', async () => {
await seedDocuments(testEnvironment, {
'campaigns/campaign-1/donations/donation-1': { donorUid: 'alice', amount: 10 },
});
const donation = doc(dbFor('alice'), 'campaigns/campaign-1/donations/donation-1');
await assertFails(setDoc(doc(dbFor('alice'), 'campaigns/campaign-1/donations/new'), { amount: 10 }));
await assertFails(updateDoc(donation, { refundState: 'complete' }));
await assertFails(deleteDoc(donation));
});
test.each([
['ordinary', {}],
['master', { role: 'master' }],
])('규칙 밖 경로는 %s 사용자에게도 읽기·쓰기를 기본 거부한다', async (uid, claims) => {
await seedDocuments(testEnvironment, {
'unmatchedCollection/secret': { value: 'secret' },
});
const firestore = dbFor(uid, claims);
await assertFails(getDoc(doc(firestore, 'unmatchedCollection/secret')));
await assertFails(setDoc(doc(firestore, 'unmatchedCollection/new'), { value: 'forged' }));
});
test('미인증 사용자의 규칙 밖 경로 읽기·쓰기를 기본 거부한다', async () => {
await seedDocuments(testEnvironment, {
'unmatchedCollection/secret': { value: 'secret' },
});
await assertFails(getDoc(doc(anonymousDb(), 'unmatchedCollection/secret')));
await assertFails(setDoc(doc(anonymousDb(), 'unmatchedCollection/new'), { value: 'forged' }));
});
});
describe('테스트 자가 점검', () => {
test('규칙이 적재된 프로젝트 ID가 demo- 접두사를 사용한다', () => {
expect(testEnvironment.projectId).toMatch(/^demo-/);
});
});
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.mjs'],
fileParallelism: false,
hookTimeout: 20_000,
testTimeout: 20_000,
},
});
+34
View File
@@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>
+2
View File
@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
+2
View File
@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
+43
View File
@@ -0,0 +1,43 @@
# Uncomment this line to define a global platform for your project
platform :ios, '15.0' # firebase_auth 6.x 최소 요구치. 기준 단말 iPhone 11 은 iOS 18까지 지원하므로 영향 없다.
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end
+159
View File
@@ -0,0 +1,159 @@
PODS:
- AppAuth (2.1.0):
- AppAuth/Core (= 2.1.0)
- AppAuth/ExternalUserAgent (= 2.1.0)
- AppAuth/Core (2.1.0)
- AppAuth/ExternalUserAgent (2.1.0):
- AppAuth/Core
- AppCheckCore (11.3.1):
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- PromisesObjC (~> 2.4)
- PromisesSwift (~> 2.4)
- RecaptchaInterop (~> 101.0)
- Firebase/Auth (12.17.0):
- Firebase/CoreOnly
- FirebaseAuth (~> 12.17.0)
- Firebase/CoreOnly (12.17.0):
- FirebaseCore (~> 12.17.0)
- firebase_auth (6.5.7):
- Firebase/Auth (= 12.17.0)
- firebase_core
- Flutter
- firebase_core (4.13.0):
- Firebase/CoreOnly (= 12.17.0)
- Flutter
- FirebaseAppCheckInterop (12.17.0)
- FirebaseAuth (12.17.0):
- FirebaseAppCheckInterop (~> 12.17.0)
- FirebaseAuthInterop (~> 12.17.0)
- FirebaseCore (~> 12.17.0)
- FirebaseCoreExtension (~> 12.17.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/Environment (~> 8.1)
- GTMSessionFetcher/Core (< 6.0, >= 3.4)
- RecaptchaInterop (~> 101.0)
- FirebaseAuthInterop (12.17.0)
- FirebaseCore (12.17.0):
- FirebaseCoreInternal (~> 12.17.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Logger (~> 8.1)
- FirebaseCoreExtension (12.17.0):
- FirebaseCore (~> 12.17.0)
- FirebaseCoreInternal (12.17.0):
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- Flutter (1.0.0)
- google_sign_in_ios (0.0.1):
- Flutter
- FlutterMacOS
- GoogleSignIn (~> 9.0)
- GTMSessionFetcher (>= 3.4.0)
- GoogleSignIn (9.2.0):
- AppAuth (~> 2.1)
- AppCheckCore (~> 11.0)
- GTMAppAuth (~> 5.0)
- GTMSessionFetcher/Core (~> 3.3)
- GoogleUtilities/AppDelegateSwizzler (8.1.2):
- GoogleUtilities/Environment
- GoogleUtilities/Logger
- GoogleUtilities/Network
- GoogleUtilities/Privacy
- GoogleUtilities/Environment (8.1.2):
- GoogleUtilities/Privacy
- GoogleUtilities/Logger (8.1.2):
- GoogleUtilities/Environment
- GoogleUtilities/Privacy
- GoogleUtilities/Network (8.1.2):
- GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib"
- GoogleUtilities/Privacy
- GoogleUtilities/Reachability
- "GoogleUtilities/NSData+zlib (8.1.2)":
- GoogleUtilities/Privacy
- GoogleUtilities/Privacy (8.1.2)
- GoogleUtilities/Reachability (8.1.2):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GoogleUtilities/UserDefaults (8.1.2):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GTMAppAuth (5.0.0):
- AppAuth/Core (~> 2.0)
- GTMSessionFetcher/Core (< 4.0, >= 3.3)
- GTMSessionFetcher (3.5.0):
- GTMSessionFetcher/Full (= 3.5.0)
- GTMSessionFetcher/Core (3.5.0)
- GTMSessionFetcher/Full (3.5.0):
- GTMSessionFetcher/Core
- PromisesObjC (2.4.1)
- PromisesSwift (2.4.1):
- PromisesObjC (= 2.4.1)
- RecaptchaInterop (101.0.0)
- sign_in_with_apple (0.0.1):
- Flutter
- FlutterMacOS
DEPENDENCIES:
- firebase_auth (from `.symlinks/plugins/firebase_auth/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- Flutter (from `Flutter`)
- google_sign_in_ios (from `.symlinks/plugins/google_sign_in_ios/darwin`)
- sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/darwin`)
SPEC REPOS:
trunk:
- AppAuth
- AppCheckCore
- Firebase
- FirebaseAppCheckInterop
- FirebaseAuth
- FirebaseAuthInterop
- FirebaseCore
- FirebaseCoreExtension
- FirebaseCoreInternal
- GoogleSignIn
- GoogleUtilities
- GTMAppAuth
- GTMSessionFetcher
- PromisesObjC
- PromisesSwift
- RecaptchaInterop
EXTERNAL SOURCES:
firebase_auth:
:path: ".symlinks/plugins/firebase_auth/ios"
firebase_core:
:path: ".symlinks/plugins/firebase_core/ios"
Flutter:
:path: Flutter
google_sign_in_ios:
:path: ".symlinks/plugins/google_sign_in_ios/darwin"
sign_in_with_apple:
:path: ".symlinks/plugins/sign_in_with_apple/darwin"
SPEC CHECKSUMS:
AppAuth: ef4da5a3fc2e10b90c09a0a94a9baeaedc0341d5
AppCheckCore: e215d35177a9cf469927863e69c13e220df32a8b
Firebase: e8400c058702940d53f31fc0653e4e7a06332326
firebase_auth: 3e82fa3e632856cd5a8e2bedf458d682740c8168
firebase_core: c94f9efeed0ded590b7eecaf6110a374eb865d7a
FirebaseAppCheckInterop: 6d4cd7b9df8047da8cfc98c3fef4618bab2a8e0d
FirebaseAuth: d27d2fc54508c3af9db2f617300fcf3042328d80
FirebaseAuthInterop: 1679f5a45921270225b7eabf1ad4ebff8790fe82
FirebaseCore: 52d4512296984a44a8c8870c6494870fc7aeee2f
FirebaseCoreExtension: be2d62bfcf339c7dd94646d43d910aaf37fe17c1
FirebaseCoreInternal: 3b310521b34a51ce3a6c605e4618b9aef8f6c112
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
google_sign_in_ios: 000870aa06da9b28d1d0bf7ef70ff0213059dd28
GoogleSignIn: e449a40a92e9f2eea56e98b5214d13725dc5a00a
GoogleUtilities: 766ace00c6b10d8148408f329d10c4f051931850
GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273
PromisesSwift: 217dea0fd5d2ad65222a109c48698add13cc1c5b
RecaptchaInterop: 11e0b637842dfb48308d242afc3f448062325aba
sign_in_with_apple: 5e34677a4e74c5f42760f41b3bbe05343e84d171
PODFILE CHECKSUM: a9f0c3e5ff2dc240d258357afba1f66fdbe55a54
COCOAPODS: 1.17.0
+753
View File
@@ -0,0 +1,753 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
87BFB00ABC23EA249E841B59 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CBE157220A7C382F845775F0 /* Pods_Runner.framework */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
EC46D318C684524E6F9C925F /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AB2C33122C7A95220B242430 /* Pods_RunnerTests.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
48D05AE8FDC95B4B55F142B5 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
84C812EE6A3D1BC4184A5BEB /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
863F6782F18B38E1171A4FA5 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
9186EB7B347BF3891391EBFA /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A68C3B80D45981965B89CA40 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
AB2C33122C7A95220B242430 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
CBE157220A7C382F845775F0 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
F91DF3B89BCA653696B13AFD /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
357429D21E63CD627659972A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
EC46D318C684524E6F9C925F /* Pods_RunnerTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
87BFB00ABC23EA249E841B59 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
192919746488C9A208BE2DB6 /* Frameworks */ = {
isa = PBXGroup;
children = (
CBE157220A7C382F845775F0 /* Pods_Runner.framework */,
AB2C33122C7A95220B242430 /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
A0B9B7DAE21F6038B2037378 /* Pods */,
192919746488C9A208BE2DB6 /* Frameworks */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
A0B9B7DAE21F6038B2037378 /* Pods */ = {
isa = PBXGroup;
children = (
A68C3B80D45981965B89CA40 /* Pods-Runner.debug.xcconfig */,
48D05AE8FDC95B4B55F142B5 /* Pods-Runner.release.xcconfig */,
9186EB7B347BF3891391EBFA /* Pods-Runner.profile.xcconfig */,
84C812EE6A3D1BC4184A5BEB /* Pods-RunnerTests.debug.xcconfig */,
F91DF3B89BCA653696B13AFD /* Pods-RunnerTests.release.xcconfig */,
863F6782F18B38E1171A4FA5 /* Pods-RunnerTests.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
8288A40396CD78821AC244E9 /* [CP] Check Pods Manifest.lock */,
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
357429D21E63CD627659972A /* Frameworks */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
2ABB74D8ADE386C52DCBF7B9 /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
73128144E02B9818A990375F /* [CP] Embed Pods Frameworks */,
530322A018F6E749E8AABA13 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
2ABB74D8ADE386C52DCBF7B9 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
530322A018F6E749E8AABA13 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
73128144E02B9818A990375F /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
8288A40396CD78821AC244E9 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = P36BV95LP5;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = kr.co.sweech.fancake;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 84C812EE6A3D1BC4184A5BEB /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = kr.co.sweech.fancake.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = F91DF3B89BCA653696B13AFD /* Pods-RunnerTests.release.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = kr.co.sweech.fancake.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 863F6782F18B38E1171A4FA5 /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = kr.co.sweech.fancake.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = P36BV95LP5;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = kr.co.sweech.fancake;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = P36BV95LP5;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = kr.co.sweech.fancake;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
+16
View File
@@ -0,0 +1,16 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}
@@ -0,0 +1 @@
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 697 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1011 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

@@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<!-- color-cosmic-void(#000). 앱이 다크 전용이라 런치 화면도 검정이어야 흰 화면이 번쩍이지 않는다. -->
<color key="backgroundColor" red="0" green="0" blue="0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
+70
View File
@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Fancake</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Fancake</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
+1
View File
@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"
+6
View File
@@ -0,0 +1,6 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}
+12
View File
@@ -0,0 +1,12 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}
+31
View File
@@ -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,
);
}
+69
View File
@@ -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;
}
}
+109
View File
@@ -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),
],
),
);
}
+105
View File
@@ -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)];
+169
View File
@@ -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
+66
View File
@@ -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),
],
),
),
],
),
),
),
);
}
}
+102
View File
@@ -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();
}
}

Some files were not shown because too many files have changed in this diff Show More