1011 lines
43 KiB
Dart
1011 lines
43 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
import 'package:firebase_storage/firebase_storage.dart' show SettableMetadata;
|
|
|
|
import '../../core/data/backend.dart';
|
|
import '../../core/data/server_clock.dart';
|
|
import '../../core/monetization/rewarded_ad.dart';
|
|
import 'quest_data.dart';
|
|
import 'quest_evidence.dart';
|
|
import '../../core/i18n/app_locale.dart';
|
|
|
|
/// 퀘스트의 실 서버 구현 — 화면 대조 원장 F4.
|
|
///
|
|
/// **읽기는 Firestore, 재화가 오가는 쓰기는 Cloud Functions.** 별첨 1 ② 가
|
|
/// 「클라이언트 단독 지급 0건」을 판정 항목으로 두었으므로 보상은 전부
|
|
/// `fn-quest-attend` · `fn-quest-verify-steps` · `fn-quest-verify-place` 를 지난다.
|
|
/// 이 파일에 잔액·원장을 쓰는 코드는 없다.
|
|
///
|
|
/// 읽는 곳
|
|
/// - 정의 `quests/{questId}` (설계서 §2.8 — `type` · `title` · `rewardPop` · `dailyLimit`)
|
|
/// - 진행 `users/{uid}/questProgress/{questId}` (설계서 §2.1 — `dayKey` · `count`)
|
|
/// - 운영값 `config/policy` (걸음 목표. 앱에 하드코딩하지 않는다 — 별첨 B 「운영값 관리 원칙」)
|
|
///
|
|
/// **설계서에 없어서 채우지 못한 것**은 0 이나 빈 값으로 두고 보고서에 남겼다 —
|
|
/// 참여 인원·목록 정렬 키·분류 마스터·위치 퀘스트의 장소 연결 필드가 그렇다.
|
|
/// 없는 필드를 여기서 만들면 Admin 이 쓰지 않는 값을 앱만 읽는 상태가 된다.
|
|
class FirestoreQuestRepository implements QuestRepository {
|
|
FirestoreQuestRepository(
|
|
this._backend, {
|
|
StepCountProbe stepCount = deviceStepCount,
|
|
TodayStepsProbe todaySteps = deviceTodaySteps,
|
|
PlaceProbe placeFix = devicePlaceFix,
|
|
ShakeProbe shakes = deviceShakes,
|
|
WalkBaseline? baseline,
|
|
}) : _stepCount = stepCount,
|
|
_todaySteps = todaySteps,
|
|
_placeFix = placeFix,
|
|
_shakes = shakes,
|
|
_baseline = baseline ?? WalkBaseline();
|
|
|
|
final Backend _backend;
|
|
final StepCountProbe _stepCount;
|
|
final TodayStepsProbe _todaySteps;
|
|
final PlaceProbe _placeFix;
|
|
final ShakeProbe _shakes;
|
|
|
|
/// 마지막 Music Shake 한 판의 판정표. 다 끝난 판만 서버에 보낸다.
|
|
ShakeJudge? _shakeRun;
|
|
|
|
/// 오늘 걸은 수를 내는 기준선. 하루 단위라 앱을 껐다 켜도 이어진다.
|
|
final WalkBaseline _baseline;
|
|
|
|
/// 마지막 페이지의 끝 문서. `offset` 대신 커서로 넘긴다 — Firestore 에 offset 이 없다.
|
|
DocumentSnapshot<Map<String, dynamic>>? _cursor;
|
|
|
|
/// 오늘 걸은 수. 마지막으로 센서에서 받은 값이며 서버에 그대로 보낸다.
|
|
int _walkSteps = 0;
|
|
|
|
@override
|
|
Future<List<QuestCard>> load({int limit = 10}) async {
|
|
_cursor = null;
|
|
_exhausted = false;
|
|
return _page(limit);
|
|
}
|
|
|
|
@override
|
|
Future<List<QuestCard>> more({required int offset, int limit = 10}) =>
|
|
_page(limit);
|
|
|
|
/// 원본 문서를 끝까지 읽었는가. 끝은 **걸러내기 전 문서 수**로 판정한다 — 비활성 ·
|
|
/// 마감 문서가 한 페이지를 채워도 뒤 문서가 사라지지 않게(2026-10-06 실기기: 11번째 이후 누락).
|
|
bool _exhausted = false;
|
|
|
|
/// 보이는 카드가 [limit] 장 모일 때까지(또는 문서가 끝날 때까지) 페이지를 이어 읽는다.
|
|
/// 그래서 [limit] 장보다 적게 돌려주면 문서가 끝났다는 뜻이다(`QuestRepository` 계약).
|
|
Future<List<QuestCard>> _page(int limit) async {
|
|
final cards = <QuestCard>[];
|
|
while (cards.length < limit && !_exhausted) {
|
|
cards.addAll(await _batch(limit));
|
|
}
|
|
return cards;
|
|
}
|
|
|
|
Future<List<QuestCard>> _batch(int limit) async {
|
|
final uid = _backend.requireUid;
|
|
final db = _backend.db;
|
|
|
|
// 명세 `518:88395` · `518:88405` *퀘스트 생성 최신순 소팅* — 서버 정렬 + 커서 페이지.
|
|
// `createdAt` 이 없는 문서는 이 쿼리에서 빠진다. 새 문서는 `fn-admin-write` · `fn-quest-create` 가
|
|
// 남기고, 예전 문서는 `tools/demo/migrate_v040.mjs quest-created-at` 로 채운다(배포 전 필수).
|
|
// 커서를 limit 보다 먼저 건다 — 서버는 순서를 가리지 않지만 테스트용 fake 는 적은 순서대로 적용한다.
|
|
Query<Map<String, dynamic>> query = db
|
|
.collection('quests')
|
|
.orderBy('createdAt', descending: true);
|
|
final cursor = _cursor;
|
|
if (cursor != null) query = query.startAfterDocument(cursor);
|
|
query = query.limit(limit);
|
|
|
|
final snap = await query.get();
|
|
if (snap.docs.isNotEmpty) _cursor = snap.docs.last;
|
|
if (snap.docs.length < limit) _exhausted = true;
|
|
|
|
// 타임어택 창은 서버 시각이다. 회차가 붙은 Quest 가 있을 때만 서버 시계를 잰다(세션당 1회).
|
|
if (snap.docs.any((d) => d.data()['timeAttack'] is Map)) {
|
|
await ServerClock.ensure(_backend);
|
|
}
|
|
final now = ServerClock.now();
|
|
final policy = await _policy();
|
|
|
|
final cards = <QuestCard>[];
|
|
for (final doc in snap.docs) {
|
|
final quest = doc.data();
|
|
if (!_open(quest, now, uid)) continue;
|
|
// 카드 한 장의 부가 조회가 실패해도 목록 전체를 깨지 않는다 — 그 카드만 시작 전으로 둔다.
|
|
// 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 {};
|
|
}
|
|
final card = _card(
|
|
doc.id,
|
|
quest,
|
|
progress,
|
|
uid,
|
|
policy,
|
|
await _submission(doc.id, quest),
|
|
await _walkFor(quest, progress),
|
|
);
|
|
// 명세 `523:18176` 퀘스트 종료 시점 1 — *참여 인원 충족 시 퀘스트 종료*. 서버는 정원이 차면
|
|
// 참여를 거부하므로(미배정 Pool 0), 아직 참여하지 않은 사람에게는 열린 퀘스트로 보이지 않게 한다.
|
|
// 이미 참여한 사람(진행중 · 완료 탭)과 생성자(내 퀘스트)에게는 그대로 남긴다.
|
|
if (questFull(card) && card.stage == QuestStage.notStarted) continue;
|
|
cards.add(card);
|
|
}
|
|
return cards;
|
|
}
|
|
|
|
/// 진행 세션이 있는 걸음 Quest 의 오늘 걸음 — Walk 는 화면 밖 걸음까지 **실제 상태**로 보인다(원장 C-62 · C-83).
|
|
/// Health 를 한 번 읽고, 못 쓰면 이번 실행에서 센서로 센 값, 그것도 없으면 null(세션의 마지막 값).
|
|
Future<int?> _walkFor(Map<String, dynamic> quest, Map<String, dynamic> progress) async {
|
|
if (QuestType.parse(quest.strOr('type')) != QuestType.steps || progress['session'] is! Map) {
|
|
return null;
|
|
}
|
|
try {
|
|
final health = await _todaySteps();
|
|
if (health != null) return health;
|
|
} on Exception {
|
|
// 권한 없음 — 아래 값으로.
|
|
}
|
|
return _walkSteps > 0 ? _walkSteps : null;
|
|
}
|
|
|
|
/// 목록 카드가 「일시 정지」로 보는 세션 — 진행완료가 아니고 Walk 가 아닌 남은 세션(앱이 꺼진
|
|
/// inProgress 포함). 카드 단계 판정([QuestStage.paused])과 같은 규칙이다. 아니면 null.
|
|
static QuestSession? pausedSessionOf(
|
|
QuestType type,
|
|
Map<String, dynamic> progress,
|
|
Map<String, dynamic> quest,
|
|
) {
|
|
final s = sessionOf(progress);
|
|
return s == null ||
|
|
type == QuestType.steps ||
|
|
s.state == QuestStage.completed ||
|
|
staleShake(type, quest, s)
|
|
? null
|
|
: s;
|
|
}
|
|
|
|
/// 운영값(첫 박 · 판 길이)이 바뀌어 이어갈 수 없는 Music Shake 세션인가 — [QuestSession.fitsShake] 와 같은 기준.
|
|
static bool staleShake(QuestType type, Map<String, dynamic> quest, QuestSession? s) =>
|
|
type == QuestType.musicShake &&
|
|
s != null &&
|
|
!s.fits(Duration(milliseconds: quest.intOr('firstBeatMs')), quest.intOr('totalSegments'));
|
|
|
|
/// 진행 세션(`questProgress.session` — `fn-quest-progress` 만 쓴다 · 원장 C-83).
|
|
static QuestSession? sessionOf(Map<String, dynamic> progress) {
|
|
final raw = progress['session'];
|
|
if (raw is! Map) return null;
|
|
final m = Map<String, dynamic>.from(raw);
|
|
final state = switch (m.strOr('state')) {
|
|
'inProgress' => QuestStage.inProgress,
|
|
'paused' => QuestStage.paused,
|
|
'completed' => QuestStage.completed,
|
|
_ => null,
|
|
};
|
|
if (state == null) return null;
|
|
final hits = m['hits'];
|
|
final judgement = m['judgement'];
|
|
return QuestSession(
|
|
state: state,
|
|
progress: m.intOr('progress'),
|
|
total: m.intOr('total'),
|
|
position: Duration(milliseconds: m.intOr('positionMs')),
|
|
hits: hits is List ? [for (final h in hits) if (h is num) h.toDouble()] : const [],
|
|
firstBeat: m['firstBeatMs'] is num ? Duration(milliseconds: m.intOr('firstBeatMs')) : null,
|
|
judgement: judgement is Map
|
|
? {for (final e in judgement.entries) if (e.value is num) '${e.key}': (e.value as num).round()}
|
|
: null,
|
|
);
|
|
}
|
|
|
|
/// 지금 참여할 수 있는 기간인가 — 설계서 §2.8 `activeFrom`/`activeTo`.
|
|
///
|
|
/// 두 필드가 없는 문서도 있어(시연 데이터는 `active` 불리언을 쓴다) **있는
|
|
/// 조건만 본다.** 없는 필드를 근거로 감추면 목록이 이유 없이 비어 보인다.
|
|
bool _open(Map<String, dynamic> quest, DateTime now, String uid) {
|
|
// 사용자 생성 Quest — 공개 전(Draft) · 취소는 감추고, 마감분은 생성자의 「내 퀘스트」에만 남긴다.
|
|
final status = quest.strOr('status');
|
|
if (status == 'draft' || status == 'cancelled') return false;
|
|
final mineFirst = quest.strOr('authorUid') == uid;
|
|
// 공개 전 검수 대기 · 운영 반려는 생성자에게만 보인다(승인 후 공개 — 1주차 4항 · Q8).
|
|
if (!mineFirst && (status == 'review' || status == 'rejected')) return false;
|
|
final mine = quest.strOr('authorUid') == uid;
|
|
final endsAt = quest['endsAt'];
|
|
if (!mine && (status == 'ended' || (endsAt is Timestamp && !now.isBefore(endsAt.toDate())))) {
|
|
return false;
|
|
}
|
|
if (mine && status.isNotEmpty) return true;
|
|
if (quest['active'] == false) return false;
|
|
final from = quest['activeFrom'];
|
|
if (from is Timestamp && now.isBefore(from.toDate())) return false;
|
|
final to = quest['activeTo'];
|
|
if (to is Timestamp && now.isAfter(to.toDate())) return false;
|
|
return true;
|
|
}
|
|
|
|
QuestCard _card(
|
|
String id,
|
|
Map<String, dynamic> quest,
|
|
Map<String, dynamic> progress,
|
|
String uid,
|
|
Map<String, dynamic> policy, [
|
|
QuestSubmission? submission,
|
|
int? walk,
|
|
]) {
|
|
final type = QuestType.parse(quest.strOr('type'));
|
|
final authorUid = quest.strOr('authorUid');
|
|
final dailyLimit = quest.intOr('dailyLimit', 1);
|
|
final (:count, :done) = questToday(type, quest, progress, policy);
|
|
final tiers = _stepTiers(policy);
|
|
final steps = type == QuestType.steps;
|
|
final goal = steps
|
|
? tiers[count.clamp(0, tiers.length - 1)]
|
|
// Music Shake 의 목표 줄은 Figma 「목표BPM 350」(`480:14466` · `480:14506`) — 곡 BPM 이다.
|
|
// 없으면 0(화면은 「-」). 🟡 「목표BPM」의 의미는 회신 대기(Q-MS-01).
|
|
: type == QuestType.musicShake
|
|
? quest.intOr('bpm')
|
|
: dailyLimit;
|
|
final saved = sessionOf(progress);
|
|
// 이어갈 수 없는 옛 Music Shake 판은 세션이 없는 것으로 본다 — 「시작하기」로 보이고 시작 때 안내한다.
|
|
final stale = staleShake(type, quest, saved);
|
|
final session = stale ? null : saved;
|
|
final cardGoal = authorUid.isEmpty ? goal : quest.intOr('goal', 1);
|
|
// 걸음은 화면 밖 걸음까지 실제 값(C-62) — 오늘 걸음, 없으면 세션의 마지막 값.
|
|
final walked = steps && session != null ? walk ?? session.progress : null;
|
|
|
|
return QuestCard(
|
|
session: session,
|
|
staleShake: stale,
|
|
id: id,
|
|
title: questTitle(quest),
|
|
categoryName: type.category,
|
|
type: type,
|
|
author: authorUid.isEmpty ? QuestAuthor.admin : QuestAuthor.user,
|
|
stage: authorUid == uid
|
|
? QuestStage.mine
|
|
: done || submission?.status == 'paid'
|
|
? QuestStage.rewarded
|
|
// 검토 대기는 「진행중」 탭에 둔다 — 지급 전이라 완료가 아니다(G-03).
|
|
: submission?.pending == true
|
|
? QuestStage.inProgress
|
|
: session == null
|
|
? QuestStage.notStarted
|
|
: walked != null
|
|
// Walk 는 일시 정지가 없다 — 목표에 닿았으면 진행완료, 아니면 진행중(C-83).
|
|
? (cardGoal > 0 && walked >= cardGoal
|
|
? QuestStage.completed
|
|
: QuestStage.inProgress)
|
|
// 화면 안에서 진행하는 Quest 가 진행 화면 밖에 있으면 일시 정지다(앱이 꺼진 경우 포함).
|
|
: session.state == QuestStage.completed
|
|
? QuestStage.completed
|
|
: QuestStage.paused,
|
|
goal: cardGoal,
|
|
// 걸음은 센서를 구독해야 알 수 있어 시작 전에는 0 이다. 나머지는 당일 수행 횟수다.
|
|
progress: type.gathers ? (done ? goal : walked ?? 0) : count,
|
|
rewardPop: openTimeAttack(quest)?.rewardPop ??
|
|
type.rewardFrom(quest, policy, cleared: count),
|
|
// 설계서 §2.8 의 `quests` 에 참여 인원 필드가 없다. 세는 곳이 없어 0 이다.
|
|
participants: quest.intOr('participantCount'),
|
|
// 사용자 생성 Quest 의 모집 인원(`capacity`). 운영 Quest 에는 없다.
|
|
capacity: quest['capacity'] is num ? (quest['capacity'] as num).round() : null,
|
|
// 광고 안내값 — 서버 `fnAdReward` 와 같은 원천(B-03 · C-02 운영값).
|
|
adMultiplier: quest.intOr('adMultiplier', policy.intOr('adMultiplier', 2)),
|
|
ugcAdPop: policy.intOr('adRewardPop', 10),
|
|
// 열린 TIME ATTACK 회차의 마감. 있으면 카드 · 상세가 남은 시간을 세고 참여는 회차로 간다.
|
|
endsAt: openTimeAttack(quest)?.endsAt,
|
|
publishState: authorUid == uid ? quest.strOr('status') : '',
|
|
authorName: quest.strOr('authorName'),
|
|
);
|
|
}
|
|
|
|
|
|
Future<Map<String, dynamic>> _policy() async {
|
|
final snap = await _backend.db.doc('config/policy').get();
|
|
return snap.data() ?? const <String, dynamic>{};
|
|
}
|
|
|
|
@override
|
|
Future<QuestDetail> detail(String questId) async {
|
|
final uid = _backend.requireUid;
|
|
final db = _backend.db;
|
|
final questSnap = await db.doc('quests/$questId').get();
|
|
final quest = questSnap.data() ?? const <String, dynamic>{};
|
|
final progress = await db
|
|
.doc('users/$uid/questProgress/$questId')
|
|
.get()
|
|
.then((s) => s.data() ?? const <String, dynamic>{});
|
|
final submission = await _submission(questId, quest);
|
|
final card = _card(
|
|
questId,
|
|
quest,
|
|
progress,
|
|
uid,
|
|
await _policy(),
|
|
submission,
|
|
await _walkFor(quest, progress),
|
|
);
|
|
final link = quest['link'];
|
|
final repeat = quest['repeat'] is Map ? Map<String, dynamic>.from(quest['repeat'] as Map) : null;
|
|
final endsAt = quest['endsAt'];
|
|
return QuestDetail(
|
|
card: card,
|
|
imageLabel: card.categoryName,
|
|
description: quest.strOr('userDescription', quest.strOr('description')),
|
|
link: link is Map ? _link(Map<String, dynamic>.from(link)) : null,
|
|
endsAt: endsAt is Timestamp ? endsAt.toDate() : null,
|
|
repeatHours: repeat?['allowed'] == true ? repeat!.intOr('intervalHours', 24) : null,
|
|
repeatMax: repeat?['allowed'] == true ? repeat!.intOr('maxCount', 1) : 1,
|
|
submission: submission,
|
|
reviews: quest.strOr('authorUid') == uid ? await _reviews(questId, uid) : const [],
|
|
publishState: quest.strOr('status'),
|
|
moderationReason: quest['moderation'] is Map
|
|
? (quest['moderation'] as Map)['reason'] as String?
|
|
: null,
|
|
verifyMethods: _verifyMethodsOf(quest),
|
|
qrCode: quest.strOr('authorUid') == uid && _verifyMethodsOf(quest).contains('qr')
|
|
? await db
|
|
.doc('questSecrets/$questId')
|
|
.get()
|
|
.then((s) => s.data()?['qrCode'] as String?, onError: (Object _) => null)
|
|
: null,
|
|
);
|
|
}
|
|
|
|
/// 위치 Quest 의 인증 수단(C-03). 문서에 없으면 GPS 하나다(운영 Quest · 예전 문서).
|
|
List<String> _verifyMethodsOf(Map<String, dynamic> quest) {
|
|
final raw = quest['verifyMethods'];
|
|
final methods = raw is List ? raw.whereType<String>().toList() : <String>[];
|
|
return methods.isEmpty ? const ['gps'] : methods;
|
|
}
|
|
|
|
QuestLink _link(Map<String, dynamic> m) => QuestLink(
|
|
url: m.strOr('url'),
|
|
status: m.strOr('status'),
|
|
title: m.strOr('title'),
|
|
thumbnail: m.strOr('thumbnail').isEmpty ? null : m.strOr('thumbnail'),
|
|
provider: m.strOr('provider').isEmpty ? null : m.strOr('provider'),
|
|
);
|
|
|
|
QuestSubmission _submissionOf(String id, Map<String, dynamic> m) {
|
|
final evidence = m['evidence'] is List ? m['evidence'] as List : const [];
|
|
final last = evidence.isNotEmpty && evidence.last is Map
|
|
? Map<String, dynamic>.from(evidence.last as Map)
|
|
: const <String, dynamic>{};
|
|
final at = m['resubmittedAt'] ?? m['submittedAt'];
|
|
final paidAt = m['paidAt'];
|
|
return QuestSubmission(
|
|
txId: m.strOr('txId').isEmpty ? null : m.strOr('txId'),
|
|
paidAt: paidAt is Timestamp ? paidAt.toDate() : null,
|
|
id: id,
|
|
status: m.strOr('status'),
|
|
submittedAt: at is Timestamp ? at.toDate() : null,
|
|
rejectReason: m.strOr('rejectReason').isEmpty ? null : m.strOr('rejectReason'),
|
|
resubmitAllowed: m['resubmitAllowed'] == true,
|
|
summary: [last.strOr('url'), last.strOr('text')].where((v) => v.isNotEmpty).join('\n'),
|
|
);
|
|
}
|
|
|
|
/// 내 제출 건(문서 ID `{questId}_{uid}`). 사용자 생성 Quest 와 운영 Vote · SNS · Community Quest 에 있다.
|
|
Future<QuestSubmission?> _submission(String questId, Map<String, dynamic> quest) async {
|
|
final type = QuestType.parse(quest.strOr('type'));
|
|
if (quest.strOr('authorUid').isEmpty && !type.reviewed && type != QuestType.community) {
|
|
return null;
|
|
}
|
|
final id = '${questId}_${_backend.requireUid}';
|
|
try {
|
|
final data = (await _backend.db.doc('questSubmissions/$id').get()).data();
|
|
if (data == null) return null;
|
|
final sub = _submissionOf(id, data);
|
|
final txId = sub.txId;
|
|
if (txId == null) return sub;
|
|
final claimed = (await _backend.db
|
|
.doc('users/${_backend.requireUid}/adBonuses/$txId')
|
|
.get())
|
|
.exists;
|
|
return QuestSubmission(
|
|
id: sub.id,
|
|
status: sub.status,
|
|
submittedAt: sub.submittedAt,
|
|
rejectReason: sub.rejectReason,
|
|
resubmitAllowed: sub.resubmitAllowed,
|
|
summary: sub.summary,
|
|
txId: txId,
|
|
paidAt: sub.paidAt,
|
|
adClaimed: claimed,
|
|
);
|
|
} on Exception {
|
|
// 규칙 배포 전(없는 문서 get 이 permission-denied) 등 — 제출 없음으로 본다.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// 생성자의 검토 대기열. 규칙이 생성자 조건을 증명하도록 `questAuthorUid` 를 함께 건다.
|
|
Future<List<QuestSubmission>> _reviews(String questId, String uid) async {
|
|
final snap = await _backend.db
|
|
.collection('questSubmissions')
|
|
.where('questId', isEqualTo: questId)
|
|
.where('questAuthorUid', isEqualTo: uid)
|
|
.get();
|
|
return [
|
|
for (final d in snap.docs)
|
|
if (d.data().strOr('status') == 'submitted') _submissionOf(d.id, d.data()),
|
|
];
|
|
}
|
|
|
|
/// 분류 마스터(C-01) — 앱 재배포 없이 Admin 이 바꾼 순서 · 노출 · `ugcAllowed` 를 그대로 따른다.
|
|
/// 사용자가 만들 수 있는 활동이 하나도 없는 분류는 보이지 않는다.
|
|
@override
|
|
Future<QuestCatalog> catalog() async {
|
|
final raw = await _backend.db
|
|
.doc('config/questCatalog')
|
|
.get()
|
|
.then((s) => s.data() ?? const <String, dynamic>{});
|
|
final policy = await _policy();
|
|
List<Map<String, dynamic>> rows(String key) => [
|
|
for (final r in (raw[key] is List ? raw[key] as List : const []))
|
|
if (r is Map && r['visible'] != false) Map<String, dynamic>.from(r),
|
|
]..sort((a, b) => a.intOr('order').compareTo(b.intOr('order')));
|
|
final activities = [for (final a in rows('activities')) if (a['ugcAllowed'] == true) a];
|
|
final categories = <QuestCategory>[];
|
|
for (final c in rows('categories')) {
|
|
final mine = [
|
|
for (final a in activities)
|
|
if (a.strOr('categoryKey') == c.strOr('key'))
|
|
QuestActivity(
|
|
key: a.strOr('key'),
|
|
name: _activityName(a.strOr('key'), a.strOr('label', a.strOr('key'))),
|
|
fields: {
|
|
for (final f in (a['inputSchema'] is List ? a['inputSchema'] as List : const []))
|
|
if (f is Map) '${f['field']}',
|
|
},
|
|
),
|
|
];
|
|
if (mine.isEmpty) continue;
|
|
categories.add(QuestCategory(
|
|
id: c.strOr('key'),
|
|
name: _categoryName(c.strOr('key'), c.strOr('label')),
|
|
activities: mine,
|
|
));
|
|
}
|
|
final rate = policy['ugcFeeRate'];
|
|
return QuestCatalog(
|
|
categories: categories,
|
|
feeRate: rate is num ? rate.toDouble() : 0.1,
|
|
feeMin: policy.intOr('ugcFeeMin', 10),
|
|
feeOnNoReward: policy['ugcFeeOnNoReward'] == true,
|
|
maxDays: policy.intOr('ugcMaxDays', 30),
|
|
);
|
|
}
|
|
|
|
static String _categoryName(String key, String fallback) => switch (key) {
|
|
'health' => appL10n.questCatHealth,
|
|
'planet' => appL10n.questCatPlanet,
|
|
'community' => appL10n.questCatCommunity,
|
|
'location' => appL10n.questTypePlace,
|
|
_ => fallback,
|
|
};
|
|
|
|
static String _activityName(String key, String fallback) => switch (key) {
|
|
'walk' => appL10n.questTypeSteps,
|
|
'shake' => appL10n.questActShake,
|
|
'streaming' => appL10n.questTypeStreaming,
|
|
'youtube' => 'YouTube',
|
|
'vote' => appL10n.questTypeVote,
|
|
'sns' => appL10n.questTypeSns,
|
|
'community' => appL10n.questCatCommunity,
|
|
'location' => appL10n.questTypePlace,
|
|
_ => fallback,
|
|
};
|
|
|
|
@override
|
|
Future<List<QuestPlace>> places() async {
|
|
final snap = await _backend.db.collection('places').get();
|
|
return [
|
|
for (final d in snap.docs)
|
|
QuestPlace(
|
|
id: d.id,
|
|
name: d.data().strOr('name', d.id),
|
|
lat: (d.data()['geo'] as GeoPoint?)?.latitude,
|
|
lng: (d.data()['geo'] as GeoPoint?)?.longitude,
|
|
),
|
|
];
|
|
}
|
|
|
|
@override
|
|
Future<QuestLink> previewLink(String url) async =>
|
|
_link(await _backend.call('fnLinkPreview', {'url': url}));
|
|
|
|
@override
|
|
Future<void> submit(String questId, {String? url, String? text}) =>
|
|
_backend.call('fnQuestSubmit', {
|
|
'questId': questId,
|
|
if (url != null && url.isNotEmpty) 'url': url,
|
|
if (text != null && text.isNotEmpty) 'text': text,
|
|
});
|
|
|
|
@override
|
|
Future<void> review(
|
|
String submissionId, {
|
|
required bool approve,
|
|
String? reason,
|
|
bool resubmitAllowed = false,
|
|
}) => _backend.call('fnQuestReview', {
|
|
'submissionId': submissionId,
|
|
'action': approve ? 'approve' : 'reject',
|
|
if (reason != null) 'reason': reason,
|
|
'resubmitAllowed': resubmitAllowed,
|
|
});
|
|
|
|
@override
|
|
Future<void> cancel(String questId) =>
|
|
_backend.call('fnQuestCancel', {'questId': questId});
|
|
|
|
@override
|
|
Future<void> edit(String questId, {required String title, required String description}) =>
|
|
_backend.call('fnQuestEdit', {
|
|
'questId': questId,
|
|
'title': title,
|
|
'description': description,
|
|
});
|
|
|
|
/// 진행중 · 일시 정지 · 진행완료 · 중단을 서버 진행 문서에 남긴다(`fn-quest-progress` · 원장 C-83).
|
|
///
|
|
/// 진행 문서는 규칙상 서버 전용이라 함수로만 쓴다. 보상 상태는 여전히 [verify] 가 지급에 성공한 뒤
|
|
/// 서버 카운터로 확정되고, 지급되면 서버가 세션을 지운다.
|
|
@override
|
|
Future<QuestStage> advance(
|
|
String questId,
|
|
QuestStage next, {
|
|
QuestSession? session,
|
|
}) async {
|
|
final state = switch (next) {
|
|
QuestStage.inProgress => 'inProgress',
|
|
QuestStage.paused => 'paused',
|
|
QuestStage.completed => 'completed',
|
|
_ => 'none',
|
|
};
|
|
await _backend.call('fnQuestProgress', {
|
|
'questId': questId,
|
|
'state': state,
|
|
if (session != null && state != 'none') ...{
|
|
'progress': session.progress,
|
|
'total': session.total,
|
|
'positionMs': session.position.inMilliseconds,
|
|
if (session.hits.isNotEmpty) 'hits': session.hits,
|
|
if (session.firstBeat != null) 'firstBeatMs': session.firstBeat!.inMilliseconds,
|
|
'judgement': ?session.judgement,
|
|
},
|
|
});
|
|
questSessionSignal.value++;
|
|
return next;
|
|
}
|
|
|
|
@override
|
|
Future<QuestCard?> pausedQuest() async {
|
|
final uid = _backend.requireUid;
|
|
final db = _backend.db;
|
|
final snap = await db
|
|
.collection('users/$uid/questProgress')
|
|
.where('session.state', isEqualTo: 'paused')
|
|
.get();
|
|
// 가장 최근 1건(QA 3-34). 한 사람이 동시에 멈춘 Quest 는 몇 건이라 단말에서 고른다.
|
|
final docs = [...snap.docs]
|
|
..sort((a, b) {
|
|
DateTime at(Map<String, dynamic> d) {
|
|
final t = (d['session'] as Map?)?['updatedAt'];
|
|
return t is Timestamp ? t.toDate() : DateTime(0);
|
|
}
|
|
|
|
return at(b.data()).compareTo(at(a.data()));
|
|
});
|
|
final policy = await _policy();
|
|
for (final doc in docs) {
|
|
final quest = (await db.doc('quests/${doc.id}').get()).data();
|
|
if (quest == null || !_open(quest, ServerClock.now(), uid)) continue;
|
|
final card = _card(doc.id, quest, doc.data(), uid, policy);
|
|
if (card.stage == QuestStage.paused) return card;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// 생성 · 예치 · 수수료 · 공개는 `fn-quest-create` 가 원장과 함께 한다(B-28 · B-30). 앱은
|
|
/// `quests` 에 직접 쓰지 않는다(규칙도 마스터 전용). 종료일은 **생성자 기기 시간대의 그 날짜
|
|
/// 23:59:59** 를 서버가 UTC 로 굳히도록 날짜와 그 시각의 UTC 오프셋을 보낸다(C-06).
|
|
@override
|
|
Future<String> create(QuestDraft draft) async {
|
|
final end = draft.endDate;
|
|
final offset = end == null
|
|
? DateTime.now().timeZoneOffset
|
|
: DateTime(end.year, end.month, end.day, 23, 59, 59).timeZoneOffset;
|
|
final result = await _backend.call('fnQuestCreate', {
|
|
'clientSeq': draft.clientSeq,
|
|
'activityKey': draft.activityKey,
|
|
'title': draft.title,
|
|
'description': draft.description,
|
|
'rewardPop': draft.rewardPop,
|
|
'capacity': draft.participants,
|
|
'utcOffsetMinutes': offset.inMinutes,
|
|
if (end != null)
|
|
'endDate': '${end.year.toString().padLeft(4, '0')}-'
|
|
'${end.month.toString().padLeft(2, '0')}-${end.day.toString().padLeft(2, '0')}',
|
|
if (draft.url != null) 'url': draft.url,
|
|
if (draft.manualTitle != null) 'manualTitle': draft.manualTitle,
|
|
if (draft.placeId != null) 'placeId': draft.placeId,
|
|
if (draft.pin case final pin?)
|
|
'pin': {'lat': pin.lat, 'lng': pin.lng, 'address': pin.name},
|
|
if (draft.goal != null) 'goal': draft.goal,
|
|
if (draft.extraVerify.isNotEmpty) 'extraVerify': draft.extraVerify,
|
|
});
|
|
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
|
|
Future<bool> adAvailable() async {
|
|
try {
|
|
final db = _backend.db;
|
|
final ad = (await db.doc('users/${_backend.requireUid}/questProgress/ad').get()).data();
|
|
// 서버 `progress(uid, 'ad', dayKey(now))` 와 같은 규약 — KST 날짜가 다르면 오늘 0회.
|
|
final today = ServerClock.now().toUtc().add(const Duration(hours: 9)).toIso8601String().substring(0, 10);
|
|
if (ad == null || ad['dayKey'] != today) return true;
|
|
final limit = (await _policy()).intOr('adDailyLimit', 5);
|
|
return ad.intOr('count') < limit;
|
|
} on Exception {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<int?> spendableBalance() async {
|
|
final wallet = await _backend.db.doc('users/${_backend.requireUid}/wallet/main').get();
|
|
final data = wallet.data();
|
|
if (data == null) return null;
|
|
return data.intOr('activityBalance') + data.intOr('supportBalance');
|
|
}
|
|
|
|
@override
|
|
Stream<int> progressStream(QuestCard card) {
|
|
if (card.type != QuestType.steps) return const Stream<int>.empty();
|
|
return _steps();
|
|
}
|
|
|
|
/// Health 걸음 갱신 주기. Health Connect · HealthKit 은 변경을 밀어 주지 않아 다시 묻는다.
|
|
static const _healthPoll = Duration(seconds: 15);
|
|
|
|
/// 오늘 걸음 — Health(0시 KST 부터 합계, B-02)가 1차, 못 쓰면 기기 센서(B-26 「권한 거부 시 앱 자체 집계」).
|
|
Stream<int> _steps() async* {
|
|
var health = await _todaySteps();
|
|
if (health == null) {
|
|
// 센서는 부팅 이후 누적을 준다. 하루 단위 기준선을 빼야 정책 원장 **B-26** 의
|
|
// 구간(1 천·5 천·1 만·1.5 만보)과 같은 것을 세게 된다.
|
|
yield* _stepCount().asyncMap((cumulative) async {
|
|
_walkSteps = await _baseline.todaySteps(cumulative);
|
|
return _walkSteps;
|
|
});
|
|
return;
|
|
}
|
|
while (true) {
|
|
_walkSteps = health!;
|
|
yield health;
|
|
await Future<void>.delayed(_healthPoll);
|
|
health = await _todaySteps() ?? health;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<QuestReward> verify(
|
|
QuestCard card, {
|
|
PlaceProof proof = const PlaceProof(),
|
|
}) async {
|
|
// 열린 TIME ATTACK 회차 — 원 Quest 유형과 무관하게 회차 참여로 보낸다(10/3 통합회신 Q-TA-01).
|
|
final reason = card.endsAt != null ? null : card.type.blockedReason;
|
|
if (reason != null) throw BackendException('E-QST-01', reason);
|
|
|
|
final result = card.endsAt != null
|
|
? await _backend.call('fnQuestTimeAttack', {'questId': card.id})
|
|
: switch (card.type) {
|
|
// **퀘스트 문서 ID 를 함께 보낸다.** 서버가 진행 문서를 이 ID 로 쓰고 일일 한도를
|
|
// `quests/{questId}.dailyLimit` 에서 읽는다. 보내지 않으면 서버는 유형 이름에
|
|
// 쓰고 앱은 문서 ID 로 읽어, 지급은 되는데 화면은 「시작하기」에 머문다.
|
|
QuestType.attend => await _backend.call('fnQuestAttend', {'questId': card.id}),
|
|
QuestType.steps => await _verifySteps(card),
|
|
QuestType.place => await _verifyPlace(card, proof),
|
|
// Community 는 서버가 응원글 · 반응을 확인해 즉시 지급한다(C-11). 증거를 싣지 않는다.
|
|
QuestType.community => await _backend.call('fnQuestSubmit', {'questId': card.id}),
|
|
// 무보상 참여(C-04) — POP 원장 없이 참여 기록 · FPI 원천만 남는다.
|
|
QuestType.streaming =>
|
|
await _backend.call('fnQuestStreamParticipate', {'questId': card.id}),
|
|
QuestType.musicShake => await _verifyShake(card),
|
|
_ => throw BackendException('E-QST-01', card.type.blockedReason ?? ''),
|
|
};
|
|
|
|
return QuestReward(
|
|
rewardedPop: result.intOr('rewarded'),
|
|
balanceAfter: result.intOr('balanceAfter'),
|
|
txId: result.strOr('txId').isEmpty ? null : result.strOr('txId'),
|
|
duplicate: result['duplicate'] == true,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<RewardedAdHandle> loadRewardedAd(QuestReward base) {
|
|
final baseTxId = base.txId;
|
|
if (baseTxId == null) {
|
|
throw BackendException('E-QST-01', appL10n.questNoBaseReward);
|
|
}
|
|
return loadGoogleRewardedAd(
|
|
userId: _backend.requireUid,
|
|
customData: baseTxId,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<int> claimAdReward(String impressionId, QuestReward base) async {
|
|
final baseTxId = base.txId;
|
|
if (baseTxId == null) {
|
|
throw BackendException('E-QST-01', appL10n.questNoBaseReward);
|
|
}
|
|
// 시청 확인은 Google 이 서버로 보내는 SSV 콜백이 한다. 콜백은 광고가 닫힌 뒤 몇 초 안에
|
|
// 도착하므로, 아직이면(E-AD-03) 잠시 기다렸다 다시 묻는다. 끝내 없으면 증분 없이 끝난다(B-03).
|
|
for (var attempt = 0; ; attempt++) {
|
|
try {
|
|
final result = await _backend.call('fnAdReward', {'baseTxId': baseTxId});
|
|
return result.intOr('rewarded');
|
|
} on BackendException catch (error) {
|
|
if (error.code != 'E-AD-03' || attempt >= 5) {
|
|
if (error.code == 'E-AD-03') {
|
|
throw BackendException('E-AD-03', appL10n.adNotVerified);
|
|
}
|
|
rethrow;
|
|
}
|
|
await Future<void>.delayed(const Duration(seconds: 2));
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<Map<String, dynamic>> _verifySteps(QuestCard card) async {
|
|
if (_walkSteps <= 0) {
|
|
// 목록에서 바로 「보상받기」(진행 화면을 거치지 않음) — 걸음을 한 번 읽는다.
|
|
try {
|
|
await _steps().first.timeout(const Duration(seconds: 5));
|
|
} on Exception {
|
|
// 아래에서 「세지 못함」으로 끊는다.
|
|
}
|
|
}
|
|
if (_walkSteps <= 0) {
|
|
// 센서 값이 아직 오지 않았다. 0 을 보내면 서버가 미완주로 판정하지만,
|
|
// 원인이 「걷지 않았다」가 아니라 「세지 못했다」라 사유가 달라진다.
|
|
throw BackendException('E-QST-01', appL10n.questNoSteps);
|
|
}
|
|
return _backend.call('fnQuestVerifySteps', {
|
|
'questId': card.id,
|
|
'steps': _walkSteps,
|
|
'occurredAt': DateTime.now().toUtc().toIso8601String(),
|
|
...await _fandomField(),
|
|
});
|
|
}
|
|
|
|
@override
|
|
Future<MusicShakeSetup> musicShakeSetup(String questId) async {
|
|
final quest = (await _backend.db.doc('quests/$questId').get()).data() ??
|
|
const <String, dynamic>{};
|
|
final link = quest['link'] is Map
|
|
? Map<String, dynamic>.from(quest['link'] as Map)
|
|
: const <String, dynamic>{};
|
|
final videoId = youtubeVideoId(link.strOr('url'));
|
|
final bpm = quest['bpm'];
|
|
final segments = quest.intOr('totalSegments');
|
|
// 영상 · BPM · 구간 수 중 하나라도 없으면 판을 열지 않는다 — 기본값을 지어내지 않는다.
|
|
if (videoId == null || bpm is! num || bpm <= 0 || segments <= 0) {
|
|
throw BackendException('E-QST-01', appL10n.questTypeUnverifiable);
|
|
}
|
|
final policy = await _policy();
|
|
final shake = policy['musicShake'];
|
|
// 🟡 임시값 — 회신 대기(해석 확인 요청서 MusicShake 「재생 시작 위치」): 첫 박 3초 전부터 튼다.
|
|
final leadIn = shake is Map ? shake['leadInMs'] : null;
|
|
return MusicShakeSetup(
|
|
videoId: videoId,
|
|
title: link.strOr('title', questTitle(quest)),
|
|
bpm: bpm.toDouble(),
|
|
segments: segments,
|
|
firstBeat: Duration(milliseconds: quest.intOr('firstBeatMs')),
|
|
windows: ShakeWindows.from(policy),
|
|
leadIn: Duration(milliseconds: leadIn is num && leadIn >= 0 ? leadIn.round() : 3000),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Stream<DateTime> shakeEvents() => _shakes();
|
|
|
|
@override
|
|
void recordShake(ShakeJudge run) => _shakeRun = run;
|
|
|
|
Future<Map<String, dynamic>> _verifyShake(QuestCard card) async {
|
|
final run = _shakeRun;
|
|
// 끝낸 판을 서버 세션에 남겨 두었다면 그 판정표로 — 목록에서 바로 「보상받기」(원장 C-83).
|
|
final saved = card.session?.state == QuestStage.completed ? card.session : null;
|
|
final judgement = run?.judgement() ?? saved?.judgement;
|
|
final segments = run?.segments ?? saved?.total ?? 0;
|
|
if (judgement == null || segments <= 0) {
|
|
throw BackendException('E-QST-01', appL10n.questNoShake);
|
|
}
|
|
final quest = (await _backend.db.doc('quests/${card.id}').get()).data() ??
|
|
const <String, dynamic>{};
|
|
final ids = quest['trackIds'];
|
|
final link = quest['link'] is Map ? Map<String, dynamic>.from(quest['link'] as Map) : null;
|
|
// 곡 ID 는 서버 대조용이다 — 화면에 띄우지 않는다. 카탈로그가 없으면 영상 ID 다.
|
|
final trackId = ids is List && ids.isNotEmpty
|
|
? '${ids.first}'
|
|
: quest.strOr('trackId', youtubeVideoId(link?.strOr('url') ?? '') ?? '');
|
|
_shakeRun = null;
|
|
return _backend.call('fnQuestVerifyMusicShake', {
|
|
'questId': card.id,
|
|
'trackId': trackId,
|
|
'totalSegments': segments,
|
|
if (run != null) 'firstBeatMs': run.firstBeat.inMilliseconds,
|
|
'judgement': judgement,
|
|
'occurredAt': DateTime.now().toUtc().toIso8601String(),
|
|
...await _fandomField(),
|
|
});
|
|
}
|
|
|
|
Future<Map<String, dynamic>> _verifyPlace(QuestCard card, PlaceProof proof) async {
|
|
final quest = await _backend.db
|
|
.doc('quests/${card.id}')
|
|
.get()
|
|
.then((s) => s.data() ?? const <String, dynamic>{});
|
|
final placeId = _placeIdOf(quest);
|
|
if (placeId.isEmpty) {
|
|
// 설계서 §2.8 의 `quests` 에 장소를 가리키는 필드가 없다. 좌표만 보내면
|
|
// 서버가 어느 반경과 견줄지 알 수 없으므로 임의로 고르지 않고 끊는다.
|
|
throw BackendException('E-QST-03', appL10n.questNoPlace);
|
|
}
|
|
final fix = await _placeFix();
|
|
// 사진은 Storage 에 올리고 경로를 보낸다 — 서버가 접두(본인 · 이 Quest)와 파일 존재를 다시 본다.
|
|
String? photoPath;
|
|
if (proof.photo case final bytes?) {
|
|
photoPath =
|
|
'questProofs/${_backend.requireUid}/${card.id}/${DateTime.now().millisecondsSinceEpoch}.jpg';
|
|
try {
|
|
await _backend.storage
|
|
.ref(photoPath)
|
|
.putData(bytes, SettableMetadata(contentType: 'image/jpeg'));
|
|
} on FirebaseException {
|
|
throw BackendException('E-QST-01', appL10n.photoUploadFailed);
|
|
}
|
|
}
|
|
return _backend.call('fnQuestVerifyPlace', {
|
|
// 추가 인증 수단(C-03) — GPS 와 함께 보낸다. 판정은 서버가 한다.
|
|
if (proof.qr case final qr? when qr.isNotEmpty) 'qr': qr,
|
|
'photo': ?photoPath,
|
|
// 참여 횟수는 Quest 단위로 센다(정책 원장 C-12 — 같은 장소의 다른 Quest 는 별개).
|
|
'questId': card.id,
|
|
'placeId': placeId,
|
|
'lat': fix.lat,
|
|
'lng': fix.lng,
|
|
'accuracyM': fix.accuracyM,
|
|
// 판정하지 않고 그대로 올린다 — 설계서 §8 「mock location 차단」은 서버 판정이다.
|
|
'mocked': fix.mocked,
|
|
...await _fandomField(),
|
|
});
|
|
}
|
|
|
|
String _placeIdOf(Map<String, dynamic> quest) {
|
|
final direct = quest.strOr('placeId');
|
|
if (direct.isNotEmpty) return direct;
|
|
final conditions = quest['conditions'];
|
|
if (conditions is Map) {
|
|
return Map<String, dynamic>.from(conditions).strOr('placeId');
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/// 활동 로그의 집계 축 — `activityLogs.fandomId`(설계서 §2.1). 소속이 없으면 싣지 않는다.
|
|
Future<Map<String, dynamic>> _fandomField() async {
|
|
final uid = _backend.uid;
|
|
if (uid == null) return const {};
|
|
final user = await _backend.db
|
|
.doc('users/$uid')
|
|
.get()
|
|
.then((s) => s.data() ?? const <String, dynamic>{});
|
|
final fandomId = user.strOr('fandomId');
|
|
return fandomId.isEmpty ? const {} : {'fandomId': fandomId};
|
|
}
|
|
}
|
|
|
|
/// 일일 한도의 리셋 단위 — 서버 `dayKey()` 와 같은 기준(KST)으로 끊는다.
|
|
/// 단말 시간대로 끊으면 해외 사용자의 화면과 서버 판정이 하루 어긋난다.
|
|
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>{};
|
|
// 열린 TIME ATTACK 회차는 회차 단위 1인 1회다 — 그날 원 Quest 를 했어도 회차는 따로 받는다.
|
|
final round = openTimeAttack(quest);
|
|
if (round != null) {
|
|
final joined = p.strOr('timeAttackRoundId') == round.roundId;
|
|
return (count: joined ? 1 : 0, done: joined);
|
|
}
|
|
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);
|
|
}
|
|
|
|
/// 열린 TIME ATTACK 회차 — 원 Quest 문서의 `timeAttack`(10/3 통합회신: 별도 유형이 아니라
|
|
/// 기존 Quest 에 붙는 제한시간 모드). 창(`activeFrom` ~ `activeTo`, 서버 시각) 밖이면 null.
|
|
/// 화면 표시용이다 — 참여 수락은 서버(`fn-quest-timeattack`)가 서버 시각으로 다시 판정한다.
|
|
/// 카드 · 상세에 보일 Quest 제목. 열린 TIME ATTACK 회차면 회차 제목(`timeAttack.title`)이 먼저다.
|
|
/// 운영 Quest 제목은 언어별 지도라 [SnapshotReader.textOr] 로 읽는다(설계서 §2.8 `title` 다국어).
|
|
String questTitle(Map<String, dynamic> quest) {
|
|
final round = openTimeAttack(quest)?.title ?? '';
|
|
return round.isNotEmpty ? round : quest.textOr('title');
|
|
}
|
|
|
|
({String roundId, DateTime endsAt, int rewardPop, String title})? openTimeAttack(
|
|
Map<String, dynamic> quest,
|
|
) {
|
|
final raw = quest['timeAttack'];
|
|
if (raw is! Map) return null;
|
|
final round = Map<String, dynamic>.from(raw);
|
|
final from = round['activeFrom'];
|
|
final to = round['activeTo'];
|
|
if (from is! Timestamp || to is! Timestamp) return null;
|
|
final now = ServerClock.now();
|
|
if (now.isBefore(from.toDate()) || !now.isBefore(to.toDate())) return null;
|
|
return (
|
|
roundId: round.strOr('roundId'),
|
|
endsAt: to.toDate(),
|
|
rewardPop: round.intOr('rewardPop'),
|
|
title: round.textOr('title'),
|
|
);
|
|
}
|