이클립스 방어 횟수 즉시 표시·진동 · 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
+46
View File
@@ -105,6 +105,40 @@ export interface PolicyConfig {
referralAttachDays: number;
/** 긴급 돌발 타임어택 지속 시간 상한(분) — 원장 「최대 24시간」. 🟡 운영값. */
timeAttackMaxMinutes: number;
/** Music Shake 판정 · 성공 · 보상 운영값(원장 B-24). 아래 [MusicShakePolicy] 참고. */
musicShake: MusicShakePolicy;
}
/** Music Shake 판정 등급. 등급 체계 자체가 기준자료에 없다 — 아래 [MusicShakePolicy] 참고. */
export type ShakeGrade = 'S' | 'A' | 'B' | 'C' | 'F';
/**
* Music Shake 운영값 — `config/policy.musicShake`.
*
* **🟡 임시값 — 회신 대기. 근거 없음.** 원장 B-24 가 확정한 것은 「BPM 기반 Shake 판정이 성공 /
* 보상 기준 · 완청 보상 아님 · 보상액은 운영값」뿐이다. 판정 창 · 성공 기준 · 등급(S/A/B/C) ·
* 등급 배수는 PRD-04 · 상세정의 v1.6.1 §20 · 별첨 B 어디에도 수치가 없어 **저희가 임시로 둔
* 값**이며, 해석 확인 요청 「2026-10-07 Music Shake」(Q-MS-04 · Q-MS-05) 회신으로 바꾼다.
* 앱(`ShakeWindows`)도 같은 기본값을 쓴다.
*/
export interface MusicShakePolicy {
/** 이 블록이 회신 전 임시값인가. 회신 반영 시 false 로 내린다. */
provisional: boolean;
/** perfect 판정 창 — 박과의 거리(박 길이 대비). 🟡 임시 0.2. */
perfectWindow: number;
/** good 판정 창(이 밖이면 miss). 🟡 임시 0.5. */
goodWindow: number;
/** 성공(보상) 문턱 — (perfect + good) / 구간 수. 🟡 임시 0.5(예전 코드의 「50%」를 옮긴 것). */
passRate: number;
/** 등급 문턱 — perfect / 구간 수. S 는 miss 0 도 요구. 🟡 임시(예전 코드 값을 옮긴 것). */
gradeCuts: { S: number; A: number; B: number };
/** 등급별 기본 보상 배수. 🟡 임시(예전 코드 값). 등급 보상 차등 자체가 회신 대기다. */
gradeMultiplier: Record<ShakeGrade, number>;
/**
* 퀘스트 `rewardPop` 이 없을 때의 기본 보상(일반 POP). 🟡 Admin Figma 「Quest 유형별 검증」
* (`2376:2189`) 의 BPM / Shake 「1 POP」 예시값 — 확정값이 아니다.
*/
pop: number;
}
/**
@@ -173,6 +207,16 @@ export const DEFAULT_POLICY: PolicyConfig = {
referralDailyCapPerReferrer: 5,
referralAttachDays: 7,
timeAttackMaxMinutes: 1440,
// 🟡 임시값 — 회신 대기 · 근거 없음(위 MusicShakePolicy 설명).
musicShake: {
provisional: true,
perfectWindow: 0.2,
goodWindow: 0.5,
passRate: 0.5,
gradeCuts: { S: 0.95, A: 0.8, B: 0.6 },
gradeMultiplier: { S: 2, A: 1.5, B: 1.2, C: 1, F: 0 },
pop: 1,
},
};
export async function loadPolicy(): Promise<PolicyConfig> {
@@ -185,6 +229,8 @@ export async function loadPolicy(): Promise<PolicyConfig> {
...raw,
// 가중치는 부분 지정이 흔하므로 축별로 병합한다. 통째로 덮으면 축이 사라진다.
fpiWeights: weights,
// 판정 운영값은 키별로 병합한다 — 일부만 바꿔도 나머지 임시값이 남는다.
musicShake: { ...DEFAULT_POLICY.musicShake, ...(raw.musicShake ?? {}) },
// 구간표는 통째로 덮는다. 섞으면 운영에서 지운 구간이 되살아난다.
stepTiers: normalizeTiers(raw.stepTiers) ?? DEFAULT_POLICY.stepTiers,
} as PolicyConfig;
+41 -19
View File
@@ -5,7 +5,7 @@ import { callerUid, num, optionalStr, str } from '../core/callable.js';
import { ERR, fail, positiveInt } from '../core/errors.js';
import { deviceHashOf, txIdOf } from '../core/ids.js';
import { dayKey, isPlausibleOccurredAt } from '../core/time.js';
import { loadPolicy } from '../core/config.js';
import { DEFAULT_POLICY, loadPolicy, type MusicShakePolicy, type ShakeGrade } from '../core/config.js';
import { grantPop, type GrantInput } from '../ledger/ledger.js';
import { fandomOfUser, markCompletion, writeFpiEvent } from '../ledger/fpi.js';
import { dailyLimitOf, progress, progressWriter, questOf } from './quest.js';
@@ -47,26 +47,42 @@ export interface ShakeJudgement {
maxCombo: number;
}
export type ShakeGrade = 'S' | 'A' | 'B' | 'C' | 'F';
export type { ShakeGrade };
/**
* 판정표 → 등급. F 면 실패(보상 0 — 원장 B-24). 문턱은 **운영값**(`config/policy.musicShake`)이며
* 🟡 임시값 — 회신 대기다(근거 없음 · 해석 확인 요청 2026-10-07 Q-MS-05).
*/
export function gradeShake(
judgement: ShakeJudgement,
totalSegments: number,
rule: MusicShakePolicy = DEFAULT_POLICY.musicShake,
): ShakeGrade {
const hit = judgement.perfect + judgement.good;
if (totalSegments <= 0 || hit + judgement.miss !== totalSegments) return 'F';
if (hit / totalSegments < rule.passRate) return 'F';
const accuracy = judgement.perfect / totalSegments;
if (judgement.miss === 0 && accuracy >= 0.95) return 'S';
if (accuracy >= 0.8) return 'A';
if (accuracy >= 0.6) return 'B';
if (hit / totalSegments >= 0.5) return 'C';
return 'F';
if (judgement.miss === 0 && accuracy >= rule.gradeCuts.S) return 'S';
if (accuracy >= rule.gradeCuts.A) return 'A';
if (accuracy >= rule.gradeCuts.B) return 'B';
return 'C';
}
/** 등급별 보상 배수. 기본 보상에 곱한다. */
const GRADE_MULTIPLIER: Readonly<Record<ShakeGrade, number>> = {
S: 2, A: 1.5, B: 1.2, C: 1, F: 0,
};
/** YouTube 링크의 영상 ID — 앱 `youtubeVideoId` 와 같은 규칙. 아니면 null. */
export function videoIdOf(raw: unknown): string | null {
if (typeof raw !== 'string') return null;
let u: URL;
try { u = new URL(raw.trim()); } catch { return null; }
const host = u.hostname.toLowerCase();
const seg = u.pathname.split('/').filter(Boolean);
let id: string | null = null;
if (host === 'youtu.be') id = seg[0] ?? null;
else if (host === 'youtube.com' || host.endsWith('.youtube.com')) {
id = u.searchParams.get('v')
?? (seg.length >= 2 && ['shorts', 'embed', 'live'].includes(seg[0]!) ? seg[1]! : null);
}
return id && /^[A-Za-z0-9_-]{11}$/.test(id) ? id : null;
}
/** `fn-quest-verify-music-shake` */
export const fnQuestVerifyMusicShake = onCall(async (req) => {
@@ -79,8 +95,11 @@ export const fnQuestVerifyMusicShake = onCall(async (req) => {
// 구간 수도 퀘스트에 정의돼 있으면 그 값만 받는다 — 판정표를 줄여 등급을 올리지 못하게.
const questId = str(req.data, 'questId');
const quest = await questOf(uid, questId, ['musicshake', 'music_shake', 'shake']);
const trackIds = Array.isArray(quest.get('trackIds'))
? quest.get('trackIds') as unknown[] : [quest.get('trackId')];
// 곡은 퀘스트의 카탈로그(`trackIds` · `trackId`) 또는 지정 공식 영상(`link.url` · PRD-04 MVP).
const trackIds = [
...(Array.isArray(quest.get('trackIds')) ? quest.get('trackIds') as unknown[] : [quest.get('trackId')]),
videoIdOf((quest.get('link') as { url?: unknown } | undefined)?.url),
].filter((v) => v != null);
if (!trackIds.includes(trackId)) {
fail(ERR.questIncomplete, '이 퀘스트의 곡이 아닙니다.');
}
@@ -105,7 +124,8 @@ export const fnQuestVerifyMusicShake = onCall(async (req) => {
fail(ERR.popDuplicate, '오늘 이 퀘스트의 보상은 이미 받았습니다.');
}
const grade = gradeShake(judgement, totalSegments);
const policy = await loadPolicy();
const grade = gradeShake(judgement, totalSegments, policy.musicShake);
const db = getFirestore();
const logRef = db.collection(`users/${uid}/activityLogs`).doc();
@@ -126,16 +146,18 @@ export const fnQuestVerifyMusicShake = onCall(async (req) => {
});
if (grade === 'F') {
// 판정표가 스스로 모순되거나 완주하지 못한 경우다. 증거는 남기고 보상은 없다.
fail(ERR.questIncomplete, '완주하지 못했습니다.');
// 판정표가 스스로 모순되거나 성공 기준에 못 미친 경우다(B-24 — 판정 실패 시 보상 0).
// 증거는 남기고 보상은 없다.
fail(ERR.questIncomplete, '박자 판정이 성공 기준에 미치지 못했습니다.');
}
const policy = await loadPolicy();
// 원장 B-24 「보상액은 운영값」 — 퀘스트 문서(Admin 「설정 보상 POP」)가 정하고, 없으면
// 옛 공통값으로 물러난다. 앱 화면도 같은 순서로 읽는다(`QuestType.rewardFrom`).
// `config/policy.musicShake.pop` 으로 물러난다. 앱 화면도 같은 순서로 읽는다(`QuestType.rewardFrom`).
// 등급 배수는 🟡 임시값 — 회신 대기(Q-MS-05).
const base = quest.get('rewardPop');
const amount = Math.round(
(typeof base === 'number' && base >= 0 ? base : policy.stepsPop) * GRADE_MULTIPLIER[grade],
(typeof base === 'number' && base >= 0 ? base : policy.musicShake.pop)
* (policy.musicShake.gradeMultiplier[grade] ?? 0),
);
const result = await grantPop({
uid,
+18 -1
View File
@@ -2,7 +2,8 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import { distanceMeters, gradeShake } from '../src/quest/verify.js';
import { distanceMeters, gradeShake, videoIdOf } from '../src/quest/verify.js';
import { DEFAULT_POLICY } from '../src/core/config.js';
import { grantPop, refundPop, spendPop } from '../src/ledger/ledger.js';
/**
@@ -43,6 +44,22 @@ describe('Music Shake 판정 (E-02)', () => {
it('구간이 0이면 실격이다', () => {
expect(gradeShake({ perfect: 0, good: 0, miss: 0, maxCombo: 0 }, 0)).toBe('F');
});
it('성공 문턱 · 등급 문턱은 운영값이다 — 🟡 임시값 회신 대기 표시가 붙어 있다', () => {
expect(DEFAULT_POLICY.musicShake.provisional).toBe(true);
const strict = { ...DEFAULT_POLICY.musicShake, passRate: 0.9 };
// 기본 문턱(0.5)이면 C, 운영값을 0.9 로 올리면 실패다.
expect(gradeShake({ perfect: 6, good: 6, miss: 8, maxCombo: 6 }, 20)).toBe('C');
expect(gradeShake({ perfect: 6, good: 6, miss: 8, maxCombo: 6 }, 20, strict)).toBe('F');
});
it('지정 공식 영상 링크에서 영상 ID 를 꺼낸다', () => {
expect(videoIdOf('https://www.youtube.com/watch?v=WMweEpGlu_U')).toBe('WMweEpGlu_U');
expect(videoIdOf('https://youtu.be/WMweEpGlu_U?si=x')).toBe('WMweEpGlu_U');
expect(videoIdOf('https://music.youtube.com/watch?v=WMweEpGlu_U')).toBe('WMweEpGlu_U');
expect(videoIdOf('https://vimeo.com/1')).toBeNull();
expect(videoIdOf(undefined)).toBeNull();
});
});
describe('방문 거리 (E-06)', () => {
@@ -519,6 +519,24 @@ describe('검증 활동 집계 규칙 — Quest 완료 1건 · 하루 1회 (Camp
expect(reply.rewarded).toBe(60);
});
it('Music Shake — 곡은 지정 영상 링크로 대조하고 · rewardPop 이 없으면 운영값 musicShake.pop · 판정 미달은 보상 0', async () => {
await db().doc('quests/music-link').set({
type: 'musicShake', totalSegments: 10, dailyLimit: 2,
link: { url: 'https://youtu.be/WMweEpGlu_U', title: 'BTS - Butter' },
});
await db().doc('config/policy').set({ musicShake: { pop: 4 } }, { merge: true });
const shake = (perfect: number, miss: number) => call(fnQuestVerifyMusicShake, {
questId: 'music-link', trackId: 'WMweEpGlu_U', totalSegments: 10,
judgement: { perfect, good: 0, miss, maxCombo: perfect },
});
// 3/10 — 임시 성공 문턱 0.5 미달 → 보상 0, 원장 없음.
expect(await codeOf(() => shake(3, 7))).toBe('E-QST-01');
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(0);
// 5/10 — 문턱 0.5 충족 · perfect 0.5 < B 문턱 0.6 → C(배수 1) → 4.
const reply = await shake(5, 5);
expect(reply.rewarded).toBe(4);
});
it('Music Shake 는 완주마다 1회 · 방문은 1회', async () => {
await db().doc('quests/music-count').set({ type: 'musicShake', trackId: 't1', totalSegments: 10, dailyLimit: 2 });
const shake = () => call(fnQuestVerifyMusicShake, {
@@ -4,6 +4,7 @@ import 'dart:math' as math;
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../core/data/backend.dart';
import '../../core/theme/tokens.dart';
@@ -56,6 +57,11 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
/// 서버가 받아들인 내 누적 참여량.
int _mine = 0;
/// 화면에 보이는 내 횟수 — 서버 확정값에 아직 보내지 않았거나 보내는 중인 흔들기를 더한다.
/// 흔드는 동안 서버 왕복(약 0.7초 묶음)을 기다리면 숫자가 늦게 올라 「얼마나 잘 흔들고
/// 있는지」 알 수 없었다. 확정값이 오면 그것으로 맞춰진다.
int get _shown => _mine + _pending + (_sending ? (_batch?.shakes ?? 0) : 0);
/// 상한에 걸려 깎인 적이 있는가. 한 번 알리고 다시 알리지 않는다.
bool _cappedNotified = false;
@@ -105,7 +111,15 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
_current?.canShake(DateTime.now()) != true ||
(_batch != null && !_sending))
return;
final qualify = _current?.qualifyShakes ?? 0;
final before = _shown;
setState(() => _pending += 1);
// 화면을 보지 않고 흔들어도 알 수 있게 — 한 번 셀 때마다 짧게, 인정 기준을 넘는 순간 길게.
if (qualify > 0 && before < qualify && _shown >= qualify) {
HapticFeedback.heavyImpact();
} else {
HapticFeedback.selectionClick();
}
_scheduleFlush();
}
@@ -256,7 +270,7 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
title: context.l10n.eclipseSuccessTitle,
success: true,
extra: [
if (_mine >= state.qualifyShakes) _Note(context.l10n.eclipseQualified),
if (_shown >= state.qualifyShakes) _Note(context.l10n.eclipseQualified),
if (_batch != null) _retryButton(),
],
);
@@ -283,7 +297,7 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
confirm: result != null,
extra: [
_Note(message),
if (_mine >= state.qualifyShakes) _Note(context.l10n.eclipseQualified),
if (_shown >= state.qualifyShakes) _Note(context.l10n.eclipseQualified),
if (_batch != null) _retryButton(),
],
);
@@ -424,16 +438,16 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
_Meter(
key: const ValueKey('eclipse-my-shakes'),
label:
'${context.l10n.eclipseMyShakes(formatPop(_mine), '${state.qualifyShakes}')}'
'${context.l10n.eclipseMyShakes(formatPop(_shown), '${state.qualifyShakes}')}'
'${_pending > 0
? context.l10n.eclipsePendingSuffix('$_pending')
: _sending
? context.l10n.eclipseSendingSuffix
: ''}'
'${_mine >= state.qualifyShakes ? ' · ${context.l10n.eclipseQualified}' : ''}',
'${_shown >= state.qualifyShakes ? ' · ${context.l10n.eclipseQualified}' : ''}',
value: state.qualifyShakes <= 0
? 0
: (_mine / state.qualifyShakes).clamp(0.0, 1.0),
: (_shown / state.qualifyShakes).clamp(0.0, 1.0),
color: AppColors.cosmicAccent,
),
const SizedBox(height: AppSpace.s3),
@@ -0,0 +1,348 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
import 'quest_data.dart';
import 'quest_evidence.dart';
/// 플레이어 상태 한 번 — 재생 중인가 · 영상 시각 · 끝났는가.
typedef ShakePlayerEvent = ({bool playing, Duration position, bool ended});
/// Music Shake 진행 — PRD-04 「뮤직비디오를 보면서 스마트폰을 흔드는 미션」.
///
/// 공식 YouTube 영상(퀘스트의 지정 링크 · PRD-04 MVP 수용 기준)을 IFrame 플레이어로 재생하고,
/// 재생되는 동안의 흔들기를 **영상 시각**으로 찍어 박마다 Perfect · Good · Miss · Combo 를
/// 띄운다. 판정 창은 운영값(🟡 임시값 — 회신 대기). 마지막 박의 창이 닫히거나 영상이 끝나면
/// 판정표를 돌려준다. 중간에 나가면 null — 판정표를 남기지 않는다.
///
/// Figma 에 이 진행 화면은 없다(상세 `480:14506` 은 「진행중」 버튼까지). 공통 기준 5항 —
/// 기존 범위 기능의 정상 구현에 필요한 화면이다. 상세의 시각 문법(#141414 · #1a1a1c r24)을 따른다.
class MusicShakePlayScreen extends StatefulWidget {
const MusicShakePlayScreen({
super.key,
required this.setup,
required this.shakes,
this.player,
});
final MusicShakeSetup setup;
final Stream<DateTime> shakes;
/// 영상 플레이어. 기본은 [YoutubeEmbed]. 테스트는 상태를 직접 넣는 가짜를 준다.
final Widget Function(ValueChanged<ShakePlayerEvent> onEvent)? player;
@override
State<MusicShakePlayScreen> createState() => _MusicShakePlayScreenState();
}
class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> {
late final ShakeJudge _judge = ShakeJudge(
bpm: widget.setup.bpm,
segments: widget.setup.segments,
windows: widget.setup.windows,
);
StreamSubscription<DateTime>? _sub;
Timer? _tick;
/// 영상 시계 — 마지막으로 받은 플레이어 시각과 그때의 단말 시각.
bool _playing = false;
Duration _anchorMedia = Duration.zero;
DateTime _anchorWall = DateTime.now();
int _lastJudged = 0;
bool _done = false;
@override
void initState() {
super.initState();
_sub = widget.shakes.listen((at) {
final media = _mediaAt(at);
if (media != null) _judge.add(media);
}, onError: (_) {});
_tick = Timer.periodic(const Duration(milliseconds: 50), (_) => _onTick());
}
@override
void dispose() {
_sub?.cancel();
_tick?.cancel();
super.dispose();
}
/// 단말 시각 [wall] 의 영상 시각. 재생 중이 아니면 null — 멈춘 동안의 흔들기는 세지 않는다.
Duration? _mediaAt(DateTime wall) =>
_playing ? _anchorMedia + wall.difference(_anchorWall) : null;
void _onEvent(ShakePlayerEvent e) {
if (_playing != e.playing && mounted) setState(() {});
_playing = e.playing;
_anchorMedia = e.position;
_anchorWall = DateTime.now();
if (e.ended) _finish();
}
void _onTick() {
final now = _mediaAt(DateTime.now());
if (now == null || _done) return;
final judged = _judge.live(now).judged;
if (judged != _lastJudged) {
_lastJudged = judged;
setState(() {});
}
if (now >= _judge.end) _finish();
}
void _finish() {
if (_done || !mounted) return;
_done = true;
// 완료 햅틱 Medium — UX·FX 정렬 기준 v1.5 「Music/Location Quest 완료」.
HapticFeedback.mediumImpact();
Navigator.of(context).pop(_judge);
}
@override
Widget build(BuildContext context) {
final now = _mediaAt(DateTime.now()) ?? _anchorMedia;
final v = _judge.live(now);
return Scaffold(
backgroundColor: AppColors.cosmicVoid,
appBar: AppBar(
title: Text(
widget.setup.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
body: ListView(
children: [
// 플레이어 위에는 아무것도 겹치지 않는다(YouTube RMF — 오버레이 금지 · 최소 200×200).
AspectRatio(
aspectRatio: 16 / 9,
child: (widget.player ?? _defaultPlayer)(_onEvent),
),
const SizedBox(height: 32),
Text(
switch (v.last) {
ShakeHit.perfect => 'PERFECT',
ShakeHit.good => 'GOOD',
ShakeHit.miss => 'MISS',
null => ' ',
},
key: const ValueKey('shake-last'),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 34,
fontWeight: FontWeight.w700,
height: 1.3,
color: switch (v.last) {
ShakeHit.perfect => AppColors.cosmicAccent,
ShakeHit.good => const Color(0xFF67D3A9),
_ => const Color(0xFF777777),
},
),
),
Text(
context.l10n.shakeCombo('${v.combo}'),
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
height: 1.4,
color: AppColors.onCosmic,
),
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ShakeTally(
perfect: v.perfect,
good: v.good,
miss: v.miss,
maxCombo: v.maxCombo,
),
),
const SizedBox(height: 16),
Text(
_playing
? context.l10n.shakeJudged(
'${v.judged}',
'${widget.setup.segments}',
)
: context.l10n.shakePlayHint,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
height: 20 / 14,
color: AppColors.onCosmicVariant,
),
),
],
),
);
}
Widget _defaultPlayer(ValueChanged<ShakePlayerEvent> onEvent) =>
YoutubeEmbed(videoId: widget.setup.videoId, onEvent: onEvent);
}
/// Perfect · Good · Miss · 최대 콤보 네 칸 — 진행 화면과 결과 카드가 같이 쓴다.
class ShakeTally extends StatelessWidget {
const ShakeTally({
super.key,
required this.perfect,
required this.good,
required this.miss,
required this.maxCombo,
});
final int perfect;
final int good;
final int miss;
final int maxCombo;
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(24),
),
child: Row(
children: [
for (final (label, n) in [
('PERFECT', perfect),
('GOOD', good),
('MISS', miss),
('MAX COMBO', maxCombo),
])
Expanded(
child: Column(
children: [
Text(
'$n',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
height: 1.4,
color: AppColors.onCosmic,
),
),
Text(
label,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
height: 16 / 11,
color: AppColors.onCosmicVariant,
),
),
],
),
),
],
),
);
}
/// 공식 YouTube IFrame 플레이어(IFrame Player API)를 WebView 로 띄운다.
///
/// - **영상을 저장하지 않는다**(PRD-04 주의 사항) — 스트리밍 재생만 한다.
/// - 플레이어를 고치거나 위에 겹치지 않는다. 컨트롤 그대로다(YouTube Required Minimum Functionality).
/// - 앱 식별: `baseUrl` 을 스토어 앱 ID 의 https URL 로 둔다(같은 문서 「Referer for mobile apps」).
/// - iOS 는 인라인 재생을 켜야 전체 화면으로 빠지지 않고 판정 화면이 보인다.
class YoutubeEmbed extends StatefulWidget {
const YoutubeEmbed({super.key, required this.videoId, required this.onEvent});
final String videoId;
final ValueChanged<ShakePlayerEvent> onEvent;
/// 스토어 앱 ID(Android applicationId · iOS bundle ID 가 같다).
static const appOrigin = 'https://kr.co.fanuzu.app';
/// WebView 를 쓸 수 있는가 — 위젯 테스트 · 데스크톱은 빈 칸.
static bool enabled =
!kIsWeb &&
(Platform.isAndroid || Platform.isIOS) &&
!Platform.environment.containsKey('FLUTTER_TEST');
@visibleForTesting
static String html(String videoId) =>
'''
<!DOCTYPE html><html><head><meta name="viewport" content="width=device-width,initial-scale=1">
<style>html,body{margin:0;height:100%;background:#000}#p{width:100%;height:100%}</style></head>
<body><div id="p"></div><script src="https://www.youtube.com/iframe_api"></script><script>
var player;
function post(s){Player.postMessage(JSON.stringify({s:s,t:player.getCurrentTime()}));}
function onYouTubeIframeAPIReady(){
player=new YT.Player('p',{videoId:${jsonEncode(videoId)},
playerVars:{playsinline:1,rel:0,origin:'$appOrigin'},
events:{onStateChange:function(e){post(e.data);},
onError:function(e){Player.postMessage(JSON.stringify({err:e.data}));}}});
setInterval(function(){if(player&&player.getPlayerState&&player.getPlayerState()==1)post(1);},500);
}
</script></body></html>''';
@override
State<YoutubeEmbed> createState() => _YoutubeEmbedState();
}
class _YoutubeEmbedState extends State<YoutubeEmbed> {
WebViewController? _web;
bool _error = false;
@override
void initState() {
super.initState();
if (!YoutubeEmbed.enabled) return;
final params = WebViewPlatform.instance is WebKitWebViewPlatform
? WebKitWebViewControllerCreationParams(allowsInlineMediaPlayback: true)
: const PlatformWebViewControllerCreationParams();
_web = WebViewController.fromPlatformCreationParams(params)
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.black)
..addJavaScriptChannel('Player', onMessageReceived: _onMessage)
..loadHtmlString(
YoutubeEmbed.html(widget.videoId),
baseUrl: YoutubeEmbed.appOrigin,
);
}
void _onMessage(JavaScriptMessage m) {
final msg = jsonDecode(m.message) as Map<String, dynamic>;
if (msg.containsKey('err')) {
if (mounted) setState(() => _error = true);
return;
}
final s = msg['s'];
final t = msg['t'];
widget.onEvent((
playing: s == 1,
position: Duration(microseconds: ((t is num ? t : 0) * 1e6).round()),
ended: s == 0,
));
}
@override
Widget build(BuildContext context) {
final web = _web;
if (web == null || _error) {
return ColoredBox(
color: Colors.black,
child: Center(
child: Text(
_error ? context.l10n.shakeVideoError : '',
style: const TextStyle(color: AppColors.onCosmicVariant),
),
),
);
}
return WebViewWidget(controller: web);
}
}
+55 -6
View File
@@ -6,9 +6,11 @@
/// - 퀘스트 생성 `523:18152`~`523:18188` · Admin 정의 `567:19531`
library;
import '../../core/data/backend.dart' show BackendException;
import '../../core/monetization/rewarded_ad.dart';
import '../../core/i18n/app_locale.dart';
import 'place_picker_screen.dart' show PlacePickerScreen;
import 'quest_evidence.dart' show ShakeJudge, ShakeWindows;
/// 진행 상태 — 명세가 **다섯 가지**를 못박았다.
///
@@ -112,13 +114,14 @@ enum QuestType {
int? rewardFrom(Map<String, dynamic> quest, Map<String, dynamic> policy) {
// 시청 · 스트리밍은 무보상 참여다(C-04) — 문서 값과 무관하게 0.
if (this == QuestType.streaming) return 0;
// Music Shake 는 BPM 판정 성공 시 일반 POP(원장 B-24). 서버(`fn-quest-verify-music-shake`)가
// 퀘스트의 `rewardPop` 을, 없으면 `config/policy.stepsPop` 을 기본액으로 쓰고 판정 등급
// 배수를 곱한다. 화면은 기본액(C 등급)을 적는다 — 운영값이 둘 다 없을 때만 중립 문구.
// Music Shake 는 BPM 판정 성공 시 일반 POP(원장 B-24 · 보상액 운영값). 서버
// (`fn-quest-verify-music-shake`)가 퀘스트의 `rewardPop` 을, 없으면
// `config/policy.musicShake.pop` 을 기본액으로 쓴다. 운영값이 둘 다 없을 때만 중립 문구.
final shake = policy['musicShake'];
final raw = this == QuestType.attend
? policy['attendPop']
: this == QuestType.musicShake
? quest['rewardPop'] ?? policy['stepsPop']
? quest['rewardPop'] ?? (shake is Map ? shake['pop'] : null)
: quest['rewardPop'];
return raw is num ? raw.round() : null;
}
@@ -130,8 +133,9 @@ enum QuestType {
/// 내부 사유(질의 번호 등)는 화면에 내보내지 않는다 — 사용자에게는 한 문구다.
bool get verifiable => this != QuestType.unknown;
/// 시작 뒤 진행도를 모아야 판정할 수 있는 유형 — 걸음(누적 걸음) · Music Shake(구간 판정).
bool get gathers => this == QuestType.steps || this == QuestType.musicShake;
/// 시작 뒤 진행도를 모아야 판정할 수 있는 유형 — 걸음(누적 걸음).
/// Music Shake 는 영상을 보며 흔드는 별도 진행 화면(`MusicShakePlayScreen`)에서 모은다.
bool get gathers => this == QuestType.steps;
/// 참여를 열지 않는 사유. 열려 있으면 null 이다.
String? get blockedReason =>
@@ -375,6 +379,30 @@ class QuestCard {
}
/// 상세 화면이 필요로 하는 것.
/// Music Shake 한 판의 준비값 — 퀘스트 문서와 운영값에서 읽는다(지어내지 않는다).
class MusicShakeSetup {
const MusicShakeSetup({
required this.videoId,
required this.title,
required this.bpm,
required this.segments,
this.windows = const ShakeWindows(),
});
/// 지정 공식 YouTube 영상(PRD-04 MVP 수용 기준) — `quests.link.url` 에서 꺼낸 ID.
final String videoId;
/// 곡 제목 — `quests.link.title`(링크 원본 제목 · C-05).
final String title;
/// `quests.bpm` — Figma 「목표BPM」. 🟡 의미 · 입력 주체 회신 대기(Q-MS-01 · Q-MS-02).
final double bpm;
/// `quests.totalSegments` — 판정 박 수. 🟡 구간 패턴 정의 회신 대기(Q-MS-02).
final int segments;
final ShakeWindows windows;
}
class QuestDetail {
const QuestDetail({
required this.card,
@@ -517,6 +545,16 @@ abstract interface class QuestRepository {
/// 센서가 없으면 오류로 끊는다.
Stream<int> progressStream(QuestCard card);
/// Music Shake 한 판의 준비값 — 공식 YouTube 영상 · BPM · 구간 수 · 판정 창(운영값).
/// 퀘스트에 값이 빠져 있으면 E-QST-01 이다(값을 지어내지 않는다).
Future<MusicShakeSetup> musicShakeSetup(String questId);
/// 단말 흔들기 시각 스트림(가속도계).
Stream<DateTime> shakeEvents();
/// 끝난 판의 판정표를 남긴다. 다음 [verify] 가 서버에 증거로 보낸다.
void recordShake(ShakeJudge run);
/// 활동 증거를 서버에 보내 판정·지급을 받는다.
///
/// **판정도 지급도 서버가 한다.** 앱은 증거(걸음 수·좌표)만 싣고, 실패는
@@ -742,6 +780,17 @@ class SampleQuestRepository implements QuestRepository {
@override
Stream<int> progressStream(QuestCard card) => const Stream<int>.empty();
/// 표본에는 영상 · BPM 이 없다 — 참여를 열지 않는다.
@override
Future<MusicShakeSetup> musicShakeSetup(String questId) async =>
throw BackendException('E-QST-01', appL10n.questTypeUnverifiable);
@override
Stream<DateTime> shakeEvents() => const Stream<DateTime>.empty();
@override
void recordShake(ShakeJudge run) {}
@override
Future<QuestReward> verify(QuestCard card) async {
_claimed.add(card.id);
@@ -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,7 +198,13 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
],
// 명세 `518:88435` — 설정 목표 수치 · 실행한 목표 수치 · 설정 보상 POP.
_Row(
// 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)}',
@@ -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,
),
),
);
+124 -26
View File
@@ -104,51 +104,149 @@ Stream<DateTime> deviceShakes() {
.map((s) => s.$2);
}
/// Music Shake 판정 한 박의 결과 — PRD-04 「Shake 판정: Perfect, Good, Miss, Combo」.
enum ShakeHit { perfect, good, miss }
/// Music Shake 판정 창 — `config/policy.musicShake`(운영값). 단위는 **박 길이 대비 비율**이다.
///
/// 🟡 **임시값 — 회신 대기.** PRD-04 · 상세정의 v1.6.1 §20 · 별첨 B 「Music Shake」 어디에도 판정 창
/// 수치가 없다(해석 확인 요청 2026-10-07 Music Shake **Q-MS-04**). 서버 `DEFAULT_POLICY.musicShake`
/// 와 같은 값이며, 운영 문서가 덮으면 그 값을 쓴다.
class ShakeWindows {
const ShakeWindows({this.perfect = 0.2, this.good = 0.5});
factory ShakeWindows.from(Map<String, dynamic> policy) {
final raw = policy['musicShake'];
final m = raw is Map ? raw : const {};
double read(String key, double fallback) {
final v = m[key];
return v is num && v > 0 ? v.toDouble() : fallback;
}
const d = ShakeWindows();
return ShakeWindows(
perfect: read('perfectWindow', d.perfect),
good: read('goodWindow', d.good),
);
}
final double perfect;
final double good;
}
/// 판정 중간값 — 화면이 그리는 수치.
typedef ShakeLive = ({
int perfect,
int good,
int miss,
int combo,
int maxCombo,
int judged,
ShakeHit? last,
});
/// Music Shake 구간 판정표 — `fn-quest-verify-music-shake` 가 받는 **증거**(원장 B-24).
///
/// 곡의 박(`quests.bpm`)마다 한 구간이다. 구간 박 시각 ±반 박 안에 흔들기가 있으면 맞춘 것,
/// 박에서 [perfectRatio] 박 이내면 perfect · 아니면 good, 없으면 miss. **등급은 서버가 다시
/// 매긴다** — 이 표가 스스로 모순되면(합 ≠ 구간 수) 서버가 F 로 끊는다.
/// 시각은 **영상 재생 시각**(0 = 영상 처음)이다 — 공식 YouTube 영상을 보며 흔드는 퀘스트라
/// (PRD-04) 박은 영상에 붙는다. 곡의 박(`quests.bpm`)마다 한 구간이고, 흔들기는 가장 가까운 박
/// 하나에만 붙는다. 박과의 거리가 [ShakeWindows.perfect] 박 이내면 perfect, [ShakeWindows.good]
/// 박 이내면 good, 없으면 miss. **성공 판정은 서버가 다시 한다** — 표가 스스로 모순되면(합 ≠ 구간
/// 수) 서버가 끊는다.
class ShakeJudge {
ShakeJudge({required this.bpm, required this.segments, required this.start})
: assert(bpm > 0 && segments > 0);
ShakeJudge({
required this.bpm,
required this.segments,
this.windows = const ShakeWindows(),
}) : assert(bpm > 0 && segments > 0);
final double bpm;
final int segments;
final DateTime start;
final ShakeWindows windows;
/// perfect 로 보는 박과의 거리(박 단위). 🟡 표시 판정용 — 등급 문턱은 서버가 가진다.
static const perfectRatio = 0.2;
final _shakes = <DateTime>[];
/// 박 번호 → 가장 가까운 흔들기의 거리(박 비율).
final _best = <int, double>{};
Duration get beat => Duration(microseconds: (60e6 / bpm).round());
/// 마지막 구간의 판정 창(박 + 반 박)까지 끝나는 시각.
DateTime get end => start.add(beat * segments - beat ~/ 2);
/// 마지막 박의 판정 창이 닫히는 영상 시각.
Duration get end => Duration(
microseconds: (beat.inMicroseconds * (segments - 1 + windows.good)).round(),
);
void add(DateTime at) => _shakes.add(at);
/// 영상 시각 [at] 의 흔들기 한 번.
void add(Duration at) {
final pos = at.inMicroseconds / beat.inMicroseconds;
final i = pos.round();
if (i < 0 || i >= segments) return;
final d = (pos - i).abs();
if (d > windows.good) return;
final prev = _best[i];
if (prev == null || d < prev) _best[i] = d;
}
/// 구간별 판정 합계 — 서버 `ShakeJudgement` 와 같은 키.
Map<String, int> judgement() {
var perfect = 0, good = 0, miss = 0, combo = 0, maxCombo = 0;
final beatUs = beat.inMicroseconds;
for (var i = 0; i < segments; i++) {
final at = start.microsecondsSinceEpoch + beatUs * i;
final best = _shakes
.map((s) => (s.microsecondsSinceEpoch - at).abs())
.where((d) => d < beatUs / 2)
.fold<int?>(null, (m, d) => m == null || d < m ? d : m);
if (best == null) {
/// 판정 창이 닫힌 박만 센다 — [now] 는 영상 시각.
ShakeLive live(Duration now) {
var perfect = 0, good = 0, miss = 0, combo = 0, maxCombo = 0, judged = 0;
ShakeHit? last;
final pos = now.inMicroseconds / beat.inMicroseconds;
for (var i = 0; i < segments && i + windows.good <= pos; i++) {
judged++;
final d = _best[i];
if (d == null) {
miss++;
combo = 0;
last = ShakeHit.miss;
continue;
}
best <= beatUs * perfectRatio ? perfect++ : good++;
if (d <= windows.perfect) {
perfect++;
last = ShakeHit.perfect;
} else {
good++;
last = ShakeHit.good;
}
maxCombo = math.max(maxCombo, ++combo);
}
return {'perfect': perfect, 'good': good, 'miss': miss, 'maxCombo': maxCombo};
return (
perfect: perfect,
good: good,
miss: miss,
combo: combo,
maxCombo: maxCombo,
judged: judged,
last: last,
);
}
/// 구간별 판정 합계 — 서버 `ShakeJudgement` 와 같은 키. 아직 오지 않은 박은 miss 다.
Map<String, int> judgement() {
final v = live(end);
return {
'perfect': v.perfect,
'good': v.good,
'miss': v.miss + segments - v.judged,
'maxCombo': v.maxCombo,
};
}
}
/// YouTube 링크에서 영상 ID 를 꺼낸다 — `watch?v=` · `youtu.be/` · `/shorts/` · `/embed/` ·
/// `music.youtube.com`. YouTube 링크가 아니면 null. 서버 `videoIdOf` 와 같은 규칙.
String? youtubeVideoId(String url) {
final u = Uri.tryParse(url.trim());
if (u == null) return null;
final host = u.host.toLowerCase();
String? id;
if (host == 'youtu.be') {
id = u.pathSegments.firstOrNull;
} else if (host == 'youtube.com' || host.endsWith('.youtube.com')) {
final seg = u.pathSegments;
id = u.queryParameters['v'] ??
(seg.length >= 2 && const {'shorts', 'embed', 'live'}.contains(seg[0])
? seg[1]
: null);
}
return id != null && RegExp(r'^[A-Za-z0-9_-]{11}$').hasMatch(id) ? id : null;
}
/// 걸음 센서 — `pedometer` 의 `stepCountStream`.
@@ -44,9 +44,6 @@ class FirestoreQuestRepository implements QuestRepository {
/// 마지막 Music Shake 한 판의 판정표. 다 끝난 판만 서버에 보낸다.
ShakeJudge? _shakeRun;
/// 곡 BPM 이 문서에 없을 때 — 🟡 운영 Quest 는 `quests.bpm` 을 채운다.
static const defaultBpm = 100.0;
/// 오늘 걸은 수를 내는 기준선. 하루 단위라 앱을 껐다 켜도 이어진다.
final WalkBaseline _baseline;
@@ -166,9 +163,10 @@ class FirestoreQuestRepository implements QuestRepository {
final steps = type == QuestType.steps;
final goal = steps
? tiers[count.clamp(0, tiers.length - 1)]
// Music Shake 의 목표는 곡의 구간 수다 — 진행도가 「n / 구간 수」로 찬다.
// Music Shake 의 목표 줄은 Figma 「목표BPM 350」(`480:14466` · `480:14506`) — 곡 BPM 이다.
// 없으면 0(화면은 「-」). 🟡 「목표BPM」의 의미는 회신 대기(Q-MS-01).
: type == QuestType.musicShake
? quest.intOr('totalSegments', dailyLimit)
? quest.intOr('bpm')
: dailyLimit;
return QuestCard(
@@ -447,7 +445,6 @@ class FirestoreQuestRepository implements QuestRepository {
@override
Stream<int> progressStream(QuestCard card) {
if (card.type == QuestType.musicShake) return _shakeProgress(card);
if (card.type != QuestType.steps) return const Stream<int>.empty();
// 센서는 부팅 이후 누적을 준다. 하루 단위 기준선을 빼야 정책 원장 **B-26** 의
// 구간(1 천·5 천·1 만·1.5 만보)과 같은 것을 세게 된다.
@@ -519,49 +516,53 @@ class FirestoreQuestRepository implements QuestRepository {
});
}
/// 곡 박마다 한 구간 — 시작부터 박 수를 세어 내고, 마지막 구간 판정 창이 닫히면 구간 수를 낸다.
/// 흔들기는 그동안만 모은다.
Stream<int> _shakeProgress(QuestCard card) async* {
_shakeRun = null;
final quest = (await _backend.db.doc('quests/${card.id}').get()).data() ??
@override
Future<MusicShakeSetup> musicShakeSetup(String questId) async {
final quest = (await _backend.db.doc('quests/$questId').get()).data() ??
const <String, dynamic>{};
final link = quest['link'] is Map
? Map<String, dynamic>.from(quest['link'] as Map)
: const <String, dynamic>{};
final videoId = youtubeVideoId(link.strOr('url'));
final bpm = quest['bpm'];
final segments = quest.intOr('totalSegments');
final bpm = quest['bpm'] is num && (quest['bpm'] as num) > 0
? (quest['bpm'] as num).toDouble()
: defaultBpm;
if (segments <= 0) {
// 영상 · BPM · 구간 수 중 하나라도 없으면 판을 열지 않는다 — 기본값을 지어내지 않는다.
if (videoId == null || bpm is! num || bpm <= 0 || segments <= 0) {
throw BackendException('E-QST-01', appL10n.questTypeUnverifiable);
}
final run = ShakeJudge(bpm: bpm, segments: segments, start: DateTime.now());
final sub = _shakes().listen(run.add, onError: (_) {});
try {
for (var i = 1; i < segments; i++) {
await Future<void>.delayed(run.beat);
yield i;
}
await Future<void>.delayed(run.end.difference(DateTime.now()));
_shakeRun = run;
yield segments;
} finally {
await sub.cancel();
}
return MusicShakeSetup(
videoId: videoId,
title: link.strOr('title', _title(quest)),
bpm: bpm.toDouble(),
segments: segments,
windows: ShakeWindows.from(await _policy()),
);
}
@override
Stream<DateTime> shakeEvents() => _shakes();
@override
void recordShake(ShakeJudge run) => _shakeRun = run;
Future<Map<String, dynamic>> _verifyShake(QuestCard card) async {
final run = _shakeRun;
if (run == null) throw BackendException('E-QST-01', appL10n.questNoShake);
final quest = (await _backend.db.doc('quests/${card.id}').get()).data() ??
const <String, dynamic>{};
final ids = quest['trackIds'];
// 곡 ID 는 서버 대조용이다 — 화면에 띄우지 않는다.
final trackId = ids is List && ids.isNotEmpty ? '${ids.first}' : quest.strOr('trackId');
final link = quest['link'] is Map ? Map<String, dynamic>.from(quest['link'] as Map) : null;
// 곡 ID 는 서버 대조용이다 — 화면에 띄우지 않는다. 카탈로그가 없으면 영상 ID 다.
final trackId = ids is List && ids.isNotEmpty
? '${ids.first}'
: quest.strOr('trackId', youtubeVideoId(link?.strOr('url') ?? '') ?? '');
_shakeRun = null;
return _backend.call('fnQuestVerifyMusicShake', {
'questId': card.id,
'trackId': trackId,
'totalSegments': run.segments,
'judgement': run.judgement(),
'occurredAt': run.end.toUtc().toIso8601String(),
'occurredAt': DateTime.now().toUtc().toIso8601String(),
...await _fandomField(),
});
}
+105 -2
View File
@@ -1,7 +1,13 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:share_plus/share_plus.dart';
import '../../core/theme/tokens.dart';
import '../campaign/campaign_share.dart' show shareHashtags;
import '../home/home_screen.dart' show formatPop;
import 'music_shake_play_screen.dart' show ShakeTally;
import 'quest_data.dart';
import '../../core/i18n/app_locale.dart';
@@ -15,14 +21,25 @@ import '../../core/i18n/app_locale.dart';
///
/// **POP 을 여기서 더하지 않는다.** 잔액과 원장은 서버가 쓰며(`fn-quest-attend`
/// 등), 이 화면은 결과를 보여 주고 상태 전이를 호출부에 알릴 뿐이다.
class QuestRewardScreen extends StatelessWidget {
///
/// Music Shake 는 판정 합계(Perfect · Good · Miss · 최대 콤보)를 함께 띄우고, 그 카드를
/// 이미지로 SNS 공유한다 — PRD-04 「퀘스트 결과 카드와 SNS 공유」.
class QuestRewardScreen extends StatefulWidget {
const QuestRewardScreen({
super.key,
required this.detail,
this.reward,
this.adBonusPop,
this.shake,
this.share,
});
/// Music Shake 판정 합계(`ShakeJudge.judgement()`). 다른 유형은 null.
final Map<String, int>? shake;
/// 공유 시트 호출. 테스트가 대신 받는다.
final Future<void> Function(ShareParams params)? share;
final QuestDetail detail;
/// 서버가 지급한 결과. 있으면 **이 값만 띄운다** — 설정 보상 POP 과 실제 지급액이
@@ -32,10 +49,64 @@ class QuestRewardScreen extends StatelessWidget {
/// 광고 추가 보상으로 서버가 지급한 기여 POP. 광고를 안 봤거나 청구가 실패했으면 null.
final int? adBonusPop;
int? get rewardPop => reward?.rewardedPop ?? detail.card.rewardPop;
@override
State<QuestRewardScreen> createState() => _QuestRewardScreenState();
}
class _QuestRewardScreenState extends State<QuestRewardScreen> {
final _card = GlobalKey();
bool _sharing = false;
QuestDetail get detail => widget.detail;
int? get adBonusPop => widget.adBonusPop;
int? get rewardPop => widget.reward?.rewardedPop ?? detail.card.rewardPop;
/// 결과 카드를 PNG 로 굽고 문구 · 해시태그(B-78)와 함께 OS 공유 시트로 보낸다.
Future<void> _share(BuildContext button) async {
if (_sharing) return;
setState(() => _sharing = true);
final box = button.findRenderObject() as RenderBox?;
final origin = box == null ? null : box.localToGlobal(Offset.zero) & box.size;
final s = widget.shake!;
final text = [
'Music Shake · ${detail.card.title}',
'PERFECT ${s['perfect']} · GOOD ${s['good']} · MISS ${s['miss']} · MAX COMBO ${s['maxCombo']}',
shareHashtags(topic: 'MusicShake').join(' '),
].join('\n');
try {
XFile? file;
final boundary = _card.currentContext?.findRenderObject() as RenderRepaintBoundary?;
if (boundary != null) {
final image = await boundary.toImage(pixelRatio: 3);
final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
image.dispose();
if (bytes != null) {
file = XFile.fromData(
bytes.buffer.asUint8List(),
mimeType: 'image/png',
name: 'fanuzu-music-shake.png',
);
}
}
await (widget.share ?? SharePlus.instance.share)(ShareParams(
text: text,
files: file == null ? null : [file],
sharePositionOrigin: origin,
));
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.commonRequestFailed)),
);
}
} finally {
if (mounted) setState(() => _sharing = false);
}
}
@override
Widget build(BuildContext context) {
final shake = widget.shake;
final t = Theme.of(context).textTheme;
return Scaffold(
body: SafeArea(
@@ -47,6 +118,10 @@ class QuestRewardScreen extends StatelessWidget {
children: [
Expanded(
child: Center(
child: RepaintBoundary(
key: _card,
child: ColoredBox(
color: AppColors.cosmicVoid,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -88,7 +163,35 @@ class QuestRewardScreen extends StatelessWidget {
textAlign: TextAlign.center,
),
],
if (shake != null) ...[
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ShakeTally(
perfect: shake['perfect'] ?? 0,
good: shake['good'] ?? 0,
miss: shake['miss'] ?? 0,
maxCombo: shake['maxCombo'] ?? 0,
),
),
],
],
),
),
),
),
),
if (shake != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Builder(
builder: (button) => OutlinedButton(
onPressed: _sharing ? null : () => _share(button),
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(56),
foregroundColor: AppColors.onCosmic,
),
child: Text(context.l10n.shakeShareResult),
),
),
),
@@ -163,11 +163,16 @@ class QuestCardTile extends StatelessWidget {
Row(
children: [
Expanded(
// Music Shake 는 Figma `480:14262` 「목표BPM 350」 — 곡 BPM 한 값이다.
child: _Stat(
label: context.l10n.questGoal,
value: card.goal > 0
? '${formatPop(card.progress)} / ${formatPop(card.goal)}'
: '-',
label: card.type == QuestType.musicShake
? context.l10n.questGoalBpm
: context.l10n.questGoal,
value: card.goal <= 0
? '-'
: card.type == QuestType.musicShake
? formatPop(card.goal)
: '${formatPop(card.progress)} / ${formatPop(card.goal)}',
muted: card.goal <= 0,
),
),
+7 -1
View File
@@ -806,5 +806,11 @@
"eclipseMinSec": "{minutes}m {seconds}s",
"eclipseOverlayTitle": "Meteor impact\nis starting!",
"eclipseLiveOccurred": "An eclipse has hit planet {name}!",
"eclipseMyShakes": "My valid shakes {count} / {total}"
"eclipseMyShakes": "My valid shakes {count} / {total}",
"questGoalBpm": "Target BPM",
"shakeCombo": "{n} COMBO",
"shakeJudged": "Judged {n} / {total}",
"shakePlayHint": "Play the video and shake your phone to the beat",
"shakeVideoError": "Can't play this video",
"shakeShareResult": "Share result"
}
+7 -1
View File
@@ -806,5 +806,11 @@
"eclipseMinSec": "{minutes} min {seconds} s",
"eclipseOverlayTitle": "¡El impacto de meteoritos\nva a comenzar!",
"eclipseLiveOccurred": "¡Un eclipse ha llegado al planeta {name}!",
"eclipseMyShakes": "Mis sacudidas válidas {count} / {total}"
"eclipseMyShakes": "Mis sacudidas válidas {count} / {total}",
"questGoalBpm": "BPM objetivo",
"shakeCombo": "{n} COMBO",
"shakeJudged": "Evaluados {n} / {total}",
"shakePlayHint": "Reproduce el video y agita el teléfono al ritmo",
"shakeVideoError": "No se puede reproducir este video",
"shakeShareResult": "Compartir resultado"
}
+7 -1
View File
@@ -806,5 +806,11 @@
"eclipseMinSec": "{minutes}分{seconds}秒",
"eclipseOverlayTitle": "惑星への隕石衝突が\n始まります!",
"eclipseLiveOccurred": "{name}惑星にイクリプスが発生しました!",
"eclipseMyShakes": "自分の有効Shake {count} / {total}回"
"eclipseMyShakes": "自分の有効Shake {count} / {total}回",
"questGoalBpm": "目標BPM",
"shakeCombo": "{n} COMBO",
"shakeJudged": "判定 {n} / {total}",
"shakePlayHint": "動画を再生してビートに合わせてスマホを振ってください",
"shakeVideoError": "この動画を再生できません",
"shakeShareResult": "結果をシェア"
}
+23
View File
@@ -1708,5 +1708,28 @@
"type": "String"
}
}
},
"questGoalBpm": "목표 BPM",
"shakeCombo": "{n} COMBO",
"@shakeCombo": {
"placeholders": {
"n": {
"type": "String"
}
}
},
"shakeJudged": "판정 {n} / {total}",
"@shakeJudged": {
"placeholders": {
"n": {
"type": "String"
},
"total": {
"type": "String"
}
}
},
"shakePlayHint": "영상을 재생하고 박자에 맞춰 폰을 흔들어 주세요",
"shakeVideoError": "영상을 재생할 수 없어요",
"shakeShareResult": "결과 공유"
}
+36
View File
@@ -4957,6 +4957,42 @@ abstract class AppLocalizations {
/// In ko, this message translates to:
/// **'내 유효 Shake {count} / {total}회'**
String eclipseMyShakes(String count, String total);
/// No description provided for @questGoalBpm.
///
/// In ko, this message translates to:
/// **'목표 BPM'**
String get questGoalBpm;
/// No description provided for @shakeCombo.
///
/// In ko, this message translates to:
/// **'{n} COMBO'**
String shakeCombo(String n);
/// No description provided for @shakeJudged.
///
/// In ko, this message translates to:
/// **'판정 {n} / {total}'**
String shakeJudged(String n, String total);
/// No description provided for @shakePlayHint.
///
/// In ko, this message translates to:
/// **'영상을 재생하고 박자에 맞춰 폰을 흔들어 주세요'**
String get shakePlayHint;
/// No description provided for @shakeVideoError.
///
/// In ko, this message translates to:
/// **'영상을 재생할 수 없어요'**
String get shakeVideoError;
/// No description provided for @shakeShareResult.
///
/// In ko, this message translates to:
/// **'결과 공유'**
String get shakeShareResult;
}
class _AppLocalizationsDelegate
+22
View File
@@ -2821,4 +2821,26 @@ class AppLocalizationsEn extends AppLocalizations {
String eclipseMyShakes(String count, String total) {
return 'My valid shakes $count / $total';
}
@override
String get questGoalBpm => 'Target BPM';
@override
String shakeCombo(String n) {
return '$n COMBO';
}
@override
String shakeJudged(String n, String total) {
return 'Judged $n / $total';
}
@override
String get shakePlayHint => 'Play the video and shake your phone to the beat';
@override
String get shakeVideoError => 'Can\'t play this video';
@override
String get shakeShareResult => 'Share result';
}
+22
View File
@@ -2833,4 +2833,26 @@ class AppLocalizationsEs extends AppLocalizations {
String eclipseMyShakes(String count, String total) {
return 'Mis sacudidas válidas $count / $total';
}
@override
String get questGoalBpm => 'BPM objetivo';
@override
String shakeCombo(String n) {
return '$n COMBO';
}
@override
String shakeJudged(String n, String total) {
return 'Evaluados $n / $total';
}
@override
String get shakePlayHint => 'Reproduce el video y agita el teléfono al ritmo';
@override
String get shakeVideoError => 'No se puede reproducir este video';
@override
String get shakeShareResult => 'Compartir resultado';
}
+22
View File
@@ -2728,4 +2728,26 @@ class AppLocalizationsJa extends AppLocalizations {
String eclipseMyShakes(String count, String total) {
return '自分の有効Shake $count / $total回';
}
@override
String get questGoalBpm => '目標BPM';
@override
String shakeCombo(String n) {
return '$n COMBO';
}
@override
String shakeJudged(String n, String total) {
return '判定 $n / $total';
}
@override
String get shakePlayHint => '動画を再生してビートに合わせてスマホを振ってください';
@override
String get shakeVideoError => 'この動画を再生できません';
@override
String get shakeShareResult => '結果をシェア';
}
+22
View File
@@ -2730,4 +2730,26 @@ class AppLocalizationsKo extends AppLocalizations {
String eclipseMyShakes(String count, String total) {
return '내 유효 Shake $count / $total회';
}
@override
String get questGoalBpm => '목표 BPM';
@override
String shakeCombo(String n) {
return '$n COMBO';
}
@override
String shakeJudged(String n, String total) {
return '판정 $n / $total';
}
@override
String get shakePlayHint => '영상을 재생하고 박자에 맞춰 폰을 흔들어 주세요';
@override
String get shakeVideoError => '영상을 재생할 수 없어요';
@override
String get shakeShareResult => '결과 공유';
}
+22
View File
@@ -2725,4 +2725,26 @@ class AppLocalizationsZh extends AppLocalizations {
String eclipseMyShakes(String count, String total) {
return '我的有效摇动 $count / $total次';
}
@override
String get questGoalBpm => '目标 BPM';
@override
String shakeCombo(String n) {
return '$n COMBO';
}
@override
String shakeJudged(String n, String total) {
return '判定 $n / $total';
}
@override
String get shakePlayHint => '播放视频并跟着节拍摇动手机';
@override
String get shakeVideoError => '无法播放该视频';
@override
String get shakeShareResult => '分享结果';
}
+7 -1
View File
@@ -806,5 +806,11 @@
"eclipseMinSec": "{minutes}分{seconds}秒",
"eclipseOverlayTitle": "陨石即将\n撞击行星!",
"eclipseLiveOccurred": "{name}行星发生了日食!",
"eclipseMyShakes": "我的有效摇动 {count} / {total}次"
"eclipseMyShakes": "我的有效摇动 {count} / {total}次",
"questGoalBpm": "目标 BPM",
"shakeCombo": "{n} COMBO",
"shakeJudged": "判定 {n} / {total}",
"shakePlayHint": "播放视频并跟着节拍摇动手机",
"shakeVideoError": "无法播放该视频",
"shakeShareResult": "分享结果"
}
@@ -1804,6 +1804,9 @@ class RemoteAppLocalizations extends AppLocalizations {
@override
String get questGoal => _remote['questGoal'] ?? _base.questGoal;
@override
String get questGoalBpm => _remote['questGoalBpm'] ?? _base.questGoalBpm;
@override
String get questGoalValue => _remote['questGoalValue'] ?? _base.questGoalValue;
@@ -2191,6 +2194,21 @@ class RemoteAppLocalizations extends AppLocalizations {
@override
String get settingsWithdrawLater => _remote['settingsWithdrawLater'] ?? _base.settingsWithdrawLater;
@override
String shakeCombo(String n) => _fill('shakeCombo', {'n': n}) ?? _base.shakeCombo(n);
@override
String shakeJudged(String n, String total) => _fill('shakeJudged', {'n': n, 'total': total}) ?? _base.shakeJudged(n, total);
@override
String get shakePlayHint => _remote['shakePlayHint'] ?? _base.shakePlayHint;
@override
String get shakeShareResult => _remote['shakeShareResult'] ?? _base.shakeShareResult;
@override
String get shakeVideoError => _remote['shakeVideoError'] ?? _base.shakeVideoError;
@override
String get shareCopied => _remote['shareCopied'] ?? _base.shareCopied;
+1 -1
View File
@@ -1450,7 +1450,7 @@ packages:
source: hosted
version: "2.15.1"
webview_flutter_wkwebview:
dependency: transitive
dependency: "direct main"
description:
name: webview_flutter_wkwebview
sha256: fe359c7fac1002124b5b9e2ba3a41906bbb9b2d029ccb4a0067404d8f3704730
+4
View File
@@ -107,6 +107,10 @@ dependencies:
# 2026-10-05 대표 지시 「html 을 클론코딩하듯」). flutter.dev 팀 관리.
# 최소 OS 하한 영향 없음: webview_flutter_android minSdk 24(앱 29) · wkwebview iOS 13.0(앱 15.0).
webview_flutter: ^4.14.1
# Music Shake 공식 YouTube IFrame 플레이어(PRD-04) — iOS 인라인 재생(allowsInlineMediaPlayback)을
# 켜려고 직접 참조한다. 이미 webview_flutter 의 전이 의존성(같은 3.26.1)이라 새 패키지가 아니며
# 최소 OS 하한 영향 없음: wkwebview iOS 13.0(앱 15.0).
webview_flutter_wkwebview: ^3.26.1
# 카카오 로그인 — Kakao 공식 SDK. 카카오톡이 있으면 앱으로, 없으면 카카오계정 웹으로 로그인해
# 액세스 토큰을 받고 서버(fnAuthKakao)가 Firebase 커스텀 토큰으로 바꿔 준다. Android 에서
# Firebase OIDC 웹 흐름이 카카오톡 앱 전환 뒤 「missing initial state」로 깨져서 옮겼다.
+6 -6
View File
@@ -213,12 +213,12 @@ void main() {
});
group('Music Shake 보상 표시 (원장 B-24)', () {
test('퀘스트 rewardPop 이 없으면 서버와 같은 운영값 stepsPop 을 기본액으로 적는다', () {
expect(QuestType.musicShake.rewardFrom(const {}, const {'stepsPop': 50}), 50);
expect(
QuestType.musicShake.rewardFrom(const {'rewardPop': 30}, const {'stepsPop': 50}),
30,
);
test('퀘스트 rewardPop 이 없으면 서버와 같은 운영값 musicShake.pop 을 기본액으로 적는다', () {
const policy = {'stepsPop': 50, 'musicShake': {'pop': 1}};
// 걸음 보상(stepsPop)을 빌려 쓰지 않는다.
expect(QuestType.musicShake.rewardFrom(const {}, policy), 1);
expect(QuestType.musicShake.rewardFrom(const {'rewardPop': 30}, policy), 30);
expect(QuestType.musicShake.rewardFrom(const {}, const {'stepsPop': 50}), isNull);
expect(QuestType.musicShake.rewardFrom(const {}, const {}), isNull);
});
});
+127
View File
@@ -0,0 +1,127 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:share_plus/share_plus.dart';
import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/features/quest/music_shake_play_screen.dart';
import 'package:fancake/features/quest/quest_data.dart';
import 'package:fancake/features/quest/quest_evidence.dart';
import 'package:fancake/features/quest/quest_reward_screen.dart';
/// Music Shake 진행 화면 — 영상 시각으로 판정하고 Perfect · Good · Miss · Combo 를 띄운다(PRD-04).
void main() {
const setup = MusicShakeSetup(
videoId: 'WMweEpGlu_U',
title: 'BTS - Butter',
bpm: 60,
segments: 3,
);
testWidgets('재생 중 흔들기만 영상 시각으로 판정 · 끝나면 판정표를 돌려준다', (tester) async {
final shakes = StreamController<DateTime>();
addTearDown(shakes.close);
late ValueChanged<ShakePlayerEvent> player;
ShakeJudge? result;
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Builder(
builder: (context) => TextButton(
onPressed: () async =>
result = await Navigator.of(context).push<ShakeJudge>(
MaterialPageRoute(
builder: (_) => MusicShakePlayScreen(
setup: setup,
shakes: shakes.stream,
player: (onEvent) {
player = onEvent;
return const SizedBox();
},
),
),
),
child: const Text('go'),
),
),
),
);
await tester.tap(find.text('go'));
await tester.pumpAndSettle();
expect(find.text('BTS - Butter'), findsOneWidget);
// 재생 전 흔들기는 세지 않는다.
shakes.add(DateTime.now());
await tester.pump();
Future<void> at(int ms, {bool shake = false, bool playing = true}) async {
player((
playing: playing,
position: Duration(milliseconds: ms),
ended: false,
));
if (shake) shakes.add(DateTime.now());
await tester.pump(const Duration(milliseconds: 60));
}
await at(0, shake: true); // 박 0 perfect
await at(1000, shake: true); // 박 1 perfect
await at(1600);
expect(find.text('PERFECT'), findsOneWidget);
expect(find.text('2 COMBO'), findsOneWidget);
await at(3000); // 마지막 박(2) 창이 닫힘 → 종료
await tester.pumpAndSettle();
expect(result, isNotNull);
expect(result!.judgement(), {
'perfect': 2,
'good': 0,
'miss': 1,
'maxCombo': 2,
});
});
testWidgets('결과 화면 — 판정 합계와 결과 카드 SNS 공유(이미지 + 문구)', (tester) async {
ShareParams? shared;
const card = QuestCard(
id: 'shake',
title: '응원 흔들기',
categoryName: 'Music Shake',
type: QuestType.musicShake,
author: QuestAuthor.admin,
stage: QuestStage.completed,
goal: 120,
progress: 0,
rewardPop: 3,
participants: 0,
);
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: QuestRewardScreen(
detail: const QuestDetail(
card: card,
imageLabel: 'Music Shake',
description: '',
),
reward: const QuestReward(rewardedPop: 3, balanceAfter: 10),
shake: const {'perfect': 5, 'good': 2, 'miss': 1, 'maxCombo': 4},
share: (p) async => shared = p,
),
),
);
await tester.pump();
expect(find.text('PERFECT'), findsOneWidget);
expect(find.text('MAX COMBO'), findsOneWidget);
expect(find.text('5'), findsOneWidget);
await tester.runAsync(() async {
await tester.tap(find.byType(OutlinedButton));
await Future<void>.delayed(const Duration(milliseconds: 300));
});
await tester.pump();
expect(shared, isNotNull);
expect(shared!.text, contains('PERFECT 5 · GOOD 2 · MISS 1 · MAX COMBO 4'));
expect(shared!.text, contains('#FANUZU'));
});
}
+27 -27
View File
@@ -1,4 +1,5 @@
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:cloud_firestore/cloud_firestore.dart' show SetOptions;
import 'package:fancake/core/data/backend.dart';
import 'package:fancake/features/quest/quest_data.dart';
import 'package:fancake/features/quest/quest_evidence.dart';
@@ -335,52 +336,51 @@ service cloud.firestore {
expect(reward.rewardedPop, 0);
});
test('Music Shake — 박마다 구간을 세고 판정표 · 곡 ID 를 서버에 보낸다', () async {
test('Music Shake — 영상 · BPM · 구간 수를 퀘스트에서 읽고 판정표 · 곡 ID 를 서버에 보낸다', () async {
final db = await _seed();
// 600 BPM = 박 100ms · 3 구간. 박 0 과 박 1 근처에서만 흔든다.
await db.doc('config/policy').set({
'musicShake': {'perfectWindow': 0.1, 'goodWindow': 0.4, 'pop': 3},
}, SetOptions(merge: true));
await db.doc('quests/shake').set({
'type': 'musicshake', 'title': '응원 흔들기', 'dailyLimit': 1,
'trackIds': ['track-a'], 'totalSegments': 3, 'bpm': 600,
'totalSegments': 3, 'bpm': 120,
'link': {'url': 'https://youtu.be/WMweEpGlu_U', 'title': 'BTS - Butter'},
});
final backend = _RecordingBackend(db);
final repo = FirestoreQuestRepository(
backend,
shakes: () async* {
final start = DateTime.now();
yield start;
await Future<void>.delayed(const Duration(milliseconds: 100));
yield start.add(const Duration(milliseconds: 140));
},
);
final repo = FirestoreQuestRepository(backend);
final card = _card(await repo.load(), 'shake');
expect(card.type.verifiable, isTrue);
expect(card.goal, 3);
expect(card.progress, 0);
// Figma 「목표BPM」 — 목표 줄은 곡 BPM 이다. 보상은 운영값 musicShake.pop.
expect(card.goal, 120);
expect(card.rewardPop, 3);
final setup = await repo.musicShakeSetup('shake');
expect(setup.videoId, 'WMweEpGlu_U');
expect(setup.title, 'BTS - Butter');
expect((setup.bpm, setup.segments), (120.0, 3));
expect((setup.windows.perfect, setup.windows.good), (0.1, 0.4));
// 판을 끝내기 전에는 서버를 부르지 않는다.
await expectLater(repo.verify(card), throwsA(isA<BackendException>()));
expect(backend.calls, isEmpty);
expect(await repo.progressStream(card).toList(), [1, 2, 3]);
repo.recordShake(ShakeJudge(bpm: 120, segments: 3, windows: setup.windows)
..add(Duration.zero));
await repo.verify(card);
final call = backend.calls.single;
expect(call.name, 'fnQuestVerifyMusicShake');
expect(call.payload['questId'], 'shake');
expect(call.payload['trackId'], 'track-a');
expect(call.payload['trackId'], 'WMweEpGlu_U');
expect(call.payload['totalSegments'], 3);
final j = call.payload['judgement'] as Map;
expect(j['perfect']! + j['good']! + j['miss']!, 3);
expect(j['miss'], 1);
expect(call.payload['judgement'], {'perfect': 1, 'good': 0, 'miss': 2, 'maxCombo': 1});
});
test('ShakeJudge — 박과의 거리로 perfect · good · miss 를 가르고 최장 연속을 센다', () {
final start = DateTime(2026, 10, 5);
final judge = ShakeJudge(bpm: 60, segments: 4, start: start)
..add(start.add(const Duration(milliseconds: 100))) // 박 0 · perfect
..add(start.add(const Duration(milliseconds: 1400))) // 박 1 · good
..add(start.add(const Duration(milliseconds: 3000))); // 박 3 · perfect, 박 2 miss
expect(judge.judgement(), {'perfect': 2, 'good': 1, 'miss': 1, 'maxCombo': 2});
expect(judge.end, start.add(const Duration(milliseconds: 3500)));
test('Music Shake — 영상 · BPM · 구간 수가 없으면 판을 열지 않는다(값을 지어내지 않는다)', () async {
final db = await _seed();
await db.doc('quests/shake').set({'type': 'musicshake', 'title': '흔들기', 'totalSegments': 3});
final repo = FirestoreQuestRepository(_RecordingBackend(db));
await expectLater(repo.musicShakeSetup('shake'), throwsA(isA<BackendException>()));
expect(_card(await repo.load(), 'shake').goal, 0);
});
test('생성은 fn-quest-create 로 — 종료일은 날짜 + 그 시각의 UTC 오프셋(C-06)', () async {
+59
View File
@@ -0,0 +1,59 @@
import 'package:fancake/features/quest/quest_evidence.dart';
import 'package:flutter_test/flutter_test.dart';
Duration ms(int v) => Duration(milliseconds: v);
void main() {
test('Music Shake 실시간 판정 — 영상 시각 기준 · 창이 닫힌 박만 센다 · 콤보', () {
// 60 BPM = 박 1초 · 창 perfect ±0.2 · good ±0.5 박(임시 운영값).
final j = ShakeJudge(bpm: 60, segments: 4)
..add(ms(100)) // 박 0 · perfect
..add(ms(1400)) // 박 1 · good
..add(ms(3050)); // 박 3 · perfect, 박 2 miss
expect(j.live(ms(400)).judged, 0);
final mid = j.live(ms(1600));
expect(
(mid.judged, mid.perfect, mid.good, mid.combo, mid.last),
(2, 1, 1, 2, ShakeHit.good),
);
final after2 = j.live(ms(2600));
expect((after2.miss, after2.combo, after2.last), (1, 0, ShakeHit.miss));
expect(j.end, ms(3500));
expect(j.judgement(), {'perfect': 2, 'good': 1, 'miss': 1, 'maxCombo': 2});
});
test('흔들기는 가장 가까운 박 하나에만 붙고 · 오지 않은 박은 miss 다', () {
final j = ShakeJudge(bpm: 60, segments: 3)
..add(ms(500))
..add(ms(600));
// 0.5 는 박 1 쪽으로 반올림(창 경계 0.5 포함) · 0.6 도 박 1 — 박 1 good 하나.
expect(j.judgement(), {'perfect': 0, 'good': 1, 'miss': 2, 'maxCombo': 1});
});
test('판정 창은 운영값 config/policy.musicShake 가 덮는다', () {
final w = ShakeWindows.from({
'musicShake': {'perfectWindow': 0.1, 'goodWindow': 0.3},
});
expect((w.perfect, w.good), (0.1, 0.3));
final d = ShakeWindows.from(const {});
expect((d.perfect, d.good), (0.2, 0.5));
});
test('YouTube 영상 ID', () {
expect(
youtubeVideoId('https://www.youtube.com/watch?v=WMweEpGlu_U'),
'WMweEpGlu_U',
);
expect(youtubeVideoId('https://youtu.be/WMweEpGlu_U?si=x'), 'WMweEpGlu_U');
expect(
youtubeVideoId('https://music.youtube.com/watch?v=WMweEpGlu_U'),
'WMweEpGlu_U',
);
expect(
youtubeVideoId('https://youtube.com/shorts/WMweEpGlu_U'),
'WMweEpGlu_U',
);
expect(youtubeVideoId('https://vimeo.com/123'), isNull);
expect(youtubeVideoId('https://www.youtube.com/watch?v=short'), isNull);
});
}