QUEST · CAMPAIGN 화면 — 퀘스트 참여·생성·인증 · 캠페인 목록·상세·후원·랭킹

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent 5de04ac281
commit 18afc9cb98
25 changed files with 9368 additions and 0 deletions
@@ -0,0 +1,529 @@
import 'package:flutter/material.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/mock_tag.dart';
import '../home/home_screen.dart' show formatPop;
import 'campaign_data.dart';
import 'widgets/campaign_period_sheet.dart';
import 'widgets/campaign_style.dart';
/// 새 캠페인 — 화면 대조 원장 F5 · Figma `287:16991`.
///
/// 명세 `523:18182`(완료 보상 설정 영역) 계열과 같은 구조다 — 입력이 다 차야
/// 완료가 열리고, 완료 전에 되묻는다.
///
/// **AI 사전 검수를 여기서 하지 않는다.** `fn-campaign-ai-precheck` 는 7주차이고,
/// 올리면 등록비(B-57)가 빠지고 운영 심사 대기(`review`)로 들어간다(`fn-campaign-create`).
/// 화면에 그 사실을 적어 둔다 — 올린 즉시 모금이 시작되는 줄 알면 사용자가 마감을 잘못
/// 계산한다.
class CampaignCreateScreen extends StatefulWidget {
CampaignCreateScreen({super.key, CampaignRepository? repository})
: repository = repository ?? SampleCampaignRepository();
final CampaignRepository repository;
@override
State<CampaignCreateScreen> createState() => _CampaignCreateScreenState();
}
class _CampaignCreateScreenState extends State<CampaignCreateScreen> {
final _title = TextEditingController();
final _body = TextEditingController();
/// 지원 종류(Catalog 상품). 고르면 목표 기여 POP · 권장 기간이 정해진다(B-59 · C-14).
CampaignProduct? _product;
/// 모금 기간. 상품을 고르면 오늘부터 권장 일수로 채워지고, 날짜 선택으로 바꾼다.
DateTimeRange? _range;
bool _saving = false;
/// 등록비에 쓸 수 있는 잔액(일반 + 기여, B-57). 읽지 못하면 줄을 그리지 않는다.
int? _balance;
@override
void initState() {
super.initState();
widget.repository.feeBalance().then((v) {
if (mounted) setState(() => _balance = v);
}, onError: (Object _) {});
for (final c in [_title, _body]) {
c.addListener(() => setState(() {}));
}
}
@override
void dispose() {
for (final c in [_title, _body]) {
c.dispose();
}
super.dispose();
}
int get _goalPop => _product?.goalPop ?? 0;
int get _days => _range?.duration.inDays ?? 0;
bool get _ready =>
_title.text.trim().isNotEmpty &&
_body.text.trim().isNotEmpty &&
_product != null &&
_days > 0;
/// 사본 척도 — 목표 막대의 오른쪽 끝 「1,000,000」.
static const _scaleMax = 1000000;
void _pickProduct(CampaignProduct p) {
final today = DateUtils.dateOnly(DateTime.now());
setState(() {
_product = p;
_range = DateTimeRange(
start: today,
end: today.add(Duration(days: p.recommendedDays)),
);
});
}
// 시안 캘린더 시트(`333:72497` · `333:72719`) — 「기간은 최대 1년까지」.
Future<void> _pickRange() async {
final r = await showCampaignPeriodSheet(context, initial: _range);
if (r != null && mounted) setState(() => _range = r);
}
/// 「2026.4.9 ~ 4.23」 — 해가 바뀌면 끝에도 연도를 붙인다.
static String _rangeLabel(DateTimeRange r) {
final s = r.start, e = r.end;
final end = e.year == s.year
? '${e.month}.${e.day}'
: '${e.year}.${e.month}.${e.day}';
return '${s.year}.${s.month}.${s.day} ~ $end';
}
@override
Widget build(BuildContext context) {
const line = Color(0xFF313131);
const hint = Color(0xFF6B6B6B);
final section = campaignText(
20,
500,
AppColors.onCosmic,
height: 28 / 20,
spacing: -0.025,
);
// 시안(287:16991) — 닫기 · 제목 · 완료(비활성 #474747), 미디어 칸 #222 200, 제목(20/600)과
// 설명(15/500) 입력, 섹션 제목 20/500, 목표 기여 POP 초록 값 · 보유 POP.
return Scaffold(
appBar: AppBar(
leading: IconButton(
tooltip: context.l10n.commonClose,
icon: const ImageIcon(AppIcons.x, size: 24),
onPressed: () => Navigator.of(context).pop(),
),
title: Text(context.l10n.campaignNew),
actions: [
// G-08 — 지원 종류 가격은 인스타 광고 300,000 외 자리표시다.
const MockTag(note: '상품 목록은 Catalog 연결 전 표본입니다'),
TextButton(
onPressed: _ready && !_saving ? _confirm : null,
// 시안 — 오른쪽 끝에서 16(x 335).
style: TextButton.styleFrom(
foregroundColor: AppColors.onCosmic,
disabledForegroundColor: const Color(0xFF474747),
textStyle: campaignText(14, 600, AppColors.onCosmic, height: 1),
padding: EdgeInsets.zero,
minimumSize: const Size(24, 56),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(context.l10n.commonDone),
),
const SizedBox(width: 16),
],
),
body: ListView(
padding: const EdgeInsets.only(bottom: AppSpace.s4),
children: [
// 사진·영상 등록은 캠페인 문서에 필드가 없어 아직 연결하지 않는다(시안 자리만).
Container(
height: 200,
color: const Color(0xFF222222),
alignment: Alignment.center,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF19191C),
borderRadius: BorderRadius.circular(AppRadius.xs),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const ImageIcon(
AssetImage('assets/figma/campaign-camera.png'),
size: 16,
color: Color(0xFF979B9E),
),
const SizedBox(width: 6),
Text(
context.l10n.campaignPickMedia,
style: campaignText(
12,
400,
const Color(0xFF979B9E),
height: 1.4,
spacing: 0,
family: AppFonts.display,
),
),
],
),
),
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _title,
style: campaignText(20, 600, AppColors.onCosmic, height: 1),
decoration: _bare(
context.l10n.campaignTitleHint,
campaignText(20, 600, hint, height: 1),
),
),
const SizedBox(height: 12),
const Divider(height: 1, thickness: 1, color: line),
const SizedBox(height: 12),
// 시안 설명 칸 180 안에 검수 안내 한 줄까지 넣는다(아래 간격 24 유지).
SizedBox(
height: 163,
child: TextField(
controller: _body,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: campaignText(
15,
500,
AppColors.onCosmic,
height: 1.4,
),
decoration: _bare(
context.l10n.campaignBodyHint,
campaignText(15, 500, hint, height: 1),
),
),
),
Text(
context.l10n.campaignReviewNote,
style: campaignText(
12,
500,
AppColors.onCosmicVariant,
height: 17 / 12,
),
),
const SizedBox(height: 24),
Text(context.l10n.campaignSupportType, style: section),
const SizedBox(height: 20),
],
),
),
// 시안 — 칩 줄은 왼쪽 16 에서 시작해 오른쪽 화면 밖으로 이어진다.
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
for (final p in sampleCampaignCatalog) ...[
_productChip(p),
if (p != sampleCampaignCatalog.last) const SizedBox(width: 8),
],
],
),
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Text(context.l10n.campaignPeriod, style: section),
),
const SizedBox(height: 12),
// 시안 Input — 획 #545454 r8 · 안쪽 16 · 「날짜 선택」 ABeeZee 16 · 달력 20.
InkWell(
onTap: _pickRange,
borderRadius: BorderRadius.circular(AppRadius.sm),
child: Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFF545454)),
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Row(
children: [
Expanded(
child: Text(
_range == null
? context.l10n.campaignDateSelect
: _rangeLabel(_range!),
style: campaignText(
16,
400,
_range == null
? const Color(0xFF545454)
: AppColors.onCosmic,
height: 24 / 16,
spacing: 0,
family: AppFonts.display,
),
),
),
ImageIcon(
const AssetImage(
'assets/figma/campaign-calendar.png',
),
size: 20,
color: _range == null
? const Color(0xFF545454)
: AppColors.onCosmic,
),
],
),
),
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Row(
children: [
Expanded(
child: Text(
context.l10n.campaignGoalPop,
style: section,
),
),
Text(
'${formatPop(_goalPop)} POP',
style: campaignText(
18,
500,
const Color(0xFF2AC28C),
height: 1,
),
),
],
),
),
const SizedBox(height: 12),
// 시안 슬라이더 자리 — 목표는 상품 등록값 그대로라(B-32) 읽기 전용 막대로 둔다.
_goalBar(_goalPop / _scaleMax),
const SizedBox(height: 6),
Padding(
padding: const EdgeInsets.only(right: 5),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('0', style: _scale),
Text(formatPop(_scaleMax), style: _scale),
],
),
),
const SizedBox(height: 16),
if (_balance != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Text.rich(
TextSpan(
text: context.l10n.commonMyPop,
children: [
TextSpan(
text: formatPop(_balance!),
style: const TextStyle(color: AppColors.onCosmic),
),
],
),
style: campaignText(
14,
500,
AppColors.onCosmicVariant,
height: 1,
),
),
),
],
),
),
],
),
);
}
InputDecoration _bare(String hint, TextStyle style) => InputDecoration(
hintText: hint,
hintStyle: style,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
filled: false,
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 6),
);
static final _scale = campaignText(
12,
400,
const Color(0xFFCDCDCD),
height: 1,
spacing: 0,
family: AppFonts.display,
);
/// 지원 종류 칩 — 시안 Chip: 알약 #1a1a1a · 획 #232324 · 안쪽 6/16 · 12/500 #d5d7d9.
/// 고른 칩은 흰 바탕 검정 글자(`333:73054`).
Widget _productChip(CampaignProduct p) {
final on = _product == p;
return Material(
color: on ? AppColors.onCosmic : const Color(0xFF1A1A1A),
shape: const StadiumBorder(side: BorderSide(color: Color(0xFF232324))),
child: InkWell(
onTap: () => _pickProduct(p),
customBorder: const StadiumBorder(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Text(
p.name,
style: campaignText(
12,
500,
on ? Colors.black : const Color(0xFFD5D7D9),
height: 20 / 12,
spacing: 0,
),
),
),
),
);
}
/// 목표 막대 — 시안 `Group 568`: #424242 6 막대 · #2ac28c 채움 · 손잡이 20.
Widget _goalBar(double fraction) => Container(
height: 20,
margin: const EdgeInsets.only(top: 4),
child: LayoutBuilder(
builder: (context, c) {
final f = fraction.clamp(0.0, 1.0);
final w = c.maxWidth - 5;
return Stack(
alignment: Alignment.centerLeft,
children: [
Container(
width: w,
height: 6,
decoration: BoxDecoration(
color: const Color(0xFF424242),
borderRadius: BorderRadius.circular(AppRadius.full),
),
),
Container(
width: (w - 20) * f + 10,
height: 6,
decoration: BoxDecoration(
color: const Color(0xFF2AC28C),
borderRadius: BorderRadius.circular(AppRadius.full),
),
),
Positioned(
left: (w - 20) * f,
child: Container(
width: 20,
height: 20,
decoration: const BoxDecoration(
color: Color(0xFF2AC28C),
shape: BoxShape.circle,
),
),
),
],
);
},
),
);
Future<void> _confirm() async {
final go = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.campaignSubmitTitle),
content: Text(
context.l10n.campaignSubmitBody(
_title.text.trim(),
formatPop(_goalPop),
'$_days',
formatPop(campaignRegistrationFeePop),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(context.l10n.commonKeepEditing),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(context.l10n.campaignSubmit),
),
],
),
);
if (go != true || !mounted) return;
setState(() => _saving = true);
final String id;
try {
id = await widget.repository.create(
CampaignDraft(
title: _title.text.trim(),
body: _body.text.trim(),
goalPop: _goalPop,
days: _days,
),
);
} on Object catch (error) {
if (!mounted) return;
setState(() => _saving = false);
// 자격 · 잔액 부족은 서버 사유를 그대로 보여 준다(E-PERM-01 · E-POP-01).
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
error is BackendException
? error.message
: context.l10n.campaignCreateFailed,
),
),
);
return;
}
if (!mounted) return;
// 올린 캠페인은 심사 전이라 목록에 보이지 않는다 — 어디 갔는지 알려 준다.
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.campaignPendingTitle),
content: Text(context.l10n.campaignPendingBody),
actions: [
FilledButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(context.l10n.commonConfirm),
),
],
),
);
if (mounted) Navigator.of(context).pop(id);
}
}
@@ -0,0 +1,622 @@
/// 캠페인이 그리는 값의 형태 — 화면 대조 원장 F5.
///
/// 근거는 Figma 명세 구간이다.
/// - 목록 `523:18347`·`523:18388`·`523:18399` · 검색 `523:18417`~`523:18429`
/// - 상세 `523:18487`~`523:18589` · 기여 바텀시트 `527:21100`
/// - 랭킹 `536:14345`~`536:14382` · 댓글 `527:21192`·`527:21198`
library;
import '../../core/i18n/app_locale.dart';
import '../my/my_data.dart';
/// 목록 정렬. 명세 — *디폴트 최신순 선택 · 단일 선택*.
enum CampaignSort {
latest,
popular,
contributing;
String get label => switch (this) {
latest => appL10n.sortLatest,
popular => appL10n.sortPopular,
contributing => appL10n.campaignSortContributing,
};
}
/// 캠페인 진행 상태. 정책 설계서 §P-09 의 상태 전이를 따른다.
enum CampaignStatus {
/// 모금 중.
funding,
/// 목표 달성 후 집행.
success,
/// 목표 미달 — 100% 자동 환불 대상.
failed,
/// 결과 보고까지 끝남.
reported,
/// 공개 전 — 초안 · 운영 검수 대기. 개설자에게만 보인다.
review,
/// 운영 반려 — 등록비 반환(B-58).
rejected,
/// 개설자 철회(B-58). 공개 전 철회만 등록비를 돌려받는다.
withdrawn,
/// 운영 취소(회사귀책, B-58) — 등록비 · 후원 전액 반환.
cancelled;
/// 공개 모금 흐름 밖의 상태 안내. 모금 · 결과 상태는 null.
String? get notice => switch (this) {
review => appL10n.campaignStatusReview,
rejected => appL10n.campaignStatusRejected,
withdrawn => appL10n.campaignStatusWithdrawn,
cancelled => appL10n.campaignStatusCancelled,
_ => null,
};
}
/// 목록 카드 하나.
///
/// 명세 `523:18399` — *대표 이미지 · 작성자 · 작성일시 · 달성률(%) · 제목 ·*
/// *내용 · 좋아요 · 댓글 · 조회수 · 공유 수*
class CampaignCardData {
const CampaignCardData({
required this.id,
required this.title,
required this.body,
required this.authorName,
this.summary = '',
this.fandomId = '',
this.fandomName = '',
required this.createdAt,
required this.status,
required this.goalPop,
required this.raisedPop,
required this.supporters,
required this.likes,
required this.comments,
required this.views,
required this.shares,
this.mine = false,
this.contributedPop = 0,
this.creatorUid,
});
final String id;
/// 개설자 UID — 캠페인 신고 · 개설자 차단의 대상. 없으면 차단할 수 없다.
final String? creatorUid;
final String title;
/// 한 줄 소개 — 서버 문서의 `summary`. 공유 문구가 이 값을 쓴다.
final String summary;
final String body;
final String authorName;
/// 소속 팬덤 — 서버 문서의 `fandomId`.
final String fandomId;
/// 행성 이름.
///
/// **캠페인 문서의 필드가 아니다.** `firestore.rules` 의 `campaigns` 생성 규칙이
/// `keys().hasOnly([...])` 로 스키마를 닫아 두었고 그 목록에 `fandomName` 이
/// 없다. 비정규화 필드를 넣으면 캠페인 생성 자체가 거부된다. 그래서 이 값은
/// `fandoms/{fandomId}.name` 을 읽어 채우는 **파생값**이고, 해석은 캠페인 서버
/// 연결 회차가 맡는다. 비어 있으면 공유 문구에서 행성 줄과 팬덤 해시태그가 함께
/// 빠진다(정책 원장 B-78 — 고정 3종이 아니다).
final String fandomName;
final DateTime createdAt;
final CampaignStatus status;
final int goalPop;
final int raisedPop;
final int supporters;
final int likes;
final int comments;
final int views;
final int shares;
/// 내가 만든 캠페인인가. 명세 — *기여하기 버튼은 타인 캠페인에서만 표시*.
final bool mine;
/// 내가 이미 기여한 POP. 「기여중」 필터의 기준이다.
final int contributedPop;
/// 달성률(%). 목표가 0이면 0.
int get achievedPercent =>
goalPop == 0 ? 0 : ((raisedPop / goalPop) * 100).round();
/// 인기순 정렬 키. 명세 — *좋아요 > 댓글 > 조회수 > 공유수*.
/// 앞의 값이 같을 때만 뒤를 본다는 뜻이라 순서대로 비교한다.
int comparePopularity(CampaignCardData other) {
for (final pick in <int Function(CampaignCardData)>[
(c) => c.likes,
(c) => c.comments,
(c) => c.views,
(c) => c.shares,
]) {
final d = pick(other).compareTo(pick(this));
if (d != 0) return d;
}
return 0;
}
}
/// 기여 랭킹 한 줄.
class ContributorRank {
const ContributorRank({
required this.rank,
required this.name,
required this.pop,
this.change = 0,
this.isMe = false,
});
final int rank;
final String name;
final int pop;
/// 순위 변동값. 양수면 올라갔다.
final int change;
final bool isMe;
}
/// 랭킹 집계 구간. 명세 `536:14382` — *실시간 · 일간 · 주간*.
enum RankWindow {
live,
daily,
weekly;
String get label => switch (this) {
live => appL10n.rankScopeLive,
daily => appL10n.rankWindowDaily,
weekly => appL10n.rankWindowWeekly,
};
}
/// 응원 게시판 댓글 한 장의 크기. 이만큼 오면 「더 보기」가 붙는다.
const campaignCommentPageSize = 20;
/// 응원 게시판 댓글 — 서버 `campaigns/{id}/comments/{cid}` .
class CampaignComment {
const CampaignComment({
required this.id,
required this.authorName,
required this.body,
required this.createdAt,
this.authorUid,
this.isMine = false,
this.likes = 0,
this.liked = false,
});
final String id;
/// 공개 닉네임(없으면 「팬」). 공급자 실명은 쓰지 않는다(M-7).
final String authorName;
final String body;
final DateTime createdAt;
/// 차단 대상. 없으면 차단할 수 없다.
final String? authorUid;
final bool isMine;
/// 좋아요 수(서버 트리거가 센 `likeCount`)와 내가 눌렀는가.
final int likes;
final bool liked;
}
/// 상세 화면이 필요로 하는 것.
class CampaignDetail {
const CampaignDetail({
required this.card,
required this.mediaLabels,
required this.endsAt,
required this.topContributors,
this.liked = false,
this.report,
});
final CampaignCardData card;
/// 결과보고. 게시 전이면 null.
final CampaignReport? report;
/// 내가 이미 좋아요를 눌렀는가. 서버에 저장된 상태에서 시작해야 두 번째 탭이 취소가 된다.
final bool liked;
/// 사진·영상. 자산이 아직 없어 라벨로 둔다. 개수가 인디케이터 수를 정한다.
final List<String> mediaLabels;
final DateTime endsAt;
final List<ContributorRank> topContributors;
/// 남은 시간. 지났으면 [Duration.zero].
Duration remaining({DateTime? now}) {
final gap = endsAt.difference(now ?? DateTime.now());
return gap.isNegative ? Duration.zero : gap;
}
}
/// 결과보고 — 요구사항 FR-CMP-06 「집행 사진 · 영수증 · 예산 사용 내역 공개」.
/// 서버 문서 `campaigns/{id}/reports/main`. 올리는 쪽은 운영이다(원장 B-35).
class CampaignReport {
const CampaignReport({
required this.body,
required this.photoUrls,
required this.receiptUrls,
required this.budget,
required this.createdAt,
});
final String body;
final List<String> photoUrls;
final List<String> receiptUrls;
final List<({String label, int amount})> budget;
final DateTime createdAt;
int get budgetTotal => budget.fold(0, (sum, l) => sum + l.amount);
}
/// 사용자 캠페인 등록비 — 원장 B-57(🟡 운영값). 서버 `fn-campaign-create` 가 실제로 뺀다.
const int campaignRegistrationFeePop = 100;
/// Campaign Product Catalog 상품 하나 — 원장 B-59(상품 · 필요 기여 POP) · C-14(권장 모금 기간).
class CampaignProduct {
const CampaignProduct(this.name, this.goalPop, this.recommendedDays);
final String name;
/// 필요 기여 POP. 목표액은 이 값 그대로다(B-32 — 사용자 임의 입력 없음).
final int goalPop;
/// 권장 모금 기간(일). 날짜 선택에 자동으로 들어간다(C-14).
final int recommendedDays;
}
/// MOCK: Catalog 가 앱에 아직 연결되지 않아 사본(`287:16991`) 칩 문구와 표본 수치를 쓴다.
/// 인스타 광고 300,000 만 사본 값이고 나머지 POP · 일수는 S03 제안표 확정 전 자리표시다.
const sampleCampaignCatalog = [
CampaignProduct('인스타 광고', 300000, 14),
CampaignProduct('지하철 CM보드', 800000, 30),
CampaignProduct('커피차 서포트', 500000, 14),
CampaignProduct('커스텀 인이어', 200000, 21),
];
/// 새 캠페인이 만드는 값.
class CampaignDraft {
const CampaignDraft({
required this.title,
required this.body,
required this.goalPop,
required this.days,
});
final String title;
final String body;
final int goalPop;
final int days;
}
abstract interface class CampaignRepository {
/// 명세 — *기본 10개 노출 · 아래 스크롤 시 10개 업데이트*.
Future<List<CampaignCardData>> load({
CampaignSort sort = CampaignSort.latest,
int limit = 10,
});
Future<List<CampaignCardData>> more({required int offset, int limit = 10});
Future<CampaignDetail> detail(String campaignId);
Future<List<ContributorRank>> ranking(
String campaignId, {
RankWindow window = RankWindow.live,
});
Future<List<String>> recentQueries();
Future<void> clearQueries();
/// 최근 검색어 한 개 지우기 — 시안 `272:241681` 검색어 칩의 ✕.
Future<void> removeQuery(String query);
Future<List<CampaignCardData>> search(String query);
/// 기여. **잔액은 서버가 깎는다** — `fn-campaign-fund` 가 원장을 지난다.
/// 실패하면 던진다 — 화면은 성공으로 그리지 않는다.
Future<int> contribute(String campaignId, int amountPop);
/// 후원에 쓸 수 있는 잔액 — 기여 POP(정책 원장 B-33).
Future<int> balance();
/// 등록비에 쓸 수 있는 잔액 — 일반 + 기여 POP. 등록비는 겸용이다(B-57 · B-06).
Future<int> feeBalance();
/// 좋아요 저장. 실패하면 던진다.
Future<void> like(String campaignId, bool on);
Future<String> create(CampaignDraft draft);
/// 내가 만든 캠페인 — 공개 전 · 철회 · 취소 상태까지(MY/내 캠페인).
Future<List<CampaignCardData>> mine();
/// 개설자 철회(`fn-campaign-withdraw` · B-58). 실패하면 던진다.
Future<void> withdraw(String campaignId);
/// 응원 게시판 댓글 한 장 — 최신순(명세 `527:21192`). [more] 가 false 면 첫
/// 장부터 다시 읽는다. 차단한 작성자의 댓글은 뺀다.
Future<List<CampaignComment>> comments(
String campaignId, {
bool more = false,
});
/// 댓글 저장. 실패하면 던진다.
Future<CampaignComment> addComment(String campaignId, String body);
Future<void> deleteComment(String campaignId, String commentId);
/// 신고 — [reason] 은 신고 화면의 사유 문구(`reportReasonCodes` 의 키).
Future<void> reportComment(
String campaignId,
CampaignComment comment,
String reason,
);
/// 작성자 차단 — 이후 그 작성자의 댓글은 목록에서 빠진다.
Future<void> blockAuthor(CampaignComment comment);
/// 댓글 좋아요 저장 — 1인 1회. 실패하면 던진다.
Future<void> likeComment(String campaignId, String commentId, bool on);
/// 캠페인 신고 — [reason] 은 신고 화면의 사유 문구. 같은 사람의 재신고는 던진다.
Future<void> reportCampaign(String campaignId, String reason);
/// 개설자 차단 — 이후 그 개설자의 캠페인 · 댓글이 목록에서 빠진다.
Future<void> blockCreator(CampaignCardData card);
/// 댓글 작성 자격 — 공개 닉네임이 있는가(비었거나 이메일 형태면 없음). 규칙도
/// 응원 게시판 댓글에는 「팬」 대체값을 받지 않는다.
Future<bool> hasPublicNickname();
/// 닉네임을 정하러 갈 MY 저장소(닉네임 수정 화면이 쓴다).
MyRepository get my;
}
/// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.**
class SampleCampaignRepository implements CampaignRepository {
SampleCampaignRepository();
static final _base = DateTime(2026, 9, 1, 12);
final List<String> _queries = ['강남역', 'LED', '커피차'];
static final List<CampaignCardData> _cards = [
CampaignCardData(
id: 'c1',
summary: '도심 한가운데에서 함께 축하해요.',
fandomId: 'bts',
fandomName: 'BTS',
title: '강남역 초대형 LED 광고',
body: 'BTS 데뷔 기념 팬 연합의 초대형 프로젝트입니다. 도심을 BTS의 색으로 물들여주세요.',
authorName: '다이브짱',
createdAt: _base.subtract(const Duration(hours: 3)),
status: CampaignStatus.funding,
goalPop: 100000,
raisedPop: 92000,
supporters: 48,
likes: 820,
comments: 24,
views: 12400,
shares: 96,
contributedPop: 500,
),
CampaignCardData(
id: 'c2',
summary: '무대 뒤에서 고생하는 분들께 시원한 한 잔.',
fandomId: 'bts',
fandomName: 'BTS',
title: '뮤직뱅크 커피차 보내기',
body: '무더위에 고생하는 스태프와 아티스트를 위해 커피차를 준비합니다.',
authorName: '아미보이',
createdAt: _base.subtract(const Duration(days: 2)),
status: CampaignStatus.funding,
goalPop: 50000,
raisedPop: 12000,
supporters: 21,
likes: 340,
comments: 12,
views: 5200,
shares: 18,
),
CampaignCardData(
id: 'c3',
summary: '데뷔일을 2호선 전 역에서.',
fandomId: 'newjeans',
fandomName: 'NewJeans',
title: '지하철 생일 광고',
body: '데뷔일에 맞춰 2호선 전 역사에 생일 광고를 겁니다.',
authorName: '네누',
createdAt: _base.subtract(const Duration(days: 12)),
status: CampaignStatus.success,
goalPop: 30000,
raisedPop: 30000,
supporters: 64,
likes: 1240,
comments: 51,
views: 21000,
shares: 210,
mine: true,
),
];
@override
Future<List<CampaignCardData>> load({
CampaignSort sort = CampaignSort.latest,
int limit = 10,
}) async {
final list = [..._cards];
switch (sort) {
case CampaignSort.latest:
list.sort((a, b) => b.createdAt.compareTo(a.createdAt));
case CampaignSort.popular:
list.sort((a, b) => a.comparePopularity(b));
case CampaignSort.contributing:
list.retainWhere((c) => c.contributedPop > 0);
}
return list.take(limit).toList();
}
@override
Future<List<CampaignCardData>> more({
required int offset,
int limit = 10,
}) async => _cards.skip(offset).take(limit).toList();
@override
Future<CampaignDetail> detail(String campaignId) async {
final card = _cards.firstWhere(
(c) => c.id == campaignId,
orElse: () => _cards.first,
);
return CampaignDetail(
card: card,
mediaLabels: const ['대표 이미지', '조감도', '위치'],
endsAt: _base.add(const Duration(days: 3)),
topContributors: const [
ContributorRank(rank: 1, name: '버니즈', pop: 12000, change: 2),
ContributorRank(rank: 2, name: '캐럿', pop: 9800, change: -1),
ContributorRank(rank: 3, name: '나', pop: 500, change: 4, isMe: true),
],
);
}
@override
Future<List<ContributorRank>> ranking(
String campaignId, {
RankWindow window = RankWindow.live,
}) async => const [
ContributorRank(rank: 1, name: '버니즈', pop: 12000, change: 2),
ContributorRank(rank: 2, name: '캐럿', pop: 9800, change: -1),
ContributorRank(rank: 3, name: '나', pop: 500, change: 4, isMe: true),
ContributorRank(rank: 4, name: '무민', pop: 400),
ContributorRank(rank: 5, name: '보라', pop: 320),
];
@override
Future<List<String>> recentQueries() async => [..._queries];
@override
Future<void> clearQueries() async => _queries.clear();
@override
Future<void> removeQuery(String query) async => _queries.remove(query);
@override
Future<List<CampaignCardData>> search(String query) async {
// 검색어를 최신순 · 중복 제거로 앞에 쌓는다(명세 `523:18429`).
_queries
..remove(query)
..insert(0, query);
return _cards
.where((c) => c.title.contains(query) || c.body.contains(query))
.toList();
}
@override
Future<int> contribute(String campaignId, int amountPop) async => amountPop;
@override
Future<int> balance() async => 12540;
@override
Future<int> feeBalance() => balance();
@override
Future<void> like(String campaignId, bool on) async {}
@override
Future<String> create(CampaignDraft draft) async => 'c-new';
@override
Future<List<CampaignCardData>> mine() async =>
(await load(limit: 100)).where((c) => c.mine).toList();
@override
Future<void> withdraw(String campaignId) async {}
late final List<CampaignComment> _comments = [
CampaignComment(
id: 'm1',
authorName: '아미보이',
authorUid: 'u-army',
body: '꼭 성공했으면 좋겠어요!',
createdAt: _base.subtract(const Duration(minutes: 20)),
),
CampaignComment(
id: 'm2',
authorName: '다이브짱',
authorUid: 'u-dive',
body: '조감도 너무 예쁘네요',
createdAt: _base.subtract(const Duration(hours: 5)),
),
];
@override
Future<List<CampaignComment>> comments(
String campaignId, {
bool more = false,
}) async => more ? const [] : [..._comments];
@override
Future<CampaignComment> addComment(String campaignId, String body) async {
final c = CampaignComment(
id: 'm${_comments.length + 1}',
authorName: appL10n.commonMe,
body: body,
createdAt: DateTime.now(),
isMine: true,
);
_comments.insert(0, c);
return c;
}
@override
Future<void> deleteComment(String campaignId, String commentId) async =>
_comments.removeWhere((c) => c.id == commentId);
@override
Future<void> reportComment(
String campaignId,
CampaignComment comment,
String reason,
) async {}
@override
Future<void> blockAuthor(CampaignComment comment) async =>
_comments.removeWhere((c) => c.authorUid == comment.authorUid);
@override
Future<void> likeComment(
String campaignId,
String commentId,
bool on,
) async {}
@override
Future<void> reportCampaign(String campaignId, String reason) async {}
@override
Future<void> blockCreator(CampaignCardData card) async {}
@override
Future<bool> hasPublicNickname() async => true;
@override
late final MyRepository my = SampleMyRepository();
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,302 @@
import 'package:flutter/material.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart';
import 'campaign_create_screen.dart';
import 'campaign_data.dart';
import 'campaign_detail_screen.dart';
import 'campaign_search_screen.dart';
import 'widgets/campaign_list_card.dart';
import 'widgets/campaign_style.dart';
/// HOT ARCHIVE — 화면 대조 원장 F5 · Figma `272:241184`.
///
/// 명세 `523:18327` — *뒤로가기 버튼 홈 화면 이동 · 검색 버튼 아카이브 검색 화면 이동*
/// 명세 `523:18347` — *디폴트 최신순 선택 · 단일 선택*
/// 명세 `523:18388` — *기본 10개 노출 · 아래 스크롤 시 10개 업데이트*
/// 명세 `523:18410` — *새 캠페인 버튼 · 하단 중앙 정렬*
class CampaignListScreen extends StatefulWidget {
CampaignListScreen({super.key, CampaignRepository? repository})
: repository = repository ?? SampleCampaignRepository();
final CampaignRepository repository;
@override
State<CampaignListScreen> createState() => _CampaignListScreenState();
}
class _CampaignListScreenState extends State<CampaignListScreen> {
final _scroll = ScrollController();
List<CampaignCardData> _cards = const [];
CampaignSort _sort = CampaignSort.latest;
bool _loading = true;
bool _more = false;
bool _end = false;
@override
void initState() {
super.initState();
_scroll.addListener(() {
if (_scroll.position.pixels > _scroll.position.maxScrollExtent - 240) {
_loadMore();
}
});
_load();
}
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
Future<void> _load() async {
setState(() => _loading = true);
List<CampaignCardData> cards;
try {
cards = await widget.repository.load(sort: _sort);
} on Object {
// 불러오지 못하면 빈 목록으로 닫고 알린다. 스피너에 고착되면 고장으로 읽힌다.
cards = const [];
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.campaignLoadFailed)),
);
}
}
if (!mounted) return;
setState(() {
_cards = cards;
_loading = false;
_end = cards.length < 10;
});
}
Future<void> _loadMore() async {
if (_more || _end) return;
setState(() => _more = true);
final next = await widget.repository.more(offset: _cards.length);
if (!mounted) return;
setState(() {
_cards = [..._cards, ...next];
_more = false;
_end = next.isEmpty;
});
}
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
// 시안(272:241184) — 제목 없는 Top Navigation 54(뒤로 · 검색 42 칸), 그 아래 칩 줄 36.
return Scaffold(
appBar: AppBar(
toolbarHeight: 54,
leadingWidth: 61,
leading: const Align(
alignment: Alignment.centerRight,
child: BackButton(),
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 13),
child: IconButton(
tooltip: context.l10n.commonSearch,
icon: const ImageIcon(AppIcons.search, size: 24),
onPressed: _search,
),
),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(36),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 2, 16, 2),
child: Row(
children: [
for (final sort in CampaignSort.values) ...[
CampaignSortChip(
label: sort.label,
icon: campaignSortIcon(sort),
selected: _sort == sort,
onTap: () {
setState(() => _sort = sort);
_load();
},
),
const SizedBox(width: AppSpace.s1),
],
],
),
),
),
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _cards.isEmpty
? Center(
child: Text(
_sort == CampaignSort.contributing
? context.l10n.campaignEmptyContributing
: context.l10n.campaignEmpty,
style: t.bodyMedium,
),
)
: ListView.separated(
controller: _scroll,
padding: const EdgeInsets.fromLTRB(16, 14, 16, 96),
itemCount: _cards.length + (_more ? 1 : 0),
separatorBuilder: (_, _) => const SizedBox(height: AppSpace.s5),
itemBuilder: (context, i) {
if (i >= _cards.length) {
return const Padding(
padding: EdgeInsets.all(AppSpace.s4),
child: Center(child: CircularProgressIndicator()),
);
}
final card = _cards[i];
return CampaignListCard(
card: card,
onOpen: () => _open(card.id),
);
},
),
floatingActionButton: CampaignFab(
label: context.l10n.campaignNew,
onTap: _create,
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
);
}
Future<void> _open(String id) async {
await Navigator.of(context, rootNavigator: true).push<void>(
MaterialPageRoute(
builder: (_) =>
CampaignDetailScreen(campaignId: id, repository: widget.repository),
),
);
if (mounted) await _load();
}
Future<void> _search() async {
final id = await Navigator.of(context, rootNavigator: true).push<String>(
MaterialPageRoute(
builder: (_) => CampaignSearchScreen(repository: widget.repository),
),
);
if (id != null && mounted) await _open(id);
}
Future<void> _create() async {
final id = await Navigator.of(context, rootNavigator: true).push<String>(
MaterialPageRoute(
builder: (_) => CampaignCreateScreen(repository: widget.repository),
),
);
if (id != null && mounted) await _load();
}
}
/// 정렬 칩 아이콘 — 시계 · 하트 · 번개(시안 아이콘 16).
Widget campaignSortIcon(CampaignSort sort) => switch (sort) {
CampaignSort.latest => const ImageIcon(
AssetImage('assets/figma/campaign-clock.png'),
),
CampaignSort.popular => const Icon(Icons.favorite_border),
CampaignSort.contributing => const ImageIcon(
AssetImage('assets/figma/icon-bolt.png'),
),
};
/// 정렬 칩 — 시안 Chip: 높이 32 · r8 · 안쪽 6/10 · 아이콘 16 · 글자 12/500(줄 20).
/// 선택 흰 바탕·검정 글자, 비선택 #1a1a1a · 획 #232324 · #d5d7d9. 테스트가 selected 를 읽는다.
class CampaignSortChip extends StatelessWidget {
const CampaignSortChip({
super.key,
required this.label,
required this.icon,
required this.selected,
required this.onTap,
});
final String label;
final Widget icon;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final fg = selected ? Colors.black : const Color(0xFFD5D7D9);
return Material(
color: selected ? AppColors.onCosmic : const Color(0xFF1A1A1A),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
side: const BorderSide(color: AppColors.cosmicSurfaceRaised),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.sm),
child: Container(
height: 32,
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconTheme(
data: IconThemeData(size: 16, color: fg),
child: icon,
),
const SizedBox(width: AppSpace.s1),
Text(
label,
style: campaignText(12, 500, fg, height: 20 / 12, spacing: 0),
),
],
),
),
),
);
}
}
/// 하단 가운데 알약 버튼 — 시안 `새 캠페인`: #19191c · 획 #232324 · 안쪽 8/16 · 간격 6 ·
/// 핑크 연필 16 · 글자 14/500 #a3a2a0.
class CampaignFab extends StatelessWidget {
const CampaignFab({super.key, required this.label, required this.onTap});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => Material(
color: const Color(0xFF19191C),
shape: const StadiumBorder(
side: BorderSide(color: AppColors.cosmicSurfaceRaised),
),
child: InkWell(
onTap: onTap,
customBorder: const StadiumBorder(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset('assets/figma/icon-pencil.png', width: 16, height: 16),
const SizedBox(width: 6),
Text(
label,
style: campaignText(
14,
500,
AppColors.onCosmicVariant,
height: 20 / 14,
spacing: 0,
),
),
],
),
),
),
);
}
@@ -0,0 +1,165 @@
import 'package:flutter/material.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
import 'campaign_data.dart';
import 'widgets/campaign_rank.dart';
import 'widgets/campaign_style.dart';
/// 기여 랭킹 더보기 — 화면 대조 원장 F5 · Figma `333:70971`.
///
/// 명세 `536:14361` — *내 순위 · 순위 변동값 · 프로필 · 닉네임 · 기여 POP*
/// 명세 `536:14371` — *기여 POP에 따른 랭킹 소팅 · 실시간 데이터 기준*
/// 명세 `536:14382` — *랭킹 필터 바텀시트 — 실시간 · 일간 · 주간*
class CampaignRankingScreen extends StatefulWidget {
CampaignRankingScreen({
super.key,
required this.campaignId,
CampaignRepository? repository,
}) : repository = repository ?? SampleCampaignRepository();
final String campaignId;
final CampaignRepository repository;
@override
State<CampaignRankingScreen> createState() => _CampaignRankingScreenState();
}
class _CampaignRankingScreenState extends State<CampaignRankingScreen> {
List<ContributorRank> _ranks = const [];
RankWindow _window = RankWindow.live;
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _loading = true);
final ranks = await widget.repository.ranking(
widget.campaignId,
window: _window,
);
if (!mounted) return;
setState(() {
_ranks = ranks;
_loading = false;
});
}
ContributorRank? get _me {
for (final r in _ranks) {
if (r.isMe) return r;
}
return null;
}
@override
Widget build(BuildContext context) {
final me = _me;
final sorted = [..._ranks]..sort((a, b) => a.rank.compareTo(b.rank));
final top = sorted.take(3).toList();
final rest = sorted.skip(3).toList();
// 시안(333:70971) — 「USER RANKING」 · 나의 기여 랭킹 카드 · 「실시간 랭킹 ⌄」 필터 · 단상 · 목록.
return Scaffold(
appBar: AppBar(title: const Text('USER RANKING')),
body: _loading
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.all(AppSpace.s4),
children: [
// 내 순위를 맨 위에 고정한다. 목록을 훑지 않아도 보여야 한다.
if (me != null) ...[
Text(
context.l10n.campaignMyRanking,
style: campaignText(24, 500, AppColors.onCosmic, height: 1),
),
const SizedBox(height: AppSpace.s4),
Container(
decoration: BoxDecoration(
color: const Color(0xFF19191C),
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: RankRow(
rank: me,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
),
const SizedBox(height: AppSpace.s5),
],
CampaignRankHeader(window: _window, onTap: _pickWindow),
const SizedBox(height: AppSpace.s3),
RankPodium(top: top),
const SizedBox(height: AppSpace.s3),
for (final r in rest) RankRow(rank: r),
],
),
);
}
Future<void> _pickWindow() async {
final picked = await showModalBottomSheet<RankWindow>(
context: context,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final w in RankWindow.values)
ListTile(
title: Text(w.label),
trailing: _window == w ? const Icon(Icons.check) : null,
onTap: () => Navigator.of(context).pop(w),
),
ListTile(
title: Text(context.l10n.commonCancel),
onTap: () => Navigator.of(context).pop(),
),
],
),
),
);
if (picked == null || !mounted) return;
setState(() => _window = picked);
await _load();
}
}
/// 「실시간 랭킹 ⌄」 — 18/600 · 아래 꺾쇠 20. 누르면 구간 바텀시트(명세 `536:14382`).
class CampaignRankHeader extends StatelessWidget {
const CampaignRankHeader({
super.key,
required this.window,
required this.onTap,
});
final RankWindow window;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => Align(
alignment: Alignment.centerLeft,
child: InkWell(
onTap: onTap,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.l10n.campaignWindowRanking(window.label),
style: campaignText(18, 600, AppColors.onCosmic, spacing: 0),
),
const SizedBox(width: 4),
const ImageIcon(
AssetImage('assets/figma/campaign-chevron-down.png'),
size: 20,
color: AppColors.onCosmic,
),
],
),
),
);
}
@@ -0,0 +1,483 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import '../../core/data/backend.dart';
import '../../core/i18n/app_locale.dart';
import '../my/my_data.dart' show MyRepository;
import '../my/my_repository_firestore.dart';
import '../planet/moderation.dart' show reportReasonCodes;
import 'campaign_data.dart';
/// 캠페인의 실 서버 구현 — 화면 대조 원장 F5 (G-03).
///
/// **읽기는 Firestore, 후원은 `fn-campaign-fund`.** 예전에는 서버가 붙어도 표본이
/// 쓰였고 표본의 `contribute` 가 금액을 그대로 돌려줘 화면이 성공처럼 보였다.
///
/// 읽는 곳 — `firestore.rules` 의 `campaigns` 화이트리스트 필드만 읽는다.
/// - 목록·상세 `campaigns/{id}` (공개 상태 `funding·success·fail·report` 만 규칙이 연다)
/// - 기여 랭킹 `campaigns/{id}/donations` (`uid` · `amount` · `createdAt`)
///
/// - 좋아요 `campaigns/{id}/likes/{uid}` (문서 ID = uid 로 중복 차단 · 개수는 집계 쿼리)
/// - 응원 게시판 댓글 `campaigns/{id}/comments` (수는 `commentCount` — 서버 트리거가 센다)
///
/// **서버에 자리가 없어 채우지 못한 것** — 조회수·공유 수(규칙에 필드·경로
/// 없음), 다른 후원자의 이름(후원 문서에 `uid` 만 있고 남의 `users` 문서는 못 읽는다).
/// 0·빈 목록·중립 이름으로 두고 보고서에 남겼다. 없는 값을 여기서 만들지 않는다.
class FirestoreCampaignRepository implements CampaignRepository {
FirestoreCampaignRepository(this._backend);
final Backend _backend;
/// 규칙 `campaignIsPublic()` 과 같은 목록. 이 필터가 없으면 쿼리 전체가 거부된다.
static const _public = ['funding', 'success', 'fail', 'report'];
final List<String> _queries = [];
// ponytail: 공개 캠페인을 한 번에 최대 100건 읽어 앱에서 정렬·검색·페이징한다.
// 캠페인이 늘면 (status, createdAt DESC) 인덱스 + 커서 페이징으로 옮긴다.
Future<List<CampaignCardData>> _all() async {
final snap = await _backend.db
.collection('campaigns')
.where('status', whereIn: _public)
.limit(100)
.get();
final uid = _backend.uid;
// 차단한 개설자의 캠페인은 목록 · 검색에서 뺀다.
final blocked = uid == null ? const <String>{} : await _blockedUids();
return snap.docs
.where((d) => !blocked.contains(d.data()['creatorUid']))
.map((d) => _card(d.id, d.data(), uid))
.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
}
static CampaignCardData _card(
String id,
Map<String, dynamic> m,
String? uid,
) => CampaignCardData(
id: id,
title: m.strOr('title'),
summary: m.strOr('summary'),
body: m.strOr('body'),
authorName: publicName(m['creatorName']),
fandomId: m.strOr('fandomId'),
createdAt: m.dateOr('createdAt', DateTime.fromMillisecondsSinceEpoch(0)),
status: switch (m.strOr('status')) {
'success' => CampaignStatus.success,
'fail' || 'failed' => CampaignStatus.failed,
'report' => CampaignStatus.reported,
'draft' || 'review' => CampaignStatus.review,
'rejected' => CampaignStatus.rejected,
'withdrawn' => CampaignStatus.withdrawn,
'cancelled' => CampaignStatus.cancelled,
_ => CampaignStatus.funding,
},
// 규칙의 스키마 이름은 `targetPop` 이다. `goalPop` 은 옛 이름이라 보조로만 읽는다.
goalPop: m.intOr('targetPop', m.intOr('goalPop')),
raisedPop: m.intOr('raisedPop'),
supporters: m.intOr('donorCount'),
likes: m.intOr('likes'),
comments: m.intOr('commentCount'),
views: m.intOr('views'),
shares: m.intOr('shares'),
mine: uid != null && m['creatorUid'] == uid,
creatorUid: m['creatorUid'] is String ? m['creatorUid'] as String : null,
);
@override
Future<List<CampaignCardData>> load({
CampaignSort sort = CampaignSort.latest,
int limit = 10,
}) async {
final all = await _all();
switch (sort) {
case CampaignSort.latest:
break;
case CampaignSort.popular:
all.sort((a, b) => a.comparePopularity(b));
case CampaignSort.contributing:
// 내 원장에서 찾는다. 후원 문서의 collectionGroup 인덱스는 `donorUid` 로
// 걸려 있는데 서버는 `uid` 로 써서 그 경로가 맞지 않는다.
final uid = _backend.requireUid;
final mine = await _backend.db
.collection('users/$uid/popTransactions')
.where('reasonCode', isEqualTo: 'CAMPAIGN_FUND')
.get();
final paths = {for (final d in mine.docs) d.data()['refPath']};
all.retainWhere((c) => paths.contains('campaigns/${c.id}'));
}
return all.take(limit).toList();
}
@override
Future<List<CampaignCardData>> more({
required int offset,
int limit = 10,
}) async => (await _all()).skip(offset).take(limit).toList();
@override
Future<CampaignDetail> detail(String campaignId) async {
final snap = await _backend.db.doc('campaigns/$campaignId').get();
final data = snap.data();
if (data == null) {
throw BackendException('E-CMP-03', appL10n.campaignNotFound);
}
final uid = _backend.uid;
final likes = _backend.db.collection('campaigns/$campaignId/likes');
final count = (await likes.count().get()).count ?? 0;
final liked = uid != null && (await likes.doc(uid).get()).exists;
final card = _card(snap.id, {...data, 'likes': count}, uid);
return CampaignDetail(
card: card,
report: card.status == CampaignStatus.reported
? await _report(campaignId, card.createdAt)
: null,
liked: liked,
mediaLabels: [appL10n.campaignMediaMain],
endsAt: data.dateOr('endsAt', card.createdAt),
topContributors: (await ranking(campaignId)).take(3).toList(),
);
}
/// 결과보고 — 파일은 Storage 경로로 저장돼 있어 표시용 URL 로 바꾼다
/// (`storage.rules` 의 `campaignReports/` 는 공개 읽기).
Future<CampaignReport?> _report(String campaignId, DateTime fallback) async {
final m =
(await _backend.db.doc('campaigns/$campaignId/reports/main').get())
.data();
if (m == null) return null;
Future<List<String>> urls(String key) => Future.wait([
for (final p in (m[key] as List? ?? const []))
if (p is String) _backend.storage.ref(p).getDownloadURL(),
]);
return CampaignReport(
body: m.strOr('body'),
photoUrls: await urls('photoPaths'),
receiptUrls: await urls('receiptPaths'),
budget: [
for (final l in (m['budget'] as List? ?? const []))
if (l is Map<String, dynamic>)
(label: l.strOr('label'), amount: l.intOr('amount')),
],
createdAt: m.dateOr('createdAt', fallback),
);
}
@override
Future<List<ContributorRank>> ranking(
String campaignId, {
RankWindow window = RankWindow.live,
}) async {
final snap = await _backend.db
.collection('campaigns/$campaignId/donations')
.get();
final since = switch (window) {
RankWindow.live => null,
RankWindow.daily => DateTime.now().subtract(const Duration(days: 1)),
RankWindow.weekly => DateTime.now().subtract(const Duration(days: 7)),
};
final totals = <String, int>{};
for (final d in snap.docs) {
final m = d.data();
if (since != null && m.dateOr('createdAt', since).isBefore(since))
continue;
final uid = m.strOr('uid');
totals[uid] = (totals[uid] ?? 0) + m.intOr('amount');
}
final me = _backend.uid;
final rows = totals.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
return [
for (var i = 0; i < rows.length; i++)
ContributorRank(
rank: i + 1,
// 남의 이름은 서버에 읽을 자리가 없다. UID 는 화면에 내보내지 않는다.
name: rows[i].key == me ? appL10n.commonMe : appL10n.commonFan,
pop: rows[i].value,
isMe: rows[i].key == me,
),
];
}
@override
Future<List<String>> recentQueries() async => [..._queries];
@override
Future<void> clearQueries() async => _queries.clear();
@override
Future<void> removeQuery(String query) async => _queries.remove(query);
@override
Future<List<CampaignCardData>> search(String query) async {
_queries
..remove(query)
..insert(0, query);
return (await _all())
.where((c) => c.title.contains(query) || c.body.contains(query))
.toList();
}
/// 아직 성공이 확인되지 않은 후원 시도의 요청 번호 — `캠페인 · 금액` 별.
///
/// 응답이 유실된 뒤 다시 누르면 **같은 번호**로 보내야 서버 멱등 키가 두 번째 차감을 막는다.
/// 탭마다 새 번호를 만들면 첫 요청이 실제로는 성공했을 때 이중 후원이 된다.
final Map<String, String> _pendingSeq = {};
/// 후원 — `fn-campaign-fund`. 기여 POP 만 쓴다(정책 원장 B-33).
/// 실패는 [BackendException] 으로 그대로 올린다 — 화면이 성공처럼 그리지 않게.
@override
Future<int> contribute(String campaignId, int amountPop) async {
final key = '$campaignId:$amountPop';
final seq = _pendingSeq[key] ??= _backend.db.collection('_').doc().id;
await _backend.call('fnCampaignFund', {
'campaignId': campaignId,
'clientSeq': seq,
'amountPop': amountPop,
});
// 성공이 확인된 뒤에만 번호를 버린다 — 다음 후원은 새 시도다.
_pendingSeq.remove(key);
return amountPop;
}
@override
Future<int> balance() async {
final uid = _backend.requireUid;
final wallet = await _backend.db.doc('users/$uid/wallet/main').get();
return (wallet.data() ?? const {}).intOr('supportBalance');
}
@override
Future<int> feeBalance() async {
final uid = _backend.requireUid;
final wallet =
(await _backend.db.doc('users/$uid/wallet/main').get()).data() ??
const {};
return wallet.intOr('activityBalance') + wallet.intOr('supportBalance');
}
/// 좋아요 — `campaigns/{id}/likes/{uid}` 를 만들거나 지운다(라운지 좋아요와 같은 구조).
/// 실패는 그대로 올린다 — 화면은 저장이 끝난 뒤에만 숫자를 바꾼다.
@override
Future<void> like(String campaignId, bool on) async {
final ref = _backend.db.doc(
'campaigns/$campaignId/likes/${_backend.requireUid}',
);
if (on) {
await ref.set({'createdAt': FieldValue.serverTimestamp()});
} else {
await ref.delete();
}
}
/// 규칙이 개설자 본인 문서는 상태와 무관하게 읽기를 허용한다(`creatorUid == auth.uid`).
@override
Future<List<CampaignCardData>> mine() async {
final uid = _backend.requireUid;
final snap = await _backend.db
.collection('campaigns')
.where('creatorUid', isEqualTo: uid)
.get();
return snap.docs.map((d) => _card(d.id, d.data(), uid)).toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
}
@override
Future<void> withdraw(String campaignId) async {
await _backend.call('fnCampaignWithdraw', {'campaignId': campaignId});
}
/// 개설 — `fn-campaign-create`. 서버가 자격(B-56)을 보고 등록비(B-57)를 뺀 뒤
/// 심사 대기(`review`)로 올린다. 규칙은 클라이언트 직접 생성을 막는다.
/// 응답이 유실된 재시도는 같은 요청 번호로 보내 등록비가 두 번 빠지지 않게 한다.
@override
Future<String> create(CampaignDraft draft) async {
final key = 'create:${draft.title}:${draft.goalPop}:${draft.days}';
final seq = _pendingSeq[key] ??= _backend.db.collection('_').doc().id;
final result = await _backend.call('fnCampaignCreate', {
'clientSeq': seq,
'title': draft.title,
'body': draft.body,
'targetPop': draft.goalPop,
'days': draft.days,
});
_pendingSeq.remove(key);
return result.strOr('campaignId');
}
QueryDocumentSnapshot<Map<String, dynamic>>? _commentCursor;
/// 내가 차단한 UID. 목록 · 댓글 장을 읽을 때마다 새로 읽는다 — MY 에서 차단을
/// 풀거나 다른 화면에서 차단해도 저장소 캐시가 낡지 않는다.
Future<Set<String>> _blockedUids() async => {
for (final d
in (await _backend.db
.collection('users/${_backend.requireUid}/blocks')
.get())
.docs)
d.id,
};
/// 최신순 한 장. 차단한 작성자의 댓글이 한 장을 다 채우면 화면이 「끝」으로
/// 오인하므로 서버에 댓글이 남아 있는 동안 다음 장을 더 읽는다(라운지와 같은 방식).
@override
Future<List<CampaignComment>> comments(
String campaignId, {
bool more = false,
}) async {
if (!more) _commentCursor = null;
final uid = _backend.requireUid;
final blocked = await _blockedUids();
final out = <CampaignComment>[];
while (out.length < campaignCommentPageSize) {
var query = _backend.db
.collection('campaigns/$campaignId/comments')
.where('visibility', isEqualTo: 'visible')
.orderBy('createdAt', descending: true);
final cursor = _commentCursor;
if (cursor != null) query = query.startAfterDocument(cursor);
final snap = await query.limit(campaignCommentPageSize).get();
if (snap.docs.isNotEmpty) _commentCursor = snap.docs.last;
out.addAll(
await Future.wait([
for (final d in snap.docs)
if (!blocked.contains(d.data()['authorUid'])) _comment(d, uid),
]),
);
if (snap.docs.length < campaignCommentPageSize) break;
}
return out;
}
/// ponytail: 댓글마다 내 좋아요 문서를 한 번씩 읽는다(한 장 20건). 무거워지면
/// 좋아요한 댓글 ID 를 한 번의 collectionGroup 질의로 모은다.
static Future<CampaignComment> _comment(
DocumentSnapshot<Map<String, dynamic>> d,
String uid,
) async {
final data = d.data() ?? const <String, dynamic>{};
final author = data['authorUid'];
final liked = (await d.reference.collection('likes').doc(uid).get()).exists;
return CampaignComment(
likes: data.intOr('likeCount'),
liked: liked,
id: d.id,
authorName: publicName(data['authorName']),
authorUid: author is String ? author : null,
body: data.strOr('body'),
createdAt: data.dateOr('createdAt', DateTime.now()),
isMine: author == uid,
);
}
/// 작성자 표시명 = 공개 닉네임(없으면 언어 무관 「팬」 — [storedPublicName]).
/// 공급자 실명(`displayName`)은 쓰지 않는다(M-7). 규칙 `authorNameIsPublic()` 이 같은 계산으로 대조한다.
@override
Future<CampaignComment> addComment(String campaignId, String body) async {
final uid = _backend.requireUid;
final name = storedPublicName(
(await _backend.db.doc('users/$uid').get()).data()?['nickname'],
);
final ref = await _backend.db
.collection('campaigns/$campaignId/comments')
.add({
'authorUid': uid,
'authorName': name,
'body': body,
'visibility': 'visible',
'createdAt': FieldValue.serverTimestamp(),
'updatedAt': FieldValue.serverTimestamp(),
});
return _comment(await ref.get(), uid);
}
@override
Future<void> deleteComment(String campaignId, String commentId) =>
_backend.db.doc('campaigns/$campaignId/comments/$commentId').delete();
/// 댓글 좋아요 — `comments/{cid}/likes/{uid}`. 수는 서버 트리거가 센다.
@override
Future<void> likeComment(String campaignId, String commentId, bool on) async {
final ref = _backend.db.doc(
'campaigns/$campaignId/comments/$commentId/likes/${_backend.requireUid}',
);
if (on) {
await ref.set({'createdAt': FieldValue.serverTimestamp()});
} else {
await ref.delete();
}
}
/// 신고 — 라운지 글과 같은 `reports` 스키마. 문서 ID 를 「대상(댓글은 캠페인 + 댓글) + 신고자」로
/// 고정해 규칙이 재접수(덮어쓰기)를 거부한다(B-45 「신고자 수」).
@override
Future<void> reportComment(
String campaignId,
CampaignComment comment,
String reason,
) => _fileReport(
'campaignComment',
'$campaignId-${comment.id}',
'campaigns/$campaignId/comments/${comment.id}',
reason,
);
/// 캠페인 신고 — ID `campaign-{캠페인}-{신고자}`.
@override
Future<void> reportCampaign(String campaignId, String reason) =>
_fileReport('campaign', campaignId, 'campaigns/$campaignId', reason);
Future<void> _fileReport(
String type,
String idPart,
String targetPath,
String reason,
) async {
final uid = _backend.requireUid;
final code = reportReasonCodes[reason];
if (code == null) {
throw BackendException('E-PERM-01', appL10n.reportReasonUnknown);
}
try {
await _backend.db.doc('reports/$type-$idPart-$uid').set({
'reporterUid': uid,
'targetType': type,
'targetPath': targetPath,
'reasonCode': code,
'status': 'pending',
'createdAt': FieldValue.serverTimestamp(),
});
} on FirebaseException catch (error) {
if (error.code == 'permission-denied') {
throw BackendException('E-PERM-01', appL10n.reportAlready);
}
rethrow;
}
}
@override
Future<bool> hasPublicNickname() async {
final user = await _backend.db.doc('users/${_backend.requireUid}').get();
return publicName(user.data()?['nickname'], '').isNotEmpty;
}
@override
late final MyRepository my = FirestoreMyRepository(_backend);
/// 차단 — MY 의 차단 목록과 같은 경로 · 같은 메서드를 쓴다.
@override
Future<void> blockAuthor(CampaignComment comment) =>
_block(comment.authorUid, comment.authorName);
/// 개설자 차단 — 댓글 작성자 차단과 같은 목록이라 그 개설자의 댓글도 빠진다.
@override
Future<void> blockCreator(CampaignCardData card) =>
_block(card.creatorUid, card.authorName);
Future<void> _block(String? target, String nickname) async {
if (target == null) {
throw BackendException('E-PERM-01', appL10n.blockUnavailable);
}
await my.block(target, nickname: nickname);
}
}
@@ -0,0 +1,321 @@
import 'package:flutter/material.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/play_banner.dart';
import '../../core/ui/app_icons.dart';
import 'campaign_create_screen.dart';
import 'campaign_data.dart';
import 'campaign_list_screen.dart';
import 'widgets/campaign_list_card.dart';
import 'widgets/campaign_style.dart';
/// 아카이브 검색 — 화면 대조 원장 F5.
///
/// Figma 노드 `272:241681`(검색) · `272:241724`(검색 결과).
/// **두 화면을 하나로 둔다** — Figma 가 프레임을 나눠 두었으나 사용자에게는
/// 같은 화면의 두 상태이며, 따로 만들면 검색어 입력창이 두 벌이 된다.
///
/// 명세 `523:18429` — *가장 최근 검색 결과 데이터가 첫번째 위치 · 최신순 노출 및*
/// *중복 제거 · 데이터가 없을 경우 「최신 검색어가 없습니다」 노출*
///
/// 검색 결과 시안은 목록과 같은 정렬 칩 · 카드 · 「새 캠페인」 버튼을 쓴다.
/// 명세 `523:18422` 의 광고 배너(AdMob Native)는 SDK 미도입이라 그리지 않는다.
class CampaignSearchScreen extends StatefulWidget {
CampaignSearchScreen({super.key, CampaignRepository? repository})
: repository = repository ?? SampleCampaignRepository();
final CampaignRepository repository;
@override
State<CampaignSearchScreen> createState() => _CampaignSearchScreenState();
}
class _CampaignSearchScreenState extends State<CampaignSearchScreen> {
final _query = TextEditingController();
List<String> _recent = const [];
List<CampaignCardData>? _results;
List<CampaignCardData> _sorted = const [];
CampaignSort _sort = CampaignSort.latest;
@override
void initState() {
super.initState();
_loadRecent();
}
@override
void dispose() {
_query.dispose();
super.dispose();
}
Future<void> _loadRecent() async {
final recent = await widget.repository.recentQueries();
if (mounted) setState(() => _recent = recent);
}
Future<void> _search(String raw) async {
final query = raw.trim();
if (query.isEmpty) return;
_query.text = query;
final results = await widget.repository.search(query);
if (!mounted) return;
setState(() => _results = results);
await _applySort(_sort);
await _loadRecent();
}
/// 결과 안에서 정렬한다. 「기여중」은 내 후원 원장을 아는 저장소 목록과 겹쳐 본다.
Future<void> _applySort(CampaignSort sort) async {
final results = _results ?? const <CampaignCardData>[];
var list = [...results];
switch (sort) {
case CampaignSort.latest:
list.sort((a, b) => b.createdAt.compareTo(a.createdAt));
case CampaignSort.popular:
list.sort((a, b) => a.comparePopularity(b));
case CampaignSort.contributing:
final mine = {
for (final c in await widget.repository.load(sort: sort)) c.id,
};
list = list.where((c) => mine.contains(c.id)).toList();
}
if (mounted) {
setState(() {
_sort = sort;
_sorted = list;
});
}
}
@override
Widget build(BuildContext context) {
final results = _results;
// 시안 — 뒤로 42 칸 옆에 #282828 r10 검색 칸(높이 42 · 돋보기 20 · 글자 16/500).
return Scaffold(
appBar: AppBar(
toolbarHeight: 54,
leadingWidth: 58,
titleSpacing: 0,
leading: const Align(
alignment: Alignment.centerRight,
child: BackButton(),
),
title: Container(
height: 42,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: const Color(0xFF282828),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
const ImageIcon(
AppIcons.search,
size: 20,
color: AppColors.onCosmicVariant,
),
const SizedBox(width: 6),
Expanded(
child: TextField(
controller: _query,
autofocus: true,
textInputAction: TextInputAction.search,
style: campaignText(
16,
500,
AppColors.onCosmic,
height: 1.4,
spacing: 0,
),
decoration: InputDecoration(
hintText: context.l10n.campaignSearchHint,
hintStyle: campaignText(
16,
500,
AppColors.onCosmicMuted,
height: 1.4,
spacing: 0,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
filled: false,
isDense: true,
contentPadding: EdgeInsets.zero,
),
onSubmitted: _search,
),
),
],
),
),
actions: const [SizedBox(width: 16)],
bottom: results == null
? null
: PreferredSize(
preferredSize: const Size.fromHeight(36),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 2, 16, 2),
child: Row(
children: [
for (final sort in CampaignSort.values) ...[
CampaignSortChip(
label: sort.label,
icon: campaignSortIcon(sort),
selected: _sort == sort,
onTap: () => _applySort(sort),
),
const SizedBox(width: AppSpace.s1),
],
],
),
),
),
),
body: results == null ? _recentView() : _resultView(),
floatingActionButton: results == null
? null
: CampaignFab(label: context.l10n.campaignNew, onTap: _create),
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
);
}
// 시안 `272:241681` — 배너만 좌우 9(357 폭), 나머지는 16 · 배너 아래 24.
Widget _recentView() => ListView(
padding: const EdgeInsets.fromLTRB(9, 16, 9, 16),
children: [
const PlayBanner(),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 7),
child: _recentBody(),
),
],
);
Widget _recentBody() => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
context.l10n.commonRecentSearches,
style: campaignText(
16,
600,
AppColors.onCosmic,
height: 20 / 16,
spacing: 0,
),
),
if (_recent.isNotEmpty)
GestureDetector(
onTap: () async {
await widget.repository.clearQueries();
await _loadRecent();
},
child: Text(
context.l10n.commonClearAll,
style: campaignText(
14,
500,
const Color(0xFFC2C2C2),
height: 20 / 14,
spacing: 0,
),
),
),
],
),
const SizedBox(height: AppSpace.s4),
if (_recent.isEmpty)
Text(
context.l10n.campaignNoRecentSearches,
style: campaignText(14, 500, AppColors.onCosmicVariant),
)
else
Wrap(
spacing: 10,
runSpacing: 10,
children: [for (final q in _recent) _queryChip(q)],
),
],
);
/// 최근 검색어 칩 — 획 #414141 알약 · 안쪽 6/14 · 글자 15/500 #c2c2c2 · ✕ 18.
Widget _queryChip(String q) => Material(
color: Colors.transparent,
shape: const StadiumBorder(side: BorderSide(color: Color(0xFF414141))),
child: InkWell(
onTap: () => _search(q),
customBorder: const StadiumBorder(),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 6, 14, 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
q,
style: campaignText(
15,
500,
const Color(0xFFC2C2C2),
height: 1.4,
spacing: 0,
),
),
const SizedBox(width: 10),
InkResponse(
onTap: () async {
await widget.repository.removeQuery(q);
await _loadRecent();
},
radius: 14,
child: Tooltip(
message: context.l10n.searchRemoveTerm(q),
child: const ImageIcon(
AppIcons.x,
size: 18,
color: Color(0xFFC2C2C2),
),
),
),
],
),
),
),
);
Widget _resultView() {
if (_sorted.isEmpty) {
return Center(
child: Text(
context.l10n.campaignNoResults,
style: campaignText(14, 500, AppColors.onCosmicVariant),
),
);
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 96),
itemCount: _sorted.length,
separatorBuilder: (_, _) => const SizedBox(height: AppSpace.s5),
itemBuilder: (context, i) => CampaignListCard(
card: _sorted[i],
onOpen: () => Navigator.of(context).pop(_sorted[i].id),
),
);
}
Future<void> _create() async {
final id = await Navigator.of(context, rootNavigator: true).push<String>(
MaterialPageRoute(
builder: (_) => CampaignCreateScreen(repository: widget.repository),
),
);
if (id != null && mounted) await _search(_query.text);
}
}
@@ -0,0 +1,83 @@
/// 캠페인 공유 문구 조립 — 「홈/HOT ARCHIVE/공유」 · Figma `480:14594`.
///
/// 조립을 화면 밖에 둔 이유는 정책 원장 **B-78** 이다. 해시태그를 콘텐츠에 따라
/// 동적으로 구성하라고 확정했고 검증 기준이 *고정 3종 하드코딩 0건* 이므로,
/// 규칙을 한 곳에 두고 테스트로 고정한다. 화면 안에 문자열을 이어 붙이면
/// 캠페인과 퀘스트에서 규칙이 갈린다.
library;
import '../../core/data/invite_code.dart';
import '../../core/i18n/app_locale.dart';
const String shareBrand = 'FANUZU';
/// 웹 브릿지 주소 — 빌드 설정(`--dart-define=FANUZU_SHARE_BASE_URL=https://…`).
///
/// 도메인은 해석 확인 요청 「공유 · 웹 브릿지」 3-1 회신 뒤 정해진다. 설정이 없으면 링크 없이
/// 문구만 보낸다 — 살아 있지 않은 주소를 사용자 손에 쥐여 주지 않는다.
const String shareBaseUrl = String.fromEnvironment('FANUZU_SHARE_BASE_URL');
/// 공유 링크 한 곳 — 경로는 프로토타입 구조(`/b/<팬덤>/<날짜>` · `/c/<캠페인>`)를 따른다.
/// 브리핑 · 캠페인(HOT ARCHIVE) 공유가 모두 여기를 지난다. 내 초대 코드([InviteCode])를
/// 받아 두었으면 `?ref=<코드>` 를 붙이고, 없으면 붙이지 않는다(공유를 막지 않는다).
String? shareLink(
List<String> segments, {
String base = shareBaseUrl,
String? ref,
}) {
if (base.isEmpty || segments.any((s) => s.isEmpty)) return null;
final root = base.endsWith('/') ? base.substring(0, base.length - 1) : base;
final code = ref ?? InviteCode.current;
final query = code == null || code.isEmpty
? ''
: '?ref=${Uri.encodeQueryComponent(code)}';
return '$root/${segments.map(Uri.encodeComponent).join('/')}$query';
}
/// 캠페인 상세로 돌아오는 링크 `/c/<campaignId>`.
String? campaignShareLink(String campaignId, {String base = shareBaseUrl}) =>
shareLink(['c', campaignId], base: base);
/// 해시태그를 조립한다. 최대 [maxHashtags] 개다.
///
/// B-78 — *브랜드 · Artist/Fandom · Campaign/Quest 등을 콘텐츠에 따라 최대 약
/// 3 개로 동적 구성합니다.* 세 자리를 채우는 것이 아니라 **있는 것만** 넣는다.
/// 팬덤을 모르는 캠페인은 두 개가 되고, 그것이 「고정 3종」이 아니라는 증거다.
List<String> shareHashtags({
String brand = shareBrand,
String? fandomName,
String? topic,
int maxHashtags = 3,
}) {
final tags = <String>[
for (final value in [brand, fandomName, topic])
if (value != null && value.trim().isNotEmpty) '#${_tag(value)}',
];
return tags.take(maxHashtags).toList();
}
/// 해시태그 한 조각. 공백과 해시는 태그를 끊으므로 지운다.
String _tag(String value) => value.trim().replaceAll(RegExp(r'[\s#]+'), '');
/// 캠페인 공유 문구.
///
/// 제목과 한 줄 소개, 행성명, 링크(있을 때), 해시태그로 끝난다. 소개가 비어 있으면 그 줄을
/// 빼고, 행성명을 모르면 그 줄과 팬덤 해시태그가 함께 빠진다.
String campaignShareText({
required String title,
String? summary,
String? fandomName,
String? topic,
String? link,
}) {
topic ??= appL10n.shareTopicCampaign;
final lines = <String>[
title.trim(),
if (summary != null && summary.trim().isNotEmpty) summary.trim(),
if (fandomName != null && fandomName.trim().isNotEmpty)
appL10n.sharePlanetLine(shareBrand, fandomName.trim()),
?link,
shareHashtags(fandomName: fandomName, topic: topic).join(' '),
];
return lines.where((l) => l.isNotEmpty).join('\n');
}
@@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
import '../../../core/i18n/app_locale.dart';
import '../../../core/theme/tokens.dart';
import '../../home/home_screen.dart' show formatPop;
import 'campaign_style.dart';
/// 캠페인 기여률 카드 — 시안 `272:245738`(상세) · `480:14594`(공유) 공용.
///
/// #1a1a1c r12 안쪽 12/16. 라벨 12/500 · 「92%」(ABeeZee 32 + % 20) · 10 두께 막대
/// (바탕 #000 30%, 채움 그라디언트) · 0 / 목표. 채움 끝 위에 누적 POP 말풍선(100 폭)이 뜬다.
class CampaignGauge extends StatelessWidget {
const CampaignGauge({super.key, required this.raised, required this.goal});
final int raised;
final int goal;
@override
Widget build(BuildContext context) {
final ratio = goal == 0 ? 0.0 : (raised / goal).clamp(0.0, 1.0);
final percent = goal == 0 ? 0 : ((raised / goal) * 100).round();
final axis = campaignText(
12,
400,
const Color(0xFFCDCDCD),
height: 1,
spacing: 0,
family: AppFonts.display,
);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: LayoutBuilder(
builder: (context, box) {
final fill = box.maxWidth * ratio;
return Stack(
clipBehavior: Clip.none,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.campaignGaugeLabel,
style: campaignText(
12,
500,
AppColors.onCosmicVariant,
height: 20 / 12,
spacing: 0,
),
),
Text.rich(
TextSpan(
text: '$percent',
children: [
TextSpan(
text: '%',
style: TextStyle(fontSize: 20, letterSpacing: -0.5),
),
],
),
style: campaignText(
32,
400,
AppColors.onCosmic,
height: 1.4,
family: AppFonts.display,
),
),
const SizedBox(height: 12),
Stack(
children: [
Container(
height: 10,
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(AppRadius.full),
),
),
Container(
width: fill,
height: 10,
decoration: BoxDecoration(
gradient: campaignGradient,
borderRadius: BorderRadius.circular(AppRadius.full),
),
),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('0', style: axis),
Text(formatPop(goal), style: axis),
],
),
],
),
Positioned(
top: 31.5,
left: (fill - 50).clamp(0.0, box.maxWidth - 100),
child: _Bubble(label: formatPop(raised)),
),
],
);
},
),
);
}
}
/// 누적 POP 말풍선 — 그라디언트 알약 100×32 + 아래 꼬리.
class _Bubble extends StatelessWidget {
const _Bubble({required this.label});
final String label;
@override
Widget build(BuildContext context) => SizedBox(
width: 100,
height: 38,
child: Stack(
alignment: Alignment.topCenter,
children: [
Positioned(
bottom: 0,
child: Transform.rotate(
angle: 0.785398,
child: Container(
width: 8,
height: 8,
color: const Color(0xFF5388CF),
),
),
),
Container(
height: 32,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: campaignGradient,
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Text(
label,
style: campaignText(
12,
500,
AppColors.onCosmic,
height: 1,
spacing: 0,
),
),
),
],
),
);
}
@@ -0,0 +1,246 @@
import 'package:flutter/material.dart';
import '../../../core/i18n/app_locale.dart';
import '../../../core/theme/tokens.dart';
import '../../../core/ui/app_icons.dart';
import '../../../core/ui/relative_time.dart';
import '../campaign_data.dart';
import 'campaign_style.dart';
/// 목록의 캠페인 카드 — 명세 `523:18399`.
///
/// *작성일시 타임스탬프 기준 … · 캠페인 카드 업데이트 된 시점의 달성률 표시 ·*
/// ***내용은 최대 2줄까지 이후는 … 처리*** *· 반응 수치는 K 단위 활용*
///
/// 시안(272:241184) — 이미지 342×228 위, 본문 #181818(#1e1e1e 획) 안쪽 14 · 간격 21,
/// 작성자 줄 오른쪽 「NN% 달성」 그라디언트 배지, 1px #4f4f4f 구분선, 반응 넷, 흰색 r12
/// 「캠페인 보기」.
///
/// [mine] 은 「내 캠페인」(`370:229665`) 변형 — 작성 시각과 반응 수치 없이 아이콘만 둔다.
class CampaignListCard extends StatelessWidget {
const CampaignListCard({
super.key,
required this.card,
required this.onOpen,
this.mine = false,
});
final CampaignCardData card;
final VoidCallback onOpen;
final bool mine;
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 캠페인 문서에 대표 이미지 필드가 아직 없어 시안 표본을 쓴다.
Image.asset(
'assets/figma/campaign-sample.png',
height: 228,
fit: BoxFit.cover,
),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFF181818),
border: Border.all(color: const Color(0xFF1E1E1E)),
borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(AppRadius.md),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: CampaignAuthor(
name: card.authorName,
createdAt: mine ? null : card.createdAt,
),
),
Container(
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 10),
alignment: Alignment.center,
decoration: BoxDecoration(
// 사본 「0% 달성」은 같은 그라디언트를 10% 로 깔아 회색에 가깝다.
gradient: card.achievedPercent == 0
? campaignGradient.scale(0.1)
: campaignGradient,
borderRadius: BorderRadius.circular(AppRadius.xs),
),
child: Text(
context.l10n.campaignAchieved(
'${card.achievedPercent}',
),
style: campaignText(
12,
600,
AppColors.onCosmic,
height: 1.3,
),
),
),
],
),
const SizedBox(height: 16),
Text(
card.title,
style: campaignText(
14,
400,
AppColors.onCosmic,
height: 1.3,
family: AppFonts.display,
),
),
const SizedBox(height: 7),
Text(
card.body,
style: campaignText(
14,
500,
const Color(0xFFC2C2C2),
height: 1.4,
),
// 명세 — 내용은 최대 2줄까지, 이후는 … 처리.
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 21),
const Divider(
height: 1,
thickness: 1,
color: Color(0xFF4F4F4F),
),
const SizedBox(height: 21),
Row(
children: [
_Reaction(AppIcons.heart, mine ? null : card.likes),
_Reaction(
AppIcons.messageCircle,
mine ? null : card.comments,
),
_Reaction(AppIcons.eye, mine ? null : card.views),
_Reaction(AppIcons.share, mine ? null : card.shares),
],
),
const SizedBox(height: 21),
Material(
color: AppColors.onCosmic,
borderRadius: BorderRadius.circular(AppRadius.md),
child: InkWell(
onTap: onOpen,
borderRadius: BorderRadius.circular(AppRadius.md),
child: SizedBox(
height: 38,
child: Center(
child: Text(
context.l10n.campaignView,
style: campaignText(
14,
600,
Colors.black,
height: 1.3,
),
),
),
),
),
),
],
),
),
],
),
);
}
}
/// 작성자 줄 — 단색 원 26 · 닉네임(ABeeZee 12) · 상대 시각(10 #767676). 상세도 같이 쓴다.
class CampaignAuthor extends StatelessWidget {
const CampaignAuthor({
super.key,
required this.name,
required this.createdAt,
});
final String name;
/// null 이면 시각 줄 없이 닉네임만(「내 캠페인」).
final DateTime? createdAt;
@override
Widget build(BuildContext context) => Row(
children: [
Container(
width: 26,
height: 26,
decoration: const BoxDecoration(
color: campaignAvatarColor,
shape: BoxShape.circle,
),
),
const SizedBox(width: 9),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
name,
style: campaignText(
12,
400,
AppColors.onCosmic,
height: 1.3,
family: AppFonts.display,
),
),
if (createdAt != null)
Text(
relativeTime(createdAt!),
style: campaignText(
10,
500,
AppColors.onCosmicMuted,
height: 1.3,
),
),
],
),
),
],
);
}
class _Reaction extends StatelessWidget {
const _Reaction(this.icon, this.value);
final ImageProvider icon;
/// null 이면 아이콘만.
final int? value;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(right: 12),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ImageIcon(icon, size: 14, color: AppColors.onCosmicMuted),
if (value != null) ...[
const SizedBox(width: 4),
Text(
formatReaction(value!),
style: campaignText(12, 500, AppColors.onCosmicMuted, height: 1),
),
],
],
),
);
}
@@ -0,0 +1,352 @@
import 'package:flutter/material.dart';
import '../../../core/i18n/app_locale.dart';
import '../../../core/theme/tokens.dart';
import '../../../core/ui/app_icons.dart';
import 'campaign_style.dart';
/// 캠페인 진행 기간 시트 — 사본 `333:72497`(시작일만) · `333:72719`(기간 선택 후).
///
/// 값은 변형 PNG(scale 2) 실측이다(변형 spec.json 미추출): 시트 위 모서리 24 · #101010,
/// 날짜 칸 49×49(행 간격 4) · 선택 원 #FF7DDD · 기간 띠 #19191C, 일요일 #F24949 · 이웃 달 #999999,
/// 버튼 56 높이(비활성 #333333 · 글자 #777777 / 활성 강조색 · 검정 글자).
///
/// 규칙은 OS 선택기 때와 같다 — 오늘부터 1년(365일) 안에서만 고른다. 시작 · 끝 순서로 누르고,
/// 끝을 시작보다 앞에 누르면 그 날이 새 시작이 된다.
Future<DateTimeRange?> showCampaignPeriodSheet(
BuildContext context, {
DateTimeRange? initial,
DateTime? today,
}) => showModalBottomSheet<DateTimeRange>(
context: context,
isScrollControlled: true,
backgroundColor: AppColors.cosmicSurface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (_) => CampaignPeriodSheet(
initial: initial,
today: DateUtils.dateOnly(today ?? DateTime.now()),
),
);
class CampaignPeriodSheet extends StatefulWidget {
const CampaignPeriodSheet({super.key, this.initial, required this.today});
final DateTimeRange? initial;
final DateTime today;
@override
State<CampaignPeriodSheet> createState() => _CampaignPeriodSheetState();
}
class _CampaignPeriodSheetState extends State<CampaignPeriodSheet> {
static const _cell = 49.0;
static const _rowGap = 4.0;
static const _band = Color(0xFF19191C);
static const _sunday = Color(0xFFF24949);
static const _outside = Color(0xFF999999);
static const _muted = Color(0xFF777777);
late DateTime? _start = widget.initial?.start;
late DateTime? _end = widget.initial?.end;
late DateTime _month = DateTime(
(_start ?? widget.today).year,
(_start ?? widget.today).month,
);
DateTime get _last => widget.today.add(const Duration(days: 365));
bool _selectable(DateTime d) =>
!d.isBefore(widget.today) && !d.isAfter(_last);
void _pick(DateTime d) => setState(() {
if (_start == null || _end != null || d.isBefore(_start!)) {
_start = d;
_end = null;
} else {
_end = d;
}
});
bool _canMove(int delta) {
final m = DateTime(_month.year, _month.month + delta);
final first = DateTime(widget.today.year, widget.today.month);
final last = DateTime(_last.year, _last.month);
return !m.isBefore(first) && !m.isAfter(last);
}
@override
Widget build(BuildContext context) {
final bottom = MediaQuery.paddingOf(context).bottom;
final done = _start != null && _end != null;
return Padding(
padding: EdgeInsets.only(bottom: bottom + 13),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 12, 4, 0),
child: IconButton(
tooltip: context.l10n.commonClose,
icon: const ImageIcon(AppIcons.x, size: 24),
color: AppColors.onCosmic,
onPressed: () => Navigator.of(context).pop(),
),
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.campaignPeriodTitle,
style: campaignText(
20,
600,
AppColors.onCosmic,
height: 1.4,
spacing: 0,
),
),
const SizedBox(height: 5),
Text(
context.l10n.campaignPeriodMax,
style: campaignText(16, 400, _muted, height: 1.4),
),
],
),
),
const SizedBox(height: 31),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Row(
children: [
Text(
context.l10n.campaignYearMonth(
'${_month.year}',
'${_month.month}',
),
style: campaignText(16, 400, AppColors.onCosmic, height: 1.4),
),
const Spacer(),
_MonthNav(
onPrev: _canMove(-1)
? () => setState(
() =>
_month = DateTime(_month.year, _month.month - 1),
)
: null,
onNext: _canMove(1)
? () => setState(
() =>
_month = DateTime(_month.year, _month.month + 1),
)
: null,
),
],
),
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
for (final w in context.l10n.campaignWeekdays.split(','))
Expanded(
child: Text(
w,
textAlign: TextAlign.center,
style: campaignText(12, 400, const Color(0xFF767676)),
),
),
],
),
),
const SizedBox(height: 22),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _grid(),
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
height: 56,
child: FilledButton(
onPressed: done
? () => Navigator.of(
context,
).pop(DateTimeRange(start: _start!, end: _end!))
: null,
style: FilledButton.styleFrom(
backgroundColor: AppColors.cosmicAccent,
foregroundColor: Colors.black,
disabledBackgroundColor: const Color(0xFF333333),
disabledForegroundColor: _muted,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
textStyle: campaignText(16, 600, Colors.black),
),
child: Text(
done
? context.l10n.campaignPeriodRange(
'${_start!.month}',
'${_start!.day}',
'${_end!.month}',
'${_end!.day}',
)
: context.l10n.campaignPeriodSelect,
),
),
),
),
],
),
);
}
/// 일요일 시작 주 단위 격자. 앞뒤 이웃 달 날짜로 칸을 채운다.
Widget _grid() {
final first = DateTime(_month.year, _month.month);
final lead = first.weekday % 7;
final days = DateUtils.getDaysInMonth(_month.year, _month.month);
final weeks = ((lead + days) / 7).ceil();
return Column(
children: [
for (var w = 0; w < weeks; w++) ...[
if (w > 0) const SizedBox(height: _rowGap),
Row(
children: [
for (var i = 0; i < 7; i++)
_day(
DateTime(first.year, first.month, 1 - lead + w * 7 + i),
i,
),
],
),
],
],
);
}
Widget _day(DateTime d, int column) {
final inMonth = d.month == _month.month;
final isStart = _start != null && DateUtils.isSameDay(d, _start);
final isEnd = _end != null && DateUtils.isSameDay(d, _end);
final inRange =
_start != null &&
_end != null &&
!d.isBefore(_start!) &&
!d.isAfter(_end!) &&
!DateUtils.isSameDay(_start, _end);
// 기간 띠는 시작 · 끝 원의 가운데에서 끊는다.
final band = inRange
? Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: ColoredBox(color: isStart ? Colors.transparent : _band),
),
Expanded(
child: ColoredBox(color: isEnd ? Colors.transparent : _band),
),
],
)
: null;
final selected = isStart || isEnd;
final color = selected
? AppColors.onCosmic
: !inMonth
? _outside
: column == 0
? _sunday
: AppColors.onCosmic;
final enabled = inMonth && _selectable(d);
return Expanded(
child: SizedBox(
height: _cell,
child: Stack(
alignment: Alignment.center,
children: [
if (band != null) Positioned.fill(child: band),
if (selected)
const DecoratedBox(
decoration: BoxDecoration(
color: AppColors.cosmicAccent,
shape: BoxShape.circle,
),
child: SizedBox.square(dimension: _cell),
),
Semantics(
button: enabled,
selected: selected,
child: InkResponse(
onTap: enabled ? () => _pick(d) : null,
radius: _cell / 2,
child: SizedBox.square(
dimension: _cell,
child: Center(
child: Text(
'${d.day}',
style: campaignText(12, 400, color, height: 1),
),
),
),
),
),
],
),
),
);
}
}
/// 월 이동 — 사본 64×28 알약(획 #474747) 안의 ‹ | ›.
class _MonthNav extends StatelessWidget {
const _MonthNav({this.onPrev, this.onNext});
final VoidCallback? onPrev;
final VoidCallback? onNext;
@override
Widget build(BuildContext context) {
Widget arrow(ImageProvider icon, String label, VoidCallback? onTap) =>
Semantics(
button: true,
label: label,
child: InkResponse(
onTap: onTap,
radius: 16,
child: SizedBox(
width: 31.5,
height: 28,
child: Center(
child: ImageIcon(icon, size: 20, color: AppColors.onCosmic),
),
),
),
);
return Container(
height: 28,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF474747)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
arrow(AppIcons.chevronLeft, context.l10n.calendarPrevMonth, onPrev),
Container(width: 1, height: 10, color: const Color(0xFF474747)),
arrow(AppIcons.chevronRight, context.l10n.calendarNextMonth, onNext),
],
),
);
}
}
@@ -0,0 +1,233 @@
import 'package:flutter/material.dart';
import '../../../core/theme/tokens.dart';
import '../../../core/ui/relative_time.dart';
import '../campaign_data.dart';
import 'campaign_style.dart';
/// 기여 랭킹 조각 — 시안 `333:70971`(사용자 랭킹) · 캠페인 상세 「실시간 랭킹」 공용.
/// 랭킹 데이터에 사진 필드가 없어 시안 목록의 단색 원을 순위 순서대로 돌려 쓴다.
const _avatarColors = [
Color(0xFF000000),
Color(0xFF7358E9),
Color(0xFFFA46E8),
Color(0xFFC6D284),
Color(0xFFF4CFFA),
];
/// 순위 아바타. 내 줄은 MY 프로필과 같은 시안 표본을 쓴다.
class RankAvatar extends StatelessWidget {
const RankAvatar({super.key, required this.rank, required this.size});
final ContributorRank rank;
final double size;
@override
Widget build(BuildContext context) => rank.isMe
? ClipOval(
child: Image.asset(
'assets/figma/avatar-sample.png',
width: size,
height: size,
fit: BoxFit.cover,
),
)
: Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
// 시안 목록은 4위부터 검정 · 보라 · 분홍 · 올리브 · 연분홍 순이다.
color: _avatarColors[(rank.rank + 1) % _avatarColors.length],
),
);
}
/// 순위 숫자 아래 변동값 — 상승 #f28180 ▲ · 하락 #6866f3 ▼ · 변동 없음 「-」.
class RankChange extends StatelessWidget {
const RankChange({super.key, required this.change});
final int change;
@override
Widget build(BuildContext context) {
if (change == 0) {
return Text(
'-',
style: campaignText(10, 500, const Color(0xFF777777), height: 1),
);
}
final up = change > 0;
final color = up ? const Color(0xFFF28180) : AppColors.neonViolet;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
up ? '▲' : '▼',
style: TextStyle(fontSize: 8, height: 1, color: color),
),
const SizedBox(width: 2),
Text('${change.abs()}', style: campaignText(10, 500, color, height: 1)),
],
);
}
}
/// 순위 한 줄 — 높이 60 · [순위+변동] 28 [아바타 40(원 36) · 6 · 닉네임 14/500 #979b9e] … 기여 POP 16/600.
class RankRow extends StatelessWidget {
const RankRow({
super.key,
required this.rank,
this.padding = const EdgeInsets.symmetric(vertical: 10),
});
final ContributorRank rank;
final EdgeInsets padding;
@override
Widget build(BuildContext context) => Padding(
padding: padding,
child: Row(
children: [
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 17),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${rank.rank}',
style: campaignText(14, 600, AppColors.onCosmic, height: 1),
),
const SizedBox(height: 2),
RankChange(change: rank.change),
],
),
),
const SizedBox(width: 28),
// 사본 — 40 프레임 가운데 지름 36 원.
SizedBox.square(
dimension: 40,
child: Center(child: RankAvatar(rank: rank, size: 36)),
),
const SizedBox(width: 6),
Expanded(
child: Text(
rank.name,
overflow: TextOverflow.ellipsis,
style: campaignText(14, 500, const Color(0xFF979B9E), height: 1),
),
),
Text(
formatReaction(rank.pop),
style: campaignText(16, 600, AppColors.onCosmic, height: 1.4),
),
],
),
);
}
/// 1~3위 단상 — 2 · 1 · 3 순서, 1위만 20 위로 올린다. 왕관 24 · 아바타 80.
class RankPodium extends StatelessWidget {
const RankPodium({super.key, required this.top});
/// 순위 오름차순 앞 세 줄.
final List<ContributorRank> top;
static const _crown = {
1: Color(0xFFFEA300),
2: Color(0xFFACACAC),
3: Color(0xFF9C724A),
};
@override
Widget build(BuildContext context) {
final byRank = {for (final r in top) r.rank: r};
return Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final (i, place) in const [2, 1, 3].indexed) ...[
if (i > 0) const SizedBox(width: 24),
SizedBox(width: 81, child: _place(byRank[place], place)),
],
],
);
}
Widget? _place(ContributorRank? r, int place) => r == null
? null
: Padding(
padding: EdgeInsets.only(top: place == 1 ? 0 : 20),
child: Column(
children: [
_Crown(color: _crown[place]!, place: place),
RankAvatar(rank: r, size: 80),
const SizedBox(height: 8),
Text(
r.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: campaignText(14, 600, AppColors.onCosmic, height: 1),
),
const SizedBox(height: 4),
Text(
formatReaction(r.pop),
style: campaignText(16, 600, AppColors.onCosmic, height: 1.4),
),
],
),
);
}
/// 체스 킹 왕관(시안 material-symbols:chess-king) — 십자 · 몸통을 그리고 가운데에 순위.
class _Crown extends StatelessWidget {
const _Crown({required this.color, required this.place});
final Color color;
final int place;
@override
Widget build(BuildContext context) => SizedBox(
width: 24,
height: 24,
child: CustomPaint(
painter: _CrownPainter(color),
child: Padding(
padding: const EdgeInsets.only(top: 10),
child: Center(
child: Text(
'$place',
style: campaignText(10, 900, AppColors.onCosmic, height: 1),
),
),
),
),
);
}
class _CrownPainter extends CustomPainter {
const _CrownPainter(this.color);
final Color color;
@override
void paint(Canvas canvas, Size size) {
final p = Paint()..color = color;
final w = size.width;
// 십자
canvas.drawRect(Rect.fromLTWH(w / 2 - 1, 1, 2, 7), p);
canvas.drawRect(Rect.fromLTWH(w / 2 - 3.5, 3, 7, 2), p);
// 몸통
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(3, 8, w - 6, 15),
const Radius.circular(4),
),
p,
);
}
@override
bool shouldRepaint(_CrownPainter old) => old.color != color;
}
@@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import '../../../core/i18n/app_locale.dart';
import '../../../core/theme/tokens.dart';
import '../../../core/ui/app_icons.dart';
import '../campaign_data.dart';
import 'campaign_gauge.dart';
import 'campaign_style.dart';
/// 공유 대상. 앞의 셋은 OS 공유 시트로, 링크는 문구 복사로 이어진다.
enum CampaignShareTarget {
instagram('assets/figma/campaign-share-instagram.png'),
tiktok('assets/figma/campaign-share-tiktok.png'),
x('assets/figma/campaign-share-x.png'),
link('assets/figma/campaign-share-link.png');
const CampaignShareTarget(this.asset);
final String asset;
String get label => switch (this) {
instagram => appL10n.shareInstagram,
tiktok => appL10n.shareTiktok,
x => 'X',
link => appL10n.shareCopyLink,
};
}
/// 캠페인 공유 오버레이 — 시안 `480:14594`.
///
/// #101010 r24 카드(좌우 20 여백) 안에 닫기 · 「캠페인 공유」 25/600 · 대표 이미지 303 정사각 ·
/// 제목 24/500 · 본문 · 기여률 카드 · 공유 원형 버튼 넷(36 · 간격 16).
class CampaignShareDialog extends StatelessWidget {
const CampaignShareDialog({
super.key,
required this.card,
required this.raised,
});
final CampaignCardData card;
final int raised;
/// 시안은 오버레이 카드가 상태 표시줄 자리까지 올라온다(위 23 · 아래 22). [Dialog] 는
/// 안전 영역을 비켜 가므로 [showGeneralDialog] 로 직접 띄운다.
static Future<CampaignShareTarget?> show(
BuildContext context, {
required CampaignCardData card,
required int raised,
}) => showGeneralDialog<CampaignShareTarget>(
context: context,
barrierDismissible: true,
barrierLabel: context.l10n.commonClose,
barrierColor: AppColors.cosmicVoid,
pageBuilder: (_, _, _) => CampaignShareDialog(card: card, raised: raised),
);
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(20, 23, 20, 22),
child: Material(
color: const Color(0xFF101010),
borderRadius: BorderRadius.circular(24),
clipBehavior: Clip.antiAlias,
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.centerRight,
child: InkResponse(
onTap: () => Navigator.of(context).pop(),
radius: 20,
child: Tooltip(
message: context.l10n.commonClose,
child: const ImageIcon(
AppIcons.x,
size: 24,
color: AppColors.onCosmic,
),
),
),
),
const SizedBox(height: 24),
Text(
context.l10n.campaignShareTitle,
textAlign: TextAlign.center,
style: campaignText(
25,
600,
AppColors.onCosmic,
height: 1.4,
spacing: 0,
),
),
const SizedBox(height: 16),
AspectRatio(
aspectRatio: 1,
child: Image.asset(
'assets/figma/campaign-sample.png',
fit: BoxFit.cover,
),
),
const SizedBox(height: 16),
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: 16),
CampaignGauge(raised: raised, goal: card.goalPop),
const SizedBox(height: 36),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (final (i, target)
in CampaignShareTarget.values.indexed) ...[
if (i > 0) const SizedBox(width: 16),
Tooltip(
message: target.label,
child: InkResponse(
onTap: () => Navigator.of(context).pop(target),
radius: 22,
child: _icon(target),
),
),
],
],
),
],
),
),
),
);
Widget _icon(CampaignShareTarget target) => switch (target) {
CampaignShareTarget.x => Container(
width: 36,
height: 36,
decoration: const BoxDecoration(
color: Colors.black,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Image.asset(target.asset, width: 21, height: 21),
),
_ => ClipOval(
child: Image.asset(
target.asset,
width: 36,
height: 36,
fit: BoxFit.cover,
),
),
};
}
@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import '../../../core/theme/tokens.dart';
/// Figma 사본(F5) 글자 — 크기 · 굵기 · 색 · 줄 높이를 spec.json 값 그대로 옮긴다.
///
/// 자간은 Figma 의 -2.5% 를 기본으로 둔다(글자 크기 비례). ABeeZee 에 한글이 없어
/// 한글은 42dot Sans 로 떨어진다 — Figma 렌더와 같은 결과다.
TextStyle campaignText(
double size,
int weight,
Color color, {
double? height,
double spacing = -0.025,
String family = AppFonts.body,
}) => TextStyle(
fontFamily: family,
fontFamilyFallback: const [AppFonts.body],
fontSize: size,
fontWeight: FontWeight.values[weight ~/ 100 - 1],
height: height,
letterSpacing: size * spacing,
color: color,
);
/// 달성 배지 · 기여 게이지 · 말풍선의 청록→보라 그라디언트(Figma 이미지 채움 실측).
const campaignGradient = LinearGradient(
colors: [AppColors.cosmicTeal, Color(0xFF5A69DE)],
);
/// 작성자 아바타 자리. 캠페인 문서에 작성자 사진 필드가 없어 시안의 단색 원을 쓴다.
const campaignAvatarColor = Color(0xFFED7E7E);