Files
fanuzu/app/lib/features/planet/planet_detail_screen.dart
T

1200 lines
41 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/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 '../home/home_data.dart' show defaultNebulaTargets;
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,
});
/// 성운 상세인가. 성운은 아직 정식 행성이 아니다(정책 §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,
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),
icon: const ImageIcon(
AppIcons.ellipsisVertical,
size: 24,
),
),
],
flexibleSpace: _CollapsingHero(
detail: d,
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,
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),
],
),
),
),
);
},
);
void _showMore(BuildContext context, String name) =>
showActionSheet(context, [
// 팬덤 역사는 시안 밖 기능이라 남기되 중립색으로 둔다.
ActionSheetItem(context.l10n.planetPassportHistory, () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => PlanetHistoryScreen(
repository: widget.repository,
fandomName: name,
),
),
);
}, destructive: false),
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,
});
final PlanetDetail detail;
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: [
Image.asset(
'assets/figma/planet-detail-hero.png',
width: 28,
height: 28,
),
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, 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,
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.repository});
final int pop;
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 target = defaultNebulaTargets.pop;
final rate = pop * 100 ~/ target;
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: pop / target,
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: 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),
);
}