사용자 화면 문구·표본 데이터 · 퀘스트 참여 흐름 · HOME 진입 구획 · 미니맵 · 공통 확인 창 배치 수정

This commit is contained in:
Sn-Kinos
2026-10-05 00:44:39 +09:00
parent c8d29d032b
commit 8a0297eb92
66 changed files with 1812 additions and 959 deletions
+72 -118
View File
@@ -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(