259 lines
11 KiB
Dart
259 lines
11 KiB
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();
|
|
|
|
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,
|
|
isHibernating: current.isHibernating,
|
|
level: current.level,
|
|
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);
|
|
}
|