퀘스트 일시 정지 · 이어하기 · 진행 상태 서버 저장 · 목록 보상받기

This commit is contained in:
Sn-Kinos
2026-10-06 03:20:46 +09:00
parent ecf5998725
commit abe5f1ae50
28 changed files with 1023 additions and 118 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ export {
fnPopGrant, fnPopSpend, fnPopRefund, fnPopReconcile, fnPopGrant, fnPopSpend, fnPopRefund, fnPopReconcile,
} from './ledger/pop.js'; } from './ledger/pop.js';
export { export {
fnQuestAttend, fnQuestVerifySteps, fnAdReward, fnQuestStreamParticipate, fnQuestAttend, fnQuestVerifySteps, fnAdReward, fnQuestStreamParticipate, fnQuestProgress,
} from './quest/quest.js'; } from './quest/quest.js';
export { export {
fnAuthOnCreate, fnNebulaCreate, fnPlanetCreate, fnAuthOnCreate, fnNebulaCreate, fnPlanetCreate,
+63
View File
@@ -119,10 +119,73 @@ export function progressWriter(
rewardedPop, rewardedPop,
completedAt: FieldValue.serverTimestamp(), completedAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
// 보상을 받았으면 진행 세션(C-83 · [fnQuestProgress])은 끝났다 — 다음 회차는 「시작전」부터.
session: FieldValue.delete(),
}, { merge: true }); }, { 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<string, unknown>, 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<string, unknown>;
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<string, unknown>, 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` 이 정한다. */ /** `fn-quest-attend` — 출석. 한도는 `quests/{questId}.dailyLimit` 이 정한다. */
export const fnQuestAttend = onCall(async (req) => { export const fnQuestAttend = onCall(async (req) => {
const uid = callerUid(req); const uid = callerUid(req);
+41 -1
View File
@@ -6,7 +6,7 @@ import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import { grantPop } from '../src/ledger/ledger.js'; import { grantPop } from '../src/ledger/ledger.js';
import { verifiedQuestCountOf } from '../src/ledger/fpi.js'; import { verifiedQuestCountOf } from '../src/ledger/fpi.js';
import { fnAdminCompletionVoid } from '../src/admin/admin.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 { fnQuestVerifyMusicShake, fnQuestVerifyPlace, placeProofStorage } from '../src/quest/verify.js';
import { fnCampaignFund } from '../src/fandom/fandom.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);
});
});
@@ -1784,3 +1784,24 @@ describe('성운 일 1회 기록 — GALAXY 「성운 생성」 버튼(Figma 540
await assertFails(setDoc(doc(dbFor('alice'), 'users/alice/nebulaCreates/2026-10-08'), { fandomId: 'f2' })); 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')));
});
});
+21 -3
View File
@@ -14,6 +14,8 @@ import '../../features/home/coach_mark.dart';
import '../../features/home/home_screen.dart'; import '../../features/home/home_screen.dart';
import '../../features/inside/inside_screen.dart'; import '../../features/inside/inside_screen.dart';
import '../../features/my/my_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'; import '../../features/station/station_screen.dart';
/// 하단 탭 하나. /// 하단 탭 하나.
@@ -149,7 +151,8 @@ GoRouter buildRouter(AuthRepository auth, {Repositories? repositories}) {
builder: (_, _) => SignupCompleteScreen(onStart: auth.finishSignup), builder: (_, _) => SignupCompleteScreen(onStart: auth.finishSignup),
), ),
StatefulShellRoute.indexedStack( StatefulShellRoute.indexedStack(
builder: (_, _, shell) => AppShell(shell: shell, flags: repos.flags), builder: (_, _, shell) =>
AppShell(shell: shell, flags: repos.flags, quests: repos.quest),
branches: [ branches: [
StatefulShellBranch( StatefulShellBranch(
observers: [HomeRefreshObserver()], observers: [HomeRefreshObserver()],
@@ -238,11 +241,19 @@ GoRouter buildRouter(AuthRepository auth, {Repositories? repositories}) {
/// 사라져 딥링크·기존 경로가 죽고, 플래그를 켤 때 재배포가 필요해진다. 여기서는 /// 사라져 딥링크·기존 경로가 죽고, 플래그를 켤 때 재배포가 필요해진다. 여기서는
/// 하단 바에 그리는 목적지만 줄이고, 눌린 위치를 원래 브랜치 번호로 되돌린다. /// 하단 바에 그리는 목적지만 줄이고, 눌린 위치를 원래 브랜치 번호로 되돌린다.
class AppShell extends StatelessWidget { 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 StatefulNavigationShell shell;
final FeatureFlags flags; final FeatureFlags flags;
/// 일시 정지 Quest 캡슐(원장 C-83)을 읽는 저장소. null 이면 캡슐이 없다.
final QuestRepository? quests;
@override @override
Widget build(BuildContext context) => ListenableBuilder( Widget build(BuildContext context) => ListenableBuilder(
listenable: flags, listenable: flags,
@@ -254,7 +265,12 @@ class AppShell extends StatelessWidget {
return Scaffold( return Scaffold(
body: shell, body: shell,
// Figma 탭 줄은 좌우 16 을 비우고 칸을 나눈다(350:215853). // Figma 탭 줄은 좌우 16 을 비우고 칸을 나눈다(350:215853).
bottomNavigationBar: ColoredBox( // 그 바로 위에 일시 정지 Quest 캡슐 한 줄(원장 C-83) — 없으면 높이 0.
bottomNavigationBar: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (quests case final repo?) PausedQuestCapsule(repository: repo),
ColoredBox(
color: AppColors.cosmicVoid, color: AppColors.cosmicVoid,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
@@ -292,6 +308,8 @@ class AppShell extends StatelessWidget {
), ),
), ),
), ),
],
),
); );
}, },
); );
@@ -21,7 +21,8 @@ typedef ShakePlayerEvent = ({bool playing, Duration position, bool ended});
/// 공식 YouTube 영상(퀘스트의 지정 링크 · PRD-04 MVP 수용 기준)을 IFrame 플레이어로 재생하고, /// 공식 YouTube 영상(퀘스트의 지정 링크 · PRD-04 MVP 수용 기준)을 IFrame 플레이어로 재생하고,
/// 재생되는 동안의 흔들기를 **영상 시각**으로 찍어 박마다 Perfect · Good · Miss · Combo 를 /// 재생되는 동안의 흔들기를 **영상 시각**으로 찍어 박마다 Perfect · Good · Miss · Combo 를
/// 띄운다. 판정 창은 운영값(🟡 임시값 — 회신 대기). 마지막 박의 창이 닫히거나 영상이 끝나면 /// 띄운다. 판정 창은 운영값(🟡 임시값 — 회신 대기). 마지막 박의 창이 닫히거나 영상이 끝나면
/// 판정표를 돌려준다. 중간에 나가면 null — 판정표를 남기지 않는다. /// 판정표를 돌려준다. 중간에 나가면 null 이다 — 호출자는 넘긴 [judge] 의 판정 · 영상 시각을
/// 일시 정지 세션으로 남기고, 「이어하기」 때 그 판정표를 다시 넘겨 멈춘 자리부터 잇는다(원장 C-83).
/// ///
/// Figma 에 이 진행 화면은 없다(상세 `480:14506` 은 「진행중」 버튼까지). 공통 기준 5항 — /// Figma 에 이 진행 화면은 없다(상세 `480:14506` 은 「진행중」 버튼까지). 공통 기준 5항 —
/// 기존 범위 기능의 정상 구현에 필요한 화면이다. 상세의 시각 문법(#141414 · #1a1a1c r24)을 따른다. /// 기존 범위 기능의 정상 구현에 필요한 화면이다. 상세의 시각 문법(#141414 · #1a1a1c r24)을 따른다.
@@ -31,9 +32,13 @@ class MusicShakePlayScreen extends StatefulWidget {
required this.setup, required this.setup,
required this.shakes, required this.shakes,
this.player, this.player,
this.judge,
}); });
final MusicShakeSetup setup; final MusicShakeSetup setup;
/// 이 판의 판정표. 넘기지 않으면 새 판이다. 「이어하기」는 멈춘 판의 판정표를 넘긴다.
final ShakeJudge? judge;
final Stream<DateTime> shakes; final Stream<DateTime> shakes;
/// 영상 플레이어. 기본은 [YoutubeEmbed]. 테스트는 상태를 직접 넣는 가짜를 준다. /// 영상 플레이어. 기본은 [YoutubeEmbed]. 테스트는 상태를 직접 넣는 가짜를 준다.
@@ -44,7 +49,9 @@ class MusicShakePlayScreen extends StatefulWidget {
} }
class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> { class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> {
late final ShakeJudge _judge = ShakeJudge( late final ShakeJudge _judge =
widget.judge ??
ShakeJudge(
bpm: widget.setup.bpm, bpm: widget.setup.bpm,
segments: widget.setup.segments, segments: widget.setup.segments,
windows: widget.setup.windows, windows: widget.setup.windows,
@@ -54,7 +61,7 @@ class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> {
/// 영상 시계 — 마지막으로 받은 플레이어 시각과 그때의 단말 시각. /// 영상 시계 — 마지막으로 받은 플레이어 시각과 그때의 단말 시각.
bool _playing = false; bool _playing = false;
Duration _anchorMedia = Duration.zero; late Duration _anchorMedia = _judge.position;
DateTime _anchorWall = DateTime.now(); DateTime _anchorWall = DateTime.now();
int _lastJudged = 0; int _lastJudged = 0;
bool _done = false; bool _done = false;
@@ -84,6 +91,7 @@ class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> {
if (_playing != e.playing && mounted) setState(() {}); if (_playing != e.playing && mounted) setState(() {});
_playing = e.playing; _playing = e.playing;
_anchorMedia = e.position; _anchorMedia = e.position;
_judge.position = e.position;
_anchorWall = DateTime.now(); _anchorWall = DateTime.now();
if (e.ended) _finish(); if (e.ended) _finish();
} }
@@ -188,8 +196,11 @@ class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> {
); );
} }
Widget _defaultPlayer(ValueChanged<ShakePlayerEvent> onEvent) => Widget _defaultPlayer(ValueChanged<ShakePlayerEvent> onEvent) => YoutubeEmbed(
YoutubeEmbed(videoId: widget.setup.videoId, onEvent: onEvent); videoId: widget.setup.videoId,
onEvent: onEvent,
start: _judge.position,
);
} }
/// Perfect · Good · Miss · 최대 콤보 네 칸 — 진행 화면과 결과 카드가 같이 쓴다. /// Perfect · Good · Miss · 최대 콤보 네 칸 — 진행 화면과 결과 카드가 같이 쓴다.
@@ -258,9 +269,17 @@ class ShakeTally extends StatelessWidget {
/// - 앱 식별: `baseUrl` 을 스토어 앱 ID 의 https URL 로 둔다(같은 문서 「Referer for mobile apps」). /// - 앱 식별: `baseUrl` 을 스토어 앱 ID 의 https URL 로 둔다(같은 문서 「Referer for mobile apps」).
/// - iOS 는 인라인 재생을 켜야 전체 화면으로 빠지지 않고 판정 화면이 보인다. /// - iOS 는 인라인 재생을 켜야 전체 화면으로 빠지지 않고 판정 화면이 보인다.
class YoutubeEmbed extends StatefulWidget { 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 String videoId;
/// 재생 시작 시각 — 「이어하기」는 멈춘 자리(초 단위 내림)부터 튼다.
final Duration start;
final ValueChanged<ShakePlayerEvent> onEvent; final ValueChanged<ShakePlayerEvent> onEvent;
/// 스토어 앱 ID(Android applicationId · iOS bundle ID 가 같다). /// 스토어 앱 ID(Android applicationId · iOS bundle ID 가 같다).
@@ -273,7 +292,7 @@ class YoutubeEmbed extends StatefulWidget {
!Platform.environment.containsKey('FLUTTER_TEST'); !Platform.environment.containsKey('FLUTTER_TEST');
@visibleForTesting @visibleForTesting
static String html(String videoId) => static String html(String videoId, {int startSeconds = 0}) =>
''' '''
<!DOCTYPE html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"> <!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> <style>html,body{margin:0;height:100%;background:#000}#p{width:100%;height:100%}</style></head>
@@ -282,7 +301,7 @@ var player;
function post(s){Player.postMessage(JSON.stringify({s:s,t:player.getCurrentTime()}));} function post(s){Player.postMessage(JSON.stringify({s:s,t:player.getCurrentTime()}));}
function onYouTubeIframeAPIReady(){ function onYouTubeIframeAPIReady(){
player=new YT.Player('p',{videoId:${jsonEncode(videoId)}, 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);}, events:{onStateChange:function(e){post(e.data);},
onError:function(e){Player.postMessage(JSON.stringify({err:e.data}));}}}); onError:function(e){Player.postMessage(JSON.stringify({err:e.data}));}}});
setInterval(function(){if(player&&player.getPlayerState&&player.getPlayerState()==1)post(1);},500); setInterval(function(){if(player&&player.getPlayerState&&player.getPlayerState()==1)post(1);},500);
@@ -309,7 +328,7 @@ class _YoutubeEmbedState extends State<YoutubeEmbed> {
..setBackgroundColor(Colors.black) ..setBackgroundColor(Colors.black)
..addJavaScriptChannel('Player', onMessageReceived: _onMessage) ..addJavaScriptChannel('Player', onMessageReceived: _onMessage)
..loadHtmlString( ..loadHtmlString(
YoutubeEmbed.html(widget.videoId), YoutubeEmbed.html(widget.videoId, startSeconds: widget.start.inSeconds),
baseUrl: YoutubeEmbed.appOrigin, baseUrl: YoutubeEmbed.appOrigin,
); );
} }
+71 -3
View File
@@ -8,6 +8,8 @@ library;
import 'dart:typed_data' show Uint8List; import 'dart:typed_data' show Uint8List;
import 'package:flutter/foundation.dart' show ValueNotifier;
import '../../core/data/backend.dart' show BackendException; import '../../core/data/backend.dart' show BackendException;
import '../../core/monetization/rewarded_ad.dart'; import '../../core/monetization/rewarded_ad.dart';
import '../../core/i18n/app_locale.dart'; import '../../core/i18n/app_locale.dart';
@@ -28,6 +30,10 @@ enum QuestStage {
mine, mine,
notStarted, notStarted,
inProgress, inProgress,
/// 화면 안에서 진행하는 Quest(Music Shake)를 진행 화면 밖으로 나와 멈춘 상태 — 원장 C-83(10/3 통합회신
/// Q-OB-01 A): 진행 상태 값 「일시 정지」 · 버튼 「이어하기」. Walk · 출석은 이 상태가 되지 않는다.
paused,
completed, completed,
rewarded; rewarded;
@@ -36,6 +42,7 @@ enum QuestStage {
mine => appL10n.questDetails, mine => appL10n.questDetails,
notStarted => appL10n.questStart, notStarted => appL10n.questStart,
inProgress => appL10n.questInProgress, inProgress => appL10n.questInProgress,
paused => appL10n.questResume,
completed => appL10n.questClaim, completed => appL10n.questClaim,
rewarded => appL10n.questRewardDone, rewarded => appL10n.questRewardDone,
}; };
@@ -335,6 +342,42 @@ bool questFull(QuestCard card) =>
card.capacity! > 0 && card.capacity! > 0 &&
card.participants >= card.capacity!; 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<double> hits;
/// 끝까지 한 Music Shake 판의 판정 합계 — 목록에서 바로 「보상받기」할 때 서버에 보낸다.
final Map<String, int>? judgement;
}
/// 일시 정지 · 진행 세션이 바뀌었다 — 탭 화면 하단 캡슐(원장 C-83)이 다시 읽는다.
final questSessionSignal = ValueNotifier<int>(0);
/// 목록 카드 하나. /// 목록 카드 하나.
class QuestCard { class QuestCard {
const QuestCard({ const QuestCard({
@@ -354,12 +397,16 @@ class QuestCard {
this.endsAt, this.endsAt,
this.publishState = '', this.publishState = '',
this.authorName = '', this.authorName = '',
this.session,
}); });
final String id; final String id;
final String title; final String title;
final String categoryName; final String categoryName;
/// 서버에 남은 진행 세션(진행중 · 일시 정지 · 진행완료) — 원장 C-83. 없으면 null.
final QuestSession? session;
/// 만든 사람의 공개 닉네임(`quests.authorName`). 운영 Quest 는 빈 값이다. /// 만든 사람의 공개 닉네임(`quests.authorName`). 운영 Quest 는 빈 값이다.
/// 신고하기 「신고 정보 영역 — 작성자 닉네임」(Figma `548:21389`)에 쓴다. /// 신고하기 「신고 정보 영역 — 작성자 닉네임」(Figma `548:21389`)에 쓴다.
final String authorName; final String authorName;
@@ -409,6 +456,7 @@ class QuestCard {
/// 상태·진행도만 바꾼 사본. [QuestCard] 는 불변이라 갈아 끼운다. /// 상태·진행도만 바꾼 사본. [QuestCard] 는 불변이라 갈아 끼운다.
QuestCard copyWith({QuestStage? stage, int? progress}) => QuestCard( QuestCard copyWith({QuestStage? stage, int? progress}) => QuestCard(
session: session,
id: id, id: id,
title: title, title: title,
categoryName: categoryName, categoryName: categoryName,
@@ -536,8 +584,11 @@ enum QuestTab {
QuestTab.all => true, QuestTab.all => true,
// 명세 `518:88405` — *진행중 - 진행중 탭 / 진행완료 - 진행중 탭 / 보상완료 - 완료 탭*. // 명세 `518:88405` — *진행중 - 진행중 탭 / 진행완료 - 진행중 탭 / 보상완료 - 완료 탭*.
// 보상을 받기 전(진행완료)까지는 진행중 탭에 두고, 받은 뒤에 완료 탭으로 옮긴다. // 보상을 받기 전(진행완료)까지는 진행중 탭에 두고, 받은 뒤에 완료 탭으로 옮긴다.
// 일시 정지도 진행 중이다(원장 C-83 — 진행 상태 값에 「일시 정지」를 더함).
QuestTab.inProgress => 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.done => card.stage == QuestStage.rewarded,
QuestTab.mine => card.stage == QuestStage.mine, QuestTab.mine => card.stage == QuestStage.mine,
}; };
@@ -630,7 +681,17 @@ abstract interface class QuestRepository {
Future<void> cancel(String questId); Future<void> cancel(String questId);
/// 상태 전이. 서버가 확정한 상태를 돌려준다. /// 상태 전이. 서버가 확정한 상태를 돌려준다.
Future<QuestStage> advance(String questId, QuestStage next); ///
/// 실행률을 모으는 Quest(걸음 · Music Shake)는 진행 세션을 서버에 남긴다(`fn-quest-progress` · C-83).
/// [session] 은 그 진행값이다. [QuestStage.notStarted] 는 중단(세션 삭제)이다.
Future<QuestStage> advance(
String questId,
QuestStage next, {
QuestSession? session,
});
/// 가장 최근에 일시 정지한 Quest 1건 — 탭 화면 하단 캡슐(원장 C-83 · QA 3-34). 없으면 null.
Future<QuestCard?> pausedQuest();
Future<String> create(QuestDraft draft); Future<String> create(QuestDraft draft);
@@ -881,7 +942,14 @@ class SampleQuestRepository implements QuestRepository {
Future<void> cancel(String questId) async {} Future<void> cancel(String questId) async {}
@override @override
Future<QuestStage> advance(String questId, QuestStage next) async => next; Future<QuestStage> advance(
String questId,
QuestStage next, {
QuestSession? session,
}) async => next;
@override
Future<QuestCard?> pausedQuest() async => null;
@override @override
Future<String> create(QuestDraft draft) async => 'q-new'; Future<String> create(QuestDraft draft) async => 'q-new';
+118 -15
View File
@@ -43,10 +43,15 @@ class QuestDetailScreen extends StatefulWidget {
super.key, super.key,
required this.questId, required this.questId,
required this.repository, required this.repository,
this.autoAct = false,
}); });
final String questId; final String questId;
/// 목록 카드 버튼으로 들어왔다 — 상세를 읽자마자 그 버튼 동작(보상받기 · 이어하기 · 참여)을 한다.
/// 명세 `518:88405` *보상 받기 버튼 클릭 시 POP 수령*(목록에서 바로).
final bool autoAct;
/// 저장소는 호출부가 준다 — 화면이 표본을 기본값으로 만들지 않는다. /// 저장소는 호출부가 준다 — 화면이 표본을 기본값으로 만들지 않는다.
final QuestRepository repository; final QuestRepository repository;
@@ -69,6 +74,12 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
/// 방금 끝낸 Music Shake 판의 판정 합계 — 결과 화면 · 결과 카드에 쓴다. /// 방금 끝낸 Music Shake 판의 판정 합계 — 결과 화면 · 결과 카드에 쓴다.
Map<String, int>? _shakeResult; Map<String, int>? _shakeResult;
/// 진행 세션(원장 C-83) — 서버에서 읽은 값을 이 화면에서 멈추면 바로 갈아 끼운다(「이어하기」 시작점).
QuestSession? _session;
/// [QuestDetailScreen.autoAct] 를 한 번만 쓴다.
bool _autoActed = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -94,12 +105,35 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
setState(() { setState(() {
_detail = detail; _detail = detail;
_stage = detail.card.stage; _stage = detail.card.stage;
_session = detail.card.session;
}); });
// 목록에서 「시작하기」를 눌러 진행중으로 들어온 경우다. 여기서 다시 켜지 않으면 // 목록에서 「시작하기」를 눌러 진행중으로 들어온 경우다. 여기서 다시 켜지 않으면
// 상세를 열어도 실행률이 0 에 멈춘 채로 남는다. // 상세를 열어도 실행률이 0 에 멈춘 채로 남는다.
if (_stage == QuestStage.inProgress && detail.card.type.gathers) { if (_stage == QuestStage.inProgress && detail.card.type.gathers) {
_watch(detail.card); _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<void> _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 @override
@@ -231,6 +265,15 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
value: value:
'${formatPop(_live ?? card.progress)} / ${formatPop(card.goal)}', '${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), const SizedBox(height: 8),
_Row( _Row(
label: context.l10n.questReward, label: context.l10n.questReward,
@@ -769,6 +812,8 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
} }
case QuestStage.inProgress: case QuestStage.inProgress:
await _confirmStop(detail); await _confirmStop(detail);
case QuestStage.paused:
await _playShake(detail, resume: _session);
case QuestStage.completed: case QuestStage.completed:
await _claim(detail); await _claim(detail);
case QuestStage.mine: case QuestStage.mine:
@@ -780,21 +825,23 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
/// 「시작하기」 — 진행중으로 바꾸고 실측 수집을 켠다. /// 「시작하기」 — 진행중으로 바꾸고 실측 수집을 켠다.
Future<void> _start(QuestDetail detail) async { Future<void> _start(QuestDetail detail) async {
final next = await widget.repository.advance(
detail.card.id,
QuestStage.inProgress,
);
if (!mounted) return;
setState(() { setState(() {
_stage = next; _stage = QuestStage.inProgress;
_live = 0; _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). /// Music Shake 한 판 — 공식 영상을 보며 흔드는 진행 화면을 띄운다(PRD-04).
/// 끝까지 했으면 판정표를 남기고 「보상받기」로, 중간에 나왔으면 시작 전으로 돌린다. /// 끝까지 했으면 판정표를 남기고 「보상받기」로, 중간에 나왔으면 **일시 정지**로 둔다(원장 C-83).
Future<void> _playShake(QuestDetail detail) async { /// [resume] 이 있으면 멈춘 판정 · 영상 시각부터 잇는다(「이어하기」).
Future<void> _playShake(QuestDetail detail, {QuestSession? resume}) async {
final MusicShakeSetup setup; final MusicShakeSetup setup;
try { try {
setup = await widget.repository.musicShakeSetup(detail.card.id); setup = await widget.repository.musicShakeSetup(detail.card.id);
@@ -803,22 +850,59 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
return; return;
} }
if (!mounted) 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); setState(() => _stage = QuestStage.inProgress);
// 진행 중 앱이 꺼져도 다음에 일시 정지(멈춘 자리)로 보이게 시작 시점을 남긴다.
final started = _persist(
detail.card.id,
QuestStage.inProgress,
snapshot(QuestStage.inProgress),
);
final run = await Navigator.of(context, rootNavigator: true) final run = await Navigator.of(context, rootNavigator: true)
.push<ShakeJudge>( .push<ShakeJudge>(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => MusicShakePlayScreen( builder: (_) => MusicShakePlayScreen(
setup: setup, setup: setup,
shakes: widget.repository.shakeEvents(), shakes: widget.repository.shakeEvents(),
judge: judge,
), ),
), ),
); );
// 시작 저장이 일시 정지 · 진행완료 저장보다 늦게 닿지 않게 기다린다.
await started;
if (!mounted) return; if (!mounted) return;
if (run != null) widget.repository.recordShake(run); if (run != null) widget.repository.recordShake(run);
final paused = snapshot(QuestStage.paused);
setState(() { setState(() {
_shakeResult = run?.judgement(); _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<QuestDetailScreen> {
.listen( .listen(
(value) { (value) {
if (!mounted) return; if (!mounted) return;
final reached =
card.goal > 0 &&
value >= card.goal &&
_stage != QuestStage.completed;
setState(() { setState(() {
_live = value; _live = value;
if (card.goal > 0 && value >= card.goal) { if (card.goal > 0 && value >= card.goal) {
_stage = QuestStage.completed; _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) { onError: (Object error) {
// 권한 거부·센서 부재. 진행 상태를 시작 전으로 되돌려야 사용자가 다시 // 권한 거부·센서 부재. 진행 상태를 시작 전으로 되돌려야 사용자가 다시
@@ -863,16 +965,13 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
); );
if (!stop) return; if (!stop) return;
_stopWatching(); _stopWatching();
final next = await widget.repository.advance(
detail.card.id,
QuestStage.notStarted,
);
if (mounted) { if (mounted) {
setState(() { setState(() {
_stage = next; _stage = QuestStage.notStarted;
_live = null; _live = null;
}); });
} }
await _persist(detail.card.id, QuestStage.notStarted);
} }
/// QR 스캔 · 인증 사진 — Quest 가 고른 수단만. 하나라도 취소하면 null. /// QR 스캔 · 인증 사진 — Quest 가 고른 수단만. 하나라도 취소하면 null.
@@ -933,6 +1032,10 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
_stage = QuestStage.notStarted; _stage = QuestStage.notStarted;
}); });
_fail(error); _fail(error);
// 남겨 둔 진행완료 세션도 지운다 — 남기면 목록에 같은 판의 「보상받기」가 계속 뜬다.
if (detail.card.type == QuestType.musicShake) {
await _persist(detail.card.id, QuestStage.notStarted);
}
// 타임어택 창 밖(E-QST-01) — 서버가 본 상태로 다시 읽어 마감 · 완료를 반영한다. // 타임어택 창 밖(E-QST-01) — 서버가 본 상태로 다시 읽어 마감 · 완료를 반영한다.
if (error is BackendException && if (error is BackendException &&
error.code == 'E-QST-01' && error.code == 'E-QST-01' &&
+14 -1
View File
@@ -159,15 +159,28 @@ class ShakeJudge {
required this.bpm, required this.bpm,
required this.segments, required this.segments,
this.windows = const ShakeWindows(), this.windows = const ShakeWindows(),
}) : assert(bpm > 0 && segments > 0); List<double> 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 double bpm;
final int segments; final int segments;
final ShakeWindows windows; final ShakeWindows windows;
/// 마지막으로 본 영상 시각 — 진행 화면을 나가면 「이어하기」의 시작점이 된다.
Duration position;
/// 박 번호 → 가장 가까운 흔들기의 거리(박 비율). /// 박 번호 → 가장 가까운 흔들기의 거리(박 비율).
final _best = <int, double>{}; final _best = <int, double>{};
/// 박마다 [_best] — 흔들기가 없으면 -1. 일시 정지 세션에 남긴다.
List<double> get hits => [for (var i = 0; i < segments; i++) _best[i] ?? -1];
Duration get beat => Duration(microseconds: (60e6 / bpm).round()); Duration get beat => Duration(microseconds: (60e6 / bpm).round());
/// 마지막 박의 판정 창이 닫히는 영상 시각. /// 마지막 박의 판정 창이 닫히는 영상 시각.
+34 -4
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/data/backend.dart' show BackendException;
import '../../core/theme/tokens.dart'; import '../../core/theme/tokens.dart';
import '../home/home_screen.dart' show formatPop; import '../home/home_screen.dart' show formatPop;
import '../planet/moderation.dart'; import '../planet/moderation.dart';
@@ -127,12 +128,14 @@ class _QuestListScreenState extends State<QuestListScreen> {
/// 줄로, 사용자 퀘스트를 아래 카드로 나눈다. 다른 탭은 카드만 있다. /// 줄로, 사용자 퀘스트를 아래 카드로 나눈다. 다른 탭은 카드만 있다.
bool get _split => _tab == QuestTab.all; bool get _split => _tab == QuestTab.all;
Future<void> _open(QuestCard card) async { /// 상세로 간다. [act] 면 상세가 열리자마자 카드 버튼 동작(보상받기 · 이어하기 · 참여)을 한다.
Future<void> _open(QuestCard card, {bool act = false}) async {
await Navigator.of(context, rootNavigator: true).push<void>( await Navigator.of(context, rootNavigator: true).push<void>(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => QuestDetailScreen( builder: (_) => QuestDetailScreen(
questId: card.id, questId: card.id,
repository: widget.repository, repository: widget.repository,
autoAct: act,
), ),
), ),
); );
@@ -140,9 +143,14 @@ class _QuestListScreenState extends State<QuestListScreen> {
} }
Future<void> _act(QuestCard card) async { Future<void> _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). // 사용자 생성 Quest 는 상세에서 링크 · 마감 · 참여 규칙을 본 뒤 참여한다(C-05 · C-06 · C-12).
if (card.stage == QuestStage.mine || card.stage == QuestStage.completed || if (card.stage == QuestStage.mine || card.author == QuestAuthor.user) {
card.author == QuestAuthor.user) {
await _open(card); await _open(card);
return; return;
} }
@@ -159,7 +167,29 @@ class _QuestListScreenState extends State<QuestListScreen> {
)); ));
return; 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; if (!mounted) return;
setState(() { setState(() {
_cards = [ _cards = [
@@ -86,12 +86,13 @@ class FirestoreQuestRepository implements QuestRepository {
final uid = _backend.requireUid; final uid = _backend.requireUid;
final db = _backend.db; final db = _backend.db;
// 정렬하지 않는다. 설계서 §2.8 의 `quests` 에 정렬 키가 없고, Admin 이 쓰는 // 명세 `518:88395` · `518:88405` *퀘스트 생성 최신순 소팅* — 서버 정렬 + 커서 페이지.
// 문서에는 `createdAt` 이 없다(`fn-admin-write` 는 `updatedAt` 만 찍는다). // `createdAt` 이 없는 문서는 이 쿼리에서 빠진다. 새 문서는 `fn-admin-write` · `fn-quest-create` 가
// `orderBy('createdAt')` 로 두면 그 문서들이 목록에서 조용히 사라진다. // 남기고, 예전 문서는 `tools/demo/migrate_v040.mjs quest-created-at` 로 채운다(배포 전 필수).
// 커서는 기본 순서(문서 ID)를 따른다. 커서를 limit 보다 먼저 건다 — 서버는 순서를 가리지 않지만 // 커서를 limit 보다 먼저 건다 — 서버는 순서를 가리지 않지만 테스트용 fake 는 적은 순서대로 적용한다.
// 테스트용 fake 는 적은 순서대로 적용한다. Query<Map<String, dynamic>> query = db
Query<Map<String, dynamic>> query = db.collection('quests'); .collection('quests')
.orderBy('createdAt', descending: true);
final cursor = _cursor; final cursor = _cursor;
if (cursor != null) query = query.startAfterDocument(cursor); if (cursor != null) query = query.startAfterDocument(cursor);
query = query.limit(limit); query = query.limit(limit);
@@ -106,20 +107,9 @@ class FirestoreQuestRepository implements QuestRepository {
} }
final now = ServerClock.now(); final now = ServerClock.now();
final policy = await _policy(); 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 = <QuestCard>[]; final cards = <QuestCard>[];
for (final doc in docs) { for (final doc in snap.docs) {
final quest = doc.data(); final quest = doc.data();
if (!_open(quest, now, uid)) continue; if (!_open(quest, now, uid)) continue;
// 카드 한 장의 부가 조회가 실패해도 목록 전체를 깨지 않는다 — 그 카드만 시작 전으로 둔다. // 카드 한 장의 부가 조회가 실패해도 목록 전체를 깨지 않는다 — 그 카드만 시작 전으로 둔다.
@@ -133,7 +123,15 @@ class FirestoreQuestRepository implements QuestRepository {
} on Exception { } on Exception {
progress = const {}; 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 — *참여 인원 충족 시 퀘스트 종료*. 서버는 정원이 차면 // 명세 `523:18176` 퀘스트 종료 시점 1 — *참여 인원 충족 시 퀘스트 종료*. 서버는 정원이 차면
// 참여를 거부하므로(미배정 Pool 0), 아직 참여하지 않은 사람에게는 열린 퀘스트로 보이지 않게 한다. // 참여를 거부하므로(미배정 Pool 0), 아직 참여하지 않은 사람에게는 열린 퀘스트로 보이지 않게 한다.
// 이미 참여한 사람(진행중 · 완료 탭)과 생성자(내 퀘스트)에게는 그대로 남긴다. // 이미 참여한 사람(진행중 · 완료 탭)과 생성자(내 퀘스트)에게는 그대로 남긴다.
@@ -143,15 +141,46 @@ class FirestoreQuestRepository implements QuestRepository {
return cards; return cards;
} }
/// 문서가 쓰인 시각. Admin 이 쓴 문서에는 `createdAt` 이 없고 `updatedAt` 만 있다 /// 진행 세션이 있는 걸음 Quest 의 오늘 걸음 — Walk 는 화면 밖 걸음까지 **실제 상태**로 보인다(원장 C-62 · C-83).
/// (`fn-admin-write`). 둘 다 없으면 정렬 근거가 없다는 뜻이라 null 이다. /// Health 를 한 번 읽고, 못 쓰면 이번 실행에서 센서로 센 값, 그것도 없으면 null(세션의 마지막 값).
DateTime? _writtenAt(Map<String, dynamic> quest) { Future<int?> _walkFor(Map<String, dynamic> quest, Map<String, dynamic> progress) async {
for (final key in ['createdAt', 'updatedAt']) { if (QuestType.parse(quest.strOr('type')) != QuestType.steps || progress['session'] is! Map) {
final value = quest[key];
if (value is Timestamp) return value.toDate();
}
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<String, dynamic> progress) {
final raw = progress['session'];
if (raw is! Map) return null;
final m = Map<String, dynamic>.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`. /// 지금 참여할 수 있는 기간인가 — 설계서 §2.8 `activeFrom`/`activeTo`.
/// ///
@@ -185,6 +214,7 @@ class FirestoreQuestRepository implements QuestRepository {
String uid, String uid,
Map<String, dynamic> policy, [ Map<String, dynamic> policy, [
QuestSubmission? submission, QuestSubmission? submission,
int? walk,
]) { ]) {
final type = QuestType.parse(quest.strOr('type')); final type = QuestType.parse(quest.strOr('type'));
final authorUid = quest.strOr('authorUid'); final authorUid = quest.strOr('authorUid');
@@ -199,8 +229,13 @@ class FirestoreQuestRepository implements QuestRepository {
: type == QuestType.musicShake : type == QuestType.musicShake
? quest.intOr('bpm') ? quest.intOr('bpm')
: dailyLimit; : 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( return QuestCard(
session: session,
id: id, id: id,
title: questTitle(quest), title: questTitle(quest),
categoryName: type.category, categoryName: type.category,
@@ -213,10 +248,20 @@ class FirestoreQuestRepository implements QuestRepository {
// 검토 대기는 「진행중」 탭에 둔다 — 지급 전이라 완료가 아니다(G-03). // 검토 대기는 「진행중」 탭에 둔다 — 지급 전이라 완료가 아니다(G-03).
: submission?.pending == true : submission?.pending == true
? QuestStage.inProgress ? QuestStage.inProgress
: QuestStage.notStarted, : session == null
goal: authorUid.isEmpty ? goal : quest.intOr('goal', 1), ? 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 이다. 나머지는 당일 수행 횟수다. // 걸음은 센서를 구독해야 알 수 있어 시작 전에는 0 이다. 나머지는 당일 수행 횟수다.
progress: type.gathers ? (done ? goal : 0) : count, progress: type.gathers ? (done ? goal : walked ?? 0) : count,
rewardPop: openTimeAttack(quest)?.rewardPop ?? type.rewardFrom(quest, policy), rewardPop: openTimeAttack(quest)?.rewardPop ?? type.rewardFrom(quest, policy),
// 설계서 §2.8 의 `quests` 에 참여 인원 필드가 없다. 세는 곳이 없어 0 이다. // 설계서 §2.8 의 `quests` 에 참여 인원 필드가 없다. 세는 곳이 없어 0 이다.
participants: quest.intOr('participantCount'), participants: quest.intOr('participantCount'),
@@ -249,7 +294,15 @@ class FirestoreQuestRepository implements QuestRepository {
.get() .get()
.then((s) => s.data() ?? const <String, dynamic>{}); .then((s) => s.data() ?? const <String, dynamic>{});
final submission = await _submission(questId, quest); 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 link = quest['link'];
final repeat = quest['repeat'] is Map ? Map<String, dynamic>.from(quest['repeat'] as Map) : null; final repeat = quest['repeat'] is Map ? Map<String, dynamic>.from(quest['repeat'] as Map) : null;
final endsAt = quest['endsAt']; final endsAt = quest['endsAt'];
@@ -474,13 +527,64 @@ class FirestoreQuestRepository implements QuestRepository {
'description': description, 'description': description,
}); });
/// 시작·중단은 세션 상태다. /// 진행중 · 일시 정지 · 진행완료 · 중단을 서버 진행 문서에 남긴다(`fn-quest-progress` · 원장 C-83).
/// ///
/// 설계서의 `questProgress` 는 **당일 수행 횟수와 지급액**만 담는다(§2.1). 참여 /// 진행 문서는 규칙상 서버 전용이라 함수로만 쓴다. 보상 상태는 여전히 [verify] 가 지급에 성공한 뒤
/// 시작 시각을 남기는 필드가 없어 서버에 「진행중」이 존재하지 않는다. 보상 상태는 /// 서버 카운터로 확정되고, 지급되면 서버가 세션을 지운다.
/// [verify] 가 지급에 성공한 뒤 서버 카운터로 확정된다.
@override @override
Future<QuestStage> advance(String questId, QuestStage next) async => next; Future<QuestStage> 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<QuestCard?> 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<String, dynamic> 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). 앱은 /// 생성 · 예치 · 수수료 · 공개는 `fn-quest-create` 가 원장과 함께 한다(B-28 · B-30). 앱은
/// `quests` 에 직접 쓰지 않는다(규칙도 마스터 전용). 종료일은 **생성자 기기 시간대의 그 날짜 /// `quests` 에 직접 쓰지 않는다(규칙도 마스터 전용). 종료일은 **생성자 기기 시간대의 그 날짜
@@ -643,6 +747,14 @@ class FirestoreQuestRepository implements QuestRepository {
} }
Future<Map<String, dynamic>> _verifySteps(QuestCard card) async { Future<Map<String, dynamic>> _verifySteps(QuestCard card) async {
if (_walkSteps <= 0) {
// 목록에서 바로 「보상받기」(진행 화면을 거치지 않음) — 걸음을 한 번 읽는다.
try {
await _steps().first.timeout(const Duration(seconds: 5));
} on Exception {
// 아래에서 「세지 못함」으로 끊는다.
}
}
if (_walkSteps <= 0) { if (_walkSteps <= 0) {
// 센서 값이 아직 오지 않았다. 0 을 보내면 서버가 미완주로 판정하지만, // 센서 값이 아직 오지 않았다. 0 을 보내면 서버가 미완주로 판정하지만,
// 원인이 「걷지 않았다」가 아니라 「세지 못했다」라 사유가 달라진다. // 원인이 「걷지 않았다」가 아니라 「세지 못했다」라 사유가 달라진다.
@@ -687,7 +799,13 @@ class FirestoreQuestRepository implements QuestRepository {
Future<Map<String, dynamic>> _verifyShake(QuestCard card) async { Future<Map<String, dynamic>> _verifyShake(QuestCard card) async {
final run = _shakeRun; final run = _shakeRun;
if (run == null) throw BackendException('E-QST-01', appL10n.questNoShake); // 끝낸 판을 서버 세션에 남겨 두었다면 그 판정표로 — 목록에서 바로 「보상받기」(원장 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() ?? final quest = (await _backend.db.doc('quests/${card.id}').get()).data() ??
const <String, dynamic>{}; const <String, dynamic>{};
final ids = quest['trackIds']; final ids = quest['trackIds'];
@@ -700,8 +818,8 @@ class FirestoreQuestRepository implements QuestRepository {
return _backend.call('fnQuestVerifyMusicShake', { return _backend.call('fnQuestVerifyMusicShake', {
'questId': card.id, 'questId': card.id,
'trackId': trackId, 'trackId': trackId,
'totalSegments': run.segments, 'totalSegments': segments,
'judgement': run.judgement(), 'judgement': judgement,
'occurredAt': DateTime.now().toUtc().toIso8601String(), 'occurredAt': DateTime.now().toUtc().toIso8601String(),
...await _fandomField(), ...await _fandomField(),
}); });
@@ -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<PausedQuestCapsule> createState() => _PausedQuestCapsuleState();
}
class _PausedQuestCapsuleState extends State<PausedQuestCapsule> {
QuestCard? _card;
@override
void initState() {
super.initState();
questSessionSignal.addListener(_load);
_load();
}
@override
void dispose() {
questSessionSignal.removeListener(_load);
super.dispose();
}
Future<void> _load() async {
QuestCard? card;
try {
card = await widget.repository.pausedQuest();
} catch (_) {
card = null; // 캡슐은 보조 표시다 — 못 읽으면 띄우지 않는다.
}
if (mounted) setState(() => _card = card);
}
Future<void> _open(QuestCard card) async {
await Navigator.of(context, rootNavigator: true).push<void>(
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,
),
),
),
],
),
),
),
),
),
);
}
}
@@ -70,6 +70,18 @@ class QuestIconBox extends StatelessWidget {
_ => (bg: AppColors.onCosmic, fg: Colors.black), _ => (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). /// 목록의 퀘스트 카드 한 장 — 시안 `264:46867` 카드(#1a1a1c r12 · 안쪽 20).
/// ///
/// 명세 — *퀘스트 아이콘 · 퀘스트 명 · 퀘스트 카테고리 · 진행 상태 값 ·* /// 명세 — *퀘스트 아이콘 · 퀘스트 명 · 퀘스트 카테고리 · 진행 상태 값 ·*
@@ -135,7 +147,10 @@ class QuestCardTile extends StatelessWidget {
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
card.categoryName, // 진행 상태 값 「일시 정지」 · 멈춘 진행값(원장 C-83 — 기존 카드 영역 안).
card.stage == QuestStage.paused
? pausedLabel(card)
: card.categoryName,
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
+2
View File
@@ -585,6 +585,8 @@
"planetJoin": "Join", "planetJoin": "Join",
"questDetails": "Details", "questDetails": "Details",
"questInProgress": "In progress", "questInProgress": "In progress",
"questResume": "Resume",
"questPaused": "Paused",
"questTypeAttend": "Check-in", "questTypeAttend": "Check-in",
"questTypeSteps": "Walking", "questTypeSteps": "Walking",
"questTypePlace": "Location", "questTypePlace": "Location",
+2
View File
@@ -585,6 +585,8 @@
"planetJoin": "Unirse", "planetJoin": "Unirse",
"questDetails": "Detalles", "questDetails": "Detalles",
"questInProgress": "En curso", "questInProgress": "En curso",
"questResume": "Reanudar",
"questPaused": "En pausa",
"questTypeAttend": "Asistencia", "questTypeAttend": "Asistencia",
"questTypeSteps": "Caminar", "questTypeSteps": "Caminar",
"questTypePlace": "Ubicación", "questTypePlace": "Ubicación",
+2
View File
@@ -585,6 +585,8 @@
"planetJoin": "参加する", "planetJoin": "参加する",
"questDetails": "詳細", "questDetails": "詳細",
"questInProgress": "進行中", "questInProgress": "進行中",
"questResume": "再開する",
"questPaused": "一時停止",
"questTypeAttend": "出席", "questTypeAttend": "出席",
"questTypeSteps": "ウォーキング", "questTypeSteps": "ウォーキング",
"questTypePlace": "特定の場所", "questTypePlace": "特定の場所",
+2
View File
@@ -1162,6 +1162,8 @@
"planetJoin": "가입하기", "planetJoin": "가입하기",
"questDetails": "자세히", "questDetails": "자세히",
"questInProgress": "진행중", "questInProgress": "진행중",
"questResume": "이어하기",
"questPaused": "일시 정지",
"questTypeAttend": "출석", "questTypeAttend": "출석",
"questTypeSteps": "걷기", "questTypeSteps": "걷기",
"questTypePlace": "특정 위치", "questTypePlace": "특정 위치",
+12
View File
@@ -3614,6 +3614,18 @@ abstract class AppLocalizations {
/// **'진행중'** /// **'진행중'**
String get questInProgress; 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. /// No description provided for @questTypeAttend.
/// ///
/// In ko, this message translates to: /// In ko, this message translates to:
+6
View File
@@ -2033,6 +2033,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get questInProgress => 'In progress'; String get questInProgress => 'In progress';
@override
String get questResume => 'Resume';
@override
String get questPaused => 'Paused';
@override @override
String get questTypeAttend => 'Check-in'; String get questTypeAttend => 'Check-in';
+6
View File
@@ -2045,6 +2045,12 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get questInProgress => 'En curso'; String get questInProgress => 'En curso';
@override
String get questResume => 'Reanudar';
@override
String get questPaused => 'En pausa';
@override @override
String get questTypeAttend => 'Asistencia'; String get questTypeAttend => 'Asistencia';
+6
View File
@@ -1973,6 +1973,12 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get questInProgress => '進行中'; String get questInProgress => '進行中';
@override
String get questResume => '再開する';
@override
String get questPaused => '一時停止';
@override @override
String get questTypeAttend => '出席'; String get questTypeAttend => '出席';
+6
View File
@@ -1974,6 +1974,12 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get questInProgress => '진행중'; String get questInProgress => '진행중';
@override
String get questResume => '이어하기';
@override
String get questPaused => '일시 정지';
@override @override
String get questTypeAttend => '출석'; String get questTypeAttend => '출석';
+6
View File
@@ -1971,6 +1971,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get questInProgress => '进行中'; String get questInProgress => '进行中';
@override
String get questResume => '继续';
@override
String get questPaused => '已暂停';
@override @override
String get questTypeAttend => '签到'; String get questTypeAttend => '签到';
+2
View File
@@ -585,6 +585,8 @@
"planetJoin": "加入", "planetJoin": "加入",
"questDetails": "详情", "questDetails": "详情",
"questInProgress": "进行中", "questInProgress": "进行中",
"questResume": "继续",
"questPaused": "已暂停",
"questTypeAttend": "签到", "questTypeAttend": "签到",
"questTypeSteps": "步行", "questTypeSteps": "步行",
"questTypePlace": "指定地点", "questTypePlace": "指定地点",
+8 -2
View File
@@ -1310,10 +1310,10 @@ class RemoteAppLocalizations extends AppLocalizations {
String get nebulaNameHint => _remote['nebulaNameHint'] ?? _base.nebulaNameHint; String get nebulaNameHint => _remote['nebulaNameHint'] ?? _base.nebulaNameHint;
@override @override
String get nebulaNameTaken => _remote['nebulaNameTaken'] ?? _base.nebulaNameTaken; String get nebulaNameInvalid => _remote['nebulaNameInvalid'] ?? _base.nebulaNameInvalid;
@override @override
String get nebulaNameInvalid => _remote['nebulaNameInvalid'] ?? _base.nebulaNameInvalid; String get nebulaNameTaken => _remote['nebulaNameTaken'] ?? _base.nebulaNameTaken;
@override @override
String get nicknameEdit => _remote['nicknameEdit'] ?? _base.nicknameEdit; String get nicknameEdit => _remote['nicknameEdit'] ?? _base.nicknameEdit;
@@ -2071,6 +2071,9 @@ class RemoteAppLocalizations extends AppLocalizations {
@override @override
String questParticipantsCount(String count) => _fill('questParticipantsCount', {'count': count}) ?? _base.questParticipantsCount(count); String questParticipantsCount(String count) => _fill('questParticipantsCount', {'count': count}) ?? _base.questParticipantsCount(count);
@override
String get questPaused => _remote['questPaused'] ?? _base.questPaused;
@override @override
String get questPickPlace => _remote['questPickPlace'] ?? _base.questPickPlace; String get questPickPlace => _remote['questPickPlace'] ?? _base.questPickPlace;
@@ -2116,6 +2119,9 @@ class RemoteAppLocalizations extends AppLocalizations {
@override @override
String get questResubmitNotAllowed => _remote['questResubmitNotAllowed'] ?? _base.questResubmitNotAllowed; String get questResubmitNotAllowed => _remote['questResubmitNotAllowed'] ?? _base.questResubmitNotAllowed;
@override
String get questResume => _remote['questResume'] ?? _base.questResume;
@override @override
String get questReviewButton => _remote['questReviewButton'] ?? _base.questReviewButton; String get questReviewButton => _remote['questReviewButton'] ?? _base.questReviewButton;
+70
View File
@@ -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_detail_screen.dart';
import 'package:fancake/features/quest/quest_list_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/quest_reward_screen.dart';
import 'package:fancake/features/quest/widgets/paused_quest_capsule.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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 { testWidgets('F4 화면 문구에 내부 경로가 새지 않는다', (tester) async {
for (final screen in <Widget>[ for (final screen in <Widget>[
const QuestListScreen(repository: _sample), 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<List<QuestCard>> load({int limit = 10}) async => const [_card];
@override
Future<List<QuestCard>> more({required int offset, int limit = 10}) async => const [];
@override
Future<QuestDetail> detail(String questId) async =>
const QuestDetail(card: _card, imageLabel: '', description: '');
@override
Future<QuestCard?> pausedQuest() async => _card;
}
+47
View File
@@ -124,4 +124,51 @@ void main() {
expect(shared!.text, contains('PERFECT 5 · GOOD 2 · MISS 1 · MAX COMBO 4')); expect(shared!.text, contains('PERFECT 5 · GOOD 2 · MISS 1 · MAX COMBO 4'));
expect(shared!.text, contains('#FANUZU')); expect(shared!.text, contains('#FANUZU'));
}); });
testWidgets('중간에 나가면 null · 넘긴 판정표에 판정과 영상 시각이 남고, 이어하면 그 판정을 잇는다(C-83)', (tester) async {
final shakes = StreamController<DateTime>();
addTearDown(shakes.close);
late ValueChanged<ShakePlayerEvent> 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<ShakeJudge>(
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);
});
} }
+120 -1
View File
@@ -1,5 +1,5 @@
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:cloud_firestore/cloud_firestore.dart' show SetOptions; import 'package:cloud_firestore/cloud_firestore.dart' show SetOptions, Timestamp;
import 'package:fancake/core/data/backend.dart'; import 'package:fancake/core/data/backend.dart';
import 'package:fancake/features/quest/quest_data.dart'; import 'package:fancake/features/quest/quest_data.dart';
import 'package:fancake/features/quest/quest_evidence.dart'; import 'package:fancake/features/quest/quest_evidence.dart';
@@ -627,4 +627,123 @@ service cloud.firestore {
expect(live.capacity, 5); expect(live.capacity, 5);
}); });
}); });
group('진행 상태 저장 · 일시 정지 · 최신순 (원장 C-83 · 명세 518:88395)', () {
Future<FakeFirebaseFirestore> 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));
});
});
} }