QUEST · CAMPAIGN 화면 — 퀘스트 참여·생성·인증 · 캠페인 목록·상세·후원·랭킹
This commit is contained in:
@@ -0,0 +1,576 @@
|
||||
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<QuestCreateScreen> createState() => _QuestCreateScreenState();
|
||||
}
|
||||
|
||||
class _QuestCreateScreenState extends State<QuestCreateScreen> {
|
||||
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<QuestPlace> _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<void> _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 <QuestCategory>[])
|
||||
_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<void> _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<void> _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<String>(
|
||||
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<void> _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<void> _confirm() async {
|
||||
final pool = _paid ? _pool : 0;
|
||||
final fee = _catalog?.feeOf(pool) ?? 0;
|
||||
final go = await showDialog<bool>(
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user