광고 추가 보상 서버 시청 검증 · 사용자 퀘스트 승인 후 공개 · TIME ATTACK 모드 · 만보기 퀘스트 · 위치 퀘스트 지도 선택 · 운영 퀘스트 보상 재원
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user