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

1294 lines
47 KiB
Dart

import 'dart:async';
import 'dart:typed_data' show Uint8List;
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/ui/app_dialog.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/moderation.dart';
import 'music_shake_play_screen.dart';
import 'quest_ad_screens.dart';
import 'quest_create_screen.dart' show QuestLinkCard;
import 'quest_data.dart';
import 'quest_evidence.dart';
import 'qr_scan_screen.dart';
import 'quest_reward_screen.dart';
import 'widgets/quest_card_tile.dart';
import 'widgets/time_attack_countdown.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/ui/sheet_route.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,
this.autoAct = false,
});
final String questId;
/// 목록 카드 버튼으로 들어왔다 — 상세를 읽자마자 그 버튼 동작(보상받기 · 이어하기 · 참여)을 한다.
/// 명세 `518:88405` *보상 받기 버튼 클릭 시 POP 수령*(목록에서 바로).
final bool autoAct;
/// 저장소는 호출부가 준다 — 화면이 표본을 기본값으로 만들지 않는다.
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;
/// 방금 끝낸 Music Shake 판의 판정 합계 — 결과 화면 · 결과 카드에 쓴다.
Map<String, int>? _shakeResult;
/// 진행 세션(원장 C-83) — 서버에서 읽은 값을 이 화면에서 멈추면 바로 갈아 끼운다(「이어하기」 시작점).
QuestSession? _session;
/// [QuestDetailScreen.autoAct] 를 한 번만 쓴다.
bool _autoActed = 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;
_session = detail.card.session;
});
// 목록에서 「시작하기」를 눌러 진행중으로 들어온 경우다. 여기서 다시 켜지 않으면
// 상세를 열어도 실행률이 0 에 멈춘 채로 남는다.
if (_stage == QuestStage.inProgress && detail.card.type.gathers) {
_watch(detail.card);
}
if (widget.autoAct && !_autoActed) {
_autoActed = true;
final stage = _stage!;
if (stage == QuestStage.completed ||
stage == QuestStage.paused ||
stage == QuestStage.notStarted) {
await _act(detail, stage);
}
}
}
/// 진행 세션을 서버에 남긴다(원장 C-83). 실패해도 이 화면의 진행은 계속된다 — 표시용 상태다.
Future<void> _persist(
String questId,
QuestStage next, [
QuestSession? session,
]) async {
try {
await widget.repository.advance(questId, next, session: session);
} catch (error) {
debugPrint('quest session save failed: $error');
}
}
@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: [
// 명세 `548:19275` — 더보기는 *사용자 퀘스트 중 타인 퀘스트에서만 노출*. 내 퀘스트의
// 수정 · 취소는 확정 정책(C-07 · B-30)이 더한 항목이라 생성자에게도 둔다. 운영 Quest 는 없음.
if (ugc)
PopupMenuButton<String>(
tooltip: context.l10n.commonMore,
icon: const ImageIcon(
AppIcons.ellipsisVertical,
size: 24,
color: AppColors.onCosmic,
),
onSelected: (value) => switch (value) {
'edit' => _edit(detail),
'cancel' => _cancel(detail),
_ => _report(card),
},
itemBuilder: (_) => [
// 공개 후 수정은 생성자의 제목 · 설명뿐이다(C-07).
if (stage == QuestStage.mine && ugc)
PopupMenuItem(
value: 'edit',
child: Text(context.l10n.questEdit),
),
// 생성자 취소(B-30) — 검수 중 · 공개 중에만.
if (stage == QuestStage.mine &&
ugc &&
(detail.publishState == 'review' ||
detail.publishState == 'published'))
PopupMenuItem(
value: 'cancel',
child: Text(context.l10n.questCancel),
),
if (stage != QuestStage.mine)
PopupMenuItem(
value: 'report',
child: Text(context.l10n.reportTitle),
),
],
),
],
),
body: ListView(
// 하단 바가 없는 상태(내 퀘스트)에서도 시스템 내비게이션 바 아래로 깔리지 않게.
padding: EdgeInsets.fromLTRB(
16,
10,
16,
16 + MediaQuery.paddingOf(context).bottom,
),
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.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).
// Music Shake 는 Figma `480:14506` 대로 링크 카드 대신 곡 제목 칩(아래)이다.
if (detail.link != null && card.type != QuestType.musicShake) ...[
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.
// Music Shake 는 Figma `480:14466` · `480:14506` 「목표BPM 350」 — 곡 BPM 한 값이다.
card.type == QuestType.musicShake
? _Row(
label: context.l10n.questGoalBpm,
value: card.goal > 0 ? formatPop(card.goal) : '-',
)
: _Row(
label: context.l10n.questGoal,
value:
'${formatPop(_live ?? card.progress)} / ${formatPop(card.goal)}',
),
// 일시 정지 — 멈춘 시점의 진행값을 그대로 둔다(원장 C-83 · 해석 확인 요청서 Q-OB-01 A 5절).
if (stage == QuestStage.paused && _session != null) ...[
const SizedBox(height: 8),
_Row(
label: context.l10n.questPaused,
value:
'${formatPop(_session!.progress)} / ${formatPop(_session!.total)}',
),
],
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),
],
// 인증 방법 — PRD-05 MVP 수용 기준 「장소 상세에서 인증 방법 · 보상 · 참여 현황 확인」 · 원장 C-03.
if (card.type == QuestType.place) ...[
_Row(
label: context.l10n.questVerifyMethods,
value: [
for (final m in detail.verifyMethods)
switch (m) {
'gps' => context.l10n.questVerifyGps,
'photo' => context.l10n.questVerifyPhoto,
'qr' => context.l10n.questVerifyQr,
_ => m.toUpperCase(),
},
].join(' · '),
),
const SizedBox(height: 8),
],
// 생성자 — 현장에 붙일 QR(생성 때 서버가 발급). 참여자에게는 값이 오지 않는다.
if (detail.qrCode case final code?) ...[
Text(context.l10n.questQrForPlace, style: _caption),
const SizedBox(height: 8),
Center(
child: Container(
color: Colors.white,
padding: const EdgeInsets.all(12),
child: QrImageView(data: code, size: 180),
),
),
const SizedBox(height: 16),
],
// 참여 전 간격 · 최대 횟수 안내(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,
),
],
),
),
// Figma `480:14506` — 곡 제목 칩(♪ · 검정 30% · r9999). 제목은 링크 원본(C-05).
if (card.type == QuestType.musicShake &&
(detail.link?.title ?? '').isNotEmpty)
Align(
alignment: Alignment.centerLeft,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(9999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.music_note_rounded,
size: 18,
color: AppColors.onCosmic,
),
const SizedBox(width: 12),
Flexible(
child: Text(
detail.link!.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: _strong.copyWith(fontSize: 16, height: 24 / 16),
),
),
],
),
),
),
// 회신 대기 유형은 참여를 열지 않는다 — 해석 확인 요청서 8.1 보류 범위.
if (blocked != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Text(blocked, style: _strong),
),
// 공개 전 검수(1주차 4항 · Q8) — 생성자에게 검수 대기 · 반려 사유를 보인다.
if (stage == QuestStage.mine && detail.publishState == 'review')
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Text(context.l10n.questUnderReview, style: _strong),
),
if (stage == QuestStage.mine && detail.publishState == 'rejected')
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Text(
context.l10n.questModerationRejected(
detail.moderationReason ?? '',
),
style: _strong,
),
),
if (submission != null) ..._submissionStatus(submission),
// 검토형 Quest 의 광고 추가 보상은 승인 · 지급된 날에 붙인다(10/3 통합회신 · Q-UQ-03 A).
if (submission != null &&
submission.status == 'paid' &&
submission.txId != null &&
!submission.adClaimed &&
submission.paidAt != null &&
_sameKstDay(submission.paidAt!, ServerClock.now()) &&
card.adBonusPop != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: OutlinedButton(
onPressed: _busy ? null : () => _approvedAdBonus(submission),
child: Text(context.l10n.questAdBonus('${card.adBonusPop}')),
),
),
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,
// 정원이 찬 퀘스트도 종료다(명세 `523:18176` 종료 시점 1) — 시작 전인 사람만 막힌다.
closed:
(detail.endsAt != null &&
!ServerClock.now().isBefore(detail.endsAt!)) ||
(questFull(card) && stage == QuestStage.notStarted),
)
: 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
? context.l10n.commonComingSoon
: !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 showConfirmDialog(
context,
context.l10n.questReject,
confirmLabel: context.l10n.questReject,
content: StatefulBuilder(
builder: (context, setDialog) => 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),
),
],
),
),
);
reason = input.text.trim();
input.dispose();
if (!ok || 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 showConfirmDialog(
context,
context.l10n.questSubmit,
confirmLabel: context.l10n.commonSubmit,
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,
),
),
],
),
);
final proofUrl = url.text.trim();
final proofText = text.text.trim();
url.dispose();
text.dispose();
if (!ok || (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). 잠금 필드는 입력 자체가 없다.
static bool _sameKstDay(DateTime a, DateTime b) {
String day(DateTime t) => t
.toUtc()
.add(const Duration(hours: 9))
.toIso8601String()
.substring(0, 10);
return day(a) == day(b);
}
/// 승인 지급분에 광고 추가 보상 — 기본 보상 txId 를 SSV 에 실어 광고를 보고 서버에 청구한다.
Future<void> _approvedAdBonus(QuestSubmission submission) async {
final base = QuestReward(
rewardedPop: 0,
balanceAfter: 0,
txId: submission.txId,
);
final impression = await _watchAd(base);
if (impression == null || !mounted) return;
await _run(() async {
final pop = await widget.repository.claimAdReward(impression, base);
if (mounted) _toast(context.l10n.questAdBonus(formatPop(pop)));
});
}
Future<void> _cancel(QuestDetail detail) async {
final ok = await showConfirmDialog(
context,
context.l10n.questCancel,
body: detail.publishState == 'review'
? context.l10n.questCancelInReview
: context.l10n.questCancelPublished,
confirmLabel: context.l10n.questCancel,
);
if (ok != true || !mounted) return;
await _run(() => widget.repository.cancel(detail.card.id));
}
Future<void> _edit(QuestDetail detail) async {
final title = TextEditingController(text: detail.card.title);
final description = TextEditingController(text: detail.description);
final ok = await showConfirmDialog(
context,
context.l10n.questEdit,
confirmLabel: context.l10n.commonEdit,
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,
),
),
],
),
);
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 when detail.card.type == QuestType.musicShake:
await _playShake(detail);
case QuestStage.notStarted:
// **모을 진행도가 있는 유형만 「진행중」을 지난다.** 걸음은 목표까지
// 쌓아야 하지만 출석·방문은 그 자리에서 판정이 끝나므로 참여가 곧 완료다.
if (detail.card.goal > 1 && detail.card.type.gathers) {
await _start(detail);
} else {
await _claim(detail);
}
case QuestStage.inProgress:
await _confirmStop(detail);
case QuestStage.paused:
await _playShake(detail, resume: _session);
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 {
setState(() {
_stage = QuestStage.inProgress;
_live = 0;
});
// 진행중을 먼저 남긴다 — 곧바로 목표에 닿으면 진행완료 저장이 뒤에 와야 한다.
await _persist(
detail.card.id,
QuestStage.inProgress,
QuestSession(state: QuestStage.inProgress, total: detail.card.goal),
);
if (mounted) _watch(detail.card);
}
/// Music Shake 한 판 — 공식 영상을 보며 흔드는 진행 화면을 띄운다(PRD-04).
/// 끝까지 했으면 판정표를 남기고 「보상받기」로, 중간에 나왔으면 **일시 정지**로 둔다(원장 C-83).
/// [resume] 이 있으면 멈춘 판정 · 영상 시각부터 잇는다(「이어하기」).
Future<void> _playShake(QuestDetail detail, {QuestSession? resume}) async {
final MusicShakeSetup setup;
try {
setup = await widget.repository.musicShakeSetup(detail.card.id);
} catch (error) {
if (mounted) _fail(error);
return;
}
if (!mounted) return;
// 운영값(첫 박 · 판 길이)이 바뀐 뒤의 옛 일시 정지는 박 위치가 맞지 않는다 — 버리고 새 판으로 시작한다.
// 목록 · 상세가 이미 시작하기로 보이는 경우(staleShake)도 같은 안내를 한 번 한다.
if (detail.card.staleShake || (resume != null && !resume.fitsShake(setup))) {
resume = null;
_toast(context.l10n.questShakeRestart);
}
final judge = ShakeJudge(
bpm: setup.bpm,
segments: setup.segments,
firstBeat: setup.firstBeat,
windows: setup.windows,
hits: resume?.hits ?? const [],
position: resume?.position ?? setup.startAt,
);
QuestSession snapshot(QuestStage state) => QuestSession(
state: state,
progress: judge.live(judge.position).judged,
total: setup.segments,
position: judge.position,
hits: judge.hits,
firstBeat: setup.firstBeat,
);
setState(() => _stage = QuestStage.inProgress);
// 진행 중 앱이 꺼져도 다음에 일시 정지(멈춘 자리)로 보이게 시작 시점을 남긴다.
final started = _persist(
detail.card.id,
QuestStage.inProgress,
snapshot(QuestStage.inProgress),
);
final run = await Navigator.of(context, rootNavigator: true)
.push<ShakeJudge>(
MaterialPageRoute(
builder: (_) => MusicShakePlayScreen(
setup: setup,
shakes: widget.repository.shakeEvents(),
judge: judge,
),
),
);
// 시작 저장이 일시 정지 · 진행완료 저장보다 늦게 닿지 않게 기다린다.
await started;
if (!mounted) return;
if (run != null) widget.repository.recordShake(run);
final paused = snapshot(QuestStage.paused);
setState(() {
_shakeResult = run?.judgement();
_stage = run == null ? QuestStage.paused : QuestStage.completed;
_session = run == null ? paused : null;
});
await _persist(
detail.card.id,
_stage!,
run == null
? paused
: QuestSession(
state: QuestStage.completed,
progress: setup.segments,
total: setup.segments,
firstBeat: setup.firstBeat,
judgement: run.judgement(),
),
);
}
/// 실시간 목표 실행률. 목표를 채우면 버튼이 「보상받기」로 바뀐다.
///
/// **여기서 지급하지 않는다.** 목표 도달 판정도 서버가 다시 하며(E-QST-01),
/// 이 값은 화면 진행률과 서버에 보낼 증거로만 쓴다.
void _watch(QuestCard card) {
_walk?.cancel();
_walk = widget.repository
.progressStream(card)
.listen(
(value) {
if (!mounted) return;
final reached =
card.goal > 0 &&
value >= card.goal &&
_stage != QuestStage.completed;
setState(() {
_live = value;
if (card.goal > 0 && value >= card.goal) {
_stage = QuestStage.completed;
}
});
// 진행완료를 남긴다 — 목록 「보상받기」 · 진행중 탭이 다시 읽어도 같게(원장 C-83).
if (reached) {
unawaited(
_persist(
card.id,
QuestStage.completed,
QuestSession(
state: QuestStage.completed,
progress: value,
total: card.goal,
),
),
);
}
},
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 showConfirmDialog(
context,
context.l10n.questStopConfirm,
body: context.l10n.questStopBody,
confirmLabel: context.l10n.questStop,
);
if (!stop) return;
_stopWatching();
if (mounted) {
setState(() {
_stage = QuestStage.notStarted;
_live = null;
});
}
await _persist(detail.card.id, QuestStage.notStarted);
}
/// QR 스캔 · 인증 사진 — Quest 가 고른 수단만. 하나라도 취소하면 null.
Future<PlaceProof?> _placeProof(List<String> methods) async {
String? qr;
if (methods.contains('qr')) {
qr = await Navigator.of(
context,
rootNavigator: true,
).push<String>(MaterialPageRoute(builder: (_) => const QrScanScreen()));
if (qr == null) return null;
}
Uint8List? photo;
if (methods.contains('photo')) {
// B-44 이미지 10MB 상한 — 긴 변 1600 · JPEG 80 으로 줄여 올린다.
final shot = await ImagePicker().pickImage(
source: ImageSource.camera,
maxWidth: 1600,
maxHeight: 1600,
imageQuality: 80,
);
if (shot == null) return null;
photo = await shot.readAsBytes();
}
return PlaceProof(qr: qr, photo: photo);
}
/// 보상받기.
///
/// 순서는 **참여 조건 판정 → 기본 보상 → 광고 선택**이다. 위치 · 권한 · 걸음 · 일일 한도는
/// 서버가 [QuestRepository.verify] 에서 판정하므로, 그 판정이 통과한 뒤에만 광고를 권한다.
/// 광고를 먼저 보이면 끝까지 보고도 위치 실패로 아무것도 못 받는 경로가 생긴다(2026-10-04 실기기 4-3).
///
/// 명세는 *관리자 퀘스트만 추가 보상 모달 · 사용자 퀘스트는 일반 보상 완료* 로 나눴지만
/// 별첨 B **B-28** 이 사용자 생성 Quest 에도 고정형 기여 POP Reward Ad 를 확정했다
/// (공통 기준 2항 — 확정 정책 우선). 그래서 두 갈래 모두 모달을 띄우고 금액만 다르다.
Future<void> _claim(QuestDetail detail) async {
if (_busy) return;
// 판정과 지급은 서버가 한다. 화면은 결과를 받아 띄우기만 한다 —
// 별첨 1 ② 「클라이언트 단독 지급 0건」.
// 위치 Quest 의 추가 인증(C-03) — 고른 수단의 증거를 먼저 모은다. 취소하면 아무것도 보내지 않는다.
var proof = const PlaceProof();
if (detail.card.type == QuestType.place && detail.card.endsAt == null) {
final collected = await _placeProof(detail.verifyMethods);
if (collected == null || !mounted) return;
proof = collected;
}
setState(() => _busy = true);
final QuestReward reward;
try {
reward = await widget.repository.verify(detail.card, proof: proof);
} catch (error) {
if (!mounted) return;
setState(() {
_busy = false;
// Music Shake 판정 미달(B-24 — 보상 0) — 판정표는 소진됐으니 다시 하게 시작 전으로.
if (detail.card.type == QuestType.musicShake)
_stage = QuestStage.notStarted;
});
_fail(error);
// 남겨 둔 진행완료 세션도 지운다 — 남기면 목록에 같은 판의 「보상받기」가 계속 뜬다.
if (detail.card.type == QuestType.musicShake) {
await _persist(detail.card.id, QuestStage.notStarted);
}
// 타임어택 창 밖(E-QST-01) — 서버가 본 상태로 다시 읽어 마감 · 완료를 반영한다.
if (error is BackendException &&
error.code == 'E-QST-01' &&
detail.card.endsAt != null) {
await _load();
}
return;
}
_stopWatching();
if (!mounted) return;
setState(() {
_busy = false;
_stage = QuestStage.rewarded;
});
// 시청 · 스트리밍은 무보상 참여다(C-04) — 보상 · 광고 화면 없이 참여만 알리고 링크를 연다.
if (detail.card.type == QuestType.streaming && detail.card.endsAt == null) {
_toast(context.l10n.questStreamJoined);
final url = Uri.tryParse(detail.link?.url ?? '');
if (url != null && url.hasScheme) {
await launchUrl(url, mode: LaunchMode.externalApplication);
}
await _load();
return;
}
// 이미 받은 보상의 재응답 — 보상 화면을 두 번 띄우지 않고 알리기만 한다.
if (reward.duplicate) {
_toast(context.l10n.questAlreadyRewarded(formatPop(reward.rewardedPop)));
await _load();
return;
}
// 기본 보상은 이미 원장에 있다. 광고는 그 위의 선택적 증분이다(원장 B-03 · C-122) —
// 광고가 실패해도 기본 보상은 그대로다.
// 일 한도를 다 쓴 사람에게는 모달을 띄우지 않는다(별첨 1 ⑥ 「일일 한도 도달 시 노출 제한」) —
// 띄우면 광고를 끝까지 보고도 증분 0 이다.
final adOpen = await widget.repository.adAvailable();
if (!mounted) return;
final bonus = adOpen &&
(await showQuestAdBonus(context, bonusPop: detail.card.adBonusPop) ??
false);
if (!mounted) return;
int? adPop;
if (bonus) {
// 끝까지 본 광고의 노출 ID. null 이면 추가 보상을 청구하지 않는다.
final impression = await _watchAd(reward);
if (!mounted) return;
if (impression != null) {
final claim = widget.repository.claimAdReward(impression, reward);
// 광고 시청 후 대기(`482:15084`) — 서버가 증분을 지급하는 동안 띄운다.
unawaited(_warp(QuestAdWarpScreen.after(until: claim)));
try {
adPop = await claim;
} on BackendException catch (error) {
// 한도 초과(E-AD-02) 등 — 기본 보상은 받았으니 보상 화면은 그대로 띄운다.
if (mounted) _toast(error.message);
}
if (!mounted) return;
} else if (_adShown) {
// 명세 `518:88645` — 광고 종료(100% 시청 또는 skip) 후 *광고 시청 후 애니메이션*. 끝까지
// 보지 않았으니 증분은 없다(B-03) — 애니메이션만 거쳐 완료 화면으로 간다.
await _warp(QuestAdWarpScreen.after(
until: Future<void>.delayed(const Duration(milliseconds: 1200)),
));
if (!mounted) return;
}
}
await Navigator.of(context, rootNavigator: true).push<bool>(
MaterialPageRoute(
builder: (_) => QuestRewardScreen(
detail: detail,
reward: reward,
adBonusPop: adPop,
shake: _shakeResult,
),
),
);
if (!mounted) return;
// 서버 상태를 다시 읽는다. 걸음 퀘스트는 구간을 하나 받아도 위 구간이 남아 있어
// (B-26 은 하루에 네 구간이다) 「보상완료」로 굳히면 남은 구간을 받을 수 없다.
await _load();
}
/// 광고 시청 전 대기(`494:14332`)를 띄운 채 광고를 불러와 보여 준다.
///
/// 끝까지 봤으면 노출 ID, 아니면 null 이다. 불러오지 못했거나 중간에 닫았으면
/// 그 사실을 알리고 **기본 보상으로 넘어간다**(원장 B-03 — 실패 시 기본 보상 지급 ·
/// 횟수 미차감). 대기 중 「건너뛰기」를 누르면 늦게 온 광고는 띄우지 않고 버린다.
/// 이번 보상에서 광고가 실제로 화면에 떴는가 — skip 으로 닫았을 때도 시청 후 애니메이션을 거친다.
bool _adShown = false;
Future<String?> _watchAd(QuestReward reward) async {
_adShown = false;
RewardedAdHandle? ad;
BackendException? failure;
final loading = widget.repository
.loadRewardedAd(reward)
.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;
}
_adShown = true;
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(QuestCard card) async {
final reason = await Navigator.of(context, rootNavigator: true)
.push<String>(
// 명세 `548:21389` — 신고 화면은 *페이지 아래 move out* 으로 닫힌다.
SheetRoute(
builder: (_) => ReportScreen(
// 신고 정보 영역 — *작성자 닉네임 · 게시물 제목*. 작성자 칸에 제목이 들어가던 것을 바로잡음.
target: ModerationTarget(
authorName: card.authorName,
title: card.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),
),
// Figma `264:48835` — 라벨은 왼쪽, 값(번개 포함)은 오른쪽 끝. 예전에는 라벨 Expanded 와 값 Flexible 이
// 폭을 반씩 나눠 값이 가운데 놓였다(10/6 실기기).
child: Row(
children: [
Text(
label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
height: 20 / 14,
color: AppColors.onCosmicVariant,
),
),
const SizedBox(width: 12),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
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,
),
),
),
],
),
),
],
),
);
}