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