HOME 을 Figma 「홈」 구성으로 — TODAY'S QUEST · 미니맵·확장 맵(프로토타입 v6.2) · 행성 정보 박스 · 인큐베이터 · 아티스트 소식 · HOT ARCHIVE · 인기 K-INSIDE
This commit is contained in:
@@ -4,6 +4,7 @@ import '../../core/data/backend.dart';
|
||||
import '../../core/data/invite_code.dart';
|
||||
import '../../core/data/server_clock.dart';
|
||||
import 'home_data.dart';
|
||||
import '../inside/inside_repository_firestore.dart' show insideQuestionFrom;
|
||||
import '../quest/quest_data.dart' show QuestType;
|
||||
import '../quest/quest_repository_firestore.dart' show questToday;
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
@@ -30,6 +31,14 @@ class FirestoreHomeRepository implements HomeRepository {
|
||||
.asyncMap((s) => _growth(s.data() ?? const {}, fandomId));
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<int> watchPopBalance() => _backend.db
|
||||
.doc('users/${_backend.requireUid}/wallet/main')
|
||||
.snapshots()
|
||||
.map(
|
||||
(s) => (s.data() ?? const <String, dynamic>{}).intOr('activityBalance'),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<({String fandomId, bool primaryChanged})> createNebula(
|
||||
String name,
|
||||
@@ -82,10 +91,13 @@ class FirestoreHomeRepository implements HomeRepository {
|
||||
// 홈 한 화면에 필요한 것을 병렬로 띄운다. 순차로 기다리면 왕복이 그대로 쌓인다.
|
||||
final walletF = db.doc('users/$uid/wallet/main').get();
|
||||
final fandomF = fandomId.isEmpty ? null : db.doc('fandoms/$fandomId').get();
|
||||
// TODAY'S QUEST — 설명 프레임 `2097:16397` 「관리자 퀘스트 최신순 최대 3개」. 사용자 생성
|
||||
// 퀘스트(`authorUid` 있음)는 HOME 에 띄우지 않는다. 정렬 · 거르기는 아래에서 한다.
|
||||
// ponytail: 활성 30건 안에서만 고른다. (active, createdAt) 색인을 배포하면 질의로 옮긴다.
|
||||
final questsF = db
|
||||
.collection('quests')
|
||||
.where('active', isEqualTo: true)
|
||||
.limit(5)
|
||||
.limit(30)
|
||||
.get();
|
||||
// 긴급 돌발 타임어택(FR-QST-06) — 내 팬덤의 **열린 창**만. 위 목록과 따로 읽어 맨 앞에
|
||||
// 둔다. 위 질의는 정렬이 없어 5 건 안에 든다는 보장이 없다. 색인 `firestore.indexes.json`.
|
||||
@@ -124,12 +136,21 @@ class FirestoreHomeRepository implements HomeRepository {
|
||||
.orderBy('rank')
|
||||
.limit(6)
|
||||
.get();
|
||||
// 차단한 개설자의 캠페인은 건너뛴다 — 다섯 건을 읽어 첫 비차단 캠페인을 쓴다.
|
||||
// HOT ARCHIVE 캐러셀 — 명세 §2.114-6 「최대 5개」. 차단한 개설자의 캠페인은 건너뛰므로
|
||||
// 열 건을 읽어 다섯을 채운다. ponytail: 정렬은 모금액 순 하나(명세의 「달성 임박 > 기여 급증 >
|
||||
// 신규」 3단 정렬은 기여 급증 집계가 없어 아직 못 한다).
|
||||
final campaignF = db
|
||||
.collection('campaigns')
|
||||
.where('status', isEqualTo: 'funding')
|
||||
.orderBy('raisedPop', descending: true)
|
||||
.limit(5)
|
||||
.limit(10)
|
||||
.get();
|
||||
// 팬덤행성 인큐베이터 — 명세 §2.114-4 「기여율 높은 순 최대 6개」. 성운(Stage0)만.
|
||||
// ponytail: 성운 30건 안에서 고른다. (stage, growthPop) 색인을 배포하면 질의로 옮긴다.
|
||||
final nebulasF = db
|
||||
.collection('fandoms')
|
||||
.where('stage', isEqualTo: 0)
|
||||
.limit(30)
|
||||
.get();
|
||||
final blocksF = db.collection('users/$uid/blocks').get();
|
||||
// Hero Gift Catalog(원장 B-85) — 판매 중인 것만. 소속 행성이 없는 사용자는 시트가
|
||||
@@ -163,17 +184,24 @@ class FirestoreHomeRepository implements HomeRepository {
|
||||
return to is Timestamp && to.toDate().isAfter(serverNow);
|
||||
}),
|
||||
// 타임어택은 위에서 창이 열린 것만 받았다. 여기서 다시 받으면 마감된 것이 남는다.
|
||||
...(await questsF).docs.where((d) => d.data()['type'] != 'timeattack'),
|
||||
];
|
||||
...adminQuestsLatest([
|
||||
for (final d in (await questsF).docs)
|
||||
if (d.data()['type'] != 'timeattack') d,
|
||||
], (d) => d.data()),
|
||||
].take(3).toList();
|
||||
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 = popularInsides([
|
||||
for (final doc in (await insidesF).docs) doc.data(),
|
||||
]);
|
||||
final insideDocs = {
|
||||
for (final doc in (await insidesF).docs) doc.data(): doc.id,
|
||||
};
|
||||
final insides = [
|
||||
for (final data in popularInsides(insideDocs.keys.toList()))
|
||||
(insideDocs[data]!, data),
|
||||
];
|
||||
|
||||
final progressById = {for (final doc in progress) doc.id: doc.data()};
|
||||
final policy = (await policyF).data() ?? const <String, dynamic>{};
|
||||
@@ -190,8 +218,13 @@ class FirestoreHomeRepository implements HomeRepository {
|
||||
_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 data in insides) _inside(data)],
|
||||
campaigns: [for (final d in campaigns.take(5)) _campaign(d.id, d.data())],
|
||||
insides: [
|
||||
for (final (id, data) in insides) insideQuestionFrom(id, data, uid),
|
||||
],
|
||||
nebulas: nebulaPicks([
|
||||
for (final d in (await nebulasF).docs) (d.id, d.data()),
|
||||
], nebulaTargetsFrom(policy).pop),
|
||||
items: [for (final doc in (await itemsF).docs) _item(doc.id, doc.data())],
|
||||
);
|
||||
}
|
||||
@@ -304,11 +337,17 @@ class FirestoreHomeRepository implements HomeRepository {
|
||||
Map<String, dynamic> policy,
|
||||
) {
|
||||
// 퀘스트 목록 · 상세와 같은 판정(`questToday`) — 오늘(KST) 행만 센다.
|
||||
final (:count, :done) =
|
||||
questToday(QuestType.parse(quest.strOr('type')), quest, progress, policy);
|
||||
final (:count, :done) = questToday(
|
||||
QuestType.parse(quest.strOr('type')),
|
||||
quest,
|
||||
progress,
|
||||
policy,
|
||||
);
|
||||
final started = count > 0;
|
||||
return QuestSummary(
|
||||
title: quest.strOr('title'),
|
||||
type: QuestType.parse(quest.strOr('type')),
|
||||
category: QuestType.parse(quest.strOr('type')).label,
|
||||
rewardPop: QuestType.parse(quest.strOr('type')).rewardFrom(quest, policy),
|
||||
state: done
|
||||
? QuestState.done
|
||||
@@ -325,11 +364,12 @@ class FirestoreHomeRepository implements HomeRepository {
|
||||
);
|
||||
}
|
||||
|
||||
CampaignHighlight _campaign(Map<String, dynamic> data) {
|
||||
CampaignHighlight _campaign(String id, Map<String, dynamic> data) {
|
||||
// 규칙의 스키마 이름(`targetPop` · `creatorName` · `summary`)을 먼저 읽는다.
|
||||
final goal = data.intOr('targetPop', data.intOr('goalPop'));
|
||||
final raised = data.intOr('raisedPop');
|
||||
return CampaignHighlight(
|
||||
id: id,
|
||||
authorName: publicName(data['creatorName'] ?? data['authorName']),
|
||||
achievedPercent: goal == 0 ? 0 : ((raised / goal) * 100).round(),
|
||||
title: data.strOr('title'),
|
||||
@@ -339,27 +379,6 @@ class FirestoreHomeRepository implements HomeRepository {
|
||||
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}');
|
||||
}
|
||||
|
||||
/// 인기 K-INSIDE 3건 — 미해결(`status == open`) 먼저, 같은 묶음 안에서는 현상금 큰 순.
|
||||
@@ -380,3 +399,43 @@ String kstDayKey([DateTime? at]) => (at ?? DateTime.now().toUtc())
|
||||
.add(const Duration(hours: 9))
|
||||
.toIso8601String()
|
||||
.substring(0, 10);
|
||||
|
||||
/// HOME TODAY'S QUEST 의 관리자 퀘스트 — `authorUid` 가 없는 것만, `createdAt` 최신순
|
||||
/// (설명 프레임 `2097:16397` 「관리자 퀘스트 - 최신순으로 최대 3개 노출」).
|
||||
List<T> adminQuestsLatest<T>(
|
||||
Iterable<T> docs,
|
||||
Map<String, dynamic> Function(T) data,
|
||||
) {
|
||||
DateTime at(T d) => switch (data(d)['createdAt']) {
|
||||
Timestamp t => t.toDate(),
|
||||
DateTime t => t,
|
||||
_ => DateTime.fromMillisecondsSinceEpoch(0),
|
||||
};
|
||||
return [
|
||||
for (final d in docs)
|
||||
if ((data(d)['authorUid'] ?? '').toString().isEmpty) d,
|
||||
]..sort((a, b) => at(b).compareTo(at(a)));
|
||||
}
|
||||
|
||||
/// 인큐베이터 카드 — 기여율(누적 POP ÷ 성운 전환 목표 POP) 높은 순 최대 6개.
|
||||
List<NebulaPick> nebulaPicks(
|
||||
Iterable<(String, Map<String, dynamic>)> docs,
|
||||
int goalPop,
|
||||
) {
|
||||
final picks = [
|
||||
for (final (id, d) in docs)
|
||||
NebulaPick(
|
||||
id: id,
|
||||
name: d.strOr('name', id),
|
||||
members: d.intOr('memberCount'),
|
||||
percent: goalPop <= 0
|
||||
? 0
|
||||
: ((d['growthPop'] is num ? d['growthPop'] as num : 0) /
|
||||
goalPop *
|
||||
100)
|
||||
.clamp(0, 100)
|
||||
.floor(),
|
||||
),
|
||||
]..sort((a, b) => b.percent.compareTo(a.percent));
|
||||
return picks.take(6).toList();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user