퀘스트 일시 정지 · 이어하기 · 진행 상태 서버 저장 · 목록 보상받기
This commit is contained in:
@@ -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<DateTime> shakes;
|
||||
|
||||
/// 영상 플레이어. 기본은 [YoutubeEmbed]. 테스트는 상태를 직접 넣는 가짜를 준다.
|
||||
@@ -44,17 +49,19 @@ class MusicShakePlayScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> {
|
||||
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<DateTime>? _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<MusicShakePlayScreen> {
|
||||
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<MusicShakePlayScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _defaultPlayer(ValueChanged<ShakePlayerEvent> onEvent) =>
|
||||
YoutubeEmbed(videoId: widget.setup.videoId, onEvent: onEvent);
|
||||
Widget _defaultPlayer(ValueChanged<ShakePlayerEvent> 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<ShakePlayerEvent> 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}) =>
|
||||
'''
|
||||
<!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>
|
||||
@@ -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<YoutubeEmbed> {
|
||||
..setBackgroundColor(Colors.black)
|
||||
..addJavaScriptChannel('Player', onMessageReceived: _onMessage)
|
||||
..loadHtmlString(
|
||||
YoutubeEmbed.html(widget.videoId),
|
||||
YoutubeEmbed.html(widget.videoId, startSeconds: widget.start.inSeconds),
|
||||
baseUrl: YoutubeEmbed.appOrigin,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<double> hits;
|
||||
|
||||
/// 끝까지 한 Music Shake 판의 판정 합계 — 목록에서 바로 「보상받기」할 때 서버에 보낸다.
|
||||
final Map<String, int>? judgement;
|
||||
}
|
||||
|
||||
/// 일시 정지 · 진행 세션이 바뀌었다 — 탭 화면 하단 캡슐(원장 C-83)이 다시 읽는다.
|
||||
final questSessionSignal = ValueNotifier<int>(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<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);
|
||||
|
||||
@@ -881,7 +942,14 @@ class SampleQuestRepository implements QuestRepository {
|
||||
Future<void> cancel(String questId) async {}
|
||||
|
||||
@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
|
||||
Future<String> create(QuestDraft draft) async => 'q-new';
|
||||
|
||||
@@ -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' &&
|
||||
|
||||
@@ -159,15 +159,28 @@ class ShakeJudge {
|
||||
required this.bpm,
|
||||
required this.segments,
|
||||
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 int segments;
|
||||
final ShakeWindows windows;
|
||||
|
||||
/// 마지막으로 본 영상 시각 — 진행 화면을 나가면 「이어하기」의 시작점이 된다.
|
||||
Duration position;
|
||||
|
||||
/// 박 번호 → 가장 가까운 흔들기의 거리(박 비율).
|
||||
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());
|
||||
|
||||
/// 마지막 박의 판정 창이 닫히는 영상 시각.
|
||||
|
||||
@@ -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<QuestListScreen> {
|
||||
/// 줄로, 사용자 퀘스트를 아래 카드로 나눈다. 다른 탭은 카드만 있다.
|
||||
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>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => QuestDetailScreen(
|
||||
questId: card.id,
|
||||
repository: widget.repository,
|
||||
autoAct: act,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -140,9 +143,14 @@ class _QuestListScreenState extends State<QuestListScreen> {
|
||||
}
|
||||
|
||||
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).
|
||||
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<QuestListScreen> {
|
||||
));
|
||||
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 = [
|
||||
|
||||
@@ -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<Map<String, dynamic>> 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<Map<String, dynamic>> 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 = <QuestCard>[];
|
||||
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<String, dynamic> 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<int?> _walkFor(Map<String, dynamic> quest, Map<String, dynamic> 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<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`.
|
||||
@@ -185,6 +214,7 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
String uid,
|
||||
Map<String, dynamic> 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 <String, dynamic>{});
|
||||
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<String, dynamic>.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<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). 앱은
|
||||
/// `quests` 에 직접 쓰지 않는다(규칙도 마스터 전용). 종료일은 **생성자 기기 시간대의 그 날짜
|
||||
@@ -643,6 +747,14 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
}
|
||||
|
||||
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) {
|
||||
// 센서 값이 아직 오지 않았다. 0 을 보내면 서버가 미완주로 판정하지만,
|
||||
// 원인이 「걷지 않았다」가 아니라 「세지 못했다」라 사유가 달라진다.
|
||||
@@ -687,7 +799,13 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
|
||||
Future<Map<String, dynamic>> _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 <String, dynamic>{};
|
||||
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(),
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
/// 일시 정지 표기 — 「카테고리 · 일시 정지 · 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,
|
||||
|
||||
Reference in New Issue
Block a user