diff --git a/app/lib/features/home/home_repository_firestore.dart b/app/lib/features/home/home_repository_firestore.dart index 02e08db..d8ba81e 100644 --- a/app/lib/features/home/home_repository_firestore.dart +++ b/app/lib/features/home/home_repository_firestore.dart @@ -423,7 +423,11 @@ class FirestoreHomeRepository implements HomeRepository { ), rewardPop: openTimeAttack(quest)?.rewardPop ?? - QuestType.parse(quest.strOr('type')).rewardFrom(quest, policy), + QuestType.parse(quest.strOr('type')).rewardFrom( + quest, + policy, + cleared: count, + ), state: done ? QuestState.done : started diff --git a/app/lib/features/quest/music_shake_play_screen.dart b/app/lib/features/quest/music_shake_play_screen.dart index 84171d0..c4c25f9 100644 --- a/app/lib/features/quest/music_shake_play_screen.dart +++ b/app/lib/features/quest/music_shake_play_screen.dart @@ -6,6 +6,7 @@ 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_android/webview_flutter_android.dart'; import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart'; import '../../core/i18n/app_locale.dart'; @@ -292,7 +293,9 @@ class YoutubeEmbed extends StatefulWidget { !Platform.environment.containsKey('FLUTTER_TEST'); @visibleForTesting - static String html(String videoId, {int startSeconds = 0}) => + /// [resumeAt] 이 있으면 「이어하기」 — 플레이어가 준비되면 그 자리(밀리초까지)로 옮겨 바로 튼다. + /// 처음 시작은 지금처럼 사용자가 재생 버튼을 누른다. + static String html(String videoId, {int startSeconds = 0, Duration? resumeAt}) => ''' @@ -302,7 +305,8 @@ 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,start:$startSeconds,origin:'$appOrigin'}, - events:{onStateChange:function(e){post(e.data);}, + events:{onReady:function(){${resumeAt == null ? '' : 'player.seekTo(${resumeAt.inMilliseconds / 1000},true);player.playVideo();'}}, + 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); } @@ -321,16 +325,32 @@ class _YoutubeEmbedState extends State { super.initState(); if (!YoutubeEmbed.enabled) return; final params = WebViewPlatform.instance is WebKitWebViewPlatform - ? WebKitWebViewControllerCreationParams(allowsInlineMediaPlayback: true) + ? WebKitWebViewControllerCreationParams( + allowsInlineMediaPlayback: true, + // 「이어하기」 자동 재생 — 사용자 탭 없이 재생을 허용한다. + mediaTypesRequiringUserAction: const {}, + ) : const PlatformWebViewControllerCreationParams(); - _web = WebViewController.fromPlatformCreationParams(params) + final web = _web = WebViewController.fromPlatformCreationParams(params) ..setJavaScriptMode(JavaScriptMode.unrestricted) ..setBackgroundColor(Colors.black) - ..addJavaScriptChannel('Player', onMessageReceived: _onMessage) - ..loadHtmlString( - YoutubeEmbed.html(widget.videoId, startSeconds: widget.start.inSeconds), + ..addJavaScriptChannel('Player', onMessageReceived: _onMessage); + // Android WebView 는 기본으로 탭 없는 재생을 막는다 — 이어하기 자동 재생을 위해 푼다. + // 불러오기 전에 설정해야 첫 문서에 적용된다. + if (web.platform case final AndroidWebViewController android) { + unawaited(android.setMediaPlaybackRequiresUserGesture(false)); + } + unawaited( + web.loadHtmlString( + YoutubeEmbed.html( + widget.videoId, + startSeconds: widget.start.inSeconds, + // 이어하기(멈춘 자리가 있음)면 저장 위치로 옮겨 자동 재생한다(10/6 실기기 A-2). + resumeAt: widget.start > Duration.zero ? widget.start : null, + ), baseUrl: YoutubeEmbed.appOrigin, - ); + ), + ); } void _onMessage(JavaScriptMessage m) { diff --git a/app/lib/features/quest/quest_data.dart b/app/lib/features/quest/quest_data.dart index 852daed..6c2c6ca 100644 --- a/app/lib/features/quest/quest_data.dart +++ b/app/lib/features/quest/quest_data.dart @@ -132,9 +132,25 @@ enum QuestType { /// 출석은 `fn-quest-attend` 가 `config/policy.attendPop` 을 지급하고 /// `quests.rewardPop` 을 보지 않는다. 그래서 출석만 운영값을 읽는다. 운영값이 /// 없으면 null — 숫자를 지어내지 않고 화면이 중립 문구를 쓴다. - int? rewardFrom(Map quest, Map policy) { + /// + /// 운영 걸음 Quest 는 `fn-quest-steps` 가 `config/policy.stepTiers` 구간표(원장 B-26 — + /// 1 천/5 천/1 만/1.5 만보 = 5/6/7/8)대로 지급하고 `quests.rewardPop` 을 보지 않는다. 카드의 + /// 목표 칸이 오늘 다음 구간(`[cleared]` 번째)이므로 보상 칸도 **그 구간의 지급액**이다. + int? rewardFrom( + Map quest, + Map policy, { + int cleared = 0, + }) { // 시청 · 스트리밍은 무보상 참여다(C-04) — 문서 값과 무관하게 0. if (this == QuestType.streaming) return 0; + if (this == QuestType.steps && quest['authorUid'] is! String) { + final tiers = [ + for (final row in policy['stepTiers'] is List ? policy['stepTiers'] as List : const []) + if (row is Map && row['steps'] is num && row['pop'] is num) + (steps: row['steps'] as num, pop: (row['pop'] as num).round()), + ]..sort((a, b) => a.steps.compareTo(b.steps)); + if (tiers.isNotEmpty) return tiers[cleared.clamp(0, tiers.length - 1)].pop; + } // Music Shake 는 BPM 판정 성공 시 일반 POP(원장 B-24 · 보상액 운영값). 서버 // (`fn-quest-verify-music-shake`)가 퀘스트의 `rewardPop` 을, 없으면 // `config/policy.musicShake.pop` 을 기본액으로 쓴다. 운영값이 둘 다 없을 때만 중립 문구. diff --git a/app/lib/features/quest/quest_repository_firestore.dart b/app/lib/features/quest/quest_repository_firestore.dart index 4f0970e..dbc29bc 100644 --- a/app/lib/features/quest/quest_repository_firestore.dart +++ b/app/lib/features/quest/quest_repository_firestore.dart @@ -271,7 +271,8 @@ class FirestoreQuestRepository implements QuestRepository { goal: cardGoal, // 걸음은 센서를 구독해야 알 수 있어 시작 전에는 0 이다. 나머지는 당일 수행 횟수다. progress: type.gathers ? (done ? goal : walked ?? 0) : count, - rewardPop: openTimeAttack(quest)?.rewardPop ?? type.rewardFrom(quest, policy), + rewardPop: openTimeAttack(quest)?.rewardPop ?? + type.rewardFrom(quest, policy, cleared: count), // 설계서 §2.8 의 `quests` 에 참여 인원 필드가 없다. 세는 곳이 없어 0 이다. participants: quest.intOr('participantCount'), // 사용자 생성 Quest 의 모집 인원(`capacity`). 운영 Quest 에는 없다. diff --git a/app/lib/features/quest/widgets/quest_card_tile.dart b/app/lib/features/quest/widgets/quest_card_tile.dart index 1c4d4a1..70474e2 100644 --- a/app/lib/features/quest/widgets/quest_card_tile.dart +++ b/app/lib/features/quest/widgets/quest_card_tile.dart @@ -211,7 +211,10 @@ class QuestCardTile extends StatelessWidget { ? '-' : formatPop(card.rewardPop!), unit: 'POP', - muted: preview && (card.rewardPop ?? 0) <= 0, + // 운영값이 아직 없는 보상(Music Shake — Q-MS-05)도 미리보기(`254:164081`)의 + // 빈 값처럼 회색 「-」다. 숫자를 지어내지 않는다. + muted: card.rewardPop == null || + (preview && card.rewardPop! <= 0), ), ), ], diff --git a/app/pubspec.lock b/app/pubspec.lock index 033c223..9de4a10 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -1602,7 +1602,7 @@ packages: source: hosted version: "4.14.1" webview_flutter_android: - dependency: transitive + dependency: "direct main" description: name: webview_flutter_android sha256: "4de8b3d1ff4ebe1bdb42e68a5e4f809194a3cb0117a8f495f590004f00da3964" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 6d2f3c4..fc765fa 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -111,6 +111,9 @@ dependencies: # 켜려고 직접 참조한다. 이미 webview_flutter 의 전이 의존성(같은 3.26.1)이라 새 패키지가 아니며 # 최소 OS 하한 영향 없음: wkwebview iOS 13.0(앱 15.0). webview_flutter_wkwebview: ^3.26.1 + # Music Shake 「이어하기」 자동 재생 — Android WebView 의 「미디어 재생에 사용자 제스처 필요」를 + # 끄려고 직접 참조한다. webview_flutter 의 전이 의존성(같은 4.14.1)이라 새 패키지가 아니다. + webview_flutter_android: ^4.14.1 # 카카오 로그인 — Kakao 공식 SDK. 카카오톡이 있으면 앱으로, 없으면 카카오계정 웹으로 로그인해 # 액세스 토큰을 받고 서버(fnAuthKakao)가 Firebase 커스텀 토큰으로 바꿔 준다. Android 에서 # Firebase OIDC 웹 흐름이 카카오톡 앱 전환 뒤 「missing initial state」로 깨져서 옮겼다. diff --git a/app/test/eclipse_figma_flow_test.dart b/app/test/eclipse_figma_flow_test.dart index bc47256..ac521ae 100644 --- a/app/test/eclipse_figma_flow_test.dart +++ b/app/test/eclipse_figma_flow_test.dart @@ -227,4 +227,28 @@ void main() { expect(QuestType.musicShake.rewardFrom(const {}, const {}), isNull); }); }); + + group('걸음 보상 표시 (원장 B-26)', () { + const policy = { + 'stepTiers': [ + {'steps': 5000, 'pop': 6}, + {'steps': 1000, 'pop': 5}, + {'steps': 10000, 'pop': 7}, + {'steps': 15000, 'pop': 8}, + ], + }; + test('운영 걸음 Quest 는 오늘 다음 구간의 지급액이다 — 「- POP」 아님 (10/6 A-5)', () { + expect(QuestType.steps.rewardFrom(const {}, policy), 5); + expect(QuestType.steps.rewardFrom(const {}, policy, cleared: 2), 7); + // 다 받은 뒤에는 마지막 구간 값에 머문다. + expect(QuestType.steps.rewardFrom(const {}, policy, cleared: 4), 8); + }); + test('사용자 생성 만보기는 생성자가 정한 보상, 구간표가 없으면 지어내지 않는다', () { + expect( + QuestType.steps.rewardFrom(const {'authorUid': 'u', 'rewardPop': 30}, policy), + 30, + ); + expect(QuestType.steps.rewardFrom(const {}, const {}), isNull); + }); + }); } diff --git a/app/test/music_shake_play_test.dart b/app/test/music_shake_play_test.dart index 1c0b958..5c5eef8 100644 --- a/app/test/music_shake_play_test.dart +++ b/app/test/music_shake_play_test.dart @@ -171,4 +171,14 @@ void main() { expect(judge.hits.take(2).every((h) => h >= 0), isTrue); expect(judge.live(judge.position).judged, 2); }); + + test('이어하기면 플레이어가 준비되자마자 저장 위치로 옮겨 튼다 — 처음 시작은 사용자가 누른다 (10/6 A-2)', () { + final resume = YoutubeEmbed.html( + 'vid', + startSeconds: 9, + resumeAt: const Duration(milliseconds: 9910), + ); + expect(resume, contains('player.seekTo(9.91,true);player.playVideo();')); + expect(YoutubeEmbed.html('vid'), isNot(contains('playVideo'))); + }); }