앱 공통 계층 — 데이터 저장소 · 서버 시계 · 푸시 · 결제·광고 · 다국어 5종 · 행성 표현 · 공통 UI · 화면 자산

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent cbfb72efe0
commit c4e9a9430e
165 changed files with 29991 additions and 54 deletions
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# 퀘스트 참여 실왕복 — 에뮬레이터 세 개를 물고 기기에서 한 바퀴 돈다.
#
# `firebase emulators:exec` 가 이 스크립트를 실행한다. 먼저 시드를 넣고(보안 규칙이
# `quests` 쓰기를 마스터로 막아 앱이 만들 수 없다) 통합 테스트를 돌린다.
#
# cd firebase
# JAVA_HOME=... test/node_modules/.bin/firebase emulators:exec \
# --only auth,firestore,functions --project demo-fanuzu \
# "bash ../tool/emulator_quest_roundtrip.sh"
set -euo pipefail
DEVICE="${DEVICE:-emulator-5554}"
HOST="${FANUZU_EMULATOR_HOST:-127.0.0.1}"
# 앱이 쓰는 프로젝트 ID. 설정 파일이 들어간 빌드는 `[DEFAULT]` 앱을 그대로 쓰므로
# 실 프로젝트 ID 로 붙는다. 에뮬레이터는 프로젝트별로 칸이 나뉘어 시드도 같은 ID 에 넣는다.
PROJECT="${FANUZU_EMULATOR_PROJECT:-fanuzu-dev-b020d}"
BASE="http://127.0.0.1:8080/v1/projects/${PROJECT}/databases/(default)/documents"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
echo "▸ 시드 — 퀘스트 정의와 운영값 (${PROJECT})"
# 에뮬레이터는 `Bearer owner` 토큰을 관리자로 본다. 이 헤더가 없으면 보안 규칙이
# 그대로 적용돼 `quests` 쓰기가 막히고(마스터 전용) 시드가 조용히 사라진다.
AUTH=(-H 'Authorization: Bearer owner' -H 'Content-Type: application/json')
curl -sf -X PATCH "${BASE}/quests/daily-checkin" "${AUTH[@]}" -d '{
"fields": {
"type": {"stringValue": "attend"},
"title": {"stringValue": "출석 체크"},
"rewardPop": {"integerValue": "10"},
"dailyLimit": {"integerValue": "1"},
"active": {"booleanValue": true}
}
}' > /dev/null
curl -sf -X PATCH "${BASE}/config/policy" "${AUTH[@]}" -d '{
"fields": {
"attendPop": {"integerValue": "10"},
"stepTiers": {"arrayValue": {"values": [
{"mapValue": {"fields": {"steps": {"integerValue": "1000"}, "pop": {"integerValue": "5"}}}},
{"mapValue": {"fields": {"steps": {"integerValue": "5000"}, "pop": {"integerValue": "6"}}}},
{"mapValue": {"fields": {"steps": {"integerValue": "10000"}, "pop": {"integerValue": "7"}}}},
{"mapValue": {"fields": {"steps": {"integerValue": "15000"}, "pop": {"integerValue": "8"}}}}
]}}
}
}' > /dev/null
# 시드가 실제로 섰는지 본다. 없으면 여기서 끊는다 — 목록이 빈 채로 테스트가 돌면
# 「참여 버튼이 없다」로만 보여 원인을 찾는 데 시간이 든다.
curl -sf "${BASE}/quests/daily-checkin" "${AUTH[@]}" | grep -q '"attend"' \
|| { echo "✗ 시드 실패 — quests/daily-checkin 이 없습니다"; exit 1; }
echo "✓ 시드 확인"
echo "▸ 기기에서 왕복 (${DEVICE} · host ${HOST})"
cd "$ROOT"
exec fvm flutter test integration_test/quest_callable_emulator_test.dart \
-d "$DEVICE" --dart-define="FANUZU_EMULATOR_HOST=${HOST}"
+70
View File
@@ -0,0 +1,70 @@
// 원격 배포 다국어 덮어쓰기 층 생성기 — 요구사항 FR-I18N-01 「언어 리소스를 원격 배포 방식으로 갱신」.
//
// `flutter gen-l10n` 이 만드는 AppLocalizations 는 문자열이 컴파일 상수라, 재배포 없이
// 문구를 고칠 길이 없다. 여기서 그 클래스를 감싸는 [RemoteAppLocalizations] 를 만든다.
// 게터마다 Firestore `i18n/{locale}` 문서의 같은 키를 먼저 보고, 없으면 번들 문자열을 쓴다.
//
// 실행: `dart run tool/gen_l10n_remote.dart` (app/ 에서). arb 키를 더하거나 바꾸면 다시 돌린다.
// 생성물이 arb 와 어긋나면 test/i18n_test.dart 가 실패한다.
import 'dart:convert';
import 'dart:io';
const templatePath = 'lib/l10n/app_ko.arb';
const outputPath = 'lib/l10n/remote_app_localizations.dart';
void main() {
final arb =
jsonDecode(File(templatePath).readAsStringSync()) as Map<String, dynamic>;
File(outputPath).writeAsStringSync(generateRemoteL10n(arb));
stdout.writeln('wrote $outputPath');
}
String generateRemoteL10n(Map<String, dynamic> arb) {
final out = StringBuffer()
..writeln('// GENERATED by tool/gen_l10n_remote.dart — 직접 고치지 말 것.')
..writeln("import 'app_localizations.dart';")
..writeln()
..writeln('/// 번들 문자열 위에 원격 문자열(`i18n/{locale}` 문서)을 덮는다.')
..writeln('/// 원격에 없는 키는 번들 → 번들에도 없으면 ko(template) 순으로 폴백한다.')
..writeln('class RemoteAppLocalizations extends AppLocalizations {')
..writeln(
' RemoteAppLocalizations(this._base, this._remote) : super(_base.localeName);',
)
..writeln()
..writeln(' final AppLocalizations _base;')
..writeln(' final Map<String, String> _remote;')
..writeln()
..writeln(' /// 자리표시자를 한 번에 치환한다 — 인자 값 안의 `{name}` 은 다시 바꾸지 않는다.')
..writeln(' String? _fill(String key, Map<String, String> args) =>')
..writeln(' _remote[key]?.replaceAllMapped(')
..writeln(" RegExp(r'\\{(\\w+)\\}'),")
..writeln(' (m) => args[m[1]] ?? m[0]!,')
..writeln(' );');
for (final key
in arb.keys.where((k) => !k.startsWith('@')).toList()..sort()) {
final message = arb[key] as String;
final meta = arb['@$key'] as Map<String, dynamic>?;
final names = ((meta?['placeholders'] as Map<String, dynamic>?) ?? const {})
.keys
.toList();
out.writeln();
out.writeln(' @override');
if (names.isEmpty) {
out.writeln(" String get $key => _remote['$key'] ?? _base.$key;");
continue;
}
final params = names.map((n) => 'String $n').join(', ');
final args = names.join(', ');
// ICU select·plural 은 원격 문자열로 해석하지 않는다 — 번들만 쓴다.
if (message.contains(', select,') || message.contains(', plural,')) {
out.writeln(' String $key($params) => _base.$key($args);');
continue;
}
final map = names.map((n) => "'$n': $n").join(', ');
out.writeln(
" String $key($params) => _fill('$key', {$map}) ?? _base.$key($args);",
);
}
out.writeln('}');
return out.toString();
}