1238 lines
43 KiB
Dart
1238 lines
43 KiB
Dart
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';
|
||
import '../../core/ui/live_planet.dart' show LivePlanetArt;
|
||
|
||
import '../../core/theme/tokens.dart';
|
||
import '../../core/ui/app_dialog.dart';
|
||
import '../../core/ui/app_icons.dart';
|
||
import '../eclipse/eclipse_data.dart';
|
||
import '../eclipse/widgets/eclipse_banner.dart';
|
||
import '../home/home_screen.dart' show formatPop;
|
||
import 'lounge_post_screens.dart';
|
||
import 'moderation.dart';
|
||
import 'planet_data.dart';
|
||
import 'fpi_ranking_screen.dart';
|
||
import 'widgets/fpi_hexagon.dart';
|
||
import 'widgets/planet_detail_hero.dart';
|
||
import 'widgets/action_sheet.dart';
|
||
import '../../core/i18n/app_locale.dart';
|
||
|
||
/// 행성 상세 · 성운 상세 — 화면 대조 원장 F3.
|
||
///
|
||
/// Figma 노드
|
||
/// - `373:441117` Galaxy/행성 상세 · `343:213428` 홈/행성 상세_FPI
|
||
/// - `373:442708` Galaxy/성운 상세
|
||
/// - `251:51073` 홈/행성 상세_팬덤 라운지 · `373:436556` FPI/BMP
|
||
/// - `373:436695` FPI/BMP/사용자 랭킹 — [FpiRankingScreen]
|
||
///
|
||
/// FPI 와 팬덤 라운지가 같은 화면의 두 탭이라 하나로 둔다. Figma 가 노드를 나눠
|
||
/// 두었으나 사용자에게는 한 화면이며, 따로 만들면 헤더가 두 벌이 된다.
|
||
class PlanetDetailScreen extends StatefulWidget {
|
||
const PlanetDetailScreen({
|
||
super.key,
|
||
this.nebula = false,
|
||
this.watchPlanet = noPlanetWatch,
|
||
required this.repository,
|
||
required this.eclipse,
|
||
this.initialTab = 0,
|
||
});
|
||
|
||
/// 처음 열 탭 — 0 FPI · 1 팬덤 라운지. HOME 행성 터치는 커뮤니티(라운지)로 연다(Figma `2024:20758`).
|
||
final int initialTab;
|
||
|
||
/// 성운 상세인가. 성운은 아직 정식 행성이 아니다(정책 §P-08).
|
||
final bool nebula;
|
||
final PlanetRepository repository;
|
||
final PlanetWatch watchPlanet;
|
||
|
||
/// 이 행성의 이클립스 집계. 침식·방어 진행을 FPI 탭 상단에 실시간으로 띄운다.
|
||
final EclipseRepository eclipse;
|
||
|
||
@override
|
||
State<PlanetDetailScreen> createState() => _PlanetDetailScreenState();
|
||
}
|
||
|
||
class _PlanetDetailScreenState extends State<PlanetDetailScreen> {
|
||
late Future<PlanetDetail> _detail;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_detail = widget.repository.load(nebula: widget.nebula);
|
||
}
|
||
|
||
/// 「가입하기」 — 가입 뒤 상세를 다시 읽어 알약과 멤버 수를 서버 값으로 맞춘다.
|
||
Future<void> _join() async {
|
||
try {
|
||
await widget.repository.join();
|
||
if (mounted) {
|
||
setState(() {
|
||
_detail = widget.repository.load(nebula: widget.nebula);
|
||
});
|
||
}
|
||
} on BackendException catch (e) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(
|
||
context,
|
||
).showSnackBar(SnackBar(content: Text(e.message)));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 「가입중」 알약 → 탈퇴 시트 → 확인 창 — Figma `343:213428`(탈퇴하기 · 취소) ·
|
||
/// `343:213582`(행성 탈퇴를 할까요?). 대표 팬덤 자동 지정 · 성운 창립자 승계는 서버 몫이다(C-100 · C-101).
|
||
void _leave() => showActionSheet(context, [
|
||
ActionSheetItem(context.l10n.planetLeave, () async {
|
||
final ok = await showConfirmDialog(
|
||
context,
|
||
context.l10n.planetLeaveConfirmTitle,
|
||
body: context.l10n.planetLeaveConfirmBody,
|
||
confirmLabel: context.l10n.commonConfirm,
|
||
);
|
||
if (!ok) return;
|
||
try {
|
||
await widget.repository.leave();
|
||
if (mounted) {
|
||
setState(() {
|
||
_detail = widget.repository.load(nebula: widget.nebula);
|
||
});
|
||
}
|
||
} on BackendException catch (e) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(
|
||
context,
|
||
).showSnackBar(SnackBar(content: Text(e.message)));
|
||
}
|
||
}
|
||
}),
|
||
]);
|
||
|
||
@override
|
||
Widget build(BuildContext context) => FutureBuilder<PlanetDetail>(
|
||
future: _detail,
|
||
builder: (context, snap) {
|
||
if (snap.connectionState != ConnectionState.done) {
|
||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||
}
|
||
final d = snap.data;
|
||
if (d == null) {
|
||
return Scaffold(
|
||
body: Center(child: Text(context.l10n.commonLoadFailed)),
|
||
);
|
||
}
|
||
// 시안(373:441117)은 히어로 543 아래 「FPI | 팬덤 라운지」 탭이 오고 화면 전체가
|
||
// 한 번에 스크롤된다. 스크롤하면 히어로가 접혀 앱 바(행성 축소판 + 이름)와 탭만
|
||
// 남는다 — 라운지 글이 보이게(발주사 지적, 2026-09-22 시연).
|
||
final top = MediaQuery.paddingOf(context).top;
|
||
return DefaultTabController(
|
||
length: 2,
|
||
initialIndex: widget.initialTab.clamp(0, 1),
|
||
child: Scaffold(
|
||
body: NestedScrollView(
|
||
headerSliverBuilder: (context, _) => [
|
||
SliverOverlapAbsorber(
|
||
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(
|
||
context,
|
||
),
|
||
sliver: SliverAppBar(
|
||
pinned: true,
|
||
expandedHeight:
|
||
PlanetDetailHero.designHeight - top + _tabHeight,
|
||
backgroundColor: AppColors.cosmicVoid,
|
||
actions: [
|
||
IconButton(
|
||
tooltip: context.l10n.commonMore,
|
||
onPressed: () =>
|
||
_showMore(context, d.name, mine: d.isOwner),
|
||
icon: const ImageIcon(
|
||
AppIcons.ellipsisVertical,
|
||
size: 24,
|
||
),
|
||
),
|
||
],
|
||
flexibleSpace: _CollapsingHero(
|
||
detail: d,
|
||
watchPlanet: widget.watchPlanet,
|
||
min: top + kToolbarHeight + _tabHeight,
|
||
hero: PlanetDetailHero(
|
||
fandomId: d.fandomId,
|
||
watchPlanet: widget.watchPlanet,
|
||
stage: d.stage ?? (d.isNebula ? 0 : null),
|
||
nebula: d.isNebula,
|
||
name: d.name,
|
||
members: d.members,
|
||
totalFpi: d.totalFpi,
|
||
ownerName: d.ownerName,
|
||
ownerPhoto: d.ownerPhoto,
|
||
joined: d.joined,
|
||
isOwner: d.isOwner,
|
||
onJoin: d.joined ? _leave : _join,
|
||
onOpenHistory: () => Navigator.of(context).push(
|
||
MaterialPageRoute<void>(
|
||
builder: (_) => PlanetHistoryScreen(
|
||
repository: widget.repository,
|
||
fandomName: d.name,
|
||
),
|
||
),
|
||
),
|
||
onHeroParticipate: () => showModalBottomSheet<void>(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
builder: (_) => HeroParticipationSheet(
|
||
fandomId: d.fandomId,
|
||
watch: widget.watchPlanet,
|
||
repository: widget.repository,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
bottom: const _Tabs(),
|
||
),
|
||
),
|
||
],
|
||
body: TabBarView(
|
||
children: [
|
||
_FpiTab(
|
||
detail: d,
|
||
repository: widget.repository,
|
||
eclipse: widget.eclipse,
|
||
),
|
||
_LoungeTab(detail: d, repository: widget.repository),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
|
||
/// Figma `543:19721` — 더보기: (타인 행성) 신고/차단 · (내 행성) 편집. 편집은 행성 사용자 커스텀이라
|
||
/// 두지 않는다(원장 C-50 「생성 후 사용자가 외형을 바꾸는 진입점 0건」). 내 행성에는 신고 · 차단도 없다.
|
||
void _showMore(BuildContext context, String name, {required bool mine}) =>
|
||
showActionSheet(context, [
|
||
// 팬덤 역사는 시안 밖 기능이라 남기되 중립색으로 둔다.
|
||
ActionSheetItem(context.l10n.planetPassportHistory, () {
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute<void>(
|
||
builder: (_) => PlanetHistoryScreen(
|
||
repository: widget.repository,
|
||
fandomName: name,
|
||
),
|
||
),
|
||
);
|
||
}, destructive: false),
|
||
if (!mine) ...[
|
||
ActionSheetItem(
|
||
context.l10n.reportTitle,
|
||
() => _moderate(context, 'report', name),
|
||
),
|
||
ActionSheetItem(
|
||
context.l10n.commonBlockAction,
|
||
() => _moderate(context, 'block', name),
|
||
),
|
||
],
|
||
]);
|
||
|
||
Future<void> _moderate(
|
||
BuildContext context,
|
||
String action,
|
||
String name,
|
||
) async {
|
||
if (action == 'report') {
|
||
final reason = await Navigator.of(context, rootNavigator: true)
|
||
.push<String>(
|
||
MaterialPageRoute(
|
||
builder: (_) => ReportScreen(
|
||
target: ModerationTarget(
|
||
authorName: name,
|
||
title: context.l10n.planetNamed(name),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
if (reason != null && context.mounted)
|
||
_toast(context, context.l10n.reportReceived);
|
||
return;
|
||
}
|
||
final ok = await confirmBlock(
|
||
context,
|
||
name,
|
||
message: widget.nebula
|
||
? context.l10n.blockNebulaConfirm
|
||
: context.l10n.blockPlanetConfirm,
|
||
);
|
||
if (ok && context.mounted) _toast(context, context.l10n.blocked);
|
||
}
|
||
}
|
||
|
||
/// 탭 줄 높이 — 시안 `Frame 1984081142`(375×44).
|
||
const _tabHeight = 44.0;
|
||
|
||
/// 「FPI | 팬덤 라운지」 — 좌우 16, 칸 171.5, 선택 밑줄 2 #4bc2ae · 바닥선 1 #1a1a1a,
|
||
/// 42dot Sans 16/500 선택 #4bc2ae · 비선택 #bbbbbb.
|
||
class _Tabs extends StatelessWidget implements PreferredSizeWidget {
|
||
const _Tabs();
|
||
|
||
@override
|
||
Size get preferredSize => const Size.fromHeight(_tabHeight);
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
const style = TextStyle(
|
||
fontFamily: AppFonts.body,
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w500,
|
||
height: 1,
|
||
);
|
||
return ColoredBox(
|
||
color: AppColors.cosmicVoid,
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
|
||
child: TabBar(
|
||
labelColor: AppColors.cosmicTeal,
|
||
unselectedLabelColor: const Color(0xFFBBBBBB),
|
||
labelStyle: style,
|
||
unselectedLabelStyle: style,
|
||
indicatorSize: TabBarIndicatorSize.tab,
|
||
indicator: const UnderlineTabIndicator(
|
||
borderSide: BorderSide(color: AppColors.cosmicTeal, width: 2),
|
||
),
|
||
dividerColor: const Color(0xFF1A1A1A),
|
||
dividerHeight: 1,
|
||
labelPadding: EdgeInsets.zero,
|
||
tabs: [
|
||
Tab(height: _tabHeight, text: 'FPI'),
|
||
Tab(height: _tabHeight, text: context.l10n.loungeTitle),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 히어로가 접히는 자리. 펼치면 시안 그대로, 접히면 히어로가 흐려지고 앱 바에
|
||
/// 행성 축소판과 이름이 남는다.
|
||
class _CollapsingHero extends StatelessWidget {
|
||
const _CollapsingHero({
|
||
required this.detail,
|
||
required this.min,
|
||
required this.hero,
|
||
required this.watchPlanet,
|
||
});
|
||
|
||
final PlanetDetail detail;
|
||
final PlanetWatch watchPlanet;
|
||
final double min;
|
||
final Widget hero;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => LayoutBuilder(
|
||
builder: (context, box) {
|
||
const max = PlanetDetailHero.designHeight + _tabHeight;
|
||
final t = ((box.maxHeight - min) / (max - min)).clamp(0.0, 1.0);
|
||
final top = MediaQuery.paddingOf(context).top;
|
||
return Stack(
|
||
clipBehavior: Clip.hardEdge,
|
||
children: [
|
||
// 히어로는 늘 543 높이로 그리고 위로 밀어 올린다(잘리는 쪽은 위).
|
||
Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
bottom: _tabHeight,
|
||
height: PlanetDetailHero.designHeight,
|
||
child: Opacity(opacity: t, child: hero),
|
||
),
|
||
if (t < 0.4)
|
||
Positioned(
|
||
top: top,
|
||
left: 56,
|
||
right: 56,
|
||
height: kToolbarHeight,
|
||
child: Opacity(
|
||
opacity: (1 - t / 0.4).clamp(0.0, 1.0),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
LivePlanetArt(
|
||
fandomId: detail.fandomId,
|
||
watch: watchPlanet,
|
||
size: 28,
|
||
nebula: detail.isNebula,
|
||
),
|
||
const SizedBox(width: AppSpace.s2),
|
||
Flexible(
|
||
child: Text(
|
||
detail.name,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(
|
||
fontFamily: AppFonts.display,
|
||
fontWeight: FontWeight.w400,
|
||
fontSize: 18,
|
||
color: AppColors.onCosmic,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
/// 탭 본문 공통 — 접힌 앱 바 아래로 들어가지 않게 겹침을 받아 둔다.
|
||
Widget _tabScroll(String key, List<Widget> slivers) => Builder(
|
||
builder: (context) => CustomScrollView(
|
||
key: PageStorageKey(key),
|
||
slivers: [
|
||
SliverOverlapInjector(
|
||
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
|
||
),
|
||
...slivers,
|
||
],
|
||
),
|
||
);
|
||
|
||
/// 「마지막 갱신」 시각. 서버 집계 시각(`fpiUpdatedAt`)이 없으면 「--:--」.
|
||
String _hhmm(DateTime? at) => at == null
|
||
? '--:--'
|
||
: '${at.hour.toString().padLeft(2, '0')}:${at.minute.toString().padLeft(2, '0')}';
|
||
|
||
/// 지표 카드 수치 — 시안 「34.2K」 · 「1.18M」 · 「450.1K」.
|
||
String _short(int v) => v >= 1000000
|
||
? '${(v / 1000000).toStringAsFixed(2)}M'
|
||
: v >= 1000
|
||
? '${(v / 1000).toStringAsFixed(1)}K'
|
||
: '$v';
|
||
|
||
/// FPI 탭 — TOTAL FPI 영역 + 상세 리스트. 시안 `Frame 1984081160`(좌우 20 · 위아래 16).
|
||
class _FpiTab extends StatelessWidget {
|
||
const _FpiTab({
|
||
required this.detail,
|
||
required this.repository,
|
||
required this.eclipse,
|
||
});
|
||
|
||
final PlanetDetail detail;
|
||
|
||
/// 이클립스 집계 구독. 대상은 이 행성이다.
|
||
final EclipseRepository eclipse;
|
||
|
||
/// 지표 카드와 랭킹이 **같은 저장소**를 지나야 수치가 갈리지 않는다.
|
||
/// 랭킹 화면이 제 안의 상수를 그리던 것이 발주사 검토회신서 4절의 지적이다.
|
||
final PlanetRepository repository;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
// 지표 카드 → 행성 지표 랭킹(Figma 546:14628·14639) → 사용자 지표 랭킹.
|
||
void open(FpiScore s) => Navigator.of(context, rootNavigator: true).push(
|
||
MaterialPageRoute<void>(
|
||
builder: (_) =>
|
||
FpiRankingScreen(metric: s.metric, repository: repository),
|
||
),
|
||
);
|
||
if (detail.isNebula) {
|
||
return _tabScroll('fpi', [
|
||
SliverPadding(
|
||
padding: const EdgeInsets.all(16),
|
||
sliver: SliverList.list(
|
||
children: [
|
||
_NebulaFpi(
|
||
pop: detail.growthPop,
|
||
target: detail.nebulaTargetPop,
|
||
members: detail.members,
|
||
targetMembers: detail.nebulaTargetMembers,
|
||
repository: repository,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
]);
|
||
}
|
||
final scores = detail.scores;
|
||
return _tabScroll('fpi', [
|
||
SliverPadding(
|
||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
|
||
sliver: SliverList.list(
|
||
children: [
|
||
// 침식·방어 진행 — `fn-eclipse-aggregate` 의 집계 문서와 샤드를 구독한다.
|
||
// TOTAL FPI 카드 위에 두는 이유는 침식이 곧 이 카드의 값을 깎기 때문이다
|
||
// (원장 B-55 — FPI 감소는 Eclipse 가 담당한다).
|
||
EclipseBanner(
|
||
repository: eclipse,
|
||
fandomId: detail.fandomId,
|
||
nebula: detail.isNebula,
|
||
margin: const EdgeInsets.only(bottom: AppSpace.s3),
|
||
),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFF19191C),
|
||
borderRadius: BorderRadius.circular(16),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
Text(
|
||
'TOTAL FPI : ${formatPop(detail.totalFpi)}',
|
||
style: const TextStyle(
|
||
fontFamily: AppFonts.display,
|
||
fontWeight: FontWeight.w400,
|
||
fontSize: 22,
|
||
height: 1,
|
||
color: AppColors.onCosmic,
|
||
),
|
||
),
|
||
// 정책 원장 B-15 검증 기준 — 화면에 마지막 갱신 시각 노출. 시안 밖 문구라
|
||
// 사본 간격 20(TOTAL FPI ↔ 육각형) 안에 4 + 10 + 6 으로 넣는다.
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
context.l10n.planetLastUpdated(_hhmm(detail.fpiUpdatedAt)),
|
||
style: const TextStyle(
|
||
fontFamily: AppFonts.body,
|
||
fontSize: 10,
|
||
fontWeight: FontWeight.w500,
|
||
height: 1,
|
||
color: AppColors.onCosmicMuted,
|
||
),
|
||
),
|
||
const SizedBox(height: 6),
|
||
FpiHexagon(scores: scores, max: detail.maxScore),
|
||
],
|
||
),
|
||
),
|
||
for (var i = 0; i < scores.length; i += 2) ...[
|
||
const SizedBox(height: 8),
|
||
if (i + 1 < scores.length)
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: _ScoreCard(
|
||
score: scores[i],
|
||
onTap: () => open(scores[i]),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: _ScoreCard(
|
||
score: scores[i + 1],
|
||
onTap: () => open(scores[i + 1]),
|
||
),
|
||
),
|
||
],
|
||
)
|
||
else
|
||
// 홀수 마지막 카드는 한 줄을 다 쓴다(시안 COMMUNITY 335).
|
||
_ScoreCard(score: scores[i], onTap: () => open(scores[i])),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
]);
|
||
}
|
||
}
|
||
|
||
/// 성운 FPI 탭 — 시안 `373:442708` `Frame 1984081160`(안쪽 16 · 간격 16).
|
||
///
|
||
/// 「기여 현황」 카드(#1a1a1c r12 · 안쪽 12/16) — 성운 성장률 · 진행 막대 · 0 ~ 목표,
|
||
/// 「실시간 기여 랭킹」 — 1위 왕관 · 빈 원 80 · 「아직 기여자가 없어요」.
|
||
/// 목표는 원장 B-10 의 1,000 POP(사본 500,000 은 과거 수치). 분자는 서버 `growthPop`.
|
||
/// 기여자 목록은 상세 데이터에 아직 없어 빈 상태로 둔다.
|
||
class _NebulaFpi extends StatelessWidget {
|
||
const _NebulaFpi({
|
||
required this.pop,
|
||
required this.target,
|
||
required this.members,
|
||
required this.targetMembers,
|
||
required this.repository,
|
||
});
|
||
|
||
final int pop;
|
||
|
||
/// 전환 목표 — 서버 `config/policy`(B-10). 탄생은 POP · 창립 인원 **두 조건**이다.
|
||
final int target;
|
||
final int members;
|
||
final int targetMembers;
|
||
final PlanetRepository repository;
|
||
|
||
static TextStyle _b(double size, Color color, {double? height}) => TextStyle(
|
||
fontFamily: AppFonts.body,
|
||
fontSize: size,
|
||
fontWeight: FontWeight.w600,
|
||
height: height,
|
||
color: color,
|
||
);
|
||
|
||
static TextStyle _d(double size, Color color, {double height = 1}) =>
|
||
TextStyle(
|
||
fontFamily: AppFonts.display,
|
||
fontSize: size,
|
||
fontWeight: FontWeight.w400,
|
||
height: height,
|
||
letterSpacing: size * -0.025,
|
||
color: color,
|
||
);
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final rate = target <= 0 ? 0 : (pop * 100 ~/ target).clamp(0, 100);
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Text(
|
||
context.l10n.planetContribution,
|
||
style: _b(18, AppColors.onCosmic),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFF1A1A1C),
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Text(
|
||
context.l10n.planetNebulaGrowth,
|
||
style: _b(
|
||
12,
|
||
const Color(0xFFA3A2A0),
|
||
height: 20 / 12,
|
||
).copyWith(fontWeight: FontWeight.w500),
|
||
),
|
||
Text.rich(
|
||
TextSpan(
|
||
text: '$rate',
|
||
style: _d(32, AppColors.onCosmic, height: 1.4),
|
||
children: [
|
||
TextSpan(
|
||
text: '%',
|
||
style: _d(20, AppColors.onCosmic, height: 1.4),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||
child: LinearProgressIndicator(
|
||
value: target <= 0 ? 0 : (pop / target).clamp(0.0, 1.0),
|
||
minHeight: 10,
|
||
backgroundColor: Colors.black.withValues(alpha: 0.3),
|
||
color: AppColors.cosmicTeal,
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text('0', style: _d(12, const Color(0xFFCDCDCD))),
|
||
Text(
|
||
formatPop(target),
|
||
style: _d(12, const Color(0xFFCDCDCD)),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
// 인원 조건도 함께 — 탄생은 POP · 창립 인원 동시 충족(B-10).
|
||
Text(
|
||
'${context.l10n.growthFounders} ${formatPop(members)} / ${formatPop(targetMembers)}',
|
||
style: _b(12, const Color(0xFFA3A2A0)),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Text(context.l10n.planetLiveRanking, style: _b(18, AppColors.onCosmic)),
|
||
const SizedBox(height: 12),
|
||
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).
|
||
///
|
||
/// 명세 *값 · 전체 순위 · 최근 동향 · 내 기여도 상위 값*. 라벨 12 #cdcdcd, 값 ABeeZee 18,
|
||
/// 「전체 n위 ▲n」(상승 #f28180 · 하락 #6866f3 · 변동 없음 #cdcdcd 「-」), 오른쪽 위
|
||
/// 3D 아이콘 54, 아래 #101010 알약에 「내 기여도 · 상위 n%」.
|
||
class _ScoreCard extends StatelessWidget {
|
||
const _ScoreCard({required this.score, required this.onTap});
|
||
|
||
final FpiScore score;
|
||
final VoidCallback onTap;
|
||
|
||
static String _icon(FpiMetric m) => switch (m) {
|
||
FpiMetric.bpm => 'assets/figma/icon-bpm.png',
|
||
FpiMetric.health => 'assets/figma/icon-health.png',
|
||
FpiMetric.referral => 'assets/figma/icon-referral.png',
|
||
FpiMetric.community => 'assets/figma/icon-community.png',
|
||
FpiMetric.contribution => 'assets/figma/icon-contribution.png',
|
||
};
|
||
|
||
static TextStyle _t(double size, Color color, {double height = 1.4}) =>
|
||
TextStyle(
|
||
fontFamily: AppFonts.body,
|
||
fontSize: size,
|
||
fontWeight: FontWeight.w500,
|
||
height: height,
|
||
letterSpacing: size * -0.025,
|
||
color: color,
|
||
);
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final trend = score.trend;
|
||
final color = trend > 0
|
||
? const Color(0xFFF28180)
|
||
: trend < 0
|
||
? AppColors.neonViolet
|
||
: const Color(0xFFCDCDCD);
|
||
final icon = _icon(score.metric);
|
||
return Material(
|
||
color: const Color(0xFF19191C),
|
||
borderRadius: BorderRadius.circular(16),
|
||
child: InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(16),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(12),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
SizedBox(
|
||
height: 59,
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
score.metric.label,
|
||
// 시안도 CONTRIBUTION 이 아이콘 칸에 1.5 걸친다 — 자르지 않는다.
|
||
softWrap: false,
|
||
overflow: TextOverflow.visible,
|
||
style: _t(12, const Color(0xFFCDCDCD)),
|
||
),
|
||
Text(
|
||
_short(score.value),
|
||
style: const TextStyle(
|
||
fontFamily: AppFonts.display,
|
||
fontWeight: FontWeight.w400,
|
||
fontSize: 18,
|
||
height: 1.4,
|
||
letterSpacing: -0.45,
|
||
color: AppColors.onCosmic,
|
||
),
|
||
),
|
||
Row(
|
||
children: [
|
||
// 순위가 없으면(집계 모집단 밖 · 0) 「전체 0위」 대신 「순위 집계 전」.
|
||
Text(
|
||
score.rank > 0
|
||
? context.l10n.planetOverallRank(
|
||
'${score.rank}',
|
||
)
|
||
: context.l10n.planetRankNone,
|
||
style: _t(12, color),
|
||
),
|
||
const SizedBox(width: 4),
|
||
if (score.rank <= 0)
|
||
const SizedBox.shrink()
|
||
else if (trend == 0)
|
||
Text(
|
||
'-',
|
||
style: _t(
|
||
10,
|
||
const Color(0xFF777777),
|
||
height: 1,
|
||
),
|
||
)
|
||
else ...[
|
||
Text(
|
||
trend > 0 ? '▲' : '▼',
|
||
style: _t(8, color, height: 1),
|
||
),
|
||
const SizedBox(width: 2),
|
||
Text(
|
||
formatPop(trend.abs()),
|
||
style: _t(10, color, height: 1),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
SizedBox(
|
||
width: 54,
|
||
height: 54,
|
||
// 시안 3D 아이콘 54. 번개(CONTRIBUTION)만 36 원형이라 안쪽 여백 9.
|
||
child: score.metric == FpiMetric.contribution
|
||
? Padding(
|
||
padding: const EdgeInsets.all(9),
|
||
child: Image.asset(icon),
|
||
)
|
||
: Image.asset(icon, fit: BoxFit.contain),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Container(
|
||
height: 22,
|
||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.cosmicSurface,
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
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}',
|
||
),
|
||
style: _t(10, AppColors.onCosmic, height: 1),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 팬덤 라운지 탭 — 명세 *최신순 · 최초 5개, 스크롤 시 5개씩 호출*.
|
||
class _LoungeTab extends StatefulWidget {
|
||
const _LoungeTab({required this.detail, required this.repository});
|
||
|
||
final PlanetDetail detail;
|
||
final PlanetRepository repository;
|
||
|
||
@override
|
||
State<_LoungeTab> createState() => _LoungeTabState();
|
||
}
|
||
|
||
class _LoungeTabState extends State<_LoungeTab> {
|
||
late List<LoungePost> _posts = [...widget.detail.posts];
|
||
bool _loading = false;
|
||
bool _end = false;
|
||
|
||
/// 이 화면에서 숨긴 작성자 — 「이 회원의 글 모두 숨기기」.
|
||
final _hidden = <String>{};
|
||
|
||
Future<void> _more() async {
|
||
if (_loading || _end) return;
|
||
setState(() => _loading = true);
|
||
final next = await widget.repository.morePosts(offset: _posts.length);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_posts = [..._posts, ...next];
|
||
_loading = false;
|
||
_end = next.isEmpty;
|
||
});
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final posts = [
|
||
for (final p in _posts)
|
||
if (!_hidden.contains(p.authorName)) p,
|
||
];
|
||
return Scaffold(
|
||
// 바깥 NestedScrollView 가 스크롤을 쥐므로 컨트롤러 대신 알림으로 끝을 본다.
|
||
body: NotificationListener<ScrollNotification>(
|
||
onNotification: (n) {
|
||
if (n.metrics.extentAfter < 240) _more();
|
||
return false;
|
||
},
|
||
child: _tabScroll('lounge', [
|
||
// 글이 없을 때 — 시안에 빈 상태가 없어 댓글 빈 상태(`commentFirst`)와 같은 꼴에
|
||
// 글쓰기 안내 문구(`loungeHint`)를 쓴다.
|
||
if (posts.isEmpty && !_loading)
|
||
SliverToBoxAdapter(
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 48),
|
||
child: Column(
|
||
children: [
|
||
Image.asset(
|
||
'assets/figma/icon-message-circle.png',
|
||
width: 48,
|
||
height: 48,
|
||
color: const Color(0xFF4F4F4F),
|
||
),
|
||
const SizedBox(height: AppSpace.s4),
|
||
Text(
|
||
context.l10n.loungeHint,
|
||
textAlign: TextAlign.center,
|
||
style: loungeText(12, AppColors.onCosmicVariant),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
SliverPadding(
|
||
padding: const EdgeInsets.all(AppSpace.s4),
|
||
sliver: SliverList.separated(
|
||
// 로딩 표시는 **실제로 불러오는 동안에만** 둔다. 「아직 끝이 아니다」를
|
||
// 기준으로 두면 사용자가 스크롤하기 전까지 바퀴가 영원히 돈다.
|
||
itemCount: posts.length + (_loading ? 1 : 0),
|
||
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||
itemBuilder: (context, i) {
|
||
if (i >= posts.length) {
|
||
return const Padding(
|
||
padding: EdgeInsets.all(AppSpace.s4),
|
||
child: Center(child: CircularProgressIndicator()),
|
||
);
|
||
}
|
||
return _PostRow(
|
||
post: posts[i],
|
||
repository: widget.repository,
|
||
onChanged: _replace,
|
||
onMore: () => _postMore(posts[i]),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
]),
|
||
),
|
||
// 시안 `Frame 1984081111` — 52 원 #ff7ddd · plus 24.
|
||
floatingActionButton: SizedBox.square(
|
||
dimension: 52,
|
||
child: FloatingActionButton(
|
||
onPressed: _write,
|
||
tooltip: context.l10n.postWrite,
|
||
elevation: 0,
|
||
shape: const CircleBorder(),
|
||
backgroundColor: AppColors.cosmicAccent,
|
||
foregroundColor: AppColors.onCosmic,
|
||
child: const ImageIcon(AppIcons.plus, size: 24),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 글 한 편을 바꾸거나(`post`) 뺀다(`null`). 상세 화면이 돌려준 결과를 받는다.
|
||
void _replace(LoungePost old, LoungePost? post) => setState(() {
|
||
_posts = [
|
||
for (final p in _posts)
|
||
if (p.id != old.id) p else if (post != null) post,
|
||
];
|
||
});
|
||
|
||
Future<void> _run(Future<void> Function() action) async {
|
||
try {
|
||
await action();
|
||
} catch (e) {
|
||
if (mounted) _toast(context, loungeError(e));
|
||
}
|
||
}
|
||
|
||
/// 글 더보기 — 시안 `251:51073` 하단 시트(신고하기 · 이 회원의 글 모두 숨기기 · 취소).
|
||
/// 내 글이면 수정 · 삭제를 띄운다.
|
||
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),
|
||
),
|
||
),
|
||
);
|
||
if (updated != null && mounted) _replace(post, updated);
|
||
},
|
||
),
|
||
ActionSheetItem(context.l10n.commonDeleteAction, () async {
|
||
if (!await confirmOverlay(context, context.l10n.postDeleteConfirm))
|
||
return;
|
||
await _run(() async {
|
||
await widget.repository.deletePost(post);
|
||
_replace(post, null);
|
||
});
|
||
}),
|
||
] else ...[
|
||
ActionSheetItem(context.l10n.reportTitle, () async {
|
||
final reason = await Navigator.of(context, rootNavigator: true)
|
||
.push<String>(
|
||
MaterialPageRoute(
|
||
builder: (_) => ReportScreen(
|
||
target: ModerationTarget(
|
||
authorName: post.authorName,
|
||
title: loungePostText(post),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
if (reason == null) return;
|
||
await _run(() async {
|
||
await widget.repository.reportPost(post, reason);
|
||
if (mounted) _toast(context, context.l10n.reportReceived);
|
||
});
|
||
}),
|
||
ActionSheetItem(context.l10n.planetHideMember, () async {
|
||
if (!await confirmBlock(context, post.authorName)) return;
|
||
// 차단은 서버(`users/{uid}/blocks`)에 남아 다음에 열어도 빠진다.
|
||
await _run(() async {
|
||
await widget.repository.blockAuthor(post);
|
||
if (mounted) setState(() => _hidden.add(post.authorName));
|
||
});
|
||
}),
|
||
],
|
||
]);
|
||
|
||
Future<void> _write() async {
|
||
final post = await Navigator.of(context, rootNavigator: true)
|
||
.push<LoungePost>(
|
||
MaterialPageRoute(
|
||
builder: (_) => LoungeComposeScreen(
|
||
onSubmit: (d) =>
|
||
widget.repository.createPost(d.body, photos: d.photos),
|
||
),
|
||
),
|
||
);
|
||
if (post == null || !mounted) return;
|
||
setState(() => _posts = [post, ..._posts]);
|
||
}
|
||
}
|
||
|
||
/// 라운지 글 카드 — 시안 `Frame 1984081004`(#19191c r8 · 안쪽 16 · 간격 16).
|
||
class _PostRow extends StatelessWidget {
|
||
const _PostRow({
|
||
required this.post,
|
||
required this.repository,
|
||
required this.onChanged,
|
||
required this.onMore,
|
||
});
|
||
|
||
final LoungePost post;
|
||
final PlanetRepository repository;
|
||
final void Function(LoungePost old, LoungePost? post) onChanged;
|
||
final VoidCallback onMore;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Material(
|
||
color: const Color(0xFF19191C),
|
||
borderRadius: BorderRadius.circular(8),
|
||
child: InkWell(
|
||
borderRadius: BorderRadius.circular(8),
|
||
onTap: () => Navigator.of(context, rootNavigator: true).push(
|
||
MaterialPageRoute<void>(
|
||
builder: (_) => LoungePostScreen(
|
||
post: post,
|
||
repository: repository,
|
||
onChanged: (p) => onChanged(post, p),
|
||
),
|
||
),
|
||
),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(AppSpace.s4),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
LoungeAuthorRow(
|
||
post: post,
|
||
trailing: InkResponse(
|
||
onTap: onMore,
|
||
radius: 18,
|
||
child: Image.asset(
|
||
'assets/figma/icon-post-more.png',
|
||
width: 24,
|
||
height: 24,
|
||
semanticLabel: context.l10n.commonMore,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Text(
|
||
loungePostText(post),
|
||
style: loungeText(14, AppColors.onCosmic),
|
||
),
|
||
if (post.images.isNotEmpty) ...[
|
||
const SizedBox(height: 16),
|
||
_PostImages(images: post.images),
|
||
],
|
||
const SizedBox(height: 16),
|
||
LoungeCounts(post: post, icon: 14, color: const Color(0xFF4F4F4F)),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 첨부 격자 — 1장은 정사각, 3장은 큰 칸 + 오른쪽 두 칸, 그 밖은 2열(넘치면 「+n」).
|
||
///
|
||
/// ponytail: 시안의 6장 이상 배치(3+3, 마지막 칸 「+n」)는 2×2 + 「+n」으로 줄였다.
|
||
/// 여섯 장 이상 글이 흔해지면 시안 배치로 늘린다.
|
||
class _PostImages extends StatelessWidget {
|
||
const _PostImages({required this.images});
|
||
|
||
final List<String> images;
|
||
|
||
Widget _tile(String src, {int more = 0}) => AspectRatio(
|
||
aspectRatio: 1,
|
||
child: Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
Image(image: loungeImage(src), fit: BoxFit.cover),
|
||
if (more > 0)
|
||
ColoredBox(
|
||
color: Colors.black.withValues(alpha: 0.7),
|
||
child: Center(
|
||
child: Text(
|
||
'+$more',
|
||
style: const TextStyle(
|
||
fontFamily: AppFonts.body,
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w900,
|
||
color: AppColors.onCosmic,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (images.length == 1) return _tile(images.first);
|
||
if (images.length == 3) {
|
||
return IntrinsicHeight(
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Expanded(flex: 2, child: _tile(images[0])),
|
||
const SizedBox(width: 6),
|
||
Expanded(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [_tile(images[1]), _tile(images[2])],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
final shown = images.take(4).toList();
|
||
Widget row(int from) => Row(
|
||
children: [
|
||
for (var i = from; i < from + 2; i++) ...[
|
||
if (i > from) const SizedBox(width: 6),
|
||
Expanded(
|
||
child: i < shown.length
|
||
? _tile(shown[i], more: i == 3 ? images.length - 4 : 0)
|
||
: const SizedBox.shrink(),
|
||
),
|
||
],
|
||
],
|
||
);
|
||
return Column(
|
||
children: [
|
||
row(0),
|
||
if (shown.length > 2) ...[const SizedBox(height: 4), row(2)],
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
void _toast(BuildContext context, String message) {
|
||
ScaffoldMessenger.of(context)
|
||
..hideCurrentSnackBar()
|
||
..showSnackBar(
|
||
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
|
||
);
|
||
}
|