diff --git a/app/firebase/functions/src/core/config.ts b/app/firebase/functions/src/core/config.ts index ac79889..a62b7d7 100644 --- a/app/firebase/functions/src/core/config.ts +++ b/app/firebase/functions/src/core/config.ts @@ -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; + /** + * 퀘스트 `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 { @@ -185,6 +229,8 @@ export async function loadPolicy(): Promise { ...raw, // 가중치는 부분 지정이 흔하므로 축별로 병합한다. 통째로 덮으면 축이 사라진다. fpiWeights: weights, + // 판정 운영값은 키별로 병합한다 — 일부만 바꿔도 나머지 임시값이 남는다. + musicShake: { ...DEFAULT_POLICY.musicShake, ...(raw.musicShake ?? {}) }, // 구간표는 통째로 덮는다. 섞으면 운영에서 지운 구간이 되살아난다. stepTiers: normalizeTiers(raw.stepTiers) ?? DEFAULT_POLICY.stepTiers, } as PolicyConfig; diff --git a/app/firebase/functions/src/quest/verify.ts b/app/firebase/functions/src/quest/verify.ts index 2ce2c98..1f450ce 100644 --- a/app/firebase/functions/src/quest/verify.ts +++ b/app/firebase/functions/src/quest/verify.ts @@ -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> = { - 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, diff --git a/app/firebase/functions/test/extended.test.ts b/app/firebase/functions/test/extended.test.ts index a1a7671..ef1b2af 100644 --- a/app/firebase/functions/test/extended.test.ts +++ b/app/firebase/functions/test/extended.test.ts @@ -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)', () => { diff --git a/app/firebase/functions/test/quest_e2e.test.ts b/app/firebase/functions/test/quest_e2e.test.ts index 55a5e68..a3de09c 100644 --- a/app/firebase/functions/test/quest_e2e.test.ts +++ b/app/firebase/functions/test/quest_e2e.test.ts @@ -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, { diff --git a/app/lib/features/eclipse/eclipse_defend_screen.dart b/app/lib/features/eclipse/eclipse_defend_screen.dart index 76c8ad5..a796cd8 100644 --- a/app/lib/features/eclipse/eclipse_defend_screen.dart +++ b/app/lib/features/eclipse/eclipse_defend_screen.dart @@ -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 /// 서버가 받아들인 내 누적 참여량. 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 _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 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 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 _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), diff --git a/app/lib/features/quest/music_shake_play_screen.dart b/app/lib/features/quest/music_shake_play_screen.dart new file mode 100644 index 0000000..77a91e5 --- /dev/null +++ b/app/lib/features/quest/music_shake_play_screen.dart @@ -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 shakes; + + /// 영상 플레이어. 기본은 [YoutubeEmbed]. 테스트는 상태를 직접 넣는 가짜를 준다. + final Widget Function(ValueChanged onEvent)? player; + + @override + State createState() => _MusicShakePlayScreenState(); +} + +class _MusicShakePlayScreenState extends State { + late final ShakeJudge _judge = ShakeJudge( + bpm: widget.setup.bpm, + segments: widget.setup.segments, + windows: widget.setup.windows, + ); + StreamSubscription? _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 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 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) => + ''' + + +
'''; + + @override + State createState() => _YoutubeEmbedState(); +} + +class _YoutubeEmbedState extends State { + 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; + 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); + } +} diff --git a/app/lib/features/quest/quest_data.dart b/app/lib/features/quest/quest_data.dart index 7ec10d5..2c08183 100644 --- a/app/lib/features/quest/quest_data.dart +++ b/app/lib/features/quest/quest_data.dart @@ -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 quest, Map 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 progressStream(QuestCard card); + /// Music Shake 한 판의 준비값 — 공식 YouTube 영상 · BPM · 구간 수 · 판정 창(운영값). + /// 퀘스트에 값이 빠져 있으면 E-QST-01 이다(값을 지어내지 않는다). + Future musicShakeSetup(String questId); + + /// 단말 흔들기 시각 스트림(가속도계). + Stream shakeEvents(); + + /// 끝난 판의 판정표를 남긴다. 다음 [verify] 가 서버에 증거로 보낸다. + void recordShake(ShakeJudge run); + /// 활동 증거를 서버에 보내 판정·지급을 받는다. /// /// **판정도 지급도 서버가 한다.** 앱은 증거(걸음 수·좌표)만 싣고, 실패는 @@ -742,6 +780,17 @@ class SampleQuestRepository implements QuestRepository { @override Stream progressStream(QuestCard card) => const Stream.empty(); + /// 표본에는 영상 · BPM 이 없다 — 참여를 열지 않는다. + @override + Future musicShakeSetup(String questId) async => + throw BackendException('E-QST-01', appL10n.questTypeUnverifiable); + + @override + Stream shakeEvents() => const Stream.empty(); + + @override + void recordShake(ShakeJudge run) {} + @override Future verify(QuestCard card) async { _claimed.add(card.id); diff --git a/app/lib/features/quest/quest_detail_screen.dart b/app/lib/features/quest/quest_detail_screen.dart index fcf808f..a8e7208 100644 --- a/app/lib/features/quest/quest_detail_screen.dart +++ b/app/lib/features/quest/quest_detail_screen.dart @@ -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 { /// 서버 왕복 중. 두 번 눌러 두 번 지급되는 경로를 막는다. bool _busy = false; + /// 방금 끝낸 Music Shake 판의 판정 합계 — 결과 화면 · 결과 카드에 쓴다. + Map? _shakeResult; + @override void initState() { super.initState(); @@ -182,7 +185,8 @@ class _QuestDetailScreenState extends State { 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 { ], // 명세 `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 { ], ), ), + // 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 { 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 { _watch(detail.card); } + /// Music Shake 한 판 — 공식 영상을 보며 흔드는 진행 화면을 띄운다(PRD-04). + /// 끝까지 했으면 판정표를 남기고 「보상받기」로, 중간에 나왔으면 시작 전으로 돌린다. + Future _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( + 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 { .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 { 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 { detail: detail, reward: reward, adBonusPop: adPop, + shake: _shakeResult, ), ), ); diff --git a/app/lib/features/quest/quest_evidence.dart b/app/lib/features/quest/quest_evidence.dart index be4cad0..b678aae 100644 --- a/app/lib/features/quest/quest_evidence.dart +++ b/app/lib/features/quest/quest_evidence.dart @@ -104,51 +104,149 @@ Stream 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 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 = []; + /// 박 번호 → 가장 가까운 흔들기의 거리(박 비율). + final _best = {}; 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 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(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 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`. diff --git a/app/lib/features/quest/quest_repository_firestore.dart b/app/lib/features/quest/quest_repository_firestore.dart index 78b918a..b79f813 100644 --- a/app/lib/features/quest/quest_repository_firestore.dart +++ b/app/lib/features/quest/quest_repository_firestore.dart @@ -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 progressStream(QuestCard card) { - if (card.type == QuestType.musicShake) return _shakeProgress(card); if (card.type != QuestType.steps) return const Stream.empty(); // 센서는 부팅 이후 누적을 준다. 하루 단위 기준선을 빼야 정책 원장 **B-26** 의 // 구간(1 천·5 천·1 만·1.5 만보)과 같은 것을 세게 된다. @@ -519,49 +516,53 @@ class FirestoreQuestRepository implements QuestRepository { }); } - /// 곡 박마다 한 구간 — 시작부터 박 수를 세어 내고, 마지막 구간 판정 창이 닫히면 구간 수를 낸다. - /// 흔들기는 그동안만 모은다. - Stream _shakeProgress(QuestCard card) async* { - _shakeRun = null; - final quest = (await _backend.db.doc('quests/${card.id}').get()).data() ?? + @override + Future musicShakeSetup(String questId) async { + final quest = (await _backend.db.doc('quests/$questId').get()).data() ?? const {}; + final link = quest['link'] is Map + ? Map.from(quest['link'] as Map) + : const {}; + 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.delayed(run.beat); - yield i; - } - await Future.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 shakeEvents() => _shakes(); + + @override + void recordShake(ShakeJudge run) => _shakeRun = run; + Future> _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 {}; final ids = quest['trackIds']; - // 곡 ID 는 서버 대조용이다 — 화면에 띄우지 않는다. - final trackId = ids is List && ids.isNotEmpty ? '${ids.first}' : quest.strOr('trackId'); + final link = quest['link'] is Map ? Map.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(), }); } diff --git a/app/lib/features/quest/quest_reward_screen.dart b/app/lib/features/quest/quest_reward_screen.dart index ae2de51..216b2fc 100644 --- a/app/lib/features/quest/quest_reward_screen.dart +++ b/app/lib/features/quest/quest_reward_screen.dart @@ -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? shake; + + /// 공유 시트 호출. 테스트가 대신 받는다. + final Future 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 createState() => _QuestRewardScreenState(); +} + +class _QuestRewardScreenState extends State { + 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 _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,7 +118,11 @@ class QuestRewardScreen extends StatelessWidget { children: [ Expanded( child: Center( - child: Column( + child: RepaintBoundary( + key: _card, + child: ColoredBox( + color: AppColors.cosmicVoid, + child: Column( mainAxisSize: MainAxisSize.min, children: [ // 120 원 둘레에 그림자 6 을 포함한 132 이미지. @@ -88,10 +163,38 @@ 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), + ), + ), + ), Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), child: FilledButton( diff --git a/app/lib/features/quest/widgets/quest_card_tile.dart b/app/lib/features/quest/widgets/quest_card_tile.dart index 2edbadd..6a30268 100644 --- a/app/lib/features/quest/widgets/quest_card_tile.dart +++ b/app/lib/features/quest/widgets/quest_card_tile.dart @@ -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, ), ), diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index b9f0864..b59a409 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -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" } diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index 610c248..3c711fa 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -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" } diff --git a/app/lib/l10n/app_ja.arb b/app/lib/l10n/app_ja.arb index e58b6f2..a1f381b 100644 --- a/app/lib/l10n/app_ja.arb +++ b/app/lib/l10n/app_ja.arb @@ -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": "結果をシェア" } diff --git a/app/lib/l10n/app_ko.arb b/app/lib/l10n/app_ko.arb index a36f8e3..cbb1956 100644 --- a/app/lib/l10n/app_ko.arb +++ b/app/lib/l10n/app_ko.arb @@ -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": "결과 공유" } diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 176d601..86a5ce5 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -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 diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index fa36746..12e686a 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -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'; } diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index 21e2b70..a48066d 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -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'; } diff --git a/app/lib/l10n/app_localizations_ja.dart b/app/lib/l10n/app_localizations_ja.dart index cf12645..9d515cc 100644 --- a/app/lib/l10n/app_localizations_ja.dart +++ b/app/lib/l10n/app_localizations_ja.dart @@ -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 => '結果をシェア'; } diff --git a/app/lib/l10n/app_localizations_ko.dart b/app/lib/l10n/app_localizations_ko.dart index 49494b2..312bd30 100644 --- a/app/lib/l10n/app_localizations_ko.dart +++ b/app/lib/l10n/app_localizations_ko.dart @@ -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 => '결과 공유'; } diff --git a/app/lib/l10n/app_localizations_zh.dart b/app/lib/l10n/app_localizations_zh.dart index 6624522..a704bb8 100644 --- a/app/lib/l10n/app_localizations_zh.dart +++ b/app/lib/l10n/app_localizations_zh.dart @@ -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 => '分享结果'; } diff --git a/app/lib/l10n/app_zh.arb b/app/lib/l10n/app_zh.arb index b52e7b4..a29cb40 100644 --- a/app/lib/l10n/app_zh.arb +++ b/app/lib/l10n/app_zh.arb @@ -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": "分享结果" } diff --git a/app/lib/l10n/remote_app_localizations.dart b/app/lib/l10n/remote_app_localizations.dart index 64c735a..649ff0e 100644 --- a/app/lib/l10n/remote_app_localizations.dart +++ b/app/lib/l10n/remote_app_localizations.dart @@ -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; diff --git a/app/pubspec.lock b/app/pubspec.lock index 4ef4962..a3c85a6 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -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 diff --git a/app/pubspec.yaml b/app/pubspec.yaml index a8b2be4..03cd74c 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -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」로 깨져서 옮겼다. diff --git a/app/test/eclipse_figma_flow_test.dart b/app/test/eclipse_figma_flow_test.dart index 0aab36f..8ad4075 100644 --- a/app/test/eclipse_figma_flow_test.dart +++ b/app/test/eclipse_figma_flow_test.dart @@ -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); }); }); diff --git a/app/test/music_shake_play_test.dart b/app/test/music_shake_play_test.dart new file mode 100644 index 0000000..c7fd551 --- /dev/null +++ b/app/test/music_shake_play_test.dart @@ -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(); + addTearDown(shakes.close); + late ValueChanged player; + ShakeJudge? result; + + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: Builder( + builder: (context) => TextButton( + onPressed: () async => + result = await Navigator.of(context).push( + 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 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.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')); + }); +} diff --git a/app/test/quest_repository_test.dart b/app/test/quest_repository_test.dart index 4e57c27..5bac9b3 100644 --- a/app/test/quest_repository_test.dart +++ b/app/test/quest_repository_test.dart @@ -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.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())); 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())); + expect(_card(await repo.load(), 'shake').goal, 0); }); test('생성은 fn-quest-create 로 — 종료일은 날짜 + 그 시각의 UTC 오프셋(C-06)', () async { diff --git a/app/test/shake_live_hits_test.dart b/app/test/shake_live_hits_test.dart new file mode 100644 index 0000000..2fc200e --- /dev/null +++ b/app/test/shake_live_hits_test.dart @@ -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); + }); +}