성운 상세 실시간 기여 랭킹에 실제 기여자 표시

This commit is contained in:
Sn-Kinos
2026-10-05 18:07:44 +09:00
parent 96a95a12d1
commit be623d2e46
9 changed files with 514 additions and 229 deletions
@@ -2,6 +2,7 @@ import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_storage/firebase_storage.dart';
import '../../core/data/backend.dart';
import '../campaign/campaign_data.dart' show ContributorRank;
import '../my/my_data.dart' show PickedPhoto, PhotoRejected;
import '../my/my_repository_firestore.dart';
import 'moderation.dart' show reportReasonCodes;
@@ -32,10 +33,14 @@ class FirestorePlanetRepository implements PlanetRepository {
final String fandomId;
@override
Future<PlanetHistoryPage> history({String? cursor, bool personal = false}) async {
Future<PlanetHistoryPage> history({
String? cursor,
bool personal = false,
}) async {
_backend.requireUid;
final result = await _backend.call('fnPlanetHistory', {
'fandomId': fandomId, if (cursor != null) 'cursor': cursor,
'fandomId': fandomId,
if (cursor != null) 'cursor': cursor,
if (personal) 'scope': 'personal',
});
final rows = result['items'];
@@ -56,23 +61,46 @@ class FirestorePlanetRepository implements PlanetRepository {
'funding' => PlanetHistoryKind.funding,
_ => null,
};
final date = row['occurredAt'] is String ? DateTime.tryParse(row['occurredAt']) : null;
final date = row['occurredAt'] is String
? DateTime.tryParse(row['occurredAt'])
: null;
final id = row['id'];
final from = row['fromStage'];
final to = row['toStage'];
final amount = row['amount'];
final activity = [PlanetHistoryKind.zap, PlanetHistoryKind.questReward, PlanetHistoryKind.companyAdBonus, PlanetHistoryKind.funding].contains(kind);
if (activity != personal || (activity && (amount is! int || amount <= 0)) || kind == null || date == null || id is! String || id.isEmpty ||
final activity = [
PlanetHistoryKind.zap,
PlanetHistoryKind.questReward,
PlanetHistoryKind.companyAdBonus,
PlanetHistoryKind.funding,
].contains(kind);
if (activity != personal ||
(activity && (amount is! int || amount <= 0)) ||
kind == null ||
date == null ||
id is! String ||
id.isEmpty ||
(kind == PlanetHistoryKind.evolution &&
(from is! int || to is! int || from < 0 || to > 6 || to <= from))) {
(from is! int ||
to is! int ||
from < 0 ||
to > 6 ||
to <= from))) {
throw StateError('역사 기록을 확인할 수 없습니다.');
}
items.add(PlanetHistoryEntry(id: id, kind: kind, occurredAt: date,
amount: activity ? amount as int : null,
fromStage: kind == PlanetHistoryKind.evolution ? from as int : null,
toStage: kind == PlanetHistoryKind.evolution ? to as int : null,
participationConfirmed: kind == PlanetHistoryKind.evolution && row['participationConfirmed'] == true,
));
items.add(
PlanetHistoryEntry(
id: id,
kind: kind,
occurredAt: date,
amount: activity ? amount as int : null,
fromStage: kind == PlanetHistoryKind.evolution ? from as int : null,
toStage: kind == PlanetHistoryKind.evolution ? to as int : null,
participationConfirmed:
kind == PlanetHistoryKind.evolution &&
row['participationConfirmed'] == true,
),
);
}
return PlanetHistoryPage(items: items, nextCursor: next as String?);
}
@@ -81,7 +109,8 @@ class FirestorePlanetRepository implements PlanetRepository {
Future<HeroDropReceipt> claimHeroDrop(String dropId) async {
_backend.requireUid;
final result = await _backend.call('fnHeroDropClaim', {
'fandomId': fandomId, 'dropId': dropId,
'fandomId': fandomId,
'dropId': dropId,
});
final amount = result['claimedPop'];
final txId = result['txId'];
@@ -104,7 +133,8 @@ class FirestorePlanetRepository implements PlanetRepository {
Future<void> join() => _backend.call('fnFandomJoin', {'fandomId': fandomId});
@override
Future<void> leave() => _backend.call('fnFandomLeave', {'fandomId': fandomId});
Future<void> leave() =>
_backend.call('fnFandomLeave', {'fandomId': fandomId});
@override
Future<PlanetDetail> load({required bool nebula}) async {
@@ -137,9 +167,8 @@ class FirestorePlanetRepository implements PlanetRepository {
// `topPercentByAxis` 는 서버 5 분 집계(`fnRankingCountryAggregate`)가 적는다.
// **값이 없으면 null** — 그 축 기여가 0 이거나 모집단(B-17) 밖이다. 100%(최하위)로
// 채우면 없는 값이 사실처럼 보인다.
int? myTopOf(FpiMetric metric) => myTop[_axisKey(metric)] is num
? myTop.intOr(_axisKey(metric))
: null;
int? myTopOf(FpiMetric metric) =>
myTop[_axisKey(metric)] is num ? myTop.intOr(_axisKey(metric)) : null;
// 스냅샷의 축 맵은 `fpiAxes` 다. 초기 시드가 `axes` 로 쓴 문서가 남아 있어
// 읽는 쪽에서 두 이름을 모두 받는다 — 시드를 다시 넣으면 앞 이름만 남는다.
final snapshot = snapshots.isEmpty
@@ -182,8 +211,9 @@ class FirestorePlanetRepository implements PlanetRepository {
: axes.intOr(_axisKey(metric)) - daily.intOr(_axisKey(metric)),
// 없으면 null — 화면이 숫자 대신 「—」를 띄운다. 행성 값이 0 이면
// 기여를 나눌 모수가 없으니 남아 있는 값(시드 등)도 쓰지 않는다.
myTopPercent:
axes.intOr(_axisKey(metric)) > 0 ? myTopOf(metric) : null,
myTopPercent: axes.intOr(_axisKey(metric)) > 0
? myTopOf(metric)
: null,
),
],
posts: posts,
@@ -276,8 +306,10 @@ class FirestorePlanetRepository implements PlanetRepository {
final axis = _axisKey(metric);
final uid = _backend.requireUid;
final members = _backend.db.collection('fandoms/$fandomId/members');
final snap =
await members.orderBy('fpiAxes.$axis', descending: true).limit(limit).get();
final snap = await members
.orderBy('fpiAxes.$axis', descending: true)
.limit(limit)
.get();
// 순위는 1·1·3 공동 순위(C-19) — 실값 동률만 같은 순위, 동점 내부는 uid 오름차순.
final rows = <FpiRankRow>[
@@ -286,12 +318,19 @@ class FirestorePlanetRepository implements PlanetRepository {
(d) => _raw(d.data(), axis),
(d) => d.id,
))
_userRow(doc.data(), id: doc.id, axis: axis, fallbackRank: rank, uid: uid),
_userRow(
doc.data(),
id: doc.id,
axis: axis,
fallbackRank: rank,
uid: uid,
),
];
// 한 줄도 직전 순위를 갖고 있지 않으면 동향은 전부 0 이다. 그 사실을 배지로
// 알린다 — 「▲▼ 가 없다」와 「아직 집계 전이다」는 다른 이야기다.
final note = snap.docs.any((d) => _map(d.data(), 'rankPrevByAxis')[axis] is num)
final note =
snap.docs.any((d) => _map(d.data(), 'rankPrevByAxis')[axis] is num)
? null
: '사용자 순위 동향은 아직 집계 전입니다';
@@ -309,17 +348,23 @@ class FirestorePlanetRepository implements PlanetRepository {
final fallback = serverRank is num
? 0
: ((await members
.where('fpiAxes.$axis', isGreaterThan: _raw(me, axis))
.count()
.get())
.count ??
rows.length) +
1;
.where('fpiAxes.$axis', isGreaterThan: _raw(me, axis))
.count()
.get())
.count ??
rows.length) +
1;
return FpiRanking(
rows: rows,
mockNote: note,
mine: _userRow(me, id: uid, axis: axis, fallbackRank: fallback, uid: uid,
name: appL10n.commonMe),
mine: _userRow(
me,
id: uid,
axis: axis,
fallbackRank: fallback,
uid: uid,
name: appL10n.commonMe,
),
);
}
@@ -346,6 +391,30 @@ class FirestorePlanetRepository implements PlanetRepository {
);
}
@override
Future<List<ContributorRank>> contributors({int limit = 3}) async {
final uid = _backend.requireUid;
final snap = await _backend.db
.collection('fandoms/$fandomId/members')
.orderBy('contributedPop', descending: true)
.limit(limit)
.get();
return [
for (final (doc, rank) in rankWithTies(
snap.docs.where((d) => d.data().intOr('contributedPop') > 0).toList(),
(d) => d.data().intOr('contributedPop'),
(d) => d.id,
))
ContributorRank(
rank: rank,
// UID·이메일을 이름 자리에 내보내지 않는다(G-07).
name: publicName(doc.data()['nickname'], null),
pop: doc.data().intOr('contributedPop'),
isMe: doc.id == uid,
),
];
}
@override
Future<FpiRanking> countryRanking({
required FpiMetric metric,
@@ -353,34 +422,37 @@ class FirestorePlanetRepository implements PlanetRepository {
}) async {
// 설계서 §10 「랭킹 사전 집계 — 랭킹 화면은 이 문서 1건만 읽는다」.
// 국가는 사용자 프로필 `country`, 없으면 `ZZ`(미지정) 버킷이다.
final doc = await _backend.db.doc('rankings/country-${_axisKey(metric)}').get();
final doc = await _backend.db
.doc('rankings/country-${_axisKey(metric)}')
.get();
final rows = (doc.data() ?? const <String, dynamic>{}).mapsOr('rows');
if (rows.isEmpty) {
return const FpiRanking(
rows: [],
mockNote: '국가별 집계가 아직 없습니다',
);
return const FpiRanking(rows: [], mockNote: '국가별 집계가 아직 없습니다');
}
// 내 국가는 멤버 문서에 비정규화된 `countryCode` 다(집계가 세는 필드와 같다).
// 「나의 랭킹 영역」(546:14713)은 국가별 필터에서도 떠야 하므로 내 국가 행을
// 찾아 올린다.
final me = (await _backend.db.doc('fandoms/$fandomId/members/${_backend.requireUid}')
.get())
.data();
final me =
(await _backend.db
.doc('fandoms/$fandomId/members/${_backend.requireUid}')
.get())
.data();
final myCode = (me ?? const <String, dynamic>{}).strOr('countryCode', 'ZZ');
final list = <FpiRankRow>[];
for (final (i, row) in rows.take(limit).indexed) {
final code = row.strOr('code', 'ZZ');
list.add(FpiRankRow(
id: code,
rank: row.intOr('rank', i + 1),
// 표시명은 코드에서 만든다 — 서버는 코드만 안다([countryName]).
name: countryName(code),
value: row.intOr('value'),
change: row.intOr('change'),
isMine: code == myCode,
));
list.add(
FpiRankRow(
id: code,
rank: row.intOr('rank', i + 1),
// 표시명은 코드에서 만든다 — 서버는 코드만 안다([countryName]).
name: countryName(code),
value: row.intOr('value'),
change: row.intOr('change'),
isMine: code == myCode,
),
);
}
return FpiRanking(
rows: list,
@@ -432,16 +504,19 @@ class FirestorePlanetRepository implements PlanetRepository {
}
static Map<String, dynamic> _map(Map<String, dynamic> data, String key) =>
Map<String, dynamic>.from((data[key] as Map?) ?? const <String, dynamic>{});
Map<String, dynamic>.from(
(data[key] as Map?) ?? const <String, dynamic>{},
);
/// 내가 차단한 UID. 저장소 하나가 행성 상세 하나와 수명이 같아 한 번만 읽는다.
Set<String>? _blocked;
Future<Set<String>> _blockedUids() async => _blocked ??= {
for (final d in (await _backend.db
.collection('users/${_backend.requireUid}/blocks')
.get())
.docs)
for (final d
in (await _backend.db
.collection('users/${_backend.requireUid}/blocks')
.get())
.docs)
d.id,
};