HOME · GALAXY · 행성·성운 상세 · 팬덤 라운지 · 이클립스 화면
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
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<PlanetGrowth> 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<void> createPlanet({
|
||||
required String fandomId,
|
||||
required String planetType,
|
||||
required String colorKey,
|
||||
}) => _backend.call('fnPlanetCreate', {
|
||||
'fandomId': fandomId,
|
||||
'planetType': planetType,
|
||||
'colorKey': colorKey,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<NebulaTargets> nebulaTargets() async {
|
||||
try {
|
||||
return nebulaTargetsFrom(
|
||||
(await _backend.db.doc('config/policy').get()).data(),
|
||||
);
|
||||
} catch (_) {
|
||||
return defaultNebulaTargets;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<HomeSnapshot> 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 <String, dynamic>{};
|
||||
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 <String, dynamic>{};
|
||||
final fandom = fandomF == null
|
||||
? const <String, dynamic>{}
|
||||
: ((await fandomF).data() ?? const <String, dynamic>{});
|
||||
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 <String, dynamic>{};
|
||||
|
||||
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<String, dynamic> 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<String, dynamic> 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<PlanetGrowth> _growth(
|
||||
Map<String, dynamic> 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<String, dynamic> quest,
|
||||
Map<String, dynamic>? progress,
|
||||
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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);
|
||||
Reference in New Issue
Block a user