/// 퀘스트가 그리는 값의 형태 — 화면 대조 원장 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 — 무보상 기본 참여(원장 C-04 · B-25, 9/16 확정). POP 지급 없이 참여 기록과 /// FPI 원천만 남는다(`fn-quest-stream-participate`). 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 quest, Map policy) { // 시청 · 스트리밍은 무보상 참여다(C-04) — 문서 값과 무관하게 0. if (this == QuestType.streaming) return 0; // Music Shake 는 BPM 판정 성공 시 일반 POP(원장 B-24). 서버(`fn-quest-verify-music-shake`)가 // 퀘스트의 `rewardPop` 을, 없으면 `config/policy.stepsPop` 을 기본액으로 쓰고 판정 등급 // 배수를 곱한다. 화면은 기본액(C 등급)을 적는다 — 운영값이 둘 다 없을 때만 중립 문구. final raw = this == QuestType.attend ? policy['attendPop'] : this == QuestType.musicShake ? quest['rewardPop'] ?? policy['stepsPop'] : quest['rewardPop']; return raw is num ? raw.round() : null; } /// 앱이 서버 검증까지 태울 수 있는 유형인가. /// /// [streaming] 은 무보상 참여(C-04), [musicShake] 는 BPM 구간 판정(B-24 · `fn-quest-verify-music-shake`)으로 /// 연다(Q-QC-04 9/16 회신). [unknown] 은 판정 함수를 모른다. /// 내부 사유(질의 번호 등)는 화면에 내보내지 않는다 — 사용자에게는 한 문구다. bool get verifiable => this != QuestType.unknown; /// 시작 뒤 진행도를 모아야 판정할 수 있는 유형 — 걸음(누적 걸음) · Music Shake(구간 판정). bool get gathers => this == QuestType.steps || this == QuestType.musicShake; /// 참여를 열지 않는 사유. 열려 있으면 null 이다. String? get blockedReason => verifiable ? null : appL10n.questTypeUnverifiable; } /// 서버가 판정하고 지급한 결과 — `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 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 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 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 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> load({int limit = 10}); Future> more({required int offset, int limit = 10}); Future detail(String questId); /// 생성 화면의 분류 · 활동과 운영값 — `config/questCatalog` · `config/policy` 를 매번 읽는다(C-01). Future catalog(); /// 위치 Quest 에 고를 수 있는 등록 장소. Future> places(); /// 링크 미리보기(C-05). 저장 때 서버가 다시 조회하므로 화면 표시용이다. Future previewLink(String url); /// 사람 검토형 인증 제출 · 반려 후 보완 제출(C-08 · G-01). Future submit(String questId, {String? url, String? text}); /// 생성자 검토 — 승인 · 반려(사유 · 재제출 가능 여부). Future review( String submissionId, { required bool approve, String? reason, bool resubmitAllowed = false, }); /// 공개 후 수정 — 제목 · 설명만(C-07). Future edit( String questId, { required String title, required String description, }); /// 상태 전이. 서버가 확정한 상태를 돌려준다. Future advance(String questId, QuestStage next); Future create(QuestDraft draft); /// 보유 일반 POP(`wallet/main.activityBalance`) — 생성 · 보상 뒤 목록 헤더를 다시 맞춘다. /// 모르면 null(헤더는 받은 값을 유지한다). Future popBalance(); /// 진행 중 실측 진행도. 걸음 퀘스트만 값을 낸다. /// /// 명세 `518:88441` 의 *「시작하기」 버튼 진행중으로 변경 후 실시간 목표 실행률 /// 수집* 이 이 스트림이다. 값은 **시작 시점 대비 증가분**이며, 권한이 없거나 /// 센서가 없으면 오류로 끊는다. Stream progressStream(QuestCard card); /// 활동 증거를 서버에 보내 판정·지급을 받는다. /// /// **판정도 지급도 서버가 한다.** 앱은 증거(걸음 수·좌표)만 싣고, 실패는 /// 에러 카탈로그 코드로 돌아온다(E-QST-01 미완주 · E-QST-02 비정상 · /// E-QST-03 반경 밖 · E-POP-03 중복). Future verify(QuestCard card); /// 보상형 광고 한 편을 불러온다. 불러오지 못하면 E-AD-01 이다. /// /// 사용자가 추가 보상 모달에서 **직접 고른 뒤에만** 부른다(별첨 1 ⑥ No-Force · /// 정책 원장 결정 8 「Reward Ad 는 명시적 옵트인」). Future loadRewardedAd(); /// 끝까지 본 광고의 추가 보상을 서버에 청구하고 지급액을 돌려준다. [base] 는 방금 받은 /// 기본 보상이다 — 서버가 그 원장 행으로 증분을 계산한다(B-03 · C-02). /// 일일 한도를 넘으면 E-AD-02, 같은 노출의 재전송은 첫 지급 결과다. Future 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 = [ 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: '신곡 스트리밍 참여', categoryName: '스트리밍', type: QuestType.streaming, author: QuestAuthor.admin, stage: QuestStage.notStarted, goal: 1, progress: 0, // 무보상 기본 참여(C-04). rewardPop: 0, participants: 3420, ), ]; @override Future> load({int limit = 10}) async => _cards.take(limit).map(_withClaim).toList(); @override Future> 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 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 catalog() async => _catalog; @override Future> places() async => [ for (final name in PlacePickerScreen.defaultPlaces) QuestPlace(id: name, name: name), ]; /// 표본은 네트워크를 쓰지 않는다 — 형식만 보고 조회 실패(기본 카드)로 돌려준다. @override Future previewLink(String url) async => QuestLink( url: url, status: Uri.tryParse(url)?.hasScheme == true ? 'preview_failed' : 'invalid', ); @override Future submit(String questId, {String? url, String? text}) async {} @override Future review( String submissionId, { required bool approve, String? reason, bool resubmitAllowed = false, }) async {} @override Future edit( String questId, { required String title, required String description, }) async {} /// 목 모드에서 이미 보상을 받은 퀘스트. 서버가 없으니 앱이 기억한다. /// /// **표본에만 있는 상태다.** 실 서버에서는 `questProgress` 가 같은 일을 한다. /// 이것이 없으면 보상을 받은 뒤 화면을 다시 읽을 때 또 「보상받기」로 보인다. static final _claimed = {}; @override Future advance(String questId, QuestStage next) async => next; @override Future create(QuestDraft draft) async => 'q-new'; @override Future popBalance() async => null; /// **표본은 센서를 읽지 않는다.** 여기서 걸음 수를 지어내면 화면이 카드에 적힌 /// 진행도 대신 만들어 낸 값을 그리게 되고, 목 모드 확인물이 실측처럼 보인다. @override Stream progressStream(QuestCard card) => const Stream.empty(); @override Future verify(QuestCard card) async { _claimed.add(card.id); return QuestReward( rewardedPop: card.rewardPop ?? 0, balanceAfter: 12540 + (card.rewardPop ?? 0), ); } /// 표본은 광고 SDK 를 부르지 않는다 — 잠깐 기다렸다가 끝까지 본 것으로 둔다. @override Future loadRewardedAd() async { await Future.delayed(const Duration(seconds: 2)); return const _SampleAd(); } /// 운영 Quest 기본 x2 의 증분(= 기본 보상). 표본이라 한도를 세지 않는다. @override Future claimAdReward(String impressionId, QuestReward base) async => base.rewardedPop; /// 표본 상태를 되돌린다. 테스트가 회차마다 같은 자리에서 시작하도록 둔다. static void resetClaims() => _claimed.clear(); } class _SampleAd implements RewardedAdHandle { const _SampleAd(); @override Future show() async => 'sample-impression'; @override void dispose() {} }