530 lines
18 KiB
Dart
530 lines
18 KiB
Dart
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);
|
|
}
|
|
}
|