QUEST · CAMPAIGN 화면 — 퀘스트 참여·생성·인증 · 캠페인 목록·상세·후원·랭킹
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user