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

951 lines
34 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 'dart:typed_data' show Uint8List;
import '../../core/data/backend.dart' show BackendException;
import '../../core/monetization/rewarded_ad.dart';
import '../../core/i18n/app_locale.dart';
import 'place_picker_screen.dart' show PlacePickerScreen;
import 'quest_evidence.dart' show ShakeJudge, ShakeWindows;
/// 진행 상태 — 명세가 **다섯 가지**를 못박았다.
///
/// 명세 주석은 *「자세히」 · 「시작하기」 · 「진행중」 · 「보상받기」 · 「보상완료」* 이지만,
/// 버튼 문구는 시각 기준(원장 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,
};
/// 목록 · 상세의 **카테고리** 값 — 명세 `518:88395` · `518:88417` *퀘스트 카테고리 - 등록된 카테고리값*
/// (시안 「건강」 · 「커뮤니티」 · 「출석체크」). 활동이 속한 분류는 `config/questCatalog` 기본값
/// (`functions/src/quest/ugc.ts` — walk · shake → health, vote · sns · streaming → planet …)과 같다.
/// 출석 · 타임어택은 카탈로그 분류가 없어 유형 이름을 쓴다.
String get category => switch (this) {
steps || musicShake => appL10n.questCatHealth,
streaming || vote || sns => appL10n.questCatPlanet,
community => appL10n.questCatCommunity,
place => appL10n.questTypePlace,
_ => label,
};
/// 사람 검토형(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) {
// 시청 · 스트리밍은 무보상 참여다(C-04) — 문서 값과 무관하게 0.
if (this == QuestType.streaming) return 0;
// Music Shake 는 BPM 판정 성공 시 일반 POP(원장 B-24 · 보상액 운영값). 서버
// (`fn-quest-verify-music-shake`)가 퀘스트의 `rewardPop` 을, 없으면
// `config/policy.musicShake.pop` 을 기본액으로 쓴다. 운영값이 둘 다 없을 때만 중립 문구.
final shake = policy['musicShake'];
final raw = this == QuestType.attend
? policy['attendPop']
: this == QuestType.musicShake
? quest['rewardPop'] ?? (shake is Map ? shake['pop'] : null)
: 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 는 영상을 보며 흔드는 별도 진행 화면(`MusicShakePlayScreen`)에서 모은다.
bool get gathers => this == QuestType.steps;
/// 참여를 열지 않는 사유. 열려 있으면 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<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}`) 또는 지도에 꽂은 핀(`id` 빈 값).
class QuestPlace {
const QuestPlace({required this.id, required this.name, this.lat, this.lng});
final String id;
/// 등록 장소 이름 또는 핀 주소.
final String name;
final double? lat;
final double? lng;
bool get isPin => id.isEmpty;
}
/// 링크 미리보기 — 서버 `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 = '',
this.txId,
this.paidAt,
this.adClaimed = false,
});
final String id;
final String status;
/// 승인 지급의 기본 보상 원장 txId · 지급 시각 — 광고 추가 보상을 붙이는 기준(Q-UQ-03 A).
final String? txId;
final DateTime? paidAt;
/// 이 지급에 광고 추가 보상을 이미 받았는가(`users/{uid}/adBonuses/{txId}`).
final bool adClaimed;
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);
}
/// 사용자 생성 Quest 의 모집 인원이 다 찼는가 — 명세 `523:18176` *참여 인원 충족 시 퀘스트 종료*.
/// 서버 참여 수(`participantCount`)는 지급 · 검토 예약 때 오르고 최종 반려 때 내려간다.
bool questFull(QuestCard card) =>
card.author == QuestAuthor.user &&
card.capacity != null &&
card.capacity! > 0 &&
card.participants >= card.capacity!;
/// 목록 카드 하나.
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,
this.publishState = '',
this.authorName = '',
});
final String id;
final String title;
final String categoryName;
/// 만든 사람의 공개 닉네임(`quests.authorName`). 운영 Quest 는 빈 값이다.
/// 신고하기 「신고 정보 영역 — 작성자 닉네임」(Figma `548:21389`)에 쓴다.
final String authorName;
/// 내가 만든 Quest 의 공개 상태(`quests.status`) — `review` · `published` · `rejected` 등.
/// 「내 퀘스트」 카드가 검수 중 · 반려를 공개된 것과 구분해 보인다(10/6 실기기 4-2).
final String publishState;
/// 검증 경로를 고르는 유형. 표본 데이터는 지정하지 않아 [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,
authorName: authorName,
publishState: publishState,
);
}
/// 상세 화면이 필요로 하는 것.
/// Music Shake 한 판의 준비값 — 퀘스트 문서와 운영값에서 읽는다(지어내지 않는다).
class MusicShakeSetup {
const MusicShakeSetup({
required this.videoId,
required this.title,
required this.bpm,
required this.segments,
this.windows = const ShakeWindows(),
});
/// 지정 공식 YouTube 영상(PRD-04 MVP 수용 기준) — `quests.link.url` 에서 꺼낸 ID.
final String videoId;
/// 곡 제목 — `quests.link.title`(링크 원본 제목 · C-05).
final String title;
/// `quests.bpm` — Figma 「목표BPM」. 🟡 의미 · 입력 주체 회신 대기(Q-MS-01 · Q-MS-02).
final double bpm;
/// `quests.totalSegments` — 판정 박 수. 🟡 구간 패턴 정의 회신 대기(Q-MS-02).
final int segments;
final ShakeWindows windows;
}
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 [],
this.publishState = '',
this.moderationReason,
this.verifyMethods = const [],
this.qrCode,
});
final QuestCard card;
/// 위치 Quest 의 인증 수단(`quests.verifyMethods` — `gps` · `photo` · `qr`, 원장 C-03). PRD-05
/// 「장소 상세에서 인증 방법 … 확인」. GPS 는 항상, 사진 · QR 은 생성 때 고른 추가 수단이다.
final List<String> verifyMethods;
/// 생성자에게만 — 현장에 붙일 QR 의 값(`questSecrets/{id}.qrCode`). 참여자에게는 null.
final String? qrCode;
/// 사용자 생성 Quest 의 공개 상태(`quests.status`) — `review`(검수 대기) · `published` ·
/// `rejected`(운영 반려) · `cancelled` · `ended`. 운영 Quest 는 빈 값.
final String publishState;
/// 운영 반려 사유(`quests.moderation.reason`).
final String? moderationReason;
/// 대표 이미지. 자산이 아직 없어 라벨로 둔다.
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;
}
/// 위치 Quest 의 추가 인증 증거(원장 C-03 · PRD-05 「사진 인증 · QR 인증」). GPS 좌표는 저장소가 따로 잰다.
class PlaceProof {
const PlaceProof({this.qr, this.photo});
/// 현장 QR 을 스캔한 값.
final String? qr;
/// 방문 인증 사진(JPEG 바이트). 저장소가 Storage 에 올리고 경로를 보낸다.
final Uint8List? photo;
}
/// 목록 탭. 명세 — *전체 / 진행중 / 완료 / 내 퀘스트*.
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,
// 명세 `518:88405` — *진행중 - 진행중 탭 / 진행완료 - 진행중 탭 / 보상완료 - 완료 탭*.
// 보상을 받기 전(진행완료)까지는 진행중 탭에 두고, 받은 뒤에 완료 탭으로 옮긴다.
QuestTab.inProgress =>
card.stage == QuestStage.inProgress || card.stage == QuestStage.completed,
QuestTab.done => 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,
this.pin,
this.goal,
this.extraVerify = const [],
});
/// 위치 Quest 의 추가 인증 수단(`photo` · `qr`, 원장 C-03). GPS 는 서버가 항상 넣는다.
final List<String> extraVerify;
/// 지도에 꽂은 핀(등록 장소가 아닐 때) — 서버가 그 좌표로 장소를 만든다.
final QuestPlace? pin;
/// 만보기 목표 걸음 수(`inputSchema` 의 `goal`).
final int? goal;
/// 같은 화면의 재시도를 한 건으로 묶는 멱등 키.
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개씩 로드)*.
///
/// [limit] 장보다 적게 돌려주면 더 읽을 문서가 없다는 뜻이다 — 화면은 이것으로 끝을 판정한다.
/// 구현은 걸러져 모자란 만큼 다음 페이지를 이어 읽어야 한다.
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,
});
/// 생성자 취소(B-30) — 검수 중이면 예치 · 수수료 전액 반환, 공개 후면 수수료 비환불 ·
/// 미배정 Pool 즉시 반환.
Future<void> cancel(String questId);
/// 상태 전이. 서버가 확정한 상태를 돌려준다.
Future<QuestStage> advance(String questId, QuestStage next);
Future<String> create(QuestDraft draft);
/// 보유 일반 POP(`wallet/main.activityBalance`) — 생성 · 보상 뒤 목록 헤더를 다시 맞춘다.
/// 모르면 null(헤더는 받은 값을 유지한다).
Future<int?> popBalance();
/// 퀘스트 생성에 쓸 수 있는 보유 POP(일반 + 기여 — 서버 예치 `spendSplit` 과 같은 합). 명세
/// `523:18176` 완료 보상 설정 영역 *보유 POP 값*. 모르면 null.
Future<int?> spendableBalance();
/// 오늘 광고 추가 보상을 더 받을 수 있는가(일 한도 — B-03 · 10/3 통합회신 4절 「일 5회」).
/// 별첨 1 ⑥ 「일일 한도 도달 시 노출 제한」 — 다 쓴 사람에게는 보상 안내 모달을 띄우지 않는다.
/// 판정은 서버(`fnAdReward`)가 다시 한다. 읽지 못하면 true(모달을 막지 않는다).
Future<bool> adAvailable();
/// 진행 중 실측 진행도. 걸음 퀘스트만 값을 낸다.
///
/// 명세 `518:88441` 의 *「시작하기」 버튼 진행중으로 변경 후 실시간 목표 실행률
/// 수집* 이 이 스트림이다. 값은 **시작 시점 대비 증가분**이며, 권한이 없거나
/// 센서가 없으면 오류로 끊는다.
Stream<int> progressStream(QuestCard card);
/// Music Shake 한 판의 준비값 — 공식 YouTube 영상 · BPM · 구간 수 · 판정 창(운영값).
/// 퀘스트에 값이 빠져 있으면 E-QST-01 이다(값을 지어내지 않는다).
Future<MusicShakeSetup> musicShakeSetup(String questId);
/// 단말 흔들기 시각 스트림(가속도계).
Stream<DateTime> shakeEvents();
/// 끝난 판의 판정표를 남긴다. 다음 [verify] 가 서버에 증거로 보낸다.
void recordShake(ShakeJudge run);
/// 활동 증거를 서버에 보내 판정·지급을 받는다.
///
/// **판정도 지급도 서버가 한다.** 앱은 증거(걸음 수·좌표)만 싣고, 실패는
/// 에러 카탈로그 코드로 돌아온다(E-QST-01 미완주 · E-QST-02 비정상 ·
/// E-QST-03 반경 밖 · E-POP-03 중복).
Future<QuestReward> verify(
QuestCard card, {
PlaceProof proof = const PlaceProof(),
});
/// 보상형 광고 한 편을 불러온다. 불러오지 못하면 E-AD-01 이다.
///
/// 사용자가 추가 보상 모달에서 **직접 고른 뒤에만** 부른다(별첨 1 ⑥ No-Force ·
/// 정책 원장 결정 8 「Reward Ad 는 명시적 옵트인」). [base] 는 광고를 붙일 기본 보상이다 —
/// 서버측 시청 검증(AdMob SSV)에 그 txId 를 싣는다.
Future<RewardedAdHandle> loadRewardedAd(QuestReward base);
/// 끝까지 본 광고의 추가 보상을 서버에 청구하고 지급액을 돌려준다. [base] 는 방금 받은
/// 기본 보상이다 — 서버가 그 원장 행으로 증분을 계산한다(B-03 · C-02).
/// 지급 근거는 Google 이 서버로 보낸 SSV 기록이다(없으면 E-AD-03 — 증분 미지급 · 횟수 미차감).
/// 일일 한도를 넘으면 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: '신곡 스트리밍 참여',
categoryName: '스트리밍',
type: QuestType.streaming,
author: QuestAuthor.admin,
stage: QuestStage.notStarted,
goal: 1,
progress: 0,
// 무보상 기본 참여(C-04).
rewardPop: 0,
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 => PlacePickerScreen.defaultPlaces;
/// 표본은 네트워크를 쓰지 않는다 — 형식만 보고 조회 실패(기본 카드)로 돌려준다.
@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<void> cancel(String questId) async {}
@override
Future<QuestStage> advance(String questId, QuestStage next) async => next;
@override
Future<String> create(QuestDraft draft) async => 'q-new';
@override
Future<int?> popBalance() async => null;
@override
Future<int?> spendableBalance() async => null;
@override
Future<bool> adAvailable() async => true;
/// **표본은 센서를 읽지 않는다.** 여기서 걸음 수를 지어내면 화면이 카드에 적힌
/// 진행도 대신 만들어 낸 값을 그리게 되고, 목 모드 확인물이 실측처럼 보인다.
@override
Stream<int> progressStream(QuestCard card) => const Stream<int>.empty();
/// 표본에는 영상 · BPM 이 없다 — 참여를 열지 않는다.
@override
Future<MusicShakeSetup> musicShakeSetup(String questId) async =>
throw BackendException('E-QST-01', appL10n.questTypeUnverifiable);
@override
Stream<DateTime> shakeEvents() => const Stream<DateTime>.empty();
@override
void recordShake(ShakeJudge run) {}
@override
Future<QuestReward> verify(
QuestCard card, {
PlaceProof proof = const PlaceProof(),
}) async {
_claimed.add(card.id);
return QuestReward(
rewardedPop: card.rewardPop ?? 0,
balanceAfter: 12540 + (card.rewardPop ?? 0),
);
}
/// 표본은 광고 SDK 를 부르지 않는다 — 잠깐 기다렸다가 끝까지 본 것으로 둔다.
@override
Future<RewardedAdHandle> loadRewardedAd(QuestReward base) 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() {}
}