Files
fanuzu/app/lib/features/quest/quest_create_screen.dart
T

639 lines
24 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 'location_consent.dart';
import 'quest_data.dart';
import 'widgets/quest_card_tile.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/ui/bottom_inset.dart';
import '../../core/ui/sheet_route.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();
// 만보기 목표 걸음(Figma `259:45798` 「목표 0 / 10,000」).
final _goal = TextEditingController();
/// 이 화면 한 번의 생성 요청 키 — 응답 유실 뒤 다시 눌러도 서버가 한 건으로 묶는다.
final _clientSeq = DateTime.now().microsecondsSinceEpoch.toString();
QuestCatalog? _catalog;
QuestCategory? _category;
QuestActivity? _activity;
QuestPlace? _place;
DateTime? _endDate;
QuestLink? _link;
bool _checkingLink = false;
bool _saving = false;
int? _myPop;
@override
void initState() {
super.initState();
for (final c in [_title, _description, _manualTitle, _reward, _participants, _goal]) {
// 입력이 바뀔 때마다 미리보기와 완료 버튼 활성화가 함께 움직여야 한다.
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, _goal]) {
c.dispose();
}
super.dispose();
}
Future<void> _load() async {
widget.repository.spendableBalance().then((v) {
if (mounted) setState(() => _myPop = v);
}, onError: (_) {});
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('goal') && _intOf(_goal) <= 0) 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,
// 명세 `523:18123` 미리보기 — *입력 상태에 따라 실시간 반영*. 고른 활동의 아이콘을 바로 보인다.
type: _activity == null ? QuestType.unknown : QuestType.parse(_activity!.key),
author: QuestAuthor.user,
stage: QuestStage.mine,
goal: _needs('goal') ? _intOf(_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: withBottomInset(context, 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),
),
// Figma `254:164081` 은 375 폭에서 3+1 줄(칩 88 · 114 · 111 · 간격 8 = 329)이다. 360 폭 기기(본문
// 328)에서는 1 이 모자라 2+2 로 접힌다 — 칩 치수는 사본 그대로 두고 기기 폭대로 줄을 바꾼다.
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('goal')) ...[
TextField(
controller: _goal,
keyboardType: TextInputType.number,
decoration: _field(context.l10n.questGoalSteps),
),
const SizedBox(height: AppSpace.s3),
],
if (_needs('place')) ...[
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.place_outlined),
// 명세 `523:18270` — *텍스트 넘칠 경우 ... 처리*.
title: Text(
_place?.name ?? context.l10n.questPickPlace,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: const Icon(Icons.chevron_right),
onTap: _pickPlace,
),
// 추가 인증 수단(원장 C-03 「Location 의 추가 인증 수단」 · PRD-05 「GPS · 사진 · QR 인증」).
// GPS 는 항상 들어간다. 🟡 Figma 생성 화면에 이 줄이 없어 배치는 임시다(해석 확인 요청 영역B Q-B-10).
Text(context.l10n.questExtraVerify, style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: AppSpace.s2),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final (key, label) in [
('photo', context.l10n.questVerifyPhoto),
('qr', context.l10n.questVerifyQr),
])
// 칩 모양은 Figma 카테고리 칩(`259:46062` 선택 = 흰 바탕 검은 글자) — 기본 FilterChip 은
// 선택 시 흰 바탕에 연회색 글자라 읽히지 않았다(10/6 실기기).
_CategoryChip(
label: label,
icon: null,
selected: _extraVerify.contains(key),
onTap: () => setState(
() => _extraVerify.contains(key)
? _extraVerify.remove(key)
: _extraVerify.add(key),
),
),
],
),
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),
),
// 명세 `523:18176` 완료 보상 설정 영역 — *보유 POP 값*.
if (_myPop != null)
Text.rich(
TextSpan(
text: context.l10n.commonMyPop,
children: [
TextSpan(
text: formatPop(_myPop!),
style: const TextStyle(color: AppColors.cosmicBlue),
),
],
),
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;
});
}
/// 위치 Quest 에 덧붙일 인증 수단(`photo` · `qr`).
final _extraVerify = <String>{};
Future<void> _pickPlace() async {
// 지도의 현재 위치는 위치기능이다 — 위치기반 약관에 먼저 동의받는다(Q-E1 · R02). 거부해도 지도에서
// 직접 고를 수 있다.
final located = await ensureLocationConsent(context, widget.repository.locationConsent);
if (!mounted) return;
final picked = await Navigator.of(context, rootNavigator: true).push<QuestPlace>(
// 명세 `523:18207` — 선택 완료 시 *하단 move out 페이지 종료 후 이전 화면 이동*.
SheetRoute(builder: (_) => PlacePickerScreen(useDeviceLocation: located)),
);
if (picked == null || !mounted) return;
setState(() => _place = picked);
}
/// 공개일을 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,
// 명세 `523:18176` 생성 안내 모달 — *취소 / 완료*(시안 `548:19548` · `548:19552`).
cancelLabel: context.l10n.commonCancel,
confirmLabel: context.l10n.commonDone,
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?.isPin ?? true) ? _place?.id : null,
pin: _needs('place') && (_place?.isPin ?? false) ? _place : null,
goal: _needs('goal') ? _intOf(_goal) : null,
extraVerify: _needs('place') ? _extraVerify.toList() : const [],
),
);
} 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,
),
),
],
),
),
),
);
}
}