광고 추가 보상 서버 시청 검증 · 사용자 퀘스트 승인 후 공개 · TIME ATTACK 모드 · 만보기 퀘스트 · 위치 퀘스트 지도 선택 · 운영 퀘스트 보상 재원

This commit is contained in:
Sn-Kinos
2026-10-05 22:48:09 +09:00
parent 4bb92c2ee7
commit fbf9a2c3d7
39 changed files with 1871 additions and 477 deletions
@@ -120,8 +120,11 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
size: 24,
color: AppColors.onCosmic,
),
onSelected: (value) =>
value == 'edit' ? _edit(detail) : _report(card.title),
onSelected: (value) => switch (value) {
'edit' => _edit(detail),
'cancel' => _cancel(detail),
_ => _report(card.title),
},
itemBuilder: (_) => [
// 공개 후 수정은 생성자의 제목 · 설명뿐이다(C-07).
if (stage == QuestStage.mine && ugc)
@@ -129,6 +132,15 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
value: 'edit',
child: Text(context.l10n.questEdit),
),
// 생성자 취소(B-30) — 검수 중 · 공개 중에만.
if (stage == QuestStage.mine &&
ugc &&
(detail.publishState == 'review' ||
detail.publishState == 'published'))
PopupMenuItem(
value: 'cancel',
child: Text(context.l10n.questCancel),
),
PopupMenuItem(
value: 'report',
child: Text(context.l10n.reportTitle),
@@ -309,7 +321,38 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Text(blocked, style: _strong),
),
// 공개 전 검수(1주차 4항 · Q8) — 생성자에게 검수 대기 · 반려 사유를 보인다.
if (stage == QuestStage.mine && detail.publishState == 'review')
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Text(context.l10n.questUnderReview, style: _strong),
),
if (stage == QuestStage.mine && detail.publishState == 'rejected')
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Text(
context.l10n.questModerationRejected(
detail.moderationReason ?? '',
),
style: _strong,
),
),
if (submission != null) ..._submissionStatus(submission),
// 검토형 Quest 의 광고 추가 보상은 승인 · 지급된 날에 붙인다(10/3 통합회신 · Q-UQ-03 A).
if (submission != null &&
submission.status == 'paid' &&
submission.txId != null &&
!submission.adClaimed &&
submission.paidAt != null &&
_sameKstDay(submission.paidAt!, ServerClock.now()) &&
card.adBonusPop != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: OutlinedButton(
onPressed: _busy ? null : () => _approvedAdBonus(submission),
child: Text(context.l10n.questAdBonus('${card.adBonusPop}')),
),
),
if (detail.reviews.isNotEmpty) ..._reviewQueue(detail),
],
),
@@ -561,6 +604,43 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
}
/// 제목 · 설명만 고친다(C-07). 잠금 필드는 입력 자체가 없다.
static bool _sameKstDay(DateTime a, DateTime b) {
String day(DateTime t) => t
.toUtc()
.add(const Duration(hours: 9))
.toIso8601String()
.substring(0, 10);
return day(a) == day(b);
}
/// 승인 지급분에 광고 추가 보상 — 기본 보상 txId 를 SSV 에 실어 광고를 보고 서버에 청구한다.
Future<void> _approvedAdBonus(QuestSubmission submission) async {
final base = QuestReward(
rewardedPop: 0,
balanceAfter: 0,
txId: submission.txId,
);
final impression = await _watchAd(base);
if (impression == null || !mounted) return;
await _run(() async {
final pop = await widget.repository.claimAdReward(impression, base);
if (mounted) _toast(context.l10n.questAdBonus(formatPop(pop)));
});
}
Future<void> _cancel(QuestDetail detail) async {
final ok = await showConfirmDialog(
context,
context.l10n.questCancel,
body: detail.publishState == 'review'
? context.l10n.questCancelInReview
: context.l10n.questCancelPublished,
confirmLabel: context.l10n.questCancel,
);
if (ok != true || !mounted) return;
await _run(() => widget.repository.cancel(detail.card.id));
}
Future<void> _edit(QuestDetail detail) async {
final title = TextEditingController(text: detail.card.title);
final description = TextEditingController(text: detail.description);
@@ -774,7 +854,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
// 타임어택 창 밖(E-QST-01) — 서버가 본 상태로 다시 읽어 마감 · 완료를 반영한다.
if (error is BackendException &&
error.code == 'E-QST-01' &&
detail.card.type == QuestType.timeAttack) {
detail.card.endsAt != null) {
await _load();
}
return;
@@ -786,7 +866,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
_stage = QuestStage.rewarded;
});
// 시청 · 스트리밍은 무보상 참여다(C-04) — 보상 · 광고 화면 없이 참여만 알리고 링크를 연다.
if (detail.card.type == QuestType.streaming) {
if (detail.card.type == QuestType.streaming && detail.card.endsAt == null) {
_toast(context.l10n.questStreamJoined);
final url = Uri.tryParse(detail.link?.url ?? '');
if (url != null && url.hasScheme) {
@@ -811,7 +891,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
int? adPop;
if (bonus) {
// 끝까지 본 광고의 노출 ID. null 이면 추가 보상을 청구하지 않는다.
final impression = await _watchAd();
final impression = await _watchAd(reward);
if (!mounted) return;
if (impression != null) {
final claim = widget.repository.claimAdReward(impression, reward);
@@ -848,10 +928,10 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
/// 끝까지 봤으면 노출 ID, 아니면 null 이다. 불러오지 못했거나 중간에 닫았으면
/// 그 사실을 알리고 **기본 보상으로 넘어간다**(원장 B-03 — 실패 시 기본 보상 지급 ·
/// 횟수 미차감). 대기 중 「건너뛰기」를 누르면 늦게 온 광고는 띄우지 않고 버린다.
Future<String?> _watchAd() async {
Future<String?> _watchAd(QuestReward reward) async {
RewardedAdHandle? ad;
BackendException? failure;
final loading = widget.repository.loadRewardedAd().then<void>(
final loading = widget.repository.loadRewardedAd(reward).then<void>(
(handle) => ad = handle,
onError: (Object error) => failure = error is BackendException
? error