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

509 lines
18 KiB
Dart

import 'package:flutter/material.dart';
import '../../core/ui/app_dialog.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 '../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: [
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 showConfirmDialog(
context,
context.l10n.campaignSubmitTitle,
body: context.l10n.campaignSubmitBody(
_title.text.trim(),
formatPop(_goalPop),
'$_days',
formatPop(campaignRegistrationFeePop),
),
cancelLabel: context.l10n.commonKeepEditing,
confirmLabel: context.l10n.campaignSubmit,
);
if (!go || !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 showAlertDialog(
context,
context.l10n.campaignPendingTitle,
body: context.l10n.campaignPendingBody,
);
if (mounted) Navigator.of(context).pop(id);
}
}