이클립스 방어 횟수 즉시 표시·진동 · Music Shake 영상 재생 중 흔들기 판정 · 결과 공유
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,11 @@
|
||||
/// - 퀘스트 생성 `523:18152`~`523:18188` · Admin 정의 `567:19531`
|
||||
library;
|
||||
|
||||
import '../../core/data/backend.dart' show BackendException;
|
||||
import '../../core/monetization/rewarded_ad.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import 'place_picker_screen.dart' show PlacePickerScreen;
|
||||
import 'quest_evidence.dart' show ShakeJudge, ShakeWindows;
|
||||
|
||||
/// 진행 상태 — 명세가 **다섯 가지**를 못박았다.
|
||||
///
|
||||
@@ -112,13 +114,14 @@ enum QuestType {
|
||||
int? rewardFrom(Map<String, dynamic> quest, Map<String, dynamic> policy) {
|
||||
// 시청 · 스트리밍은 무보상 참여다(C-04) — 문서 값과 무관하게 0.
|
||||
if (this == QuestType.streaming) return 0;
|
||||
// Music Shake 는 BPM 판정 성공 시 일반 POP(원장 B-24). 서버(`fn-quest-verify-music-shake`)가
|
||||
// 퀘스트의 `rewardPop` 을, 없으면 `config/policy.stepsPop` 을 기본액으로 쓰고 판정 등급
|
||||
// 배수를 곱한다. 화면은 기본액(C 등급)을 적는다 — 운영값이 둘 다 없을 때만 중립 문구.
|
||||
// Music Shake 는 BPM 판정 성공 시 일반 POP(원장 B-24 · 보상액 운영값). 서버
|
||||
// (`fn-quest-verify-music-shake`)가 퀘스트의 `rewardPop` 을, 없으면
|
||||
// `config/policy.musicShake.pop` 을 기본액으로 쓴다. 운영값이 둘 다 없을 때만 중립 문구.
|
||||
final shake = policy['musicShake'];
|
||||
final raw = this == QuestType.attend
|
||||
? policy['attendPop']
|
||||
: this == QuestType.musicShake
|
||||
? quest['rewardPop'] ?? policy['stepsPop']
|
||||
? quest['rewardPop'] ?? (shake is Map ? shake['pop'] : null)
|
||||
: quest['rewardPop'];
|
||||
return raw is num ? raw.round() : null;
|
||||
}
|
||||
@@ -130,8 +133,9 @@ enum QuestType {
|
||||
/// 내부 사유(질의 번호 등)는 화면에 내보내지 않는다 — 사용자에게는 한 문구다.
|
||||
bool get verifiable => this != QuestType.unknown;
|
||||
|
||||
/// 시작 뒤 진행도를 모아야 판정할 수 있는 유형 — 걸음(누적 걸음) · Music Shake(구간 판정).
|
||||
bool get gathers => this == QuestType.steps || this == QuestType.musicShake;
|
||||
/// 시작 뒤 진행도를 모아야 판정할 수 있는 유형 — 걸음(누적 걸음).
|
||||
/// Music Shake 는 영상을 보며 흔드는 별도 진행 화면(`MusicShakePlayScreen`)에서 모은다.
|
||||
bool get gathers => this == QuestType.steps;
|
||||
|
||||
/// 참여를 열지 않는 사유. 열려 있으면 null 이다.
|
||||
String? get blockedReason =>
|
||||
@@ -375,6 +379,30 @@ class QuestCard {
|
||||
}
|
||||
|
||||
/// 상세 화면이 필요로 하는 것.
|
||||
/// Music Shake 한 판의 준비값 — 퀘스트 문서와 운영값에서 읽는다(지어내지 않는다).
|
||||
class MusicShakeSetup {
|
||||
const MusicShakeSetup({
|
||||
required this.videoId,
|
||||
required this.title,
|
||||
required this.bpm,
|
||||
required this.segments,
|
||||
this.windows = const ShakeWindows(),
|
||||
});
|
||||
|
||||
/// 지정 공식 YouTube 영상(PRD-04 MVP 수용 기준) — `quests.link.url` 에서 꺼낸 ID.
|
||||
final String videoId;
|
||||
|
||||
/// 곡 제목 — `quests.link.title`(링크 원본 제목 · C-05).
|
||||
final String title;
|
||||
|
||||
/// `quests.bpm` — Figma 「목표BPM」. 🟡 의미 · 입력 주체 회신 대기(Q-MS-01 · Q-MS-02).
|
||||
final double bpm;
|
||||
|
||||
/// `quests.totalSegments` — 판정 박 수. 🟡 구간 패턴 정의 회신 대기(Q-MS-02).
|
||||
final int segments;
|
||||
final ShakeWindows windows;
|
||||
}
|
||||
|
||||
class QuestDetail {
|
||||
const QuestDetail({
|
||||
required this.card,
|
||||
@@ -517,6 +545,16 @@ abstract interface class QuestRepository {
|
||||
/// 센서가 없으면 오류로 끊는다.
|
||||
Stream<int> progressStream(QuestCard card);
|
||||
|
||||
/// Music Shake 한 판의 준비값 — 공식 YouTube 영상 · BPM · 구간 수 · 판정 창(운영값).
|
||||
/// 퀘스트에 값이 빠져 있으면 E-QST-01 이다(값을 지어내지 않는다).
|
||||
Future<MusicShakeSetup> musicShakeSetup(String questId);
|
||||
|
||||
/// 단말 흔들기 시각 스트림(가속도계).
|
||||
Stream<DateTime> shakeEvents();
|
||||
|
||||
/// 끝난 판의 판정표를 남긴다. 다음 [verify] 가 서버에 증거로 보낸다.
|
||||
void recordShake(ShakeJudge run);
|
||||
|
||||
/// 활동 증거를 서버에 보내 판정·지급을 받는다.
|
||||
///
|
||||
/// **판정도 지급도 서버가 한다.** 앱은 증거(걸음 수·좌표)만 싣고, 실패는
|
||||
@@ -742,6 +780,17 @@ class SampleQuestRepository implements QuestRepository {
|
||||
@override
|
||||
Stream<int> progressStream(QuestCard card) => const Stream<int>.empty();
|
||||
|
||||
/// 표본에는 영상 · BPM 이 없다 — 참여를 열지 않는다.
|
||||
@override
|
||||
Future<MusicShakeSetup> musicShakeSetup(String questId) async =>
|
||||
throw BackendException('E-QST-01', appL10n.questTypeUnverifiable);
|
||||
|
||||
@override
|
||||
Stream<DateTime> shakeEvents() => const Stream<DateTime>.empty();
|
||||
|
||||
@override
|
||||
void recordShake(ShakeJudge run) {}
|
||||
|
||||
@override
|
||||
Future<QuestReward> verify(QuestCard card) async {
|
||||
_claimed.add(card.id);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../core/ui/app_dialog.dart';
|
||||
@@ -13,6 +12,7 @@ import '../../core/theme/tokens.dart';
|
||||
import '../../core/ui/app_icons.dart';
|
||||
import '../home/home_screen.dart' show formatPop;
|
||||
import '../planet/moderation.dart';
|
||||
import 'music_shake_play_screen.dart';
|
||||
import 'quest_ad_screens.dart';
|
||||
import 'quest_create_screen.dart' show QuestLinkCard;
|
||||
import 'quest_data.dart';
|
||||
@@ -60,6 +60,9 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
/// 서버 왕복 중. 두 번 눌러 두 번 지급되는 경로를 막는다.
|
||||
bool _busy = false;
|
||||
|
||||
/// 방금 끝낸 Music Shake 판의 판정 합계 — 결과 화면 · 결과 카드에 쓴다.
|
||||
Map<String, int>? _shakeResult;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -182,7 +185,8 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 링크 원본(서버 조회값)과 작성자 설명을 나눠 그린다(C-05 · R10).
|
||||
if (detail.link != null) ...[
|
||||
// Music Shake 는 Figma `480:14506` 대로 링크 카드 대신 곡 제목 칩(아래)이다.
|
||||
if (detail.link != null && card.type != QuestType.musicShake) ...[
|
||||
QuestLinkCard(link: detail.link!),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
@@ -194,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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -806,5 +806,11 @@
|
||||
"eclipseMinSec": "{minutes}m {seconds}s",
|
||||
"eclipseOverlayTitle": "Meteor impact\nis starting!",
|
||||
"eclipseLiveOccurred": "An eclipse has hit planet {name}!",
|
||||
"eclipseMyShakes": "My valid shakes {count} / {total}"
|
||||
"eclipseMyShakes": "My valid shakes {count} / {total}",
|
||||
"questGoalBpm": "Target BPM",
|
||||
"shakeCombo": "{n} COMBO",
|
||||
"shakeJudged": "Judged {n} / {total}",
|
||||
"shakePlayHint": "Play the video and shake your phone to the beat",
|
||||
"shakeVideoError": "Can't play this video",
|
||||
"shakeShareResult": "Share result"
|
||||
}
|
||||
|
||||
@@ -806,5 +806,11 @@
|
||||
"eclipseMinSec": "{minutes} min {seconds} s",
|
||||
"eclipseOverlayTitle": "¡El impacto de meteoritos\nva a comenzar!",
|
||||
"eclipseLiveOccurred": "¡Un eclipse ha llegado al planeta {name}!",
|
||||
"eclipseMyShakes": "Mis sacudidas válidas {count} / {total}"
|
||||
"eclipseMyShakes": "Mis sacudidas válidas {count} / {total}",
|
||||
"questGoalBpm": "BPM objetivo",
|
||||
"shakeCombo": "{n} COMBO",
|
||||
"shakeJudged": "Evaluados {n} / {total}",
|
||||
"shakePlayHint": "Reproduce el video y agita el teléfono al ritmo",
|
||||
"shakeVideoError": "No se puede reproducir este video",
|
||||
"shakeShareResult": "Compartir resultado"
|
||||
}
|
||||
|
||||
@@ -806,5 +806,11 @@
|
||||
"eclipseMinSec": "{minutes}分{seconds}秒",
|
||||
"eclipseOverlayTitle": "惑星への隕石衝突が\n始まります!",
|
||||
"eclipseLiveOccurred": "{name}惑星にイクリプスが発生しました!",
|
||||
"eclipseMyShakes": "自分の有効Shake {count} / {total}回"
|
||||
"eclipseMyShakes": "自分の有効Shake {count} / {total}回",
|
||||
"questGoalBpm": "目標BPM",
|
||||
"shakeCombo": "{n} COMBO",
|
||||
"shakeJudged": "判定 {n} / {total}",
|
||||
"shakePlayHint": "動画を再生してビートに合わせてスマホを振ってください",
|
||||
"shakeVideoError": "この動画を再生できません",
|
||||
"shakeShareResult": "結果をシェア"
|
||||
}
|
||||
|
||||
+24
-1
@@ -1708,5 +1708,28 @@
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"questGoalBpm": "목표 BPM",
|
||||
"shakeCombo": "{n} COMBO",
|
||||
"@shakeCombo": {
|
||||
"placeholders": {
|
||||
"n": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shakeJudged": "판정 {n} / {total}",
|
||||
"@shakeJudged": {
|
||||
"placeholders": {
|
||||
"n": {
|
||||
"type": "String"
|
||||
},
|
||||
"total": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shakePlayHint": "영상을 재생하고 박자에 맞춰 폰을 흔들어 주세요",
|
||||
"shakeVideoError": "영상을 재생할 수 없어요",
|
||||
"shakeShareResult": "결과 공유"
|
||||
}
|
||||
|
||||
@@ -4957,6 +4957,42 @@ abstract class AppLocalizations {
|
||||
/// In ko, this message translates to:
|
||||
/// **'내 유효 Shake {count} / {total}회'**
|
||||
String eclipseMyShakes(String count, String total);
|
||||
|
||||
/// No description provided for @questGoalBpm.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'목표 BPM'**
|
||||
String get questGoalBpm;
|
||||
|
||||
/// No description provided for @shakeCombo.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'{n} COMBO'**
|
||||
String shakeCombo(String n);
|
||||
|
||||
/// No description provided for @shakeJudged.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'판정 {n} / {total}'**
|
||||
String shakeJudged(String n, String total);
|
||||
|
||||
/// No description provided for @shakePlayHint.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'영상을 재생하고 박자에 맞춰 폰을 흔들어 주세요'**
|
||||
String get shakePlayHint;
|
||||
|
||||
/// No description provided for @shakeVideoError.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'영상을 재생할 수 없어요'**
|
||||
String get shakeVideoError;
|
||||
|
||||
/// No description provided for @shakeShareResult.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'결과 공유'**
|
||||
String get shakeShareResult;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -2821,4 +2821,26 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String eclipseMyShakes(String count, String total) {
|
||||
return 'My valid shakes $count / $total';
|
||||
}
|
||||
|
||||
@override
|
||||
String get questGoalBpm => 'Target BPM';
|
||||
|
||||
@override
|
||||
String shakeCombo(String n) {
|
||||
return '$n COMBO';
|
||||
}
|
||||
|
||||
@override
|
||||
String shakeJudged(String n, String total) {
|
||||
return 'Judged $n / $total';
|
||||
}
|
||||
|
||||
@override
|
||||
String get shakePlayHint => 'Play the video and shake your phone to the beat';
|
||||
|
||||
@override
|
||||
String get shakeVideoError => 'Can\'t play this video';
|
||||
|
||||
@override
|
||||
String get shakeShareResult => 'Share result';
|
||||
}
|
||||
|
||||
@@ -2833,4 +2833,26 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String eclipseMyShakes(String count, String total) {
|
||||
return 'Mis sacudidas válidas $count / $total';
|
||||
}
|
||||
|
||||
@override
|
||||
String get questGoalBpm => 'BPM objetivo';
|
||||
|
||||
@override
|
||||
String shakeCombo(String n) {
|
||||
return '$n COMBO';
|
||||
}
|
||||
|
||||
@override
|
||||
String shakeJudged(String n, String total) {
|
||||
return 'Evaluados $n / $total';
|
||||
}
|
||||
|
||||
@override
|
||||
String get shakePlayHint => 'Reproduce el video y agita el teléfono al ritmo';
|
||||
|
||||
@override
|
||||
String get shakeVideoError => 'No se puede reproducir este video';
|
||||
|
||||
@override
|
||||
String get shakeShareResult => 'Compartir resultado';
|
||||
}
|
||||
|
||||
@@ -2728,4 +2728,26 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String eclipseMyShakes(String count, String total) {
|
||||
return '自分の有効Shake $count / $total回';
|
||||
}
|
||||
|
||||
@override
|
||||
String get questGoalBpm => '目標BPM';
|
||||
|
||||
@override
|
||||
String shakeCombo(String n) {
|
||||
return '$n COMBO';
|
||||
}
|
||||
|
||||
@override
|
||||
String shakeJudged(String n, String total) {
|
||||
return '判定 $n / $total';
|
||||
}
|
||||
|
||||
@override
|
||||
String get shakePlayHint => '動画を再生してビートに合わせてスマホを振ってください';
|
||||
|
||||
@override
|
||||
String get shakeVideoError => 'この動画を再生できません';
|
||||
|
||||
@override
|
||||
String get shakeShareResult => '結果をシェア';
|
||||
}
|
||||
|
||||
@@ -2730,4 +2730,26 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String eclipseMyShakes(String count, String total) {
|
||||
return '내 유효 Shake $count / $total회';
|
||||
}
|
||||
|
||||
@override
|
||||
String get questGoalBpm => '목표 BPM';
|
||||
|
||||
@override
|
||||
String shakeCombo(String n) {
|
||||
return '$n COMBO';
|
||||
}
|
||||
|
||||
@override
|
||||
String shakeJudged(String n, String total) {
|
||||
return '판정 $n / $total';
|
||||
}
|
||||
|
||||
@override
|
||||
String get shakePlayHint => '영상을 재생하고 박자에 맞춰 폰을 흔들어 주세요';
|
||||
|
||||
@override
|
||||
String get shakeVideoError => '영상을 재생할 수 없어요';
|
||||
|
||||
@override
|
||||
String get shakeShareResult => '결과 공유';
|
||||
}
|
||||
|
||||
@@ -2725,4 +2725,26 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String eclipseMyShakes(String count, String total) {
|
||||
return '我的有效摇动 $count / $total次';
|
||||
}
|
||||
|
||||
@override
|
||||
String get questGoalBpm => '目标 BPM';
|
||||
|
||||
@override
|
||||
String shakeCombo(String n) {
|
||||
return '$n COMBO';
|
||||
}
|
||||
|
||||
@override
|
||||
String shakeJudged(String n, String total) {
|
||||
return '判定 $n / $total';
|
||||
}
|
||||
|
||||
@override
|
||||
String get shakePlayHint => '播放视频并跟着节拍摇动手机';
|
||||
|
||||
@override
|
||||
String get shakeVideoError => '无法播放该视频';
|
||||
|
||||
@override
|
||||
String get shakeShareResult => '分享结果';
|
||||
}
|
||||
|
||||
@@ -806,5 +806,11 @@
|
||||
"eclipseMinSec": "{minutes}分{seconds}秒",
|
||||
"eclipseOverlayTitle": "陨石即将\n撞击行星!",
|
||||
"eclipseLiveOccurred": "{name}行星发生了日食!",
|
||||
"eclipseMyShakes": "我的有效摇动 {count} / {total}次"
|
||||
"eclipseMyShakes": "我的有效摇动 {count} / {total}次",
|
||||
"questGoalBpm": "目标 BPM",
|
||||
"shakeCombo": "{n} COMBO",
|
||||
"shakeJudged": "判定 {n} / {total}",
|
||||
"shakePlayHint": "播放视频并跟着节拍摇动手机",
|
||||
"shakeVideoError": "无法播放该视频",
|
||||
"shakeShareResult": "分享结果"
|
||||
}
|
||||
|
||||
@@ -1804,6 +1804,9 @@ class RemoteAppLocalizations extends AppLocalizations {
|
||||
@override
|
||||
String get questGoal => _remote['questGoal'] ?? _base.questGoal;
|
||||
|
||||
@override
|
||||
String get questGoalBpm => _remote['questGoalBpm'] ?? _base.questGoalBpm;
|
||||
|
||||
@override
|
||||
String get questGoalValue => _remote['questGoalValue'] ?? _base.questGoalValue;
|
||||
|
||||
@@ -2191,6 +2194,21 @@ class RemoteAppLocalizations extends AppLocalizations {
|
||||
@override
|
||||
String get settingsWithdrawLater => _remote['settingsWithdrawLater'] ?? _base.settingsWithdrawLater;
|
||||
|
||||
@override
|
||||
String shakeCombo(String n) => _fill('shakeCombo', {'n': n}) ?? _base.shakeCombo(n);
|
||||
|
||||
@override
|
||||
String shakeJudged(String n, String total) => _fill('shakeJudged', {'n': n, 'total': total}) ?? _base.shakeJudged(n, total);
|
||||
|
||||
@override
|
||||
String get shakePlayHint => _remote['shakePlayHint'] ?? _base.shakePlayHint;
|
||||
|
||||
@override
|
||||
String get shakeShareResult => _remote['shakeShareResult'] ?? _base.shakeShareResult;
|
||||
|
||||
@override
|
||||
String get shakeVideoError => _remote['shakeVideoError'] ?? _base.shakeVideoError;
|
||||
|
||||
@override
|
||||
String get shareCopied => _remote['shareCopied'] ?? _base.shareCopied;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user