Files
fanuzu/app/lib/features/quest/quest_data.dart
T

772 lines
26 KiB
Dart

/// 퀘스트가 그리는 값의 형태 — 화면 대조 원장 F4.
///
/// 근거는 Figma 명세 구간이다.
/// - 퀘스트 목록 `518:88395`·`518:88405` · 상세 `518:88429`~`518:88441`
/// - 광고 보상 `523:18244`·`518:88685` · 보상 완료 `518:88630`
/// - 퀘스트 생성 `523:18152`~`523:18188` · Admin 정의 `567:19531`
library;
import '../../core/monetization/rewarded_ad.dart';
import '../../core/i18n/app_locale.dart';
import 'place_picker_screen.dart' show PlacePickerScreen;
/// 진행 상태 — 명세가 **다섯 가지**를 못박았다.
///
/// 명세 주석은 *「자세히」 · 「시작하기」 · 「진행중」 · 「보상받기」 · 「보상완료」* 이지만,
/// 버튼 문구는 시각 기준(원장 C-44)인 Figma 화면을 따른다 — 「퀘스트 시작하기」 ·
/// 「보상받기」 · 「보상 완료」(`264:46867` · `373:433420`). 상세 `272:245274` 는 「보상 받기」로
/// 띄어 썼지만 홈 · 목록 사본 표기로 통일한다.
///
/// 홈 화면의 [QuestState] 와 다른 열거형인 이유는 다루는 것이 다르기 때문이다.
/// 홈은 「오늘 할 것」 요약이고 여기는 퀘스트 하나의 생애다.
enum QuestStage {
/// 내가 만든 퀘스트. 참여 대상이 아니라 관리 대상이라 버튼이 「자세히」다.
mine,
notStarted,
inProgress,
completed,
rewarded;
/// Figma 화면 문구. 임의로 바꾸지 마라 — 화면 대조 대상이다.
String get buttonLabel => switch (this) {
mine => appL10n.questDetails,
notStarted => appL10n.questStart,
inProgress => appL10n.questInProgress,
completed => appL10n.questClaim,
rewarded => appL10n.questRewardDone,
};
/// 누를 수 있는가. 「보상완료」는 명세가 *클릭 불가* 로 정했다.
bool get tappable => this != QuestStage.rewarded;
}
/// 퀘스트 유형 — `quests/{questId}.type`(Firestore 설계서 §2.8).
///
/// 서버의 검증 함수가 유형마다 다르므로(`fn-quest-attend` · `fn-quest-verify-steps` ·
/// `fn-quest-verify-place` · `fn-quest-verify-music-shake`) 앱은 이 값으로 어느 함수를
/// 부를지 고른다.
///
/// **표기 문자열은 확정값이 아니다.** 상세 정의서 8장의 Quest Type 10종과 앱 Figma
/// 카테고리 4종의 관계가 Q-QC-01 회신 대기라, 들어오는 문자열을 별칭까지 받아 옮긴다
/// ([parse]). 모르는 값은 [unknown] 으로 두고 참여를 잠근다 — 임의로 어느 검증 함수에
/// 붙이면 검증 없는 지급 경로가 생긴다.
enum QuestType {
attend,
steps,
place,
musicShake,
/// 긴급 돌발 타임어택 — 별첨 1 ③-4 · FR-QST-06. 운영이 발동하고 창(`activeFrom`~`activeTo`)
/// 안에서만 참여를 받는다. 판정 시각은 서버 시각이다(`fn-quest-timeattack`).
timeAttack,
/// 스밍·YouTube. 별첨 B 「1차 별도 완청 보상형 Quest 없음」 + Q-QC-04 회신 대기.
streaming,
/// 사용자 생성 Vote · SNS — 인증 자료 제출 → 생성자 · 운영자 검토(원장 C-08 · C-10).
vote,
sns,
/// 사용자 생성 Community — 응원글 + 다른 회원 반응을 서버가 확인하면 즉시 지급(C-11).
community,
/// 앱이 판정 경로를 아는 유형이 아니다.
unknown;
/// 목록·상세의 분류 표기. 서버가 분류명을 따로 주지 않아 유형에서 낸다.
String get label => switch (this) {
attend => appL10n.questTypeAttend,
steps => appL10n.questTypeSteps,
place => appL10n.questTypePlace,
musicShake => 'Music Shake',
timeAttack => appL10n.questTypeTimeAttack,
streaming => appL10n.questTypeStreaming,
vote => appL10n.questTypeVote,
sns => appL10n.questTypeSns,
community => appL10n.questCatCommunity,
unknown => appL10n.commonEtc,
};
static QuestType parse(String raw) => switch (raw.toLowerCase()) {
'attend' || 'checkin' => QuestType.attend,
'steps' || 'walk' || 'health' => QuestType.steps,
'place' || 'location' => QuestType.place,
'musicshake' || 'shake' => QuestType.musicShake,
'timeattack' => QuestType.timeAttack,
'streaming' || 'stream' || 'youtube' => QuestType.streaming,
'vote' => QuestType.vote,
'sns' => QuestType.sns,
'community' => QuestType.community,
_ => QuestType.unknown,
};
/// 사람 검토형(C-08) — 참여가 곧 지급이 아니라 제출이다.
bool get reviewed => this == QuestType.vote || this == QuestType.sns;
/// 화면에 적는 보상 POP — **서버가 실제로 지급하는 값과 같은 원천**.
///
/// 출석은 `fn-quest-attend` 가 `config/policy.attendPop` 을 지급하고
/// `quests.rewardPop` 을 보지 않는다. 그래서 출석만 운영값을 읽는다. 운영값이
/// 없으면 null — 숫자를 지어내지 않고 화면이 중립 문구를 쓴다.
int? rewardFrom(Map<String, dynamic> quest, Map<String, dynamic> policy) {
final raw = this == QuestType.attend
? policy['attendPop']
: quest['rewardPop'];
return raw is num ? raw.round() : null;
}
/// 앱이 서버 검증까지 태울 수 있는 유형인가.
///
/// [musicShake] 와 [streaming] 은 Q-QC-04 회신 대기라 참여를 열지 않는다
/// (해석 확인 요청서 8.1 보류 범위). [unknown] 은 판정 함수를 모른다.
bool get verifiable => switch (this) {
QuestType.attend ||
QuestType.steps ||
QuestType.place ||
QuestType.timeAttack ||
QuestType.vote ||
QuestType.sns ||
QuestType.community => true,
QuestType.musicShake || QuestType.streaming || QuestType.unknown => false,
};
/// 참여를 열지 않는 사유. 열려 있으면 null 이다.
String? get blockedReason => switch (this) {
QuestType.musicShake || QuestType.streaming =>
'발주사 확인 중입니다 — 스밍·Music Shake 유형은 회신 대기 항목입니다(Q-QC-04).',
QuestType.unknown => appL10n.questTypeUnverifiable,
_ => null,
};
}
/// 서버가 판정하고 지급한 결과 — `fn-quest-*` 응답.
///
/// **앱이 계산한 값이 아니다.** 지급액과 잔액은 원장을 지난 서버 값이며
/// (별첨 1 ② 「클라이언트 단독 지급 0건」) 화면은 이 값을 그대로 띄운다.
class QuestReward {
const QuestReward({
required this.rewardedPop,
required this.balanceAfter,
this.txId,
this.duplicate = false,
});
final int rewardedPop;
final int balanceAfter;
final String? txId;
/// 이미 받은 보상의 재응답(`duplicate: true`). 새 지급이 아니므로 보상 화면 · 광고
/// 추가 보상을 다시 열지 않는다.
final bool duplicate;
}
/// 퀘스트를 누가 만들었는가. 명세가 관리자 리스트와 사용자 카드를 나눠 두었다.
enum QuestAuthor {
/// 어드민에 등록된 퀘스트. 광고 추가 보상은 기본 보상 × (배수 − 1) 기여 POP(B-03).
admin,
/// 사용자가 만든 퀘스트. 광고 추가 보상은 고정 기여 POP(B-28 · C-02).
user,
}
/// 퀘스트 분류 — `config/questCatalog.categories`(원장 C-01). 순서 · 노출은 Admin 운영값이다.
class QuestCategory {
const QuestCategory({
required this.id,
required this.name,
this.activities = const [],
});
final String id;
final String name;
/// 이 분류에서 **사용자가 만들 수 있는** 활동(노출 · `ugcAllowed`), 순서대로.
final List<QuestActivity> activities;
}
/// 생성 화면의 활동 — `config/questCatalog.activities`. 입력 필드는 [fields] 만 그린다(C-01).
class QuestActivity {
const QuestActivity({
required this.key,
required this.name,
this.fields = const {},
});
final String key;
final String name;
/// `inputSchema` 의 필드 이름 — `link` · `place` 등.
final Set<String> fields;
/// 무보상 기본 참여 Quest(C-04) — 보상 · 인원 입력이 없다.
bool get noReward => key == 'youtube' || key == 'streaming';
}
/// 생성 화면이 쓰는 분류와 운영값(B-30 수수료 · C-06 최대 기간).
class QuestCatalog {
const QuestCatalog({
this.categories = const [],
this.feeRate = 0.1,
this.feeMin = 10,
this.feeOnNoReward = false,
this.maxDays = 30,
});
final List<QuestCategory> categories;
final double feeRate;
final int feeMin;
/// 보상 없는 Quest(Pool 0)에도 최소 수수료를 받는가 — 해석 확인 요청 Q-UQ-02. 권고 A안은 false(0 POP).
final bool feeOnNoReward;
final int maxDays;
/// 수수료 안내값 — Pool 의 10%(올림) · 최소 10, Pool 0 은 [feeOnNoReward] 가 꺼져 있으면 0.
/// 실제 차감은 서버가 다시 계산한다.
int feeOf(int pool) => pool == 0 && !feeOnNoReward
? 0
: (pool * feeRate).ceil() < feeMin
? feeMin
: (pool * feeRate).ceil();
}
/// 위치 Quest 의 등록 장소 — `places/{placeId}`.
class QuestPlace {
const QuestPlace({required this.id, required this.name});
final String id;
final String name;
}
/// 링크 미리보기 — 서버 `fn-link-preview`(C-05). [status] 는 `ok` · `preview_failed` · `invalid`.
class QuestLink {
const QuestLink({
required this.url,
required this.status,
this.title = '',
this.thumbnail,
this.provider,
});
final String url;
final String status;
/// 원본 제목(서버 조회값) 또는 조회 실패 시 생성자가 적은 최소 제목.
final String title;
final String? thumbnail;
final String? provider;
bool get ok => status == 'ok';
bool get failed => status == 'preview_failed';
}
/// 제출 건 — `questSubmissions/{id}`(C-08 · G-01 · G-03). [status] 는
/// `submitted` · `approved` · `paid` · `rejected` · `rejected_final`.
class QuestSubmission {
const QuestSubmission({
required this.id,
required this.status,
this.submittedAt,
this.rejectReason,
this.resubmitAllowed = false,
this.summary = '',
});
final String id;
final String status;
final DateTime? submittedAt;
final String? rejectReason;
final bool resubmitAllowed;
/// 인증 자료 요약(마지막 제출의 링크 · 내용). 생성자 검토 목록에 쓴다.
final String summary;
bool get pending => status == 'submitted' || status == 'approved';
/// 최종 반려 — 검토자가 재제출 불가로 반려했거나, 마감 뒤 보완 대기가 닫힌 건(`rejected_final`).
bool get finalRejected =>
status == 'rejected_final' || (status == 'rejected' && !resubmitAllowed);
bool get rejected => status == 'rejected' || status == 'rejected_final';
/// 주말 포함 24시간 검토 목표를 넘겼는가(C-08). 자동 승인은 없고 안내만 한다.
bool delayed(DateTime now, {int hours = 24}) =>
status == 'submitted' &&
submittedAt != null &&
now.difference(submittedAt!) > Duration(hours: hours);
}
/// 목록 카드 하나.
class QuestCard {
const QuestCard({
required this.id,
required this.title,
required this.categoryName,
required this.author,
required this.stage,
required this.goal,
required this.progress,
required this.rewardPop,
required this.participants,
this.type = QuestType.unknown,
this.capacity,
this.adMultiplier = 2,
this.ugcAdPop = 10,
this.endsAt,
});
final String id;
final String title;
final String categoryName;
/// 검증 경로를 고르는 유형. 표본 데이터는 지정하지 않아 [QuestType.unknown] 이다.
final QuestType type;
final QuestAuthor author;
final QuestStage stage;
/// 설정 목표 수치. 0 이면 수치 목표가 없는 퀘스트다.
final int goal;
/// 실행한 목표 수치.
final int progress;
/// 보상 POP. 운영값을 읽지 못했으면 null 이고 화면은 숫자 대신 중립 문구를 쓴다.
final int? rewardPop;
final int participants;
/// 모집 인원 — 카드의 *참여 인원 : 0 / 5* 분모. 설계서 §2.8 `quests` 에 필드가 없어
/// 서버 경로는 null 이고, 화면은 분모 없이 참여 수만 적는다.
final int? capacity;
/// 운영 Quest 의 광고 배수 — 원장 **B-03** 기본 x2, 지정 Quest 는 `quests/{id}.adMultiplier`.
final int adMultiplier;
/// 사용자 생성 Quest 의 광고 추가 보상 — 원장 **C-02** 고정 10 기여 POP(운영값).
final int ugcAdPop;
/// 참여 마감 시각(`quests.activeTo`). 타임어택만 채운다 — 화면이 남은 시간을 센다.
final DateTime? endsAt;
/// 광고를 끝까지 보면 더 받는 기여 POP 의 **안내값**. 실제 지급액은 서버가 기본 보상
/// 원장 행으로 다시 계산해 돌려준다(`fnAdReward.rewarded`). 보상 POP 을 모르면 null.
int? get adBonusPop => author == QuestAuthor.user
? ugcAdPop
: rewardPop == null
? null
: rewardPop! * (adMultiplier - 1);
/// 0~1. 목표가 없으면 0 이다.
double get ratio => goal == 0 ? 0 : (progress / goal).clamp(0.0, 1.0);
/// 상태·진행도만 바꾼 사본. [QuestCard] 는 불변이라 갈아 끼운다.
QuestCard copyWith({QuestStage? stage, int? progress}) => QuestCard(
id: id,
title: title,
categoryName: categoryName,
author: author,
stage: stage ?? this.stage,
goal: goal,
progress: progress ?? this.progress,
rewardPop: rewardPop,
participants: participants,
type: type,
capacity: capacity,
adMultiplier: adMultiplier,
ugcAdPop: ugcAdPop,
endsAt: endsAt,
);
}
/// 상세 화면이 필요로 하는 것.
class QuestDetail {
const QuestDetail({
required this.card,
required this.imageLabel,
required this.description,
this.link,
this.endsAt,
this.repeatHours,
this.repeatMax = 1,
this.submission,
this.reviews = const [],
});
final QuestCard card;
/// 대표 이미지. 자산이 아직 없어 라벨로 둔다.
final String imageLabel;
/// 사용자 생성 Quest 는 작성자 설명(`userDescription`) — 링크 원본 제목과 다른 필드다(C-05).
final String description;
final QuestLink? link;
/// 서버가 정한 마감 순간(UTC instant). 화면은 기기 시간대로 표시만 바꾼다(C-06 · G-04).
final DateTime? endsAt;
/// 위치 반복 규칙(C-12). 반복 허용이면 간격, 아니면 null(1인 1회).
final int? repeatHours;
final int repeatMax;
/// 내 제출 건(사람 검토형).
final QuestSubmission? submission;
/// 생성자에게만 — 검토 대기 제출 건.
final List<QuestSubmission> reviews;
}
/// 목록 탭. 명세 — *전체 / 진행중 / 완료 / 내 퀘스트*.
enum QuestTab {
all,
inProgress,
done,
mine;
String get label => switch (this) {
all => appL10n.commonAll,
inProgress => appL10n.questInProgress,
done => appL10n.commonDone,
mine => appL10n.questTabMine,
};
bool accepts(QuestCard card) => switch (this) {
QuestTab.all => true,
QuestTab.inProgress => card.stage == QuestStage.inProgress,
QuestTab.done =>
card.stage == QuestStage.completed || card.stage == QuestStage.rewarded,
QuestTab.mine => card.stage == QuestStage.mine,
};
}
/// 생성 화면이 만드는 값 — `fn-quest-create` 입력.
class QuestDraft {
const QuestDraft({
required this.clientSeq,
required this.activityKey,
required this.title,
this.description = '',
this.rewardPop = 0,
this.participants = 0,
this.endDate,
this.url,
this.manualTitle,
this.placeId,
});
/// 같은 화면의 재시도를 한 건으로 묶는 멱등 키.
final String clientSeq;
final String activityKey;
final String title;
final String description;
final int rewardPop;
final int participants;
/// 종료일(날짜만). null 이면 공개 후 24시간(C-06).
final DateTime? endDate;
final String? url;
/// 링크 미리보기 조회 실패 시 생성자가 적은 제목.
final String? manualTitle;
final String? placeId;
}
abstract interface class QuestRepository {
/// 명세 — *퀘스트 생성 최신순 소팅(최초 10개 노출, 스크롤 시 10개씩 로드)*.
Future<List<QuestCard>> load({int limit = 10});
Future<List<QuestCard>> more({required int offset, int limit = 10});
Future<QuestDetail> detail(String questId);
/// 생성 화면의 분류 · 활동과 운영값 — `config/questCatalog` · `config/policy` 를 매번 읽는다(C-01).
Future<QuestCatalog> catalog();
/// 위치 Quest 에 고를 수 있는 등록 장소.
Future<List<QuestPlace>> places();
/// 링크 미리보기(C-05). 저장 때 서버가 다시 조회하므로 화면 표시용이다.
Future<QuestLink> previewLink(String url);
/// 사람 검토형 인증 제출 · 반려 후 보완 제출(C-08 · G-01).
Future<void> submit(String questId, {String? url, String? text});
/// 생성자 검토 — 승인 · 반려(사유 · 재제출 가능 여부).
Future<void> review(
String submissionId, {
required bool approve,
String? reason,
bool resubmitAllowed = false,
});
/// 공개 후 수정 — 제목 · 설명만(C-07).
Future<void> edit(
String questId, {
required String title,
required String description,
});
/// 상태 전이. 서버가 확정한 상태를 돌려준다.
Future<QuestStage> advance(String questId, QuestStage next);
Future<String> create(QuestDraft draft);
/// 진행 중 실측 진행도. 걸음 퀘스트만 값을 낸다.
///
/// 명세 `518:88441` 의 *「시작하기」 버튼 진행중으로 변경 후 실시간 목표 실행률
/// 수집* 이 이 스트림이다. 값은 **시작 시점 대비 증가분**이며, 권한이 없거나
/// 센서가 없으면 오류로 끊는다.
Stream<int> progressStream(QuestCard card);
/// 활동 증거를 서버에 보내 판정·지급을 받는다.
///
/// **판정도 지급도 서버가 한다.** 앱은 증거(걸음 수·좌표)만 싣고, 실패는
/// 에러 카탈로그 코드로 돌아온다(E-QST-01 미완주 · E-QST-02 비정상 ·
/// E-QST-03 반경 밖 · E-POP-03 중복).
Future<QuestReward> verify(QuestCard card);
/// 보상형 광고 한 편을 불러온다. 불러오지 못하면 E-AD-01 이다.
///
/// 사용자가 추가 보상 모달에서 **직접 고른 뒤에만** 부른다(별첨 1 ⑥ No-Force ·
/// 정책 원장 결정 8 「Reward Ad 는 명시적 옵트인」).
Future<RewardedAdHandle> loadRewardedAd();
/// 끝까지 본 광고의 추가 보상을 서버에 청구하고 지급액을 돌려준다. [base] 는 방금 받은
/// 기본 보상이다 — 서버가 그 원장 행으로 증분을 계산한다(B-03 · C-02).
/// 일일 한도를 넘으면 E-AD-02, 같은 노출의 재전송은 첫 지급 결과다.
Future<int> claimAdReward(String impressionId, QuestReward base);
}
/// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.**
class SampleQuestRepository implements QuestRepository {
const SampleQuestRepository();
/// 원장 C-01 매핑 중 사용자 생성 초기값(C-02 A안)으로 열린 활동만.
static QuestCatalog get _catalog => QuestCatalog(
categories: [
QuestCategory(
id: 'planet',
name: appL10n.questCatPlanet,
activities: [
QuestActivity(
key: 'vote',
name: appL10n.questTypeVote,
fields: const {'link'},
),
QuestActivity(
key: 'sns',
name: appL10n.questTypeSns,
fields: const {'link'},
),
],
),
QuestCategory(
id: 'community',
name: appL10n.questCatCommunity,
activities: [
QuestActivity(key: 'community', name: appL10n.questCatCommunity),
],
),
QuestCategory(
id: 'location',
name: appL10n.questTypePlace,
activities: [
QuestActivity(
key: 'location',
name: appL10n.questTypePlace,
fields: const {'place'},
),
],
),
],
);
/// 유형([QuestType])을 함께 담는다. 유형이 없으면 화면이 어느 검증 경로인지 몰라
/// 참여 버튼을 잠근다 — 표본에서도 상태 전이를 보여 주려면 유형이 있어야 한다.
static final _cards = <QuestCard>[
const QuestCard(
id: 'q1',
title: '만보기 챌린지',
categoryName: '걷기',
type: QuestType.steps,
author: QuestAuthor.admin,
stage: QuestStage.inProgress,
goal: 10000,
progress: 6400,
rewardPop: 50,
participants: 1284,
),
const QuestCard(
id: 'q2',
title: '주말 만보기',
categoryName: '걷기',
type: QuestType.steps,
author: QuestAuthor.admin,
stage: QuestStage.completed,
goal: 10000,
progress: 10000,
rewardPop: 10,
participants: 3420,
),
const QuestCard(
id: 'q3',
title: '오늘의 출석',
categoryName: '출석',
type: QuestType.attend,
author: QuestAuthor.admin,
stage: QuestStage.rewarded,
goal: 1,
progress: 1,
rewardPop: 10,
participants: 8210,
),
const QuestCard(
id: 'q4',
title: '홍대 팝업 방문',
categoryName: '특정 위치',
type: QuestType.place,
author: QuestAuthor.user,
stage: QuestStage.notStarted,
goal: 1,
progress: 0,
rewardPop: 100,
participants: 42,
),
const QuestCard(
id: 'q5',
title: '데뷔일 챌린지 함께해요',
categoryName: '걷기',
type: QuestType.steps,
author: QuestAuthor.user,
stage: QuestStage.mine,
goal: 5000,
progress: 0,
rewardPop: 30,
participants: 8,
),
// 스밍 유형은 Q-QC-04 회신 대기다(해석 확인 요청서 8.1). 화면은 두고 참여만
// 잠근 상태를 표본에서도 보여 준다.
const QuestCard(
id: 'q6',
title: '신곡 3분 완청',
categoryName: '스트리밍',
type: QuestType.streaming,
author: QuestAuthor.admin,
stage: QuestStage.notStarted,
goal: 1,
progress: 0,
rewardPop: 10,
participants: 3420,
),
];
@override
Future<List<QuestCard>> load({int limit = 10}) async =>
_cards.take(limit).map(_withClaim).toList();
@override
Future<List<QuestCard>> more({required int offset, int limit = 10}) async =>
_cards.skip(offset).take(limit).map(_withClaim).toList();
QuestCard _withClaim(QuestCard card) => _claimed.contains(card.id)
? card.copyWith(stage: QuestStage.rewarded, progress: card.goal)
: card;
@override
Future<QuestDetail> detail(String questId) async {
final found = _cards.firstWhere(
(c) => c.id == questId,
orElse: () => _cards.first,
);
final card = _claimed.contains(found.id)
? found.copyWith(stage: QuestStage.rewarded, progress: found.goal)
: found;
return QuestDetail(
card: card,
imageLabel: card.categoryName,
description: '${card.title} — 목표를 채우면 ${card.rewardPop ?? 0} POP 을 받습니다.',
);
}
@override
Future<QuestCatalog> catalog() async => _catalog;
@override
Future<List<QuestPlace>> places() async => [
for (final name in PlacePickerScreen.defaultPlaces)
QuestPlace(id: name, name: name),
];
/// 표본은 네트워크를 쓰지 않는다 — 형식만 보고 조회 실패(기본 카드)로 돌려준다.
@override
Future<QuestLink> previewLink(String url) async => QuestLink(
url: url,
status: Uri.tryParse(url)?.hasScheme == true ? 'preview_failed' : 'invalid',
);
@override
Future<void> submit(String questId, {String? url, String? text}) async {}
@override
Future<void> review(
String submissionId, {
required bool approve,
String? reason,
bool resubmitAllowed = false,
}) async {}
@override
Future<void> edit(
String questId, {
required String title,
required String description,
}) async {}
/// 목 모드에서 이미 보상을 받은 퀘스트. 서버가 없으니 앱이 기억한다.
///
/// **표본에만 있는 상태다.** 실 서버에서는 `questProgress` 가 같은 일을 한다.
/// 이것이 없으면 보상을 받은 뒤 화면을 다시 읽을 때 또 「보상받기」로 보인다.
static final _claimed = <String>{};
@override
Future<QuestStage> advance(String questId, QuestStage next) async => next;
@override
Future<String> create(QuestDraft draft) async => 'q-new';
/// **표본은 센서를 읽지 않는다.** 여기서 걸음 수를 지어내면 화면이 카드에 적힌
/// 진행도 대신 만들어 낸 값을 그리게 되고, 목 모드 확인물이 실측처럼 보인다.
@override
Stream<int> progressStream(QuestCard card) => const Stream<int>.empty();
@override
Future<QuestReward> verify(QuestCard card) async {
_claimed.add(card.id);
return QuestReward(
rewardedPop: card.rewardPop ?? 0,
balanceAfter: 12540 + (card.rewardPop ?? 0),
);
}
/// 표본은 광고 SDK 를 부르지 않는다 — 잠깐 기다렸다가 끝까지 본 것으로 둔다.
@override
Future<RewardedAdHandle> loadRewardedAd() async {
await Future<void>.delayed(const Duration(seconds: 2));
return const _SampleAd();
}
/// 운영 Quest 기본 x2 의 증분(= 기본 보상). 표본이라 한도를 세지 않는다.
@override
Future<int> claimAdReward(String impressionId, QuestReward base) async =>
base.rewardedPop;
/// 표본 상태를 되돌린다. 테스트가 회차마다 같은 자리에서 시작하도록 둔다.
static void resetClaims() => _claimed.clear();
}
class _SampleAd implements RewardedAdHandle {
const _SampleAd();
@override
Future<String?> show() async => 'sample-impression';
@override
void dispose() {}
}