Passport Tier 팬덤 단위 · 활동 Badge · 진화 Emblem · Eclipse 참여 기록

This commit is contained in:
Sn-Kinos
2026-10-05 22:09:10 +09:00
parent 71ec17dc3c
commit cc0f2fb292
36 changed files with 877 additions and 81 deletions
@@ -19,8 +19,10 @@ import '../../core/i18n/app_locale.dart';
/// - 프로필 `users/{uid}` (nickname · photoUrl · originNo · steps)
/// - 완료 퀘스트 `users/{uid}/verifiedCompletions` 표식 수(무효 제외)
/// - 누적 기여 POP `users/{uid}.totalContributedPop`(`fn-pop-spend` 가 갱신, 설계서 §3.6)
/// - 레벨 `users/{uid}.{userExp, userLevel}` · Passport `users/{uid}.passportTier`
/// (C-18 · B-65). 필드가 없는 기존 사용자는 Lv.1 · SILVER 로 둔다.
/// - 레벨 `users/{uid}.{userExp, userLevel}`(C-18). 필드가 없는 기존 사용자는 Lv.1.
/// - Passport `users/{uid}/passports/{fandomId}.tier` — **팬덤별**(10/3 #10 · 9/21 5절). 없으면 SILVER.
/// 탈퇴한 팬덤의 Passport 도 지우지 않고 보인다(ARCHIVED).
/// - Badge `users/{uid}/badges`(상세정의 4장 · 별첨 1 ②-5)
/// - 잔액 `users/{uid}/wallet/main.activityBalance`
/// - 행성별 기여 `fandoms/{fandomId}` + `fandoms/{fandomId}/members/{uid}.contributedPop`
/// - POP 원장 `users/{uid}/popTransactions` (최신순 · 커서 `createdAt`)
@@ -64,22 +66,47 @@ class FirestoreMyRepository extends SampleMyRepository {
final user = (await userF).data() ?? const <String, dynamic>{};
final wallet = (await walletF).data() ?? const <String, dynamic>{};
final fandomId = user.strOr('fandomId');
var tiers = const <PlanetTier>[];
if (fandomId.isNotEmpty) {
final fandomF = db.doc('fandoms/$fandomId').get();
final memberF = db.doc('fandoms/$fandomId/members/$uid').get();
final fandom = (await fandomF).data() ?? const <String, dynamic>{};
final member = (await memberF).data() ?? const <String, dynamic>{};
tiers = [
PlanetTier(
fandomName: fandom.strOr('name', fandomId),
// 팬덤별 누적 기여 POP. `fn-pop-spend` 가 기여 차감 때 함께 올린다.
contributedPop: member.intOr('contributedPop'),
isPrimary: true,
final primaryId = user.strOr('primaryFandomId', user.strOr('fandomId'));
final joinedIds = (user['fandomIds'] as List?)?.whereType<String>().toSet() ?? <String>{};
if (primaryId.isNotEmpty) joinedIds.add(primaryId);
// 규칙 배포 전 서버에서는 하위 컬렉션 읽기가 거부된다 — MY 전체를 떨어뜨리지 않는다.
Future<List<QueryDocumentSnapshot<Map<String, dynamic>>>> docsOf(String path) => db
.collection(path)
.get()
.then((q) => q.docs, onError: (Object _) => <QueryDocumentSnapshot<Map<String, dynamic>>>[]);
final passportsF = docsOf('users/$uid/passports');
final badgesF = docsOf('users/$uid/badges');
final passports = {for (final d in await passportsF) d.id: d.data()};
final ids = {...joinedIds, ...passports.keys};
final tiers = await Future.wait(ids.map((id) async {
final joined = joinedIds.contains(id);
final fandom = (await db.doc('fandoms/$id').get()).data() ?? const <String, dynamic>{};
final member = joined
? (await db.doc('fandoms/$id/members/$uid').get()).data() ?? const <String, dynamic>{}
: const <String, dynamic>{};
final passport = passports[id] ?? const <String, dynamic>{};
return PlanetTier(
fandomId: id,
fandomName: fandom.strOr('name', id),
// 팬덤별 누적 기여 POP. 가입 중이면 멤버 문서(`fn-pop-spend` 가 올림), 탈퇴했으면 Passport 보존값.
contributedPop: joined ? member.intOr('contributedPop') : passport.intOr('contributedPop'),
isPrimary: id == primaryId,
passportTier: PassportTier.parse(passport.strOr('tier')),
joined: joined,
);
}));
final primaryTier = tiers.where((t) => t.isPrimary).firstOrNull?.passportTier ?? PassportTier.silver;
final badges = [
for (final d in await badgesF)
MyBadge(
id: d.id,
kind: d.data().strOr('kind'),
stage: d.data().intOr('stage'),
axis: d.data().strOr('axis'),
memberNo: d.data()['memberNo'] is num ? (d.data()['memberNo'] as num).toInt() : null,
toStage: d.data()['toStage'] is num ? (d.data()['toStage'] as num).toInt() : null,
),
];
}
];
// 닉네임이 아직 없으면(온보딩 전) 중립 이름으로 부른다. 「— 님」은 고장처럼 보인다.
// **이메일 앞부분을 쓰지 않는다** — 프로필 수정이 그 값을 그대로
@@ -95,11 +122,12 @@ class FirestoreMyRepository extends SampleMyRepository {
contributedPop: user.intOr('totalContributedPop'),
userExp: user.intOr('userExp'),
userLevel: user.intOr('userLevel').clamp(1, 1 << 31),
passportTier: PassportTier.parse(user.strOr('passportTier')),
passportTier: primaryTier,
balancePop: wallet.intOr('activityBalance'),
steps: user.intOr('steps'),
completedQuests: await completedF,
planetTiers: tiers,
badges: badges,
photoUrl: photoUrl.isEmpty ? null : photoUrl,
originNo: user.containsKey('originNo') ? user.intOr('originNo') : null,
);