import 'planet_history_screen.dart'; import 'hero_participation_sheet.dart'; import 'package:flutter/material.dart'; import '../../core/data/backend.dart' show BackendException; import '../../core/data/planet_visual_state.dart'; import '../../core/theme/tokens.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 createState() => _PlanetDetailScreenState(); } class _PlanetDetailScreenState extends State { late Future _detail; @override void initState() { super.initState(); _detail = widget.repository.load(nebula: widget.nebula); } /// 「가입하기」 — 가입 뒤 상세를 다시 읽어 알약과 멤버 수를 서버 값으로 맞춘다. Future _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))); } } } @override Widget build(BuildContext context) => FutureBuilder( 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 ? null : _join, onOpenHistory: () => Navigator.of(context).push( MaterialPageRoute( builder: (_) => PlanetHistoryScreen( repository: widget.repository, fandomName: d.name, ), ), ), onHeroParticipate: () => showModalBottomSheet( 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( 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 _moderate( BuildContext context, String action, String name, ) async { if (action == 'report') { final reason = await Navigator.of(context, rootNavigator: true) .push( 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 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( builder: (_) => FpiRankingScreen(metric: s.metric, repository: repository), ), ); if (detail.isNebula) { return _tabScroll('fpi', [ SliverPadding( padding: const EdgeInsets.all(16), sliver: SliverList.list( children: [ const _NebulaFpi(), ], ), ), ]); } 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 은 과거 수치). 성운 누적 POP 과 기여자 /// 목록은 상세 데이터에 아직 없어 0 · 빈 상태로 둔다. class _NebulaFpi extends StatelessWidget { const _NebulaFpi(); 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) { const pop = 0; 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), 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 _posts = [...widget.detail.posts]; bool _loading = false; bool _end = false; /// 이 화면에서 숨긴 작성자 — 「이 회원의 글 모두 숨기기」. final _hidden = {}; Future _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( 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 _run(Future 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( 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( 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 _write() async { final post = await Navigator.of(context, rootNavigator: true) .push( 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( 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 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), ); }