diff --git a/app/firebase/functions/src/index.ts b/app/firebase/functions/src/index.ts index 7637125..ea9a080 100644 --- a/app/firebase/functions/src/index.ts +++ b/app/firebase/functions/src/index.ts @@ -7,7 +7,7 @@ export { fnPopGrant, fnPopSpend, fnPopRefund, fnPopReconcile, } from './ledger/pop.js'; export { - fnQuestAttend, fnQuestVerifySteps, fnAdReward, fnQuestStreamParticipate, + fnQuestAttend, fnQuestVerifySteps, fnAdReward, fnQuestStreamParticipate, fnQuestProgress, } from './quest/quest.js'; export { fnAuthOnCreate, fnNebulaCreate, fnPlanetCreate, diff --git a/app/firebase/functions/src/quest/quest.ts b/app/firebase/functions/src/quest/quest.ts index bb785ce..bea9d45 100644 --- a/app/firebase/functions/src/quest/quest.ts +++ b/app/firebase/functions/src/quest/quest.ts @@ -119,10 +119,73 @@ export function progressWriter( rewardedPop, completedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(), + // 보상을 받았으면 진행 세션(C-83 · [fnQuestProgress])은 끝났다 — 다음 회차는 「시작전」부터. + session: FieldValue.delete(), }, { merge: true }); }; } +/** 진행 세션을 두는 유형 — 실행률을 모으는 걸음 · 화면 안에서 진행하는 Music Shake(Q-B-12 A · C-83). */ +const SESSION_TYPES = ['walk', 'steps', 'health', 'musicshake', 'music_shake', 'shake']; +const SESSION_STATES = ['inProgress', 'paused', 'completed'] as const; + +/** 0 이상 정수 · 상한 안. 없으면 0. 화면 진행 표시값이라 상한만 둔다. */ +function count(data: Record, key: string, max: number): number { + const v = data[key] ?? 0; + if (typeof v !== 'number' || !Number.isInteger(v) || v < 0 || v > max) { + fail(ERR.questAbnormal, `${key} 값이 올바르지 않습니다.`); + } + return v; +} + +/** + * `fn-quest-progress` — Quest 진행 상태(진행중 · 일시 정지 · 진행완료)를 진행 문서에 남긴다(원장 C-83). + * + * 화면을 다시 열거나 앱을 다시 켜도 「진행중」 탭 · 「이어하기」 · 목록 「보상받기」가 유지되게 하는 + * **표시용 상태**다. 지급 판정은 여기서 하지 않는다 — 보상은 그대로 검증 함수가 증거로 판정한다. + * `state: 'none'` 은 중단(퀘스트 중단 모달 → 시작전)이다. 진행 문서 쓰기는 규칙상 서버 전용이다. + * `dayKey` · `count` 는 건드리지 않는다 — 일일 한도와 완료 알림(`fnInboxQuestDone`)의 원천이다. + */ +export const fnQuestProgress = onCall(async (req) => { + const uid = callerUid(req); + const data = (req.data ?? {}) as Record; + const questId = str(data, 'questId'); + const state = str(data, 'state'); + const ref = getFirestore().doc(`users/${uid}/questProgress/${questId}`); + if (state === 'none') { + await ref.set({ session: FieldValue.delete() }, { merge: true }); + return { questId, state }; + } + if (!(SESSION_STATES as readonly string[]).includes(state)) { + fail(ERR.questAbnormal, '진행 상태가 올바르지 않습니다.'); + } + await questOf(uid, questId, SESSION_TYPES); + const hits = data.hits; + if (hits !== undefined && (!Array.isArray(hits) || hits.length > 2000 + || hits.some((h) => typeof h !== 'number' || h < -1 || h > 1))) { + fail(ERR.questAbnormal, '판정 기록이 올바르지 않습니다.'); + } + const judgement = data.judgement; + const tally = judgement && typeof judgement === 'object' + ? Object.fromEntries(['perfect', 'good', 'miss', 'maxCombo'] + .map((k) => [k, count(judgement as Record, k, 2000)])) + : null; + await ref.set({ + questId, + session: { + state, + progress: count(data, 'progress', 1_000_000), + total: count(data, 'total', 1_000_000), + positionMs: count(data, 'positionMs', 86_400_000), + ...(hits !== undefined ? { hits } : {}), + ...(tally ? { judgement: tally } : {}), + updatedAt: FieldValue.serverTimestamp(), + }, + // `session` 을 통째로 바꾼다 — 일시 정지의 판정 기록이 진행완료 뒤에 남지 않게. + }, { mergeFields: ['questId', 'session'] }); + return { questId, state }; +}); + /** `fn-quest-attend` — 출석. 한도는 `quests/{questId}.dailyLimit` 이 정한다. */ export const fnQuestAttend = onCall(async (req) => { const uid = callerUid(req); diff --git a/app/firebase/functions/test/quest_e2e.test.ts b/app/firebase/functions/test/quest_e2e.test.ts index 9ab0d13..d42001e 100644 --- a/app/firebase/functions/test/quest_e2e.test.ts +++ b/app/firebase/functions/test/quest_e2e.test.ts @@ -6,7 +6,7 @@ import { bootAdmin, shutdownAdmin, wipeUser } from './support.js'; import { grantPop } from '../src/ledger/ledger.js'; import { verifiedQuestCountOf } from '../src/ledger/fpi.js'; import { fnAdminCompletionVoid } from '../src/admin/admin.js'; -import { fnAdReward, fnQuestAttend, fnQuestVerifySteps } from '../src/quest/quest.js'; +import { fnAdReward, fnQuestAttend, fnQuestProgress, fnQuestVerifySteps } from '../src/quest/quest.js'; import { fnQuestVerifyMusicShake, fnQuestVerifyPlace, placeProofStorage } from '../src/quest/verify.js'; import { fnCampaignFund } from '../src/fandom/fandom.js'; @@ -715,3 +715,43 @@ describe('POP → 행성 반영 (별첨 1 ② 완료 시나리오 1)', () => { // 여기서 남긴 문서를 그대로 읽는다. 그래서 이 테스트는 상태를 지우지 않는다. }); }); + +describe('진행 상태 — 일시 정지 · 이어하기 · 진행완료 (fn-quest-progress · 원장 C-83)', () => { + const ref = () => db().doc(`users/${UID}/questProgress/${STEPS_QUEST}`); + + it('진행중 → 진행완료를 남기고, 일일 한도 · 완료 횟수는 건드리지 않는다', async () => { + await call(fnQuestProgress, { questId: STEPS_QUEST, state: 'inProgress', total: 1000 }); + expect((await ref().get()).get('session.state')).toBe('inProgress'); + await call(fnQuestProgress, { + questId: STEPS_QUEST, state: 'paused', progress: 3, total: 60, positionMs: 3000, hits: [0, 0.1, -1], + }); + await call(fnQuestProgress, { questId: STEPS_QUEST, state: 'completed', progress: 1200, total: 1000 }); + const snap = await ref().get(); + // 세션은 통째로 바뀐다 — 일시 정지의 판정 기록이 남지 않는다. + expect(snap.get('session')).toMatchObject({ state: 'completed', progress: 1200, total: 1000 }); + expect(snap.get('session.hits')).toBeUndefined(); + expect(snap.get('count')).toBeUndefined(); + expect(snap.get('dayKey')).toBeUndefined(); + }); + + it('보상을 받으면 세션이 지워지고, 중단(none)도 세션만 지운다', async () => { + await call(fnQuestProgress, { questId: STEPS_QUEST, state: 'completed', progress: 1200, total: 1000 }); + await call(fnQuestVerifySteps, { questId: STEPS_QUEST, steps: 1200 }); + expect((await ref().get()).get('session')).toBeUndefined(); + expect((await ref().get()).get('count')).toBe(1); + + await call(fnQuestProgress, { questId: STEPS_QUEST, state: 'inProgress' }); + await call(fnQuestProgress, { questId: STEPS_QUEST, state: 'none' }); + expect((await ref().get()).get('session')).toBeUndefined(); + expect((await ref().get()).get('count')).toBe(1); + }); + + it('진행 단계가 없는 유형 · 없는 퀘스트 · 이상한 값은 거부한다(Q-B-12 A)', async () => { + expect(await codeOf(() => call(fnQuestProgress, { questId: ATTEND_QUEST, state: 'inProgress' }))).toBe('E-QST-01'); + expect(await codeOf(() => call(fnQuestProgress, { questId: 'no-such-quest', state: 'paused' }))).toBe('E-QST-01'); + expect(await codeOf(() => call(fnQuestProgress, { questId: STEPS_QUEST, state: 'rewarded' }))).toBe('E-QST-02'); + expect(await codeOf(() => call(fnQuestProgress, { questId: STEPS_QUEST, state: 'paused', progress: -1 }))).toBe('E-QST-02'); + expect(await codeOf(() => call(fnQuestProgress, { questId: STEPS_QUEST, state: 'paused', hits: [2] }))).toBe('E-QST-02'); + expect((await db().doc(`users/${UID}/questProgress/${ATTEND_QUEST}`).get()).exists).toBe(false); + }); +}); diff --git a/app/firebase/test/tests/firestore.rules.test.mjs b/app/firebase/test/tests/firestore.rules.test.mjs index e441bd2..74cb44d 100644 --- a/app/firebase/test/tests/firestore.rules.test.mjs +++ b/app/firebase/test/tests/firestore.rules.test.mjs @@ -1784,3 +1784,24 @@ describe('성운 일 1회 기록 — GALAXY 「성운 생성」 버튼(Figma 540 await assertFails(setDoc(doc(dbFor('alice'), 'users/alice/nebulaCreates/2026-10-08'), { fandomId: 'f2' })); }); }); + +describe('Quest 진행 세션 — 일시 정지 · 진행완료(원장 C-83 · fn-quest-progress 전용)', () => { + test('본인도 진행 세션을 직접 쓰거나 지울 수 없다 — 함수로만', async () => { + await seedDocuments(testEnvironment, { + 'users/alice/questProgress/shake': { session: { state: 'paused', progress: 43, total: 60 } }, + }); + const db = dbFor('alice'); + await assertFails(setDoc(doc(db, 'users/alice/questProgress/shake'), { session: { state: 'completed' } }, { merge: true })); + await assertFails(setDoc(doc(db, 'users/alice/questProgress/new'), { session: { state: 'completed', judgement: { perfect: 60 } } })); + await assertFails(deleteDoc(doc(db, 'users/alice/questProgress/shake'))); + }); + + test('본인은 일시 정지 Quest 를 조회할 수 있고(탭 위 캡슐) 다른 사용자는 못 본다', async () => { + await seedDocuments(testEnvironment, { + 'users/alice/questProgress/shake': { session: { state: 'paused', progress: 43, total: 60 } }, + }); + const paused = (db) => getDocs(query(collection(db, 'users/alice/questProgress'), where('session.state', '==', 'paused'))); + await assertSucceeds(paused(dbFor('alice'))); + await assertFails(paused(dbFor('bob'))); + }); +}); diff --git a/app/lib/core/router/app_router.dart b/app/lib/core/router/app_router.dart index 3ea7d2f..aa6e9e2 100644 --- a/app/lib/core/router/app_router.dart +++ b/app/lib/core/router/app_router.dart @@ -14,6 +14,8 @@ import '../../features/home/coach_mark.dart'; import '../../features/home/home_screen.dart'; import '../../features/inside/inside_screen.dart'; import '../../features/my/my_screen.dart'; +import '../../features/quest/quest_data.dart' show QuestRepository; +import '../../features/quest/widgets/paused_quest_capsule.dart'; import '../../features/station/station_screen.dart'; /// 하단 탭 하나. @@ -149,7 +151,8 @@ GoRouter buildRouter(AuthRepository auth, {Repositories? repositories}) { builder: (_, _) => SignupCompleteScreen(onStart: auth.finishSignup), ), StatefulShellRoute.indexedStack( - builder: (_, _, shell) => AppShell(shell: shell, flags: repos.flags), + builder: (_, _, shell) => + AppShell(shell: shell, flags: repos.flags, quests: repos.quest), branches: [ StatefulShellBranch( observers: [HomeRefreshObserver()], @@ -238,11 +241,19 @@ GoRouter buildRouter(AuthRepository auth, {Repositories? repositories}) { /// 사라져 딥링크·기존 경로가 죽고, 플래그를 켤 때 재배포가 필요해진다. 여기서는 /// 하단 바에 그리는 목적지만 줄이고, 눌린 위치를 원래 브랜치 번호로 되돌린다. class AppShell extends StatelessWidget { - const AppShell({super.key, required this.shell, required this.flags}); + const AppShell({ + super.key, + required this.shell, + required this.flags, + this.quests, + }); final StatefulNavigationShell shell; final FeatureFlags flags; + /// 일시 정지 Quest 캡슐(원장 C-83)을 읽는 저장소. null 이면 캡슐이 없다. + final QuestRepository? quests; + @override Widget build(BuildContext context) => ListenableBuilder( listenable: flags, @@ -254,43 +265,50 @@ class AppShell extends StatelessWidget { return Scaffold( body: shell, // Figma 탭 줄은 좌우 16 을 비우고 칸을 나눈다(350:215853). - bottomNavigationBar: ColoredBox( - color: AppColors.cosmicVoid, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: NavigationBar( - // 숨긴 탭에 머무는 동안(딥링크 등)에는 선택 표시를 첫 탭으로 둔다. - selectedIndex: selected < 0 ? 0 : selected, - onDestinationSelected: (i) { - // 다른 탭에서 HOME 으로 돌아오면 다시 읽는다(HOME 은 탭 전환으로 다시 만들어지지 않는다). - if (branches[i] == 0 && shell.currentIndex != 0) - homeRefreshSignal.value++; - final again = branches[i] == shell.currentIndex; - shell.goBranch( - branches[i], - // initialLocation: true 를 주면 이미 열려 있는 탭을 다시 누를 때 루트로 되돌아간다. - initialLocation: again, - ); - // 설명 프레임 `2097:16427` 「HOME 화면에서 클릭 시, Y스크롤값 초기화」(INSIDE · GALAXY · - // MY 동일) — 열려 있는 탭을 다시 누르면 그 탭 루트 화면을 맨 위로 올린다. - if (again) { - scrollTabToTop( - shell.route.branches[branches[i]].navigatorKey, - ); - } - }, - destinations: [ - for (final tab in tabs) - NavigationDestination( - // Coach Mark ② 가 MY 위치를 가리킨다(coach_mark.dart). - icon: tab.path == appTabs.last.path - ? KeyedSubtree(key: coachMyTabKey, child: tab.icon) - : tab.icon, - label: tab.label, - ), - ], + // 그 바로 위에 일시 정지 Quest 캡슐 한 줄(원장 C-83) — 없으면 높이 0. + bottomNavigationBar: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (quests case final repo?) PausedQuestCapsule(repository: repo), + ColoredBox( + color: AppColors.cosmicVoid, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: NavigationBar( + // 숨긴 탭에 머무는 동안(딥링크 등)에는 선택 표시를 첫 탭으로 둔다. + selectedIndex: selected < 0 ? 0 : selected, + onDestinationSelected: (i) { + // 다른 탭에서 HOME 으로 돌아오면 다시 읽는다(HOME 은 탭 전환으로 다시 만들어지지 않는다). + if (branches[i] == 0 && shell.currentIndex != 0) + homeRefreshSignal.value++; + final again = branches[i] == shell.currentIndex; + shell.goBranch( + branches[i], + // initialLocation: true 를 주면 이미 열려 있는 탭을 다시 누를 때 루트로 되돌아간다. + initialLocation: again, + ); + // 설명 프레임 `2097:16427` 「HOME 화면에서 클릭 시, Y스크롤값 초기화」(INSIDE · GALAXY · + // MY 동일) — 열려 있는 탭을 다시 누르면 그 탭 루트 화면을 맨 위로 올린다. + if (again) { + scrollTabToTop( + shell.route.branches[branches[i]].navigatorKey, + ); + } + }, + destinations: [ + for (final tab in tabs) + NavigationDestination( + // Coach Mark ② 가 MY 위치를 가리킨다(coach_mark.dart). + icon: tab.path == appTabs.last.path + ? KeyedSubtree(key: coachMyTabKey, child: tab.icon) + : tab.icon, + label: tab.label, + ), + ], + ), + ), ), - ), + ], ), ); }, diff --git a/app/lib/features/quest/music_shake_play_screen.dart b/app/lib/features/quest/music_shake_play_screen.dart index 77a91e5..84171d0 100644 --- a/app/lib/features/quest/music_shake_play_screen.dart +++ b/app/lib/features/quest/music_shake_play_screen.dart @@ -21,7 +21,8 @@ typedef ShakePlayerEvent = ({bool playing, Duration position, bool ended}); /// 공식 YouTube 영상(퀘스트의 지정 링크 · PRD-04 MVP 수용 기준)을 IFrame 플레이어로 재생하고, /// 재생되는 동안의 흔들기를 **영상 시각**으로 찍어 박마다 Perfect · Good · Miss · Combo 를 /// 띄운다. 판정 창은 운영값(🟡 임시값 — 회신 대기). 마지막 박의 창이 닫히거나 영상이 끝나면 -/// 판정표를 돌려준다. 중간에 나가면 null — 판정표를 남기지 않는다. +/// 판정표를 돌려준다. 중간에 나가면 null 이다 — 호출자는 넘긴 [judge] 의 판정 · 영상 시각을 +/// 일시 정지 세션으로 남기고, 「이어하기」 때 그 판정표를 다시 넘겨 멈춘 자리부터 잇는다(원장 C-83). /// /// Figma 에 이 진행 화면은 없다(상세 `480:14506` 은 「진행중」 버튼까지). 공통 기준 5항 — /// 기존 범위 기능의 정상 구현에 필요한 화면이다. 상세의 시각 문법(#141414 · #1a1a1c r24)을 따른다. @@ -31,9 +32,13 @@ class MusicShakePlayScreen extends StatefulWidget { required this.setup, required this.shakes, this.player, + this.judge, }); final MusicShakeSetup setup; + + /// 이 판의 판정표. 넘기지 않으면 새 판이다. 「이어하기」는 멈춘 판의 판정표를 넘긴다. + final ShakeJudge? judge; final Stream shakes; /// 영상 플레이어. 기본은 [YoutubeEmbed]. 테스트는 상태를 직접 넣는 가짜를 준다. @@ -44,17 +49,19 @@ class MusicShakePlayScreen extends StatefulWidget { } class _MusicShakePlayScreenState extends State { - late final ShakeJudge _judge = ShakeJudge( - bpm: widget.setup.bpm, - segments: widget.setup.segments, - windows: widget.setup.windows, - ); + late final ShakeJudge _judge = + widget.judge ?? + ShakeJudge( + bpm: widget.setup.bpm, + segments: widget.setup.segments, + windows: widget.setup.windows, + ); StreamSubscription? _sub; Timer? _tick; /// 영상 시계 — 마지막으로 받은 플레이어 시각과 그때의 단말 시각. bool _playing = false; - Duration _anchorMedia = Duration.zero; + late Duration _anchorMedia = _judge.position; DateTime _anchorWall = DateTime.now(); int _lastJudged = 0; bool _done = false; @@ -84,6 +91,7 @@ class _MusicShakePlayScreenState extends State { if (_playing != e.playing && mounted) setState(() {}); _playing = e.playing; _anchorMedia = e.position; + _judge.position = e.position; _anchorWall = DateTime.now(); if (e.ended) _finish(); } @@ -188,8 +196,11 @@ class _MusicShakePlayScreenState extends State { ); } - Widget _defaultPlayer(ValueChanged onEvent) => - YoutubeEmbed(videoId: widget.setup.videoId, onEvent: onEvent); + Widget _defaultPlayer(ValueChanged onEvent) => YoutubeEmbed( + videoId: widget.setup.videoId, + onEvent: onEvent, + start: _judge.position, + ); } /// Perfect · Good · Miss · 최대 콤보 네 칸 — 진행 화면과 결과 카드가 같이 쓴다. @@ -258,9 +269,17 @@ class ShakeTally extends StatelessWidget { /// - 앱 식별: `baseUrl` 을 스토어 앱 ID 의 https URL 로 둔다(같은 문서 「Referer for mobile apps」). /// - iOS 는 인라인 재생을 켜야 전체 화면으로 빠지지 않고 판정 화면이 보인다. class YoutubeEmbed extends StatefulWidget { - const YoutubeEmbed({super.key, required this.videoId, required this.onEvent}); + const YoutubeEmbed({ + super.key, + required this.videoId, + required this.onEvent, + this.start = Duration.zero, + }); final String videoId; + + /// 재생 시작 시각 — 「이어하기」는 멈춘 자리(초 단위 내림)부터 튼다. + final Duration start; final ValueChanged onEvent; /// 스토어 앱 ID(Android applicationId · iOS bundle ID 가 같다). @@ -273,7 +292,7 @@ class YoutubeEmbed extends StatefulWidget { !Platform.environment.containsKey('FLUTTER_TEST'); @visibleForTesting - static String html(String videoId) => + static String html(String videoId, {int startSeconds = 0}) => ''' @@ -282,7 +301,7 @@ 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'}, + playerVars:{playsinline:1,rel:0,start:$startSeconds,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); @@ -309,7 +328,7 @@ class _YoutubeEmbedState extends State { ..setBackgroundColor(Colors.black) ..addJavaScriptChannel('Player', onMessageReceived: _onMessage) ..loadHtmlString( - YoutubeEmbed.html(widget.videoId), + YoutubeEmbed.html(widget.videoId, startSeconds: widget.start.inSeconds), baseUrl: YoutubeEmbed.appOrigin, ); } diff --git a/app/lib/features/quest/quest_data.dart b/app/lib/features/quest/quest_data.dart index d26f5cd..852daed 100644 --- a/app/lib/features/quest/quest_data.dart +++ b/app/lib/features/quest/quest_data.dart @@ -8,6 +8,8 @@ library; import 'dart:typed_data' show Uint8List; +import 'package:flutter/foundation.dart' show ValueNotifier; + import '../../core/data/backend.dart' show BackendException; import '../../core/monetization/rewarded_ad.dart'; import '../../core/i18n/app_locale.dart'; @@ -28,6 +30,10 @@ enum QuestStage { mine, notStarted, inProgress, + + /// 화면 안에서 진행하는 Quest(Music Shake)를 진행 화면 밖으로 나와 멈춘 상태 — 원장 C-83(10/3 통합회신 + /// Q-OB-01 A): 진행 상태 값 「일시 정지」 · 버튼 「이어하기」. Walk · 출석은 이 상태가 되지 않는다. + paused, completed, rewarded; @@ -36,6 +42,7 @@ enum QuestStage { mine => appL10n.questDetails, notStarted => appL10n.questStart, inProgress => appL10n.questInProgress, + paused => appL10n.questResume, completed => appL10n.questClaim, rewarded => appL10n.questRewardDone, }; @@ -335,6 +342,42 @@ bool questFull(QuestCard card) => card.capacity! > 0 && card.participants >= card.capacity!; +/// 진행 세션 — `users/{uid}/questProgress/{questId}.session`(`fn-quest-progress` 만 쓴다 · 원장 C-83). +/// +/// **표시용 상태다.** 화면을 다시 열거나 앱을 다시 켜도 「진행중」 탭 · 「이어하기」 · 목록 「보상받기」가 +/// 남게 한다. 지급 판정은 여전히 검증 함수가 증거로 한다. +class QuestSession { + const QuestSession({ + required this.state, + this.progress = 0, + this.total = 0, + this.position = Duration.zero, + this.hits = const [], + this.judgement, + }); + + /// [QuestStage.inProgress] · [QuestStage.paused] · [QuestStage.completed] 중 하나. + final QuestStage state; + + /// 실행한 값 — 걸음 수 · Music Shake 판정이 끝난 박 수. + final int progress; + + /// 목표 — 걸음 목표 · Music Shake 박 수. + final int total; + + /// Music Shake 를 멈춘 영상 시각 — 「이어하기」 시작점. + final Duration position; + + /// Music Shake 박마다 가장 가까운 흔들기의 거리(박 비율). 흔들기가 없으면 -1. + final List hits; + + /// 끝까지 한 Music Shake 판의 판정 합계 — 목록에서 바로 「보상받기」할 때 서버에 보낸다. + final Map? judgement; +} + +/// 일시 정지 · 진행 세션이 바뀌었다 — 탭 화면 하단 캡슐(원장 C-83)이 다시 읽는다. +final questSessionSignal = ValueNotifier(0); + /// 목록 카드 하나. class QuestCard { const QuestCard({ @@ -354,12 +397,16 @@ class QuestCard { this.endsAt, this.publishState = '', this.authorName = '', + this.session, }); final String id; final String title; final String categoryName; + /// 서버에 남은 진행 세션(진행중 · 일시 정지 · 진행완료) — 원장 C-83. 없으면 null. + final QuestSession? session; + /// 만든 사람의 공개 닉네임(`quests.authorName`). 운영 Quest 는 빈 값이다. /// 신고하기 「신고 정보 영역 — 작성자 닉네임」(Figma `548:21389`)에 쓴다. final String authorName; @@ -409,6 +456,7 @@ class QuestCard { /// 상태·진행도만 바꾼 사본. [QuestCard] 는 불변이라 갈아 끼운다. QuestCard copyWith({QuestStage? stage, int? progress}) => QuestCard( + session: session, id: id, title: title, categoryName: categoryName, @@ -536,8 +584,11 @@ enum QuestTab { QuestTab.all => true, // 명세 `518:88405` — *진행중 - 진행중 탭 / 진행완료 - 진행중 탭 / 보상완료 - 완료 탭*. // 보상을 받기 전(진행완료)까지는 진행중 탭에 두고, 받은 뒤에 완료 탭으로 옮긴다. + // 일시 정지도 진행 중이다(원장 C-83 — 진행 상태 값에 「일시 정지」를 더함). QuestTab.inProgress => - card.stage == QuestStage.inProgress || card.stage == QuestStage.completed, + card.stage == QuestStage.inProgress || + card.stage == QuestStage.paused || + card.stage == QuestStage.completed, QuestTab.done => card.stage == QuestStage.rewarded, QuestTab.mine => card.stage == QuestStage.mine, }; @@ -630,7 +681,17 @@ abstract interface class QuestRepository { Future cancel(String questId); /// 상태 전이. 서버가 확정한 상태를 돌려준다. - Future advance(String questId, QuestStage next); + /// + /// 실행률을 모으는 Quest(걸음 · Music Shake)는 진행 세션을 서버에 남긴다(`fn-quest-progress` · C-83). + /// [session] 은 그 진행값이다. [QuestStage.notStarted] 는 중단(세션 삭제)이다. + Future advance( + String questId, + QuestStage next, { + QuestSession? session, + }); + + /// 가장 최근에 일시 정지한 Quest 1건 — 탭 화면 하단 캡슐(원장 C-83 · QA 3-34). 없으면 null. + Future pausedQuest(); Future create(QuestDraft draft); @@ -881,7 +942,14 @@ class SampleQuestRepository implements QuestRepository { Future cancel(String questId) async {} @override - Future advance(String questId, QuestStage next) async => next; + Future advance( + String questId, + QuestStage next, { + QuestSession? session, + }) async => next; + + @override + Future pausedQuest() async => null; @override Future create(QuestDraft draft) async => 'q-new'; diff --git a/app/lib/features/quest/quest_detail_screen.dart b/app/lib/features/quest/quest_detail_screen.dart index 5c56901..d0cf9b1 100644 --- a/app/lib/features/quest/quest_detail_screen.dart +++ b/app/lib/features/quest/quest_detail_screen.dart @@ -43,10 +43,15 @@ class QuestDetailScreen extends StatefulWidget { super.key, required this.questId, required this.repository, + this.autoAct = false, }); final String questId; + /// 목록 카드 버튼으로 들어왔다 — 상세를 읽자마자 그 버튼 동작(보상받기 · 이어하기 · 참여)을 한다. + /// 명세 `518:88405` *보상 받기 버튼 클릭 시 POP 수령*(목록에서 바로). + final bool autoAct; + /// 저장소는 호출부가 준다 — 화면이 표본을 기본값으로 만들지 않는다. final QuestRepository repository; @@ -69,6 +74,12 @@ class _QuestDetailScreenState extends State { /// 방금 끝낸 Music Shake 판의 판정 합계 — 결과 화면 · 결과 카드에 쓴다. Map? _shakeResult; + /// 진행 세션(원장 C-83) — 서버에서 읽은 값을 이 화면에서 멈추면 바로 갈아 끼운다(「이어하기」 시작점). + QuestSession? _session; + + /// [QuestDetailScreen.autoAct] 를 한 번만 쓴다. + bool _autoActed = false; + @override void initState() { super.initState(); @@ -94,12 +105,35 @@ class _QuestDetailScreenState extends State { setState(() { _detail = detail; _stage = detail.card.stage; + _session = detail.card.session; }); // 목록에서 「시작하기」를 눌러 진행중으로 들어온 경우다. 여기서 다시 켜지 않으면 // 상세를 열어도 실행률이 0 에 멈춘 채로 남는다. if (_stage == QuestStage.inProgress && detail.card.type.gathers) { _watch(detail.card); } + if (widget.autoAct && !_autoActed) { + _autoActed = true; + final stage = _stage!; + if (stage == QuestStage.completed || + stage == QuestStage.paused || + stage == QuestStage.notStarted) { + await _act(detail, stage); + } + } + } + + /// 진행 세션을 서버에 남긴다(원장 C-83). 실패해도 이 화면의 진행은 계속된다 — 표시용 상태다. + Future _persist( + String questId, + QuestStage next, [ + QuestSession? session, + ]) async { + try { + await widget.repository.advance(questId, next, session: session); + } catch (error) { + debugPrint('quest session save failed: $error'); + } } @override @@ -231,6 +265,15 @@ class _QuestDetailScreenState extends State { value: '${formatPop(_live ?? card.progress)} / ${formatPop(card.goal)}', ), + // 일시 정지 — 멈춘 시점의 진행값을 그대로 둔다(원장 C-83 · 해석 확인 요청서 Q-OB-01 A 5절). + if (stage == QuestStage.paused && _session != null) ...[ + const SizedBox(height: 8), + _Row( + label: context.l10n.questPaused, + value: + '${formatPop(_session!.progress)} / ${formatPop(_session!.total)}', + ), + ], const SizedBox(height: 8), _Row( label: context.l10n.questReward, @@ -769,6 +812,8 @@ class _QuestDetailScreenState extends State { } case QuestStage.inProgress: await _confirmStop(detail); + case QuestStage.paused: + await _playShake(detail, resume: _session); case QuestStage.completed: await _claim(detail); case QuestStage.mine: @@ -780,21 +825,23 @@ class _QuestDetailScreenState extends State { /// 「시작하기」 — 진행중으로 바꾸고 실측 수집을 켠다. Future _start(QuestDetail detail) async { - final next = await widget.repository.advance( - detail.card.id, - QuestStage.inProgress, - ); - if (!mounted) return; setState(() { - _stage = next; + _stage = QuestStage.inProgress; _live = 0; }); - _watch(detail.card); + // 진행중을 먼저 남긴다 — 곧바로 목표에 닿으면 진행완료 저장이 뒤에 와야 한다. + await _persist( + detail.card.id, + QuestStage.inProgress, + QuestSession(state: QuestStage.inProgress, total: detail.card.goal), + ); + if (mounted) _watch(detail.card); } /// Music Shake 한 판 — 공식 영상을 보며 흔드는 진행 화면을 띄운다(PRD-04). - /// 끝까지 했으면 판정표를 남기고 「보상받기」로, 중간에 나왔으면 시작 전으로 돌린다. - Future _playShake(QuestDetail detail) async { + /// 끝까지 했으면 판정표를 남기고 「보상받기」로, 중간에 나왔으면 **일시 정지**로 둔다(원장 C-83). + /// [resume] 이 있으면 멈춘 판정 · 영상 시각부터 잇는다(「이어하기」). + Future _playShake(QuestDetail detail, {QuestSession? resume}) async { final MusicShakeSetup setup; try { setup = await widget.repository.musicShakeSetup(detail.card.id); @@ -803,22 +850,59 @@ class _QuestDetailScreenState extends State { return; } if (!mounted) return; + final judge = ShakeJudge( + bpm: setup.bpm, + segments: setup.segments, + windows: setup.windows, + hits: resume?.hits ?? const [], + position: resume?.position ?? Duration.zero, + ); + QuestSession snapshot(QuestStage state) => QuestSession( + state: state, + progress: judge.live(judge.position).judged, + total: setup.segments, + position: judge.position, + hits: judge.hits, + ); setState(() => _stage = QuestStage.inProgress); + // 진행 중 앱이 꺼져도 다음에 일시 정지(멈춘 자리)로 보이게 시작 시점을 남긴다. + final started = _persist( + detail.card.id, + QuestStage.inProgress, + snapshot(QuestStage.inProgress), + ); final run = await Navigator.of(context, rootNavigator: true) .push( MaterialPageRoute( builder: (_) => MusicShakePlayScreen( setup: setup, shakes: widget.repository.shakeEvents(), + judge: judge, ), ), ); + // 시작 저장이 일시 정지 · 진행완료 저장보다 늦게 닿지 않게 기다린다. + await started; if (!mounted) return; if (run != null) widget.repository.recordShake(run); + final paused = snapshot(QuestStage.paused); setState(() { _shakeResult = run?.judgement(); - _stage = run == null ? QuestStage.notStarted : QuestStage.completed; + _stage = run == null ? QuestStage.paused : QuestStage.completed; + _session = run == null ? paused : null; }); + await _persist( + detail.card.id, + _stage!, + run == null + ? paused + : QuestSession( + state: QuestStage.completed, + progress: setup.segments, + total: setup.segments, + judgement: run.judgement(), + ), + ); } /// 실시간 목표 실행률. 목표를 채우면 버튼이 「보상받기」로 바뀐다. @@ -832,12 +916,30 @@ class _QuestDetailScreenState extends State { .listen( (value) { if (!mounted) return; + final reached = + card.goal > 0 && + value >= card.goal && + _stage != QuestStage.completed; setState(() { _live = value; if (card.goal > 0 && value >= card.goal) { _stage = QuestStage.completed; } }); + // 진행완료를 남긴다 — 목록 「보상받기」 · 진행중 탭이 다시 읽어도 같게(원장 C-83). + if (reached) { + unawaited( + _persist( + card.id, + QuestStage.completed, + QuestSession( + state: QuestStage.completed, + progress: value, + total: card.goal, + ), + ), + ); + } }, onError: (Object error) { // 권한 거부·센서 부재. 진행 상태를 시작 전으로 되돌려야 사용자가 다시 @@ -863,16 +965,13 @@ class _QuestDetailScreenState extends State { ); if (!stop) return; _stopWatching(); - final next = await widget.repository.advance( - detail.card.id, - QuestStage.notStarted, - ); if (mounted) { setState(() { - _stage = next; + _stage = QuestStage.notStarted; _live = null; }); } + await _persist(detail.card.id, QuestStage.notStarted); } /// QR 스캔 · 인증 사진 — Quest 가 고른 수단만. 하나라도 취소하면 null. @@ -933,6 +1032,10 @@ class _QuestDetailScreenState extends State { _stage = QuestStage.notStarted; }); _fail(error); + // 남겨 둔 진행완료 세션도 지운다 — 남기면 목록에 같은 판의 「보상받기」가 계속 뜬다. + if (detail.card.type == QuestType.musicShake) { + await _persist(detail.card.id, QuestStage.notStarted); + } // 타임어택 창 밖(E-QST-01) — 서버가 본 상태로 다시 읽어 마감 · 완료를 반영한다. if (error is BackendException && error.code == 'E-QST-01' && diff --git a/app/lib/features/quest/quest_evidence.dart b/app/lib/features/quest/quest_evidence.dart index 01be83d..ffdb199 100644 --- a/app/lib/features/quest/quest_evidence.dart +++ b/app/lib/features/quest/quest_evidence.dart @@ -159,15 +159,28 @@ class ShakeJudge { required this.bpm, required this.segments, this.windows = const ShakeWindows(), - }) : assert(bpm > 0 && segments > 0); + List hits = const [], + this.position = Duration.zero, + }) : assert(bpm > 0 && segments > 0) { + // 일시 정지에서 「이어하기」 — 멈추기 전 판정을 그대로 잇는다(원장 C-83). + for (var i = 0; i < hits.length && i < segments; i++) { + if (hits[i] >= 0) _best[i] = hits[i]; + } + } final double bpm; final int segments; final ShakeWindows windows; + /// 마지막으로 본 영상 시각 — 진행 화면을 나가면 「이어하기」의 시작점이 된다. + Duration position; + /// 박 번호 → 가장 가까운 흔들기의 거리(박 비율). final _best = {}; + /// 박마다 [_best] — 흔들기가 없으면 -1. 일시 정지 세션에 남긴다. + List get hits => [for (var i = 0; i < segments; i++) _best[i] ?? -1]; + Duration get beat => Duration(microseconds: (60e6 / bpm).round()); /// 마지막 박의 판정 창이 닫히는 영상 시각. diff --git a/app/lib/features/quest/quest_list_screen.dart b/app/lib/features/quest/quest_list_screen.dart index 1dfaffd..362dcc8 100644 --- a/app/lib/features/quest/quest_list_screen.dart +++ b/app/lib/features/quest/quest_list_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../../core/data/backend.dart' show BackendException; import '../../core/theme/tokens.dart'; import '../home/home_screen.dart' show formatPop; import '../planet/moderation.dart'; @@ -127,12 +128,14 @@ class _QuestListScreenState extends State { /// 줄로, 사용자 퀘스트를 아래 카드로 나눈다. 다른 탭은 카드만 있다. bool get _split => _tab == QuestTab.all; - Future _open(QuestCard card) async { + /// 상세로 간다. [act] 면 상세가 열리자마자 카드 버튼 동작(보상받기 · 이어하기 · 참여)을 한다. + Future _open(QuestCard card, {bool act = false}) async { await Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( builder: (_) => QuestDetailScreen( questId: card.id, repository: widget.repository, + autoAct: act, ), ), ); @@ -140,9 +143,14 @@ class _QuestListScreenState extends State { } Future _act(QuestCard card) async { + // 명세 `518:88405` *보상 받기 버튼 클릭 시 POP 수령* — 목록에서 바로 받는다. 일시 정지는 「이어하기」 + // (원장 C-83). 판정 · 광고 모달 · 보상 완료 화면은 상세의 같은 경로를 쓴다. + if (card.stage == QuestStage.completed || card.stage == QuestStage.paused) { + await _open(card, act: true); + return; + } // 사용자 생성 Quest 는 상세에서 링크 · 마감 · 참여 규칙을 본 뒤 참여한다(C-05 · C-06 · C-12). - if (card.stage == QuestStage.mine || card.stage == QuestStage.completed || - card.author == QuestAuthor.user) { + if (card.stage == QuestStage.mine || card.author == QuestAuthor.user) { await _open(card); return; } @@ -159,7 +167,29 @@ class _QuestListScreenState extends State { )); return; } - final next = await widget.repository.advance(card.id, QuestStage.inProgress); + // 진행중을 거치는 것은 실행률을 모으는 Quest(걸음)뿐이다 — 나머지는 누르는 순간 판정이라 + // 상세의 같은 참여 경로로 보낸다(해석 확인 요청서 영역B Q-B-12 A · Music Shake 는 진행 화면). + if (!(card.goal > 1 && card.type.gathers)) { + await _open(card, act: true); + return; + } + final QuestStage next; + try { + next = await widget.repository.advance( + card.id, + QuestStage.inProgress, + session: QuestSession(state: QuestStage.inProgress, total: card.goal), + ); + } on Exception catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar( + content: Text(error is BackendException ? error.message : context.l10n.commonRequestFailed), + behavior: SnackBarBehavior.floating, + )); + return; + } if (!mounted) return; setState(() { _cards = [ diff --git a/app/lib/features/quest/quest_repository_firestore.dart b/app/lib/features/quest/quest_repository_firestore.dart index 0d9865e..337c097 100644 --- a/app/lib/features/quest/quest_repository_firestore.dart +++ b/app/lib/features/quest/quest_repository_firestore.dart @@ -86,12 +86,13 @@ class FirestoreQuestRepository implements QuestRepository { final uid = _backend.requireUid; final db = _backend.db; - // 정렬하지 않는다. 설계서 §2.8 의 `quests` 에 정렬 키가 없고, Admin 이 쓰는 - // 문서에는 `createdAt` 이 없다(`fn-admin-write` 는 `updatedAt` 만 찍는다). - // `orderBy('createdAt')` 로 두면 그 문서들이 목록에서 조용히 사라진다. - // 커서는 기본 순서(문서 ID)를 따른다. 커서를 limit 보다 먼저 건다 — 서버는 순서를 가리지 않지만 - // 테스트용 fake 는 적은 순서대로 적용한다. - Query> query = db.collection('quests'); + // 명세 `518:88395` · `518:88405` *퀘스트 생성 최신순 소팅* — 서버 정렬 + 커서 페이지. + // `createdAt` 이 없는 문서는 이 쿼리에서 빠진다. 새 문서는 `fn-admin-write` · `fn-quest-create` 가 + // 남기고, 예전 문서는 `tools/demo/migrate_v040.mjs quest-created-at` 로 채운다(배포 전 필수). + // 커서를 limit 보다 먼저 건다 — 서버는 순서를 가리지 않지만 테스트용 fake 는 적은 순서대로 적용한다. + Query> query = db + .collection('quests') + .orderBy('createdAt', descending: true); final cursor = _cursor; if (cursor != null) query = query.startAfterDocument(cursor); query = query.limit(limit); @@ -106,20 +107,9 @@ class FirestoreQuestRepository implements QuestRepository { } final now = ServerClock.now(); final policy = await _policy(); - // 명세 `518:88395` 의 *퀘스트 생성 최신순* 은 페이지 안에서만 맞춘다. 서버 정렬은 - // 걸지 않았다 — `quests` 에 정렬 키가 정해져 있지 않고, `orderBy` 를 걸면 그 필드가 - // 없는 문서가 목록에서 통째로 빠진다. - final docs = [...snap.docs]..sort((a, b) { - final left = _writtenAt(a.data()); - final right = _writtenAt(b.data()); - if (left == null && right == null) return 0; - if (left == null) return 1; - if (right == null) return -1; - return right.compareTo(left); - }); final cards = []; - for (final doc in docs) { + for (final doc in snap.docs) { final quest = doc.data(); if (!_open(quest, now, uid)) continue; // 카드 한 장의 부가 조회가 실패해도 목록 전체를 깨지 않는다 — 그 카드만 시작 전으로 둔다. @@ -133,7 +123,15 @@ class FirestoreQuestRepository implements QuestRepository { } on Exception { progress = const {}; } - final card = _card(doc.id, quest, progress, uid, policy, await _submission(doc.id, quest)); + final card = _card( + doc.id, + quest, + progress, + uid, + policy, + await _submission(doc.id, quest), + await _walkFor(quest, progress), + ); // 명세 `523:18176` 퀘스트 종료 시점 1 — *참여 인원 충족 시 퀘스트 종료*. 서버는 정원이 차면 // 참여를 거부하므로(미배정 Pool 0), 아직 참여하지 않은 사람에게는 열린 퀘스트로 보이지 않게 한다. // 이미 참여한 사람(진행중 · 완료 탭)과 생성자(내 퀘스트)에게는 그대로 남긴다. @@ -143,14 +141,45 @@ class FirestoreQuestRepository implements QuestRepository { return cards; } - /// 문서가 쓰인 시각. Admin 이 쓴 문서에는 `createdAt` 이 없고 `updatedAt` 만 있다 - /// (`fn-admin-write`). 둘 다 없으면 정렬 근거가 없다는 뜻이라 null 이다. - DateTime? _writtenAt(Map quest) { - for (final key in ['createdAt', 'updatedAt']) { - final value = quest[key]; - if (value is Timestamp) return value.toDate(); + /// 진행 세션이 있는 걸음 Quest 의 오늘 걸음 — Walk 는 화면 밖 걸음까지 **실제 상태**로 보인다(원장 C-62 · C-83). + /// Health 를 한 번 읽고, 못 쓰면 이번 실행에서 센서로 센 값, 그것도 없으면 null(세션의 마지막 값). + Future _walkFor(Map quest, Map progress) async { + if (QuestType.parse(quest.strOr('type')) != QuestType.steps || progress['session'] is! Map) { + return null; } - return null; + try { + final health = await _todaySteps(); + if (health != null) return health; + } on Exception { + // 권한 없음 — 아래 값으로. + } + return _walkSteps > 0 ? _walkSteps : null; + } + + /// 진행 세션(`questProgress.session` — `fn-quest-progress` 만 쓴다 · 원장 C-83). + static QuestSession? _sessionOf(Map progress) { + final raw = progress['session']; + if (raw is! Map) return null; + final m = Map.from(raw); + final state = switch (m.strOr('state')) { + 'inProgress' => QuestStage.inProgress, + 'paused' => QuestStage.paused, + 'completed' => QuestStage.completed, + _ => null, + }; + if (state == null) return null; + final hits = m['hits']; + final judgement = m['judgement']; + return QuestSession( + state: state, + progress: m.intOr('progress'), + total: m.intOr('total'), + position: Duration(milliseconds: m.intOr('positionMs')), + hits: hits is List ? [for (final h in hits) if (h is num) h.toDouble()] : const [], + judgement: judgement is Map + ? {for (final e in judgement.entries) if (e.value is num) '${e.key}': (e.value as num).round()} + : null, + ); } /// 지금 참여할 수 있는 기간인가 — 설계서 §2.8 `activeFrom`/`activeTo`. @@ -185,6 +214,7 @@ class FirestoreQuestRepository implements QuestRepository { String uid, Map policy, [ QuestSubmission? submission, + int? walk, ]) { final type = QuestType.parse(quest.strOr('type')); final authorUid = quest.strOr('authorUid'); @@ -199,8 +229,13 @@ class FirestoreQuestRepository implements QuestRepository { : type == QuestType.musicShake ? quest.intOr('bpm') : dailyLimit; + final session = _sessionOf(progress); + final cardGoal = authorUid.isEmpty ? goal : quest.intOr('goal', 1); + // 걸음은 화면 밖 걸음까지 실제 값(C-62) — 오늘 걸음, 없으면 세션의 마지막 값. + final walked = steps && session != null ? walk ?? session.progress : null; return QuestCard( + session: session, id: id, title: questTitle(quest), categoryName: type.category, @@ -213,10 +248,20 @@ class FirestoreQuestRepository implements QuestRepository { // 검토 대기는 「진행중」 탭에 둔다 — 지급 전이라 완료가 아니다(G-03). : submission?.pending == true ? QuestStage.inProgress - : QuestStage.notStarted, - goal: authorUid.isEmpty ? goal : quest.intOr('goal', 1), + : session == null + ? QuestStage.notStarted + : walked != null + // Walk 는 일시 정지가 없다 — 목표에 닿았으면 진행완료, 아니면 진행중(C-83). + ? (cardGoal > 0 && walked >= cardGoal + ? QuestStage.completed + : QuestStage.inProgress) + // 화면 안에서 진행하는 Quest 가 진행 화면 밖에 있으면 일시 정지다(앱이 꺼진 경우 포함). + : session.state == QuestStage.completed + ? QuestStage.completed + : QuestStage.paused, + goal: cardGoal, // 걸음은 센서를 구독해야 알 수 있어 시작 전에는 0 이다. 나머지는 당일 수행 횟수다. - progress: type.gathers ? (done ? goal : 0) : count, + progress: type.gathers ? (done ? goal : walked ?? 0) : count, rewardPop: openTimeAttack(quest)?.rewardPop ?? type.rewardFrom(quest, policy), // 설계서 §2.8 의 `quests` 에 참여 인원 필드가 없다. 세는 곳이 없어 0 이다. participants: quest.intOr('participantCount'), @@ -249,7 +294,15 @@ class FirestoreQuestRepository implements QuestRepository { .get() .then((s) => s.data() ?? const {}); final submission = await _submission(questId, quest); - final card = _card(questId, quest, progress, uid, await _policy(), submission); + final card = _card( + questId, + quest, + progress, + uid, + await _policy(), + submission, + await _walkFor(quest, progress), + ); final link = quest['link']; final repeat = quest['repeat'] is Map ? Map.from(quest['repeat'] as Map) : null; final endsAt = quest['endsAt']; @@ -474,13 +527,64 @@ class FirestoreQuestRepository implements QuestRepository { 'description': description, }); - /// 시작·중단은 세션 상태다. + /// 진행중 · 일시 정지 · 진행완료 · 중단을 서버 진행 문서에 남긴다(`fn-quest-progress` · 원장 C-83). /// - /// 설계서의 `questProgress` 는 **당일 수행 횟수와 지급액**만 담는다(§2.1). 참여 - /// 시작 시각을 남기는 필드가 없어 서버에 「진행중」이 존재하지 않는다. 보상 상태는 - /// [verify] 가 지급에 성공한 뒤 서버 카운터로 확정된다. + /// 진행 문서는 규칙상 서버 전용이라 함수로만 쓴다. 보상 상태는 여전히 [verify] 가 지급에 성공한 뒤 + /// 서버 카운터로 확정되고, 지급되면 서버가 세션을 지운다. @override - Future advance(String questId, QuestStage next) async => next; + Future advance( + String questId, + QuestStage next, { + QuestSession? session, + }) async { + final state = switch (next) { + QuestStage.inProgress => 'inProgress', + QuestStage.paused => 'paused', + QuestStage.completed => 'completed', + _ => 'none', + }; + await _backend.call('fnQuestProgress', { + 'questId': questId, + 'state': state, + if (session != null && state != 'none') ...{ + 'progress': session.progress, + 'total': session.total, + 'positionMs': session.position.inMilliseconds, + if (session.hits.isNotEmpty) 'hits': session.hits, + 'judgement': ?session.judgement, + }, + }); + questSessionSignal.value++; + return next; + } + + @override + Future pausedQuest() async { + final uid = _backend.requireUid; + final db = _backend.db; + final snap = await db + .collection('users/$uid/questProgress') + .where('session.state', isEqualTo: 'paused') + .get(); + // 가장 최근 1건(QA 3-34). 한 사람이 동시에 멈춘 Quest 는 몇 건이라 단말에서 고른다. + final docs = [...snap.docs] + ..sort((a, b) { + DateTime at(Map d) { + final t = (d['session'] as Map?)?['updatedAt']; + return t is Timestamp ? t.toDate() : DateTime(0); + } + + return at(b.data()).compareTo(at(a.data())); + }); + final policy = await _policy(); + for (final doc in docs) { + final quest = (await db.doc('quests/${doc.id}').get()).data(); + if (quest == null || !_open(quest, ServerClock.now(), uid)) continue; + final card = _card(doc.id, quest, doc.data(), uid, policy); + if (card.stage == QuestStage.paused) return card; + } + return null; + } /// 생성 · 예치 · 수수료 · 공개는 `fn-quest-create` 가 원장과 함께 한다(B-28 · B-30). 앱은 /// `quests` 에 직접 쓰지 않는다(규칙도 마스터 전용). 종료일은 **생성자 기기 시간대의 그 날짜 @@ -643,6 +747,14 @@ class FirestoreQuestRepository implements QuestRepository { } Future> _verifySteps(QuestCard card) async { + if (_walkSteps <= 0) { + // 목록에서 바로 「보상받기」(진행 화면을 거치지 않음) — 걸음을 한 번 읽는다. + try { + await _steps().first.timeout(const Duration(seconds: 5)); + } on Exception { + // 아래에서 「세지 못함」으로 끊는다. + } + } if (_walkSteps <= 0) { // 센서 값이 아직 오지 않았다. 0 을 보내면 서버가 미완주로 판정하지만, // 원인이 「걷지 않았다」가 아니라 「세지 못했다」라 사유가 달라진다. @@ -687,7 +799,13 @@ class FirestoreQuestRepository implements QuestRepository { Future> _verifyShake(QuestCard card) async { final run = _shakeRun; - if (run == null) throw BackendException('E-QST-01', appL10n.questNoShake); + // 끝낸 판을 서버 세션에 남겨 두었다면 그 판정표로 — 목록에서 바로 「보상받기」(원장 C-83). + final saved = card.session?.state == QuestStage.completed ? card.session : null; + final judgement = run?.judgement() ?? saved?.judgement; + final segments = run?.segments ?? saved?.total ?? 0; + if (judgement == null || segments <= 0) { + throw BackendException('E-QST-01', appL10n.questNoShake); + } final quest = (await _backend.db.doc('quests/${card.id}').get()).data() ?? const {}; final ids = quest['trackIds']; @@ -700,8 +818,8 @@ class FirestoreQuestRepository implements QuestRepository { return _backend.call('fnQuestVerifyMusicShake', { 'questId': card.id, 'trackId': trackId, - 'totalSegments': run.segments, - 'judgement': run.judgement(), + 'totalSegments': segments, + 'judgement': judgement, 'occurredAt': DateTime.now().toUtc().toIso8601String(), ...await _fandomField(), }); diff --git a/app/lib/features/quest/widgets/paused_quest_capsule.dart b/app/lib/features/quest/widgets/paused_quest_capsule.dart new file mode 100644 index 0000000..02d8ffd --- /dev/null +++ b/app/lib/features/quest/widgets/paused_quest_capsule.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; + +import '../../../core/theme/tokens.dart'; +import '../quest_data.dart'; +import '../quest_detail_screen.dart'; +import 'quest_card_tile.dart' show pausedLabel; + +/// 탭 화면(HOME · INSIDE · GALAXY · MY) 하단 탭 바로 위 한 줄 캡슐 — 원장 C-83(10/3 통합회신 Q-OB-01 A). +/// +/// 일시 정지한 Quest 가 있을 때만 가장 최근 1건을 「일시 정지 · Quest명 · 43/60」으로 띄우고, 누르면 그 +/// Quest 상세로 간다. Walk · 출석은 대상이 아니다(일시 정지가 되지 않는다). 새 탭 · 독립 화면이 아니다. +class PausedQuestCapsule extends StatefulWidget { + const PausedQuestCapsule({super.key, required this.repository}); + + final QuestRepository repository; + + @override + State createState() => _PausedQuestCapsuleState(); +} + +class _PausedQuestCapsuleState extends State { + QuestCard? _card; + + @override + void initState() { + super.initState(); + questSessionSignal.addListener(_load); + _load(); + } + + @override + void dispose() { + questSessionSignal.removeListener(_load); + super.dispose(); + } + + Future _load() async { + QuestCard? card; + try { + card = await widget.repository.pausedQuest(); + } catch (_) { + card = null; // 캡슐은 보조 표시다 — 못 읽으면 띄우지 않는다. + } + if (mounted) setState(() => _card = card); + } + + Future _open(QuestCard card) async { + await Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => + QuestDetailScreen(questId: card.id, repository: widget.repository), + ), + ); + await _load(); + } + + @override + Widget build(BuildContext context) { + final card = _card; + if (card == null) return const SizedBox.shrink(); + return ColoredBox( + color: AppColors.cosmicVoid, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Material( + color: AppColors.cosmicSurfaceContainer, + borderRadius: BorderRadius.circular(AppRadius.full), + child: InkWell( + key: const ValueKey('paused-quest-capsule'), + onTap: () => _open(card), + borderRadius: BorderRadius.circular(AppRadius.full), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + const Icon( + Icons.pause_circle_filled_rounded, + size: 18, + color: AppColors.cosmicAccent, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + pausedLabel(card, title: true), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + height: 20 / 14, + color: AppColors.onCosmic, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/features/quest/widgets/quest_card_tile.dart b/app/lib/features/quest/widgets/quest_card_tile.dart index a2d5bc7..c4f39fe 100644 --- a/app/lib/features/quest/widgets/quest_card_tile.dart +++ b/app/lib/features/quest/widgets/quest_card_tile.dart @@ -70,6 +70,18 @@ class QuestIconBox extends StatelessWidget { _ => (bg: AppColors.onCosmic, fg: Colors.black), }; +/// 일시 정지 표기 — 「카테고리 · 일시 정지 · 43/60」. 카드와 탭 위 캡슐이 같이 쓴다(원장 C-83). +String pausedLabel(QuestCard card, {bool title = false}) { + final session = card.session; + return [ + if (!title) card.categoryName, + appL10n.questPaused, + if (title) card.title, + if (session != null && session.total > 0) + '${formatPop(session.progress)}/${formatPop(session.total)}', + ].join(' · '); +} + /// 목록의 퀘스트 카드 한 장 — 시안 `264:46867` 카드(#1a1a1c r12 · 안쪽 20). /// /// 명세 — *퀘스트 아이콘 · 퀘스트 명 · 퀘스트 카테고리 · 진행 상태 값 ·* @@ -135,7 +147,10 @@ class QuestCardTile extends StatelessWidget { ), const SizedBox(height: 6), Text( - card.categoryName, + // 진행 상태 값 「일시 정지」 · 멈춘 진행값(원장 C-83 — 기존 카드 영역 안). + card.stage == QuestStage.paused + ? pausedLabel(card) + : card.categoryName, style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w400, diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 8c2e2a2..46265dd 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -585,6 +585,8 @@ "planetJoin": "Join", "questDetails": "Details", "questInProgress": "In progress", + "questResume": "Resume", + "questPaused": "Paused", "questTypeAttend": "Check-in", "questTypeSteps": "Walking", "questTypePlace": "Location", diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index c35a820..9517bb0 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -585,6 +585,8 @@ "planetJoin": "Unirse", "questDetails": "Detalles", "questInProgress": "En curso", + "questResume": "Reanudar", + "questPaused": "En pausa", "questTypeAttend": "Asistencia", "questTypeSteps": "Caminar", "questTypePlace": "Ubicación", diff --git a/app/lib/l10n/app_ja.arb b/app/lib/l10n/app_ja.arb index 35f321a..b0c268e 100644 --- a/app/lib/l10n/app_ja.arb +++ b/app/lib/l10n/app_ja.arb @@ -585,6 +585,8 @@ "planetJoin": "参加する", "questDetails": "詳細", "questInProgress": "進行中", + "questResume": "再開する", + "questPaused": "一時停止", "questTypeAttend": "出席", "questTypeSteps": "ウォーキング", "questTypePlace": "特定の場所", diff --git a/app/lib/l10n/app_ko.arb b/app/lib/l10n/app_ko.arb index bc0926d..426dce7 100644 --- a/app/lib/l10n/app_ko.arb +++ b/app/lib/l10n/app_ko.arb @@ -1162,6 +1162,8 @@ "planetJoin": "가입하기", "questDetails": "자세히", "questInProgress": "진행중", + "questResume": "이어하기", + "questPaused": "일시 정지", "questTypeAttend": "출석", "questTypeSteps": "걷기", "questTypePlace": "특정 위치", diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index c0d47e3..36aba94 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -3614,6 +3614,18 @@ abstract class AppLocalizations { /// **'진행중'** String get questInProgress; + /// No description provided for @questResume. + /// + /// In ko, this message translates to: + /// **'이어하기'** + String get questResume; + + /// No description provided for @questPaused. + /// + /// In ko, this message translates to: + /// **'일시 정지'** + String get questPaused; + /// No description provided for @questTypeAttend. /// /// In ko, this message translates to: diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 6f1d38b..c8a893e 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -2033,6 +2033,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get questInProgress => 'In progress'; + @override + String get questResume => 'Resume'; + + @override + String get questPaused => 'Paused'; + @override String get questTypeAttend => 'Check-in'; diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index ac574e5..dc1c0b7 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -2045,6 +2045,12 @@ class AppLocalizationsEs extends AppLocalizations { @override String get questInProgress => 'En curso'; + @override + String get questResume => 'Reanudar'; + + @override + String get questPaused => 'En pausa'; + @override String get questTypeAttend => 'Asistencia'; diff --git a/app/lib/l10n/app_localizations_ja.dart b/app/lib/l10n/app_localizations_ja.dart index 30e7a3f..8b2a4ed 100644 --- a/app/lib/l10n/app_localizations_ja.dart +++ b/app/lib/l10n/app_localizations_ja.dart @@ -1973,6 +1973,12 @@ class AppLocalizationsJa extends AppLocalizations { @override String get questInProgress => '進行中'; + @override + String get questResume => '再開する'; + + @override + String get questPaused => '一時停止'; + @override String get questTypeAttend => '出席'; diff --git a/app/lib/l10n/app_localizations_ko.dart b/app/lib/l10n/app_localizations_ko.dart index fde5e19..0d247b1 100644 --- a/app/lib/l10n/app_localizations_ko.dart +++ b/app/lib/l10n/app_localizations_ko.dart @@ -1974,6 +1974,12 @@ class AppLocalizationsKo extends AppLocalizations { @override String get questInProgress => '진행중'; + @override + String get questResume => '이어하기'; + + @override + String get questPaused => '일시 정지'; + @override String get questTypeAttend => '출석'; diff --git a/app/lib/l10n/app_localizations_zh.dart b/app/lib/l10n/app_localizations_zh.dart index 94d1588..7f77df8 100644 --- a/app/lib/l10n/app_localizations_zh.dart +++ b/app/lib/l10n/app_localizations_zh.dart @@ -1971,6 +1971,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get questInProgress => '进行中'; + @override + String get questResume => '继续'; + + @override + String get questPaused => '已暂停'; + @override String get questTypeAttend => '签到'; diff --git a/app/lib/l10n/app_zh.arb b/app/lib/l10n/app_zh.arb index bfb42ef..9fef03b 100644 --- a/app/lib/l10n/app_zh.arb +++ b/app/lib/l10n/app_zh.arb @@ -585,6 +585,8 @@ "planetJoin": "加入", "questDetails": "详情", "questInProgress": "进行中", + "questResume": "继续", + "questPaused": "已暂停", "questTypeAttend": "签到", "questTypeSteps": "步行", "questTypePlace": "指定地点", diff --git a/app/lib/l10n/remote_app_localizations.dart b/app/lib/l10n/remote_app_localizations.dart index 28d5242..a293b84 100644 --- a/app/lib/l10n/remote_app_localizations.dart +++ b/app/lib/l10n/remote_app_localizations.dart @@ -1310,10 +1310,10 @@ class RemoteAppLocalizations extends AppLocalizations { String get nebulaNameHint => _remote['nebulaNameHint'] ?? _base.nebulaNameHint; @override - String get nebulaNameTaken => _remote['nebulaNameTaken'] ?? _base.nebulaNameTaken; + String get nebulaNameInvalid => _remote['nebulaNameInvalid'] ?? _base.nebulaNameInvalid; @override - String get nebulaNameInvalid => _remote['nebulaNameInvalid'] ?? _base.nebulaNameInvalid; + String get nebulaNameTaken => _remote['nebulaNameTaken'] ?? _base.nebulaNameTaken; @override String get nicknameEdit => _remote['nicknameEdit'] ?? _base.nicknameEdit; @@ -2071,6 +2071,9 @@ class RemoteAppLocalizations extends AppLocalizations { @override String questParticipantsCount(String count) => _fill('questParticipantsCount', {'count': count}) ?? _base.questParticipantsCount(count); + @override + String get questPaused => _remote['questPaused'] ?? _base.questPaused; + @override String get questPickPlace => _remote['questPickPlace'] ?? _base.questPickPlace; @@ -2116,6 +2119,9 @@ class RemoteAppLocalizations extends AppLocalizations { @override String get questResubmitNotAllowed => _remote['questResubmitNotAllowed'] ?? _base.questResubmitNotAllowed; + @override + String get questResume => _remote['questResume'] ?? _base.questResume; + @override String get questReviewButton => _remote['questReviewButton'] ?? _base.questReviewButton; diff --git a/app/test/f4_screens_test.dart b/app/test/f4_screens_test.dart index 1a4a0ec..0f7c19f 100644 --- a/app/test/f4_screens_test.dart +++ b/app/test/f4_screens_test.dart @@ -7,6 +7,7 @@ import 'package:fancake/features/quest/quest_data.dart'; import 'package:fancake/features/quest/quest_detail_screen.dart'; import 'package:fancake/features/quest/quest_list_screen.dart'; import 'package:fancake/features/quest/quest_reward_screen.dart'; +import 'package:fancake/features/quest/widgets/paused_quest_capsule.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -516,6 +517,50 @@ void main() { }); }); + group('일시 정지 · 이어하기 · 목록 보상받기 (원장 C-83 · 명세 518:88405)', () { + testWidgets('목록 「보상받기」는 상세를 거쳐 바로 수령 흐름(보상 안내 모달)을 연다', (tester) async { + await pumpWide(tester, const QuestListScreen(repository: _sample)); + await tester.pump(); + await tester.tap( + find.descendant(of: find.byType(QuestTabStrip), matching: find.text(QuestTab.inProgress.label)), + ); + await tester.pump(); + await tester.tap(find.widgetWithText(FilledButton, '보상받기')); + await tester.pumpAndSettle(); + expect(find.text('+10 기여 POP'), findsOneWidget); + }); + + testWidgets('일시 정지 Quest — 상세에 멈춘 진행값 · 「이어하기」, 진행중 탭 카드에 「일시 정지」', (tester) async { + await pumpWide(tester, const QuestDetailScreen(questId: 's1', repository: _PausedRepo())); + await tester.pump(); + expect(find.text('일시 정지'), findsOneWidget); + expect(find.text('43 / 60'), findsOneWidget); + expect(find.widgetWithText(FilledButton, '이어하기'), findsOneWidget); + + await pumpWide(tester, const QuestListScreen(repository: _PausedRepo())); + await tester.pump(); + await tester.tap( + find.descendant(of: find.byType(QuestTabStrip), matching: find.text(QuestTab.inProgress.label)), + ); + await tester.pump(); + expect(find.text('Music Shake · 일시 정지 · 43/60'), findsOneWidget); + expect(find.widgetWithText(FilledButton, '이어하기'), findsOneWidget); + }); + + testWidgets('탭 위 캡슐 — 일시 정지 Quest 가 있을 때만 한 줄 · 누르면 그 상세', (tester) async { + await pumpWide(tester, const Scaffold(body: PausedQuestCapsule(repository: _PausedRepo()))); + await tester.pump(); + expect(find.text('일시 정지 · 응원 흔들기 · 43/60'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('paused-quest-capsule'))); + await tester.pumpAndSettle(); + expect(find.byType(QuestDetailScreen), findsOneWidget); + + await pumpWide(tester, const Scaffold(body: PausedQuestCapsule(repository: _sample))); + await tester.pump(); + expect(find.byKey(const ValueKey('paused-quest-capsule')), findsNothing); + }); + }); + testWidgets('F4 화면 문구에 내부 경로가 새지 않는다', (tester) async { for (final screen in [ const QuestListScreen(repository: _sample), @@ -700,3 +745,28 @@ class _ReviewQuests extends SampleQuestRepository { ), ); } + +/// 일시 정지한 Music Shake 1건(43/60)이 있는 표본. +class _PausedRepo extends SampleQuestRepository { + const _PausedRepo(); + + static const _card = QuestCard( + id: 's1', title: '응원 흔들기', categoryName: 'Music Shake', type: QuestType.musicShake, + author: QuestAuthor.admin, stage: QuestStage.paused, goal: 120, progress: 0, + rewardPop: 3, participants: 0, + session: QuestSession(state: QuestStage.paused, progress: 43, total: 60), + ); + + @override + Future> load({int limit = 10}) async => const [_card]; + + @override + Future> more({required int offset, int limit = 10}) async => const []; + + @override + Future detail(String questId) async => + const QuestDetail(card: _card, imageLabel: '', description: ''); + + @override + Future pausedQuest() async => _card; +} diff --git a/app/test/music_shake_play_test.dart b/app/test/music_shake_play_test.dart index c7fd551..1c0b958 100644 --- a/app/test/music_shake_play_test.dart +++ b/app/test/music_shake_play_test.dart @@ -124,4 +124,51 @@ void main() { expect(shared!.text, contains('PERFECT 5 · GOOD 2 · MISS 1 · MAX COMBO 4')); expect(shared!.text, contains('#FANUZU')); }); + + testWidgets('중간에 나가면 null · 넘긴 판정표에 판정과 영상 시각이 남고, 이어하면 그 판정을 잇는다(C-83)', (tester) async { + final shakes = StreamController(); + addTearDown(shakes.close); + late ValueChanged player; + // 이어하기 — 박 0 은 멈추기 전에 perfect 였다. + final judge = ShakeJudge(bpm: 60, segments: 3, hits: const [0, -1, -1], position: const Duration(seconds: 1)); + Object? result = 'unset'; + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: Builder( + builder: (context) => TextButton( + onPressed: () async => result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => MusicShakePlayScreen( + setup: setup, + shakes: shakes.stream, + judge: judge, + player: (onEvent) { + player = onEvent; + return const SizedBox(); + }, + ), + ), + ), + child: const Text('go'), + ), + ), + ), + ); + await tester.tap(find.text('go')); + await tester.pumpAndSettle(); + player((playing: true, position: const Duration(milliseconds: 1000), ended: false)); + shakes.add(DateTime.now()); + await tester.pump(const Duration(milliseconds: 60)); + player((playing: true, position: const Duration(milliseconds: 1600), ended: false)); + await tester.pump(const Duration(milliseconds: 60)); + expect(find.text('2 COMBO'), findsOneWidget); + + await tester.pageBack(); + await tester.pumpAndSettle(); + expect(result, isNull); + expect(judge.position, const Duration(milliseconds: 1600)); + expect(judge.hits.take(2).every((h) => h >= 0), isTrue); + expect(judge.live(judge.position).judged, 2); + }); } diff --git a/app/test/quest_repository_test.dart b/app/test/quest_repository_test.dart index e7b7600..5375212 100644 --- a/app/test/quest_repository_test.dart +++ b/app/test/quest_repository_test.dart @@ -1,5 +1,5 @@ import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; -import 'package:cloud_firestore/cloud_firestore.dart' show SetOptions; +import 'package:cloud_firestore/cloud_firestore.dart' show SetOptions, Timestamp; import 'package:fancake/core/data/backend.dart'; import 'package:fancake/features/quest/quest_data.dart'; import 'package:fancake/features/quest/quest_evidence.dart'; @@ -627,4 +627,123 @@ service cloud.firestore { expect(live.capacity, 5); }); }); + + group('진행 상태 저장 · 일시 정지 · 최신순 (원장 C-83 · 명세 518:88395)', () { + Future shakeSeed() async { + final db = await _seed(); + await db.doc('quests/shake').set({ + 'type': 'musicshake', 'title': '응원 흔들기', 'dailyLimit': 1, + 'totalSegments': 60, 'bpm': 120, + 'link': {'url': 'https://youtu.be/WMweEpGlu_U'}, + }); + return db; + } + + test('목록은 createdAt 내림차순으로 서버 정렬해 커서로 이어 읽는다', () async { + final db = FakeFirebaseFirestore(); + await db.doc('users/$_uid').set({'uid': _uid}); + await db.doc('config/policy').set({'attendPop': 10}); + for (var i = 0; i < 12; i++) { + await db.doc('quests/q$i').set({ + 'type': 'attend', 'title': 'Q$i', 'dailyLimit': 1, + 'createdAt': Timestamp.fromMillisecondsSinceEpoch(1000000 + i * 1000), + }); + } + final repo = FirestoreQuestRepository(_RecordingBackend(db)); + final first = await repo.load(); + final next = await repo.more(offset: first.length); + expect([...first, ...next].map((c) => c.id).toList(), + [for (var i = 11; i >= 0; i--) 'q$i']); + }); + + test('Music Shake 세션 — 화면 밖 진행중은 일시 정지 · 진행완료는 보상받기 · 진행중 탭이 받는다', () async { + final db = await shakeSeed(); + final ref = db.doc('users/$_uid/questProgress/shake'); + final repo = FirestoreQuestRepository(_RecordingBackend(db)); + + await ref.set({'session': {'state': 'inProgress', 'progress': 43, 'total': 60}}); + var card = _card(await repo.load(), 'shake'); + // 앱이 꺼진 진행중도 진행 화면 밖이므로 일시 정지다. + expect(card.stage, QuestStage.paused); + expect(card.stage.buttonLabel, '이어하기'); + expect(QuestTab.inProgress.accepts(card), isTrue); + + await ref.set({'session': { + 'state': 'completed', 'progress': 60, 'total': 60, + 'judgement': {'perfect': 50, 'good': 5, 'miss': 5, 'maxCombo': 30}, + }}); + card = _card(await repo.load(), 'shake'); + expect(card.stage, QuestStage.completed); + expect(QuestTab.inProgress.accepts(card), isTrue); + }); + + test('목록에서 바로 받는 Music Shake 는 서버 세션의 판정표를 보낸다', () async { + final db = await shakeSeed(); + await db.doc('users/$_uid/questProgress/shake').set({'session': { + 'state': 'completed', 'progress': 60, 'total': 60, + 'judgement': {'perfect': 50, 'good': 5, 'miss': 5, 'maxCombo': 30}, + }}); + final backend = _RecordingBackend(db); + final repo = FirestoreQuestRepository(backend); + await repo.verify(_card(await repo.load(), 'shake')); + final call = backend.calls.single; + expect(call.name, 'fnQuestVerifyMusicShake'); + expect(call.payload['totalSegments'], 60); + expect(call.payload['judgement'], {'perfect': 50, 'good': 5, 'miss': 5, 'maxCombo': 30}); + }); + + test('Walk 는 일시 정지 없이 오늘 걸음으로 진행중 · 진행완료를 낸다(C-62)', () async { + final db = await _seed(); + await db.doc('users/$_uid/questProgress/steps').set({ + 'session': {'state': 'inProgress', 'progress': 0, 'total': 10000}, + }); + var steps = 4_000; + final repo = FirestoreQuestRepository( + _RecordingBackend(db), + todaySteps: () async => steps, + ); + var card = _card(await repo.load(), 'steps'); + expect((card.stage, card.progress), (QuestStage.inProgress, 4_000)); + steps = 10_200; // 화면 밖에서 걸었다. + card = _card(await repo.load(), 'steps'); + expect((card.stage, card.progress), (QuestStage.completed, 10_200)); + }); + + test('진행 상태는 fnQuestProgress 로만 쓰고 중단은 none 이다', () async { + final db = await _seed(); + final backend = _RecordingBackend(db); + final repo = FirestoreQuestRepository(backend); + await repo.advance('shake', QuestStage.paused, + session: const QuestSession( + state: QuestStage.paused, progress: 43, total: 60, + position: Duration(milliseconds: 21500), hits: [0.1, -1], + )); + await repo.advance('steps', QuestStage.notStarted); + expect(backend.calls.map((c) => c.name), ['fnQuestProgress', 'fnQuestProgress']); + expect(backend.calls.first.payload, { + 'questId': 'shake', 'state': 'paused', 'progress': 43, 'total': 60, + 'positionMs': 21500, 'hits': [0.1, -1.0], + }); + expect(backend.calls.last.payload, {'questId': 'steps', 'state': 'none'}); + }); + + test('캡슐은 가장 최근에 멈춘 Quest 1건이다(QA 3-34)', () async { + final db = await shakeSeed(); + await db.doc('quests/shake2').set({ + 'type': 'musicshake', 'title': '두 번째', 'totalSegments': 30, 'bpm': 100, + }); + await db.doc('users/$_uid/questProgress/shake').set({'session': { + 'state': 'paused', 'progress': 43, 'total': 60, + 'updatedAt': Timestamp.fromMillisecondsSinceEpoch(2000), + }}); + await db.doc('users/$_uid/questProgress/shake2').set({'session': { + 'state': 'paused', 'progress': 3, 'total': 30, + 'updatedAt': Timestamp.fromMillisecondsSinceEpoch(1000), + }}); + final repo = FirestoreQuestRepository(_RecordingBackend(db)); + final card = await repo.pausedQuest(); + expect(card?.id, 'shake'); + expect((card?.session?.progress, card?.session?.total), (43, 60)); + }); + }); }