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 createState() => _CampaignCreateScreenState(); } class _CampaignCreateScreenState extends State { 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 _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 _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); } }