567 lines
20 KiB
Dart
567 lines
20 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../../core/ui/app_dialog.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 showConfirmDialog(
|
|
context,
|
|
context.l10n.questCreateConfirm,
|
|
cancelLabel: context.l10n.commonKeepEditing,
|
|
confirmLabel: context.l10n.questCreate,
|
|
body: context.l10n.questCreateCost(
|
|
_preview.title,
|
|
formatPop(_intOf(_reward)),
|
|
formatPop(_intOf(_participants)),
|
|
formatPop(pool),
|
|
formatPop(fee),
|
|
formatPop(pool + fee),
|
|
),
|
|
);
|
|
if (!go || !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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|