Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d2394c002 | ||
|
|
de65316205 | ||
|
|
59a8f9ead8 | ||
|
|
3b2bb5435e | ||
|
|
3511ba2de9 | ||
|
|
6e0aa5d43c | ||
|
|
c42fd615a6 | ||
|
|
c40bde2cd0 | ||
|
|
9119b3beaa | ||
|
|
e440251c78 | ||
|
|
cef5436336 | ||
|
|
897dff950d | ||
|
|
bc19797158 | ||
|
|
4d2ce67886 | ||
|
|
b66863a149 | ||
|
|
7bfc4faa13 | ||
|
|
9d34935b94 | ||
|
|
c301f3a1db |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"flutter": "3.47.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,30 +55,15 @@ key.properties
|
||||
*.mobileprovision
|
||||
**/GoogleService-Info.plist
|
||||
**/google-services.json
|
||||
# GoogleService-Info.plist 에서 생성한다 — tools/ios/inject_url_scheme.py
|
||||
# GoogleService-Info.plist 에서 생성한다 — tool/ios/inject_url_scheme.py
|
||||
ios/config/Google.xcconfig
|
||||
# 구글 지도 API 키 — BUILD.md 「구글 지도 API 키」
|
||||
ios/config/Maps.xcconfig
|
||||
|
||||
# FVM Version Cache
|
||||
.fvm/
|
||||
|
||||
# Node — Cloud Functions 의존성. 잠금 파일(package-lock.json)만 추적한다.
|
||||
node_modules/
|
||||
|
||||
# Cloud Functions 의 tsc 산출물. `npm --prefix firebase/functions run build` 가 여기에
|
||||
# JS 를 떨군다. 소스는 src/ 이고 배포가 이 디렉터리를 다시 만든다 — 추적하면 빌드마다
|
||||
# diff 가 수천 줄 뜨고 커밋에서 소스 변경이 보이지 않는다.
|
||||
firebase/functions/lib/
|
||||
|
||||
# Gradle/Kotlin 빌드 캐시
|
||||
/android/.kotlin/
|
||||
|
||||
# 확인용 캡처 산출물. 앱 저장소가 아니라 앱 저장소 밖에 둔다.
|
||||
# test_driver/capture_driver.dart 가 여기에 PNG 를 떨군다.
|
||||
docs/output/
|
||||
|
||||
# Cloud Functions 의 tsc 산출물. 소스는 firebase/functions/src 다.
|
||||
# Firebase 도구·에뮬레이터 산출물
|
||||
firebase/**/node_modules/
|
||||
firebase/**/*-debug.log
|
||||
firebase/functions/lib/
|
||||
|
||||
# 카카오 네이티브 앱 키. 다른 설정 파일(google-services.json 등)과 같이 저장소 밖에서 넣는다.
|
||||
|
||||
@@ -6,7 +6,7 @@ Gradle 이 조용히 실패하지 않고 명확히 죽으므로 아래를 그대
|
||||
| 구성 | 값 | 비고 |
|
||||
|---|---|---|
|
||||
| Flutter | **3.47.2** | `fvm` 으로 고정 — `app/.fvmrc` |
|
||||
| JDK | **Temurin 17** | `~/.jdks/jdk-17.0.20.1+1/Contents/Home` |
|
||||
| JDK | **Temurin 17** | `fvm flutter config --jdk-dir` 로 지정 |
|
||||
| Gradle | 8.14 | `android/gradle/wrapper/gradle-wrapper.properties` |
|
||||
| AGP | 8.11.1 | `android/settings.gradle.kts` |
|
||||
| Android SDK | `/opt/homebrew/share/android-commandlinetools` | `brew install --cask android-commandlinetools` |
|
||||
@@ -21,11 +21,11 @@ Android Studio 번들 JBR 은 **JDK 25** 이고 **Gradle 8.14 와 비호환**이
|
||||
JDK 17 을 쓰는 쪽을 택했다.
|
||||
|
||||
```bash
|
||||
fvm flutter config --jdk-dir ~/.jdks/jdk-17.0.20.1+1/Contents/Home
|
||||
fvm flutter config --jdk-dir <JDK 17 설치 경로>
|
||||
```
|
||||
|
||||
`brew install --cask temurin@17` 은 sudo 를 요구한다. sudo 없이 쓰려면 Adoptium
|
||||
tar.gz 를 `~/.jdks/` 에 풀면 된다.
|
||||
tar.gz 를 사용자 폴더에 풀고 그 경로를 지정하면 된다.
|
||||
|
||||
## 테스트
|
||||
|
||||
@@ -33,22 +33,8 @@ tar.gz 를 `~/.jdks/` 에 풀면 된다.
|
||||
export ANDROID_HOME=/opt/homebrew/share/android-commandlinetools
|
||||
fvm flutter analyze
|
||||
fvm flutter test
|
||||
fvm flutter build apk --debug # 플러그인 네이티브 결합까지 본다
|
||||
npm --prefix firebase/functions run build
|
||||
```
|
||||
|
||||
**`pubspec.lock` 의 `firebase_core` 는 4.13.0 이어야 한다.** 4.14.0 이 올라오면 Android
|
||||
컴파일이 깨진다. `pub get` 이 올려 버렸는지 게이트에서 함께 본다.
|
||||
|
||||
```bash
|
||||
grep -A 6 '^ firebase_core:' pubspec.lock | grep version # → 4.13.0
|
||||
```
|
||||
|
||||
**`analyze` 와 `test` 는 Android 네이티브 컴파일을 하지 않는다.** 의존성을 추가하거나
|
||||
올리면 Dart 쪽은 멀쩡한데 Gradle 이 죽는 조합이 생긴다(실례 — `firebase_core` 가
|
||||
4.14.0 으로 올라가 `firebase_auth` 6.5.7 의 `customAuthDomain` 에서 깨졌다).
|
||||
그래서 의존성을 건드린 회차는 **디버그 APK 빌드까지 게이트에 넣는다.**
|
||||
|
||||
스모크 테스트가 `craft-allowlist.yaml` 의 `APP_TABS` 를 직접 읽어 앱의 `appTabs` 와 정확 일치를
|
||||
검증한다(5탭 계약 대조). 파일은 아래 순서로 찾는다.
|
||||
|
||||
@@ -57,8 +43,7 @@ grep -A 6 '^ firebase_core:' pubspec.lock | grep version # → 4.13.0
|
||||
3. 저장소 루트 `design/craft-allowlist.yaml`.
|
||||
|
||||
하나도 못 찾으면 통과가 아니라 실패한다 — 계약 대조가 조용히 사라지면 안 된다.
|
||||
루트 파일을 고칠 때는 `app/design/` 사본도 함께 맞춘다. 두 파일이 함께 보이면 스모크 테스트가
|
||||
`APP_TABS` 줄을 서로 비교해 다르면 실패한다.
|
||||
루트 파일을 고칠 때는 `app/design/` 사본도 함께 맞춘다.
|
||||
|
||||
## 빌드
|
||||
|
||||
@@ -69,7 +54,25 @@ fvm flutter build apk --release # build/app/outputs/flutter-apk/app-relea
|
||||
fvm flutter build appbundle --release # build/app/outputs/bundle/release/app-release.aab
|
||||
```
|
||||
|
||||
### Android Apple 로그인 — 앱에 넣을 값 없음
|
||||
## 카카오 · LINE 로그인 설정
|
||||
|
||||
**카카오 네이티브 앱 키** — `config/kakao.env`(저장소 밖, `config/kakao.env.example` 복사) 한 줄
|
||||
`FANUZU_KAKAO_NATIVE_APP_KEY=<키>`. 주석을 넣지 않는다(iOS xcconfig 가 그대로 include 한다).
|
||||
Android `build.gradle.kts`(리다이렉트 스킴) · iOS `Flutter/*.xcconfig`(URL 스킴) 가 이 파일을 읽고,
|
||||
Dart 는 실행·빌드 때 `--dart-define-from-file=config/kakao.env` 로 받는다. 없으면 카카오 로그인만
|
||||
「설정 전」으로 막힌다. 서버 `fnAuthKakao` 는 카카오 **앱 ID**(숫자)를 `firebase/functions/.env.<프로젝트>`
|
||||
의 `KAKAO_APP_ID` 로 받는다(저장소 밖).
|
||||
|
||||
```bash
|
||||
fvm flutter build apk --release --dart-define-from-file=config/kakao.env
|
||||
```
|
||||
|
||||
**LINE 채널 ID** — 같은 `config/kakao.env` 에 `FANUZU_LINE_CHANNEL_ID=<LINE Login 채널 ID(숫자)>`. Dart 만 읽는다
|
||||
(iOS 복귀 스킴은 `line3rdp.<번들 ID>` 라 키가 필요 없다). 없으면 LINE 로그인만 「설정 전」으로 막힌다. 서버
|
||||
`fnAuthLine` 은 같은 값을 `firebase/functions/.env.<프로젝트>` 의 `LINE_CHANNEL_ID` 로 받는다(저장소 밖).
|
||||
LINE Developers 콘솔의 LINE Login 채널에 Android 패키지명 · iOS 번들 ID 를 등록한다.
|
||||
|
||||
## Android Apple 로그인 — 앱에 넣을 값 없음
|
||||
|
||||
Android 는 Apple SDK 가 없어 Firebase 웹 흐름(`apple.com` 제공자 · `signInWithProvider`)으로 로그인한다.
|
||||
값은 모두 콘솔에 둔다 — 빌드 옵션은 없다.
|
||||
@@ -77,10 +80,12 @@ Android 는 Apple SDK 가 없어 Firebase 웹 흐름(`apple.com` 제공자 · `s
|
||||
1. Apple 개발자 계정 「Identifiers → Services IDs」에 서비스 ID 를 만들고 「Sign in with Apple」을 켠다.
|
||||
- Domains: `<프로젝트 ID>.firebaseapp.com`
|
||||
- Return URLs: `https://<프로젝트 ID>.firebaseapp.com/__/auth/handler`
|
||||
2. 「Keys」에서 Sign in with Apple 키를 만들고 .p8 를 받는다(키 ID · 팀 ID 기록).
|
||||
2. 「Keys」에서 Sign in with Apple 키를 만들고 .p8 를 받는다(키 ID · 팀 ID 기록). 키 파일은 저장소에 넣지 않는다.
|
||||
3. Firebase 콘솔 Authentication → 로그인 방법 → Apple 에 서비스 ID · 팀 ID · 키 ID · 비공개 키를 넣는다.
|
||||
4. 확인: Android 첫 화면 Apple → 브라우저 탭에서 Apple 로그인 → 앱 HOME.
|
||||
|
||||
iOS 는 지금처럼 Apple SDK(`AppleSignInProvider`)로 로그인한다.
|
||||
|
||||
## 서명
|
||||
|
||||
`android/key.properties` 가 없으면 **릴리스 빌드가 의도적으로 실패**한다
|
||||
@@ -99,104 +104,12 @@ apksigner verify --print-certs build/app/outputs/flutter-apk/app-release.apk
|
||||
# Signer #1 certificate SHA-256 digest: 5e83f4c9cd35357b11561a5b82c47d300af8c31e33d80d94bc32a4f10f46dee7
|
||||
```
|
||||
|
||||
## Firebase 앱 등록 — 식별자 `kr.co.fanuzu.app`
|
||||
## 알려진 어긋남 — minSdk
|
||||
|
||||
프로젝트는 `fanuzu-dev-b020d`(계정 `dev@sweech.io`). 2026-09-11 별첨 C 4번 확정으로
|
||||
아래 두 앱을 새로 등록했다. 잠정 식별자(`kr.co.sweech.fancake`) 앱은 지우지 않고
|
||||
남겨 두었다 — 그 식별자로 이미 설치된 테스트 빌드가 있고, Gradle 은
|
||||
`google-services.json` 안에서 **패키지명으로 골라 쓰므로** 섞이지 않는다.
|
||||
|
||||
| 플랫폼 | App ID |
|
||||
|---|---|
|
||||
| Android | `1:959364197461:android:6f1b79847ba8c3ed08b8dc` |
|
||||
| iOS | `1:959364197461:ios:716771ae8ece67ee08b8dc` |
|
||||
|
||||
설정 파일은 `firebase/setup-project.sh` 가 제자리에 내려받는다(둘 다 `.gitignore` 대상).
|
||||
|
||||
### SHA-1 지문은 firebase CLI 로 등록할 수 없다
|
||||
|
||||
firebase-tools 15 에 지문 명령이 없다. Firebase Management REST 로 넣었고, 등록된 값은
|
||||
디버그 키스토어와 업로드 키스토어 둘이다.
|
||||
|
||||
지문을 다시 뽑으려면(JDK 17 의 keytool):
|
||||
|
||||
```bash
|
||||
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android | grep SHA
|
||||
keytool -list -v -keystore ~/.fanuzu-signing/fanuzu-upload.p12 -alias fanuzu-upload -storetype PKCS12 | grep SHA
|
||||
```
|
||||
|
||||
등록값 4건(디버그·릴리스 × SHA-1·SHA-256):
|
||||
|
||||
```
|
||||
SHA-1 debug 85:2B:1B:A8:93:4D:CB:AF:D0:B6:D6:56:AF:98:99:5D:02:FF:07:C1
|
||||
SHA-1 release 58:27:8E:F0:80:B8:DA:6E:0E:E8:C1:70:F4:90:74:AD:5A:54:94:E8
|
||||
SHA-256 debug 28:F1:75:B8:…:88:46:5E:AF
|
||||
SHA-256 release 5E:83:F4:C9:…:0F:46:DE:E7
|
||||
```
|
||||
|
||||
### 설정 파일은 저장소에 없다 — 다시 받는 명령
|
||||
|
||||
`google-services.json` · `GoogleService-Info.plist` · `ios/config/Google.xcconfig` 셋 다
|
||||
`.gitignore` 대상이다. 새 작업 폴더에서는 아래를 돌린다.
|
||||
|
||||
```bash
|
||||
firebase login:use dev@sweech.io
|
||||
firebase apps:sdkconfig ANDROID 1:959364197461:android:6f1b79847ba8c3ed08b8dc \
|
||||
--project fanuzu-dev-b020d --out android/app/google-services.json
|
||||
firebase apps:sdkconfig IOS 1:959364197461:ios:716771ae8ece67ee08b8dc \
|
||||
--project fanuzu-dev-b020d --out ios/Runner/GoogleService-Info.plist
|
||||
python3 tools/ios/inject_url_scheme.py # → ios/config/Google.xcconfig
|
||||
```
|
||||
|
||||
> `apps:sdkconfig` 는 대상 파일이 이미 있으면 거부한다. 갱신하려면 먼저 지운다.
|
||||
|
||||
**카카오 네이티브 앱 키** — `config/kakao.env`(저장소 밖, `config/kakao.env.example` 복사) 한 줄
|
||||
`FANUZU_KAKAO_NATIVE_APP_KEY=<키>`. 주석을 넣지 않는다(iOS xcconfig 가 그대로 include 한다).
|
||||
Android `build.gradle.kts`(리다이렉트 스킴) · iOS `Flutter/*.xcconfig`(URL 스킴) 가 이 파일을 읽고,
|
||||
Dart 는 실행·빌드 때 `--dart-define-from-file=config/kakao.env` 로 받는다. 없으면 카카오 로그인만
|
||||
「설정 전」으로 막힌다. 서버 `fnAuthKakao` 는 카카오 **앱 ID**(숫자)를 `firebase/functions/.env.<프로젝트>`
|
||||
의 `KAKAO_APP_ID` 로 받는다.
|
||||
|
||||
**구글 지도 API 키(위치 선택 지도)** — 키는 저장소에 없다. 없어도 빌드는 되고 지도만 빈다.
|
||||
- Android: `android/local.properties` 에 `MAPS_API_KEY=<키>` → `build.gradle.kts` 가 `manifestPlaceholders` 로
|
||||
AndroidManifest `com.google.android.geo.API_KEY` 에 넣는다.
|
||||
- iOS: `ios/config/Maps.xcconfig`(`ios/config/Maps.xcconfig.example` 복사, gitignore) 에 `MAPS_API_KEY = <키>` →
|
||||
`Flutter/*.xcconfig` include → Info.plist `GMSApiKey` → `AppDelegate` 의 `GMSServices.provideAPIKey`.
|
||||
- 콘솔: Maps SDK for Android · iOS 사용 설정. 키 제한은 Android 앱 `kr.co.fanuzu.app` + SHA-1(디버그 · 릴리스),
|
||||
iOS 번들 ID 로 건다.
|
||||
|
||||
Authentication 제공자는 이메일/비밀번호와 Google 둘 다 켜져 있다(2026-09-11). Google 을
|
||||
켜야 `oauth_client` 와 `REVERSED_CLIENT_ID` 가 채워지며, 콘솔에서만 켤 수 있다.
|
||||
|
||||
|
||||
> **테스터 안내(식별자 전환 이후):** 새 빌드(`kr.co.fanuzu.app`)는 기존 설치본(`kr.co.sweech.fancake`)과 **별개의 앱으로 나란히 설치**됩니다. 기존 앱의 SharedPreferences · iOS 키체인 · 캐시된 소셜 자격 증명은 넘어오지 않으므로 다시 로그인해야 합니다. Firebase Authentication 계정은 프로젝트 단위라 그대로 살아 있어 같은 계정으로 들어갑니다. "로그아웃됐다" 는 오인 신고를 막기 위해 빌드 노트에도 같은 문장을 넣습니다.
|
||||
|
||||
## minSdk 는 29 — 문서와 일치한다
|
||||
|
||||
`android/app/build.gradle.kts` 의 `minSdk = 29` 다. 문서(`2주차_플러그인_선검증리포트` ·
|
||||
별첨 1)가 적은 **Android 10(API 29)** 과 같다. (이 절은 하한이 24 이던 때의 기록을
|
||||
지운 것이다 — 값이 이미 29 이므로 「올려야 한다」는 서술이 사실과 달랐다.)
|
||||
|
||||
## 퀘스트 플러그인 3종 — 선검증리포트를 그대로 따른다
|
||||
|
||||
| 플러그인 | 버전 | 쓰는 곳 | 하한 |
|
||||
|---|---|---|---|
|
||||
| `pedometer` | 4.2.0 | 만보기 퀘스트(S-H-WALK) 걸음 수 | Android 16 · iOS 10 |
|
||||
| `geolocator` | **13.x 고정** | 위치 퀘스트(S-H-PLACE) 좌표·mock location | iOS 11 |
|
||||
| `permission_handler` | **12.x 고정** | 활동 인식 권한 | Android 24 · iOS 12 |
|
||||
| `shared_preferences` | 2.5.x | 걸음 기준선을 하루 단위로 보관 | 하한 영향 없음 |
|
||||
|
||||
**두 버전을 올리지 않는 이유는 선검증리포트에 있다.** `geolocator 14.x` 는 `win32 6.x` 를
|
||||
끌어와 `health 13.3.1` 과 공존하지 못하고(§2.2), `permission_handler 13.0.0` 은 Gradle
|
||||
빌드가 깨진다(§2.3). 올리려면 그 두 항목을 다시 재고 리포트를 고쳐야 한다.
|
||||
|
||||
`geofence_foreground_service` 는 **넣지 않았다.** 지오펜스 판정은 서버가 하고
|
||||
(`fn-quest-verify-place`), 앱은 인증 시점의 좌표 한 점만 보낸다 — 상시 추적을 하지 않는다는
|
||||
v1.6.1 13장을 지키려면 상주 서비스가 필요 없다.
|
||||
|
||||
권한은 `android/app/src/main/AndroidManifest.xml` 에 `ACTIVITY_RECOGNITION` ·
|
||||
`ACCESS_FINE_LOCATION` · `ACCESS_COARSE_LOCATION` 셋이다. **`ACCESS_BACKGROUND_LOCATION`
|
||||
은 요청하지 않는다.** iOS 사용 문구는 `Info.plist` 에 이미 들어 있다.
|
||||
문서(`2주차_플러그인_선검증리포트` · 별첨 1)는 **Android 10(API 29)** 을 하한으로 적었으나
|
||||
현재 빌드는 **API 24** 로 나간다. 하한을 29 로 올리는 것은 위치 퀘스트의
|
||||
`geofence_foreground_service` 인데 아직 넣지 않았다. **6주차 위치 퀘스트 구현 시점에
|
||||
29 로 올라간다.** 그때 기준 단말 2종 충족을 다시 확인해 빌드 노트에 적는다.
|
||||
|
||||
---
|
||||
|
||||
@@ -219,8 +132,32 @@ export DEVELOPER_DIR=~/Downloads/Xcode-beta.app/Contents/Developer
|
||||
fvm flutter build ios --release --no-codesign
|
||||
```
|
||||
|
||||
**서명은 Apple Developer 계정이 있어야 한다.** 계정 없이 여기까지는 확인되므로,
|
||||
TestFlight 제출에 남은 것은 **서명과 업로드뿐**이다.
|
||||
### 제출 빌드 — App Store Connect
|
||||
|
||||
서명 팀은 **Fancake Inc. `V28G543VK5`** 다(`Runner.xcodeproj` 의 `DEVELOPMENT_TEAM`,
|
||||
`ios/ExportOptions.plist` 의 `teamID`). 번들 ID `kr.co.fanuzu.app`.
|
||||
|
||||
| 항목 | 파일 | 없으면 |
|
||||
|---|---|---|
|
||||
| Sign in with Apple · 푸시 환경 | `ios/Runner/Runner.entitlements` | Apple 로그인이 동작하지 않고 심사에서 반려된다 |
|
||||
| 암호화 사용 신고 | `Info.plist` `ITSAppUsesNonExemptEncryption` | 업로드마다 수동 응답을 요구한다 |
|
||||
| Google 콜백 스킴 | `Info.plist` + `ios/config/Google.xcconfig` | Google 로그인 콜백이 돌아오지 않는다 |
|
||||
| Firebase 설정 | `ios/Runner/GoogleService-Info.plist` (번들 리소스) | 목 모드로 뜨고 Google 로그인 client ID 를 못 읽는다 |
|
||||
| 내보내기 설정 | `ios/ExportOptions.plist` | `-exportArchive` 가 대화형으로 묻는다 |
|
||||
|
||||
`GoogleService-Info.plist` 와 `ios/config/Google.xcconfig` 는 저장소에 넣지 않는다.
|
||||
Xcode 프로젝트가 `GoogleService-Info.plist` 를 리소스로 참조하므로 **파일이 없으면 iOS 빌드가 멈춘다.**
|
||||
|
||||
```bash
|
||||
# 1. Firebase 콘솔에서 받은 iOS 설정 파일을 둔다
|
||||
cp <받은 파일> ios/Runner/GoogleService-Info.plist
|
||||
# 2. Google 콜백 스킴을 뽑는다 → ios/config/Google.xcconfig
|
||||
python3 tool/ios/inject_url_scheme.py
|
||||
# 3. 아카이브 · IPA
|
||||
fvm flutter build ipa --release --export-options-plist=ios/ExportOptions.plist
|
||||
```
|
||||
|
||||
배포 프로파일이 없으면 `xcodebuild` 로 나눠 `-allowProvisioningUpdates` 를 붙인다.
|
||||
|
||||
### 정리해야 할 것 — CocoaPods / SwiftPM 이중 상태
|
||||
|
||||
@@ -234,64 +171,22 @@ iOS 플러그인이 전부 Swift Package 로 옮겨졌는데 프로젝트에 Coc
|
||||
`Debug.xcconfig` · `Release.xcconfig` 의 Pods include 제거가 필요하다.
|
||||
**11주차 최적화 구간에서 정리한다.**
|
||||
|
||||
## 서버 없이 로그인·원장을 돌려 보기
|
||||
## 서버 없이 로그인을 돌려 보기
|
||||
|
||||
실 Firebase 프로젝트는 계정 명의가 확정되어야 열 수 있다(계정·권한 이전 별첨 A-3).
|
||||
실 Firebase 프로젝트는 계정 명의가 확정되어야 열 수 있다.
|
||||
그 전까지는 **에뮬레이터를 물려 실제 왕복을 돈다.** 설정 파일 대신 에뮬레이터를 붙이는
|
||||
것이라, 이 절차가 통과하면 설정 파일이 들어오는 날 코드 수정 없이 동작한다.
|
||||
|
||||
### 1. 에뮬레이터 기동
|
||||
|
||||
```
|
||||
cd firebase
|
||||
npm install # 최초 1회
|
||||
npm --prefix functions run build # Functions 를 JS 로 빌드
|
||||
firebase emulators:start --only auth,firestore,functions --project demo-fanuzu
|
||||
```
|
||||
|
||||
프로필 사진 업로드를 함께 돌려 보려면 `storage` 를 더한다(`firebase.json` 에
|
||||
규칙과 포트 9199 가 들어 있다).
|
||||
|
||||
```
|
||||
firebase emulators:start --only auth,firestore,functions,storage --project demo-fanuzu
|
||||
cd app/firebase
|
||||
firebase emulators:start --only auth --project demo-fanuzu
|
||||
```
|
||||
|
||||
**JDK 21 이상이 필요하다.** firebase-tools 15 가 그 아래를 거부한다. Gradle 은 Temurin 17 을
|
||||
쓰므로 둘이 다르며, 에뮬레이터를 띄울 때만 `JAVA_HOME` 을 21+ 로 바꾼다.
|
||||
|
||||
```bash
|
||||
export JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
|
||||
```
|
||||
|
||||
### 전역 `firebase` 가 조용히 죽으면 저장소 로컬 firebase-tools 를 쓴다
|
||||
|
||||
**증상** — `firebase emulators:exec` 가 출력을 한 줄도 남기지 않고 종료 코드 137(SIGKILL)로
|
||||
끝난다. `firebase --version` 은 정상이고 `JAVA_HOME` 을 21 로 맞춰도 같다. 포트도 비어 있다.
|
||||
에뮬레이터가 안 뜨는 것처럼 보이지만 원인은 CLI 래퍼다.
|
||||
|
||||
**원인과 대처** — 전역 Homebrew `firebase`(15.29.0)에서만 나며, 저장소 devDependency 인
|
||||
firebase-tools 로는 정상 기동한다. `firebase/test` 는 자기 `node_modules/.bin` 이 PATH 에
|
||||
올라 이미 로컬 바이너리를 쓰고, `firebase/functions` 는 firebase-tools 를 직접 의존하지
|
||||
않으므로 스크립트가 형제 경로를 가리킨다.
|
||||
|
||||
```bash
|
||||
cd firebase/functions && npm ci # 최초 1회
|
||||
cd firebase/test && npm ci # firebase-tools 가 여기 들어 있다
|
||||
npm --prefix firebase/functions run test:emulator # 로컬 바이너리로 돈다
|
||||
cd firebase/test && npm run test:emulator
|
||||
```
|
||||
|
||||
직접 부를 때도 같다.
|
||||
|
||||
```bash
|
||||
../test/node_modules/.bin/firebase emulators:exec \
|
||||
--config ../firebase.json --only firestore --project demo-fanuzu "npm test"
|
||||
```
|
||||
|
||||
> 비상 대안 — 그마저 막히면 에뮬레이터 JAR 를 직접 띄우고
|
||||
> `FIRESTORE_EMULATOR_HOST=127.0.0.1:8080` 을 실어 테스트를 돌린다(규칙 적재는 회귀 테스트가
|
||||
> REST 로 따로 한다): `"$JAVA_HOME/bin/java" -jar ~/.cache/firebase/emulators/cloud-firestore-emulator-v1.22.0.jar --host=127.0.0.1 --port=8080`
|
||||
|
||||
### 2. 앱을 에뮬레이터에 붙이기
|
||||
|
||||
```
|
||||
@@ -301,82 +196,6 @@ flutter run --dart-define=FANUZU_EMULATOR_HOST=127.0.0.1 # iOS 시뮬레이터
|
||||
|
||||
`10.0.2.2` 는 Android 에뮬레이터가 보는 호스트 PC 주소다.
|
||||
|
||||
### 2-1. 에뮬레이터는 **저장소 안의 firebase-tools** 로 띄운다
|
||||
|
||||
전역 Homebrew `firebase`(15.29.0)는 일부 환경에서 시작 배너만 찍고 **SIGKILL(exit 137)**
|
||||
로 죽는다(`emulators:*` · `deploy`. `--version` 과 `projects:list` 는 정상). 저장소의
|
||||
devDependency 를 쓰면 그대로 뜬다.
|
||||
|
||||
```bash
|
||||
npm --prefix firebase/test ci # 최초 1회 — firebase-tools 15.28.2
|
||||
cd firebase
|
||||
JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home \
|
||||
test/node_modules/.bin/firebase emulators:exec \
|
||||
--only auth,firestore,functions --project fanuzu-dev-b020d "<명령>"
|
||||
```
|
||||
|
||||
**프로젝트 ID 를 앱과 맞춘다.** `firebase.json` 이 `singleProjectMode: true` 라 다른 ID 의
|
||||
요청은 서지 않는다. 설정 파일이 들어간 Android 빌드는 네이티브가 만든 `[DEFAULT]` 앱을
|
||||
그대로 쓰므로 **실 프로젝트 ID(`fanuzu-dev-b020d`)** 로 붙는다. 에뮬레이터도 같은 ID 로
|
||||
띄워야 앱이 넣은 값과 시드가 같은 칸에 들어간다.
|
||||
|
||||
**시드는 `Authorization: Bearer owner` 로 넣는다.** 없으면 보안 규칙이 그대로 걸려
|
||||
`quests` 쓰기가 마스터 전용으로 막히고, 시드가 조용히 사라진다.
|
||||
|
||||
### 2-2. Firestore 만 필요할 때 — CLI 없이
|
||||
|
||||
서버 단위 검증만 할 때는 받아 둔 jar 를 직접 띄워도 된다.
|
||||
|
||||
```bash
|
||||
java -Djava.net.preferIPv4Stack=true \
|
||||
-jar ~/.cache/firebase/emulators/cloud-firestore-emulator-v1.22.0.jar \
|
||||
--host=0.0.0.0 --port=8080 &
|
||||
CI=1 FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 GCLOUD_PROJECT=demo-fanuzu \
|
||||
npm --prefix firebase/functions test
|
||||
```
|
||||
|
||||
`CI=1` 이 필요하다. 없으면 vitest 가 대화형 모드로 남아 명령이 끝나지 않는다.
|
||||
**`-Djava.net.preferIPv4Stack=true` 를 빼지 마라.** 없으면 JVM 이 8080 을 IPv6 로만 열고,
|
||||
Android 에뮬레이터가 `10.0.2.2` 로 거는 연결이 **ECONNREFUSED** 로 떨어진다. 앱에서는
|
||||
`[cloud_firestore/unavailable] client is offline` 으로만 보여 원인이 네트워크 설정처럼 읽힌다.
|
||||
|
||||
### 2-3. 앱을 붙여 한 바퀴 — 로그인 → 출석 → 원장 → 화면
|
||||
|
||||
Callable 까지 도는 확인이다. 기기 포트를 호스트로 넘기고 한 명령으로 돌린다.
|
||||
|
||||
```bash
|
||||
for p in 8080 9099 5001; do adb reverse tcp:$p tcp:$p; done
|
||||
cd firebase
|
||||
JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home \
|
||||
test/node_modules/.bin/firebase emulators:exec \
|
||||
--only auth,firestore,functions --project fanuzu-dev-b020d \
|
||||
"bash ../tool/emulator_quest_roundtrip.sh"
|
||||
```
|
||||
|
||||
스크립트가 퀘스트 정의·운영값을 넣고 `integration_test/quest_callable_emulator_test.dart`
|
||||
를 돌린다. 확인하는 것은 **가입 → `fn-auth-oncreate` 계정·지갑 생성 → 출석 참여 →
|
||||
`fn-quest-attend` → 원장 1행 → 보유 POP → 목록 재조회 「보상완료」 → 2회차 E-POP-03 거부**다.
|
||||
|
||||
### 2-4. 서버가 쓴 값을 앱이 읽는지만 볼 때
|
||||
|
||||
```bash
|
||||
CI=1 FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 GCLOUD_PROJECT=demo-fanuzu \
|
||||
npm --prefix firebase/functions test # 서버가 상태를 만든다
|
||||
fvm flutter test integration_test/pop_loop_emulator_test.dart \
|
||||
-d <device> --dart-define=FANUZU_EMULATOR_HOST=10.0.2.2
|
||||
```
|
||||
|
||||
### 3. 자동 검증
|
||||
|
||||
```
|
||||
flutter drive --driver=test_driver/perf_driver.dart \
|
||||
--target=integration_test/auth_emulator_test.dart \
|
||||
--dart-define=FANUZU_EMULATOR_HOST=10.0.2.2 --profile --no-dds -d <device>
|
||||
```
|
||||
|
||||
가입 → 로그인 → 재로그인 → Auth 트리거의 계정·지갑 문서 생성 → 클라이언트 지갑 쓰기
|
||||
차단까지 6건을 확인한다.
|
||||
|
||||
### 평문 HTTP 예외에 대하여
|
||||
|
||||
에뮬레이터는 평문 HTTP 로 뜨는데 Android 9 부터 평문이 기본 차단이다. 예외를
|
||||
@@ -387,45 +206,3 @@ flutter drive --driver=test_driver/perf_driver.dart \
|
||||
unzip -p build/app/outputs/flutter-apk/app-release.apk AndroidManifest.xml \
|
||||
| strings | grep -c networkSecurityConfig # 0 이어야 한다
|
||||
```
|
||||
|
||||
## iOS 스토어 제출 준비 — 계정 전에 할 수 있는 것을 다 해 두었다
|
||||
|
||||
Apple Developer 계정은 발주사 명의 회신 대상이라(계정 이전 별첨 A-1) 서명과
|
||||
업로드는 그날부터다. **그 외의 것을 미리 두지 않으면 계정이 열린 날 하루를
|
||||
설정에 쓴다.** 아래는 이미 들어가 있다.
|
||||
|
||||
| 항목 | 파일 | 없으면 어떻게 되는가 |
|
||||
|---|---|---|
|
||||
| Sign in with Apple Capability | `ios/Runner/Runner.entitlements` | Apple 로그인 코드가 있어도 동작하지 않고 심사에서 반려된다 |
|
||||
| 푸시 환경 선언 | 같은 파일 `aps-environment` | FCM 토큰이 발급되지 않는다 |
|
||||
| 암호화 사용 신고 | `Info.plist` `ITSAppUsesNonExemptEncryption` | 업로드마다 수동 응답을 요구하고 그동안 배포가 멈춘다 |
|
||||
| 권한 사용 문구 6종 | `Info.plist` `NS*UsageDescription` | **권한을 요청하는 순간 앱이 종료된다.** 크래시로 보이지만 원인은 이 파일이다 |
|
||||
| Google 콜백 스킴 | `Info.plist` + `ios/config/Google.xcconfig` | Google 로그인 콜백이 조용히 돌아오지 않는다 |
|
||||
| 내보내기 설정 | `ios/ExportOptions.plist` | `xcodebuild -exportArchive` 가 대화형으로 물어 자동 배포가 불가능하다 |
|
||||
| 업로드 절차 | `tools/ios/upload_testflight.sh` | 계정이 열린 날 명령을 처음부터 찾게 된다 |
|
||||
|
||||
### 업로드 절차
|
||||
|
||||
서명 팀은 발주사 Fancake Inc.(`V28G543VK5`)다. `ios/Runner.xcodeproj` 의 `DEVELOPMENT_TEAM`
|
||||
과 `ios/ExportOptions.plist` 의 `teamID` 가 모두 이 값이어야 하고, `store_readiness_test`
|
||||
가 지킨다. 다른 팀으로 서명하면 업로드가 그 팀의 App Store Connect 로 가 버린다.
|
||||
|
||||
```
|
||||
# 1. Firebase 설정 파일(ios/Runner/GoogleService-Info.plist)을 둔다 — 없으면 빌드가 멈춘다
|
||||
# 2. Google 콜백 스킴을 뽑는다
|
||||
python3 tools/ios/inject_url_scheme.py
|
||||
|
||||
# 3. 올린다 (Xcode 정식판 필요 — 베타 SDK 빌드는 App Store Connect 가 거부한다)
|
||||
export ASC_KEY_ID=... ASC_ISSUER_ID=... ASC_KEY_PATH=...
|
||||
bash tools/ios/upload_testflight.sh
|
||||
```
|
||||
|
||||
API 키가 없으면 Xcode 에 로그인된 계정으로 올린다:
|
||||
`xcodebuild -exportArchive -archivePath build/ios/archive/Runner.xcarchive -exportOptionsPlist ios/ExportOptions.plist -exportPath build/ios/ipa -allowProvisioningUpdates`
|
||||
(`ExportOptions.plist` 에 `destination` = `upload` 를 임시로 넣거나 생성된 IPA 를 Transporter 로 올린다.)
|
||||
|
||||
### 화면 회전을 막지 않은 이유
|
||||
|
||||
별첨 1 ① 예외 처리가 「백그라운드 복귀·**화면 회전** 시 렌더링 정상 재개」를
|
||||
요구한다. Figma 는 세로 프레임만 있으나 계약 문서가 회전을 판정 항목으로
|
||||
두었으므로 가로를 닫지 않는다.
|
||||
|
||||
@@ -1,117 +1,338 @@
|
||||
# FANUZU 앱 · 서버 소스
|
||||
# FANUZU 앱 골격 (Flutter)
|
||||
|
||||
발주사 (주)팬케이크 / 수행사 스위치 주식회사(Sweech)
|
||||
> 2주차 산출물 · 발주사 (주)팬케이크 / 수행사 스위치 주식회사(Sweech)
|
||||
> 별첨2 2주차 「첫 빌드(앱 골격)」의 빌드 포함 범위 = **Flutter 앱 골격 · 5탭 네비게이션 · 소셜 로그인 · 디자인시스템**
|
||||
|
||||
이 저장소는 FANUZU 모바일 앱(Flutter)과 서버(Firebase — Cloud Functions · Firestore 보안 규칙 · 인덱스 · Storage 규칙)의 전체 소스입니다.
|
||||
## 1. 이 골격의 범위
|
||||
|
||||
| 폴더 | 내용 |
|
||||
| | 내용 |
|
||||
|---|---|
|
||||
| `lib/` | 앱 소스 (화면 · 데이터 저장소 · 다국어) |
|
||||
| `lib/l10n/` | 다국어 리소스 5종(ko · en · ja · zh · es) |
|
||||
| `test/` · `integration_test/` | 앱 테스트 |
|
||||
| `firebase/functions/` | Cloud Functions (TypeScript, Node.js 22) · 서버 테스트 `firebase/functions/test/` |
|
||||
| `firebase/firestore.rules` · `firebase/storage.rules` · `firebase/firestore.indexes.json` | 보안 규칙 · 인덱스 |
|
||||
| `firebase/test/` | 보안 규칙 테스트 |
|
||||
| `android/` · `ios/` | 플랫폼 설정 |
|
||||
| **포함** | 앱 기동 · 코스믹 다크 테마(design-tokens.md 생성) · 탭 셸 네비게이션 · 로그인 화면(S-A-LOGIN) 소셜 4종 버튼 · 인증 인터페이스와 Google·Apple·카카오·LINE 실 코드 경로 · 계정 연결(linkWithCredential) · Firebase 초기화(미설정 시 목 모드) |
|
||||
| **미포함** | 각 탭의 실제 화면(3주차 이후) · 온보딩 나머지 6화면(S-A-INTRO · S-A-TERMS · S-A-PROFILE · S-A-ARTIST · S-A-PLANET-NEW · S-A-PERMS) · 도메인 로직(POP·FPI·퀘스트·이클립스) · 상태관리 라이브러리 |
|
||||
|
||||
---
|
||||
미포함 항목을 지금 만들지 않은 이유: **RN/Flutter 승인이 아직 없다(2주차_확인요청항목 Q1).**
|
||||
미승인 시 폐기할 수 있도록 골격과 도메인 로직을 섞지 않았다. 5탭 라벨·화면 ID·토큰처럼
|
||||
언어와 무관한 계약 정보만 코드에 들어가 있다.
|
||||
|
||||
## 1. 개발 환경
|
||||
## 2. 빌드·실행
|
||||
|
||||
| 항목 | 버전 |
|
||||
|---|---|
|
||||
| Flutter | **3.47.2** (`.fvmrc` 로 고정 — [fvm](https://fvm.app) 사용 권장) |
|
||||
| Dart | 3.11.5 이상 |
|
||||
| JDK (Android 빌드) | **17** (Temurin 17 권장. Android Studio 번들 JDK 25 는 현재 Gradle 8.14 와 호환되지 않아 빌드가 실패합니다) |
|
||||
| JDK (Firebase 에뮬레이터) | **21 이상** (firebase-tools 요구) |
|
||||
| Android | minSdk 29 (Android 10) · Android SDK build-tools 35 |
|
||||
| iOS | 15.0 이상 · Xcode 26 |
|
||||
| Node.js | 22 (Cloud Functions 런타임과 동일) |
|
||||
| Firebase CLI | 최신 (`npm i -g firebase-tools`) |
|
||||
|
||||
`fvm` 을 쓰지 않는 경우 아래 명령의 `fvm flutter` 를 `flutter` 로 바꾸고, Flutter 3.47.2 를 직접 설치해 주십시오.
|
||||
|
||||
JDK 17 을 Flutter 에 지정합니다.
|
||||
|
||||
```bash
|
||||
fvm flutter config --jdk-dir <JDK 17 경로>
|
||||
```
|
||||
|
||||
## 2. 저장소에 포함하지 않은 파일
|
||||
|
||||
아래 파일은 보안상 저장소에 넣지 않았습니다. 빌드 전에 준비해 주십시오.
|
||||
|
||||
| 파일 | 받는 곳 |
|
||||
|---|---|
|
||||
| `android/app/google-services.json` | Firebase 콘솔 → 프로젝트 `fanuzu-dev-b020d` → 프로젝트 설정 → Android 앱(`kr.co.fanuzu.app`) |
|
||||
| `ios/Runner/GoogleService-Info.plist` | 같은 화면의 iOS 앱 |
|
||||
| `android/key.properties` · 업로드 서명 키 | **릴리스(서명) 빌드에만** 필요합니다. 요청하시면 별도 보안 경로로 전달합니다. 디버그 빌드에는 필요 없습니다 |
|
||||
|
||||
설정 파일이 없으면 앱은 표본 데이터로 동작하는 목(mock) 모드로 뜹니다(디버그 빌드에서만 진입 가능).
|
||||
|
||||
## 3. 앱 빌드 · 실행
|
||||
전제: Flutter stable 3.47.2 / Dart 3.13.2 (`app/.fvmrc` 로 고정), Android SDK 36, Xcode 26.6
|
||||
|
||||
```bash
|
||||
cd app
|
||||
fvm flutter pub get
|
||||
fvm flutter run # 연결된 실기기 · 에뮬레이터에서 실행
|
||||
fvm flutter build apk --debug # 디버그 APK → build/app/outputs/flutter-apk/app-debug.apk
|
||||
fvm flutter build apk --release # 서명 릴리스 APK (key.properties 필요)
|
||||
fvm flutter build ios --no-codesign # iOS 빌드 확인
|
||||
flutter pub get
|
||||
flutter run # 실기기·시뮬레이터
|
||||
flutter analyze # 정적 분석
|
||||
flutter test # 스모크·인증 상태 전이·회귀 테스트 19건
|
||||
flutter build apk --debug # Android 디버그 APK
|
||||
```
|
||||
|
||||
선택 빌드 설정(`--dart-define`):
|
||||
빌드 산출물은 `build/app/outputs/flutter-apk/app-debug.apk` 에 떨어진다.
|
||||
|
||||
| 키 | 용도 | 없을 때 |
|
||||
> ⚠️ **`pubspec.yaml` 의 의존성을 추가·변경·업그레이드하면 최소 지원 OS 하한을 재계산할 것.**
|
||||
> 절차: 2주차 플러그인 선검증 리포트 §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` 는 **생성물**이다. 직접 고치면 토큰 문서(`design/design-tokens.md`)와 어긋난다.
|
||||
|
||||
```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` 로 분리해 로그에만 남긴다.
|
||||
|
||||
**가드가 두 겹인 이유**
|
||||
|
||||
```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` | iOS 빌드가 멈춘다(번들 리소스로 참조). 투입 절차는 `BUILD.md` 「제출 빌드」 |
|
||||
|
||||
> S2 투입 시 Android 는 `android/settings.gradle.kts` 에 `com.google.gms.google-services` 플러그인
|
||||
> 추가가 함께 필요하다. 지금은 설정 파일이 없어 플러그인을 적용하지 않았다 —
|
||||
> 적용한 채로 파일이 없으면 Gradle 빌드 자체가 실패한다.
|
||||
|
||||
### 5-2. 발주사 확인 필요
|
||||
|
||||
| ID | 항목 | 투입 위치 | 없으면 |
|
||||
|---|---|---|---|
|
||||
| Q3 | 카카오 네이티브 앱 키 | `config/kakao.env`(저장소 밖) | 카카오 버튼이 「설정 전」 안내 다이얼로그를 띄운다 |
|
||||
| Q3 | LINE Login 채널 ID / 시크릿 | Firebase 콘솔 `oidc.line` 제공자 | LINE 로그인이 E-AUTH-11 로 끝난다 |
|
||||
| Q5 | 스토어 등록 명의 | — | 3주차 TestFlight·Play 내부트랙 제출 불가 |
|
||||
| ~~Q14~~ | 앱 식별자 확정 | `android/app/build.gradle.kts` · `ios/Runner.xcodeproj` | **2026-09-11 확정 — 별첨 C 4번 `kr.co.fanuzu.app`.** Android applicationId·namespace·Kotlin 패키지, iOS bundle id 를 모두 옮겼다. **스토어 등록 후에는 변경 불가** |
|
||||
| ~~Q6~~ | 공식 FANUZU 심볼 원본 | `assets/brand/` | **2026-08-13 반영 완료.** 제공본이 심볼 전용이라 앱 아이콘은 공식 자산으로 생성했고 로그인 워드마크는 `FANUZU` 텍스트를 유지 |
|
||||
| Q15 | 하단 탭 아이콘 자산 | `assets/icons/` | Material 기본 아이콘 5종으로 잠정 |
|
||||
|
||||
### 5-3. 폰트
|
||||
|
||||
폰트 파일이 리포에 없다. `pubspec.yaml` 의 `fonts:` 블록이 **주석 처리된 채로** 투입 위치만 표시돼 있다.
|
||||
|
||||
| 용도 | 패밀리 | 투입 위치 | 출처 |
|
||||
|---|---|---|---|
|
||||
| 제목 | Gmarket Sans | `assets/fonts/GmarketSansTTF{Light,Medium,Bold}.ttf` | webfontworld/gmarket |
|
||||
| 본문 | Pretendard Variable | `assets/fonts/PretendardVariable.ttf` | orioncactus/pretendard |
|
||||
| 수치 | JetBrains Mono | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains |
|
||||
|
||||
미투입 상태에서는 **시스템 폰트로 폴백**된다. 크기·굵기·행간·자간은 토큰 값 그대로 적용되므로
|
||||
투입 전후 레이아웃 차이는 자소 폭 수준에 그친다.
|
||||
|
||||
## 6. 로그인 — 별첨 B v1.4 확정 4종
|
||||
|
||||
> 1차 로그인은 Google / Apple / Kakao / LINE 4종입니다. 이메일/비밀번호 가입 및 X 로그인은
|
||||
> 제외합니다. 하나의 FANUZU UID 에 복수 소셜 Provider 연결은 허용합니다.
|
||||
> — 별첨B_정책설계서_요구사항정의서_반영_회신표_v1.4
|
||||
|
||||
버튼 순서는 이 회신 문구 순서다(`AuthRepository._defaultProviders()`).
|
||||
|
||||
| 제공자 | 상태 | 구현 | 막고 있는 것 |
|
||||
|---|---|---|---|
|
||||
| Google | 실 코드 경로 | `GoogleSignInProvider` | **Firebase 콘솔에서 Google 제공자 미활성** + Android OAuth 클라이언트(SHA-1) 미등록 |
|
||||
| Apple | 실 코드 경로 | iOS `AppleSignInProvider`(Apple SDK) · Android `OidcSignInProvider`(Firebase 웹 흐름 `apple.com`) | iOS Xcode Capability · Android 는 Firebase 콘솔 Apple 제공자에 서비스 ID · 키 등록(`BUILD.md` 「Android Apple 로그인」) |
|
||||
| 카카오 | 실 코드 경로 | `KakaoSignInProvider` — 카카오 SDK 로그인 + 서버 `fnAuthKakao` 커스텀 토큰 | 카카오 네이티브 앱 키(`config/kakao.env`) · 서버 `KAKAO_APP_ID` |
|
||||
| LINE | 실 코드 경로 | `OidcSignInProvider` — Firebase OIDC `oidc.line` | Firebase 콘솔 LINE 제공자 등록 |
|
||||
|
||||
카카오 · LINE 의 이메일은 **선택 동의 항목**이다. 이메일을 받지 못하거나 사용자가 동의하지 않아도
|
||||
제공자 고유 식별값(카카오 회원번호 · LINE 사용자 ID)으로 가입 · 로그인되고, 같은 계정으로 다시 들어오면
|
||||
같은 UID 로 로그인된다. 동의한 이메일만 사용자 문서(`users/{uid}.email`)에 남는다.
|
||||
|
||||
키가 없는 제공자의 버튼에는 **「키 대기」 배지**가 붙고, 누르면 무엇이 없어서 못 쓰는지
|
||||
**안내 다이얼로그**가 뜬다. 버튼을 회색으로 잠그지 않는 것은 회색이 사유를 말해 주지 않기 때문이다.
|
||||
|
||||
### 이메일 가입 — 사용자 노출 없음
|
||||
|
||||
별첨 B v1.4 가 이메일/비밀번호 가입을 1차 범위에서 뺐다. 로그인 화면에 이메일 버튼도,
|
||||
「회원가입」도, 「비밀번호 재설정」도 없다. 닫은 방법은 **기능 플래그가 아니라 목록에서 빼는 것**이다 —
|
||||
`AuthRepository.providers` 에 없으면 화면이 버튼을 그리지 않고, 버튼이 없으면 닿는 경로가 없다.
|
||||
|
||||
발주사 QA 계정(`qa-test@fanuzu.co.kr`)용 이메일 로그인 코드는 남아 있으나 입구가 하나뿐이다:
|
||||
|
||||
* **디버그 빌드에서 로그인 화면의 FANUZU 로고를 길게 누른다.**
|
||||
* 릴리스 빌드에서는 `AuthRepository.qaEmailProvider` 가 `null` 이라 폼이 열리지 않고
|
||||
`signInWithEmail` 도 실패로 닫힌다. 회귀 가드 4건이 이걸 고정한다.
|
||||
* 보이는 「QA 로그인」 버튼을 두지 않은 이유: **디버그 APK 도 발주사가 설치해 보는 산출물**이라
|
||||
그 버튼 자체가 「이메일 가입이 살아 있다」로 읽힌다.
|
||||
|
||||
### 계정 연결 — 하나의 UID 에 복수 Provider
|
||||
|
||||
`FirebaseAccountLinker` 가 `User.linkWithCredential` / `User.unlink` 를 쓴다.
|
||||
`signInWithCredential` 이 아니라 **링크 API 만** 쓰는 것이 「UID 는 하나」를 지키는 지점이다 —
|
||||
전자는 해당 계정이 없으면 새로 만들어 버린다.
|
||||
|
||||
* 로그인과 연결은 자격증명을 얻는 데까지 완전히 같아서 그 공통 부분을 `CredentialProvider`
|
||||
하나로 뺐다. 두 벌로 적으면 Apple nonce 같은 보안 절차가 한쪽에서만 고쳐진다.
|
||||
* 명세 `554:22797`(*동일 이메일만 연동 가능*)은 **붙이기 전에** 판정한다. 붙인 뒤 되돌리는
|
||||
방식은 `unlink` 가 실패하면 남의 계정이 붙은 채로 남는다.
|
||||
단 Apple 은 **최초 인가 때만** 이메일을 주므로 재연동 시에는 판정할 값이 없어 통과한다 —
|
||||
서버에서 다시 보려면 Functions 층(fn-account-link)에 같은 판정을 둔다.
|
||||
* **마지막 하나 남은 Provider 는 뗄 수 없다.** 떼는 순간 로그인 수단이 0개가 되어 계정에
|
||||
다시 들어올 방법이 사라지는데, Firebase 는 이걸 막아 주지 않는다.
|
||||
|
||||
설계 원칙: **「키 미투입」은 예외가 아니라 상태다.** 예외를 던지지 않고
|
||||
`AuthNotConfigured(provider, missing)` 를 반환해 "무엇이 없어서 못 쓰는지"를 화면에 그대로 보여 준다.
|
||||
|
||||
Apple은 로그인 시도마다 암호학적 nonce를 만들고 SHA-256 해시를 Apple에, 원문 nonce를
|
||||
Firebase에 전달한다. Android에서는 Service ID와 redirect URI가 없으면 SDK를 호출하지 않는다.
|
||||
Firebase가 live가 되면 보존된 `currentUser`를 앱 시작 시 복원하고 `authStateChanges`를 계속 들어
|
||||
만료·원격 로그아웃도 라우터에 반영한다. 이 내부 계약은 fake 세션·provider로 자동 검증한다.
|
||||
|
||||
**이메일 가입은 구현하지 않았다.** CD-18 미확정(Q2)이라 `SocialAuthProvider` 인터페이스에
|
||||
구현체 하나가 더 붙을 자리만 비워 뒀다. **기능 플래그 상수는 두지 않았다** — 읽는 분기가 없는
|
||||
플래그는 "false 라서 닫혀 있다" 는 착각만 만들고, 누가 true 로 바꿔도 아무 일이 일어나지 않는다
|
||||
. 지금 이메일을 닫고 있는 것은 `AuthRepository._defaultProviders()` 에
|
||||
구현체가 없다는 사실 하나다.
|
||||
|
||||
## 7. 계약 고정 표기 — 5탭
|
||||
|
||||
```
|
||||
HOME · INSIDE · GALAXY · STATION · MY
|
||||
```
|
||||
|
||||
근거 3개가 서로 일치한다:
|
||||
|
||||
| 근거 | 위치 |
|
||||
|---|---|
|
||||
| 견적서 v2.1 「5탭 네비게이션」 | 견적서 v2.1 |
|
||||
| 크래프트 게이트 C15 | `design/craft-allowlist.yaml` → `APP_TABS` |
|
||||
|
||||
**한글화·치환·순서 변경 금지.** 스모크 테스트가 `design/craft-allowlist.yaml` 을 직접 읽어
|
||||
문자열과 순서를 정확 일치로 검증한다 — 앱 코드만 고치면 테스트가 깨진다.
|
||||
|
||||
탭별 루트 화면:
|
||||
|
||||
| 탭 | 화면 ID | 화면명 |
|
||||
|---|---|---|
|
||||
| `FANUZU_SHARE_BASE_URL` | 공유 링크 주소(웹 브릿지) | 공유 시 링크 없이 카드 · 문구만 보냅니다 |
|
||||
| `FANUZU_EMULATOR_HOST` | 로컬 Firebase 에뮬레이터 연결(개발용) | 실서버에 연결합니다 |
|
||||
| HOME | S-H-HOME | 오늘의 퀘스트 |
|
||||
| INSIDE | S-I-LOUNGE | 팬덤 라운지 |
|
||||
| GALAXY | S-G-PLANET | 내 행성 |
|
||||
| STATION | S-S-HUB | STATION 허브 |
|
||||
| MY | S-M-HOME | 마이 페이지 |
|
||||
|
||||
## 4. 테스트
|
||||
## 8. 폴더 구조
|
||||
|
||||
```bash
|
||||
cd app
|
||||
fvm flutter analyze # 정적 분석 — 0건이어야 합니다
|
||||
fvm flutter test # 앱 테스트
|
||||
|
||||
cd firebase/test
|
||||
npm ci # 에뮬레이터용 Firebase CLI 포함 — 서버 테스트도 이것을 씁니다
|
||||
npm run test:emulator # 보안 규칙 테스트 (JDK 21 필요)
|
||||
|
||||
cd ../functions
|
||||
npm ci
|
||||
npm run typecheck # 서버 타입 검사
|
||||
npm run test:emulator # 서버 테스트 (Firestore 에뮬레이터 · JDK 21 필요)
|
||||
```
|
||||
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 앱 기동·5탭·목 세션 회귀 10건
|
||||
├── test/auth_repository_test.dart provider 결과·세션 복원·로그아웃 9건
|
||||
└── 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/ 탭 루트 플레이스홀더
|
||||
```
|
||||
|
||||
의존성을 바꾼 경우 `analyze` · `test` 만으로는 Android 네이티브 컴파일 오류를 잡지 못하므로 `fvm flutter build apk --debug` 까지 확인해 주십시오. `pubspec.lock` 의 `firebase_core` 는 4.13.0 이어야 합니다(4.14.0 에서 Android 컴파일이 실패합니다).
|
||||
## 9. 검증 결과
|
||||
|
||||
## 5. 서버 배포
|
||||
<!-- VERIFY:BEGIN 실행 출력 그대로. 손으로 고치지 마라. -->
|
||||
2026-08-14 재실행. macOS 27.0 · Flutter 3.41.7 (stable) · Dart 3.11.5 · Android SDK 36 · Xcode 26.6
|
||||
|
||||
```bash
|
||||
cd app/firebase
|
||||
npm --prefix functions ci
|
||||
npm --prefix functions run build
|
||||
firebase deploy --only firestore,storage,functions --project fanuzu-dev-b020d
|
||||
```
|
||||
$ flutter analyze
|
||||
Analyzing app...
|
||||
No issues found! (ran in 3.2s)
|
||||
|
||||
$ flutter test
|
||||
app_smoke_test.dart: 앱 기동·세션 복원·5탭·릴리스 목 우회 차단 10건 통과
|
||||
auth_repository_test.dart: 제공자 4종 성공/취소/실패/미설정·예외 안전성·
|
||||
Android Apple 설정 게이트·세션 복원/만료·로그아웃/재로그인 9건 통과
|
||||
00:01 +19: 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 (164,473,984 bytes · 약 156.9 MiB · 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)
|
||||
```
|
||||
|
||||
Storage 규칙 배포는 Firebase 콘솔에서 Storage 를 먼저 활성화해야 합니다.
|
||||
**테스트 19종은 Firebase 설정 파일이 없는 상태(목 모드)에서 통과한 결과다.**
|
||||
|
||||
## 6. 테스트 계정 · 권한
|
||||
음성 검증 2회 — 테스트가 실제로 게이트 역할을 하는지 직접 깨뜨려 확인했다.
|
||||
|
||||
- 릴리스 빌드의 로그인은 **Google 계정**으로 합니다. 각자의 Google 계정으로 가입하시면 됩니다. Apple 은 iOS(TestFlight) 준비 후, Kakao · LINE 은 제공자 앱 키 수령 후 연결합니다.
|
||||
- 운영 기능(퀘스트 · 캠페인 검수, 타임어택 발동, 결과보고 등록 등)은 운영자 권한이 있는 계정만 호출할 수 있습니다. 권한을 드릴 Google 계정을 알려 주시면 운영자 권한을 부여합니다.
|
||||
|
||||
## 7. 실기기에서만 확인 가능한 기능
|
||||
|
||||
| 기능 | 이유 |
|
||||
| 변조 | 결과 |
|
||||
|---|---|
|
||||
| 만보기 퀘스트 | 걸음 수 센서 · 건강 데이터 권한 |
|
||||
| 위치 퀘스트 | GPS 반경 인증 |
|
||||
| 이클립스 방어 · Music Shake | 흔들기(가속도 센서) |
|
||||
| 푸시 알림(이클립스 사전 알림 · 타임어택 · 브리핑) | FCM 수신 |
|
||||
| 보상형 광고 | Google 테스트 광고 단위로 동작 |
|
||||
| 인앱 결제 · Focus 구독 | 스토어 결제 창 — 스토어 상품 등록 전에는 「스토어 상품 준비 중」으로 표시 |
|
||||
| 공유(카드 이미지) | 시스템 공유 시트 |
|
||||
| `appTabs` 의 `STATION` → `스테이션` | 2·3번 실패. 되돌린 뒤 재통과 |
|
||||
| `isMockSessionAvailable` 에서 `&& !_releaseMode` 제거 | 릴리스 가드 3개 실패. 되돌린 뒤 재통과 |
|
||||
|
||||
에뮬레이터에서는 위 기능의 화면은 뜨지만 센서 · 결제 · 푸시가 동작하지 않습니다.
|
||||
`contrast_checks` 25쌍에는 앱 테마가 조립하며 새로 만드는 3쌍(선택 탭 라벨 9.21:1 ·
|
||||
인디케이터 위 선택 라벨 7.93:1 · 인디케이터 위 비선택 라벨 8.05:1)이 포함돼 있다.
|
||||
토큰 문서에만 있는 쌍을 검사하면 위젯 테마에서 대비가 무너져도 생성기가 통과를 찍는다.
|
||||
|
||||
### 실기기 기동 확인 (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 | 카카오 · LINE 로그인 연결 | 카카오는 SDK + 서버 커스텀 토큰(`fnAuthKakao`, 사용자 제공자 정보는 `oidc.kakao`), LINE 은 Firebase OIDC(`oidc.line`)다. 설정 방법은 `BUILD.md` 「카카오 · LINE 로그인 설정」 | 반영 완료 |
|
||||
| L3-1 | 「동일 이메일만 연동」이 Apple 재연동에서는 판정되지 않는다 | Apple 이 **최초 인가 때만** 이메일을 준다. 클라이언트에서 볼 값이 없어 통과시킨다 — 서버(fn-account-link)에 같은 판정을 두어야 완결된다 | Firestore 설계서 v1.1 |
|
||||
| L4 | 폰트 파일 미투입 | 시스템 폰트로 폴백. 크기·굵기·행간·자간은 토큰 값대로 적용됨 | 라이선스 원본 확보 시 |
|
||||
| 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 164,473,984 bytes(약 156.9 MiB) | 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 -->
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
# SwiftPM 이 iOS 빌드 때 여기에 서드파티 소스를 받는다. 우리 코드가 아니라
|
||||
# 분석 대상이 아니며, 그쪽 테스트 파일이 우리 lint 결과를 덮어 버린다.
|
||||
- build/**
|
||||
# Firebase 도구가 내려받는 서드파티 소스. 우리 분석 대상이 아니다.
|
||||
- '**/node_modules/**'
|
||||
- build/**
|
||||
- android/**
|
||||
- ios/**
|
||||
|
||||
@@ -25,13 +25,6 @@ val kakaoProperties = Properties().apply {
|
||||
if (f.exists()) f.inputStream().use { load(it) }
|
||||
}
|
||||
|
||||
// 구글 지도 API 키 — `android/local.properties` 의 `MAPS_API_KEY=<키>`(gitignore). 없으면 빈 값으로
|
||||
// 빌드되고 지도만 비어 보인다(크래시 없음).
|
||||
val localProperties = Properties().apply {
|
||||
val f = rootProject.file("local.properties")
|
||||
if (f.exists()) f.inputStream().use { load(it) }
|
||||
}
|
||||
|
||||
/// 이번 실행이 릴리스 산출물(assembleRelease · bundleRelease 등)을 요구하는가.
|
||||
val wantsRelease = gradle.startParameter.taskNames.any { it.contains("Release", ignoreCase = true) }
|
||||
|
||||
@@ -71,7 +64,6 @@ android {
|
||||
// AndroidManifest 의 카카오 로그인 리다이렉트 스킴 `kakao{네이티브 앱 키}`.
|
||||
manifestPlaceholders["kakaoNativeAppKey"] =
|
||||
kakaoProperties.getProperty("FANUZU_KAKAO_NATIVE_APP_KEY", "")
|
||||
manifestPlaceholders["mapsApiKey"] = localProperties.getProperty("MAPS_API_KEY", "")
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
@@ -112,3 +104,9 @@ android {
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// MainActivity 의 「버려진 Firebase 웹 로그인 정리」(kr.co.fanuzu.app/auth) — firebase-auth 가 이미 쓰는
|
||||
// 라이브러리라 새로 들어오는 것은 없다. 앱 모듈에서 직접 부르려고 컴파일 경로에 올린다.
|
||||
implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0")
|
||||
}
|
||||
|
||||
@@ -1,17 +1,4 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- 만보기 퀘스트(S-H-WALK). Android 10(API 29)부터 런타임 권한이고 minSdk 가 29 라
|
||||
분기 없이 항상 요청한다. 거부하면 인증만 막히고 목록·상세는 그대로 열린다. -->
|
||||
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
|
||||
<!-- 만보기 걸음 수 — Health Connect(견적서 「만보기(Health API)」 · 대조표 B-02). 걸음 읽기 하나만
|
||||
선언한다(Play 건강 앱 선언 양식 — 요청 권한마다 사유를 적는다). 거부하면 위 기기 센서로 센다(B-26). -->
|
||||
<uses-permission android:name="android.permission.health.READ_STEPS" />
|
||||
|
||||
<!-- 위치 퀘스트(S-H-PLACE). 별첨 B **B-27** 「탐색은 가능하나 보상 인증은
|
||||
GPS/Geofence 필수」 — 상시 추적이 아니라 인증 시점에만 쓴다(v1.6.1 13장).
|
||||
그래서 ACCESS_BACKGROUND_LOCATION 은 요청하지 않는다. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
|
||||
<application
|
||||
android:label="FANUZU"
|
||||
android:name="${applicationName}"
|
||||
@@ -20,7 +7,6 @@
|
||||
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"
|
||||
@@ -37,22 +23,7 @@
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<!-- Health Connect 권한 화면의 「개인정보처리방침」 링크가 여는 화면(health 플러그인 설정 안내). -->
|
||||
<intent-filter>
|
||||
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Android 14+ 의 Health Connect 권한 사용 내역 → 앱. -->
|
||||
<activity-alias
|
||||
android:name="ViewPermissionUsageActivity"
|
||||
android:exported="true"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
|
||||
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
<!-- Apple Android 웹 인증 callback. Service ID/redirect URI가 투입되기 전에는
|
||||
Dart 설정 게이트가 SDK 호출을 막는다. -->
|
||||
<activity
|
||||
@@ -78,27 +49,6 @@
|
||||
<data android:scheme="kakao${kakaoNativeAppKey}" android:host="oauth" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- AdMob 앱 ID — Google 공식 **테스트** 앱 ID(9주차 샌드박스). 발주사 AdMob 계정의
|
||||
실 앱 ID 로 바꿀 때 lib/core/monetization/rewarded_ad.dart 의 AdUnits 광고 단위도 함께 바꾼다. -->
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.ads.APPLICATION_ID"
|
||||
android:value="ca-app-pub-3940256099942544~3347511713" />
|
||||
<!-- 구글 지도 API 키 — android/local.properties 의 MAPS_API_KEY(저장소 밖). 없으면 지도만 빈다. -->
|
||||
<meta-data
|
||||
android:name="com.google.android.geo.API_KEY"
|
||||
android:value="${mapsApiKey}" />
|
||||
<!-- FCM 알림 표시 기본값 — Android 가 유일한 실동작 플랫폼이다(iOS 는 APNs 키
|
||||
대기). 둘 다 없으면 백그라운드 알림이 「Miscellaneous」 채널로 흘러가고
|
||||
흰 사각형 아이콘으로 뜬다. 사용자가 채널을 끄면 5개 구분 설정과 무관하게
|
||||
전부 막히므로, 정책 원장 B-77 의 구분 설정이 유일한 제어면이 되도록
|
||||
앱이 채널을 직접 만든다(MainActivity.kt). -->
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||
android:value="@string/default_notification_channel_id" />
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||
android:resource="@mipmap/ic_launcher" />
|
||||
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
@@ -111,11 +61,6 @@
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<!-- Health Connect 설치 여부 확인(health 플러그인). -->
|
||||
<package android:name="com.google.android.apps.healthdata" />
|
||||
<intent>
|
||||
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
|
||||
@@ -1,63 +1,29 @@
|
||||
package kr.co.fanuzu.app
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
import android.content.Intent
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.text.Collator
|
||||
import java.util.Locale
|
||||
|
||||
// Health Connect 권한 요청(health 플러그인)이 `registerForActivityResult` 를 쓰므로 FragmentActivity 여야 한다.
|
||||
class MainActivity : FlutterFragmentActivity() {
|
||||
/**
|
||||
* FCM 기본 알림 채널을 만든다.
|
||||
*
|
||||
* AndroidManifest 의 `default_notification_channel_id` 는 **가리키기만 한다.**
|
||||
* 채널을 만들지 않으면 Android 가 「Miscellaneous」로 대체하고, 사용자는 그
|
||||
* 채널을 끄는 것 말고 조절할 수 없다. 정책 원장 B-77 의 5개 구분이 유일한
|
||||
* 제어면이어야 하므로 채널을 하나만 두고 구분은 토픽 구독으로 가른다.
|
||||
*
|
||||
* 채널 생성은 멱등이다 — 같은 ID 로 다시 만들어도 사용자가 바꾼 설정을
|
||||
* 덮지 않는다. minSdk 가 29 라 버전 분기가 필요 없다.
|
||||
*/
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
getString(R.string.default_notification_channel_id),
|
||||
getString(R.string.default_notification_channel_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 국가 표시명 — 앱 언어로 OS 가 아는 이름(`Locale.getDisplayCountry`)을 OS 정렬(`Collator`) 순서로 준다.
|
||||
* 국가 이름을 앱이 번역하지 않으려는 통로다(Dart `loadCountryNames`). 결과는 `[[코드, 이름], …]`.
|
||||
*/
|
||||
class MainActivity : FlutterActivity() {
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "kr.co.fanuzu.app/locale")
|
||||
// 버려진 Firebase 웹 로그인(LINE · Apple OIDC) 정리 — Dart `clearStaleWebSignIn`.
|
||||
// 인가 중 다른 앱(LINE 본인 확인 등)으로 갔다 돌아오면 Firebase 의 대기 작업이 결과 없이 남아,
|
||||
// 다음 시도가 `web-context-already-presented` 로 막힌다. Firebase 의 인가 화면이 「닫힘」을 알릴 때
|
||||
// 보내는 것과 같은 취소 신호를 보내 대기 작업을 취소로 끝내고 다음 시도를 연다. 대기 작업이 없으면 아무 일도 없다.
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "kr.co.fanuzu.app/auth")
|
||||
.setMethodCallHandler { call, result ->
|
||||
if (call.method != "countryNames") {
|
||||
if (call.method != "cancelPendingWebSignIn") {
|
||||
result.notImplemented()
|
||||
return@setMethodCallHandler
|
||||
}
|
||||
val locale = Locale.forLanguageTag(call.argument<String>("locale") ?: "ko")
|
||||
val collator = Collator.getInstance(locale)
|
||||
val pairs = Locale.getISOCountries()
|
||||
.map { listOf(it, Locale("", it).getDisplayCountry(locale)) }
|
||||
.sortedWith(compareBy(collator) { it[1] })
|
||||
result.success(pairs)
|
||||
}
|
||||
// API 레벨 — Android 13+ 는 클립보드 복사를 시스템이 알리므로 앱 토스트를 생략한다(Dart `copyToClipboard`).
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "kr.co.fanuzu.app/device")
|
||||
.setMethodCallHandler { call, result ->
|
||||
if (call.method == "sdkInt") result.success(Build.VERSION.SDK_INT) else result.notImplemented()
|
||||
LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(
|
||||
Intent("com.google.firebase.auth.ACTION_RECEIVE_FIREBASE_AUTH_INTENT")
|
||||
.putExtra("com.google.firebase.auth.internal.EXTRA_CANCELED", true)
|
||||
)
|
||||
result.success(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 런치 배경 = color-cosmic-void(#141414, Figma 스플래시 `184:35466` 배경). Flutter 첫 프레임 전 흰 화면이 번쩍이지 않게 한다. -->
|
||||
<!-- 런치 배경 = color-cosmic-void(#000). Flutter 첫 프레임 전 흰 화면이 번쩍이지 않게 한다. -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/cosmic_void" />
|
||||
|
||||
<!-- 스플래시 로고 — Figma `510:19047` 「화면 중앙 정렬 · 앱 실행 시 노출」. 발주사 인수 자산
|
||||
`고객사 준비 데모/LOGO/fanuzu logo only.png` 의 마크만 잘라 쓴다. -->
|
||||
<item>
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@drawable/launch_logo" />
|
||||
</item>
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
||||
|
Before Width: | Height: | Size: 151 KiB |
@@ -1,13 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 런치 배경 = color-cosmic-void(#141414, Figma 스플래시 `184:35466` 배경). Flutter 첫 프레임 전 흰 화면이 번쩍이지 않게 한다. -->
|
||||
<!-- 런치 배경 = color-cosmic-void(#000). Flutter 첫 프레임 전 흰 화면이 번쩍이지 않게 한다. -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/cosmic_void" />
|
||||
|
||||
<!-- 스플래시 로고 — Figma `510:19047` 「화면 중앙 정렬 · 앱 실행 시 노출」. 발주사 인수 자산
|
||||
`고객사 준비 데모/LOGO/fanuzu logo only.png` 의 마크만 잘라 쓴다. -->
|
||||
<item>
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@drawable/launch_logo" />
|
||||
</item>
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Android 12+ 시스템 스플래시 아이콘 — Figma `510:19047` 「화면 중앙 정렬 · 앱 실행 시 노출」.
|
||||
launch_background 와 같은 FANUZU 마크(launch_logo, 120dp)를 쓴다. 시스템은 이 그림을
|
||||
240dp 칸에 그리고 지름 160dp 원으로 자르므로, 마크를 원래 크기로 가운데 두어 잘리지 않게 한다. -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@drawable/launch_logo" />
|
||||
</item>
|
||||
</layer-list>
|
||||
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Android 12+ 는 windowBackground 대신 시스템 스플래시(배경색 + 가운데 아이콘)를 그린다.
|
||||
지정하지 않으면 흰 둥근 사각 안 앱 아이콘이 뜬다(10/6 실기기 E-1).
|
||||
배경 = Figma 스플래시 `184:35466` #141414(color-cosmic-void) · 아이콘 = FANUZU 마크. 앱은 다크 전용이라 night 도 같다. -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:windowSplashScreenBackground">@color/cosmic_void</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
|
||||
<item name="android:windowSplashScreenIconBackgroundColor">@color/cosmic_void</item>
|
||||
</style>
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Android 12+ 는 windowBackground 대신 시스템 스플래시(배경색 + 가운데 아이콘)를 그린다.
|
||||
지정하지 않으면 흰 둥근 사각 안 앱 아이콘이 뜬다(10/6 실기기 E-1).
|
||||
배경 = Figma 스플래시 `184:35466` #141414(color-cosmic-void) · 아이콘 = FANUZU 마크. 앱은 다크 전용이라 night 도 같다. -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:windowSplashScreenBackground">@color/cosmic_void</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
|
||||
<item name="android:windowSplashScreenIconBackgroundColor">@color/cosmic_void</item>
|
||||
</style>
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -2,6 +2,6 @@
|
||||
<!-- design/design-tokens.md 의 color-cosmic-void. 네이티브 런치 화면이 Flutter 첫 프레임과
|
||||
같은 캔버스에서 시작하도록 쓴다. 앱은 다크 전용이라 values-night 를 따로 두지 않는다. -->
|
||||
<resources>
|
||||
<color name="cosmic_void">#FF141414</color>
|
||||
<color name="cosmic_void">#FF000000</color>
|
||||
<color name="ic_launcher_background">#120E25</color>
|
||||
</resources>
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- FCM 기본 알림 채널. AndroidManifest 의 default_notification_channel_id 와
|
||||
MainActivity 의 채널 생성이 같은 값을 봐야 한다. -->
|
||||
<string name="default_notification_channel_id" translatable="false">fanuzu_default</string>
|
||||
<!-- 기기 알림 설정에 보이는 채널 이름이라 사용자 노출 문자열이다. -->
|
||||
<string name="default_notification_channel_name">FANUZU 알림</string>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 986 B |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 857 B |
|
Before Width: | Height: | Size: 801 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 435 B |
|
Before Width: | Height: | Size: 835 B |
|
Before Width: | Height: | Size: 340 B |
|
Before Width: | Height: | Size: 811 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 798 B |
|
Before Width: | Height: | Size: 472 B |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 532 B |
|
Before Width: | Height: | Size: 835 B |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 680 B |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 496 B |
|
Before Width: | Height: | Size: 690 B |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 637 B |
|
Before Width: | Height: | Size: 456 B |
|
Before Width: | Height: | Size: 1006 B |
|
Before Width: | Height: | Size: 944 B |
|
Before Width: | Height: | Size: 237 B |
|
Before Width: | Height: | Size: 760 B |
|
Before Width: | Height: | Size: 823 B |
|
Before Width: | Height: | Size: 510 B |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 472 B |
|
Before Width: | Height: | Size: 696 B |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 845 B |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 863 B |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 370 KiB |
|
Before Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 768 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 423 KiB |
|
Before Width: | Height: | Size: 309 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 309 KiB |
|
Before Width: | Height: | Size: 309 KiB |
|
Before Width: | Height: | Size: 276 KiB |
|
Before Width: | Height: | Size: 355 KiB |
|
Before Width: | Height: | Size: 423 KiB |
|
Before Width: | Height: | Size: 276 KiB |
|
Before Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 591 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 629 B |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 95 KiB |