사용자 화면 문구·표본 데이터 · 퀘스트 참여 흐름 · HOME 진입 구획 · 미니맵 · 공통 확인 창 배치 수정
This commit is contained in:
@@ -95,7 +95,8 @@ class _AdBonusDialog extends StatelessWidget {
|
||||
child: Text(
|
||||
bonusPop == null
|
||||
? context.l10n.adBonusPlaceholder
|
||||
: '+${formatPop(bonusPop!)} POP',
|
||||
// 원장 C-116 — 운영값 기반 「+N 기여 POP」(증분은 기여 POP · C-122).
|
||||
: '+${formatPop(bonusPop!)} ${context.l10n.popKindContribution}',
|
||||
style: TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -285,7 +286,8 @@ class _QuestAdWarpScreenState extends State<QuestAdWarpScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 실패해도 닫는다 — 오류는 [until] 을 만든 쪽이 따로 받는다.
|
||||
unawaited(widget.until?.catchError((_) => null).whenComplete(_close));
|
||||
// `catchError((_) => null)` 은 `Future<int>` 처럼 null 을 못 받는 타입에서 던진다.
|
||||
unawaited(widget.until?.then<void>((_) {}, onError: (_) {}).whenComplete(_close));
|
||||
}
|
||||
|
||||
/// 맨 위가 아니어도 이 화면만 걷어 낸다. 지급이 끝나 보상 화면이 먼저 올라와도
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/ui/app_dialog.dart';
|
||||
|
||||
import '../../core/data/backend.dart';
|
||||
import '../../core/data/server_clock.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
@@ -401,12 +403,12 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
|
||||
Future<void> _confirm() async {
|
||||
final pool = _paid ? _pool : 0;
|
||||
final fee = _catalog?.feeOf(pool) ?? 0;
|
||||
final go = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.questCreateConfirm),
|
||||
content: Text(
|
||||
context.l10n.questCreateCost(
|
||||
final go = await showConfirmDialog(
|
||||
context,
|
||||
context.l10n.questCreateConfirm,
|
||||
cancelLabel: context.l10n.commonKeepEditing,
|
||||
confirmLabel: context.l10n.questCreate,
|
||||
body: context.l10n.questCreateCost(
|
||||
_preview.title,
|
||||
formatPop(_intOf(_reward)),
|
||||
formatPop(_intOf(_participants)),
|
||||
@@ -414,20 +416,8 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
|
||||
formatPop(fee),
|
||||
formatPop(pool + fee),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(context.l10n.commonKeepEditing),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(context.l10n.questCreate),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (go != true || !mounted) return;
|
||||
if (!go || !mounted) return;
|
||||
|
||||
setState(() => _saving = true);
|
||||
final String id;
|
||||
|
||||
@@ -119,6 +119,7 @@ enum QuestType {
|
||||
///
|
||||
/// [musicShake] 와 [streaming] 은 Q-QC-04 회신 대기라 참여를 열지 않는다
|
||||
/// (해석 확인 요청서 8.1 보류 범위). [unknown] 은 판정 함수를 모른다.
|
||||
/// 내부 사유(질의 번호 등)는 화면에 내보내지 않는다 — 사용자에게는 한 문구다.
|
||||
bool get verifiable => switch (this) {
|
||||
QuestType.attend ||
|
||||
QuestType.steps ||
|
||||
@@ -131,12 +132,8 @@ enum QuestType {
|
||||
};
|
||||
|
||||
/// 참여를 열지 않는 사유. 열려 있으면 null 이다.
|
||||
String? get blockedReason => switch (this) {
|
||||
QuestType.musicShake || QuestType.streaming =>
|
||||
'발주사 확인 중입니다 — 스밍·Music Shake 유형은 회신 대기 항목입니다(Q-QC-04).',
|
||||
QuestType.unknown => appL10n.questTypeUnverifiable,
|
||||
_ => null,
|
||||
};
|
||||
String? get blockedReason =>
|
||||
verifiable ? null : appL10n.questTypeUnverifiable;
|
||||
}
|
||||
|
||||
/// 서버가 판정하고 지급한 결과 — `fn-quest-*` 응답.
|
||||
@@ -507,6 +504,10 @@ abstract interface class QuestRepository {
|
||||
|
||||
Future<String> create(QuestDraft draft);
|
||||
|
||||
/// 보유 일반 POP(`wallet/main.activityBalance`) — 생성 · 보상 뒤 목록 헤더를 다시 맞춘다.
|
||||
/// 모르면 null(헤더는 받은 값을 유지한다).
|
||||
Future<int?> popBalance();
|
||||
|
||||
/// 진행 중 실측 진행도. 걸음 퀘스트만 값을 낸다.
|
||||
///
|
||||
/// 명세 `518:88441` 의 *「시작하기」 버튼 진행중으로 변경 후 실시간 목표 실행률
|
||||
@@ -730,6 +731,9 @@ class SampleQuestRepository implements QuestRepository {
|
||||
@override
|
||||
Future<String> create(QuestDraft draft) async => 'q-new';
|
||||
|
||||
@override
|
||||
Future<int?> popBalance() async => null;
|
||||
|
||||
/// **표본은 센서를 읽지 않는다.** 여기서 걸음 수를 지어내면 화면이 카드에 적힌
|
||||
/// 진행도 대신 만들어 낸 값을 그리게 되고, 목 모드 확인물이 실측처럼 보인다.
|
||||
@override
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -63,13 +63,30 @@ class _QuestListScreenState extends State<QuestListScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 헤더 보유 POP. 생성(예치 · 수수료) · 보상 뒤 [_load] 가 지갑을 다시 읽어 맞춘다.
|
||||
late int _pop = widget.myPop;
|
||||
|
||||
Future<void> _load() async {
|
||||
final cards = await widget.repository.load();
|
||||
final List<QuestCard> cards;
|
||||
try {
|
||||
cards = await widget.repository.load();
|
||||
} on Exception {
|
||||
// 실패해도 로딩을 끝낸다 — 끝내지 않으면 탭 전체가 무한 로딩으로 남는다(2026-10-04 실기기).
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
content: Text(context.l10n.commonRequestFailed),
|
||||
));
|
||||
return;
|
||||
}
|
||||
final pop = await widget.repository.popBalance().catchError((Object _) => null);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_cards = cards;
|
||||
_loading = false;
|
||||
_end = cards.length < 10;
|
||||
if (pop != null) _pop = pop;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -259,7 +276,7 @@ class _QuestListScreenState extends State<QuestListScreen> {
|
||||
Image.asset('assets/figma/icon-bolt.png', width: 10, height: 10.4),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
formatPop(widget.myPop),
|
||||
formatPop(_pop),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
||||
@@ -93,10 +93,17 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
for (final doc in docs) {
|
||||
final quest = doc.data();
|
||||
if (!_open(quest, now, uid)) continue;
|
||||
final progress = await db
|
||||
.doc('users/$uid/questProgress/${doc.id}')
|
||||
.get()
|
||||
.then((s) => s.data() ?? const <String, dynamic>{});
|
||||
// 카드 한 장의 부가 조회가 실패해도 목록 전체를 깨지 않는다 — 그 카드만 시작 전으로 둔다.
|
||||
// UGC 퀘스트 하나의 제출 건 조회가 거부되어 **모든 사용자의** 목록이 무한 로딩이었다(2026-10-04).
|
||||
Map<String, dynamic> progress;
|
||||
try {
|
||||
progress = await db
|
||||
.doc('users/$uid/questProgress/${doc.id}')
|
||||
.get()
|
||||
.then((s) => s.data() ?? const <String, dynamic>{});
|
||||
} on Exception {
|
||||
progress = const {};
|
||||
}
|
||||
cards.add(_card(doc.id, quest, progress, uid, policy, await _submission(doc.id, quest)));
|
||||
}
|
||||
return cards;
|
||||
@@ -145,17 +152,9 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
final type = QuestType.parse(quest.strOr('type'));
|
||||
final authorUid = quest.strOr('authorUid');
|
||||
final dailyLimit = quest.intOr('dailyLimit', 1);
|
||||
// 타임어택은 1인 1회라 날짜로 끊지 않는다 — 창이 자정을 넘어도 「보상 완료」로 남는다.
|
||||
final sameDay = type == QuestType.timeAttack ||
|
||||
progress.strOr('dayKey') == _dayKey(DateTime.now());
|
||||
final count = sameDay ? progress.intOr('count') : 0;
|
||||
|
||||
// 걸음은 **구간표**가 목표다 — 정책 원장 **B-26**(1 천·5 천·1 만·1.5 만보). 오늘
|
||||
// 받은 구간 수가 `count` 이므로 다음 문턱이 지금 목표다. 나머지 유형은 일일 한도가
|
||||
// 곧 목표 횟수다 — 설계서 §2.8 이 `quests` 에 목표 수치 필드를 두지 않았다.
|
||||
final (:count, :done) = questToday(type, quest, progress, policy);
|
||||
final tiers = _stepTiers(policy);
|
||||
final steps = type == QuestType.steps;
|
||||
final done = steps ? count >= tiers.length : count >= dailyLimit;
|
||||
final goal = steps
|
||||
? tiers[count.clamp(0, tiers.length - 1)]
|
||||
: dailyLimit;
|
||||
@@ -191,23 +190,6 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
);
|
||||
}
|
||||
|
||||
/// 걸음 구간 문턱들 — `config/policy.stepTiers`(운영값).
|
||||
///
|
||||
/// **화면 표시용이다. 판정은 서버가 다시 한다.** 문서가 아직 없을 때만 예전 단일
|
||||
/// 목표(`stepsGoal`)로 떨어뜨린다 — 앱에 구간값을 박으면 운영값이 두 벌이 된다.
|
||||
List<int> _stepTiers(Map<String, dynamic> policy) {
|
||||
final raw = policy['stepTiers'];
|
||||
if (raw is List && raw.isNotEmpty) {
|
||||
final steps = <int>[];
|
||||
for (final row in raw) {
|
||||
if (row is Map && row['steps'] is num) {
|
||||
steps.add((row['steps'] as num).round());
|
||||
}
|
||||
}
|
||||
if (steps.isNotEmpty) return steps..sort();
|
||||
}
|
||||
return [policy.intOr('stepsGoal', 10000)];
|
||||
}
|
||||
|
||||
/// 다국어 제목 — 설계서 §2.8 은 `title`(다국어)이라고만 적었다.
|
||||
/// 문자열과 지도(map) 둘 다 들어올 수 있어 둘 다 받는다.
|
||||
@@ -282,9 +264,13 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
Future<QuestSubmission?> _submission(String questId, Map<String, dynamic> quest) async {
|
||||
if (quest.strOr('authorUid').isEmpty) return null;
|
||||
final id = '${questId}_${_backend.requireUid}';
|
||||
final snap = await _backend.db.doc('questSubmissions/$id').get();
|
||||
final data = snap.data();
|
||||
return data == null ? null : _submissionOf(id, data);
|
||||
try {
|
||||
final data = (await _backend.db.doc('questSubmissions/$id').get()).data();
|
||||
return data == null ? null : _submissionOf(id, data);
|
||||
} on Exception {
|
||||
// 규칙 배포 전(없는 문서 get 이 permission-denied) 등 — 제출 없음으로 본다.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 생성자의 검토 대기열. 규칙이 생성자 조건을 증명하도록 `questAuthorUid` 를 함께 건다.
|
||||
@@ -441,6 +427,12 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
return result.strOr('questId');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int?> popBalance() async {
|
||||
final wallet = await _backend.db.doc('users/${_backend.requireUid}/wallet/main').get();
|
||||
return wallet.data()?.intOr('activityBalance');
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<int> progressStream(QuestCard card) {
|
||||
if (card.type != QuestType.steps) return const Stream<int>.empty();
|
||||
@@ -562,3 +554,47 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
/// 단말 시간대로 끊으면 해외 사용자의 화면과 서버 판정이 하루 어긋난다.
|
||||
String _dayKey(DateTime at) =>
|
||||
at.toUtc().add(const Duration(hours: 9)).toIso8601String().substring(0, 10);
|
||||
|
||||
/// 걸음 구간 문턱들 — `config/policy.stepTiers`(운영값).
|
||||
///
|
||||
/// **화면 표시용이다. 판정은 서버가 다시 한다.** 문서가 아직 없을 때만 예전 단일
|
||||
/// 목표(`stepsGoal`)로 떨어뜨린다 — 앱에 구간값을 박으면 운영값이 두 벌이 된다.
|
||||
List<int> _stepTiers(Map<String, dynamic> policy) {
|
||||
final raw = policy['stepTiers'];
|
||||
if (raw is List && raw.isNotEmpty) {
|
||||
final steps = <int>[];
|
||||
for (final row in raw) {
|
||||
if (row is Map && row['steps'] is num) {
|
||||
steps.add((row['steps'] as num).round());
|
||||
}
|
||||
}
|
||||
if (steps.isNotEmpty) return steps..sort();
|
||||
}
|
||||
return [policy.intOr('stepsGoal', 10000)];
|
||||
}
|
||||
|
||||
/// 오늘(KST) 수행 횟수와 완료 여부 — **HOME 과 퀘스트 목록 · 상세가 같이 쓰는 단일 판정.**
|
||||
///
|
||||
/// `users/{uid}/questProgress/{questId}.count` 는 `dayKey` 가 오늘일 때만 오늘 값이다. 날짜를
|
||||
/// 보지 않으면 9/21 출석의 `count: 1` 이 매일 「보상 완료」로 남는다(2026-10-04 실기기 4-1).
|
||||
/// 타임어택은 1인 1회라 날짜로 끊지 않는다 — 창이 자정을 넘어도 「보상 완료」로 남는다.
|
||||
///
|
||||
/// 걸음은 **구간표**가 목표다 — 정책 원장 **B-26**(1 천·5 천·1 만·1.5 만보). 오늘 받은 구간
|
||||
/// 수가 `count` 라 구간을 다 받아야 완료다. 나머지 유형은 일일 한도가 곧 목표 횟수다 —
|
||||
/// 설계서 §2.8 이 `quests` 에 목표 수치 필드를 두지 않았다.
|
||||
({int count, bool done}) questToday(
|
||||
QuestType type,
|
||||
Map<String, dynamic> quest,
|
||||
Map<String, dynamic>? progress,
|
||||
Map<String, dynamic> policy, [
|
||||
DateTime? now,
|
||||
]) {
|
||||
final p = progress ?? const <String, dynamic>{};
|
||||
final sameDay = type == QuestType.timeAttack ||
|
||||
p.strOr('dayKey') == _dayKey(now ?? DateTime.now());
|
||||
final count = sameDay ? p.intOr('count') : 0;
|
||||
final done = type == QuestType.steps
|
||||
? count >= _stepTiers(policy).length
|
||||
: count >= quest.intOr('dailyLimit', 1);
|
||||
return (count: count, done: done);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import '../../../core/i18n/app_locale.dart';
|
||||
/// 퀘스트 대표 이미지 — Figma 사본의 3D 아이콘(`264:46867` 목록 · `264:48835` 상세).
|
||||
///
|
||||
/// ponytail: `quests` 문서(설계서 §2.8)에 이미지 필드가 없어 유형과 제목으로 고른다.
|
||||
/// 서버가 이미지 URL 을 주게 되면 그 값으로 바꾼다. 맞는 것이 없으면 빈 칸이다.
|
||||
String? questImage(QuestCard card) {
|
||||
/// 서버가 이미지 URL 을 주게 되면 그 값으로 바꾼다. 맞는 것이 없으면 기본 아이콘이다.
|
||||
String questImage(QuestCard card) {
|
||||
switch (card.type) {
|
||||
case QuestType.attend:
|
||||
return 'assets/figma/quest-img-stamp.png';
|
||||
@@ -27,8 +27,8 @@ String? questImage(QuestCard card) {
|
||||
if (title.contains('만보기') || title.contains('걷기')) {
|
||||
return 'assets/figma/quest-img-shoe.png';
|
||||
}
|
||||
if (title.contains('POP')) return 'assets/figma/quest-img-bolt.png';
|
||||
return null;
|
||||
// 유형 · 제목으로 못 고르면 기본 아이콘(번개) — 빈 칸은 고장처럼 보인다(2026-10-04 실기기 4-9).
|
||||
return 'assets/figma/quest-img-bolt.png';
|
||||
}
|
||||
|
||||
/// 목록의 아이콘 칸 — #2e2e2e r16 50 칸 안에 42 이미지.
|
||||
@@ -39,7 +39,6 @@ class QuestIconBox extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final image = questImage(card);
|
||||
return Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
@@ -48,7 +47,7 @@ class QuestIconBox extends StatelessWidget {
|
||||
color: const Color(0xFF2E2E2E),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: image == null ? null : Image.asset(image, width: 42, height: 42),
|
||||
child: Image.asset(questImage(card), width: 42, height: 42),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user