import 'package:cloud_firestore/cloud_firestore.dart'; import 'dart:async'; import '../config/feature_flags.dart'; import '../monetization/store.dart'; import '../firebase/firebase_boot.dart'; import '../push/push_messaging.dart'; import '../../features/galaxy/galaxy_data.dart'; import '../../features/galaxy/galaxy_repository_firestore.dart'; import '../../features/home/home_data.dart'; import '../../features/home/home_repository_firestore.dart'; import '../../features/inside/inside_data.dart'; import '../../features/inside/inside_repository_firestore.dart'; import '../../features/auth/auth.dart'; import '../../features/auth/firebase_account_linker.dart'; import '../../features/campaign/campaign_data.dart'; import '../../features/campaign/campaign_repository_firestore.dart'; import '../../features/eclipse/eclipse_data.dart'; import '../../features/eclipse/eclipse_repository_firestore.dart'; import '../../features/my/my_data.dart'; import '../../features/notification/notification_data.dart'; import '../../features/my/my_repository_firestore.dart'; import '../../features/planet/planet_data.dart'; import '../../features/quest/quest_data.dart'; import '../../features/quest/quest_repository_firestore.dart'; import '../../features/planet/planet_repository_firestore.dart'; import '../../features/station/station_data.dart'; import '../../features/station/station_repository_firestore.dart'; import '../../features/translation/translation_data.dart'; import 'backend.dart'; import 'planet_visual_state.dart'; import 'shared_stream.dart'; /// 저장소 선택 — 서버가 붙어 있으면 Firestore, 아니면 표본. /// /// **화면은 이 분기를 모른다.** 화면이 `FirebaseBoot.isLive` 를 읽기 시작하면 /// 같은 조건문이 화면 수만큼 늘어나고, 설정 파일이 투입되는 날 전부 고쳐야 한다. /// 갈림길을 여기 한 곳에 둔다. /// /// 목 모드에서 표본을 쓰는 이유는 별첨 2 2주차 확인 항목 「앱 설치·로그인·탭 이동 /// 확인」 때문이다. 설정 파일이 없다고 화면이 빈 상태로 뜨면 확인 자체가 불가능하다. class Repositories { Repositories({Backend? backend, bool? live}) : _live = live ?? FirebaseBoot.isLive, _backend = backend; final bool _live; Backend? _backend; /// 서버가 붙어 있는가. 저장소 밖에서 Firestore 를 직접 볼 곳(다국어 원격 문자열)이 쓴다. bool get live => _live; Backend get backend => _backend ??= Backend(); HomeRepository get home => _live ? FirestoreHomeRepository(backend) : const SampleHomeRepository(); GalaxyRepository get galaxy => _live ? FirestoreGalaxyRepository(backend) : const SampleGalaxyRepository(); /// INSIDE 와 STATION 은 소속 팬덤으로 범위가 갈리지만, 라우터는 그 값을 모른다. /// 구현체가 사용자 문서에서 직접 읽으므로 여기서는 인자를 받지 않는다. /// 화면을 한 번 만들면 그대로 들고 있어야 커서와 캐시가 유지된다. late final InsideRepository inside = _live ? FirestoreInsideRepository(backend) : const SampleInsideRepository(); late final MyRepository my = _live ? FirestoreMyRepository(backend) : SampleMyRepository(); /// 퀘스트는 화면 3개(목록·상세·생성)가 같은 것을 들고 있어야 한다. 목록에서 /// 상세로 들어갔다 돌아올 때 페이지 커서와 걸음 세션이 유지되어야 하므로 /// 한 번 만들어 계속 쓴다. late final QuestRepository quest = _live ? FirestoreQuestRepository(backend) : const SampleQuestRepository(); /// 캠페인(F5). 목록 · 상세 · 랭킹이 같은 것을 들고 있어야 검색어가 유지된다. /// 예전에는 여기 없어서 서버가 붙어도 표본이 후원을 성공처럼 그렸다. late final CampaignRepository campaign = _live ? FirestoreCampaignRepository(backend) : SampleCampaignRepository(); late final StationRepository station = _live ? FirestoreStationRepository(backend) : const SampleStationRepository(); /// 알림함(F0). 화면을 여는 곳이 HOME·GALAXY 둘이라 저장소를 한 번만 만들어 /// 양쪽에 같은 것을 내려 준다. late final NotificationRepository notifications = _live ? FirestoreNotificationRepository(backend) : SampleNotificationRepository(); /// 이클립스. 대상 팬덤은 호출 시점에 정해지므로(내 팬덤이거나 보고 있는 행성) /// 저장소 자체는 하나만 둔다 — 화면마다 새로 만들면 구독이 화면 수만큼 늘어난다. late final EclipseRepository eclipse = _live ? FirestoreEclipseRepository(backend) : SampleEclipseRepository(); /// 참여형 번역(FR-I18N-02). 번역 영역을 붙이는 화면이 같은 것을 쓴다. late final TranslationRepository translation = _live ? FirestoreTranslationRepository(backend) : SampleTranslationRepository(); /// 계정 연결 경계 — 별첨 B v1.4 「하나의 UID 에 복수 소셜 Provider」. /// 목 모드에서는 실제 UID 가 없어 표본이 화면 동선만 유지한다. AccountLinker linker(List providers) => _live ? FirebaseAccountLinker(providers: providers) : SampleAccountLinker( seed: const {AuthProviderKind.google: 'fan@gmail.com'}, ); /// 푸시 수신 경계 — 정책 원장 B-77. 목 모드에서는 null 이다. /// /// 플러그인은 Firebase 가 초기화된 뒤에만 부를 수 있어서 여기서 갈라 둔다. /// null 이면 화면은 권한 재요청 버튼을 그리지 않고 토글 동선만 유지한다. late final PushMessaging? push = _live ? PushMessaging(backend: backend) : null; /// 인앱 결제 경계. 목 모드에서는 null 이고 화면은 「준비 중」을 띄운다. /// /// 결제 결과는 화면이 닫힌 뒤에도 도착하므로(OS 결제 창 · 승인 대기) 앱 전체에서 /// 하나를 쓴다. 홈 아이템 시트와 퀘스트 광고 모달이 [Store.instance] 로 찾는다. late final Store? store = _live ? (Store.instance = Store(backend)) : null; /// 원격 기능 플래그. 목 모드에서는 구독 없이 전부 꺼진 채로 둔다. late final FeatureFlags flags = FeatureFlags( source: _live ? _featureDocument() : null, ); /// `config/features` 한 장을 구독한다. 로그인 전에도 읽어야 해서 /// `firestore.rules` 가 `config/{id}` 를 `allow read: if true` 로 열어 두었다. Stream> _featureDocument() => backend.db .doc(FeatureFlags.documentPath) .snapshots() .map((snapshot) => snapshot.data() ?? const {}); final _planetStates = >{}; Stream watchPlanet(String fandomId) { if (!_live || fandomId.isEmpty) return const Stream.empty(); return (_planetStates[fandomId] ??= SharedStream( () => _openPlanet(fandomId), )).stream; } Stream _openPlanet(String fandomId) => Stream.multi((out) { PlanetVisualState? planet; var isEclipsed = false; var isHeroTime = false; Set? heroEventIds; Timer? heroExpiry; DateTime? heroEndsAt; List heroNicknames = const []; int? heroClaimCount; void emit() { final current = planet; if (current != null) out.add( PlanetVisualState( stage: current.stage, colorKey: current.colorKey, isHibernating: current.isHibernating, level: current.level, evolutionImminent: current.evolutionImminent, isEclipsed: isEclipsed, isHeroTime: isHeroTime, heroEventIds: heroEventIds, heroEndsAt: heroEndsAt, heroNicknames: heroNicknames, heroClaimCount: heroClaimCount, ), ); } final document = backend.db .doc('fandoms/$fandomId') .snapshots(includeMetadataChanges: true) .where((s) => !s.metadata.isFromCache && !s.metadata.hasPendingWrites) .listen((s) { planet = PlanetVisualState.fromDocument(s.data() ?? const {}); emit(); }, onError: out.addError); final danger = eclipse.watch(fandomId: fandomId).listen((state) { isEclipsed = state != null && !state.defended && !state.isExpired(DateTime.now()); emit(); }, onError: out.addError); // 기존 선물 이벤트만 읽는다. 구매 시도/로컬 캐시는 축제 확정이 아니다. final gifts = backend.db .collection('fandoms/$fandomId/heroDrops') .where('endsAt', isGreaterThan: Timestamp.now()) .snapshots(includeMetadataChanges: true) .where((s) => !s.metadata.isFromCache && !s.metadata.hasPendingWrites) .listen((snapshot) { void refresh() { heroExpiry?.cancel(); final now = DateTime.now(); final ends = []; final ids = {}; final names = {}; var claims = 0; var countsKnown = true; for (final doc in snapshot.docs) { final d = doc.data(); final end = d['endsAt']; final remaining = d['remainingPop']; if (end is Timestamp && end.toDate().isAfter(now) && remaining is num && remaining.isFinite && remaining > 0) { ends.add(end.toDate()); ids.add(doc.id); // 닉네임 없는 영웅(저장값 「팬」 · 빈 값)은 이름을 띄우지 않는다 — 서버가 「팬」을 // 싣기 전과 같은 표시(M-7). final nickname = publicName(d['heroNickname'], ''); if (nickname.isNotEmpty) names.add(nickname); final count = d['claimedCount']; if (count is int && count >= 0) { claims += count; } else { countsKnown = false; } } } heroEventIds = Set.unmodifiable(ids); heroNicknames = List.unmodifiable(names); heroClaimCount = ids.isNotEmpty && countsKnown ? claims : null; ends.sort(); heroEndsAt = ends.lastOrNull; isHeroTime = ends.isNotEmpty; emit(); if (ends.isNotEmpty) { heroExpiry = Timer(ends.first.difference(now), refresh); } } refresh(); }, onError: out.addError); out.onCancel = () async { heroExpiry?.cancel(); await gifts.cancel(); await document.cancel(); await danger.cancel(); }; }); /// 행성 상세는 팬덤 하나를 지정해야 하므로 호출부가 ID 를 준다. PlanetRepository planet(String? fandomId) => _live && fandomId != null && fandomId.isNotEmpty ? FirestorePlanetRepository(backend, fandomId: fandomId) // 목 모드에서도 누른 행성을 그려야 한다 — ID 를 버리면 어느 HUD 를 // 눌러도 같은 상세가 열린다. : SamplePlanetRepository(fandomId: fandomId); }