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

This commit is contained in:
Sn-Kinos
2026-10-05 21:39:22 +09:00
parent 65669b969e
commit 17c658b272
30 changed files with 1337 additions and 144 deletions
@@ -4,6 +4,7 @@ import 'dart:math' as math;
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../core/data/backend.dart';
import '../../core/theme/tokens.dart';
@@ -56,6 +57,11 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
/// 서버가 받아들인 내 누적 참여량.
int _mine = 0;
/// 화면에 보이는 내 횟수 — 서버 확정값에 아직 보내지 않았거나 보내는 중인 흔들기를 더한다.
/// 흔드는 동안 서버 왕복(약 0.7초 묶음)을 기다리면 숫자가 늦게 올라 「얼마나 잘 흔들고
/// 있는지」 알 수 없었다. 확정값이 오면 그것으로 맞춰진다.
int get _shown => _mine + _pending + (_sending ? (_batch?.shakes ?? 0) : 0);
/// 상한에 걸려 깎인 적이 있는가. 한 번 알리고 다시 알리지 않는다.
bool _cappedNotified = false;
@@ -105,7 +111,15 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
_current?.canShake(DateTime.now()) != true ||
(_batch != null && !_sending))
return;
final qualify = _current?.qualifyShakes ?? 0;
final before = _shown;
setState(() => _pending += 1);
// 화면을 보지 않고 흔들어도 알 수 있게 — 한 번 셀 때마다 짧게, 인정 기준을 넘는 순간 길게.
if (qualify > 0 && before < qualify && _shown >= qualify) {
HapticFeedback.heavyImpact();
} else {
HapticFeedback.selectionClick();
}
_scheduleFlush();
}
@@ -256,7 +270,7 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
title: context.l10n.eclipseSuccessTitle,
success: true,
extra: [
if (_mine >= state.qualifyShakes) _Note(context.l10n.eclipseQualified),
if (_shown >= state.qualifyShakes) _Note(context.l10n.eclipseQualified),
if (_batch != null) _retryButton(),
],
);
@@ -283,7 +297,7 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
confirm: result != null,
extra: [
_Note(message),
if (_mine >= state.qualifyShakes) _Note(context.l10n.eclipseQualified),
if (_shown >= state.qualifyShakes) _Note(context.l10n.eclipseQualified),
if (_batch != null) _retryButton(),
],
);
@@ -424,16 +438,16 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
_Meter(
key: const ValueKey('eclipse-my-shakes'),
label:
'${context.l10n.eclipseMyShakes(formatPop(_mine), '${state.qualifyShakes}')}'
'${context.l10n.eclipseMyShakes(formatPop(_shown), '${state.qualifyShakes}')}'
'${_pending > 0
? context.l10n.eclipsePendingSuffix('$_pending')
: _sending
? context.l10n.eclipseSendingSuffix
: ''}'
'${_mine >= state.qualifyShakes ? ' · ${context.l10n.eclipseQualified}' : ''}',
'${_shown >= state.qualifyShakes ? ' · ${context.l10n.eclipseQualified}' : ''}',
value: state.qualifyShakes <= 0
? 0
: (_mine / state.qualifyShakes).clamp(0.0, 1.0),
: (_shown / state.qualifyShakes).clamp(0.0, 1.0),
color: AppColors.cosmicAccent,
),
const SizedBox(height: AppSpace.s3),
@@ -0,0 +1,348 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
import 'quest_data.dart';
import 'quest_evidence.dart';
/// 플레이어 상태 한 번 — 재생 중인가 · 영상 시각 · 끝났는가.
typedef ShakePlayerEvent = ({bool playing, Duration position, bool ended});
/// Music Shake 진행 — PRD-04 「뮤직비디오를 보면서 스마트폰을 흔드는 미션」.
///
/// 공식 YouTube 영상(퀘스트의 지정 링크 · PRD-04 MVP 수용 기준)을 IFrame 플레이어로 재생하고,
/// 재생되는 동안의 흔들기를 **영상 시각**으로 찍어 박마다 Perfect · Good · Miss · Combo 를
/// 띄운다. 판정 창은 운영값(🟡 임시값 — 회신 대기). 마지막 박의 창이 닫히거나 영상이 끝나면
/// 판정표를 돌려준다. 중간에 나가면 null — 판정표를 남기지 않는다.
///
/// Figma 에 이 진행 화면은 없다(상세 `480:14506` 은 「진행중」 버튼까지). 공통 기준 5항 —
/// 기존 범위 기능의 정상 구현에 필요한 화면이다. 상세의 시각 문법(#141414 · #1a1a1c r24)을 따른다.
class MusicShakePlayScreen extends StatefulWidget {
const MusicShakePlayScreen({
super.key,
required this.setup,
required this.shakes,
this.player,
});
final MusicShakeSetup setup;
final Stream<DateTime> shakes;
/// 영상 플레이어. 기본은 [YoutubeEmbed]. 테스트는 상태를 직접 넣는 가짜를 준다.
final Widget Function(ValueChanged<ShakePlayerEvent> onEvent)? player;
@override
State<MusicShakePlayScreen> createState() => _MusicShakePlayScreenState();
}
class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> {
late final ShakeJudge _judge = ShakeJudge(
bpm: widget.setup.bpm,
segments: widget.setup.segments,
windows: widget.setup.windows,
);
StreamSubscription<DateTime>? _sub;
Timer? _tick;
/// 영상 시계 — 마지막으로 받은 플레이어 시각과 그때의 단말 시각.
bool _playing = false;
Duration _anchorMedia = Duration.zero;
DateTime _anchorWall = DateTime.now();
int _lastJudged = 0;
bool _done = false;
@override
void initState() {
super.initState();
_sub = widget.shakes.listen((at) {
final media = _mediaAt(at);
if (media != null) _judge.add(media);
}, onError: (_) {});
_tick = Timer.periodic(const Duration(milliseconds: 50), (_) => _onTick());
}
@override
void dispose() {
_sub?.cancel();
_tick?.cancel();
super.dispose();
}
/// 단말 시각 [wall] 의 영상 시각. 재생 중이 아니면 null — 멈춘 동안의 흔들기는 세지 않는다.
Duration? _mediaAt(DateTime wall) =>
_playing ? _anchorMedia + wall.difference(_anchorWall) : null;
void _onEvent(ShakePlayerEvent e) {
if (_playing != e.playing && mounted) setState(() {});
_playing = e.playing;
_anchorMedia = e.position;
_anchorWall = DateTime.now();
if (e.ended) _finish();
}
void _onTick() {
final now = _mediaAt(DateTime.now());
if (now == null || _done) return;
final judged = _judge.live(now).judged;
if (judged != _lastJudged) {
_lastJudged = judged;
setState(() {});
}
if (now >= _judge.end) _finish();
}
void _finish() {
if (_done || !mounted) return;
_done = true;
// 완료 햅틱 Medium — UX·FX 정렬 기준 v1.5 「Music/Location Quest 완료」.
HapticFeedback.mediumImpact();
Navigator.of(context).pop(_judge);
}
@override
Widget build(BuildContext context) {
final now = _mediaAt(DateTime.now()) ?? _anchorMedia;
final v = _judge.live(now);
return Scaffold(
backgroundColor: AppColors.cosmicVoid,
appBar: AppBar(
title: Text(
widget.setup.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
body: ListView(
children: [
// 플레이어 위에는 아무것도 겹치지 않는다(YouTube RMF — 오버레이 금지 · 최소 200×200).
AspectRatio(
aspectRatio: 16 / 9,
child: (widget.player ?? _defaultPlayer)(_onEvent),
),
const SizedBox(height: 32),
Text(
switch (v.last) {
ShakeHit.perfect => 'PERFECT',
ShakeHit.good => 'GOOD',
ShakeHit.miss => 'MISS',
null => ' ',
},
key: const ValueKey('shake-last'),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 34,
fontWeight: FontWeight.w700,
height: 1.3,
color: switch (v.last) {
ShakeHit.perfect => AppColors.cosmicAccent,
ShakeHit.good => const Color(0xFF67D3A9),
_ => const Color(0xFF777777),
},
),
),
Text(
context.l10n.shakeCombo('${v.combo}'),
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
height: 1.4,
color: AppColors.onCosmic,
),
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ShakeTally(
perfect: v.perfect,
good: v.good,
miss: v.miss,
maxCombo: v.maxCombo,
),
),
const SizedBox(height: 16),
Text(
_playing
? context.l10n.shakeJudged(
'${v.judged}',
'${widget.setup.segments}',
)
: context.l10n.shakePlayHint,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
height: 20 / 14,
color: AppColors.onCosmicVariant,
),
),
],
),
);
}
Widget _defaultPlayer(ValueChanged<ShakePlayerEvent> onEvent) =>
YoutubeEmbed(videoId: widget.setup.videoId, onEvent: onEvent);
}
/// Perfect · Good · Miss · 최대 콤보 네 칸 — 진행 화면과 결과 카드가 같이 쓴다.
class ShakeTally extends StatelessWidget {
const ShakeTally({
super.key,
required this.perfect,
required this.good,
required this.miss,
required this.maxCombo,
});
final int perfect;
final int good;
final int miss;
final int maxCombo;
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(24),
),
child: Row(
children: [
for (final (label, n) in [
('PERFECT', perfect),
('GOOD', good),
('MISS', miss),
('MAX COMBO', maxCombo),
])
Expanded(
child: Column(
children: [
Text(
'$n',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
height: 1.4,
color: AppColors.onCosmic,
),
),
Text(
label,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
height: 16 / 11,
color: AppColors.onCosmicVariant,
),
),
],
),
),
],
),
);
}
/// 공식 YouTube IFrame 플레이어(IFrame Player API)를 WebView 로 띄운다.
///
/// - **영상을 저장하지 않는다**(PRD-04 주의 사항) — 스트리밍 재생만 한다.
/// - 플레이어를 고치거나 위에 겹치지 않는다. 컨트롤 그대로다(YouTube Required Minimum Functionality).
/// - 앱 식별: `baseUrl` 을 스토어 앱 ID 의 https URL 로 둔다(같은 문서 「Referer for mobile apps」).
/// - iOS 는 인라인 재생을 켜야 전체 화면으로 빠지지 않고 판정 화면이 보인다.
class YoutubeEmbed extends StatefulWidget {
const YoutubeEmbed({super.key, required this.videoId, required this.onEvent});
final String videoId;
final ValueChanged<ShakePlayerEvent> onEvent;
/// 스토어 앱 ID(Android applicationId · iOS bundle ID 가 같다).
static const appOrigin = 'https://kr.co.fanuzu.app';
/// WebView 를 쓸 수 있는가 — 위젯 테스트 · 데스크톱은 빈 칸.
static bool enabled =
!kIsWeb &&
(Platform.isAndroid || Platform.isIOS) &&
!Platform.environment.containsKey('FLUTTER_TEST');
@visibleForTesting
static String html(String videoId) =>
'''
<!DOCTYPE html><html><head><meta name="viewport" content="width=device-width,initial-scale=1">
<style>html,body{margin:0;height:100%;background:#000}#p{width:100%;height:100%}</style></head>
<body><div id="p"></div><script src="https://www.youtube.com/iframe_api"></script><script>
var player;
function post(s){Player.postMessage(JSON.stringify({s:s,t:player.getCurrentTime()}));}
function onYouTubeIframeAPIReady(){
player=new YT.Player('p',{videoId:${jsonEncode(videoId)},
playerVars:{playsinline:1,rel:0,origin:'$appOrigin'},
events:{onStateChange:function(e){post(e.data);},
onError:function(e){Player.postMessage(JSON.stringify({err:e.data}));}}});
setInterval(function(){if(player&&player.getPlayerState&&player.getPlayerState()==1)post(1);},500);
}
</script></body></html>''';
@override
State<YoutubeEmbed> createState() => _YoutubeEmbedState();
}
class _YoutubeEmbedState extends State<YoutubeEmbed> {
WebViewController? _web;
bool _error = false;
@override
void initState() {
super.initState();
if (!YoutubeEmbed.enabled) return;
final params = WebViewPlatform.instance is WebKitWebViewPlatform
? WebKitWebViewControllerCreationParams(allowsInlineMediaPlayback: true)
: const PlatformWebViewControllerCreationParams();
_web = WebViewController.fromPlatformCreationParams(params)
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.black)
..addJavaScriptChannel('Player', onMessageReceived: _onMessage)
..loadHtmlString(
YoutubeEmbed.html(widget.videoId),
baseUrl: YoutubeEmbed.appOrigin,
);
}
void _onMessage(JavaScriptMessage m) {
final msg = jsonDecode(m.message) as Map<String, dynamic>;
if (msg.containsKey('err')) {
if (mounted) setState(() => _error = true);
return;
}
final s = msg['s'];
final t = msg['t'];
widget.onEvent((
playing: s == 1,
position: Duration(microseconds: ((t is num ? t : 0) * 1e6).round()),
ended: s == 0,
));
}
@override
Widget build(BuildContext context) {
final web = _web;
if (web == null || _error) {
return ColoredBox(
color: Colors.black,
child: Center(
child: Text(
_error ? context.l10n.shakeVideoError : '',
style: const TextStyle(color: AppColors.onCosmicVariant),
),
),
);
}
return WebViewWidget(controller: web);
}
}
+55 -6
View File
@@ -6,9 +6,11 @@
/// - 퀘스트 생성 `523:18152`~`523:18188` · Admin 정의 `567:19531`
library;
import '../../core/data/backend.dart' show BackendException;
import '../../core/monetization/rewarded_ad.dart';
import '../../core/i18n/app_locale.dart';
import 'place_picker_screen.dart' show PlacePickerScreen;
import 'quest_evidence.dart' show ShakeJudge, ShakeWindows;
/// 진행 상태 — 명세가 **다섯 가지**를 못박았다.
///
@@ -112,13 +114,14 @@ enum QuestType {
int? rewardFrom(Map<String, dynamic> quest, Map<String, dynamic> policy) {
// 시청 · 스트리밍은 무보상 참여다(C-04) — 문서 값과 무관하게 0.
if (this == QuestType.streaming) return 0;
// Music Shake 는 BPM 판정 성공 시 일반 POP(원장 B-24). 서버(`fn-quest-verify-music-shake`)가
// 퀘스트의 `rewardPop` 을, 없으면 `config/policy.stepsPop` 을 기본액으로 쓰고 판정 등급
// 배수를 곱한다. 화면은 기본액(C 등급)을 적는다 — 운영값이 둘 다 없을 때만 중립 문구.
// Music Shake 는 BPM 판정 성공 시 일반 POP(원장 B-24 · 보상액 운영값). 서버
// (`fn-quest-verify-music-shake`)가 퀘스트의 `rewardPop` 을, 없으면
// `config/policy.musicShake.pop` 을 기본액으로 쓴다. 운영값이 둘 다 없을 때만 중립 문구.
final shake = policy['musicShake'];
final raw = this == QuestType.attend
? policy['attendPop']
: this == QuestType.musicShake
? quest['rewardPop'] ?? policy['stepsPop']
? quest['rewardPop'] ?? (shake is Map ? shake['pop'] : null)
: quest['rewardPop'];
return raw is num ? raw.round() : null;
}
@@ -130,8 +133,9 @@ enum QuestType {
/// 내부 사유(질의 번호 등)는 화면에 내보내지 않는다 — 사용자에게는 한 문구다.
bool get verifiable => this != QuestType.unknown;
/// 시작 뒤 진행도를 모아야 판정할 수 있는 유형 — 걸음(누적 걸음) · Music Shake(구간 판정).
bool get gathers => this == QuestType.steps || this == QuestType.musicShake;
/// 시작 뒤 진행도를 모아야 판정할 수 있는 유형 — 걸음(누적 걸음).
/// Music Shake 는 영상을 보며 흔드는 별도 진행 화면(`MusicShakePlayScreen`)에서 모은다.
bool get gathers => this == QuestType.steps;
/// 참여를 열지 않는 사유. 열려 있으면 null 이다.
String? get blockedReason =>
@@ -375,6 +379,30 @@ class QuestCard {
}
/// 상세 화면이 필요로 하는 것.
/// Music Shake 한 판의 준비값 — 퀘스트 문서와 운영값에서 읽는다(지어내지 않는다).
class MusicShakeSetup {
const MusicShakeSetup({
required this.videoId,
required this.title,
required this.bpm,
required this.segments,
this.windows = const ShakeWindows(),
});
/// 지정 공식 YouTube 영상(PRD-04 MVP 수용 기준) — `quests.link.url` 에서 꺼낸 ID.
final String videoId;
/// 곡 제목 — `quests.link.title`(링크 원본 제목 · C-05).
final String title;
/// `quests.bpm` — Figma 「목표BPM」. 🟡 의미 · 입력 주체 회신 대기(Q-MS-01 · Q-MS-02).
final double bpm;
/// `quests.totalSegments` — 판정 박 수. 🟡 구간 패턴 정의 회신 대기(Q-MS-02).
final int segments;
final ShakeWindows windows;
}
class QuestDetail {
const QuestDetail({
required this.card,
@@ -517,6 +545,16 @@ abstract interface class QuestRepository {
/// 센서가 없으면 오류로 끊는다.
Stream<int> progressStream(QuestCard card);
/// Music Shake 한 판의 준비값 — 공식 YouTube 영상 · BPM · 구간 수 · 판정 창(운영값).
/// 퀘스트에 값이 빠져 있으면 E-QST-01 이다(값을 지어내지 않는다).
Future<MusicShakeSetup> musicShakeSetup(String questId);
/// 단말 흔들기 시각 스트림(가속도계).
Stream<DateTime> shakeEvents();
/// 끝난 판의 판정표를 남긴다. 다음 [verify] 가 서버에 증거로 보낸다.
void recordShake(ShakeJudge run);
/// 활동 증거를 서버에 보내 판정·지급을 받는다.
///
/// **판정도 지급도 서버가 한다.** 앱은 증거(걸음 수·좌표)만 싣고, 실패는
@@ -742,6 +780,17 @@ class SampleQuestRepository implements QuestRepository {
@override
Stream<int> progressStream(QuestCard card) => const Stream<int>.empty();
/// 표본에는 영상 · BPM 이 없다 — 참여를 열지 않는다.
@override
Future<MusicShakeSetup> musicShakeSetup(String questId) async =>
throw BackendException('E-QST-01', appL10n.questTypeUnverifiable);
@override
Stream<DateTime> shakeEvents() => const Stream<DateTime>.empty();
@override
void recordShake(ShakeJudge run) {}
@override
Future<QuestReward> verify(QuestCard card) async {
_claimed.add(card.id);
+81 -10
View File
@@ -1,7 +1,6 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/ui/app_dialog.dart';
@@ -13,6 +12,7 @@ import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart';
import '../home/home_screen.dart' show formatPop;
import '../planet/moderation.dart';
import 'music_shake_play_screen.dart';
import 'quest_ad_screens.dart';
import 'quest_create_screen.dart' show QuestLinkCard;
import 'quest_data.dart';
@@ -60,6 +60,9 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
/// 서버 왕복 중. 두 번 눌러 두 번 지급되는 경로를 막는다.
bool _busy = false;
/// 방금 끝낸 Music Shake 판의 판정 합계 — 결과 화면 · 결과 카드에 쓴다.
Map<String, int>? _shakeResult;
@override
void initState() {
super.initState();
@@ -182,7 +185,8 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
const SizedBox(height: 24),
// 링크 원본(서버 조회값)과 작성자 설명을 나눠 그린다(C-05 · R10).
if (detail.link != null) ...[
// Music Shake 는 Figma `480:14506` 대로 링크 카드 대신 곡 제목 칩(아래)이다.
if (detail.link != null && card.type != QuestType.musicShake) ...[
QuestLinkCard(link: detail.link!),
const SizedBox(height: 12),
],
@@ -194,11 +198,17 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
],
// 명세 `518:88435` — 설정 목표 수치 · 실행한 목표 수치 · 설정 보상 POP.
_Row(
label: context.l10n.questGoal,
value:
'${formatPop(_live ?? card.progress)} / ${formatPop(card.goal)}',
),
// Music Shake 는 Figma `480:14466` · `480:14506` 「목표BPM 350」 — 곡 BPM 한 값이다.
card.type == QuestType.musicShake
? _Row(
label: context.l10n.questGoalBpm,
value: card.goal > 0 ? formatPop(card.goal) : '-',
)
: _Row(
label: context.l10n.questGoal,
value:
'${formatPop(_live ?? card.progress)} / ${formatPop(card.goal)}',
),
const SizedBox(height: 8),
_Row(
label: context.l10n.questReward,
@@ -265,6 +275,34 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
],
),
),
// Figma `480:14506` — 곡 제목 칩(♪ · 검정 30% · r9999). 제목은 링크 원본(C-05).
if (card.type == QuestType.musicShake &&
(detail.link?.title ?? '').isNotEmpty)
Align(
alignment: Alignment.centerLeft,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(9999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.music_note_rounded, size: 18, color: AppColors.onCosmic),
const SizedBox(width: 12),
Flexible(
child: Text(
detail.link!.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: _strong.copyWith(fontSize: 16, height: 24 / 16),
),
),
],
),
),
),
// 회신 대기 유형은 참여를 열지 않는다 — 해석 확인 요청서 8.1 보류 범위.
if (blocked != null)
Padding(
@@ -591,6 +629,8 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
switch (stage) {
case QuestStage.notStarted when detail.card.type.reviewed:
await _submitProof(detail);
case QuestStage.notStarted when detail.card.type == QuestType.musicShake:
await _playShake(detail);
case QuestStage.notStarted:
// **모을 진행도가 있는 유형만 「진행중」을 지난다.** 걸음은 목표까지
// 쌓아야 하지만 출석·방문은 그 자리에서 판정이 끝나므로 참여가 곧 완료다.
@@ -624,6 +664,34 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
_watch(detail.card);
}
/// Music Shake 한 판 — 공식 영상을 보며 흔드는 진행 화면을 띄운다(PRD-04).
/// 끝까지 했으면 판정표를 남기고 「보상받기」로, 중간에 나왔으면 시작 전으로 돌린다.
Future<void> _playShake(QuestDetail detail) async {
final MusicShakeSetup setup;
try {
setup = await widget.repository.musicShakeSetup(detail.card.id);
} catch (error) {
if (mounted) _fail(error);
return;
}
if (!mounted) return;
setState(() => _stage = QuestStage.inProgress);
final run = await Navigator.of(context, rootNavigator: true).push<ShakeJudge>(
MaterialPageRoute(
builder: (_) => MusicShakePlayScreen(
setup: setup,
shakes: widget.repository.shakeEvents(),
),
),
);
if (!mounted) return;
if (run != null) widget.repository.recordShake(run);
setState(() {
_shakeResult = run?.judgement();
_stage = run == null ? QuestStage.notStarted : QuestStage.completed;
});
}
/// 실시간 목표 실행률. 목표를 채우면 버튼이 「보상받기」로 바뀐다.
///
/// **여기서 지급하지 않는다.** 목표 도달 판정도 서버가 다시 하며(E-QST-01),
@@ -635,8 +703,6 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
.listen(
(value) {
if (!mounted) return;
// Music Shake — 박마다 짧은 진동으로 흔들 때를 알린다.
if (card.type == QuestType.musicShake) HapticFeedback.selectionClick();
setState(() {
_live = value;
if (card.goal > 0 && value >= card.goal) {
@@ -699,7 +765,11 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
reward = await widget.repository.verify(detail.card);
} catch (error) {
if (!mounted) return;
setState(() => _busy = false);
setState(() {
_busy = false;
// Music Shake 판정 미달(B-24 — 보상 0) — 판정표는 소진됐으니 다시 하게 시작 전으로.
if (detail.card.type == QuestType.musicShake) _stage = QuestStage.notStarted;
});
_fail(error);
// 타임어택 창 밖(E-QST-01) — 서버가 본 상태로 다시 읽어 마감 · 완료를 반영한다.
if (error is BackendException &&
@@ -763,6 +833,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
detail: detail,
reward: reward,
adBonusPop: adPop,
shake: _shakeResult,
),
),
);
+124 -26
View File
@@ -104,51 +104,149 @@ Stream<DateTime> deviceShakes() {
.map((s) => s.$2);
}
/// Music Shake 판정 한 박의 결과 — PRD-04 「Shake 판정: Perfect, Good, Miss, Combo」.
enum ShakeHit { perfect, good, miss }
/// Music Shake 판정 창 — `config/policy.musicShake`(운영값). 단위는 **박 길이 대비 비율**이다.
///
/// 🟡 **임시값 — 회신 대기.** PRD-04 · 상세정의 v1.6.1 §20 · 별첨 B 「Music Shake」 어디에도 판정 창
/// 수치가 없다(해석 확인 요청 2026-10-07 Music Shake **Q-MS-04**). 서버 `DEFAULT_POLICY.musicShake`
/// 와 같은 값이며, 운영 문서가 덮으면 그 값을 쓴다.
class ShakeWindows {
const ShakeWindows({this.perfect = 0.2, this.good = 0.5});
factory ShakeWindows.from(Map<String, dynamic> policy) {
final raw = policy['musicShake'];
final m = raw is Map ? raw : const {};
double read(String key, double fallback) {
final v = m[key];
return v is num && v > 0 ? v.toDouble() : fallback;
}
const d = ShakeWindows();
return ShakeWindows(
perfect: read('perfectWindow', d.perfect),
good: read('goodWindow', d.good),
);
}
final double perfect;
final double good;
}
/// 판정 중간값 — 화면이 그리는 수치.
typedef ShakeLive = ({
int perfect,
int good,
int miss,
int combo,
int maxCombo,
int judged,
ShakeHit? last,
});
/// Music Shake 구간 판정표 — `fn-quest-verify-music-shake` 가 받는 **증거**(원장 B-24).
///
/// 곡의 박(`quests.bpm`)마다 한 구간이다. 구간 박 시각 ±반 박 안에 흔들기가 있으면 맞춘 것,
/// 박에서 [perfectRatio] 박 이내면 perfect · 아니면 good, 없으면 miss. **등급은 서버가 다시
/// 매긴다** — 이 표가 스스로 모순되면(합 ≠ 구간 수) 서버가 F 로 끊는다.
/// 시각은 **영상 재생 시각**(0 = 영상 처음)이다 — 공식 YouTube 영상을 보며 흔드는 퀘스트라
/// (PRD-04) 박은 영상에 붙는다. 곡의 박(`quests.bpm`)마다 한 구간이고, 흔들기는 가장 가까운 박
/// 하나에만 붙는다. 박과의 거리가 [ShakeWindows.perfect] 박 이내면 perfect, [ShakeWindows.good]
/// 박 이내면 good, 없으면 miss. **성공 판정은 서버가 다시 한다** — 표가 스스로 모순되면(합 ≠ 구간
/// 수) 서버가 끊는다.
class ShakeJudge {
ShakeJudge({required this.bpm, required this.segments, required this.start})
: assert(bpm > 0 && segments > 0);
ShakeJudge({
required this.bpm,
required this.segments,
this.windows = const ShakeWindows(),
}) : assert(bpm > 0 && segments > 0);
final double bpm;
final int segments;
final DateTime start;
final ShakeWindows windows;
/// perfect 로 보는 박과의 거리(박 단위). 🟡 표시 판정용 — 등급 문턱은 서버가 가진다.
static const perfectRatio = 0.2;
final _shakes = <DateTime>[];
/// 박 번호 → 가장 가까운 흔들기의 거리(박 비율).
final _best = <int, double>{};
Duration get beat => Duration(microseconds: (60e6 / bpm).round());
/// 마지막 구간의 판정 창(박 + 반 박)까지 끝나는 시각.
DateTime get end => start.add(beat * segments - beat ~/ 2);
/// 마지막 박의 판정 창이 닫히는 영상 시각.
Duration get end => Duration(
microseconds: (beat.inMicroseconds * (segments - 1 + windows.good)).round(),
);
void add(DateTime at) => _shakes.add(at);
/// 영상 시각 [at] 의 흔들기 한 번.
void add(Duration at) {
final pos = at.inMicroseconds / beat.inMicroseconds;
final i = pos.round();
if (i < 0 || i >= segments) return;
final d = (pos - i).abs();
if (d > windows.good) return;
final prev = _best[i];
if (prev == null || d < prev) _best[i] = d;
}
/// 구간별 판정 합계 — 서버 `ShakeJudgement` 와 같은 키.
Map<String, int> judgement() {
var perfect = 0, good = 0, miss = 0, combo = 0, maxCombo = 0;
final beatUs = beat.inMicroseconds;
for (var i = 0; i < segments; i++) {
final at = start.microsecondsSinceEpoch + beatUs * i;
final best = _shakes
.map((s) => (s.microsecondsSinceEpoch - at).abs())
.where((d) => d < beatUs / 2)
.fold<int?>(null, (m, d) => m == null || d < m ? d : m);
if (best == null) {
/// 판정 창이 닫힌 박만 센다 — [now] 는 영상 시각.
ShakeLive live(Duration now) {
var perfect = 0, good = 0, miss = 0, combo = 0, maxCombo = 0, judged = 0;
ShakeHit? last;
final pos = now.inMicroseconds / beat.inMicroseconds;
for (var i = 0; i < segments && i + windows.good <= pos; i++) {
judged++;
final d = _best[i];
if (d == null) {
miss++;
combo = 0;
last = ShakeHit.miss;
continue;
}
best <= beatUs * perfectRatio ? perfect++ : good++;
if (d <= windows.perfect) {
perfect++;
last = ShakeHit.perfect;
} else {
good++;
last = ShakeHit.good;
}
maxCombo = math.max(maxCombo, ++combo);
}
return {'perfect': perfect, 'good': good, 'miss': miss, 'maxCombo': maxCombo};
return (
perfect: perfect,
good: good,
miss: miss,
combo: combo,
maxCombo: maxCombo,
judged: judged,
last: last,
);
}
/// 구간별 판정 합계 — 서버 `ShakeJudgement` 와 같은 키. 아직 오지 않은 박은 miss 다.
Map<String, int> judgement() {
final v = live(end);
return {
'perfect': v.perfect,
'good': v.good,
'miss': v.miss + segments - v.judged,
'maxCombo': v.maxCombo,
};
}
}
/// YouTube 링크에서 영상 ID 를 꺼낸다 — `watch?v=` · `youtu.be/` · `/shorts/` · `/embed/` ·
/// `music.youtube.com`. YouTube 링크가 아니면 null. 서버 `videoIdOf` 와 같은 규칙.
String? youtubeVideoId(String url) {
final u = Uri.tryParse(url.trim());
if (u == null) return null;
final host = u.host.toLowerCase();
String? id;
if (host == 'youtu.be') {
id = u.pathSegments.firstOrNull;
} else if (host == 'youtube.com' || host.endsWith('.youtube.com')) {
final seg = u.pathSegments;
id = u.queryParameters['v'] ??
(seg.length >= 2 && const {'shorts', 'embed', 'live'}.contains(seg[0])
? seg[1]
: null);
}
return id != null && RegExp(r'^[A-Za-z0-9_-]{11}$').hasMatch(id) ? id : null;
}
/// 걸음 센서 — `pedometer` 의 `stepCountStream`.
@@ -44,9 +44,6 @@ class FirestoreQuestRepository implements QuestRepository {
/// 마지막 Music Shake 한 판의 판정표. 다 끝난 판만 서버에 보낸다.
ShakeJudge? _shakeRun;
/// 곡 BPM 이 문서에 없을 때 — 🟡 운영 Quest 는 `quests.bpm` 을 채운다.
static const defaultBpm = 100.0;
/// 오늘 걸은 수를 내는 기준선. 하루 단위라 앱을 껐다 켜도 이어진다.
final WalkBaseline _baseline;
@@ -166,9 +163,10 @@ class FirestoreQuestRepository implements QuestRepository {
final steps = type == QuestType.steps;
final goal = steps
? tiers[count.clamp(0, tiers.length - 1)]
// Music Shake 의 목표는 곡의 구간 수다 — 진행도가 「n / 구간 수」로 찬다.
// Music Shake 의 목표 줄은 Figma 「목표BPM 350」(`480:14466` · `480:14506`) — 곡 BPM 이다.
// 없으면 0(화면은 「-」). 🟡 「목표BPM」의 의미는 회신 대기(Q-MS-01).
: type == QuestType.musicShake
? quest.intOr('totalSegments', dailyLimit)
? quest.intOr('bpm')
: dailyLimit;
return QuestCard(
@@ -447,7 +445,6 @@ class FirestoreQuestRepository implements QuestRepository {
@override
Stream<int> progressStream(QuestCard card) {
if (card.type == QuestType.musicShake) return _shakeProgress(card);
if (card.type != QuestType.steps) return const Stream<int>.empty();
// 센서는 부팅 이후 누적을 준다. 하루 단위 기준선을 빼야 정책 원장 **B-26** 의
// 구간(1 천·5 천·1 만·1.5 만보)과 같은 것을 세게 된다.
@@ -519,49 +516,53 @@ class FirestoreQuestRepository implements QuestRepository {
});
}
/// 곡 박마다 한 구간 — 시작부터 박 수를 세어 내고, 마지막 구간 판정 창이 닫히면 구간 수를 낸다.
/// 흔들기는 그동안만 모은다.
Stream<int> _shakeProgress(QuestCard card) async* {
_shakeRun = null;
final quest = (await _backend.db.doc('quests/${card.id}').get()).data() ??
@override
Future<MusicShakeSetup> musicShakeSetup(String questId) async {
final quest = (await _backend.db.doc('quests/$questId').get()).data() ??
const <String, dynamic>{};
final link = quest['link'] is Map
? Map<String, dynamic>.from(quest['link'] as Map)
: const <String, dynamic>{};
final videoId = youtubeVideoId(link.strOr('url'));
final bpm = quest['bpm'];
final segments = quest.intOr('totalSegments');
final bpm = quest['bpm'] is num && (quest['bpm'] as num) > 0
? (quest['bpm'] as num).toDouble()
: defaultBpm;
if (segments <= 0) {
// 영상 · BPM · 구간 수 중 하나라도 없으면 판을 열지 않는다 — 기본값을 지어내지 않는다.
if (videoId == null || bpm is! num || bpm <= 0 || segments <= 0) {
throw BackendException('E-QST-01', appL10n.questTypeUnverifiable);
}
final run = ShakeJudge(bpm: bpm, segments: segments, start: DateTime.now());
final sub = _shakes().listen(run.add, onError: (_) {});
try {
for (var i = 1; i < segments; i++) {
await Future<void>.delayed(run.beat);
yield i;
}
await Future<void>.delayed(run.end.difference(DateTime.now()));
_shakeRun = run;
yield segments;
} finally {
await sub.cancel();
}
return MusicShakeSetup(
videoId: videoId,
title: link.strOr('title', _title(quest)),
bpm: bpm.toDouble(),
segments: segments,
windows: ShakeWindows.from(await _policy()),
);
}
@override
Stream<DateTime> shakeEvents() => _shakes();
@override
void recordShake(ShakeJudge run) => _shakeRun = run;
Future<Map<String, dynamic>> _verifyShake(QuestCard card) async {
final run = _shakeRun;
if (run == null) throw BackendException('E-QST-01', appL10n.questNoShake);
final quest = (await _backend.db.doc('quests/${card.id}').get()).data() ??
const <String, dynamic>{};
final ids = quest['trackIds'];
// 곡 ID 는 서버 대조용이다 — 화면에 띄우지 않는다.
final trackId = ids is List && ids.isNotEmpty ? '${ids.first}' : quest.strOr('trackId');
final link = quest['link'] is Map ? Map<String, dynamic>.from(quest['link'] as Map) : null;
// 곡 ID 는 서버 대조용이다 — 화면에 띄우지 않는다. 카탈로그가 없으면 영상 ID 다.
final trackId = ids is List && ids.isNotEmpty
? '${ids.first}'
: quest.strOr('trackId', youtubeVideoId(link?.strOr('url') ?? '') ?? '');
_shakeRun = null;
return _backend.call('fnQuestVerifyMusicShake', {
'questId': card.id,
'trackId': trackId,
'totalSegments': run.segments,
'judgement': run.judgement(),
'occurredAt': run.end.toUtc().toIso8601String(),
'occurredAt': DateTime.now().toUtc().toIso8601String(),
...await _fandomField(),
});
}
+106 -3
View File
@@ -1,7 +1,13 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:share_plus/share_plus.dart';
import '../../core/theme/tokens.dart';
import '../campaign/campaign_share.dart' show shareHashtags;
import '../home/home_screen.dart' show formatPop;
import 'music_shake_play_screen.dart' show ShakeTally;
import 'quest_data.dart';
import '../../core/i18n/app_locale.dart';
@@ -15,14 +21,25 @@ import '../../core/i18n/app_locale.dart';
///
/// **POP 을 여기서 더하지 않는다.** 잔액과 원장은 서버가 쓰며(`fn-quest-attend`
/// 등), 이 화면은 결과를 보여 주고 상태 전이를 호출부에 알릴 뿐이다.
class QuestRewardScreen extends StatelessWidget {
///
/// Music Shake 는 판정 합계(Perfect · Good · Miss · 최대 콤보)를 함께 띄우고, 그 카드를
/// 이미지로 SNS 공유한다 — PRD-04 「퀘스트 결과 카드와 SNS 공유」.
class QuestRewardScreen extends StatefulWidget {
const QuestRewardScreen({
super.key,
required this.detail,
this.reward,
this.adBonusPop,
this.shake,
this.share,
});
/// Music Shake 판정 합계(`ShakeJudge.judgement()`). 다른 유형은 null.
final Map<String, int>? shake;
/// 공유 시트 호출. 테스트가 대신 받는다.
final Future<void> Function(ShareParams params)? share;
final QuestDetail detail;
/// 서버가 지급한 결과. 있으면 **이 값만 띄운다** — 설정 보상 POP 과 실제 지급액이
@@ -32,10 +49,64 @@ class QuestRewardScreen extends StatelessWidget {
/// 광고 추가 보상으로 서버가 지급한 기여 POP. 광고를 안 봤거나 청구가 실패했으면 null.
final int? adBonusPop;
int? get rewardPop => reward?.rewardedPop ?? detail.card.rewardPop;
@override
State<QuestRewardScreen> createState() => _QuestRewardScreenState();
}
class _QuestRewardScreenState extends State<QuestRewardScreen> {
final _card = GlobalKey();
bool _sharing = false;
QuestDetail get detail => widget.detail;
int? get adBonusPop => widget.adBonusPop;
int? get rewardPop => widget.reward?.rewardedPop ?? detail.card.rewardPop;
/// 결과 카드를 PNG 로 굽고 문구 · 해시태그(B-78)와 함께 OS 공유 시트로 보낸다.
Future<void> _share(BuildContext button) async {
if (_sharing) return;
setState(() => _sharing = true);
final box = button.findRenderObject() as RenderBox?;
final origin = box == null ? null : box.localToGlobal(Offset.zero) & box.size;
final s = widget.shake!;
final text = [
'Music Shake · ${detail.card.title}',
'PERFECT ${s['perfect']} · GOOD ${s['good']} · MISS ${s['miss']} · MAX COMBO ${s['maxCombo']}',
shareHashtags(topic: 'MusicShake').join(' '),
].join('\n');
try {
XFile? file;
final boundary = _card.currentContext?.findRenderObject() as RenderRepaintBoundary?;
if (boundary != null) {
final image = await boundary.toImage(pixelRatio: 3);
final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
image.dispose();
if (bytes != null) {
file = XFile.fromData(
bytes.buffer.asUint8List(),
mimeType: 'image/png',
name: 'fanuzu-music-shake.png',
);
}
}
await (widget.share ?? SharePlus.instance.share)(ShareParams(
text: text,
files: file == null ? null : [file],
sharePositionOrigin: origin,
));
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.commonRequestFailed)),
);
}
} finally {
if (mounted) setState(() => _sharing = false);
}
}
@override
Widget build(BuildContext context) {
final shake = widget.shake;
final t = Theme.of(context).textTheme;
return Scaffold(
body: SafeArea(
@@ -47,7 +118,11 @@ class QuestRewardScreen extends StatelessWidget {
children: [
Expanded(
child: Center(
child: Column(
child: RepaintBoundary(
key: _card,
child: ColoredBox(
color: AppColors.cosmicVoid,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 120 원 둘레에 그림자 6 을 포함한 132 이미지.
@@ -88,10 +163,38 @@ class QuestRewardScreen extends StatelessWidget {
textAlign: TextAlign.center,
),
],
if (shake != null) ...[
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ShakeTally(
perfect: shake['perfect'] ?? 0,
good: shake['good'] ?? 0,
miss: shake['miss'] ?? 0,
maxCombo: shake['maxCombo'] ?? 0,
),
),
],
],
),
),
),
),
),
if (shake != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Builder(
builder: (button) => OutlinedButton(
onPressed: _sharing ? null : () => _share(button),
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(56),
foregroundColor: AppColors.onCosmic,
),
child: Text(context.l10n.shakeShareResult),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: FilledButton(
@@ -163,11 +163,16 @@ class QuestCardTile extends StatelessWidget {
Row(
children: [
Expanded(
// Music Shake 는 Figma `480:14262` 「목표BPM 350」 — 곡 BPM 한 값이다.
child: _Stat(
label: context.l10n.questGoal,
value: card.goal > 0
? '${formatPop(card.progress)} / ${formatPop(card.goal)}'
: '-',
label: card.type == QuestType.musicShake
? context.l10n.questGoalBpm
: context.l10n.questGoal,
value: card.goal <= 0
? '-'
: card.type == QuestType.musicShake
? formatPop(card.goal)
: '${formatPop(card.progress)} / ${formatPop(card.goal)}',
muted: card.goal <= 0,
),
),