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

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
+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);