QUEST · CAMPAIGN 화면 — 퀘스트 참여·생성·인증 · 캠페인 목록·상세·후원·랭킹
This commit is contained in:
@@ -0,0 +1,564 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
|
||||
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,
|
||||
PlaceProbe placeFix = devicePlaceFix,
|
||||
WalkBaseline? baseline,
|
||||
}) : _stepCount = stepCount,
|
||||
_placeFix = placeFix,
|
||||
_baseline = baseline ?? WalkBaseline();
|
||||
|
||||
final Backend _backend;
|
||||
final StepCountProbe _stepCount;
|
||||
final PlaceProbe _placeFix;
|
||||
|
||||
/// 오늘 걸은 수를 내는 기준선. 하루 단위라 앱을 껐다 켜도 이어진다.
|
||||
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;
|
||||
return _page(limit);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<QuestCard>> more({required int offset, int limit = 10}) =>
|
||||
_page(limit);
|
||||
|
||||
Future<List<QuestCard>> _page(int limit) async {
|
||||
final uid = _backend.requireUid;
|
||||
final db = _backend.db;
|
||||
|
||||
// 정렬하지 않는다. 설계서 §2.8 의 `quests` 에 정렬 키가 없고, Admin 이 쓰는
|
||||
// 문서에는 `createdAt` 이 없다(`fn-admin-write` 는 `updatedAt` 만 찍는다).
|
||||
// `orderBy('createdAt')` 로 두면 그 문서들이 목록에서 조용히 사라진다.
|
||||
var query = db.collection('quests').limit(limit);
|
||||
final cursor = _cursor;
|
||||
if (cursor != null) query = query.startAfterDocument(cursor);
|
||||
|
||||
final snap = await query.get();
|
||||
if (snap.docs.isNotEmpty) _cursor = snap.docs.last;
|
||||
|
||||
// 타임어택 창은 서버 시각이다. 타임어택이 있을 때만 서버 시계를 잰다(세션당 1회).
|
||||
if (snap.docs.any((d) => d.data()['type'] == 'timeattack')) {
|
||||
await ServerClock.ensure(_backend);
|
||||
}
|
||||
final now = ServerClock.now();
|
||||
final policy = await _policy();
|
||||
// 명세 `518:88395` 의 *퀘스트 생성 최신순* 은 페이지 안에서만 맞춘다. 서버 정렬은
|
||||
// 걸지 않았다 — `quests` 에 정렬 키가 정해져 있지 않고, `orderBy` 를 걸면 그 필드가
|
||||
// 없는 문서가 목록에서 통째로 빠진다.
|
||||
final docs = [...snap.docs]..sort((a, b) {
|
||||
final left = _writtenAt(a.data());
|
||||
final right = _writtenAt(b.data());
|
||||
if (left == null && right == null) return 0;
|
||||
if (left == null) return 1;
|
||||
if (right == null) return -1;
|
||||
return right.compareTo(left);
|
||||
});
|
||||
|
||||
final cards = <QuestCard>[];
|
||||
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>{});
|
||||
cards.add(_card(doc.id, quest, progress, uid, policy, await _submission(doc.id, quest)));
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
|
||||
/// 문서가 쓰인 시각. Admin 이 쓴 문서에는 `createdAt` 이 없고 `updatedAt` 만 있다
|
||||
/// (`fn-admin-write`). 둘 다 없으면 정렬 근거가 없다는 뜻이라 null 이다.
|
||||
DateTime? _writtenAt(Map<String, dynamic> quest) {
|
||||
for (final key in ['createdAt', 'updatedAt']) {
|
||||
final value = quest[key];
|
||||
if (value is Timestamp) return value.toDate();
|
||||
}
|
||||
return 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 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,
|
||||
]) {
|
||||
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 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;
|
||||
|
||||
return QuestCard(
|
||||
id: id,
|
||||
title: _title(quest),
|
||||
categoryName: type.label,
|
||||
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
|
||||
: QuestStage.notStarted,
|
||||
goal: authorUid.isEmpty ? goal : quest.intOr('goal', 1),
|
||||
// 걸음은 센서를 구독해야 알 수 있어 시작 전에는 0 이다. 나머지는 당일 수행 횟수다.
|
||||
progress: steps ? (done ? goal : 0) : count,
|
||||
rewardPop: type.rewardFrom(quest, policy),
|
||||
// 설계서 §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),
|
||||
endsAt: type == QuestType.timeAttack && quest['activeTo'] is Timestamp
|
||||
? (quest['activeTo'] as Timestamp).toDate()
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// 걸음 구간 문턱들 — `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) 둘 다 들어올 수 있어 둘 다 받는다.
|
||||
String _title(Map<String, dynamic> quest) {
|
||||
final raw = quest['title'];
|
||||
if (raw is String) return raw;
|
||||
if (raw is Map) {
|
||||
final map = Map<String, dynamic>.from(raw);
|
||||
return map.strOr('ko', map.strOr('en'));
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
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);
|
||||
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 [],
|
||||
);
|
||||
}
|
||||
|
||||
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'];
|
||||
return QuestSubmission(
|
||||
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'),
|
||||
);
|
||||
}
|
||||
|
||||
/// 내 제출 건. 사용자 생성 Quest 만 있다(문서 ID `{questId}_{uid}`).
|
||||
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);
|
||||
}
|
||||
|
||||
/// 생성자의 검토 대기열. 규칙이 생성자 조건을 증명하도록 `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)),
|
||||
];
|
||||
}
|
||||
|
||||
@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> edit(String questId, {required String title, required String description}) =>
|
||||
_backend.call('fnQuestEdit', {
|
||||
'questId': questId,
|
||||
'title': title,
|
||||
'description': description,
|
||||
});
|
||||
|
||||
/// 시작·중단은 세션 상태다.
|
||||
///
|
||||
/// 설계서의 `questProgress` 는 **당일 수행 횟수와 지급액**만 담는다(§2.1). 참여
|
||||
/// 시작 시각을 남기는 필드가 없어 서버에 「진행중」이 존재하지 않는다. 보상 상태는
|
||||
/// [verify] 가 지급에 성공한 뒤 서버 카운터로 확정된다.
|
||||
@override
|
||||
Future<QuestStage> advance(String questId, QuestStage next) async => next;
|
||||
|
||||
/// 생성 · 예치 · 수수료 · 공개는 `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,
|
||||
});
|
||||
return result.strOr('questId');
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<int> progressStream(QuestCard card) {
|
||||
if (card.type != QuestType.steps) return const Stream<int>.empty();
|
||||
// 센서는 부팅 이후 누적을 준다. 하루 단위 기준선을 빼야 정책 원장 **B-26** 의
|
||||
// 구간(1 천·5 천·1 만·1.5 만보)과 같은 것을 세게 된다.
|
||||
return _stepCount().asyncMap((cumulative) async {
|
||||
_walkSteps = await _baseline.todaySteps(cumulative);
|
||||
return _walkSteps;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<QuestReward> verify(QuestCard card) async {
|
||||
final reason = card.type.blockedReason;
|
||||
if (reason != null) throw BackendException('E-QST-01', reason);
|
||||
|
||||
final result = 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),
|
||||
// Community 는 서버가 응원글 · 반응을 확인해 즉시 지급한다(C-11). 증거를 싣지 않는다.
|
||||
QuestType.community => await _backend.call('fnQuestSubmit', {'questId': card.id}),
|
||||
QuestType.timeAttack =>
|
||||
await _backend.call('fnQuestTimeAttack', {'questId': card.id}),
|
||||
_ => 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() => loadGoogleRewardedAd();
|
||||
|
||||
@override
|
||||
Future<int> claimAdReward(String impressionId, QuestReward base) async {
|
||||
final baseTxId = base.txId;
|
||||
if (baseTxId == null) {
|
||||
throw BackendException('E-QST-01', appL10n.questNoBaseReward);
|
||||
}
|
||||
final result = await _backend.call('fnAdReward', {
|
||||
'adImpressionId': impressionId,
|
||||
'baseTxId': baseTxId,
|
||||
'network': 'admob',
|
||||
});
|
||||
return result.intOr('rewarded');
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _verifySteps(QuestCard card) async {
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _verifyPlace(QuestCard card) 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();
|
||||
return _backend.call('fnQuestVerifyPlace', {
|
||||
// 참여 횟수는 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);
|
||||
Reference in New Issue
Block a user