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 createState() => _QuestDetailScreenState(); } class _QuestDetailScreenState extends State { QuestDetail? _detail; QuestStage? _stage; /// 실측 진행도. 명세 `518:88441` 의 *실시간 목표 실행률 수집* 이다. /// null 이면 아직 수집을 시작하지 않아 서버가 준 값을 그린다. int? _live; StreamSubscription? _walk; /// 서버 왕복 중. 두 번 눌러 두 번 지급되는 경로를 막는다. bool _busy = false; /// 방금 끝낸 Music Shake 판의 판정 합계 — 결과 화면 · 결과 카드에 쓴다. Map? _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 _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 _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( 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 _submissionStatus(QuestSubmission s) { final lines = [ 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 _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 _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 _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 _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 _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 _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 _run(Future 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 _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 _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 _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; final judge = ShakeJudge( bpm: setup.bpm, segments: setup.segments, windows: setup.windows, hits: resume?.hits ?? const [], position: resume?.position ?? Duration.zero, ); QuestSession snapshot(QuestStage state) => QuestSession( state: state, progress: judge.live(judge.position).judged, total: setup.segments, position: judge.position, hits: judge.hits, ); setState(() => _stage = QuestStage.inProgress); // 진행 중 앱이 꺼져도 다음에 일시 정지(멈춘 자리)로 보이게 시작 시점을 남긴다. final started = _persist( detail.card.id, QuestStage.inProgress, snapshot(QuestStage.inProgress), ); final run = await Navigator.of(context, rootNavigator: true) .push( 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, 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 _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(List methods) async { String? qr; if (methods.contains('qr')) { qr = await Navigator.of( context, rootNavigator: true, ).push(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 _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.delayed(const Duration(milliseconds: 1200)), )); if (!mounted) return; } } await Navigator.of(context, rootNavigator: true).push( 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 _watchAd(QuestReward reward) async { _adShown = false; RewardedAdHandle? ad; BackendException? failure; final loading = widget.repository .loadRewardedAd(reward) .then( (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 _warp(QuestAdWarpScreen screen) => Navigator.of( context, rootNavigator: true, ).push(MaterialPageRoute(builder: (_) => screen)); Future _report(QuestCard card) async { final reason = await Navigator.of(context, rootNavigator: true) .push( // 명세 `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, ), ), ), ], ), ), ], ), ); }