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

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
@@ -428,7 +428,9 @@ class _Podium extends StatelessWidget {
_avatar(i + 3, 80),
const SizedBox(height: 8),
Text(
rows[i].isMine ? context.l10n.rankMeSuffix(rows[i].name) : rows[i].name,
rows[i].isMine
? context.l10n.rankMeSuffix(rows[i].name)
: rows[i].name,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.visible,
@@ -11,8 +11,13 @@ import '../../core/i18n/app_locale.dart';
/// 수량·상한·중복 여부는 서버가 결정한다. 단말은 참여 의사와 결과만 전달한다.
class HeroParticipationSheet extends StatefulWidget {
const HeroParticipationSheet({super.key, required this.fandomId,
required this.watch, required this.repository, this.accelerometer});
const HeroParticipationSheet({
super.key,
required this.fandomId,
required this.watch,
required this.repository,
this.accelerometer,
});
final String fandomId;
final PlanetWatch watch;
final PlanetRepository repository;
@@ -39,10 +44,17 @@ class _HeroParticipationSheetState extends State<HeroParticipationSheet>
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_sensor = (widget.accelerometer ?? accelerometerEventStream()).listen((event) {
final magnitude = math.sqrt(event.x * event.x + event.y * event.y + event.z * event.z);
_sensor = (widget.accelerometer ?? accelerometerEventStream()).listen((
event,
) {
final magnitude = math.sqrt(
event.x * event.x + event.y * event.y + event.z * event.z,
);
final now = DateTime.now();
if (!magnitude.isFinite || magnitude < 18 || now.difference(_lastShake).inMilliseconds < 220) return;
if (!magnitude.isFinite ||
magnitude < 18 ||
now.difference(_lastShake).inMilliseconds < 220)
return;
_lastShake = now;
if (!_failed) _claim();
}, onError: (_) {}); // 센서가 없어도 동일한 서버 경로의 참여 버튼을 제공한다.
@@ -62,21 +74,39 @@ class _HeroParticipationSheetState extends State<HeroParticipationSheet>
String? get _next {
final state = _state;
if (_accessDenied || state?.isHeroTime != true || state?.heroEndsAt?.isAfter(DateTime.now()) != true) return null;
final ids = (state?.heroEventIds ?? {}).where((id) => !_received.containsKey(id) && !_unavailable.contains(id)).toList()..sort();
if (_accessDenied ||
state?.isHeroTime != true ||
state?.heroEndsAt?.isAfter(DateTime.now()) != true)
return null;
final ids =
(state?.heroEventIds ?? {})
.where(
(id) => !_received.containsKey(id) && !_unavailable.contains(id),
)
.toList()
..sort();
return ids.firstOrNull;
}
Future<void> _claim() async {
if (_sending || !_foreground || ModalRoute.of(context)?.isCurrent != true) return;
if (_sending || !_foreground || ModalRoute.of(context)?.isCurrent != true)
return;
// 응답 유실 재확인은 이벤트 종료 뒤에도 같은 멱등키로 요청한다.
final id = _pendingId ?? _next;
if (id == null) return;
setState(() { _sending = true; _failed = false; _notice = null; _pendingId = id; });
setState(() {
_sending = true;
_failed = false;
_notice = null;
_pendingId = id;
});
try {
final receipt = await widget.repository.claimHeroDrop(id);
if (!mounted) return;
setState(() { _received[id] = receipt; _pendingId = null; });
setState(() {
_received[id] = receipt;
_pendingId = null;
});
if (_foreground) HapticFeedback.lightImpact();
} catch (error) {
if (!mounted) return;
@@ -109,28 +139,54 @@ class _HeroParticipationSheetState extends State<HeroParticipationSheet>
_state = state;
final available = _next != null;
final total = _received.values.fold<int>(0, (sum, r) => sum + r.amount);
return SafeArea(child: Padding(
padding: const EdgeInsets.all(24),
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('HERO TIME · ZAP+', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
Text(context.l10n.heroGiftHowTo),
Text(context.l10n.heroGiftPopNote),
const SizedBox(height: 16),
if (_received.isNotEmpty)
Semantics(liveRegion: true, child: Text(context.l10n.heroGiftReceived('$total'), style: Theme.of(context).textTheme.titleMedium)),
if (_notice case final notice?) Semantics(liveRegion: true, child: Text(notice)),
if (_failed) Text(context.l10n.heroGiftUnknown),
if (!available && !_sending && !_failed && _notice == null)
Text(_received.isEmpty ? context.l10n.heroGiftNone : context.l10n.heroGiftConfirmed),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _sending || (!available && !_failed) ? null : _claim,
icon: const Icon(Icons.auto_awesome),
label: Text(_sending ? context.l10n.heroGiftChecking : _failed ? context.l10n.heroGiftRetry : context.l10n.heroGiftTap),
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'HERO TIME · ZAP+',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
Text(context.l10n.heroGiftHowTo),
Text(context.l10n.heroGiftPopNote),
const SizedBox(height: 16),
if (_received.isNotEmpty)
Semantics(
liveRegion: true,
child: Text(
context.l10n.heroGiftReceived('$total'),
style: Theme.of(context).textTheme.titleMedium,
),
),
if (_notice case final notice?)
Semantics(liveRegion: true, child: Text(notice)),
if (_failed) Text(context.l10n.heroGiftUnknown),
if (!available && !_sending && !_failed && _notice == null)
Text(
_received.isEmpty
? context.l10n.heroGiftNone
: context.l10n.heroGiftConfirmed,
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _sending || (!available && !_failed) ? null : _claim,
icon: const Icon(Icons.auto_awesome),
label: Text(
_sending
? context.l10n.heroGiftChecking
: _failed
? context.l10n.heroGiftRetry
: context.l10n.heroGiftTap,
),
),
],
),
]),
));
),
);
},
);
}
+44 -24
View File
@@ -51,11 +51,7 @@ class ModerationTarget {
/// 신고하기 화면.
class ReportScreen extends StatefulWidget {
const ReportScreen({
super.key,
required this.target,
this.reasons,
});
const ReportScreen({super.key, required this.target, this.reasons});
final ModerationTarget target;
final List<String>? reasons;
@@ -67,15 +63,18 @@ class ReportScreen extends StatefulWidget {
class _ReportScreenState extends State<ReportScreen> {
String? _reason;
static TextStyle _t(double size, Color color, {FontWeight w = FontWeight.w600}) =>
TextStyle(
fontFamily: AppFonts.body,
fontSize: size,
fontWeight: w,
height: 1,
letterSpacing: size * -0.025,
color: color,
);
static TextStyle _t(
double size,
Color color, {
FontWeight w = FontWeight.w600,
}) => TextStyle(
fontFamily: AppFonts.body,
fontSize: size,
fontWeight: w,
height: 1,
letterSpacing: size * -0.025,
color: color,
);
// 시안 `373:442007` — 닫기 X · 「신고하기」 · 오른쪽 「신고」(미선택 #474747),
// 작성자(#ee0c2c)/제목 줄, 「신고 사유」 띠 #232325, 사유 줄 48(라디오 20 · 16/600 · 밑줄 #313131).
@@ -105,18 +104,29 @@ class _ReportScreenState extends State<ReportScreen> {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Column(
children: [
_info(context.l10n.reportAuthor, widget.target.authorName, const Color(0xFFEE0C2C)),
_info(
context.l10n.reportAuthor,
widget.target.authorName,
const Color(0xFFEE0C2C),
),
const SizedBox(height: 16),
const Divider(height: 1, thickness: 1, color: Color(0xFF313131)),
const SizedBox(height: 16),
_info(context.l10n.commonTitle, widget.target.title, const Color(0xFFD1D1D2)),
_info(
context.l10n.commonTitle,
widget.target.title,
const Color(0xFFD1D1D2),
),
],
),
),
Container(
color: const Color(0xFF232325),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Text(context.l10n.reportReasonLabel, style: _t(12, const Color(0xFF909091))),
child: Text(
context.l10n.reportReasonLabel,
style: _t(12, const Color(0xFF909091)),
),
),
// 디폴트 미선택 · 단수 선택.
for (final r in widget.reasons ?? defaultReportReasons)
@@ -145,20 +155,26 @@ class _ReportScreenState extends State<ReportScreen> {
children: [
SizedBox(
width: 36,
child: Text(label, textAlign: TextAlign.center, style: _t(14, const Color(0xFF59595B))),
child: Text(
label,
textAlign: TextAlign.center,
style: _t(14, const Color(0xFF59595B)),
),
),
const SizedBox(width: 20),
Expanded(
child: Text(value, maxLines: 1, overflow: TextOverflow.ellipsis, style: _t(14, color)),
child: Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: _t(14, color),
),
),
],
);
Future<void> _submit() async {
final ok = await confirmOverlay(
context,
context.l10n.reportConfirm,
);
final ok = await confirmOverlay(context, context.l10n.reportConfirm);
if (!ok || !mounted) return;
Navigator.pop(context, _reason);
}
@@ -226,7 +242,11 @@ Future<bool> confirmOverlay(BuildContext context, String message) async {
child: Row(
children: [
button(c, c.l10n.commonCancel, false),
Container(width: 0.5, height: 42, color: const Color(0xFF636463)),
Container(
width: 0.5,
height: 42,
color: const Color(0xFF636463),
),
button(c, c.l10n.commonConfirm, true),
],
),
+65 -21
View File
@@ -6,6 +6,7 @@
/// - 팬덤 라운지 리스트 — *최신순 소팅 · 최초 5개, 스크롤 시 5개씩 호출*
library;
import '../campaign/campaign_data.dart' show ContributorRank;
import '../my/my_data.dart' show PickedPhoto, PhotoRejected, checkProfilePhoto;
import '../../core/i18n/app_locale.dart';
@@ -69,10 +70,11 @@ List<(T, int)> rankWithTies<T>(
num Function(T) value,
String Function(T) id,
) {
final sorted = [...items]..sort((a, b) {
final byValue = value(b).compareTo(value(a));
return byValue != 0 ? byValue : id(a).compareTo(id(b));
});
final sorted = [...items]
..sort((a, b) {
final byValue = value(b).compareTo(value(a));
return byValue != 0 ? byValue : id(a).compareTo(id(b));
});
final out = <(T, int)>[];
for (var i = 0; i < sorted.length; i++) {
final tied = i > 0 && value(sorted[i]) == value(sorted[i - 1]);
@@ -304,12 +306,26 @@ class HeroDropReceipt {
final int amount;
}
enum PlanetHistoryKind { evolution, hibernation, wakeUp, zap, questReward, companyAdBonus, funding }
enum PlanetHistoryKind {
evolution,
hibernation,
wakeUp,
zap,
questReward,
companyAdBonus,
funding,
}
class PlanetHistoryEntry {
const PlanetHistoryEntry({required this.id, required this.kind,
required this.occurredAt, this.fromStage, this.toStage,
this.participationConfirmed = false, this.amount});
const PlanetHistoryEntry({
required this.id,
required this.kind,
required this.occurredAt,
this.fromStage,
this.toStage,
this.participationConfirmed = false,
this.amount,
});
final String id;
final PlanetHistoryKind kind;
final DateTime occurredAt;
@@ -340,7 +356,10 @@ abstract interface class PlanetRepository {
Future<List<LoungePost>> morePosts({required int offset, int limit = 5});
/// 글 쓰기. 사진은 먼저 Storage 에 올리고 URL 을 글에 붙인다(B-44).
Future<LoungePost> createPost(String body, {List<PickedPhoto> photos = const []});
Future<LoungePost> createPost(
String body, {
List<PickedPhoto> photos = const [],
});
/// 내 글 본문 수정. 사진은 바꾸지 않는다.
Future<LoungePost> updatePost(LoungePost post, String body);
@@ -377,7 +396,13 @@ abstract interface class PlanetRepository {
});
/// 지표별 **국가** 랭킹 — Figma `546:14713`(랭킹 필터 「국가별」).
Future<FpiRanking> countryRanking({required FpiMetric metric, int limit = 30});
Future<FpiRanking> countryRanking({
required FpiMetric metric,
int limit = 30,
});
/// 성운 「실시간 기여 랭킹」 — 멤버 누적 기여 POP(`members.contributedPop`) 상위.
Future<List<ContributorRank>> contributors({int limit = 3});
}
/// 행성 ID 로 저장소를 만드는 함수. 갤럭시 화면이 어느 행성으로 들어가든
@@ -398,14 +423,21 @@ class SamplePlanetRepository implements PlanetRepository {
@override
Future<void> join() async {}
@override
Future<List<ContributorRank>> contributors({int limit = 3}) async => const [];
@override
Future<void> leave() async {}
@override
Future<PlanetHistoryPage> history({String? cursor, bool personal = false}) async => const PlanetHistoryPage(items: []);
Future<PlanetHistoryPage> history({
String? cursor,
bool personal = false,
}) async => const PlanetHistoryPage(items: []);
@override
Future<HeroDropReceipt> claimHeroDrop(String dropId) =>
Future.error(StateError('실제 지급 서버가 연결되지 않았습니다.'));
/// [fandomId] 는 갤럭시에서 누른 행성이다. 주지 않으면 내 행성으로 본다.
const SamplePlanetRepository({String? fandomId})
: fandomId = fandomId ?? _myFandomId;
@@ -428,8 +460,10 @@ class SamplePlanetRepository implements PlanetRepository {
/// ponytail: 표본에 없는 ID(빈 문자열 포함)면 첫 행으로 떨어진다. 목 모드에서
/// ID 를 주는 곳은 [SampleGalaxyRepository] 의 6행성뿐이라 닿지 않는 길이고,
/// 여기서 던지면 발주사 확인용 화면이 빈다. 표본 행성이 늘어나면 두 표를 함께 늘린다.
(String, String, List<int>) get _row =>
_planets.firstWhere((p) => p.$1 == fandomId, orElse: () => _planets.first);
(String, String, List<int>) get _row => _planets.firstWhere(
(p) => p.$1 == fandomId,
orElse: () => _planets.first,
);
/// 행성별 5축 값. 순서는 [FpiMetric.values] 와 같다 — 별첨 B 확정 순서인
/// `bpm · health · referral · contribution · community`.
@@ -532,9 +566,12 @@ class SamplePlanetRepository implements PlanetRepository {
// 전체 순위 = 행성 랭킹에서 **이 행성**의 순위. 랭킹 화면과 같은 계산이다.
rank: nebula
? 0
: _rank(_planets, metric, myId: row.$1, limit: _planets.length)
.mine!
.rank,
: _rank(
_planets,
metric,
myId: row.$1,
limit: _planets.length,
).mine!.rank,
trend: nebula ? 0 : axes[metric.index] ~/ 60 - metric.index * 40,
// 내 기여도는 **내 행성에만 있다.** 남의 행성에서 내 등수를 지어내면
// 화면이 그것을 사실로 띄운다(대조표 3-5 — 없으면 null).
@@ -546,8 +583,10 @@ class SamplePlanetRepository implements PlanetRepository {
}
@override
Future<List<LoungePost>> morePosts({required int offset, int limit = 5}) async =>
_posts(offset, limit);
Future<List<LoungePost>> morePosts({
required int offset,
int limit = 5,
}) async => _posts(offset, limit);
/// 표본 댓글. 서버가 없어도 쓰고 지운 댓글이 상세를 다시 열 때 남아 있게 한다.
static final _comments = <String, List<LoungeComment>>{};
@@ -613,8 +652,10 @@ class SamplePlanetRepository implements PlanetRepository {
Future<void> blockAuthor(LoungePost post) async {}
@override
Future<FpiRanking> planetRanking({required FpiMetric metric, int limit = 30}) async =>
_rank(_planets, metric, myId: _myFandomId, limit: limit);
Future<FpiRanking> planetRanking({
required FpiMetric metric,
int limit = 30,
}) async => _rank(_planets, metric, myId: _myFandomId, limit: limit);
/// 표본 사용자는 **내 행성 소속**이다. 남의 행성 랭킹에 「나」를 끼우면
/// 「나의 랭킹 영역」이 가입하지도 않은 행성에서 뜬다. 대상 행성은 인터페이스가
@@ -636,7 +677,10 @@ class SamplePlanetRepository implements PlanetRepository {
);
@override
Future<FpiRanking> countryRanking({required FpiMetric metric, int limit = 30}) async =>
Future<FpiRanking> countryRanking({
required FpiMetric metric,
int limit = 30,
}) async =>
// 「나의 랭킹 영역」은 국가별 필터에서도 뜬다 — 내 국가 행이다(546:14713).
_rank(_countries, metric, myId: 'KR', limit: limit);
+130 -79
View File
@@ -2,6 +2,9 @@ import 'planet_history_screen.dart';
import 'hero_participation_sheet.dart';
import 'package:flutter/material.dart';
import '../campaign/campaign_data.dart' show ContributorRank;
import '../campaign/widgets/campaign_rank.dart' show RankPodium;
import '../../core/data/backend.dart' show BackendException;
import '../../core/data/planet_visual_state.dart';
@@ -66,11 +69,15 @@ class _PlanetDetailScreenState extends State<PlanetDetailScreen> {
try {
await widget.repository.join();
if (mounted) {
setState(() { _detail = widget.repository.load(nebula: widget.nebula); });
setState(() {
_detail = widget.repository.load(nebula: widget.nebula);
});
}
} on BackendException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(e.message)));
}
}
}
@@ -89,11 +96,15 @@ class _PlanetDetailScreenState extends State<PlanetDetailScreen> {
try {
await widget.repository.leave();
if (mounted) {
setState(() { _detail = widget.repository.load(nebula: widget.nebula); });
setState(() {
_detail = widget.repository.load(nebula: widget.nebula);
});
}
} on BackendException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(e.message)));
}
}
}),
@@ -108,7 +119,9 @@ class _PlanetDetailScreenState extends State<PlanetDetailScreen> {
}
final d = snap.data;
if (d == null) {
return Scaffold(body: Center(child: Text(context.l10n.commonLoadFailed)));
return Scaffold(
body: Center(child: Text(context.l10n.commonLoadFailed)),
);
}
// 시안(373:441117)은 히어로 543 아래 「FPI | 팬덤 라운지」 탭이 오고 화면 전체가
// 한 번에 스크롤된다. 스크롤하면 히어로가 접혀 앱 바(행성 축소판 + 이름)와 탭만
@@ -205,8 +218,14 @@ class _PlanetDetailScreenState extends State<PlanetDetailScreen> {
),
);
}, destructive: false),
ActionSheetItem(context.l10n.reportTitle, () => _moderate(context, 'report', name)),
ActionSheetItem(context.l10n.commonBlockAction, () => _moderate(context, 'block', name)),
ActionSheetItem(
context.l10n.reportTitle,
() => _moderate(context, 'report', name),
),
ActionSheetItem(
context.l10n.commonBlockAction,
() => _moderate(context, 'block', name),
),
]);
Future<void> _moderate(
@@ -219,11 +238,15 @@ class _PlanetDetailScreenState extends State<PlanetDetailScreen> {
.push<String>(
MaterialPageRoute(
builder: (_) => ReportScreen(
target: ModerationTarget(authorName: name, title: context.l10n.planetNamed(name)),
target: ModerationTarget(
authorName: name,
title: context.l10n.planetNamed(name),
),
),
),
);
if (reason != null && context.mounted) _toast(context, context.l10n.reportReceived);
if (reason != null && context.mounted)
_toast(context, context.l10n.reportReceived);
return;
}
final ok = await confirmBlock(
@@ -408,7 +431,7 @@ class _FpiTab extends StatelessWidget {
padding: const EdgeInsets.all(16),
sliver: SliverList.list(
children: [
_NebulaFpi(pop: detail.growthPop),
_NebulaFpi(pop: detail.growthPop, repository: repository),
],
),
),
@@ -502,9 +525,10 @@ class _FpiTab extends StatelessWidget {
/// 목표는 원장 B-10 의 1,000 POP(사본 500,000 은 과거 수치). 분자는 서버 `growthPop`.
/// 기여자 목록은 상세 데이터에 아직 없어 빈 상태로 둔다.
class _NebulaFpi extends StatelessWidget {
const _NebulaFpi({required this.pop});
const _NebulaFpi({required this.pop, required this.repository});
final int pop;
final PlanetRepository repository;
static TextStyle _b(double size, Color color, {double? height}) => TextStyle(
fontFamily: AppFonts.body,
@@ -531,7 +555,10 @@ class _NebulaFpi extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(context.l10n.planetContribution, style: _b(18, AppColors.onCosmic)),
Text(
context.l10n.planetContribution,
style: _b(18, AppColors.onCosmic),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
@@ -589,57 +616,72 @@ class _NebulaFpi extends StatelessWidget {
const SizedBox(height: 16),
Text(context.l10n.planetLiveRanking, style: _b(18, AppColors.onCosmic)),
const SizedBox(height: 12),
Column(
children: [
SizedBox.square(
dimension: 24,
child: Stack(
alignment: Alignment.center,
children: [
Image.asset(
'assets/figma/icon-crown.png',
width: 24,
height: 24,
color: const Color(0xFFFEA300),
),
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
'1',
style: _b(
10,
AppColors.onCosmic,
height: 1,
).copyWith(fontWeight: FontWeight.w900),
),
),
],
),
),
Container(
width: 80,
height: 80,
decoration: const BoxDecoration(
color: Color(0xFF1A1A1C),
shape: BoxShape.circle,
),
),
const SizedBox(height: 8),
Text(
context.l10n.planetNoContributors,
style: _b(14, const Color(0xFFCDCDCD), height: 1),
),
const SizedBox(height: 4),
Text(
context.l10n.planetContributeCta,
style: _b(16, AppColors.onCosmic, height: 22 / 16),
),
const SizedBox(height: 20),
],
FutureBuilder<List<ContributorRank>>(
future: repository.contributors(),
builder: (context, snap) {
final top = snap.data ?? const <ContributorRank>[];
if (top.isNotEmpty) {
// 캠페인 상세와 같은 단상(2 · 1 · 3) — 시안 `373:442708` 랭킹 자리.
return Padding(
padding: const EdgeInsets.only(bottom: 20),
child: RankPodium(top: top),
);
}
return _empty(context);
},
),
],
);
}
Widget _empty(BuildContext context) => Column(
children: [
SizedBox.square(
dimension: 24,
child: Stack(
alignment: Alignment.center,
children: [
Image.asset(
'assets/figma/icon-crown.png',
width: 24,
height: 24,
color: const Color(0xFFFEA300),
),
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
'1',
style: _b(
10,
AppColors.onCosmic,
height: 1,
).copyWith(fontWeight: FontWeight.w900),
),
),
],
),
),
Container(
width: 80,
height: 80,
decoration: const BoxDecoration(
color: Color(0xFF1A1A1C),
shape: BoxShape.circle,
),
),
const SizedBox(height: 8),
Text(
context.l10n.planetNoContributors,
style: _b(14, const Color(0xFFCDCDCD), height: 1),
),
const SizedBox(height: 4),
Text(
context.l10n.planetContributeCta,
style: _b(16, AppColors.onCosmic, height: 22 / 16),
),
const SizedBox(height: 20),
],
);
}
/// 지표 카드 — 시안 `Frame 1984081152`(#19191c r16 · 안쪽 12 · 높이 117).
@@ -723,7 +765,9 @@ class _ScoreCard extends StatelessWidget {
// 순위가 없으면(집계 모집단 밖 · 0) 「전체 0위」 대신 「순위 집계 전」.
Text(
score.rank > 0
? context.l10n.planetOverallRank('${score.rank}')
? context.l10n.planetOverallRank(
'${score.rank}',
)
: context.l10n.planetRankNone,
style: _t(12, color),
),
@@ -779,14 +823,19 @@ class _ScoreCard extends StatelessWidget {
),
child: Row(
children: [
Text(context.l10n.planetMyContribution, style: _t(10, AppColors.onCosmic, height: 1)),
Text(
context.l10n.planetMyContribution,
style: _t(10, AppColors.onCosmic, height: 1),
),
const Spacer(),
Text(
// 서버 산출값이 없으면 숫자를 짓지 않는다 — 「상위 100%」는
// 최하위라는 사실 주장이 되어 버린다(대조표 3-5).
score.myTopPercent == null
? '—'
: context.l10n.planetTopPercent('${score.myTopPercent}'),
: context.l10n.planetTopPercent(
'${score.myTopPercent}',
),
style: _t(10, AppColors.onCosmic, height: 1),
),
],
@@ -914,10 +963,7 @@ class _LoungeTabState extends State<_LoungeTab> {
void _replace(LoungePost old, LoungePost? post) => setState(() {
_posts = [
for (final p in _posts)
if (p.id != old.id)
p
else if (post != null)
post,
if (p.id != old.id) p else if (post != null) post,
];
});
@@ -933,21 +979,26 @@ class _LoungeTabState extends State<_LoungeTab> {
/// 내 글이면 수정 · 삭제를 띄운다.
void _postMore(LoungePost post) => showActionSheet(context, [
if (post.isMine) ...[
ActionSheetItem(context.l10n.commonEditAction, destructive: false, () async {
final updated = await Navigator.of(context, rootNavigator: true)
.push<LoungePost>(
MaterialPageRoute(
builder: (_) => LoungeComposeScreen(
initialBody: post.body,
allowPhotos: false,
onSubmit: (d) => widget.repository.updatePost(post, d.body),
ActionSheetItem(
context.l10n.commonEditAction,
destructive: false,
() async {
final updated = await Navigator.of(context, rootNavigator: true)
.push<LoungePost>(
MaterialPageRoute(
builder: (_) => LoungeComposeScreen(
initialBody: post.body,
allowPhotos: false,
onSubmit: (d) => widget.repository.updatePost(post, d.body),
),
),
),
);
if (updated != null && mounted) _replace(post, updated);
}),
);
if (updated != null && mounted) _replace(post, updated);
},
),
ActionSheetItem(context.l10n.commonDeleteAction, () async {
if (!await confirmOverlay(context, context.l10n.postDeleteConfirm)) return;
if (!await confirmOverlay(context, context.l10n.postDeleteConfirm))
return;
await _run(() async {
await widget.repository.deletePost(post);
_replace(post, null);
@@ -35,7 +35,10 @@ class _PlanetHistoryScreenState extends State<PlanetHistoryScreen> {
_failed = false;
});
try {
final page = await widget.repository.history(cursor: _cursor, personal: _personal);
final page = await widget.repository.history(
cursor: _cursor,
personal: _personal,
);
if (!mounted) return;
setState(() {
final ids = _items.map((item) => item.id).toSet();
@@ -60,18 +63,25 @@ class _PlanetHistoryScreenState extends State<PlanetHistoryScreen> {
const SizedBox(height: 16),
SegmentedButton<bool>(
segments: [
ButtonSegment(value: false, label: Text(context.l10n.homeFandomHistory)),
ButtonSegment(
value: false,
label: Text(context.l10n.homeFandomHistory),
),
ButtonSegment(value: true, label: Text(context.l10n.historyMine)),
],
selected: {_personal},
onSelectionChanged: _loading ? null : (selection) {
if (selection.single == _personal) return;
setState(() {
_personal = selection.single;
_items.clear(); _cursor = null; _loaded = false;
});
_load();
},
onSelectionChanged: _loading
? null
: (selection) {
if (selection.single == _personal) return;
setState(() {
_personal = selection.single;
_items.clear();
_cursor = null;
_loaded = false;
});
_load();
},
),
const SizedBox(height: 16),
for (final item in _items)
@@ -87,11 +97,10 @@ class _PlanetHistoryScreenState extends State<PlanetHistoryScreen> {
PlanetHistoryKind.funding => Icons.favorite_border,
}),
title: Text(switch (item.kind) {
PlanetHistoryKind.evolution =>
context.l10n.historyEvolution(
'${item.fromStage}',
'${item.toStage}',
),
PlanetHistoryKind.evolution => context.l10n.historyEvolution(
'${item.fromStage}',
'${item.toStage}',
),
PlanetHistoryKind.hibernation => context.l10n.historyHibernation,
PlanetHistoryKind.wakeUp => context.l10n.historyWakeUp,
PlanetHistoryKind.zap => context.l10n.historyZap,
@@ -104,7 +113,8 @@ class _PlanetHistoryScreenState extends State<PlanetHistoryScreen> {
MaterialLocalizations.of(
context,
).formatMediumDate(item.occurredAt.toLocal()),
if (item.amount != null) context.l10n.historyGrowthPop('${item.amount}'),
if (item.amount != null)
context.l10n.historyGrowthPop('${item.amount}'),
if (item.participationConfirmed) context.l10n.historyJoined,
].join('\n'),
),
@@ -114,9 +124,11 @@ class _PlanetHistoryScreenState extends State<PlanetHistoryScreen> {
Text(context.l10n.historyLoadFailed),
TextButton(onPressed: _load, child: Text(context.l10n.commonRetry)),
] else if (_loaded && _items.isEmpty && _cursor == null)
Text(_personal
Text(
_personal
? context.l10n.historyMineEmpty
: context.l10n.historyFandomEmpty),
: context.l10n.historyFandomEmpty,
),
if (!_loading && !_failed && _cursor != null)
TextButton(onPressed: _load, child: Text(context.l10n.historyMore)),
],
@@ -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,
};
@@ -217,7 +217,9 @@ class PlanetDetailHero extends StatelessWidget {
Text('|', style: _body(14, info)),
const SizedBox(width: 4),
Text(
context.l10n.planetPerHead(perHead.toStringAsFixed(2)),
context.l10n.planetPerHead(
perHead.toStringAsFixed(2),
),
style: _body(14, info),
),
],
@@ -239,7 +241,9 @@ class PlanetDetailHero extends StatelessWidget {
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Text(
joined ? context.l10n.planetJoined : context.l10n.planetJoin,
joined
? context.l10n.planetJoined
: context.l10n.planetJoin,
style: _body(
14,
const Color(0xFFD5D7D9),