/// 퀘스트가 그리는 값의 형태 — 화면 대조 원장 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 'package:flutter/foundation.dart' show ValueNotifier; 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, /// 화면 안에서 진행하는 Quest(Music Shake)를 진행 화면 밖으로 나와 멈춘 상태 — 원장 C-83(10/3 통합회신 /// Q-OB-01 A): 진행 상태 값 「일시 정지」 · 버튼 「이어하기」. Walk · 출석은 이 상태가 되지 않는다. paused, completed, rewarded; /// Figma 화면 문구. 임의로 바꾸지 마라 — 화면 대조 대상이다. String get buttonLabel => switch (this) { mine => appL10n.questDetails, notStarted => appL10n.questStart, inProgress => appL10n.questInProgress, paused => appL10n.questResume, 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 — 숫자를 지어내지 않고 화면이 중립 문구를 쓴다. /// /// 운영 걸음 Quest 는 `fn-quest-steps` 가 `config/policy.stepTiers` 구간표(원장 B-26 — /// 1 천/5 천/1 만/1.5 만보 = 5/6/7/8)대로 지급하고 `quests.rewardPop` 을 보지 않는다. 카드의 /// 목표 칸이 오늘 다음 구간(`[cleared]` 번째)이므로 보상 칸도 **그 구간의 지급액**이다. int? rewardFrom( Map quest, Map policy, { int cleared = 0, }) { // 시청 · 스트리밍은 무보상 참여다(C-04) — 문서 값과 무관하게 0. if (this == QuestType.streaming) return 0; if (this == QuestType.steps && quest['authorUid'] is! String) { final tiers = [ for (final row in policy['stepTiers'] is List ? policy['stepTiers'] as List : const []) if (row is Map && row['steps'] is num && row['pop'] is num) (steps: row['steps'] as num, pop: (row['pop'] as num).round()), ]..sort((a, b) => a.steps.compareTo(b.steps)); if (tiers.isNotEmpty) return tiers[cleared.clamp(0, tiers.length - 1)].pop; } // 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 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}`) 또는 지도에 꽂은 핀(`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!; /// 진행 세션 — `users/{uid}/questProgress/{questId}.session`(`fn-quest-progress` 만 쓴다 · 원장 C-83). /// /// **표시용 상태다.** 화면을 다시 열거나 앱을 다시 켜도 「진행중」 탭 · 「이어하기」 · 목록 「보상받기」가 /// 남게 한다. 지급 판정은 여전히 검증 함수가 증거로 한다. class QuestSession { const QuestSession({ required this.state, this.progress = 0, this.total = 0, this.position = Duration.zero, this.hits = const [], this.judgement, this.firstBeat, }); /// [QuestStage.inProgress] · [QuestStage.paused] · [QuestStage.completed] 중 하나. final QuestStage state; /// 실행한 값 — 걸음 수 · Music Shake 판정이 끝난 박 수. final int progress; /// 목표 — 걸음 목표 · Music Shake 박 수. final int total; /// Music Shake 를 멈춘 영상 시각 — 「이어하기」 시작점. final Duration position; /// 이 일시 정지가 지금 운영값([setup])의 박 위치로 이어질 수 있는지. 첫 박이나 판 길이가 바뀌었으면 false. bool fitsShake(MusicShakeSetup setup) => fits(setup.firstBeat, setup.segments); /// [fitsShake] 와 같은 비교 — 운영값만 아는 곳(목록 · HOME · 캡슐은 퀘스트 문서를 읽는다)용. bool fits(Duration firstBeat, int segments) => this.firstBeat == firstBeat && total == segments; /// Music Shake 판을 시작할 때의 첫 박(`quests.firstBeatMs`). 없으면 첫 박 도입 전에 저장된 판이다. final Duration? firstBeat; /// Music Shake 박마다 가장 가까운 흔들기의 거리(박 비율). 흔들기가 없으면 -1. final List hits; /// 끝까지 한 Music Shake 판의 판정 합계 — 목록에서 바로 「보상받기」할 때 서버에 보낸다. final Map? judgement; } /// 일시 정지 · 진행 세션이 바뀌었다 — 탭 화면 하단 캡슐(원장 C-83)이 다시 읽는다. final questSessionSignal = ValueNotifier(0); /// 목록 카드 하나. 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 = '', this.session, this.staleShake = false, }); final String id; final String title; final String categoryName; /// 서버에 남은 진행 세션(진행중 · 일시 정지 · 진행완료) — 원장 C-83. 없으면 null. final QuestSession? session; /// 운영값(첫 박 · 판 길이)이 바뀌어 이어갈 수 없는 Music Shake 세션이 서버에 남아 있다. /// 이때 [session] 은 null 이고 카드는 「시작하기」로 보인다 — 시작하면 안내를 한 번 띄운다. final bool staleShake; /// 만든 사람의 공개 닉네임(`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( session: session, staleShake: staleShake, 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.firstBeat = Duration.zero, this.windows = const ShakeWindows(), this.leadIn = const Duration(seconds: 3), }); /// 지정 공식 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; /// `quests.firstBeatMs` — 영상에서 첫 박 시각. 🟡 임시값 — 회신 대기(Q-MS-02 · Q-MS-03). 없으면 0. final Duration firstBeat; final ShakeWindows windows; /// 첫 박 몇 초 전부터 영상을 트는가 — `config/policy.musicShake.leadInMs`(없으면 3초). /// 🟡 임시값 — 회신 대기(해석 확인 요청서 MusicShake 「재생 시작 위치」 B안). final Duration leadIn; /// 새 판의 영상 시작 시각 — 첫 박 [leadIn] 전(0 아래로는 가지 않는다). 이어하기는 저장 위치다. Duration get startAt => firstBeat > leadIn ? firstBeat - leadIn : Duration.zero; } 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 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 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` — *진행중 - 진행중 탭 / 진행완료 - 진행중 탭 / 보상완료 - 완료 탭*. // 보상을 받기 전(진행완료)까지는 진행중 탭에 두고, 받은 뒤에 완료 탭으로 옮긴다. // 일시 정지도 진행 중이다(원장 C-83 — 진행 상태 값에 「일시 정지」를 더함). QuestTab.inProgress => card.stage == QuestStage.inProgress || card.stage == QuestStage.paused || 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 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> 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, }); /// 생성자 취소(B-30) — 검수 중이면 예치 · 수수료 전액 반환, 공개 후면 수수료 비환불 · /// 미배정 Pool 즉시 반환. Future cancel(String questId); /// 상태 전이. 서버가 확정한 상태를 돌려준다. /// /// 실행률을 모으는 Quest(걸음 · Music Shake)는 진행 세션을 서버에 남긴다(`fn-quest-progress` · C-83). /// [session] 은 그 진행값이다. [QuestStage.notStarted] 는 중단(세션 삭제)이다. Future advance( String questId, QuestStage next, { QuestSession? session, }); /// 가장 최근에 일시 정지한 Quest 1건 — 탭 화면 하단 캡슐(원장 C-83 · QA 3-34). 없으면 null. Future pausedQuest(); Future create(QuestDraft draft); /// 보유 일반 POP(`wallet/main.activityBalance`) — 생성 · 보상 뒤 목록 헤더를 다시 맞춘다. /// 모르면 null(헤더는 받은 값을 유지한다). Future popBalance(); /// 퀘스트 생성에 쓸 수 있는 보유 POP(일반 + 기여 — 서버 예치 `spendSplit` 과 같은 합). 명세 /// `523:18176` 완료 보상 설정 영역 *보유 POP 값*. 모르면 null. Future spendableBalance(); /// 오늘 광고 추가 보상을 더 받을 수 있는가(일 한도 — B-03 · 10/3 통합회신 4절 「일 5회」). /// 별첨 1 ⑥ 「일일 한도 도달 시 노출 제한」 — 다 쓴 사람에게는 보상 안내 모달을 띄우지 않는다. /// 판정은 서버(`fnAdReward`)가 다시 한다. 읽지 못하면 true(모달을 막지 않는다). Future adAvailable(); /// 진행 중 실측 진행도. 걸음 퀘스트만 값을 낸다. /// /// 명세 `518:88441` 의 *「시작하기」 버튼 진행중으로 변경 후 실시간 목표 실행률 /// 수집* 이 이 스트림이다. 값은 **시작 시점 대비 증가분**이며, 권한이 없거나 /// 센서가 없으면 오류로 끊는다. Stream progressStream(QuestCard card); /// Music Shake 한 판의 준비값 — 공식 YouTube 영상 · BPM · 구간 수 · 판정 창(운영값). /// 퀘스트에 값이 빠져 있으면 E-QST-01 이다(값을 지어내지 않는다). Future musicShakeSetup(String questId); /// 단말 흔들기 시각 스트림(가속도계). Stream shakeEvents(); /// 끝난 판의 판정표를 남긴다. 다음 [verify] 가 서버에 증거로 보낸다. void recordShake(ShakeJudge run); /// 활동 증거를 서버에 보내 판정·지급을 받는다. /// /// **판정도 지급도 서버가 한다.** 앱은 증거(걸음 수·좌표)만 싣고, 실패는 /// 에러 카탈로그 코드로 돌아온다(E-QST-01 미완주 · E-QST-02 비정상 · /// E-QST-03 반경 밖 · E-POP-03 중복). Future verify( QuestCard card, { PlaceProof proof = const PlaceProof(), }); /// 보상형 광고 한 편을 불러온다. 불러오지 못하면 E-AD-01 이다. /// /// 사용자가 추가 보상 모달에서 **직접 고른 뒤에만** 부른다(별첨 1 ⑥ No-Force · /// 정책 원장 결정 8 「Reward Ad 는 명시적 옵트인」). [base] 는 광고를 붙일 기본 보상이다 — /// 서버측 시청 검증(AdMob SSV)에 그 txId 를 싣는다. Future loadRewardedAd(QuestReward base); /// 끝까지 본 광고의 추가 보상을 서버에 청구하고 지급액을 돌려준다. [base] 는 방금 받은 /// 기본 보상이다 — 서버가 그 원장 행으로 증분을 계산한다(B-03 · C-02). /// 지급 근거는 Google 이 서버로 보낸 SSV 기록이다(없으면 E-AD-03 — 증분 미지급 · 횟수 미차감). /// 일일 한도를 넘으면 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 => PlacePickerScreen.defaultPlaces; /// 표본은 네트워크를 쓰지 않는다 — 형식만 보고 조회 실패(기본 카드)로 돌려준다. @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 cancel(String questId) async {} @override Future advance( String questId, QuestStage next, { QuestSession? session, }) async => next; @override Future pausedQuest() async => null; @override Future create(QuestDraft draft) async => 'q-new'; @override Future popBalance() async => null; @override Future spendableBalance() async => null; @override Future adAvailable() async => true; /// **표본은 센서를 읽지 않는다.** 여기서 걸음 수를 지어내면 화면이 카드에 적힌 /// 진행도 대신 만들어 낸 값을 그리게 되고, 목 모드 확인물이 실측처럼 보인다. @override Stream progressStream(QuestCard card) => const Stream.empty(); /// 표본에는 영상 · BPM 이 없다 — 참여를 열지 않는다. @override Future musicShakeSetup(String questId) async => throw BackendException('E-QST-01', appL10n.questTypeUnverifiable); @override Stream shakeEvents() => const Stream.empty(); @override void recordShake(ShakeJudge run) {} @override Future 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 loadRewardedAd(QuestReward base) 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() {} }