Files
fanuzu/app/lib/features/campaign/campaign_detail_screen.dart
T

1304 lines
44 KiB
Dart

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';
/// 캠페인 상세 — 화면 대조 원장 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<CampaignDetailScreen> createState() => _CampaignDetailScreenState();
}
class _CampaignDetailScreenState extends State<CampaignDetailScreen> {
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<CampaignComment>? _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<void> _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<void> _loadComments({bool more = false}) async {
final List<CampaignComment> 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<bool> _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<void> _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 <CampaignComment>[])
x == c ? next : x,
],
);
}
/// 댓글 카드의 더보기 — 내 댓글은 삭제, 남의 댓글은 신고 · 차단(명세 `527:21192`).
Future<void> _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<String>(
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 <CampaignComment>[])
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<String>(
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<void>(
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<String> 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<String, List<TranslationEntry>> byTarget,
) {
final own = byTarget[_root] ?? const <TranslationEntry>[];
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<String>(
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<void> _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<void> _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<void> _contribute(CampaignDetail detail) async {
final available = _balance - _spent;
final amount = await showModalBottomSheet<int>(
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;
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;
});
}
} on Object catch (_) {
// 다시 읽기 실패는 후원 결과와 무관하다 — 앞 화면을 그대로 둔다.
}
}
Future<void> _openRanking(CampaignDetail detail) =>
Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => CampaignRankingScreen(
campaignId: detail.card.id,
repository: widget.repository,
),
),
);
/// 공유 오버레이 — Figma `480:14594`.
///
/// 인스타그램 · 틱톡 · X 는 OS 공유 시트를 띄운다. 앱이 대상별 SDK 를 따로 넣지 않아도
/// 사용자가 설치한 앱 가운데서 고르며, 시안의 버튼 넷은 그대로 남는다. 마지막 「링크
/// 복사」는 **문구 전문을 클립보드에 담는다** — 웹 브릿지 주소가 설정되면 문구 안에
/// `/c/<캠페인>` 링크가 들어간다([campaignShareLink]).
///
/// 명세 — *공유 성공 콜백을 받을 경우 +1*. 지금은 세지 않는다. 공유 수를 올리는
/// 것은 `campaigns` 문서의 집계 필드를 건드리는 일이고 규칙이 클라이언트 쓰기를
/// 막아 두었다. 카운터는 캠페인 서버 연결 회차에서 Functions 로 붙는다.
Future<void> _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<void> _shareText(CampaignCardData card) async {
await SharePlus.instance.share(
ShareParams(text: _shareBody(card), subject: card.title),
);
}
Future<void> _copyText(CampaignCardData card) async {
await Clipboard.setData(ClipboardData(text: _shareBody(card)));
if (mounted) _toast(context.l10n.shareCopied);
}
/// 캠페인 신고(`reports/campaign-{캠페인}-{신고자}`) · 개설자 차단.
/// 차단하면 그 개설자의 캠페인 · 댓글이 빠지므로 상세를 닫는다.
Future<void> _moderate(String action, CampaignCardData card) async {
try {
if (action == 'report') {
final reason = await Navigator.of(context, rootNavigator: true)
.push<String>(
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),
),
);
}