import 'package:flutter/material.dart'; import '../../core/data/backend.dart'; import '../../core/data/server_clock.dart'; import '../../core/theme/tokens.dart'; import '../home/home_screen.dart' show formatPop; import 'place_picker_screen.dart'; import 'quest_data.dart'; import 'widgets/quest_card_tile.dart'; import '../../core/i18n/app_locale.dart'; /// 퀘스트 생성 — 화면 대조 원장 F4 · Figma `254:164081`. /// /// 명세 `523:18152` — *완료 버튼 디폴트 비활성화. 입력 항목이 모두 선택될 경우 활성화 ·* /// *클릭 시 생성 안내 모달 노출* /// 명세 `523:18163` — *퀘스트 미리보기 카드 · 입력 상태에 따라 실시간 반영* /// 명세 `523:18173` — *디폴트 카테고리 항목 미선택 · 단일 선택 가능 ·* /// *선택한 카테고리에 해당하는 입력필드 노출* class QuestCreateScreen extends StatefulWidget { const QuestCreateScreen({super.key, required this.repository}); /// 저장소는 호출부가 준다 — 화면이 표본을 기본값으로 만들지 않는다. final QuestRepository repository; @override State createState() => _QuestCreateScreenState(); } class _QuestCreateScreenState extends State { final _title = TextEditingController(); final _description = TextEditingController(); final _url = TextEditingController(); final _manualTitle = TextEditingController(); final _reward = TextEditingController(); final _participants = TextEditingController(); /// 이 화면 한 번의 생성 요청 키 — 응답 유실 뒤 다시 눌러도 서버가 한 건으로 묶는다. final _clientSeq = DateTime.now().microsecondsSinceEpoch.toString(); QuestCatalog? _catalog; QuestCategory? _category; QuestActivity? _activity; List _places = const []; QuestPlace? _place; DateTime? _endDate; QuestLink? _link; bool _checkingLink = false; bool _saving = false; @override void initState() { super.initState(); for (final c in [_title, _description, _manualTitle, _reward, _participants]) { // 입력이 바뀔 때마다 미리보기와 완료 버튼 활성화가 함께 움직여야 한다. c.addListener(() => setState(() {})); } // 주소가 바뀌면 이전 미리보기는 더 이상 이 주소의 것이 아니다. _url.addListener(() { if (_link != null && _link!.url != _url.text.trim()) setState(() => _link = null); }); _load(); } @override void dispose() { for (final c in [_title, _description, _url, _manualTitle, _reward, _participants]) { c.dispose(); } super.dispose(); } Future _load() async { final catalog = await widget.repository.catalog(); if (mounted) setState(() => _catalog = catalog); } int _intOf(TextEditingController c) => int.tryParse(c.text.trim()) ?? 0; bool _needs(String field) => _activity?.fields.contains(field) ?? false; bool get _paid => !(_activity?.noReward ?? true); int get _pool => _intOf(_reward) * _intOf(_participants); /// 완료 버튼 활성화 조건 — 선택한 활동에 필요한 입력만 본다(C-01). bool get _ready { if (_activity == null || _title.text.trim().isEmpty) return false; if (_needs('place') && _place == null) return false; if (_needs('link')) { final link = _link; if (link == null || !(link.ok || (link.failed && _manualTitle.text.trim().isNotEmpty))) { return false; } } return !_paid || (_intOf(_reward) > 0 && _intOf(_participants) > 0); } QuestCard get _preview => QuestCard( id: 'preview', title: _title.text.trim().isEmpty ? context.l10n.questPreviewHint : _title.text.trim(), categoryName: _activity?.name ?? _category?.name ?? context.l10n.questNoCategory, author: QuestAuthor.user, stage: QuestStage.mine, goal: 1, progress: 0, rewardPop: _intOf(_reward), // 시안 — *참여 인원 : 0 / 0*. 분모가 모집 인원이다. participants: 0, capacity: _intOf(_participants), ); /// 카테고리 칩 아이콘 — 시안 `254:164081`. 분류 키(`config/questCatalog`)로 고른다. static String? _chipIcon(String key) => switch (key) { 'health' => 'assets/figma/quest-pulse.png', 'planet' => 'assets/figma/quest-globe.png', 'community' => 'assets/figma/quest-people.png', 'location' => 'assets/figma/quest-pin.png', _ => null, }; static const _fieldBorder = OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(AppRadius.sm)), borderSide: BorderSide(color: Color(0xFF333333)), ); InputDecoration _field(String label, {String? helper, String? error, Widget? suffix}) => InputDecoration( labelText: label, helperText: helper, errorText: error, suffixIcon: suffix, border: _fieldBorder, enabledBorder: _fieldBorder, ); static const _sectionStyle = TextStyle( fontSize: 20, fontWeight: FontWeight.w500, height: 28 / 20, letterSpacing: -0.5, color: AppColors.onCosmic, ); @override Widget build(BuildContext context) { final catalog = _catalog; final category = _category; final activity = _activity; final link = _link; return Scaffold( appBar: AppBar( leading: const CloseButton(), title: Text(context.l10n.questNew), actions: [ TextButton( onPressed: _ready && !_saving ? _confirm : null, style: TextButton.styleFrom( foregroundColor: AppColors.onCosmic, disabledForegroundColor: const Color(0xFF474747), padding: const EdgeInsets.symmetric(horizontal: 16), textStyle: const TextStyle( fontFamily: AppFonts.body, fontSize: 14, fontWeight: FontWeight.w600, height: 1, letterSpacing: -0.35, ), ), child: Text(context.l10n.commonDone), ), ], ), body: ListView( padding: const EdgeInsets.all(AppSpace.s4), children: [ // 명세 — 입력 상태에 따라 실시간 반영. QuestCardTile.preview(card: _preview), const SizedBox(height: 24), const Divider(height: 1, thickness: 1, color: Color(0xFF242424)), const SizedBox(height: 24), Text(context.l10n.questCategory, style: _sectionStyle), const SizedBox(height: 20), if (catalog != null && catalog.categories.isEmpty) Text( context.l10n.questNoCreatable, style: const TextStyle(color: AppColors.onCosmicVariant), ), Wrap( spacing: 8, runSpacing: 8, children: [ for (final c in catalog?.categories ?? const []) _CategoryChip( label: c.name, icon: _chipIcon(c.id), selected: category?.id == c.id, onTap: () => setState(() { _category = c; // 분류를 바꾸면 활동과 그 활동의 입력을 다시 고른다. _activity = c.activities.length == 1 ? c.activities.first : null; _place = null; _link = null; }), ), ], ), // 명세 `523:18182` — *액션 종류(어드민에서 등록된 데이터) · 선택한 카테고리에 따라 노출*. if (category != null) ...[ const SizedBox(height: 36), Text(context.l10n.questActivity, style: _sectionStyle), const SizedBox(height: 20), Wrap( spacing: 8, runSpacing: 8, children: [ for (final a in category.activities) _CategoryChip( label: a.name, icon: null, selected: activity?.key == a.key, onTap: () => setState(() { _activity = a; _place = null; _link = null; }), ), ], ), ], // 선택한 활동에 필요한 입력만(C-01). if (activity != null) ...[ const SizedBox(height: 36), TextField(controller: _title, decoration: _field(context.l10n.questName)), const SizedBox(height: AppSpace.s3), TextField( controller: _description, maxLines: 3, minLines: 1, decoration: _field(context.l10n.questDescriptionOptional), ), const SizedBox(height: AppSpace.s3), if (_needs('link')) ...[ TextField( controller: _url, keyboardType: TextInputType.url, decoration: _field( context.l10n.questLinkUrl, error: link?.status == 'invalid' ? context.l10n.questLinkInvalid : null, suffix: IconButton( tooltip: context.l10n.questLinkCheck, icon: _checkingLink ? const SizedBox.square( dimension: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.search), onPressed: _checkingLink ? null : _checkLink, ), ), onSubmitted: (_) => _checkLink(), ), if (link != null && link.ok) ...[ const SizedBox(height: AppSpace.s3), QuestLinkCard(link: link), ], if (link != null && link.failed) ...[ const SizedBox(height: AppSpace.s3), TextField( controller: _manualTitle, decoration: _field( context.l10n.questLinkTitle, helper: context.l10n.questLinkPreviewFailed, ), ), ], const SizedBox(height: AppSpace.s3), ], if (_needs('place')) ...[ ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.place_outlined), title: Text(_place?.name ?? context.l10n.questPickPlace), trailing: const Icon(Icons.chevron_right), onTap: _pickPlace, ), const SizedBox(height: AppSpace.s3), ], // 기간 — 기본 공개 + 24h, 종료일은 생성자 시간대의 23:59:59(C-06). ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.event_outlined), title: Text(context.l10n.questEndDate), subtitle: Text( _endDate == null ? context.l10n.questEndDefault : context.l10n.questEndDateValue(_dateLabel(_endDate!)), ), trailing: const Icon(Icons.chevron_right), onTap: _pickEndDate, ), const SizedBox(height: AppSpace.s3), if (_paid) ...[ Row( children: [ Expanded( child: TextField( controller: _participants, keyboardType: TextInputType.number, decoration: _field(context.l10n.questParticipants), ), ), const SizedBox(width: AppSpace.s3), Expanded( child: TextField( controller: _reward, keyboardType: TextInputType.number, decoration: _field(context.l10n.questRewardPop), ), ), ], ), const SizedBox(height: AppSpace.s2), // 명세 `523:18194` — *참여 인원 및 완료 보상 POP 기준 계산* · B-30 수수료. Text( context.l10n.questPoolHelper( formatPop(_pool), formatPop(catalog?.feeOf(_pool) ?? 0), ), style: const TextStyle(fontSize: 13, color: AppColors.onCosmicVariant), ), ] else Text( context.l10n.questNoReward, style: const TextStyle(fontSize: 13, color: AppColors.onCosmicVariant), ), ], ], ), ); } static String _dateLabel(DateTime d) => '${d.year}.${d.month.toString().padLeft(2, '0')}.${d.day.toString().padLeft(2, '0')}'; Future _checkLink() async { final url = _url.text.trim(); if (url.isEmpty || _checkingLink) return; setState(() => _checkingLink = true); QuestLink link; try { link = await widget.repository.previewLink(url); } on BackendException { // 조회 호출 자체가 실패해도 주소가 틀렸다는 뜻은 아니다 — 기본 카드 경로(C-05). link = QuestLink(url: url, status: 'preview_failed'); } if (!mounted) return; setState(() { _checkingLink = false; _link = _url.text.trim() == url ? link : null; }); } Future _pickPlace() async { if (_places.isEmpty) { final places = await widget.repository.places(); if (!mounted) return; _places = places; } final name = await Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( builder: (_) => PlacePickerScreen(places: [for (final p in _places) p.name]), ), ); if (name == null || !mounted) return; setState(() => _place = _places.firstWhere((p) => p.name == name)); } /// 공개일을 1일째로 최대 [QuestCatalog.maxDays] 일째까지. 남은 1시간 미만 판정은 서버가 한다. Future _pickEndDate() async { // 오늘은 서버 시각의 현지 날짜 — 단말 시계를 돌려도 선택 범위가 늘지 않는다(G-04). final today = DateUtils.dateOnly(ServerClock.now()); final picked = await showDatePicker( context: context, initialDate: _endDate ?? today, firstDate: today, lastDate: today.add(Duration(days: (_catalog?.maxDays ?? 30) - 1)), ); if (picked != null && mounted) setState(() => _endDate = picked); } /// 명세 — *완료 버튼 클릭 시, 생성 안내 모달 노출*. Future _confirm() async { final pool = _paid ? _pool : 0; final fee = _catalog?.feeOf(pool) ?? 0; final go = await showDialog( context: context, builder: (context) => AlertDialog( title: Text(context.l10n.questCreateConfirm), content: Text( context.l10n.questCreateCost( _preview.title, formatPop(_intOf(_reward)), formatPop(_intOf(_participants)), formatPop(pool), formatPop(fee), formatPop(pool + fee), ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: Text(context.l10n.commonKeepEditing), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), child: Text(context.l10n.questCreate), ), ], ), ); if (go != true || !mounted) return; setState(() => _saving = true); final String id; try { id = await widget.repository.create( QuestDraft( clientSeq: _clientSeq, activityKey: _activity!.key, title: _title.text.trim(), description: _description.text.trim(), rewardPop: _paid ? _intOf(_reward) : 0, participants: _paid ? _intOf(_participants) : 0, endDate: _endDate, url: _needs('link') ? _link?.url : null, manualTitle: _needs('link') && (_link?.failed ?? false) ? _manualTitle.text.trim() : null, placeId: _needs('place') ? _place?.id : null, ), ); } on BackendException catch (error) { // 잔액 부족 · 자격 · 기간 거부는 서버 문구를 그대로 띄운다. 삼키면 「아무 일도 없다」로 보인다. if (!mounted) return; setState(() => _saving = false); ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar(SnackBar( content: Text(error.message), behavior: SnackBarBehavior.floating, )); return; } if (mounted) Navigator.of(context).pop(id); } } /// 링크 카드 — 서버가 조회한 **원본** 제목 · 썸네일 · 서비스명(C-05 · R10). 작성자 설명과 섞지 않는다. class QuestLinkCard extends StatelessWidget { const QuestLinkCard({super.key, required this.link}); final QuestLink link; @override Widget build(BuildContext context) { final thumb = link.thumbnail; return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppColors.cosmicSurfaceContainer, borderRadius: BorderRadius.circular(AppRadius.sm), ), child: Row( children: [ if (thumb != null) ...[ ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.network( thumb, width: 96, height: 54, fit: BoxFit.cover, errorBuilder: (_, _, _) => const SizedBox(width: 96, height: 54), ), ), const SizedBox(width: 12), ], Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (link.provider != null) Text( link.provider!, style: const TextStyle(fontSize: 12, color: AppColors.onCosmicVariant), ), Text( link.title.isEmpty ? link.url : link.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.onCosmic, ), ), ], ), ), ], ), ); } } /// 카테고리 칩 — #1a1a1a · #232324 테두리 · 높이 42 알약. 고르면 흰 바탕 검은 글자. class _CategoryChip extends StatelessWidget { const _CategoryChip({ required this.label, required this.icon, required this.selected, required this.onTap, }); final String label; final String? icon; final bool selected; final VoidCallback onTap; @override Widget build(BuildContext context) { final fg = selected ? Colors.black : AppColors.onCosmic; return Semantics( button: true, selected: selected, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(AppRadius.full), child: Container( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), decoration: BoxDecoration( color: selected ? AppColors.onCosmic : const Color(0xFF1A1A1A), border: Border.all(color: const Color(0xFF232324)), borderRadius: BorderRadius.circular(AppRadius.full), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ if (icon != null) ...[ ImageIcon(AssetImage(icon!), size: 20, color: fg), const SizedBox(width: 4), ], Text( label, style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, height: 20 / 14, letterSpacing: -0.5, color: fg, ), ), ], ), ), ), ); } }