import 'package:cloud_firestore/cloud_firestore.dart' show Timestamp; import '../../core/data/backend.dart'; import '../../core/data/invite_code.dart'; import '../../core/data/server_clock.dart'; import 'home_data.dart'; import '../quest/quest_data.dart' show QuestType; import '../../core/i18n/app_locale.dart'; /// 홈의 실 서버 구현 — 화면 대조 원장 F1. /// /// [SampleHomeRepository] 와 같은 인터페이스라 화면은 어느 쪽이 붙었는지 모른다. /// 값의 출처만 표본에서 Firestore 로 바뀐다. /// /// 성장 조건은 팬덤 공동 상태와 Stage 마스터에서 읽는다. /// 개인 멤버 기여량/개인 행성 Stage는 공동 성장으로 대체하지 않는다. class FirestoreHomeRepository implements HomeRepository { FirestoreHomeRepository(this._backend); final Backend _backend; @override Stream watchGrowth(String fandomId) { if (fandomId.isEmpty) return const Stream.empty(); return _backend.db .doc('fandoms/$fandomId') .snapshots(includeMetadataChanges: true) .where((s) => !s.metadata.isFromCache && !s.metadata.hasPendingWrites) .asyncMap((s) => _growth(s.data() ?? const {}, fandomId)); } @override Future<({String fandomId, bool primaryChanged})> createNebula( String name, ) async { final data = await _backend.call('fnNebulaCreate', {'name': name}); final fandomId = data['fandomId']; // ID 없이 성공으로 돌아오면 화면이 빈 ID 로 성운 상세를 연다 — 실패로 다룬다. if (fandomId is! String || fandomId.isEmpty) { throw BackendException('E-SYS-01', appL10n.nebulaCreateFailed); } return ( fandomId: fandomId, primaryChanged: data['primaryChanged'] != false, ); } @override Future createPlanet({ required String fandomId, required String planetType, required String colorKey, }) => _backend.call('fnPlanetCreate', { 'fandomId': fandomId, 'planetType': planetType, 'colorKey': colorKey, }); @override Future nebulaTargets() async { try { return nebulaTargetsFrom( (await _backend.db.doc('config/policy').get()).data(), ); } catch (_) { return defaultNebulaTargets; } } @override Future load() async { final uid = _backend.requireUid; // 공유 링크 `?ref=` 용 — 기다리지 않는다. InviteCode.ensure(_backend); final db = _backend.db; final userSnap = await db.doc('users/$uid').get(); final user = userSnap.data() ?? const {}; final fandomId = user.strOr('fandomId'); // 홈 한 화면에 필요한 것을 병렬로 띄운다. 순차로 기다리면 왕복이 그대로 쌓인다. final walletF = db.doc('users/$uid/wallet/main').get(); final fandomF = fandomId.isEmpty ? null : db.doc('fandoms/$fandomId').get(); final questsF = db .collection('quests') .where('active', isEqualTo: true) .limit(5) .get(); // 긴급 돌발 타임어택(FR-QST-06) — 내 팬덤의 **열린 창**만. 위 목록과 따로 읽어 맨 앞에 // 둔다. 위 질의는 정렬이 없어 5 건 안에 든다는 보장이 없다. 색인 `firestore.indexes.json`. // 질의는 단말 시계 기준 1시간 여유로 넓게 받고, 마감 판정은 아래에서 서버 시각으로 한다. // ponytail: 단말 시계가 1시간 넘게 빠르면 열린 창을 놓친다. 그때는 여유를 늘린다. final timeAttacksF = fandomId.isEmpty ? null : db .collection('quests') .where('type', isEqualTo: 'timeattack') .where('fandomId', isEqualTo: fandomId) .where( 'activeTo', isGreaterThan: Timestamp.fromDate( DateTime.now().subtract(const Duration(hours: 1)), ), ) .limit(10) .get(); final progressF = db.collection('users/$uid/questProgress').get(); // 출석 보상은 서버가 `config/policy.attendPop` 으로 지급한다. 같은 곳을 읽는다. final policyF = db.doc('config/policy').get(); // 브리핑은 **게시된 당일분만** 읽는다 — 정책 원장 B-47 「당일 승인된 콘텐츠만 // 노출 · 미승인 Draft 노출 0건 · 전일 콘텐츠 자동 재사용 없음」. 세 조건을 // 질의에 두어 화면이 걸러 낼 것이 남지 않게 한다. 앱에서 거르면 초안이 기기에 // 한 번 내려온 뒤에 지워지는 것이라 「노출 0건」이 아니다. final briefingsF = db .collection('briefings') .where('fandomId', isEqualTo: fandomId) .where('status', isEqualTo: 'published') .where('dayKey', isEqualTo: kstDayKey()) // **섹션별로 rank 를 매기므로 정렬 키가 둘이다.** `rank` 만으로 자르면 // News 3 건 + SNS 1 건일 때 rank 3 두 개가 경쟁해 News 셋째 줄이 잘린다. // 상한 6 은 두 구획 각 3 줄 기준이다(Figma 홈의 3 줄 × 구획 2). .orderBy('section') .orderBy('rank') .limit(6) .get(); // 차단한 개설자의 캠페인은 건너뛴다 — 다섯 건을 읽어 첫 비차단 캠페인을 쓴다. final campaignF = db .collection('campaigns') .where('status', isEqualTo: 'funding') .orderBy('raisedPop', descending: true) .limit(5) .get(); final blocksF = db.collection('users/$uid/blocks').get(); // Hero Gift Catalog(원장 B-85) — 판매 중인 것만. 소속 행성이 없는 사용자는 시트가 // 결제 창을 열기 전에 막는다(`fn-iap-verify` 도 거부한다). final itemsF = db .collection('heroGiftProducts') .where('active', isEqualTo: true) .get(); final insidesF = db .collection('questions') .orderBy('createdAt', descending: true) .limit(3) .get(); final wallet = (await walletF).data() ?? const {}; final fandom = fandomF == null ? const {} : ((await fandomF).data() ?? const {}); final timeAttacks = [ for (final doc in (await timeAttacksF)?.docs ?? const []) if (doc.data()['active'] != false) doc, ]; if (timeAttacks.isNotEmpty) await ServerClock.ensure(_backend); final serverNow = ServerClock.now(); final quests = [ ...timeAttacks.where((d) { final to = d.data()['activeTo']; return to is Timestamp && to.toDate().isAfter(serverNow); }), // 타임어택은 위에서 창이 열린 것만 받았다. 여기서 다시 받으면 마감된 것이 남는다. ...(await questsF).docs.where((d) => d.data()['type'] != 'timeattack'), ]; final progress = (await progressF).docs; final briefings = (await briefingsF).docs; final blocked = {for (final d in (await blocksF).docs) d.id}; final campaigns = (await campaignF).docs .where((d) => !blocked.contains(d.data()['creatorUid'])) .toList(); final insides = (await insidesF).docs; final progressById = {for (final doc in progress) doc.id: doc.data()}; final policy = (await policyF).data() ?? const {}; return HomeSnapshot( popBalance: wallet.intOr('activityBalance'), fandomId: fandomId, liveTicker: fandom.strOr('liveTicker').isEmpty ? null : fandom.strOr('liveTicker'), growth: await _growth(fandom, fandomId), quests: [ for (final doc in quests) _quest(doc.id, doc.data(), progressById[doc.id], policy), ], briefings: [for (final doc in briefings) ?_briefing(doc.data())], campaign: campaigns.isEmpty ? null : _campaign(campaigns.first.data()), insides: [for (final doc in insides) _inside(doc.data())], items: [for (final doc in (await itemsF).docs) _item(doc.id, doc.data())], ); } /// 문서 ID 가 스토어 상품 ID 다. 수치는 Admin 저장값 그대로(B-84 · 환율 재계산 없음). ItemProduct _item(String productId, Map data) => ItemProduct( productId: productId, name: data.strOr('name', productId), description: data.strOr('description'), dropPop: data.intOr('dropPop'), buyerPop: data.intOr('buyerPop'), priceWon: data.intOr('priceWon'), // 구매 즉시 Drop 이 열려 보유 재고가 생기지 않는다. owned: 0, ); /// 브리핑 한 줄 — 정책 원장 B-75 5 요소가 전건 있어야 그린다. /// /// **하나라도 비면 그 줄을 버린다.** 서버가 수신 단계에서 이미 거부하지만 /// (`fn-briefing-ingest`), Admin 수동 편집이나 예전 스키마 문서가 섞일 수 있다. /// 여기서 통과시키면 출처가 빈 카드가 화면에 뜨고 그것이 B-75 위반이다. /// 섹션이 News·SNS 밖이면 정책이 정한 구획이 아니므로 역시 버린다. BriefingItem? _briefing(Map data) { final section = BriefingSection.of(data.strOr('section')); final headline = data.strOr('headline'); final sourceName = data.strOr('sourceName'); final sourceUrl = data.strOr('sourceUrl'); final articleUrl = data.strOr('articleUrl'); final aiSummary = data.strOr('aiSummary'); final sourceAt = data['sourceAt']; if (section == null || headline.isEmpty || sourceName.isEmpty || sourceUrl.isEmpty || articleUrl.isEmpty || aiSummary.isEmpty || sourceAt == null) { return null; } return BriefingItem( rank: data.intOr('rank', 1), headline: headline, section: section, sourceName: sourceName, sourceAt: data.dateOr('sourceAt', DateTime.now()), sourceUrl: sourceUrl, articleUrl: articleUrl, aiSummary: aiSummary, ); } Future _growth( Map fandom, String fandomId, ) async { final stage = switch (fandom['stage']) { int n when n >= 0 && n <= 6 => n, _ => null, }; // Stage0의 탄생은 별도의 멤버/POP 조건이다. Stage1 마스터의 0으로 승급을 추정하지 않는다. final next = stage != null && stage > 0 && stage < 6 ? (await _backend.db.doc('planetStages/${stage + 1}').get()).data() : null; int? validInt(dynamic value) => value is int && value >= 0 ? value : null; num? validNumber(dynamic value) => value is num && value.isFinite && value >= 0 ? value : null; final current = validNumber(fandom['growthPop']); final required = validInt(next?['requiredPop']); // 성운(Stage0)의 전환 목표는 서버와 같은 `config/policy` 키로 읽는다. final policy = stage == 0 ? (await _backend.db.doc('config/policy').get()).data() : null; final targets = stage == 0 ? nebulaTargetsFrom(policy) : null; final goal = targets?.pop ?? required; // Q-PN-02 — 기간이 지난 성운은 운영 설정이 막지 않는 한 조건만 채우면 탄생한다(서버와 같은 판정). final deadline = fandom['nebulaDeadline']; final goalPassed = stage == 0 && deadline is Timestamp && ServerClock.now().isAfter(deadline.toDate()) && policy?['nebulaConversionDeadlineEnforced'] != true; return PlanetGrowth( stage: stage, fandomName: fandom.strOr('name', fandomId), // 기존 데이터에 이메일 형태가 남아 있어도 가린다. contributorName: fandom.containsKey('lastContributorName') ? publicName(fandom['lastContributorName']) : '', contributedPop: fandom.intOr('lastContributedPop'), growthPercent: current != null && goal != null && goal > 0 ? ((current / goal) * 100).clamp(0, 100).round() : null, currentPop: current, requiredPop: goal, currentSfpi: validNumber(fandom['sfpi']), members: validInt(fandom['memberCount']), requiredMembers: targets?.members, // Q-PN-01 A안 — 탄생은 창립자만. isFounder: fandom['founderUid'] != null && fandom['founderUid'] == _backend.uid, requiredSfpi: validNumber(next?['requiredSfpi']), nebulaGoalPassed: goalPassed, ); } QuestSummary _quest( String id, Map quest, Map? progress, Map policy, ) { final done = (progress?.intOr('count') ?? 0) >= quest.intOr('dailyLimit', 1); final started = (progress?.intOr('count') ?? 0) > 0; return QuestSummary( title: quest.strOr('title'), rewardPop: QuestType.parse(quest.strOr('type')).rewardFrom(quest, policy), state: done ? QuestState.done : started ? QuestState.claimable : QuestState.inProgress, progressLabel: quest.strOr('progressLabel').isEmpty ? null : quest.strOr('progressLabel'), id: id, endsAt: quest['type'] == 'timeattack' && quest['activeTo'] is Timestamp ? (quest['activeTo'] as Timestamp).toDate() : null, ); } CampaignHighlight _campaign(Map data) { // 규칙의 스키마 이름(`targetPop` · `creatorName` · `summary`)을 먼저 읽는다. final goal = data.intOr('targetPop', data.intOr('goalPop')); final raised = data.intOr('raisedPop'); return CampaignHighlight( authorName: publicName(data['creatorName'] ?? data['authorName']), achievedPercent: goal == 0 ? 0 : ((raised / goal) * 100).round(), title: data.strOr('title'), description: data.strOr('summary', data.strOr('description')), supporters: data.intOr('donorCount'), comments: data.intOr('commentCount'), likes: data.intOr('likeCount'), ); } InsideDigest _inside(Map data) { final created = data.dateOr('createdAt', DateTime.now()); return InsideDigest( authorName: publicName(data['creatorName'] ?? data['authorName']), timeAgo: _timeAgo(created), bountyPop: data.intOr('bountyPop'), title: data.strOr('title'), body: data.strOr('body'), answers: data.intOr('answerCount'), ); } } /// 상대 시각. 화면이 「10분 전」 형태를 그대로 쓰므로 여기서 문자열로 만든다. String _timeAgo(DateTime at) { final gap = DateTime.now().difference(at); if (gap.inMinutes < 1) return appL10n.timeJustNow; if (gap.inHours < 1) return appL10n.timeMinutesAgo('${gap.inMinutes}'); if (gap.inDays < 1) return appL10n.timeHoursAgo('${gap.inHours}'); return appL10n.timeDaysAgo('${gap.inDays}'); } /// 오늘 날짜(KST `YYYY-MM-DD`) — 서버 `briefings.dayKey` 와 같은 규약이다. /// /// 단말 시간대로 끊으면 해외 사용자의 「오늘」이 서버의 `dayKey` 와 달라 브리핑이 /// 안 보인다. 브리핑은 한국 시간 기준 하루다. String kstDayKey([DateTime? at]) => (at ?? DateTime.now().toUtc()) .toUtc() .add(const Duration(hours: 9)) .toIso8601String() .substring(0, 10);