이클립스 방어 횟수 즉시 표시·진동 · Music Shake 영상 재생 중 흔들기 판정 · 결과 공유

This commit is contained in:
Sn-Kinos
2026-10-05 21:39:22 +09:00
parent 65669b969e
commit 17c658b272
30 changed files with 1337 additions and 144 deletions
+81 -10
View File
@@ -1,7 +1,6 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/ui/app_dialog.dart';
@@ -13,6 +12,7 @@ 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';
@@ -60,6 +60,9 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
/// 서버 왕복 중. 두 번 눌러 두 번 지급되는 경로를 막는다.
bool _busy = false;
/// 방금 끝낸 Music Shake 판의 판정 합계 — 결과 화면 · 결과 카드에 쓴다.
Map<String, int>? _shakeResult;
@override
void initState() {
super.initState();
@@ -182,7 +185,8 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
const SizedBox(height: 24),
// 링크 원본(서버 조회값)과 작성자 설명을 나눠 그린다(C-05 · R10).
if (detail.link != null) ...[
// Music Shake 는 Figma `480:14506` 대로 링크 카드 대신 곡 제목 칩(아래)이다.
if (detail.link != null && card.type != QuestType.musicShake) ...[
QuestLinkCard(link: detail.link!),
const SizedBox(height: 12),
],
@@ -194,11 +198,17 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
],
// 명세 `518:88435` — 설정 목표 수치 · 실행한 목표 수치 · 설정 보상 POP.
_Row(
label: context.l10n.questGoal,
value:
'${formatPop(_live ?? card.progress)} / ${formatPop(card.goal)}',
),
// 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)}',
),
const SizedBox(height: 8),
_Row(
label: context.l10n.questReward,
@@ -265,6 +275,34 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
],
),
),
// 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(
@@ -591,6 +629,8 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
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:
// **모을 진행도가 있는 유형만 「진행중」을 지난다.** 걸음은 목표까지
// 쌓아야 하지만 출석·방문은 그 자리에서 판정이 끝나므로 참여가 곧 완료다.
@@ -624,6 +664,34 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
_watch(detail.card);
}
/// Music Shake 한 판 — 공식 영상을 보며 흔드는 진행 화면을 띄운다(PRD-04).
/// 끝까지 했으면 판정표를 남기고 「보상받기」로, 중간에 나왔으면 시작 전으로 돌린다.
Future<void> _playShake(QuestDetail detail) async {
final MusicShakeSetup setup;
try {
setup = await widget.repository.musicShakeSetup(detail.card.id);
} catch (error) {
if (mounted) _fail(error);
return;
}
if (!mounted) return;
setState(() => _stage = QuestStage.inProgress);
final run = await Navigator.of(context, rootNavigator: true).push<ShakeJudge>(
MaterialPageRoute(
builder: (_) => MusicShakePlayScreen(
setup: setup,
shakes: widget.repository.shakeEvents(),
),
),
);
if (!mounted) return;
if (run != null) widget.repository.recordShake(run);
setState(() {
_shakeResult = run?.judgement();
_stage = run == null ? QuestStage.notStarted : QuestStage.completed;
});
}
/// 실시간 목표 실행률. 목표를 채우면 버튼이 「보상받기」로 바뀐다.
///
/// **여기서 지급하지 않는다.** 목표 도달 판정도 서버가 다시 하며(E-QST-01),
@@ -635,8 +703,6 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
.listen(
(value) {
if (!mounted) return;
// Music Shake — 박마다 짧은 진동으로 흔들 때를 알린다.
if (card.type == QuestType.musicShake) HapticFeedback.selectionClick();
setState(() {
_live = value;
if (card.goal > 0 && value >= card.goal) {
@@ -699,7 +765,11 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
reward = await widget.repository.verify(detail.card);
} catch (error) {
if (!mounted) return;
setState(() => _busy = false);
setState(() {
_busy = false;
// Music Shake 판정 미달(B-24 — 보상 0) — 판정표는 소진됐으니 다시 하게 시작 전으로.
if (detail.card.type == QuestType.musicShake) _stage = QuestStage.notStarted;
});
_fail(error);
// 타임어택 창 밖(E-QST-01) — 서버가 본 상태로 다시 읽어 마감 · 완료를 반영한다.
if (error is BackendException &&
@@ -763,6 +833,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
detail: detail,
reward: reward,
adBonusPop: adPop,
shake: _shakeResult,
),
),
);