사용자 화면 문구·표본 데이터 · 퀘스트 참여 흐름 · HOME 진입 구획 · 미니맵 · 공통 확인 창 배치 수정
This commit is contained in:
@@ -2,13 +2,14 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/ui/app_dialog.dart';
|
||||
|
||||
import '../../core/data/backend.dart';
|
||||
import '../../core/data/server_clock.dart';
|
||||
import '../../core/monetization/rewarded_ad.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/ui/app_icons.dart';
|
||||
import '../home/home_screen.dart' show formatPop;
|
||||
import '../planet/fpi_ranking_screen.dart' show MockBadge;
|
||||
import '../planet/moderation.dart';
|
||||
import 'quest_ad_screens.dart';
|
||||
import 'quest_create_screen.dart' show QuestLinkCard;
|
||||
@@ -135,9 +136,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
color: Color(0xFF2E2E2E),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: image == null
|
||||
? null
|
||||
: Image.asset(image, width: 200, height: 200),
|
||||
child: Image.asset(image, width: 200, height: 200),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
@@ -232,8 +231,11 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
),
|
||||
),
|
||||
// 회신 대기 유형은 참여를 열지 않는다 — 해석 확인 요청서 8.1 보류 범위.
|
||||
// 실데이터가 아닌 표시는 기존 관례대로 MockBadge 로 알린다.
|
||||
if (blocked != null) MockBadge(note: blocked),
|
||||
if (blocked != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: Text(blocked, style: _strong),
|
||||
),
|
||||
if (submission != null) ..._submissionStatus(submission),
|
||||
if (detail.reviews.isNotEmpty) ..._reviewQueue(detail),
|
||||
],
|
||||
@@ -290,7 +292,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
),
|
||||
child: Text(
|
||||
blocked != null
|
||||
? '발주사 확인 중'
|
||||
? context.l10n.commonComingSoon
|
||||
: !open
|
||||
? context.l10n.questTimeAttackClosed
|
||||
: _busy
|
||||
@@ -388,34 +390,23 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
var resubmit = false;
|
||||
if (!approve) {
|
||||
final input = TextEditingController();
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setDialog) => AlertDialog(
|
||||
title: Text(context.l10n.questReject),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: input,
|
||||
decoration: InputDecoration(labelText: context.l10n.questRejectReason),
|
||||
),
|
||||
CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: resubmit,
|
||||
onChanged: (v) => setDialog(() => resubmit = v ?? false),
|
||||
title: Text(context.l10n.questAllowResubmit),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(context.l10n.commonCancel),
|
||||
final ok = await showConfirmDialog(
|
||||
context,
|
||||
context.l10n.questReject,
|
||||
confirmLabel: context.l10n.questReject,
|
||||
content: StatefulBuilder(
|
||||
builder: (context, setDialog) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: input,
|
||||
decoration: InputDecoration(labelText: context.l10n.questRejectReason),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(context.l10n.questReject),
|
||||
CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: resubmit,
|
||||
onChanged: (v) => setDialog(() => resubmit = v ?? false),
|
||||
title: Text(context.l10n.questAllowResubmit),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -423,7 +414,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
);
|
||||
reason = input.text.trim();
|
||||
input.dispose();
|
||||
if (ok != true || reason.isEmpty) return;
|
||||
if (!ok || reason.isEmpty) return;
|
||||
}
|
||||
await _run(() => widget.repository.review(
|
||||
s.id,
|
||||
@@ -437,11 +428,11 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
Future<void> _submitProof(QuestDetail detail) async {
|
||||
final url = TextEditingController();
|
||||
final text = TextEditingController();
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.questSubmit),
|
||||
content: Column(
|
||||
final ok = await showConfirmDialog(
|
||||
context,
|
||||
context.l10n.questSubmit,
|
||||
confirmLabel: context.l10n.commonSubmit,
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
@@ -457,23 +448,12 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(context.l10n.commonCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(context.l10n.commonSubmit),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final proofUrl = url.text.trim();
|
||||
final proofText = text.text.trim();
|
||||
url.dispose();
|
||||
text.dispose();
|
||||
if (ok != true || (proofUrl.isEmpty && proofText.isEmpty)) return;
|
||||
if (!ok || (proofUrl.isEmpty && proofText.isEmpty)) return;
|
||||
final done = await _run(
|
||||
() => widget.repository.submit(detail.card.id, url: proofUrl, text: proofText),
|
||||
);
|
||||
@@ -484,11 +464,11 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
Future<void> _edit(QuestDetail detail) async {
|
||||
final title = TextEditingController(text: detail.card.title);
|
||||
final description = TextEditingController(text: detail.description);
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.questEdit),
|
||||
content: Column(
|
||||
final ok = await showConfirmDialog(
|
||||
context,
|
||||
context.l10n.questEdit,
|
||||
confirmLabel: context.l10n.commonEdit,
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(context.l10n.questEditNotice, style: _caption),
|
||||
@@ -504,17 +484,6 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(context.l10n.commonCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(context.l10n.commonEdit),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final newTitle = title.text.trim();
|
||||
final newDescription = description.text.trim();
|
||||
@@ -619,24 +588,13 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
/// 명세 `518:88552` — *진행중 버튼 클릭 시 노출 · 취소 버튼 모달 종료 ·*
|
||||
/// *중단 버튼 퀘스트 진행 상태값 시작전으로 업데이트*
|
||||
Future<void> _confirmStop(QuestDetail detail) async {
|
||||
final stop = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.questStopConfirm),
|
||||
content: Text(context.l10n.questStopBody),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(context.l10n.commonCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(context.l10n.questStop),
|
||||
),
|
||||
],
|
||||
),
|
||||
final stop = await showConfirmDialog(
|
||||
context,
|
||||
context.l10n.questStopConfirm,
|
||||
body: context.l10n.questStopBody,
|
||||
confirmLabel: context.l10n.questStop,
|
||||
);
|
||||
if (stop != true) return;
|
||||
if (!stop) return;
|
||||
_stopWatching();
|
||||
final next =
|
||||
await widget.repository.advance(detail.card.id, QuestStage.notStarted);
|
||||
@@ -650,46 +608,21 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
|
||||
/// 보상받기.
|
||||
///
|
||||
/// 순서는 **참여 조건 판정 → 기본 보상 → 광고 선택**이다. 위치 · 권한 · 걸음 · 일일 한도는
|
||||
/// 서버가 [QuestRepository.verify] 에서 판정하므로, 그 판정이 통과한 뒤에만 광고를 권한다.
|
||||
/// 광고를 먼저 보이면 끝까지 보고도 위치 실패로 아무것도 못 받는 경로가 생긴다(2026-10-04 실기기 4-3).
|
||||
///
|
||||
/// 명세는 *관리자 퀘스트만 추가 보상 모달 · 사용자 퀘스트는 일반 보상 완료* 로 나눴지만
|
||||
/// 별첨 B **B-28** 이 사용자 생성 Quest 에도 고정형 기여 POP Reward Ad 를 확정했다
|
||||
/// (공통 기준 2항 — 확정 정책 우선). 그래서 두 갈래 모두 모달을 띄우고 금액만 다르다.
|
||||
Future<void> _claim(QuestDetail detail) async {
|
||||
if (_busy) return;
|
||||
final bonus =
|
||||
await showQuestAdBonus(context, bonusPop: detail.card.adBonusPop) ?? false;
|
||||
if (!mounted) return;
|
||||
// 끝까지 본 광고의 노출 ID. null 이면 추가 보상을 청구하지 않는다.
|
||||
String? impression;
|
||||
if (bonus) {
|
||||
impression = await _watchAd();
|
||||
if (!mounted) return;
|
||||
}
|
||||
|
||||
// 판정과 지급은 서버가 한다. 화면은 결과를 받아 띄우기만 한다 —
|
||||
// 별첨 1 ② 「클라이언트 단독 지급 0건」.
|
||||
// 기본 보상을 먼저 받고 광고 증분을 뒤에 청구한다(원장 B-03 데이터 관계) —
|
||||
// 광고 쪽이 실패해도 기본 보상은 이미 지급되어 있다.
|
||||
setState(() => _busy = true);
|
||||
final QuestReward reward;
|
||||
int? adPop;
|
||||
BackendException? adError;
|
||||
try {
|
||||
final pending = () async {
|
||||
final base = await widget.repository.verify(detail.card);
|
||||
if (impression != null && !base.duplicate) {
|
||||
try {
|
||||
adPop = await widget.repository.claimAdReward(impression, base);
|
||||
} on BackendException catch (error) {
|
||||
adError = error;
|
||||
}
|
||||
}
|
||||
return base;
|
||||
}();
|
||||
// 광고 시청 후 대기(`482:15084`) — 서버가 판정·지급하는 동안 띄운다.
|
||||
if (impression != null) {
|
||||
unawaited(_warp(QuestAdWarpScreen.after(until: pending)));
|
||||
}
|
||||
reward = await pending;
|
||||
reward = await widget.repository.verify(detail.card);
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
setState(() => _busy = false);
|
||||
@@ -714,9 +647,30 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
await _load();
|
||||
return;
|
||||
}
|
||||
// 한도 초과(E-AD-02) 등 — 기본 보상은 받았으니 보상 화면은 그대로 띄운다.
|
||||
final failed = adError;
|
||||
if (failed != null) _toast(failed.message);
|
||||
|
||||
// 기본 보상은 이미 원장에 있다. 광고는 그 위의 선택적 증분이다(원장 B-03 · C-122) —
|
||||
// 광고가 실패해도 기본 보상은 그대로다.
|
||||
final bonus =
|
||||
await showQuestAdBonus(context, bonusPop: detail.card.adBonusPop) ?? false;
|
||||
if (!mounted) return;
|
||||
int? adPop;
|
||||
if (bonus) {
|
||||
// 끝까지 본 광고의 노출 ID. null 이면 추가 보상을 청구하지 않는다.
|
||||
final impression = await _watchAd();
|
||||
if (!mounted) return;
|
||||
if (impression != null) {
|
||||
final claim = widget.repository.claimAdReward(impression, reward);
|
||||
// 광고 시청 후 대기(`482:15084`) — 서버가 증분을 지급하는 동안 띄운다.
|
||||
unawaited(_warp(QuestAdWarpScreen.after(until: claim)));
|
||||
try {
|
||||
adPop = await claim;
|
||||
} on BackendException catch (error) {
|
||||
// 한도 초과(E-AD-02) 등 — 기본 보상은 받았으니 보상 화면은 그대로 띄운다.
|
||||
if (mounted) _toast(error.message);
|
||||
}
|
||||
if (!mounted) return;
|
||||
}
|
||||
}
|
||||
|
||||
await Navigator.of(context, rootNavigator: true).push<bool>(
|
||||
MaterialPageRoute(
|
||||
|
||||
Reference in New Issue
Block a user