이클립스 방어 횟수 즉시 표시·진동 · Music Shake 영상 재생 중 흔들기 판정 · 결과 공유
This commit is contained in:
@@ -105,6 +105,40 @@ export interface PolicyConfig {
|
|||||||
referralAttachDays: number;
|
referralAttachDays: number;
|
||||||
/** 긴급 돌발 타임어택 지속 시간 상한(분) — 원장 「최대 24시간」. 🟡 운영값. */
|
/** 긴급 돌발 타임어택 지속 시간 상한(분) — 원장 「최대 24시간」. 🟡 운영값. */
|
||||||
timeAttackMaxMinutes: number;
|
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,
|
referralDailyCapPerReferrer: 5,
|
||||||
referralAttachDays: 7,
|
referralAttachDays: 7,
|
||||||
timeAttackMaxMinutes: 1440,
|
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> {
|
export async function loadPolicy(): Promise<PolicyConfig> {
|
||||||
@@ -185,6 +229,8 @@ export async function loadPolicy(): Promise<PolicyConfig> {
|
|||||||
...raw,
|
...raw,
|
||||||
// 가중치는 부분 지정이 흔하므로 축별로 병합한다. 통째로 덮으면 축이 사라진다.
|
// 가중치는 부분 지정이 흔하므로 축별로 병합한다. 통째로 덮으면 축이 사라진다.
|
||||||
fpiWeights: weights,
|
fpiWeights: weights,
|
||||||
|
// 판정 운영값은 키별로 병합한다 — 일부만 바꿔도 나머지 임시값이 남는다.
|
||||||
|
musicShake: { ...DEFAULT_POLICY.musicShake, ...(raw.musicShake ?? {}) },
|
||||||
// 구간표는 통째로 덮는다. 섞으면 운영에서 지운 구간이 되살아난다.
|
// 구간표는 통째로 덮는다. 섞으면 운영에서 지운 구간이 되살아난다.
|
||||||
stepTiers: normalizeTiers(raw.stepTiers) ?? DEFAULT_POLICY.stepTiers,
|
stepTiers: normalizeTiers(raw.stepTiers) ?? DEFAULT_POLICY.stepTiers,
|
||||||
} as PolicyConfig;
|
} as PolicyConfig;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { callerUid, num, optionalStr, str } from '../core/callable.js';
|
|||||||
import { ERR, fail, positiveInt } from '../core/errors.js';
|
import { ERR, fail, positiveInt } from '../core/errors.js';
|
||||||
import { deviceHashOf, txIdOf } from '../core/ids.js';
|
import { deviceHashOf, txIdOf } from '../core/ids.js';
|
||||||
import { dayKey, isPlausibleOccurredAt } from '../core/time.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 { grantPop, type GrantInput } from '../ledger/ledger.js';
|
||||||
import { fandomOfUser, markCompletion, writeFpiEvent } from '../ledger/fpi.js';
|
import { fandomOfUser, markCompletion, writeFpiEvent } from '../ledger/fpi.js';
|
||||||
import { dailyLimitOf, progress, progressWriter, questOf } from './quest.js';
|
import { dailyLimitOf, progress, progressWriter, questOf } from './quest.js';
|
||||||
@@ -47,26 +47,42 @@ export interface ShakeJudgement {
|
|||||||
maxCombo: number;
|
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(
|
export function gradeShake(
|
||||||
judgement: ShakeJudgement,
|
judgement: ShakeJudgement,
|
||||||
totalSegments: number,
|
totalSegments: number,
|
||||||
|
rule: MusicShakePolicy = DEFAULT_POLICY.musicShake,
|
||||||
): ShakeGrade {
|
): ShakeGrade {
|
||||||
const hit = judgement.perfect + judgement.good;
|
const hit = judgement.perfect + judgement.good;
|
||||||
if (totalSegments <= 0 || hit + judgement.miss !== totalSegments) return 'F';
|
if (totalSegments <= 0 || hit + judgement.miss !== totalSegments) return 'F';
|
||||||
|
if (hit / totalSegments < rule.passRate) return 'F';
|
||||||
const accuracy = judgement.perfect / totalSegments;
|
const accuracy = judgement.perfect / totalSegments;
|
||||||
if (judgement.miss === 0 && accuracy >= 0.95) return 'S';
|
if (judgement.miss === 0 && accuracy >= rule.gradeCuts.S) return 'S';
|
||||||
if (accuracy >= 0.8) return 'A';
|
if (accuracy >= rule.gradeCuts.A) return 'A';
|
||||||
if (accuracy >= 0.6) return 'B';
|
if (accuracy >= rule.gradeCuts.B) return 'B';
|
||||||
if (hit / totalSegments >= 0.5) return 'C';
|
return 'C';
|
||||||
return 'F';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 등급별 보상 배수. 기본 보상에 곱한다. */
|
/** YouTube 링크의 영상 ID — 앱 `youtubeVideoId` 와 같은 규칙. 아니면 null. */
|
||||||
const GRADE_MULTIPLIER: Readonly<Record<ShakeGrade, number>> = {
|
export function videoIdOf(raw: unknown): string | null {
|
||||||
S: 2, A: 1.5, B: 1.2, C: 1, F: 0,
|
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` */
|
/** `fn-quest-verify-music-shake` */
|
||||||
export const fnQuestVerifyMusicShake = onCall(async (req) => {
|
export const fnQuestVerifyMusicShake = onCall(async (req) => {
|
||||||
@@ -79,8 +95,11 @@ export const fnQuestVerifyMusicShake = onCall(async (req) => {
|
|||||||
// 구간 수도 퀘스트에 정의돼 있으면 그 값만 받는다 — 판정표를 줄여 등급을 올리지 못하게.
|
// 구간 수도 퀘스트에 정의돼 있으면 그 값만 받는다 — 판정표를 줄여 등급을 올리지 못하게.
|
||||||
const questId = str(req.data, 'questId');
|
const questId = str(req.data, 'questId');
|
||||||
const quest = await questOf(uid, questId, ['musicshake', 'music_shake', 'shake']);
|
const quest = await questOf(uid, questId, ['musicshake', 'music_shake', 'shake']);
|
||||||
const trackIds = Array.isArray(quest.get('trackIds'))
|
// 곡은 퀘스트의 카탈로그(`trackIds` · `trackId`) 또는 지정 공식 영상(`link.url` · PRD-04 MVP).
|
||||||
? quest.get('trackIds') as unknown[] : [quest.get('trackId')];
|
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)) {
|
if (!trackIds.includes(trackId)) {
|
||||||
fail(ERR.questIncomplete, '이 퀘스트의 곡이 아닙니다.');
|
fail(ERR.questIncomplete, '이 퀘스트의 곡이 아닙니다.');
|
||||||
}
|
}
|
||||||
@@ -105,7 +124,8 @@ export const fnQuestVerifyMusicShake = onCall(async (req) => {
|
|||||||
fail(ERR.popDuplicate, '오늘 이 퀘스트의 보상은 이미 받았습니다.');
|
fail(ERR.popDuplicate, '오늘 이 퀘스트의 보상은 이미 받았습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const grade = gradeShake(judgement, totalSegments);
|
const policy = await loadPolicy();
|
||||||
|
const grade = gradeShake(judgement, totalSegments, policy.musicShake);
|
||||||
const db = getFirestore();
|
const db = getFirestore();
|
||||||
const logRef = db.collection(`users/${uid}/activityLogs`).doc();
|
const logRef = db.collection(`users/${uid}/activityLogs`).doc();
|
||||||
|
|
||||||
@@ -126,16 +146,18 @@ export const fnQuestVerifyMusicShake = onCall(async (req) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (grade === 'F') {
|
if (grade === 'F') {
|
||||||
// 판정표가 스스로 모순되거나 완주하지 못한 경우다. 증거는 남기고 보상은 없다.
|
// 판정표가 스스로 모순되거나 성공 기준에 못 미친 경우다(B-24 — 판정 실패 시 보상 0).
|
||||||
fail(ERR.questIncomplete, '완주하지 못했습니다.');
|
// 증거는 남기고 보상은 없다.
|
||||||
|
fail(ERR.questIncomplete, '박자 판정이 성공 기준에 미치지 못했습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const policy = await loadPolicy();
|
|
||||||
// 원장 B-24 「보상액은 운영값」 — 퀘스트 문서(Admin 「설정 보상 POP」)가 정하고, 없으면
|
// 원장 B-24 「보상액은 운영값」 — 퀘스트 문서(Admin 「설정 보상 POP」)가 정하고, 없으면
|
||||||
// 옛 공통값으로 물러난다. 앱 화면도 같은 순서로 읽는다(`QuestType.rewardFrom`).
|
// `config/policy.musicShake.pop` 으로 물러난다. 앱 화면도 같은 순서로 읽는다(`QuestType.rewardFrom`).
|
||||||
|
// 등급 배수는 🟡 임시값 — 회신 대기(Q-MS-05).
|
||||||
const base = quest.get('rewardPop');
|
const base = quest.get('rewardPop');
|
||||||
const amount = Math.round(
|
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({
|
const result = await grantPop({
|
||||||
uid,
|
uid,
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|||||||
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
|
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
|
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';
|
import { grantPop, refundPop, spendPop } from '../src/ledger/ledger.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,6 +44,22 @@ describe('Music Shake 판정 (E-02)', () => {
|
|||||||
it('구간이 0이면 실격이다', () => {
|
it('구간이 0이면 실격이다', () => {
|
||||||
expect(gradeShake({ perfect: 0, good: 0, miss: 0, maxCombo: 0 }, 0)).toBe('F');
|
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)', () => {
|
describe('방문 거리 (E-06)', () => {
|
||||||
|
|||||||
@@ -519,6 +519,24 @@ describe('검증 활동 집계 규칙 — Quest 완료 1건 · 하루 1회 (Camp
|
|||||||
expect(reply.rewarded).toBe(60);
|
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 () => {
|
it('Music Shake 는 완주마다 1회 · 방문은 1회', async () => {
|
||||||
await db().doc('quests/music-count').set({ type: 'musicShake', trackId: 't1', totalSegments: 10, dailyLimit: 2 });
|
await db().doc('quests/music-count').set({ type: 'musicShake', trackId: 't1', totalSegments: 10, dailyLimit: 2 });
|
||||||
const shake = () => call(fnQuestVerifyMusicShake, {
|
const shake = () => call(fnQuestVerifyMusicShake, {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'dart:math' as math;
|
|||||||
import 'dart:ui' show ImageFilter;
|
import 'dart:ui' show ImageFilter;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import '../../core/data/backend.dart';
|
import '../../core/data/backend.dart';
|
||||||
import '../../core/theme/tokens.dart';
|
import '../../core/theme/tokens.dart';
|
||||||
@@ -56,6 +57,11 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
|
|||||||
/// 서버가 받아들인 내 누적 참여량.
|
/// 서버가 받아들인 내 누적 참여량.
|
||||||
int _mine = 0;
|
int _mine = 0;
|
||||||
|
|
||||||
|
/// 화면에 보이는 내 횟수 — 서버 확정값에 아직 보내지 않았거나 보내는 중인 흔들기를 더한다.
|
||||||
|
/// 흔드는 동안 서버 왕복(약 0.7초 묶음)을 기다리면 숫자가 늦게 올라 「얼마나 잘 흔들고
|
||||||
|
/// 있는지」 알 수 없었다. 확정값이 오면 그것으로 맞춰진다.
|
||||||
|
int get _shown => _mine + _pending + (_sending ? (_batch?.shakes ?? 0) : 0);
|
||||||
|
|
||||||
/// 상한에 걸려 깎인 적이 있는가. 한 번 알리고 다시 알리지 않는다.
|
/// 상한에 걸려 깎인 적이 있는가. 한 번 알리고 다시 알리지 않는다.
|
||||||
bool _cappedNotified = false;
|
bool _cappedNotified = false;
|
||||||
|
|
||||||
@@ -105,7 +111,15 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
|
|||||||
_current?.canShake(DateTime.now()) != true ||
|
_current?.canShake(DateTime.now()) != true ||
|
||||||
(_batch != null && !_sending))
|
(_batch != null && !_sending))
|
||||||
return;
|
return;
|
||||||
|
final qualify = _current?.qualifyShakes ?? 0;
|
||||||
|
final before = _shown;
|
||||||
setState(() => _pending += 1);
|
setState(() => _pending += 1);
|
||||||
|
// 화면을 보지 않고 흔들어도 알 수 있게 — 한 번 셀 때마다 짧게, 인정 기준을 넘는 순간 길게.
|
||||||
|
if (qualify > 0 && before < qualify && _shown >= qualify) {
|
||||||
|
HapticFeedback.heavyImpact();
|
||||||
|
} else {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
}
|
||||||
_scheduleFlush();
|
_scheduleFlush();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,7 +270,7 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
|
|||||||
title: context.l10n.eclipseSuccessTitle,
|
title: context.l10n.eclipseSuccessTitle,
|
||||||
success: true,
|
success: true,
|
||||||
extra: [
|
extra: [
|
||||||
if (_mine >= state.qualifyShakes) _Note(context.l10n.eclipseQualified),
|
if (_shown >= state.qualifyShakes) _Note(context.l10n.eclipseQualified),
|
||||||
if (_batch != null) _retryButton(),
|
if (_batch != null) _retryButton(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -283,7 +297,7 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
|
|||||||
confirm: result != null,
|
confirm: result != null,
|
||||||
extra: [
|
extra: [
|
||||||
_Note(message),
|
_Note(message),
|
||||||
if (_mine >= state.qualifyShakes) _Note(context.l10n.eclipseQualified),
|
if (_shown >= state.qualifyShakes) _Note(context.l10n.eclipseQualified),
|
||||||
if (_batch != null) _retryButton(),
|
if (_batch != null) _retryButton(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -424,16 +438,16 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
|
|||||||
_Meter(
|
_Meter(
|
||||||
key: const ValueKey('eclipse-my-shakes'),
|
key: const ValueKey('eclipse-my-shakes'),
|
||||||
label:
|
label:
|
||||||
'${context.l10n.eclipseMyShakes(formatPop(_mine), '${state.qualifyShakes}')}'
|
'${context.l10n.eclipseMyShakes(formatPop(_shown), '${state.qualifyShakes}')}'
|
||||||
'${_pending > 0
|
'${_pending > 0
|
||||||
? context.l10n.eclipsePendingSuffix('$_pending')
|
? context.l10n.eclipsePendingSuffix('$_pending')
|
||||||
: _sending
|
: _sending
|
||||||
? context.l10n.eclipseSendingSuffix
|
? context.l10n.eclipseSendingSuffix
|
||||||
: ''}'
|
: ''}'
|
||||||
'${_mine >= state.qualifyShakes ? ' · ${context.l10n.eclipseQualified}' : ''}',
|
'${_shown >= state.qualifyShakes ? ' · ${context.l10n.eclipseQualified}' : ''}',
|
||||||
value: state.qualifyShakes <= 0
|
value: state.qualifyShakes <= 0
|
||||||
? 0
|
? 0
|
||||||
: (_mine / state.qualifyShakes).clamp(0.0, 1.0),
|
: (_shown / state.qualifyShakes).clamp(0.0, 1.0),
|
||||||
color: AppColors.cosmicAccent,
|
color: AppColors.cosmicAccent,
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpace.s3),
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,11 @@
|
|||||||
/// - 퀘스트 생성 `523:18152`~`523:18188` · Admin 정의 `567:19531`
|
/// - 퀘스트 생성 `523:18152`~`523:18188` · Admin 정의 `567:19531`
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import '../../core/data/backend.dart' show BackendException;
|
||||||
import '../../core/monetization/rewarded_ad.dart';
|
import '../../core/monetization/rewarded_ad.dart';
|
||||||
import '../../core/i18n/app_locale.dart';
|
import '../../core/i18n/app_locale.dart';
|
||||||
import 'place_picker_screen.dart' show PlacePickerScreen;
|
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) {
|
int? rewardFrom(Map<String, dynamic> quest, Map<String, dynamic> policy) {
|
||||||
// 시청 · 스트리밍은 무보상 참여다(C-04) — 문서 값과 무관하게 0.
|
// 시청 · 스트리밍은 무보상 참여다(C-04) — 문서 값과 무관하게 0.
|
||||||
if (this == QuestType.streaming) return 0;
|
if (this == QuestType.streaming) return 0;
|
||||||
// Music Shake 는 BPM 판정 성공 시 일반 POP(원장 B-24). 서버(`fn-quest-verify-music-shake`)가
|
// Music Shake 는 BPM 판정 성공 시 일반 POP(원장 B-24 · 보상액 운영값). 서버
|
||||||
// 퀘스트의 `rewardPop` 을, 없으면 `config/policy.stepsPop` 을 기본액으로 쓰고 판정 등급
|
// (`fn-quest-verify-music-shake`)가 퀘스트의 `rewardPop` 을, 없으면
|
||||||
// 배수를 곱한다. 화면은 기본액(C 등급)을 적는다 — 운영값이 둘 다 없을 때만 중립 문구.
|
// `config/policy.musicShake.pop` 을 기본액으로 쓴다. 운영값이 둘 다 없을 때만 중립 문구.
|
||||||
|
final shake = policy['musicShake'];
|
||||||
final raw = this == QuestType.attend
|
final raw = this == QuestType.attend
|
||||||
? policy['attendPop']
|
? policy['attendPop']
|
||||||
: this == QuestType.musicShake
|
: this == QuestType.musicShake
|
||||||
? quest['rewardPop'] ?? policy['stepsPop']
|
? quest['rewardPop'] ?? (shake is Map ? shake['pop'] : null)
|
||||||
: quest['rewardPop'];
|
: quest['rewardPop'];
|
||||||
return raw is num ? raw.round() : null;
|
return raw is num ? raw.round() : null;
|
||||||
}
|
}
|
||||||
@@ -130,8 +133,9 @@ enum QuestType {
|
|||||||
/// 내부 사유(질의 번호 등)는 화면에 내보내지 않는다 — 사용자에게는 한 문구다.
|
/// 내부 사유(질의 번호 등)는 화면에 내보내지 않는다 — 사용자에게는 한 문구다.
|
||||||
bool get verifiable => this != QuestType.unknown;
|
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 이다.
|
/// 참여를 열지 않는 사유. 열려 있으면 null 이다.
|
||||||
String? get blockedReason =>
|
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 {
|
class QuestDetail {
|
||||||
const QuestDetail({
|
const QuestDetail({
|
||||||
required this.card,
|
required this.card,
|
||||||
@@ -517,6 +545,16 @@ abstract interface class QuestRepository {
|
|||||||
/// 센서가 없으면 오류로 끊는다.
|
/// 센서가 없으면 오류로 끊는다.
|
||||||
Stream<int> progressStream(QuestCard card);
|
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
|
@override
|
||||||
Stream<int> progressStream(QuestCard card) => const Stream<int>.empty();
|
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
|
@override
|
||||||
Future<QuestReward> verify(QuestCard card) async {
|
Future<QuestReward> verify(QuestCard card) async {
|
||||||
_claimed.add(card.id);
|
_claimed.add(card.id);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import '../../core/ui/app_dialog.dart';
|
import '../../core/ui/app_dialog.dart';
|
||||||
@@ -13,6 +12,7 @@ import '../../core/theme/tokens.dart';
|
|||||||
import '../../core/ui/app_icons.dart';
|
import '../../core/ui/app_icons.dart';
|
||||||
import '../home/home_screen.dart' show formatPop;
|
import '../home/home_screen.dart' show formatPop;
|
||||||
import '../planet/moderation.dart';
|
import '../planet/moderation.dart';
|
||||||
|
import 'music_shake_play_screen.dart';
|
||||||
import 'quest_ad_screens.dart';
|
import 'quest_ad_screens.dart';
|
||||||
import 'quest_create_screen.dart' show QuestLinkCard;
|
import 'quest_create_screen.dart' show QuestLinkCard;
|
||||||
import 'quest_data.dart';
|
import 'quest_data.dart';
|
||||||
@@ -60,6 +60,9 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
|||||||
/// 서버 왕복 중. 두 번 눌러 두 번 지급되는 경로를 막는다.
|
/// 서버 왕복 중. 두 번 눌러 두 번 지급되는 경로를 막는다.
|
||||||
bool _busy = false;
|
bool _busy = false;
|
||||||
|
|
||||||
|
/// 방금 끝낸 Music Shake 판의 판정 합계 — 결과 화면 · 결과 카드에 쓴다.
|
||||||
|
Map<String, int>? _shakeResult;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -182,7 +185,8 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// 링크 원본(서버 조회값)과 작성자 설명을 나눠 그린다(C-05 · R10).
|
// 링크 원본(서버 조회값)과 작성자 설명을 나눠 그린다(C-05 · R10).
|
||||||
if (detail.link != null) ...[
|
// Music Shake 는 Figma `480:14506` 대로 링크 카드 대신 곡 제목 칩(아래)이다.
|
||||||
|
if (detail.link != null && card.type != QuestType.musicShake) ...[
|
||||||
QuestLinkCard(link: detail.link!),
|
QuestLinkCard(link: detail.link!),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
],
|
],
|
||||||
@@ -194,7 +198,13 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
|||||||
],
|
],
|
||||||
|
|
||||||
// 명세 `518:88435` — 설정 목표 수치 · 실행한 목표 수치 · 설정 보상 POP.
|
// 명세 `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,
|
label: context.l10n.questGoal,
|
||||||
value:
|
value:
|
||||||
'${formatPop(_live ?? card.progress)} / ${formatPop(card.goal)}',
|
'${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 보류 범위.
|
// 회신 대기 유형은 참여를 열지 않는다 — 해석 확인 요청서 8.1 보류 범위.
|
||||||
if (blocked != null)
|
if (blocked != null)
|
||||||
Padding(
|
Padding(
|
||||||
@@ -591,6 +629,8 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
|||||||
switch (stage) {
|
switch (stage) {
|
||||||
case QuestStage.notStarted when detail.card.type.reviewed:
|
case QuestStage.notStarted when detail.card.type.reviewed:
|
||||||
await _submitProof(detail);
|
await _submitProof(detail);
|
||||||
|
case QuestStage.notStarted when detail.card.type == QuestType.musicShake:
|
||||||
|
await _playShake(detail);
|
||||||
case QuestStage.notStarted:
|
case QuestStage.notStarted:
|
||||||
// **모을 진행도가 있는 유형만 「진행중」을 지난다.** 걸음은 목표까지
|
// **모을 진행도가 있는 유형만 「진행중」을 지난다.** 걸음은 목표까지
|
||||||
// 쌓아야 하지만 출석·방문은 그 자리에서 판정이 끝나므로 참여가 곧 완료다.
|
// 쌓아야 하지만 출석·방문은 그 자리에서 판정이 끝나므로 참여가 곧 완료다.
|
||||||
@@ -624,6 +664,34 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
|||||||
_watch(detail.card);
|
_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),
|
/// **여기서 지급하지 않는다.** 목표 도달 판정도 서버가 다시 하며(E-QST-01),
|
||||||
@@ -635,8 +703,6 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
|||||||
.listen(
|
.listen(
|
||||||
(value) {
|
(value) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
// Music Shake — 박마다 짧은 진동으로 흔들 때를 알린다.
|
|
||||||
if (card.type == QuestType.musicShake) HapticFeedback.selectionClick();
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_live = value;
|
_live = value;
|
||||||
if (card.goal > 0 && value >= card.goal) {
|
if (card.goal > 0 && value >= card.goal) {
|
||||||
@@ -699,7 +765,11 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
|||||||
reward = await widget.repository.verify(detail.card);
|
reward = await widget.repository.verify(detail.card);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!mounted) return;
|
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);
|
_fail(error);
|
||||||
// 타임어택 창 밖(E-QST-01) — 서버가 본 상태로 다시 읽어 마감 · 완료를 반영한다.
|
// 타임어택 창 밖(E-QST-01) — 서버가 본 상태로 다시 읽어 마감 · 완료를 반영한다.
|
||||||
if (error is BackendException &&
|
if (error is BackendException &&
|
||||||
@@ -763,6 +833,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
|||||||
detail: detail,
|
detail: detail,
|
||||||
reward: reward,
|
reward: reward,
|
||||||
adBonusPop: adPop,
|
adBonusPop: adPop,
|
||||||
|
shake: _shakeResult,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -104,51 +104,149 @@ Stream<DateTime> deviceShakes() {
|
|||||||
.map((s) => s.$2);
|
.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).
|
/// Music Shake 구간 판정표 — `fn-quest-verify-music-shake` 가 받는 **증거**(원장 B-24).
|
||||||
///
|
///
|
||||||
/// 곡의 박(`quests.bpm`)마다 한 구간이다. 구간 박 시각 ±반 박 안에 흔들기가 있으면 맞춘 것,
|
/// 시각은 **영상 재생 시각**(0 = 영상 처음)이다 — 공식 YouTube 영상을 보며 흔드는 퀘스트라
|
||||||
/// 박에서 [perfectRatio] 박 이내면 perfect · 아니면 good, 없으면 miss. **등급은 서버가 다시
|
/// (PRD-04) 박은 영상에 붙는다. 곡의 박(`quests.bpm`)마다 한 구간이고, 흔들기는 가장 가까운 박
|
||||||
/// 매긴다** — 이 표가 스스로 모순되면(합 ≠ 구간 수) 서버가 F 로 끊는다.
|
/// 하나에만 붙는다. 박과의 거리가 [ShakeWindows.perfect] 박 이내면 perfect, [ShakeWindows.good]
|
||||||
|
/// 박 이내면 good, 없으면 miss. **성공 판정은 서버가 다시 한다** — 표가 스스로 모순되면(합 ≠ 구간
|
||||||
|
/// 수) 서버가 끊는다.
|
||||||
class ShakeJudge {
|
class ShakeJudge {
|
||||||
ShakeJudge({required this.bpm, required this.segments, required this.start})
|
ShakeJudge({
|
||||||
: assert(bpm > 0 && segments > 0);
|
required this.bpm,
|
||||||
|
required this.segments,
|
||||||
|
this.windows = const ShakeWindows(),
|
||||||
|
}) : assert(bpm > 0 && segments > 0);
|
||||||
|
|
||||||
final double bpm;
|
final double bpm;
|
||||||
final int segments;
|
final int segments;
|
||||||
final DateTime start;
|
final ShakeWindows windows;
|
||||||
|
|
||||||
/// perfect 로 보는 박과의 거리(박 단위). 🟡 표시 판정용 — 등급 문턱은 서버가 가진다.
|
/// 박 번호 → 가장 가까운 흔들기의 거리(박 비율).
|
||||||
static const perfectRatio = 0.2;
|
final _best = <int, double>{};
|
||||||
|
|
||||||
final _shakes = <DateTime>[];
|
|
||||||
|
|
||||||
Duration get beat => Duration(microseconds: (60e6 / bpm).round());
|
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` 와 같은 키.
|
/// 판정 창이 닫힌 박만 센다 — [now] 는 영상 시각.
|
||||||
Map<String, int> judgement() {
|
ShakeLive live(Duration now) {
|
||||||
var perfect = 0, good = 0, miss = 0, combo = 0, maxCombo = 0;
|
var perfect = 0, good = 0, miss = 0, combo = 0, maxCombo = 0, judged = 0;
|
||||||
final beatUs = beat.inMicroseconds;
|
ShakeHit? last;
|
||||||
for (var i = 0; i < segments; i++) {
|
final pos = now.inMicroseconds / beat.inMicroseconds;
|
||||||
final at = start.microsecondsSinceEpoch + beatUs * i;
|
for (var i = 0; i < segments && i + windows.good <= pos; i++) {
|
||||||
final best = _shakes
|
judged++;
|
||||||
.map((s) => (s.microsecondsSinceEpoch - at).abs())
|
final d = _best[i];
|
||||||
.where((d) => d < beatUs / 2)
|
if (d == null) {
|
||||||
.fold<int?>(null, (m, d) => m == null || d < m ? d : m);
|
|
||||||
if (best == null) {
|
|
||||||
miss++;
|
miss++;
|
||||||
combo = 0;
|
combo = 0;
|
||||||
|
last = ShakeHit.miss;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
best <= beatUs * perfectRatio ? perfect++ : good++;
|
if (d <= windows.perfect) {
|
||||||
|
perfect++;
|
||||||
|
last = ShakeHit.perfect;
|
||||||
|
} else {
|
||||||
|
good++;
|
||||||
|
last = ShakeHit.good;
|
||||||
|
}
|
||||||
maxCombo = math.max(maxCombo, ++combo);
|
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`.
|
/// 걸음 센서 — `pedometer` 의 `stepCountStream`.
|
||||||
|
|||||||
@@ -44,9 +44,6 @@ class FirestoreQuestRepository implements QuestRepository {
|
|||||||
/// 마지막 Music Shake 한 판의 판정표. 다 끝난 판만 서버에 보낸다.
|
/// 마지막 Music Shake 한 판의 판정표. 다 끝난 판만 서버에 보낸다.
|
||||||
ShakeJudge? _shakeRun;
|
ShakeJudge? _shakeRun;
|
||||||
|
|
||||||
/// 곡 BPM 이 문서에 없을 때 — 🟡 운영 Quest 는 `quests.bpm` 을 채운다.
|
|
||||||
static const defaultBpm = 100.0;
|
|
||||||
|
|
||||||
/// 오늘 걸은 수를 내는 기준선. 하루 단위라 앱을 껐다 켜도 이어진다.
|
/// 오늘 걸은 수를 내는 기준선. 하루 단위라 앱을 껐다 켜도 이어진다.
|
||||||
final WalkBaseline _baseline;
|
final WalkBaseline _baseline;
|
||||||
|
|
||||||
@@ -166,9 +163,10 @@ class FirestoreQuestRepository implements QuestRepository {
|
|||||||
final steps = type == QuestType.steps;
|
final steps = type == QuestType.steps;
|
||||||
final goal = steps
|
final goal = steps
|
||||||
? tiers[count.clamp(0, tiers.length - 1)]
|
? 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
|
: type == QuestType.musicShake
|
||||||
? quest.intOr('totalSegments', dailyLimit)
|
? quest.intOr('bpm')
|
||||||
: dailyLimit;
|
: dailyLimit;
|
||||||
|
|
||||||
return QuestCard(
|
return QuestCard(
|
||||||
@@ -447,7 +445,6 @@ class FirestoreQuestRepository implements QuestRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<int> progressStream(QuestCard card) {
|
Stream<int> progressStream(QuestCard card) {
|
||||||
if (card.type == QuestType.musicShake) return _shakeProgress(card);
|
|
||||||
if (card.type != QuestType.steps) return const Stream<int>.empty();
|
if (card.type != QuestType.steps) return const Stream<int>.empty();
|
||||||
// 센서는 부팅 이후 누적을 준다. 하루 단위 기준선을 빼야 정책 원장 **B-26** 의
|
// 센서는 부팅 이후 누적을 준다. 하루 단위 기준선을 빼야 정책 원장 **B-26** 의
|
||||||
// 구간(1 천·5 천·1 만·1.5 만보)과 같은 것을 세게 된다.
|
// 구간(1 천·5 천·1 만·1.5 만보)과 같은 것을 세게 된다.
|
||||||
@@ -519,49 +516,53 @@ class FirestoreQuestRepository implements QuestRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 곡 박마다 한 구간 — 시작부터 박 수를 세어 내고, 마지막 구간 판정 창이 닫히면 구간 수를 낸다.
|
@override
|
||||||
/// 흔들기는 그동안만 모은다.
|
Future<MusicShakeSetup> musicShakeSetup(String questId) async {
|
||||||
Stream<int> _shakeProgress(QuestCard card) async* {
|
final quest = (await _backend.db.doc('quests/$questId').get()).data() ??
|
||||||
_shakeRun = null;
|
|
||||||
final quest = (await _backend.db.doc('quests/${card.id}').get()).data() ??
|
|
||||||
const <String, dynamic>{};
|
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 segments = quest.intOr('totalSegments');
|
||||||
final bpm = quest['bpm'] is num && (quest['bpm'] as num) > 0
|
// 영상 · BPM · 구간 수 중 하나라도 없으면 판을 열지 않는다 — 기본값을 지어내지 않는다.
|
||||||
? (quest['bpm'] as num).toDouble()
|
if (videoId == null || bpm is! num || bpm <= 0 || segments <= 0) {
|
||||||
: defaultBpm;
|
|
||||||
if (segments <= 0) {
|
|
||||||
throw BackendException('E-QST-01', appL10n.questTypeUnverifiable);
|
throw BackendException('E-QST-01', appL10n.questTypeUnverifiable);
|
||||||
}
|
}
|
||||||
final run = ShakeJudge(bpm: bpm, segments: segments, start: DateTime.now());
|
return MusicShakeSetup(
|
||||||
final sub = _shakes().listen(run.add, onError: (_) {});
|
videoId: videoId,
|
||||||
try {
|
title: link.strOr('title', _title(quest)),
|
||||||
for (var i = 1; i < segments; i++) {
|
bpm: bpm.toDouble(),
|
||||||
await Future<void>.delayed(run.beat);
|
segments: segments,
|
||||||
yield i;
|
windows: ShakeWindows.from(await _policy()),
|
||||||
}
|
);
|
||||||
await Future<void>.delayed(run.end.difference(DateTime.now()));
|
|
||||||
_shakeRun = run;
|
|
||||||
yield segments;
|
|
||||||
} finally {
|
|
||||||
await sub.cancel();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<DateTime> shakeEvents() => _shakes();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void recordShake(ShakeJudge run) => _shakeRun = run;
|
||||||
|
|
||||||
Future<Map<String, dynamic>> _verifyShake(QuestCard card) async {
|
Future<Map<String, dynamic>> _verifyShake(QuestCard card) async {
|
||||||
final run = _shakeRun;
|
final run = _shakeRun;
|
||||||
if (run == null) throw BackendException('E-QST-01', appL10n.questNoShake);
|
if (run == null) throw BackendException('E-QST-01', appL10n.questNoShake);
|
||||||
final quest = (await _backend.db.doc('quests/${card.id}').get()).data() ??
|
final quest = (await _backend.db.doc('quests/${card.id}').get()).data() ??
|
||||||
const <String, dynamic>{};
|
const <String, dynamic>{};
|
||||||
final ids = quest['trackIds'];
|
final ids = quest['trackIds'];
|
||||||
// 곡 ID 는 서버 대조용이다 — 화면에 띄우지 않는다.
|
final link = quest['link'] is Map ? Map<String, dynamic>.from(quest['link'] as Map) : null;
|
||||||
final trackId = ids is List && ids.isNotEmpty ? '${ids.first}' : quest.strOr('trackId');
|
// 곡 ID 는 서버 대조용이다 — 화면에 띄우지 않는다. 카탈로그가 없으면 영상 ID 다.
|
||||||
|
final trackId = ids is List && ids.isNotEmpty
|
||||||
|
? '${ids.first}'
|
||||||
|
: quest.strOr('trackId', youtubeVideoId(link?.strOr('url') ?? '') ?? '');
|
||||||
_shakeRun = null;
|
_shakeRun = null;
|
||||||
return _backend.call('fnQuestVerifyMusicShake', {
|
return _backend.call('fnQuestVerifyMusicShake', {
|
||||||
'questId': card.id,
|
'questId': card.id,
|
||||||
'trackId': trackId,
|
'trackId': trackId,
|
||||||
'totalSegments': run.segments,
|
'totalSegments': run.segments,
|
||||||
'judgement': run.judgement(),
|
'judgement': run.judgement(),
|
||||||
'occurredAt': run.end.toUtc().toIso8601String(),
|
'occurredAt': DateTime.now().toUtc().toIso8601String(),
|
||||||
...await _fandomField(),
|
...await _fandomField(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
import '../../core/theme/tokens.dart';
|
import '../../core/theme/tokens.dart';
|
||||||
|
import '../campaign/campaign_share.dart' show shareHashtags;
|
||||||
import '../home/home_screen.dart' show formatPop;
|
import '../home/home_screen.dart' show formatPop;
|
||||||
|
import 'music_shake_play_screen.dart' show ShakeTally;
|
||||||
import 'quest_data.dart';
|
import 'quest_data.dart';
|
||||||
import '../../core/i18n/app_locale.dart';
|
import '../../core/i18n/app_locale.dart';
|
||||||
|
|
||||||
@@ -15,14 +21,25 @@ import '../../core/i18n/app_locale.dart';
|
|||||||
///
|
///
|
||||||
/// **POP 을 여기서 더하지 않는다.** 잔액과 원장은 서버가 쓰며(`fn-quest-attend`
|
/// **POP 을 여기서 더하지 않는다.** 잔액과 원장은 서버가 쓰며(`fn-quest-attend`
|
||||||
/// 등), 이 화면은 결과를 보여 주고 상태 전이를 호출부에 알릴 뿐이다.
|
/// 등), 이 화면은 결과를 보여 주고 상태 전이를 호출부에 알릴 뿐이다.
|
||||||
class QuestRewardScreen extends StatelessWidget {
|
///
|
||||||
|
/// Music Shake 는 판정 합계(Perfect · Good · Miss · 최대 콤보)를 함께 띄우고, 그 카드를
|
||||||
|
/// 이미지로 SNS 공유한다 — PRD-04 「퀘스트 결과 카드와 SNS 공유」.
|
||||||
|
class QuestRewardScreen extends StatefulWidget {
|
||||||
const QuestRewardScreen({
|
const QuestRewardScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.detail,
|
required this.detail,
|
||||||
this.reward,
|
this.reward,
|
||||||
this.adBonusPop,
|
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;
|
final QuestDetail detail;
|
||||||
|
|
||||||
/// 서버가 지급한 결과. 있으면 **이 값만 띄운다** — 설정 보상 POP 과 실제 지급액이
|
/// 서버가 지급한 결과. 있으면 **이 값만 띄운다** — 설정 보상 POP 과 실제 지급액이
|
||||||
@@ -32,10 +49,64 @@ class QuestRewardScreen extends StatelessWidget {
|
|||||||
/// 광고 추가 보상으로 서버가 지급한 기여 POP. 광고를 안 봤거나 청구가 실패했으면 null.
|
/// 광고 추가 보상으로 서버가 지급한 기여 POP. 광고를 안 봤거나 청구가 실패했으면 null.
|
||||||
final int? adBonusPop;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final shake = widget.shake;
|
||||||
final t = Theme.of(context).textTheme;
|
final t = Theme.of(context).textTheme;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
@@ -47,6 +118,10 @@ class QuestRewardScreen extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Center(
|
child: Center(
|
||||||
|
child: RepaintBoundary(
|
||||||
|
key: _card,
|
||||||
|
child: ColoredBox(
|
||||||
|
color: AppColors.cosmicVoid,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@@ -88,7 +163,35 @@ class QuestRewardScreen extends StatelessWidget {
|
|||||||
textAlign: TextAlign.center,
|
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(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
|
// Music Shake 는 Figma `480:14262` 「목표BPM 350」 — 곡 BPM 한 값이다.
|
||||||
child: _Stat(
|
child: _Stat(
|
||||||
label: context.l10n.questGoal,
|
label: card.type == QuestType.musicShake
|
||||||
value: card.goal > 0
|
? context.l10n.questGoalBpm
|
||||||
? '${formatPop(card.progress)} / ${formatPop(card.goal)}'
|
: context.l10n.questGoal,
|
||||||
: '-',
|
value: card.goal <= 0
|
||||||
|
? '-'
|
||||||
|
: card.type == QuestType.musicShake
|
||||||
|
? formatPop(card.goal)
|
||||||
|
: '${formatPop(card.progress)} / ${formatPop(card.goal)}',
|
||||||
muted: card.goal <= 0,
|
muted: card.goal <= 0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -806,5 +806,11 @@
|
|||||||
"eclipseMinSec": "{minutes}m {seconds}s",
|
"eclipseMinSec": "{minutes}m {seconds}s",
|
||||||
"eclipseOverlayTitle": "Meteor impact\nis starting!",
|
"eclipseOverlayTitle": "Meteor impact\nis starting!",
|
||||||
"eclipseLiveOccurred": "An eclipse has hit planet {name}!",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -806,5 +806,11 @@
|
|||||||
"eclipseMinSec": "{minutes} min {seconds} s",
|
"eclipseMinSec": "{minutes} min {seconds} s",
|
||||||
"eclipseOverlayTitle": "¡El impacto de meteoritos\nva a comenzar!",
|
"eclipseOverlayTitle": "¡El impacto de meteoritos\nva a comenzar!",
|
||||||
"eclipseLiveOccurred": "¡Un eclipse ha llegado al planeta {name}!",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -806,5 +806,11 @@
|
|||||||
"eclipseMinSec": "{minutes}分{seconds}秒",
|
"eclipseMinSec": "{minutes}分{seconds}秒",
|
||||||
"eclipseOverlayTitle": "惑星への隕石衝突が\n始まります!",
|
"eclipseOverlayTitle": "惑星への隕石衝突が\n始まります!",
|
||||||
"eclipseLiveOccurred": "{name}惑星にイクリプスが発生しました!",
|
"eclipseLiveOccurred": "{name}惑星にイクリプスが発生しました!",
|
||||||
"eclipseMyShakes": "自分の有効Shake {count} / {total}回"
|
"eclipseMyShakes": "自分の有効Shake {count} / {total}回",
|
||||||
|
"questGoalBpm": "目標BPM",
|
||||||
|
"shakeCombo": "{n} COMBO",
|
||||||
|
"shakeJudged": "判定 {n} / {total}",
|
||||||
|
"shakePlayHint": "動画を再生してビートに合わせてスマホを振ってください",
|
||||||
|
"shakeVideoError": "この動画を再生できません",
|
||||||
|
"shakeShareResult": "結果をシェア"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1708,5 +1708,28 @@
|
|||||||
"type": "String"
|
"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": "결과 공유"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4957,6 +4957,42 @@ abstract class AppLocalizations {
|
|||||||
/// In ko, this message translates to:
|
/// In ko, this message translates to:
|
||||||
/// **'내 유효 Shake {count} / {total}회'**
|
/// **'내 유효 Shake {count} / {total}회'**
|
||||||
String eclipseMyShakes(String count, String 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
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -2821,4 +2821,26 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String eclipseMyShakes(String count, String total) {
|
String eclipseMyShakes(String count, String total) {
|
||||||
return 'My valid shakes $count / $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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2833,4 +2833,26 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String eclipseMyShakes(String count, String total) {
|
String eclipseMyShakes(String count, String total) {
|
||||||
return 'Mis sacudidas válidas $count / $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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2728,4 +2728,26 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String eclipseMyShakes(String count, String total) {
|
String eclipseMyShakes(String count, String total) {
|
||||||
return '自分の有効Shake $count / $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 => '結果をシェア';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2730,4 +2730,26 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String eclipseMyShakes(String count, String total) {
|
String eclipseMyShakes(String count, String total) {
|
||||||
return '내 유효 Shake $count / $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 => '결과 공유';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2725,4 +2725,26 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
String eclipseMyShakes(String count, String total) {
|
String eclipseMyShakes(String count, String total) {
|
||||||
return '我的有效摇动 $count / $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 => '分享结果';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -806,5 +806,11 @@
|
|||||||
"eclipseMinSec": "{minutes}分{seconds}秒",
|
"eclipseMinSec": "{minutes}分{seconds}秒",
|
||||||
"eclipseOverlayTitle": "陨石即将\n撞击行星!",
|
"eclipseOverlayTitle": "陨石即将\n撞击行星!",
|
||||||
"eclipseLiveOccurred": "{name}行星发生了日食!",
|
"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
|
@override
|
||||||
String get questGoal => _remote['questGoal'] ?? _base.questGoal;
|
String get questGoal => _remote['questGoal'] ?? _base.questGoal;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get questGoalBpm => _remote['questGoalBpm'] ?? _base.questGoalBpm;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get questGoalValue => _remote['questGoalValue'] ?? _base.questGoalValue;
|
String get questGoalValue => _remote['questGoalValue'] ?? _base.questGoalValue;
|
||||||
|
|
||||||
@@ -2191,6 +2194,21 @@ class RemoteAppLocalizations extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settingsWithdrawLater => _remote['settingsWithdrawLater'] ?? _base.settingsWithdrawLater;
|
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
|
@override
|
||||||
String get shareCopied => _remote['shareCopied'] ?? _base.shareCopied;
|
String get shareCopied => _remote['shareCopied'] ?? _base.shareCopied;
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1450,7 +1450,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "2.15.1"
|
version: "2.15.1"
|
||||||
webview_flutter_wkwebview:
|
webview_flutter_wkwebview:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: webview_flutter_wkwebview
|
name: webview_flutter_wkwebview
|
||||||
sha256: fe359c7fac1002124b5b9e2ba3a41906bbb9b2d029ccb4a0067404d8f3704730
|
sha256: fe359c7fac1002124b5b9e2ba3a41906bbb9b2d029ccb4a0067404d8f3704730
|
||||||
|
|||||||
@@ -107,6 +107,10 @@ dependencies:
|
|||||||
# 2026-10-05 대표 지시 「html 을 클론코딩하듯」). flutter.dev 팀 관리.
|
# 2026-10-05 대표 지시 「html 을 클론코딩하듯」). flutter.dev 팀 관리.
|
||||||
# 최소 OS 하한 영향 없음: webview_flutter_android minSdk 24(앱 29) · wkwebview iOS 13.0(앱 15.0).
|
# 최소 OS 하한 영향 없음: webview_flutter_android minSdk 24(앱 29) · wkwebview iOS 13.0(앱 15.0).
|
||||||
webview_flutter: ^4.14.1
|
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. 카카오톡이 있으면 앱으로, 없으면 카카오계정 웹으로 로그인해
|
# 카카오 로그인 — Kakao 공식 SDK. 카카오톡이 있으면 앱으로, 없으면 카카오계정 웹으로 로그인해
|
||||||
# 액세스 토큰을 받고 서버(fnAuthKakao)가 Firebase 커스텀 토큰으로 바꿔 준다. Android 에서
|
# 액세스 토큰을 받고 서버(fnAuthKakao)가 Firebase 커스텀 토큰으로 바꿔 준다. Android 에서
|
||||||
# Firebase OIDC 웹 흐름이 카카오톡 앱 전환 뒤 「missing initial state」로 깨져서 옮겼다.
|
# Firebase OIDC 웹 흐름이 카카오톡 앱 전환 뒤 「missing initial state」로 깨져서 옮겼다.
|
||||||
|
|||||||
@@ -213,12 +213,12 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('Music Shake 보상 표시 (원장 B-24)', () {
|
group('Music Shake 보상 표시 (원장 B-24)', () {
|
||||||
test('퀘스트 rewardPop 이 없으면 서버와 같은 운영값 stepsPop 을 기본액으로 적는다', () {
|
test('퀘스트 rewardPop 이 없으면 서버와 같은 운영값 musicShake.pop 을 기본액으로 적는다', () {
|
||||||
expect(QuestType.musicShake.rewardFrom(const {}, const {'stepsPop': 50}), 50);
|
const policy = {'stepsPop': 50, 'musicShake': {'pop': 1}};
|
||||||
expect(
|
// 걸음 보상(stepsPop)을 빌려 쓰지 않는다.
|
||||||
QuestType.musicShake.rewardFrom(const {'rewardPop': 30}, const {'stepsPop': 50}),
|
expect(QuestType.musicShake.rewardFrom(const {}, policy), 1);
|
||||||
30,
|
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);
|
expect(QuestType.musicShake.rewardFrom(const {}, const {}), isNull);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
|
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/core/data/backend.dart';
|
||||||
import 'package:fancake/features/quest/quest_data.dart';
|
import 'package:fancake/features/quest/quest_data.dart';
|
||||||
import 'package:fancake/features/quest/quest_evidence.dart';
|
import 'package:fancake/features/quest/quest_evidence.dart';
|
||||||
@@ -335,52 +336,51 @@ service cloud.firestore {
|
|||||||
expect(reward.rewardedPop, 0);
|
expect(reward.rewardedPop, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Music Shake — 박마다 구간을 세고 판정표 · 곡 ID 를 서버에 보낸다', () async {
|
test('Music Shake — 영상 · BPM · 구간 수를 퀘스트에서 읽고 판정표 · 곡 ID 를 서버에 보낸다', () async {
|
||||||
final db = await _seed();
|
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({
|
await db.doc('quests/shake').set({
|
||||||
'type': 'musicshake', 'title': '응원 흔들기', 'dailyLimit': 1,
|
'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 backend = _RecordingBackend(db);
|
||||||
final repo = FirestoreQuestRepository(
|
final repo = FirestoreQuestRepository(backend);
|
||||||
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 card = _card(await repo.load(), 'shake');
|
final card = _card(await repo.load(), 'shake');
|
||||||
expect(card.type.verifiable, isTrue);
|
expect(card.type.verifiable, isTrue);
|
||||||
expect(card.goal, 3);
|
// Figma 「목표BPM」 — 목표 줄은 곡 BPM 이다. 보상은 운영값 musicShake.pop.
|
||||||
expect(card.progress, 0);
|
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>()));
|
await expectLater(repo.verify(card), throwsA(isA<BackendException>()));
|
||||||
expect(backend.calls, isEmpty);
|
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);
|
await repo.verify(card);
|
||||||
final call = backend.calls.single;
|
final call = backend.calls.single;
|
||||||
expect(call.name, 'fnQuestVerifyMusicShake');
|
expect(call.name, 'fnQuestVerifyMusicShake');
|
||||||
expect(call.payload['questId'], 'shake');
|
expect(call.payload['questId'], 'shake');
|
||||||
expect(call.payload['trackId'], 'track-a');
|
expect(call.payload['trackId'], 'WMweEpGlu_U');
|
||||||
expect(call.payload['totalSegments'], 3);
|
expect(call.payload['totalSegments'], 3);
|
||||||
final j = call.payload['judgement'] as Map;
|
expect(call.payload['judgement'], {'perfect': 1, 'good': 0, 'miss': 2, 'maxCombo': 1});
|
||||||
expect(j['perfect']! + j['good']! + j['miss']!, 3);
|
|
||||||
expect(j['miss'], 1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ShakeJudge — 박과의 거리로 perfect · good · miss 를 가르고 최장 연속을 센다', () {
|
test('Music Shake — 영상 · BPM · 구간 수가 없으면 판을 열지 않는다(값을 지어내지 않는다)', () async {
|
||||||
final start = DateTime(2026, 10, 5);
|
final db = await _seed();
|
||||||
final judge = ShakeJudge(bpm: 60, segments: 4, start: start)
|
await db.doc('quests/shake').set({'type': 'musicshake', 'title': '흔들기', 'totalSegments': 3});
|
||||||
..add(start.add(const Duration(milliseconds: 100))) // 박 0 · perfect
|
final repo = FirestoreQuestRepository(_RecordingBackend(db));
|
||||||
..add(start.add(const Duration(milliseconds: 1400))) // 박 1 · good
|
await expectLater(repo.musicShakeSetup('shake'), throwsA(isA<BackendException>()));
|
||||||
..add(start.add(const Duration(milliseconds: 3000))); // 박 3 · perfect, 박 2 miss
|
expect(_card(await repo.load(), 'shake').goal, 0);
|
||||||
expect(judge.judgement(), {'perfect': 2, 'good': 1, 'miss': 1, 'maxCombo': 2});
|
|
||||||
expect(judge.end, start.add(const Duration(milliseconds: 3500)));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('생성은 fn-quest-create 로 — 종료일은 날짜 + 그 시각의 UTC 오프셋(C-06)', () async {
|
test('생성은 fn-quest-create 로 — 종료일은 날짜 + 그 시각의 UTC 오프셋(C-06)', () async {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user