import 'package:flutter/material.dart'; import '../../core/ui/app_dialog.dart'; import 'package:flutter/services.dart'; import 'package:share_plus/share_plus.dart'; import '../../core/data/backend.dart' show BackendException; import '../../core/i18n/app_locale.dart'; import '../../core/theme/tokens.dart'; import '../../core/ui/app_icons.dart'; import '../../core/ui/relative_time.dart'; import '../home/home_screen.dart' show formatPop; import '../my/profile_edit_screen.dart'; import '../planet/moderation.dart'; import '../translation/translation_comments.dart'; import '../translation/translation_data.dart'; import 'campaign_data.dart'; import 'campaign_ranking_screen.dart'; import 'campaign_share.dart'; import 'widgets/campaign_gauge.dart'; import 'widgets/campaign_list_card.dart' show CampaignAuthor; import 'widgets/campaign_rank.dart'; import 'widgets/campaign_share_dialog.dart'; import 'widgets/campaign_style.dart'; import '../../core/ui/fx_intensity.dart'; /// 캠페인 상세 — 화면 대조 원장 F5 · Figma `272:245738`. /// /// 신고하기는 `272:246311` 이며 [ReportScreen] 을 공용으로 쓴다. /// /// 명세 `523:18493` — *디폴트 첫 번째 사진 노출 · 사진/영상 개수에 따른 인디케이터 수* /// 명세 `523:18525` — *상세 페이지 진입과 동시에 조회수 +1 · 좋아요 클릭 시 +1* /// 명세 `523:18559` — *캠페인 정보 탭 · 응원 게시판 +댓글수 탭 · 디폴트 캠페인 정보* /// 명세 `523:18569` — *기여율 · 누적 POP · 목표 · 참여 인원 · 남은 시간* /// 명세 `523:18589` — *기여하기 버튼은 **타인 캠페인에서만** 표시* class CampaignDetailScreen extends StatefulWidget { CampaignDetailScreen({ super.key, required this.campaignId, CampaignRepository? repository, this.myPop, this.translation, }) : repository = repository ?? SampleCampaignRepository(); final String campaignId; final CampaignRepository repository; /// 후원 가능 잔액. 넘기지 않으면 저장소에서 읽는다(기여 POP · B-33). final int? myPop; /// 참여형 번역 — 없으면 앱 루트의 [TranslationScope] 를 쓴다. final TranslationRepository? translation; @override State createState() => _CampaignDetailScreenState(); } class _CampaignDetailScreenState extends State { final _media = PageController(); CampaignDetail? _detail; int _page = 0; int _tabIndex = 0; int _likes = 0; bool _liked = false; int _raised = 0; int _spent = 0; int _balance = 0; Object? _error; /// 응원 게시판 댓글(최신순). null 이면 아직 읽는 중. List? _comments; bool _moreComments = false; int _commentCount = 0; /// 공개 닉네임이 있는가 — 없으면 입력 대신 닉네임 안내를 띄운다. bool _hasNickname = true; /// 번역할 댓글. 없으면 번역 댓글은 캠페인 본문의 번역이다(Q-TR-07 A안). TranslationReplyTarget? _replyTo; TranslationRepository? get _translation => translationSurfaces.contains(TranslationSurface.campaignBoard) ? widget.translation ?? TranslationScope.maybeOf(context) : null; String get _root => 'campaigns/${widget.campaignId}'; @override void initState() { super.initState(); _load(); _loadComments(); } @override void dispose() { _media.dispose(); super.dispose(); } Future _load() async { final CampaignDetail detail; final int balance; try { detail = await widget.repository.detail(widget.campaignId); balance = widget.myPop ?? await widget.repository.balance(); } on Object catch (error) { if (mounted) setState(() => _error = error); return; } if (!mounted) return; setState(() { _balance = balance; _detail = detail; _likes = detail.card.likes; _liked = detail.liked; _raised = detail.card.raisedPop; _commentCount = detail.card.comments; }); } Future _loadComments({bool more = false}) async { final List page; try { if (!more) _hasNickname = await widget.repository.hasPublicNickname(); page = await widget.repository.comments(widget.campaignId, more: more); } on Object catch (error) { if (!mounted) return; setState(() => _comments ??= const []); _fail(error); return; } if (!mounted) return; setState(() { _comments = [if (more) ...?_comments, ...page]; _moreComments = page.length >= campaignCommentPageSize; }); } Future _send(String body) async { try { final c = await widget.repository.addComment(widget.campaignId, body); if (!mounted) return true; setState(() { _comments = [c, ...?_comments]; _commentCount++; }); return true; } on Object catch (error) { _fail(error); return false; } } /// 댓글 좋아요 — 저장이 끝난 뒤에만 숫자를 바꾼다(캠페인 좋아요와 같은 방식). Future _likeComment(CampaignComment c) async { final on = !c.liked; try { await widget.repository.likeComment(widget.campaignId, c.id, on); } on Object catch (error) { _fail(error); return; } if (!mounted) return; final next = CampaignComment( id: c.id, authorName: c.authorName, body: c.body, createdAt: c.createdAt, authorUid: c.authorUid, isMine: c.isMine, liked: on, likes: c.likes + (on ? 1 : -1), ); setState( () => _comments = [ for (final x in _comments ?? const []) x == c ? next : x, ], ); } /// 댓글 카드의 더보기 — 내 댓글은 삭제, 남의 댓글은 신고 · 차단(명세 `527:21192`). Future _commentAction(CampaignComment c, String action) async { final repo = widget.repository; try { switch (action) { case 'delete': if (!await confirmOverlay( context, context.l10n.commentDeleteConfirm, )) { return; } await repo.deleteComment(widget.campaignId, c.id); if (!mounted) return; setState(() { _comments = [...?_comments]..remove(c); if (_commentCount > 0) _commentCount--; }); case 'report': final reason = await Navigator.of(context, rootNavigator: true) .push( MaterialPageRoute( builder: (_) => ReportScreen( target: ModerationTarget( authorName: c.authorName, title: c.body, ), ), ), ); if (reason == null) return; await repo.reportComment(widget.campaignId, c, reason); if (mounted) _toast(context.l10n.reportReceived); case 'block': if (!await confirmBlock(context, c.authorName)) return; await repo.blockAuthor(c); if (!mounted) return; // 차단한 작성자의 댓글은 바로 숨긴다 — 다음 읽기부터는 저장소가 뺀다. setState( () => _comments = [ for (final x in _comments ?? const []) if (x.authorUid != c.authorUid) x, ], ); _toast(context.l10n.blocked); } } on Object catch (error) { _fail(error); } } @override Widget build(BuildContext context) { final detail = _detail; if (detail == null) { final error = _error; return Scaffold( appBar: error == null ? null : AppBar(), body: Center( child: error == null ? const CircularProgressIndicator() : Text( error is BackendException ? error.message : context.l10n.campaignLoadFailed, ), ), ); } final card = detail.card; // 시안(272:245738) — 대표 이미지 360 위에 반투명 원형 버튼 둘(42 · #000 60%)이 뜨고, // 제목 · 본문 · 탭 · 펀딩 현황이 한 스크롤에 이어진다. 탭은 고정 헤더가 아니다. return Scaffold( extendBodyBehindAppBar: true, appBar: AppBar( backgroundColor: Colors.transparent, toolbarHeight: 54, leadingWidth: 58, leading: const Padding( padding: EdgeInsets.only(left: 16), child: _Round(child: BackButton()), ), actions: [ Padding( padding: const EdgeInsets.only(right: 16), child: _Round( child: PopupMenuButton( tooltip: context.l10n.commonMore, icon: const ImageIcon(AppIcons.ellipsisVertical, size: 24), onSelected: (v) => _moderate(v, card), itemBuilder: (_) => [ PopupMenuItem( value: 'report', child: Text(context.l10n.reportTitle), ), PopupMenuItem( value: 'block', child: Text(context.l10n.commonBlockAction), ), ], ), ), ), ], ), body: ListView( key: const ValueKey('campaign-info'), padding: EdgeInsets.only(bottom: card.mine ? AppSpace.s5 : 96), children: [ _hero(detail), Padding( padding: const EdgeInsets.fromLTRB(20, 12, 20, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: CampaignAuthor( name: card.authorName, createdAt: card.createdAt, ), ), Tooltip( message: context.l10n.commonShare, child: Material( color: AppColors.cosmicSurfaceContainer, shape: const StadiumBorder(), child: InkWell( onTap: () => _share(card), customBorder: const StadiumBorder(), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 6, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( context.l10n.commonShare, style: campaignText( 12, 600, AppColors.onCosmicVariant, height: 1, spacing: 0, ), ), const SizedBox(width: 4), const ImageIcon( AppIcons.share, size: 14, color: AppColors.onCosmicVariant, ), ], ), ), ), ), ), ], ), const SizedBox(height: 12), Text( card.title, style: campaignText(24, 500, AppColors.onCosmic, height: 1.4), ), const SizedBox(height: 12), Text( card.body, style: campaignText( 14, 500, const Color(0xFFC2C2C2), height: 1.4, ), ), const SizedBox(height: 12), Row( children: [ InkResponse( onTap: _like, radius: 16, child: Tooltip( message: context.l10n.like, child: Icon( _liked ? Icons.favorite : Icons.favorite_border, size: 16, color: _liked ? AppColors.cosmicAccent : AppColors.onCosmic, ), ), ), const SizedBox(width: 4), Text(formatReaction(_likes), style: _reaction), const SizedBox(width: 12), const Icon( Icons.visibility_outlined, size: 16, color: AppColors.onCosmic, ), const SizedBox(width: 4), // 진입과 동시에 +1. Text(formatReaction(card.views + 1), style: _reaction), const SizedBox(width: 12), const Icon( Icons.share_outlined, size: 16, color: AppColors.onCosmic, ), const SizedBox(width: 4), Text(formatReaction(card.shares), style: _reaction), ], ), ], ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), child: Row( children: [ _tab(context.l10n.campaignTabInfo, 0), _tab(context.l10n.campaignTabBoard('$_commentCount'), 1), ], ), ), if (_tabIndex == 0) _info(detail) else _board(), ], ), // 명세 — 기여하기는 타인 캠페인에서만. 내 캠페인은 철회할 수 있을 때 철회 버튼(B-58). // 응원 게시판 탭은 하단에 댓글 입력 영역을 고정한다(명세 `527:21196`). // 기본은 일반 댓글, 지구본을 켜면 캠페인 본문의 번역 댓글이다. bottomNavigationBar: _tabIndex == 1 && !_hasNickname ? _nicknamePrompt() : _tabIndex == 1 ? CommentInputBar( hint: context.l10n.commentHint, onComment: _send, // 규칙의 댓글 상한과 같은 수. maxLength: 1000, translation: _translation, rootPath: _root, replyTo: _replyTo, onClearReply: () => setState(() => _replyTo = null), ) : card.mine ? (_withdrawable(card) ? SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), child: OutlinedButton( onPressed: () => _withdraw(card), style: OutlinedButton.styleFrom( minimumSize: const Size.fromHeight(50), foregroundColor: AppColors.onCosmic, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.sm), ), ), child: Text(context.l10n.campaignWithdraw), ), ), ) : null) : SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), child: FilledButton( onPressed: () => _contribute(detail), style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(50), backgroundColor: AppColors.onCosmic, foregroundColor: Colors.black, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.sm), ), textStyle: campaignText(14, 600, Colors.black, height: 1.3), ), child: Text(context.l10n.campaignContributeAction), ), ), ), ); } /// 닉네임 없는 사용자 — 응원 게시판 댓글은 「팬」으로 쓸 수 없다(규칙과 같은 조건). Widget _nicknamePrompt() => ColoredBox( color: AppColors.cosmicVoid, child: SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB(16, 10, 10, 10), child: Row( children: [ Expanded( child: Text( context.l10n.campaignCommentNeedNickname, style: campaignText(14, 500, AppColors.onCosmicMuted), ), ), TextButton( onPressed: () async { await Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( builder: (_) => ProfileEditScreen( nickname: '', repository: widget.repository.my, ), ), ); final ok = await widget.repository.hasPublicNickname(); if (mounted) setState(() => _hasNickname = ok); }, child: Text(context.l10n.campaignCommentSetNickname), ), ], ), ), ), ); static final _reaction = campaignText(12, 500, AppColors.onCosmic, height: 1); /// 대표 이미지 — 상태 표시줄을 포함해 360. 인디케이터 수는 사진 개수를 따른다. Widget _hero(CampaignDetail detail) => SizedBox( height: 312 + MediaQuery.paddingOf(context).top, child: Stack( fit: StackFit.expand, children: [ PageView( // 사진 슬라이드를 가려내는 표식. key: const ValueKey('campaign-media'), controller: _media, onPageChanged: (i) => setState(() => _page = i), children: [ // 캠페인 문서의 사진은 아직 라벨뿐이라 시안 표본을 깐다. for (final label in detail.mediaLabels) Image.asset( 'assets/figma/campaign-sample.png', fit: BoxFit.cover, semanticLabel: label, ), ], ), if (detail.mediaLabels.length > 1) Positioned( bottom: 12, left: 0, right: 0, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ for (var i = 0; i < detail.mediaLabels.length; i++) Container( width: i == _page ? 16 : 6, height: 6, margin: const EdgeInsets.symmetric(horizontal: 2), decoration: BoxDecoration( borderRadius: BorderRadius.circular(AppRadius.full), color: i == _page ? AppColors.onCosmic : AppColors.onCosmic.withValues(alpha: 0.5), ), ), ], ), ), ], ), ); /// 탭 — 반폭 · 글자 14/500 · 선택 흰 글자·흰 밑줄, 비선택 #84868e · #1b1c1c 밑줄. Widget _tab(String label, int index) { final on = _tabIndex == index; return Expanded( child: InkWell( onTap: () => setState(() => _tabIndex = index), child: Container( height: 37, alignment: Alignment.center, decoration: BoxDecoration( border: Border( bottom: BorderSide( color: on ? AppColors.onCosmic : const Color(0xFF1B1C1C), ), ), ), child: Text( label, style: campaignText( 14, 500, on ? AppColors.onCosmic : const Color(0xFF84868E), spacing: 0, ), ), ), ), ); } Widget _info(CampaignDetail detail) { final card = detail.card; final sorted = [...detail.topContributors] ..sort((a, b) => a.rank.compareTo(b.rank)); return Padding( padding: const EdgeInsets.fromLTRB(20, 12, 20, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (card.status.notice case final notice?) ...[ Container( key: const ValueKey('campaign-status-notice'), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppColors.cosmicSurfaceContainer, borderRadius: BorderRadius.circular(AppRadius.sm), ), child: Text( notice, style: campaignText(14, 500, AppColors.onCosmic, height: 1.4), ), ), const SizedBox(height: 12), ], Text( context.l10n.campaignFundingStatus, style: campaignText(18, 600, AppColors.onCosmic, spacing: 0), ), const SizedBox(height: 12), CampaignGauge(raised: _raised, goal: card.goalPop), const SizedBox(height: 6), Row( children: [ _stat( // 서버 donorCount(고유 후원자 수)를 그대로 쓴다. 후원 뒤 상세를 다시 읽으므로 화면이 더하지 않는다. formatPop(card.supporters), context.l10n.campaignSupporting, ), const SizedBox(width: 6), _stat(_left(detail.remaining()), context.l10n.campaignTimeLeft), ], ), const SizedBox(height: AppSpace.s5), CampaignRankHeader( window: RankWindow.live, onTap: () => _openRanking(detail), ), const SizedBox(height: 12), RankPodium(top: sorted.take(3).toList()), for (final r in sorted.skip(3)) RankRow(rank: r), if (detail.report case final report?) ...[ const SizedBox(height: AppSpace.s5), _reportSection(report), ], ], ), ); } /// 결과보고 — 집행 사진 · 영수증 · 예산 사용 내역(FR-CMP-06). 운영이 올린다(B-35). Widget _reportSection(CampaignReport report) { final heading = campaignText(18, 600, AppColors.onCosmic, spacing: 0); final sub = campaignText(14, 600, AppColors.onCosmic, spacing: 0); final text = campaignText(14, 500, AppColors.onCosmic, height: 1.4); Widget photos(List urls, String label) => Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final url in urls) Padding( padding: const EdgeInsets.only(bottom: AppSpace.s2), child: ClipRRect( borderRadius: BorderRadius.circular(AppRadius.sm), child: Image.network( url, fit: BoxFit.cover, semanticLabel: label, errorBuilder: (_, _, _) => const SizedBox( height: 120, child: Center(child: Icon(Icons.broken_image_outlined)), ), ), ), ), ], ); return Column( key: const ValueKey('campaign-report'), crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(context.l10n.campaignReport, style: heading), const SizedBox(height: 4), Text( relativeTime(report.createdAt), style: campaignText(12, 500, AppColors.onCosmicMuted), ), const SizedBox(height: 12), photos(report.photoUrls, context.l10n.campaignReportPhotos), Text(report.body, style: text), const SizedBox(height: AppSpace.s5), Text(context.l10n.campaignBudget, style: sub), const SizedBox(height: AppSpace.s2), for (final line in report.budget) Padding( padding: const EdgeInsets.only(bottom: 4), child: Row( children: [ Expanded(child: Text(line.label, style: text)), Text('${formatPop(line.amount)} POP', style: text), ], ), ), const Divider(), Row( children: [ Expanded(child: Text(context.l10n.campaignTotal, style: sub)), Text('${formatPop(report.budgetTotal)} POP', style: sub), ], ), const SizedBox(height: AppSpace.s5), Text(context.l10n.campaignReceipts, style: sub), const SizedBox(height: AppSpace.s2), photos(report.receiptUrls, context.l10n.campaignReceipts), ], ); } /// 응원 중 · 남은 시간 칸 — #1a1a1c r8 높이 80, 값 20/500 · 라벨 12/500. Widget _stat(String value, String label) => Expanded( child: Container( height: 80, decoration: BoxDecoration( color: AppColors.cosmicSurfaceContainer, borderRadius: BorderRadius.circular(AppRadius.sm), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( value, style: campaignText( 20, 500, AppColors.onCosmic, height: 1.2, spacing: 0, ), ), const SizedBox(height: 4), Text( label, style: campaignText(12, 500, AppColors.onCosmic, spacing: 0), ), ], ), ), ); /// 시안 「6일 23:59:06」 — 일 · 시:분:초. String _left(Duration d) { String two(int v) => v.toString().padLeft(2, '0'); final hms = '${two(d.inHours % 24)}:${two(d.inMinutes % 60)}:${two(d.inSeconds % 60)}'; return d.inDays > 0 ? context.l10n.campaignDaysLeft('${d.inDays}', hms) : hms; } Widget _board() => Padding( padding: const EdgeInsets.all(20), child: switch (_translation) { final t? => TranslationThreads( repository: t, rootPath: _root, builder: (_, byTarget) => _boardBody(t, byTarget), ), null => _boardBody(null, const {}), }, ); /// 댓글(최신순) → 더 보기 → 캠페인 본문의 번역 댓글(시안 `527:21198`). /// 댓글마다 그 댓글의 번역 댓글을 대댓글 자리에 붙인다(라운지와 같은 배치). Widget _boardBody( TranslationRepository? t, Map> byTarget, ) { final own = byTarget[_root] ?? const []; final comments = _comments; if (comments == null) { return const Center(child: CircularProgressIndicator()); } return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (comments.isEmpty && own.isEmpty) Text( context.l10n.commentFirst, textAlign: TextAlign.center, style: campaignText(12, 500, AppColors.onCosmicMuted), ), for (final (i, c) in comments.indexed) ...[ if (i > 0) const Divider(height: AppSpace.s5), _commentCard(c, t), for (final e in byTarget['$_root/comments/${c.id}'] ?? const []) Padding( padding: const EdgeInsets.only(top: AppSpace.s3), child: TranslationCommentCard( entry: e, repository: t!, reply: true, ), ), ], if (_moreComments) TextButton( onPressed: () => _loadComments(more: true), child: Text(context.l10n.commonMore), ), for (final e in own) Padding( padding: const EdgeInsets.only(top: AppSpace.s4), child: TranslationCommentCard(entry: e, repository: t!), ), ], ); } /// 댓글 카드 — 명세 `527:21192` *좋아요 · 대댓글(번역) · 신고/차단*. Widget _commentCard(CampaignComment c, TranslationRepository? t) => Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Flexible( child: Text( c.authorName, overflow: TextOverflow.ellipsis, style: campaignText(12, 500, AppColors.onCosmic), ), ), const SizedBox(width: AppSpace.s2), Text( relativeTime(c.createdAt), style: campaignText(10, 500, AppColors.onCosmicMuted), ), ], ), const SizedBox(height: 4), Text( c.body, style: campaignText(14, 500, AppColors.onCosmic, height: 1.4), ), const SizedBox(height: 6), Row( children: [ InkWell( onTap: () => _likeComment(c), child: Tooltip( message: context.l10n.like, child: Icon( c.liked ? Icons.favorite : Icons.favorite_border, size: 14, color: c.liked ? AppColors.cosmicAccent : AppColors.onCosmicMuted, ), ), ), const SizedBox(width: 4), Text( formatReaction(c.likes), style: campaignText(10, 500, AppColors.onCosmicMuted), ), ], ), ], ), ), if (t != null) IconButton( tooltip: context.l10n.translationWrite, visualDensity: VisualDensity.compact, onPressed: () => setState( () => _replyTo = ( path: '$_root/comments/${c.id}', name: c.authorName, ), ), icon: const ImageIcon( AppIcons.globe, size: 16, color: AppColors.onCosmicMuted, ), ), PopupMenuButton( tooltip: context.l10n.commonMore, padding: EdgeInsets.zero, icon: const ImageIcon( AppIcons.ellipsisVertical, size: 16, color: AppColors.onCosmicMuted, ), onSelected: (v) => _commentAction(c, v), itemBuilder: (_) => c.isMine ? [ PopupMenuItem( value: 'delete', child: Text(context.l10n.commonDeleteAction), ), ] : [ PopupMenuItem( value: 'report', child: Text(context.l10n.reportTitle), ), PopupMenuItem( value: 'block', child: Text(context.l10n.commonBlockAction), ), ], ), ], ); /// 두 번 누르면 취소된다. 명세는 +1 만 적었으나 취소가 없으면 오조작을 /// 되돌릴 수 없다. **저장이 끝난 뒤에만** 숫자를 바꾼다. Future _like() async { final on = !_liked; try { await widget.repository.like(widget.campaignId, on); } on Object catch (error) { _fail(error); return; } if (!mounted) return; setState(() { _liked = on; _likes += on ? 1 : -1; }); } /// B-58 — 공개 전(검수 중)이거나 공개 후 첫 후원 전이면 개설자가 철회할 수 있다. bool _withdrawable(CampaignCardData card) => card.status == CampaignStatus.review || (card.status == CampaignStatus.funding && card.supporters == 0 && _raised == 0); /// 철회 확인 — 등록비 반환 여부를 먼저 알린다(B-58 · 공개 전만 반환). Future _withdraw(CampaignCardData card) async { final beforePublish = card.status == CampaignStatus.review; final go = await showConfirmDialog( context, context.l10n.campaignWithdrawTitle, confirmLabel: context.l10n.campaignWithdrawConfirm, body: beforePublish ? context.l10n.campaignWithdrawRefund( formatPop(campaignRegistrationFeePop), ) : context.l10n.campaignWithdrawNoRefund( formatPop(campaignRegistrationFeePop), ), ); if (!go || !mounted) return; try { await widget.repository.withdraw(card.id); } on Object catch (error) { _fail(error); return; } await _load(); } void _fail(Object error) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( error is BackendException ? error.message : context.l10n.commonRequestFailed, ), ), ); } /// 기여하기 바텀시트 — 명세 `527:21100`. /// /// *기여 POP 설정 영역 - 디폴트 0, **보유 POP 이상 설정 불가** ·* /// *완료 버튼 - 디폴트 비활성화, 기여 POP 설정 후 활성화* Future _contribute(CampaignDetail detail) async { final available = _balance - _spent; final amount = await showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, barrierColor: Colors.black.withValues(alpha: 0.7), builder: (context) => _ContributeSheet(available: available), ); if (amount == null || amount <= 0 || !mounted) return; // **앱이 잔액을 깎지 않는다.** 원장은 fn-campaign-fund 가 쓴다. // 실패하면 숫자를 올리지 않고 사유를 알린다 — 성공처럼 그리지 않는다. final int applied; try { applied = await widget.repository.contribute(detail.card.id, amount); } on Object catch (error) { _fail(error); return; } if (!mounted) return; final before = _raised; setState(() { _spent += applied; _raised += applied; }); // 실시간 랭킹(상위 3) · 모금액을 서버 값으로 다시 읽는다. 잔액 계산(_balance · _spent)은 그대로 둔다. try { final fresh = await widget.repository.detail(widget.campaignId); if (mounted) { setState(() { _detail = fresh; _raised = fresh.card.raisedPop; }); // v1.5 §1 Legendary 「Campaign 100%」 — 서버가 다시 준 모금액으로 이번 후원이 목표를 넘긴 때만 1회. if (campaignJustCompleted(before, fresh.card.raisedPop, fresh.card.goalPop)) fxHaptic(FxIntensity.legendary); } } on Object catch (_) { // 다시 읽기 실패는 후원 결과와 무관하다 — 앞 화면을 그대로 둔다. } } Future _openRanking(CampaignDetail detail) => Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( builder: (_) => CampaignRankingScreen( campaignId: detail.card.id, repository: widget.repository, ), ), ); /// 공유 오버레이 — Figma `480:14594`. /// /// 인스타그램 · 틱톡 · X 는 OS 공유 시트를 띄운다. 앱이 대상별 SDK 를 따로 넣지 않아도 /// 사용자가 설치한 앱 가운데서 고르며, 시안의 버튼 넷은 그대로 남는다. 마지막 「링크 /// 복사」는 **문구 전문을 클립보드에 담는다** — 웹 브릿지 주소가 설정되면 문구 안에 /// `/c/<캠페인>` 링크가 들어간다([campaignShareLink]). /// /// 명세 — *공유 성공 콜백을 받을 경우 +1*. 지금은 세지 않는다. 공유 수를 올리는 /// 것은 `campaigns` 문서의 집계 필드를 건드리는 일이고 규칙이 클라이언트 쓰기를 /// 막아 두었다. 카운터는 캠페인 서버 연결 회차에서 Functions 로 붙는다. Future _share(CampaignCardData card) async { final target = await CampaignShareDialog.show( context, card: card, raised: _raised, ); switch (target) { case null: return; case CampaignShareTarget.link: await _copyText(card); default: await _shareText(card); } } String _shareBody(CampaignCardData card) => campaignShareText( title: card.title, summary: card.summary.isEmpty ? card.body : card.summary, fandomName: card.fandomName, link: campaignShareLink(card.id), ); Future _shareText(CampaignCardData card) async { await SharePlus.instance.share( ShareParams(text: _shareBody(card), subject: card.title), ); } Future _copyText(CampaignCardData card) async { await Clipboard.setData(ClipboardData(text: _shareBody(card))); if (mounted) _toast(context.l10n.shareCopied); } /// 캠페인 신고(`reports/campaign-{캠페인}-{신고자}`) · 개설자 차단. /// 차단하면 그 개설자의 캠페인 · 댓글이 빠지므로 상세를 닫는다. Future _moderate(String action, CampaignCardData card) async { try { if (action == 'report') { final reason = await Navigator.of(context, rootNavigator: true) .push( MaterialPageRoute( builder: (_) => ReportScreen( target: ModerationTarget( authorName: card.authorName, title: card.title, ), ), ), ); if (reason == null) return; await widget.repository.reportCampaign(card.id, reason); if (mounted) _toast(context.l10n.reportReceived); return; } if (!await confirmBlock(context, card.authorName)) return; await widget.repository.blockCreator(card); if (!mounted) return; _toast(context.l10n.blocked); Navigator.pop(context); } on Object catch (error) { _fail(error); } } void _toast(String message) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), ); } } /// 기여 POP 설정 — 명세 `527:21100`. class _ContributeSheet extends StatefulWidget { const _ContributeSheet({required this.available}); final int available; @override State<_ContributeSheet> createState() => _ContributeSheetState(); } class _ContributeSheetState extends State<_ContributeSheet> { double _amount = 0; // 시안 527:21100 — #1a1a1c r12 카드(안쪽 20 · 간격 14) + 8 + 완료 56(비활성 #333 · #777). @override Widget build(BuildContext context) { final max = widget.available.toDouble(); final axis = campaignText( 12, 400, const Color(0xFFCDCDCD), height: 1, spacing: 0, family: AppFonts.display, ); final ready = _amount >= 1; return SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: AppColors.cosmicSurfaceContainer, borderRadius: BorderRadius.circular(AppRadius.md), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( context.l10n.campaignContributeAction, textAlign: TextAlign.center, style: campaignText( 18, 500, AppColors.onCosmic, height: 1.4, spacing: 0, ), ), const SizedBox(height: 14), Text( context.l10n.campaignContributePop, style: campaignText( 14, 600, AppColors.onCosmic, spacing: 0, ), ), const SizedBox(height: 14), Container( height: 77, alignment: Alignment.center, decoration: BoxDecoration( color: AppColors.cosmicVoid, borderRadius: BorderRadius.circular(AppRadius.sm), ), child: Text( formatPop(_amount.round()), style: campaignText( 32, 400, AppColors.onCosmic, height: 1.4, family: AppFonts.display, ), ), ), const SizedBox(height: 18), SliderTheme( data: SliderThemeData( trackHeight: 6, padding: EdgeInsets.zero, activeTrackColor: AppColors.cosmicTeal, inactiveTrackColor: const Color(0xFF424242), thumbColor: const Color(0xFF52A0C4), overlayShape: SliderComponentShape.noOverlay, thumbShape: const RoundSliderThumbShape( enabledThumbRadius: 10, ), ), child: SizedBox( height: 20, child: Slider( value: _amount.clamp(0, max), // 보유 POP 이상은 아예 끌 수 없다. max: max <= 0 ? 1 : max, onChanged: max <= 0 ? null : (v) => setState(() => _amount = v.roundToDouble()), ), ), ), const SizedBox(height: 6), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('0', style: axis), Text(formatPop(widget.available), style: axis), ], ), const SizedBox(height: 18), Padding( padding: const EdgeInsets.symmetric(horizontal: 2), child: Text.rich( TextSpan( text: context.l10n.commonMyPop, children: [ TextSpan( text: formatPop(widget.available), style: const TextStyle(color: AppColors.onCosmic), ), ], ), style: campaignText( 14, 500, AppColors.onCosmicVariant, height: 1, ), ), ), ], ), ), const SizedBox(height: 8), FilledButton( // 디폴트 비활성화 · 설정 후 활성화. onPressed: ready ? () => Navigator.of(context).pop(_amount.round()) : null, style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(56), backgroundColor: AppColors.onCosmic, foregroundColor: Colors.black, disabledBackgroundColor: const Color(0xFF333333), disabledForegroundColor: const Color(0xFF777777), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.sm), ), textStyle: campaignText( 16, 600, Colors.black, height: 1.5, spacing: 0, ), ), child: Text(context.l10n.commonDone), ), ], ), ), ); } } /// 대표 이미지 위 원형 버튼 — 42 · #000 60%. class _Round extends StatelessWidget { const _Round({required this.child}); final Widget child; @override Widget build(BuildContext context) => Center( child: Container( width: 42, height: 42, decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.6), shape: BoxShape.circle, ), child: Center(child: child), ), ); } /// 이번 후원 전 모금액 [before] 가 목표 [goal] 미만이었고 서버 확정 모금액 [raised] 가 목표에 닿았는가. bool campaignJustCompleted(int before, int raised, int goal) => goal > 0 && before < goal && raised >= goal;