사용자 화면 문구·표본 데이터 · 퀘스트 참여 흐름 · 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
@@ -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);
}