Files
fanuzu/app/lib/core/data/repositories.dart
T

267 lines
11 KiB
Dart

import '../../features/auth/onboarding_data.dart';
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();
/// 온보딩 6단계(약관 · 프로필 · 관심 아티스트 · 행성 가입).
late final OnboardingRepository onboarding = _live
? FirestoreOnboardingRepository(backend)
: SampleOnboardingRepository();
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<SocialAuthProvider> 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<Map<String, Object?>> _featureDocument() => backend.db
.doc(FeatureFlags.documentPath)
.snapshots()
.map((snapshot) => snapshot.data() ?? const {});
final _planetStates = <String, SharedStream<PlanetVisualState>>{};
Stream<PlanetVisualState> watchPlanet(String fandomId) {
if (!_live || fandomId.isEmpty) return const Stream.empty();
return (_planetStates[fandomId] ??= SharedStream(
() => _openPlanet(fandomId),
)).stream;
}
Stream<PlanetVisualState> _openPlanet(String fandomId) => Stream.multi((out) {
PlanetVisualState? planet;
var isEclipsed = false;
var isHeroTime = false;
Set<String>? heroEventIds;
Timer? heroExpiry;
DateTime? heroEndsAt;
List<String> heroNicknames = const [];
int? heroClaimCount;
void emit() {
final current = planet;
if (current != null)
out.add(
PlanetVisualState(
stage: current.stage,
colorKey: current.colorKey,
planetType: current.planetType,
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 = <DateTime>[];
final ids = <String>{};
final names = <String>{};
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);
}