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

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
+118 -15
View File
@@ -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<QuestDetailScreen> {
/// 방금 끝낸 Music Shake 판의 판정 합계 — 결과 화면 · 결과 카드에 쓴다.
Map<String, int>? _shakeResult;
/// 진행 세션(원장 C-83) — 서버에서 읽은 값을 이 화면에서 멈추면 바로 갈아 끼운다(「이어하기」 시작점).
QuestSession? _session;
/// [QuestDetailScreen.autoAct] 를 한 번만 쓴다.
bool _autoActed = false;
@override
void initState() {
super.initState();
@@ -94,12 +105,35 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
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<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
@@ -231,6 +265,15 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
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<QuestDetailScreen> {
}
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<QuestDetailScreen> {
/// 「시작하기」 — 진행중으로 바꾸고 실측 수집을 켠다.
Future<void> _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<void> _playShake(QuestDetail detail) async {
/// 끝까지 했으면 판정표를 남기고 「보상받기」로, 중간에 나왔으면 **일시 정지**로 둔다(원장 C-83).
/// [resume] 이 있으면 멈춘 판정 · 영상 시각부터 잇는다(「이어하기」).
Future<void> _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<QuestDetailScreen> {
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<ShakeJudge>(
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<QuestDetailScreen> {
.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<QuestDetailScreen> {
);
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<QuestDetailScreen> {
_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' &&