QUEST · CAMPAIGN 화면 — 퀘스트 참여·생성·인증 · 캠페인 목록·상세·후원·랭킹
This commit is contained in:
@@ -0,0 +1,868 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/data/backend.dart';
|
||||
import '../../core/data/server_clock.dart';
|
||||
import '../../core/monetization/rewarded_ad.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/ui/app_icons.dart';
|
||||
import '../home/home_screen.dart' show formatPop;
|
||||
import '../planet/fpi_ranking_screen.dart' show MockBadge;
|
||||
import '../planet/moderation.dart';
|
||||
import 'quest_ad_screens.dart';
|
||||
import 'quest_create_screen.dart' show QuestLinkCard;
|
||||
import 'quest_data.dart';
|
||||
import 'quest_evidence.dart';
|
||||
import 'quest_reward_screen.dart';
|
||||
import 'widgets/quest_card_tile.dart';
|
||||
import 'widgets/time_attack_countdown.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
|
||||
/// 퀘스트 상세 — 화면 대조 원장 F4.
|
||||
///
|
||||
/// Figma 노드
|
||||
/// - `264:48835` 홈/퀘스트/퀘스트 상세 · `272:245274` 홈_퀘스트/퀘스트 상세
|
||||
/// - `494:14332` 광고 시청 전 · `482:15084` 광고 시청 후
|
||||
/// - `548:21261` 신고하기
|
||||
///
|
||||
/// 명세 `518:88441` — 상태별 버튼과 동작이 전부 정해져 있다.
|
||||
/// *「시작하기」 버튼 진행중으로 변경 후 실시간 목표 실행률 수집 ·*
|
||||
/// *「진행중」 퀘스트 포기 알림 노출 · 「보상받기」 관리자 퀘스트 경우 추가 보상 모달*
|
||||
class QuestDetailScreen extends StatefulWidget {
|
||||
const QuestDetailScreen({
|
||||
super.key,
|
||||
required this.questId,
|
||||
required this.repository,
|
||||
});
|
||||
|
||||
final String questId;
|
||||
|
||||
/// 저장소는 호출부가 준다 — 화면이 표본을 기본값으로 만들지 않는다.
|
||||
final QuestRepository repository;
|
||||
|
||||
@override
|
||||
State<QuestDetailScreen> createState() => _QuestDetailScreenState();
|
||||
}
|
||||
|
||||
class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
QuestDetail? _detail;
|
||||
QuestStage? _stage;
|
||||
|
||||
/// 실측 진행도. 명세 `518:88441` 의 *실시간 목표 실행률 수집* 이다.
|
||||
/// null 이면 아직 수집을 시작하지 않아 서버가 준 값을 그린다.
|
||||
int? _live;
|
||||
StreamSubscription<int>? _walk;
|
||||
|
||||
/// 서버 왕복 중. 두 번 눌러 두 번 지급되는 경로를 막는다.
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_walk?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 실측 수집을 끈다. 해지 완료를 기다리지 않는다 — 기다릴 이유가 없고,
|
||||
/// 끝난 스트림에서는 그 Future 가 늦게 완료된다.
|
||||
void _stopWatching() {
|
||||
unawaited(_walk?.cancel());
|
||||
_walk = null;
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final detail = await widget.repository.detail(widget.questId);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_detail = detail;
|
||||
_stage = detail.card.stage;
|
||||
});
|
||||
// 목록에서 「시작하기」를 눌러 진행중으로 들어온 경우다. 여기서 다시 켜지 않으면
|
||||
// 상세를 열어도 실행률이 0 에 멈춘 채로 남는다.
|
||||
if (_stage == QuestStage.inProgress && detail.card.type == QuestType.steps) {
|
||||
_watch(detail.card);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final detail = _detail;
|
||||
final stage = _stage;
|
||||
if (detail == null || stage == null) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
final card = detail.card;
|
||||
final image = questImage(card);
|
||||
final blocked = card.type.blockedReason;
|
||||
final submission = detail.submission;
|
||||
final ugc = card.author == QuestAuthor.user;
|
||||
final endsAt = card.endsAt;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
actions: [
|
||||
PopupMenuButton<String>(
|
||||
tooltip: context.l10n.commonMore,
|
||||
icon: const ImageIcon(AppIcons.ellipsisVertical, size: 24, color: AppColors.onCosmic),
|
||||
onSelected: (value) =>
|
||||
value == 'edit' ? _edit(detail) : _report(card.title),
|
||||
itemBuilder: (_) => [
|
||||
// 공개 후 수정은 생성자의 제목 · 설명뿐이다(C-07).
|
||||
if (stage == QuestStage.mine && ugc)
|
||||
PopupMenuItem(value: 'edit', child: Text(context.l10n.questEdit)),
|
||||
PopupMenuItem(value: 'report', child: Text(context.l10n.reportTitle)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 16),
|
||||
children: [
|
||||
// 명세 `518:88429` — 대표 이미지 · 카테고리 · 퀘스트 명 · 중앙 정렬.
|
||||
// 시안 `264:48835` — #2e2e2e 240 원 안에 200 이미지.
|
||||
Center(
|
||||
child: Container(
|
||||
width: 240,
|
||||
height: 240,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF2E2E2E),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: image == null
|
||||
? null
|
||||
: Image.asset(image, width: 200, height: 200),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
card.categoryName,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 20 / 15,
|
||||
color: Color(0xFF777777),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
card.title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.4,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 링크 원본(서버 조회값)과 작성자 설명을 나눠 그린다(C-05 · R10).
|
||||
if (detail.link != null) ...[
|
||||
QuestLinkCard(link: detail.link!),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (ugc && detail.description.isNotEmpty) ...[
|
||||
Text(context.l10n.questAuthorNote, style: _caption),
|
||||
const SizedBox(height: 4),
|
||||
Text(detail.description, style: _body),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
|
||||
// 명세 `518:88435` — 설정 목표 수치 · 실행한 목표 수치 · 설정 보상 POP.
|
||||
_Row(
|
||||
label: context.l10n.questGoal,
|
||||
value: '${formatPop(_live ?? card.progress)} / ${formatPop(card.goal)}',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_Row(
|
||||
label: context.l10n.questReward,
|
||||
bolt: card.rewardPop != null,
|
||||
value: card.rewardPop == null ? context.l10n.questPopGrant : '+${formatPop(card.rewardPop!)}',
|
||||
),
|
||||
// 타임어택 — 남은 시간. 마감 판정은 서버 시각이 다시 한다(FR-QST-06).
|
||||
if (endsAt != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
TimeAttackCountdown(
|
||||
endsAt: endsAt,
|
||||
builder: (context, left) => _Row(
|
||||
label: context.l10n.questTimeLeft,
|
||||
value: left == null ? context.l10n.questTimeAttackClosed : formatLeft(left),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
// 마감 순간은 서버값 그대로, 표시만 기기 시간대(C-06 · G-04).
|
||||
if (detail.endsAt != null) ...[
|
||||
_Row(label: context.l10n.questDeadline, value: _deadline(detail.endsAt!)),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
// 참여 전 간격 · 최대 횟수 안내(C-12).
|
||||
if (card.type == QuestType.place)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||
child: Text(
|
||||
detail.repeatHours == null
|
||||
? context.l10n.questRepeatOnce
|
||||
: context.l10n.questRepeatEvery('${detail.repeatHours}', '${detail.repeatMax}'),
|
||||
style: _body,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 24),
|
||||
child: Row(
|
||||
children: [
|
||||
Image.asset('assets/figma/quest-check.png', width: 20, height: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(context.l10n.questParticipants, style: _strong)),
|
||||
Text(
|
||||
card.capacity == null
|
||||
? formatPop(card.participants)
|
||||
: '${formatPop(card.participants)} / ${formatPop(card.capacity!)}',
|
||||
style: _strong,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 회신 대기 유형은 참여를 열지 않는다 — 해석 확인 요청서 8.1 보류 범위.
|
||||
// 실데이터가 아닌 표시는 기존 관례대로 MockBadge 로 알린다.
|
||||
if (blocked != null) MockBadge(note: blocked),
|
||||
if (submission != null) ..._submissionStatus(submission),
|
||||
if (detail.reviews.isNotEmpty) ..._reviewQueue(detail),
|
||||
],
|
||||
),
|
||||
// 시안 — 내가 만든 퀘스트는 하단 버튼이 없다(`264:48835`). 나머지는 하단 고정 56 버튼.
|
||||
bottomNavigationBar: stage == QuestStage.mine
|
||||
? null
|
||||
: endsAt == null
|
||||
// 사용자 생성 Quest 는 서버 시각 기준 마감이 지나면 잠근다(C-06 · G-04). 판정은 서버가 다시 한다.
|
||||
? _button(detail, stage, blocked,
|
||||
closed: detail.endsAt != null && !ServerClock.now().isBefore(detail.endsAt!))
|
||||
: TimeAttackCountdown(
|
||||
endsAt: endsAt,
|
||||
builder: (_, left) =>
|
||||
_button(detail, stage, blocked, closed: left == null),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 하단 고정 56 버튼. 타임어택은 마감([closed])되면 잠근다.
|
||||
Widget _button(
|
||||
QuestDetail detail,
|
||||
QuestStage stage,
|
||||
String? blocked, {
|
||||
required bool closed,
|
||||
}) {
|
||||
final colors = questButtonColors(stage);
|
||||
final open = !closed || stage == QuestStage.rewarded;
|
||||
final submission = detail.submission;
|
||||
// 사람 검토형의 제출 상태(C-08 · G-03) — 검토 중 · 최종 반려는 다시 누를 수 없다.
|
||||
final held = submission != null && (submission.pending || submission.finalRejected);
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: FilledButton(
|
||||
onPressed: stage.tappable && !_busy && blocked == null && open && !held
|
||||
? () => _act(detail, stage)
|
||||
: null,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(56),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
backgroundColor: colors.bg,
|
||||
foregroundColor: colors.fg,
|
||||
disabledBackgroundColor: const Color(0xFF333333),
|
||||
disabledForegroundColor: const Color(0xFF777777),
|
||||
textStyle: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 24 / 16,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
blocked != null
|
||||
? '발주사 확인 중'
|
||||
: !open
|
||||
? context.l10n.questTimeAttackClosed
|
||||
: _busy
|
||||
? context.l10n.commonChecking
|
||||
: submission != null && submission.pending
|
||||
? context.l10n.questReviewButton
|
||||
: submission != null && submission.rejected
|
||||
? (!submission.finalRejected
|
||||
? context.l10n.questResubmit
|
||||
: context.l10n.questRejectedButton)
|
||||
: detail.card.type.reviewed && stage == QuestStage.notStarted
|
||||
? context.l10n.questSubmit
|
||||
: stage.buttonLabel,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static const _caption = TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 18 / 13,
|
||||
color: Color(0xFF777777),
|
||||
);
|
||||
|
||||
static const _body = TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.4,
|
||||
color: AppColors.onCosmic,
|
||||
);
|
||||
|
||||
static String _two(int n) => n.toString().padLeft(2, '0');
|
||||
|
||||
/// 기기 시간대의 마감 시각과 남은 시간.
|
||||
String _deadline(DateTime endsAt) {
|
||||
final local = endsAt.toLocal();
|
||||
final at = '${local.year}.${_two(local.month)}.${_two(local.day)} '
|
||||
'${_two(local.hour)}:${_two(local.minute)}';
|
||||
final left = endsAt.difference(ServerClock.now());
|
||||
return left.isNegative
|
||||
? '$at · ${context.l10n.questEnded}'
|
||||
: '$at · ${context.l10n.questRemainingHours('${left.inHours}')}';
|
||||
}
|
||||
|
||||
/// 내 제출 건 안내(C-08) — 검토 중 · 지연 · 승인 후 지급 · 반려 사유와 재제출 가능 여부.
|
||||
List<Widget> _submissionStatus(QuestSubmission s) {
|
||||
final lines = <String>[
|
||||
if (s.status == 'submitted') context.l10n.questReviewPending,
|
||||
if (s.delayed(ServerClock.now())) context.l10n.questReviewDelayed,
|
||||
if (s.status == 'approved') context.l10n.questApprovedPaying,
|
||||
if (s.rejected) ...[
|
||||
context.l10n.questRejected(s.rejectReason ?? ''),
|
||||
!s.finalRejected
|
||||
? context.l10n.questResubmitAllowed
|
||||
: context.l10n.questResubmitNotAllowed,
|
||||
],
|
||||
];
|
||||
return [for (final line in lines) Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||||
child: Text(line, style: _body),
|
||||
)];
|
||||
}
|
||||
|
||||
/// 생성자 검토 대기열 — 승인 · 반려. 시간이 지나도 자동 승인하지 않는다(C-08).
|
||||
List<Widget> _reviewQueue(QuestDetail detail) => [
|
||||
const SizedBox(height: 16),
|
||||
Text(context.l10n.questReviewQueue('${detail.reviews.length}'), style: _strong),
|
||||
for (final s in detail.reviews)
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
title: Text(s.summary, style: _body),
|
||||
subtitle: s.delayed(ServerClock.now())
|
||||
? Text(context.l10n.questReviewDelayed, style: _caption)
|
||||
: null,
|
||||
trailing: Wrap(
|
||||
spacing: 4,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _busy ? null : () => _review(s, approve: true),
|
||||
child: Text(context.l10n.questApprove),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _busy ? null : () => _review(s, approve: false),
|
||||
child: Text(context.l10n.questReject),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
Future<void> _review(QuestSubmission s, {required bool approve}) async {
|
||||
String? reason;
|
||||
var resubmit = false;
|
||||
if (!approve) {
|
||||
final input = TextEditingController();
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setDialog) => AlertDialog(
|
||||
title: Text(context.l10n.questReject),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: input,
|
||||
decoration: InputDecoration(labelText: context.l10n.questRejectReason),
|
||||
),
|
||||
CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: resubmit,
|
||||
onChanged: (v) => setDialog(() => resubmit = v ?? false),
|
||||
title: Text(context.l10n.questAllowResubmit),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(context.l10n.commonCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(context.l10n.questReject),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
reason = input.text.trim();
|
||||
input.dispose();
|
||||
if (ok != true || reason.isEmpty) return;
|
||||
}
|
||||
await _run(() => widget.repository.review(
|
||||
s.id,
|
||||
approve: approve,
|
||||
reason: reason,
|
||||
resubmitAllowed: resubmit,
|
||||
));
|
||||
}
|
||||
|
||||
/// 인증 제출 · 보완 제출(C-08 · G-01) — 링크 또는 내용 중 하나 이상.
|
||||
Future<void> _submitProof(QuestDetail detail) async {
|
||||
final url = TextEditingController();
|
||||
final text = TextEditingController();
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.questSubmit),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: url,
|
||||
keyboardType: TextInputType.url,
|
||||
decoration: InputDecoration(labelText: context.l10n.questEvidenceUrl),
|
||||
),
|
||||
TextField(
|
||||
controller: text,
|
||||
maxLines: 3,
|
||||
minLines: 1,
|
||||
decoration: InputDecoration(labelText: context.l10n.questEvidenceText),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(context.l10n.commonCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(context.l10n.commonSubmit),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final proofUrl = url.text.trim();
|
||||
final proofText = text.text.trim();
|
||||
url.dispose();
|
||||
text.dispose();
|
||||
if (ok != true || (proofUrl.isEmpty && proofText.isEmpty)) return;
|
||||
final done = await _run(
|
||||
() => widget.repository.submit(detail.card.id, url: proofUrl, text: proofText),
|
||||
);
|
||||
if (done && mounted) _toast(context.l10n.questSubmitted);
|
||||
}
|
||||
|
||||
/// 제목 · 설명만 고친다(C-07). 잠금 필드는 입력 자체가 없다.
|
||||
Future<void> _edit(QuestDetail detail) async {
|
||||
final title = TextEditingController(text: detail.card.title);
|
||||
final description = TextEditingController(text: detail.description);
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.questEdit),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(context.l10n.questEditNotice, style: _caption),
|
||||
TextField(
|
||||
controller: title,
|
||||
decoration: InputDecoration(labelText: context.l10n.questName),
|
||||
),
|
||||
TextField(
|
||||
controller: description,
|
||||
maxLines: 3,
|
||||
minLines: 1,
|
||||
decoration: InputDecoration(labelText: context.l10n.questDescriptionOptional),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(context.l10n.commonCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(context.l10n.commonEdit),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final newTitle = title.text.trim();
|
||||
final newDescription = description.text.trim();
|
||||
title.dispose();
|
||||
description.dispose();
|
||||
if (ok != true || newTitle.isEmpty) return;
|
||||
await _run(() => widget.repository.edit(
|
||||
detail.card.id,
|
||||
title: newTitle,
|
||||
description: newDescription,
|
||||
));
|
||||
}
|
||||
|
||||
/// 서버 호출 한 번 — 실패는 알리고, 성공하면 상세를 다시 읽는다.
|
||||
Future<bool> _run(Future<void> Function() call) async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await call();
|
||||
} catch (error) {
|
||||
if (!mounted) return false;
|
||||
setState(() => _busy = false);
|
||||
_fail(error);
|
||||
return false;
|
||||
}
|
||||
if (!mounted) return false;
|
||||
setState(() => _busy = false);
|
||||
await _load();
|
||||
return true;
|
||||
}
|
||||
|
||||
static const _strong = TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 20 / 15,
|
||||
color: AppColors.onCosmic,
|
||||
);
|
||||
|
||||
Future<void> _act(QuestDetail detail, QuestStage stage) async {
|
||||
switch (stage) {
|
||||
case QuestStage.notStarted when detail.card.type.reviewed:
|
||||
await _submitProof(detail);
|
||||
case QuestStage.notStarted:
|
||||
// **모을 진행도가 있는 유형만 「진행중」을 지난다.** 걸음은 목표까지
|
||||
// 쌓아야 하지만 출석·방문은 그 자리에서 판정이 끝나므로 참여가 곧 완료다.
|
||||
if (detail.card.goal > 1 && detail.card.type == QuestType.steps) {
|
||||
await _start(detail);
|
||||
} else {
|
||||
await _claim(detail);
|
||||
}
|
||||
case QuestStage.inProgress:
|
||||
await _confirmStop(detail);
|
||||
case QuestStage.completed:
|
||||
await _claim(detail);
|
||||
case QuestStage.mine:
|
||||
_toast(context.l10n.questMine('${detail.card.participants}'));
|
||||
case QuestStage.rewarded:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// 「시작하기」 — 진행중으로 바꾸고 실측 수집을 켠다.
|
||||
Future<void> _start(QuestDetail detail) async {
|
||||
final next =
|
||||
await widget.repository.advance(detail.card.id, QuestStage.inProgress);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_stage = next;
|
||||
_live = 0;
|
||||
});
|
||||
_watch(detail.card);
|
||||
}
|
||||
|
||||
/// 실시간 목표 실행률. 목표를 채우면 버튼이 「보상받기」로 바뀐다.
|
||||
///
|
||||
/// **여기서 지급하지 않는다.** 목표 도달 판정도 서버가 다시 하며(E-QST-01),
|
||||
/// 이 값은 화면 진행률과 서버에 보낼 증거로만 쓴다.
|
||||
void _watch(QuestCard card) {
|
||||
_walk?.cancel();
|
||||
_walk = widget.repository.progressStream(card).listen(
|
||||
(value) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_live = value;
|
||||
if (card.goal > 0 && value >= card.goal) {
|
||||
_stage = QuestStage.completed;
|
||||
}
|
||||
});
|
||||
},
|
||||
onError: (Object error) {
|
||||
// 권한 거부·센서 부재. 진행 상태를 시작 전으로 되돌려야 사용자가 다시
|
||||
// 시도할 수 있고, 미동의 상태에서도 화면은 그대로 쓸 수 있다(B-27).
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_stage = QuestStage.notStarted;
|
||||
_live = null;
|
||||
});
|
||||
_fail(error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 명세 `518:88552` — *진행중 버튼 클릭 시 노출 · 취소 버튼 모달 종료 ·*
|
||||
/// *중단 버튼 퀘스트 진행 상태값 시작전으로 업데이트*
|
||||
Future<void> _confirmStop(QuestDetail detail) async {
|
||||
final stop = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.questStopConfirm),
|
||||
content: Text(context.l10n.questStopBody),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(context.l10n.commonCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(context.l10n.questStop),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (stop != true) return;
|
||||
_stopWatching();
|
||||
final next =
|
||||
await widget.repository.advance(detail.card.id, QuestStage.notStarted);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_stage = next;
|
||||
_live = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 보상받기.
|
||||
///
|
||||
/// 명세는 *관리자 퀘스트만 추가 보상 모달 · 사용자 퀘스트는 일반 보상 완료* 로 나눴지만
|
||||
/// 별첨 B **B-28** 이 사용자 생성 Quest 에도 고정형 기여 POP Reward Ad 를 확정했다
|
||||
/// (공통 기준 2항 — 확정 정책 우선). 그래서 두 갈래 모두 모달을 띄우고 금액만 다르다.
|
||||
Future<void> _claim(QuestDetail detail) async {
|
||||
if (_busy) return;
|
||||
final bonus =
|
||||
await showQuestAdBonus(context, bonusPop: detail.card.adBonusPop) ?? false;
|
||||
if (!mounted) return;
|
||||
// 끝까지 본 광고의 노출 ID. null 이면 추가 보상을 청구하지 않는다.
|
||||
String? impression;
|
||||
if (bonus) {
|
||||
impression = await _watchAd();
|
||||
if (!mounted) return;
|
||||
}
|
||||
|
||||
// 판정과 지급은 서버가 한다. 화면은 결과를 받아 띄우기만 한다 —
|
||||
// 별첨 1 ② 「클라이언트 단독 지급 0건」.
|
||||
// 기본 보상을 먼저 받고 광고 증분을 뒤에 청구한다(원장 B-03 데이터 관계) —
|
||||
// 광고 쪽이 실패해도 기본 보상은 이미 지급되어 있다.
|
||||
setState(() => _busy = true);
|
||||
final QuestReward reward;
|
||||
int? adPop;
|
||||
BackendException? adError;
|
||||
try {
|
||||
final pending = () async {
|
||||
final base = await widget.repository.verify(detail.card);
|
||||
if (impression != null && !base.duplicate) {
|
||||
try {
|
||||
adPop = await widget.repository.claimAdReward(impression, base);
|
||||
} on BackendException catch (error) {
|
||||
adError = error;
|
||||
}
|
||||
}
|
||||
return base;
|
||||
}();
|
||||
// 광고 시청 후 대기(`482:15084`) — 서버가 판정·지급하는 동안 띄운다.
|
||||
if (impression != null) {
|
||||
unawaited(_warp(QuestAdWarpScreen.after(until: pending)));
|
||||
}
|
||||
reward = await pending;
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
setState(() => _busy = false);
|
||||
_fail(error);
|
||||
// 타임어택 창 밖(E-QST-01) — 서버가 본 상태로 다시 읽어 마감 · 완료를 반영한다.
|
||||
if (error is BackendException &&
|
||||
error.code == 'E-QST-01' &&
|
||||
detail.card.type == QuestType.timeAttack) {
|
||||
await _load();
|
||||
}
|
||||
return;
|
||||
}
|
||||
_stopWatching();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_stage = QuestStage.rewarded;
|
||||
});
|
||||
// 이미 받은 보상의 재응답 — 보상 화면을 두 번 띄우지 않고 알리기만 한다.
|
||||
if (reward.duplicate) {
|
||||
_toast(context.l10n.questAlreadyRewarded(formatPop(reward.rewardedPop)));
|
||||
await _load();
|
||||
return;
|
||||
}
|
||||
// 한도 초과(E-AD-02) 등 — 기본 보상은 받았으니 보상 화면은 그대로 띄운다.
|
||||
final failed = adError;
|
||||
if (failed != null) _toast(failed.message);
|
||||
|
||||
await Navigator.of(context, rootNavigator: true).push<bool>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
QuestRewardScreen(detail: detail, reward: reward, adBonusPop: adPop),
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
// 서버 상태를 다시 읽는다. 걸음 퀘스트는 구간을 하나 받아도 위 구간이 남아 있어
|
||||
// (B-26 은 하루에 네 구간이다) 「보상완료」로 굳히면 남은 구간을 받을 수 없다.
|
||||
await _load();
|
||||
}
|
||||
|
||||
/// 광고 시청 전 대기(`494:14332`)를 띄운 채 광고를 불러와 보여 준다.
|
||||
///
|
||||
/// 끝까지 봤으면 노출 ID, 아니면 null 이다. 불러오지 못했거나 중간에 닫았으면
|
||||
/// 그 사실을 알리고 **기본 보상으로 넘어간다**(원장 B-03 — 실패 시 기본 보상 지급 ·
|
||||
/// 횟수 미차감). 대기 중 「건너뛰기」를 누르면 늦게 온 광고는 띄우지 않고 버린다.
|
||||
Future<String?> _watchAd() async {
|
||||
RewardedAdHandle? ad;
|
||||
BackendException? failure;
|
||||
final loading = widget.repository.loadRewardedAd().then<void>(
|
||||
(handle) => ad = handle,
|
||||
onError: (Object error) => failure = error is BackendException
|
||||
? error
|
||||
: BackendException('E-AD-01', context.l10n.adLoadFailed),
|
||||
);
|
||||
await _warp(QuestAdWarpScreen.before(until: loading));
|
||||
final loaded = ad;
|
||||
if (!mounted) {
|
||||
loaded?.dispose();
|
||||
return null;
|
||||
}
|
||||
if (loaded == null) {
|
||||
final error = failure;
|
||||
if (error != null) {
|
||||
_toast(error.message);
|
||||
} else {
|
||||
// 건너뛰기 — 아직 로딩 중인 광고는 도착하는 대로 버린다.
|
||||
unawaited(loading.then((_) => ad?.dispose()));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
final impression = await loaded.show();
|
||||
if (impression == null && mounted) {
|
||||
_toast(context.l10n.adNotFinished);
|
||||
}
|
||||
return impression;
|
||||
}
|
||||
|
||||
/// 실패를 사용자 말로 옮긴다. 분기는 에러 카탈로그 코드로 한다 —
|
||||
/// gRPC 상태는 여러 사유가 겹쳐 들어온다(`Backend.call`).
|
||||
void _fail(Object error) {
|
||||
final message = error is BackendException
|
||||
? error.message
|
||||
: context.l10n.commonRequestFailed;
|
||||
final permission = error is BackendException && error.code == 'E-PERM-01';
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(
|
||||
content: Text(message),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
action: permission
|
||||
? SnackBarAction(
|
||||
label: context.l10n.openSettings,
|
||||
onPressed: openQuestPermissionSettings,
|
||||
)
|
||||
: null,
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> _warp(QuestAdWarpScreen screen) =>
|
||||
Navigator.of(context, rootNavigator: true).push<void>(
|
||||
MaterialPageRoute(builder: (_) => screen),
|
||||
);
|
||||
|
||||
Future<void> _report(String title) async {
|
||||
final reason = await Navigator.of(context, rootNavigator: true).push<String>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ReportScreen(
|
||||
target: ModerationTarget(authorName: title, title: context.l10n.questNamed(title)),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (reason != null && mounted) _toast(context.l10n.reportReceived);
|
||||
}
|
||||
|
||||
void _toast(String message) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 목표·보상 줄 — #1a1a1c r24 · 높이 62 · 안쪽 20.
|
||||
class _Row extends StatelessWidget {
|
||||
const _Row({required this.label, required this.value, this.bolt = false});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
/// 보상 POP 앞 번개(민트).
|
||||
final bool bolt;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
constraints: const BoxConstraints(minHeight: 62),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cosmicSurfaceContainer,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 20 / 14,
|
||||
color: AppColors.onCosmicVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (bolt) ...[
|
||||
Image.asset('assets/figma/icon-bolt.png', width: 16, height: 16.7),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
// 마감 줄처럼 값이 길면 줄을 바꾼다 — 좁은 화면에서 넘치지 않게.
|
||||
Flexible(
|
||||
child: Text(
|
||||
value,
|
||||
textAlign: TextAlign.end,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.4,
|
||||
letterSpacing: -0.4,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user