1312 lines
45 KiB
Dart
1312 lines
45 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';
|
|
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<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;
|
|
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<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),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 이번 후원 전 모금액 [before] 가 목표 [goal] 미만이었고 서버 확정 모금액 [raised] 가 목표에 닿았는가.
|
|
bool campaignJustCompleted(int before, int raised, int goal) =>
|
|
goal > 0 && before < goal && raised >= goal;
|