/// MY 가 그리는 값의 형태 — 화면 대조 원장 F6. /// /// 근거는 Figma 명세 구간이다. /// - 프로필 카드 `549:22058` · 사진 설정 `549:22105`~`549:22129` · 닉네임 `549:22136` /// - 티어 정보 `549:22185` · 티어 상세 `549:22206` /// - 설정 `554:22628`~`554:22903` · POP 내역 `554:23732` /// - 결제 내역 `554:23841`·`554:23961` · 환불 `554:23978`·`554:24008` /// - 푸시 알림 `554:24049`·`554:24062` · 차단 `554:24090` library; import 'dart:math' as math; import 'dart:typed_data'; import '../../core/data/backend.dart'; import '../../core/i18n/app_locale.dart'; /// POP 종류 — 정책 원장 **B-01**. /// /// 확정값은 「일반 POP / 기여 POP 으로 사용자 표기 통일」이고, 검증 기준이 /// *앱·Admin 사용자 노출 문자열에 「활동 POP」·「지원 POP」·「무료 POP」·「유료 POP」 0건* /// 이다. 그래서 **화면에 나가는 이름은 이 두 개뿐**이다. /// /// 별첨 1 ② 의 「무료/유료 POP 원장 분리」는 회계 내부 표기이고(B-01 비고 /// *회계 원장의 무료/유료 구분은 내부 표기로만 유지*), 사용자 표기로 쓰지 않는다. enum PopKind { general, contribution; String get label => switch (this) { general => appL10n.popKindGeneral, contribution => appL10n.popKindContribution, }; /// 원장 `popType` → 종류. /// /// 설계서 §2.5 는 `general`·`contribution` 으로 적었고 배포된 Functions 는 /// `activity`·`support` 로 쓴다(`firebase/functions/src/ledger/ledger.ts`). /// 둘은 같은 두 종류를 가리키므로(B-84 — 구매자에게 기여 POP · 퀘스트 보상은 /// 일반 POP) 네 값을 모두 받는다. 한쪽만 받으면 원장 표기가 조용히 뒤집힌다. static PopKind parse(String raw) => switch (raw) { 'contribution' || 'support' => PopKind.contribution, _ => PopKind.general, }; } /// 원장 거래 종류 — 설계서 §3 · Functions `TxType`. enum PopTxType { grant, spend, refund, voided; String get label => switch (this) { grant => appL10n.popTxGrant, spend => appL10n.popTxSpend, refund => appL10n.popTxRefund, voided => appL10n.popTxVoid, }; static PopTxType parse(String raw) => switch (raw) { 'spend' => PopTxType.spend, 'refund' => PopTxType.refund, 'void' => PopTxType.voided, _ => PopTxType.grant, }; } /// 원장 `reasonCode` → 화면의 활동명·활동 타입. /// /// 명세 `554:23732` 가 한 줄에 *활동명 · 활동 타입* 둘을 요구한다. 코드 목록은 /// Functions 의 `ReasonCode`(`firebase/functions/src/ledger/ledger.ts`)와 짝이며, /// 모르는 코드는 원문을 그대로 내보이지 않고 중립 문구로 접는다 — 사용자 화면에 /// `QUEST_MUSIC_SHAKE` 같은 내부 식별자가 새면 고장으로 읽힌다. ({String title, String type}) popReasonLabel( String reasonCode, ) => switch (reasonCode) { 'QUEST_STEPS' => ( title: appL10n.popReasonQuestSteps, type: appL10n.popTypeQuest, ), 'QUEST_MUSIC_SHAKE' => ( title: appL10n.popReasonQuestMusicShake, type: appL10n.popTypeQuest, ), 'QUEST_PLACE' => ( title: appL10n.popReasonQuestPlace, type: appL10n.popTypeQuest, ), 'QUEST_ATTEND' => ( title: appL10n.popReasonQuestAttend, type: appL10n.popTypeQuest, ), 'QUEST_UGC' => (title: appL10n.popReasonQuestUgc, type: appL10n.popTypeQuest), 'QUEST_FEE' => (title: appL10n.popReasonQuestFee, type: appL10n.popTypeFee), 'QUEST_POOL_DEPOSIT' => ( title: appL10n.popReasonQuestPoolDeposit, type: appL10n.popTypeQuest, ), // 정상 종료 반환(C-09)과 공개 전 실패 환불은 사용자에게 같은 「반환」이다. 'QUEST_POOL_RETURN' || 'QUEST_POOL_REFUND' => ( title: appL10n.popReasonQuestPoolReturn, type: appL10n.popTxRefund, ), 'QUEST_FEE_REFUND' => ( title: appL10n.popReasonQuestFeeRefund, type: appL10n.popTxRefund, ), 'QUEST_TIMEATTACK' => ( title: appL10n.popReasonQuestTimeAttack, type: appL10n.popTypeQuest, ), 'AD_REWARD' => ( title: appL10n.popReasonAdReward, type: appL10n.popTypeReward, ), 'HERO_DROP' => ( title: appL10n.popReasonHeroDrop, type: appL10n.popTypeReward, ), 'HERO_DROP_TRIGGER' => ( title: appL10n.popReasonHeroDropTrigger, type: appL10n.commonContribute, ), 'TRANSLATION' => ( title: appL10n.popReasonTranslation, type: appL10n.popTypeReward, ), 'IAP_PURCHASE' => ( title: appL10n.popReasonIapPurchase, type: appL10n.popTypePayment, ), 'FOCUS_DAILY' => ( title: appL10n.popReasonFocusDaily, type: appL10n.popTypeSubscription, ), 'PREREG_BONUS' => ( title: appL10n.popReasonPreregBonus, type: appL10n.popTypeReward, ), 'REFERRAL_REWARD' => ( // 사본 POP 내역 `554:23742` 「친구 초대 · 레퍼럴」. title: appL10n.popReasonReferral, type: appL10n.popTypeReferral, ), 'ADMIN_GRANT' => ( title: appL10n.popReasonAdminGrant, type: appL10n.popTypeAdmin, ), 'ADMIN_CLAWBACK' => ( title: appL10n.popReasonAdminClawback, type: appL10n.popTypeAdmin, ), 'CAMPAIGN_FUND' => ( title: appL10n.popReasonCampaignFund, type: appL10n.commonContribute, ), 'CAMPAIGN_REFUND' => ( title: appL10n.popReasonCampaignRefund, type: appL10n.popTxRefund, ), 'CAMPAIGN_FEE_REFUND' => ( title: appL10n.popReasonCampaignFeeRefund, type: appL10n.popTxRefund, ), 'CAMPAIGN_FEE' => ( title: appL10n.popReasonCampaignFee, type: appL10n.popTypeFee, ), 'QNA_BOUNTY' => ( title: appL10n.popReasonQnaBounty, type: appL10n.commonContribute, ), 'QNA_REFUND' => ( title: appL10n.popReasonQnaRefund, type: appL10n.popTxRefund, ), 'QNA_REWARD' => ( title: appL10n.popReasonQnaReward, type: appL10n.popTypeReward, ), 'ECLIPSE_DEFENSE' => ( title: appL10n.popReasonEclipseDefense, type: appL10n.commonContribute, ), _ => (title: appL10n.popReasonOther, type: appL10n.commonEtc), }; /// 레벨 칭호 — 정책 원장 **C-18 · B-66**. /// /// User EXP 로 오르는 `userLevel` 의 **구간 이름**일 뿐이다. 누적 기여 POP 문턱으로 /// 승급하던 v0.3.0 의 5등급은 2026-09-16 회신으로 기각됐다(R04). 칭호별 권한·보상은 /// 없다 — 그래서 여기에는 이름과 시작 레벨만 둔다. enum LevelTitle { glow('GLOW', 1), spark('SPARK', 10), beam('BEAM', 30), starlight('STARLIGHT', 60), nova('NOVA', 100); const LevelTitle(this.label, this.minLevel); final String label; /// 이 칭호가 시작되는 레벨. final int minLevel; /// 그 레벨의 칭호. NOVA 는 끝이 없다 — Lv.100 이후에도 숫자만 오른다. static LevelTitle of(int level) => LevelTitle.values.lastWhere( (t) => level >= t.minLevel, orElse: () => glow, ); /// 화면 표기 「Lv.1~9」 · 「Lv.100~」. String get rangeLabel { final i = LevelTitle.values.indexOf(this); return i + 1 < LevelTitle.values.length ? 'Lv.$minLevel~${LevelTitle.values[i + 1].minLevel - 1}' : 'Lv.$minLevel~'; } } /// 레벨 L 에 닿는 누적 User EXP. Lv.1 은 0 부터다. /// /// 서버 `firebase/functions/src/core/level.ts` 의 `expForLevel` 과 **같은 식**이어야 /// 진행도가 서버 레벨과 어긋나지 않는다. 앱은 레벨을 스스로 매기지 않고 진행도에만 쓴다. // S01 확정 전 유지 기준(S01 A안) — Lv.L→L+1 요구량 = 243 × L^1.5 를 10 단위 반올림, 누적은 그 합. int expForLevel(int level) { var total = 0; for (var l = 1; l < level; l++) { total += (243 * math.pow(l, 1.5) / 10).round() * 10; } return total; } /// Passport Tier — 정책 원장 **B-65**. 레벨과 별개 필드이며 강등하지 않는다. enum PassportTier { silver('SILVER'), gold('GOLD'), diamond('DIAMOND'); const PassportTier(this.label); final String label; /// 서버 문자열(`users/{uid}/passports/{fandomId}.tier`). 없거나 모르는 값은 기본 SILVER. static PassportTier parse(String raw) => PassportTier.values.firstWhere( (t) => t.label == raw.toUpperCase(), orElse: () => PassportTier.silver, ); } /// 소속 행성별 내 기여 — 명세 `549:22206`. /// /// *갤럭시에서 선택된 기본 행성의 티어를 메인으로 상단에 위치 ·* /// *나머지 행성 티어는 티어 높은순으로 소팅* /// /// 칭호는 개인 `userLevel` 에서만 나온다(C-18). 행성별로는 **그 팬덤 Passport Tier 와 기여 실적**이 /// 보인다 — Tier 는 사용자 × 팬덤 단위다(10/3 통합회신 #10 · 9/21 회신 5절). class PlanetTier { const PlanetTier({ required this.fandomName, required this.contributedPop, this.isPrimary = false, this.fandomId = '', this.passportTier = PassportTier.silver, this.joined = true, }); final String fandomName; final int contributedPop; /// 갤럭시에서 고른 기본 행성인가. final bool isPrimary; final String fandomId; /// 그 팬덤의 Passport Tier — `users/{uid}/passports/{fandomId}.tier`. final PassportTier passportTier; /// 지금 가입 중인가. false 면 탈퇴한 팬덤의 보존 기록(FX §7.1 ARCHIVED). final bool joined; } /// 보유 Badge 한 건 — `users/{uid}/badges/{badgeId}`(서버 판정). 상세정의 4장 · 22장 · 별첨 B. class MyBadge { const MyBadge({required this.id, required this.kind, required this.stage, this.axis = '', this.memberNo, this.toStage}); final String id; /// `activity`(활동 카테고리 7단계) · `founder100`(팬덤/성운 최초 100명) · `evolution`(진화 기념). final String kind; final int stage; /// 활동 Badge 카테고리(FPI 축 키). final String axis; final int? memberNo; final int? toStage; /// 화면 표기. 축 이름은 답변서 v1.1 Q3 확정 표기(영문)라 번역하지 않는다. String get label => switch (kind) { 'activity' => '${axis.toUpperCase()} · $stage/7', 'founder100' => 'FIRST 100 · #${(memberNo ?? 0).toString().padLeft(3, '0')}', 'evolution' => 'EVOLUTION · STAGE ${toStage ?? stage}', _ => id, }; } /// MY 첫 화면이 쓰는 값. class MyProfile { const MyProfile({ required this.nickname, required this.email, required this.contributedPop, this.userExp = 0, this.userLevel = 1, this.passportTier = PassportTier.silver, required this.balancePop, required this.steps, required this.completedQuests, required this.planetTiers, this.badges = const [], this.photoLabel, this.photoUrl, this.originNo, }); final String nickname; final String email; /// 누적 기여 POP. 레벨과 무관한 기여 실적이다(C-18). final int contributedPop; /// 누적 User EXP — `users.userExp`. final int userExp; /// 레벨 — `users.userLevel`. **서버가 준 값**이며 앱이 다시 매기지 않는다. final int userLevel; /// 대표 팬덤의 Passport Tier(B-65 · 10/3 #10 사용자 × 팬덤). 팬덤별 값은 [planetTiers]. final PassportTier passportTier; /// 보유 POP(활동). final int balancePop; /// 누적 걸음. 명세 `549:22058` 의 프로필 카드 항목. final int steps; final int completedQuests; final List planetTiers; /// 보유 Badge — 별첨 1 ②-5 「등급·배지 부여·프로필 표시」. final List badges; /// 프로필 사진. 자산이 아직 없어 라벨로 둔다. final String? photoLabel; /// 프로필 사진 원본 URL — `users/{uid}.photoUrl`. 없으면 시안 표본을 그린다. final String? photoUrl; /// ORIGIN 100 창립 멤버 순번. 정책 §P-04. final int? originNo; /// 레벨 칭호 — 개인 레벨에서만 나온다. LevelTitle get title => LevelTitle.of(userLevel); /// 「Lv.N TITLE」. String get levelLabel => 'Lv.$userLevel ${title.label}'; /// 다음 레벨까지 남은 EXP. int get toNextLevel => (expForLevel(userLevel + 1) - userExp).clamp(0, 1 << 31); /// 현재 레벨 구간의 진행도 0~1. double get levelProgress { final from = expForLevel(userLevel); final span = expForLevel(userLevel + 1) - from; if (span <= 0) return 1; return ((userExp - from) / span).clamp(0.0, 1.0); } } /// 다음 장을 읽을 위치. /// /// 시각 하나로는 **같은 `createdAt` 을 가진 행**을 가릴 수 없다. 한 트랜잭션이 /// 일반·기여 두 종류를 각각 원장에 쓰면 두 행의 서버 타임스탬프가 같아지는데, /// 시각만 비교하면 경계에서 한 행이 사라지거나 두 번 나온다. 그래서 문서 ID 를 /// 함께 들고 다니며 `(createdAt, documentId)` 두 단계로 정렬·이어읽기한다. class PopCursor { const PopCursor({required this.at, required this.id}); final DateTime at; /// 원장 문서 ID. 표본은 자리만 채운다. final String id; } /// POP 내역 한 줄 — 명세 `554:23732`. /// /// 이름에 `Ledger` 를 넣은 것은 Flutter 의 `PopEntry`(라우트 pop 처리)와 이름이 /// 겹치기 때문이다. material 을 임포트한 화면에서 어느 쪽인지 알 수 없어진다. /// /// *날짜 · 활동명 · 활동 타입 · 증감 POP 값 · 누적 POP 값* class PopLedgerEntry { const PopLedgerEntry({ required this.at, required this.title, required this.type, required this.delta, required this.balanceAfter, this.kind = PopKind.general, this.txType = PopTxType.grant, this.repeatCount, this.cursor, }); final DateTime at; final String title; final String type; /// 일반 POP / 기여 POP — 정책 원장 B-01. 별첨 1 ② 「원장 분리 유지」를 한 줄 /// 안에서 보이는 지점이다. final PopKind kind; /// 지급 · 차감 · 환불 · 무효. 부호만으로는 환불과 지급이 구분되지 않는다. final PopTxType txType; /// 부호 있는 값. 서버 원장의 `amount` 와 같은 규약이다. final int delta; final int balanceAfter; /// Zap 처럼 하루에 여러 번 일어나는 활동의 횟수. /// /// 명세 — *Zap 횟수에 따라 리스트 생성이 아닌 당일 기준 총기여 값 및 시간* /// *update 하는 방식으로 노출. 예) Zap 128회* final int? repeatCount; /// 이 줄 **다음**을 읽을 위치. /// /// 접힌 Zap 줄은 [at] 이 그룹에서 가장 최신이므로(명세가 *당일 기준 총기여 값 및 /// 시간 update* 를 요구한다) 커서로 쓸 수 없다. 커서는 그 그룹에서 **가장 오래된** /// 행의 위치다. 접히지 않은 줄은 자기 자신의 위치를 들고 있다. final PopCursor? cursor; /// 같은 날 Zap 인가. 접기 대상 판정에 쓴다. /// /// 접힌 줄과 접히지 않은 Zap 줄 모두 [repeatCount] 를 들고 있으므로 이 값으로 /// 가른다. 조건을 저장소와 화면에 두 벌로 적으면 한쪽만 고쳐진다. bool foldsWith(PopLedgerEntry other) => repeatCount != null && other.repeatCount != null && title == other.title && at.year == other.at.year && at.month == other.at.month && at.day == other.at.day; /// [other] 를 이 줄에 접어 넣은 결과. /// /// 시각과 누적 POP 은 더 최신인 이쪽을 유지하고, 증감은 더하고, 커서는 더 /// 오래된 저쪽으로 내린다. 누적 값을 저쪽 것으로 바꾸면 그 줄이 보여 주는 /// 잔액이 그 시점의 실제 잔액과 달라진다. PopLedgerEntry fold(PopLedgerEntry other) => PopLedgerEntry( at: at, title: title, type: type, kind: kind, txType: txType, delta: delta + other.delta, balanceAfter: balanceAfter, repeatCount: (repeatCount ?? 1) + (other.repeatCount ?? 1), cursor: other.cursor, ); String get displayTitle => repeatCount == null ? title : appL10n.popRepeat(title, '$repeatCount'); /// 목록 한 줄의 부가 정보. 명세가 요구한 활동 타입에 거래 종류와 POP 종류를 함께 붙인다. String get displaySubtitle => '$type · ${txType.label} · ${kind.label}'; } /// 결제 상태 — 명세 `554:23961` 이 다섯을 못박았다. enum PaymentState { paid, refundRequested, refundApproved, refunded, refundRejected; String get label => switch (this) { paid => appL10n.payStatePaid, refundRequested => appL10n.payStateRefundRequested, refundApproved => appL10n.payStateRefundApproved, refunded => appL10n.payStateRefunded, refundRejected => appL10n.payStateRefundRejected, }; /// 환불을 신청할 수 있는가. 결제 완료 상태에서만 열린다. bool get canRequestRefund => this == PaymentState.paid; } /// 서버 결제 상태 → 화면 다섯 상태. /// /// `verified`(지급 전)도 결제는 끝난 상태라 「결제 완료」로 본다. 회수 대기 /// (`clawback-pending`)는 스토어 환불이 이미 끝난 것이라 「환불 완료」다. PaymentState paymentStateOf(String state) => switch (state) { 'refund-requested' => PaymentState.refundRequested, 'refund-approved' => PaymentState.refundApproved, 'refund-rejected' => PaymentState.refundRejected, 'refunded' || 'clawed-back' || 'clawback-pending' => PaymentState.refunded, _ => PaymentState.paid, }; /// Focus 구독 — 원장 **B-04** *광고 제거 + 일 40 기여 POP, 당일 Claim, 미수령 누적 없음*. class FocusStatus { const FocusStatus({required this.expiresAt, required this.claimedToday}); final DateTime expiresAt; /// 오늘(서버 일 경계 KST) 몫을 이미 받았는가. final bool claimedToday; } class PaymentEntry { const PaymentEntry({ required this.id, required this.at, required this.itemName, required this.price, required this.method, required this.state, }); final String id; final DateTime at; final String itemName; final int price; final String method; final PaymentState state; } /// 푸시 알림 구분 — 정책 원장 B-77 확정 **5 개 구분**. /// /// 별첨 B — *서비스 필수 / 팬덤 이벤트(Eclipse·Campaign·Quest·Artist Hot Time) /* /// *AI Briefing / Community / Marketing 으로 분리합니다. Marketing 은 별도 동의* /// *기본 OFF, 나머지 선택형 알림은 사용자 개별 OFF 가능하도록 합니다.* /// /// **Figma `554:24062` 의 「디폴트 알림 전부 활성화」와 토글 6 종을 대체한다.** /// 공통 기준 2항 — Figma 의 과거 표기가 이후 확정된 정책보다 우선하지 않는다. /// 이클립스·좋아요·댓글·퀘스트·캠페인 6 종은 확정된 5 개 구분 안으로 들어간다. /// /// 서버는 구분마다 FCM 토픽을 따로 두고(`fandom_{fandomId}_{name}`) 앱이 자기 /// 설정대로 구독·해지한다. 그래서 **OFF 구분의 발송이 구조적으로 0 건**이 된다. enum PushTopic { /// 계정·결제·점검 등 끌 수 없는 알림. 화면에 노출하되 토글은 잠근다. essential(locked: true), fandomEvent(), briefing(), community(), /// 별첨 B — *Marketing 은 별도 동의 기본 OFF*. marketing(defaultOn: false); const PushTopic({this.defaultOn = true, this.locked = false}); String get label => switch (this) { essential => appL10n.pushTopicEssential, fandomEvent => appL10n.pushTopicFandomEvent, briefing => appL10n.pushTopicBriefing, community => appL10n.pushTopicCommunity, marketing => appL10n.pushTopicMarketing, }; /// 설정 화면의 보조 문구. Marketing 의 별도 동의 문구가 여기 들어간다. String get description => switch (this) { essential => appL10n.pushTopicEssentialDesc, fandomEvent => appL10n.pushTopicFandomEventDesc, briefing => appL10n.pushTopicBriefingDesc, community => appL10n.pushTopicCommunityDesc, marketing => appL10n.pushTopicMarketingDesc, }; /// 초기값. Marketing 만 false 다. final bool defaultOn; /// 사용자가 끌 수 없는 구분인가. 서비스 필수만 true 다. final bool locked; /// 서버가 쓰는 FCM 토픽 이름. `firebase/functions/src/notify/push.ts` 의 /// `fandomTopic` 과 **같은 규약이어야 한다.** /// /// FCM 토픽은 `[a-zA-Z0-9-_.~%]` 만 받는다. 서버는 그 밖의 문자를 `-` 로 바꿔 /// 보내므로 앱도 같이 바꿔야 한다. 한쪽만 치환하면 팬덤 ID 에 공백이나 슬래시가 /// 섞인 순간 서버가 보내는 토픽과 앱이 구독한 토픽이 갈리고, 알림이 오지 않는 /// 원인이 이름 불일치라는 것은 로그에 드러나지 않는다. String topicFor(String fandomId) => 'fandom_${fandomId.replaceAll(RegExp(r'[^a-zA-Z0-9\-_.~%]'), '-')}_$name'; /// 정책 기본값. `users/{uid}.notificationPrefs` 가 비었을 때 쓴다. static Map get defaults => { for (final t in PushTopic.values) t: t.defaultOn, }; } class NoticeItem { const NoticeItem({ required this.id, required this.title, required this.body, required this.at, }); final String id; final String title; final String body; final DateTime at; } /// 닉네임 길이 — 정책 원장 **B-52**(2~20자 · 중복 허용). /// /// 검증 기준이 *20자 성공 · 21자 거부* 이므로 경계를 상수로 두고 화면과 규칙이 /// 같은 값을 본다. `firestore.rules` 의 `users` 갱신 조건에 같은 두 수가 있다. const int nicknameMinLength = 2; const int nicknameMaxLength = 20; /// 운영 사칭으로 읽힐 수 있는 이름 — 대소문자 무시 부분 일치로 막는다. /// `firestore.rules` 의 `nicknameOk` 가 같은 목록을 본다. const List reservedNicknameWords = [ 'fanuzu', '운영팀', '관리자', 'admin', 'official', ]; /// 닉네임 없는 작성자 표기([storedFanName] 「팬」)와 그 번역 — 정확히 일치하면 막는다. /// `firestore.rules` 의 `nicknameOk` 가 같은 목록을 본다. const List reservedFanNames = [storedFanName, 'fan', 'ファン', '粉丝']; /// 예약어 판정용 정규화 — 폭 없는 문자(U+200B~200D · U+FEFF)를 지우고 전각 공백(U+3000)을 /// 공백으로 본 뒤 앞뒤 공백을 떼고 소문자로. 규칙 `nicknameKey` 와 같은 계산. String normalizeNickname(String nickname) => nickname .replaceAll(RegExp('[\u200B-\u200D\uFEFF]'), '') .replaceAll('\u3000', ' ') .trim() .toLowerCase(); bool nicknameReserved(String nickname) { final key = normalizeNickname(nickname); return reservedFanNames.contains(key) || reservedNicknameWords.any(key.contains); } /// 차단한 계정 한 건 — 설계서 `users/{uid}/blocks/{targetUid}`. /// /// 문서 ID 가 차단 대상 UID 다. 이름만 들고 있으면 해제할 때 누구를 지우는지 /// 알 수 없어(동명이인) 문서 ID 를 그대로 들고 다닌다. class BlockedUser { const BlockedUser({required this.uid, required this.nickname, this.at}); final String uid; final String nickname; final DateTime? at; } /// 1:1 문의 처리 상태 — Figma `373:634991`·`373:634998`·`507:38223` 세 값. enum InquiryStatus { pending, inProgress, answered; String get label => switch (this) { pending => appL10n.inquiryPending, inProgress => appL10n.inquiryInProgress, answered => appL10n.inquiryAnswered, }; /// `inquiries.status` → 상태. 값은 `pending` · `in_progress` · `answered` /// 세 개로 확정되어 있다(Firestore 설계서 v1.2 개정분). 모르는 값은 접수로 /// 접는다 — 빈 칸보다 낫고, 상태를 올려 보이는 것보다 안전하다. static InquiryStatus parse(String raw) => switch (raw) { 'in_progress' => InquiryStatus.inProgress, 'answered' => InquiryStatus.answered, _ => InquiryStatus.pending, }; } /// 내 문의 한 건 — Figma 목록 `373:634916` · 상세 `373:635153`. class InquiryItem { const InquiryItem({ required this.id, required this.title, required this.body, required this.at, required this.status, this.answerBody, this.answeredAt, }); final String id; final String title; final String body; final DateTime at; final InquiryStatus status; /// 운영 답변. 명세 `376:14163`~`376:14165` — 답변 · 날짜 · 본문. final String? answerBody; final DateTime? answeredAt; } /// 고른 사진 한 장. 업로드 직전의 바이트와 종류를 함께 들고 다닌다. class PickedPhoto { const PickedPhoto({ required this.bytes, required this.contentType, required this.name, }); final Uint8List bytes; final String contentType; final String name; } /// 프로필 사진 상한 — 정책 원장 **B-44** 「이미지 5 장 10MB」의 1 장 상한. /// /// 프로필 사진 전용 상한이 따로 확정된 문서가 없어 같은 이미지 상한을 쓴다. /// 이 값을 Storage 규칙(`firebase/storage.rules`)이 서버에서 한 번 더 본다 — /// B-44 데이터 관계가 *클라이언트 1차 검증 → Storage 규칙 서버 검증* 이다. const int profilePhotoMaxBytes = 10 * 1024 * 1024; /// 허용 형식. B-44 의 「이미지 / GIF」를 그대로 옮겼다. const List profilePhotoContentTypes = [ 'image/jpeg', 'image/png', 'image/webp', 'image/gif', ]; /// 사진이 상한·형식에서 걸린 경우. 화면이 사유를 그대로 띄운다. class PhotoRejected implements Exception { const PhotoRejected(this.message); final String message; @override String toString() => 'PhotoRejected: $message'; } /// 사진을 1차 검증한다. 통과하지 못하면 [PhotoRejected] 를 던진다. /// /// 업로드 직전에 여기를 지나므로 앨범·카메라·테스트 어느 경로로 들어와도 같은 /// 판정을 받는다. 호출부마다 조건문을 두면 한 경로만 고쳐지는 일이 생긴다. void checkProfilePhoto(PickedPhoto photo) { if (!profilePhotoContentTypes.contains(photo.contentType)) { throw PhotoRejected(appL10n.photoBadType); } if (photo.bytes.lengthInBytes > profilePhotoMaxBytes) { throw PhotoRejected(appL10n.photoTooBig); } if (photo.bytes.isEmpty) { throw PhotoRejected(appL10n.photoUnreadable); } } abstract interface class MyRepository { Future profile(); /// 명세 — *디폴트 최신순으로 20개 노출*. /// /// [after] 다음의 거래만 돌려준다. 화면이 마지막 줄의 /// [PopLedgerEntry.cursor] 를 다시 넣으면 다음 장이 온다. Future> popHistory({int limit = 20, PopCursor? after}); Future> payments(); Future payment(String id); Future requestRefund(String paymentId, String reason); /// Focus 구독 상태. 구독 중(만료 전)이 아니면 null — MY 에 카드를 그리지 않는다. Future focus(); /// Focus 오늘 몫 받기(원장 B-04). 받은 기여 POP 을 돌려준다. 구독이 아니면 E-PAY-03. Future claimFocusDaily(); Future> pushSettings(); Future setPush(PushTopic topic, bool on); Future> blockedUsers(); /// 차단 추가 — Figma `527:21092` 차단 안내 모달의 확인. Future block(String targetUid, {String? nickname}); Future unblock(String targetUid); Future> notices(); Future updateNickname(String nickname); /// 프로필 사진 업로드. 저장된 URL 을 돌려준다. /// /// 상한·형식 위반은 [PhotoRejected], 그 밖의 실패는 저장소 구현이 던지는 /// 예외가 화면까지 올라온다. Future uploadProfilePhoto(PickedPhoto photo); Future submitInquiry(String title, String body); /// 문의 수정 — Figma `554:24330`. 접수 상태에서만 제목·본문을 고친다. /// 상태를 올리는 것은 운영이므로 여기서 보내지 않는다. Future updateInquiry(String id, String title, String body); /// 내 문의 목록 — Figma `373:634916`. Future> myInquiries(); /// 앱 버전. 명세 — *최신버전 → 「최신 버전」 · 이전버전 → 「n.n」*. Future<({String current, bool latest})> appVersion(); /// 서비스 탈퇴 — `fnAccountWithdraw`(Figma `554:24885`). SNS 연동이 남아 있으면 E-AUTH-14. Future withdraw(); } /// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.** class SampleMyRepository implements MyRepository { SampleMyRepository(); @override Future withdraw() async {} static final _base = DateTime(2026, 9, 1, 12); String _nickname = '아미보이'; // 정책 기본값 — Marketing 만 OFF(별첨 B). 전부 true 로 두면 표본이 정책과 어긋난다. final Map _push = PushTopic.defaults; final Map _blocked = {'u-spam': '스팸계정', 'u-adbot': '광고봇'}; /// 올린 프로필 사진. 실 구현이 `users/{uid}.photoUrl` 을 고치는 자리다. String? _photoUrl; final List _inquiries = []; final Map _paymentStates = {}; @override Future profile() async => MyProfile( nickname: _nickname, email: 'fan@fanuzu.co.kr', contributedPop: 24800, // Admin Figma 표본 「Lv.18 · User EXP 128,450」. Lv.30 미만이라 SILVER(B-65 초기 기준). userExp: 128450, userLevel: 18, balancePop: 12540, steps: 184200, completedQuests: 37, photoUrl: _photoUrl, originNo: 42, planetTiers: const [ PlanetTier(fandomName: 'BTS', contributedPop: 24800, isPrimary: true), PlanetTier(fandomName: 'NewJeans', contributedPop: 4100), PlanetTier(fandomName: 'IVE', contributedPop: 900), ], ); /// 표본 원장. **차감을 음수로 적는다** — 서버 원장이 `amount` 에 부호를 실어 /// 쓰고(`ledger.ts` 의 `spendPop`) 설계서 §3.4 대조식이 단순 합산으로 잔액을 /// 재현하므로, 표본이 양수를 쓰면 화면 검증이 거짓으로 통과한다. @override Future> popHistory({ int limit = 20, PopCursor? after, }) async { final all = [ PopLedgerEntry( at: _base.subtract(const Duration(minutes: 30)), title: 'Zap 기여', type: '기여', kind: PopKind.general, txType: PopTxType.spend, delta: -1280, balanceAfter: 12540, repeatCount: 128, ), PopLedgerEntry( at: _base.subtract(const Duration(hours: 4)), title: '만보기 퀘스트', type: '퀘스트', kind: PopKind.general, txType: PopTxType.grant, delta: 50, balanceAfter: 13820, ), PopLedgerEntry( at: _base.subtract(const Duration(days: 1)), title: '캠페인 후원', type: '기여', kind: PopKind.contribution, txType: PopTxType.spend, delta: -500, balanceAfter: 13770, ), PopLedgerEntry( at: _base.subtract(const Duration(days: 2)), title: '캠페인 환불', type: '환불', kind: PopKind.contribution, txType: PopTxType.refund, delta: 500, balanceAfter: 14270, ), PopLedgerEntry( at: _base.subtract(const Duration(days: 3)), title: '상품 구매', type: '결제', kind: PopKind.contribution, txType: PopTxType.grant, delta: 5500, balanceAfter: 13770, ), ]; // 표본은 시각이 모두 달라 시각 비교만으로 충분하다. 서버 구현은 문서 ID 까지 // 본다([PopCursor]). final page = after == null ? all : all.where((e) => e.at.isBefore(after.at)).toList(); return [ for (final e in page.take(limit)) PopLedgerEntry( at: e.at, title: e.title, type: e.type, kind: e.kind, txType: e.txType, delta: e.delta, balanceAfter: e.balanceAfter, repeatCount: e.repeatCount, cursor: PopCursor( at: e.at, id: 'sample-${e.at.microsecondsSinceEpoch}', ), ), ]; } @override Future> payments() async => [ // 상품명에 「지원 POP」을 쓰지 않는 것은 B-01(사용자 표기는 일반/기여 POP)과 // B-02(스토어에 POP 자체 판매 상품 0건) 둘 다에 걸리기 때문이다. 결제 상품은 // 혜택으로 기여 POP 이 붙는 묶음이다(B-84). _payment('p1', '서포터 팩', 7900, const Duration(days: 2)), _payment('p2', 'Focus 구독 1개월', 4900, const Duration(days: 32)), _payment('p3', '부스트 앰플', 2900, const Duration(days: 61)), ]; PaymentEntry _payment(String id, String name, int price, Duration ago) => PaymentEntry( id: id, at: _base.subtract(ago), itemName: name, price: price, method: '신용카드', state: _paymentStates[id] ?? PaymentState.paid, ); @override Future payment(String id) async { final all = await payments(); return all.firstWhere((p) => p.id == id, orElse: () => all.first); } @override Future requestRefund(String paymentId, String reason) async { _paymentStates[paymentId] = PaymentState.refundRequested; } /// 표본은 구독하지 않은 사용자다 — 결제 없이 혜택 카드를 보여 주지 않는다. @override Future focus() async => null; @override Future claimFocusDaily() async => throw const BackendException('E-PAY-03', 'Focus 구독 중일 때만 받을 수 있습니다.'); @override Future> pushSettings() async => {..._push}; @override Future setPush(PushTopic topic, bool on) async => _push[topic] = on; @override Future> blockedUsers() async => [ for (final e in _blocked.entries) BlockedUser(uid: e.key, nickname: e.value), ]; @override Future block(String targetUid, {String? nickname}) async => _blocked[targetUid] = nickname ?? targetUid; @override Future unblock(String targetUid) async => _blocked.remove(targetUid); @override Future> notices() async => [ NoticeItem( id: 'n1', title: '서비스 점검 안내', body: '9월 3일 새벽 2시부터 4시까지 점검이 있습니다.', at: _base.subtract(const Duration(days: 1)), ), NoticeItem( id: 'n2', title: '이클립스 방어 이벤트', body: '이번 주 방어에 참여하시면 추가 POP 을 드립니다.', at: _base.subtract(const Duration(days: 6)), ), ]; @override Future updateNickname(String nickname) async { // 중복 닉네임을 서버가 판정한다. 표본은 한 이름만 막아 화면 동선을 보인다. if (nickname == '아미보이2') { throw const NicknameTakenException(); } _nickname = nickname; } @override Future uploadProfilePhoto(PickedPhoto photo) async { checkProfilePhoto(photo); return _photoUrl = 'https://example.invalid/${photo.name}'; } @override Future submitInquiry(String title, String body) async { _inquiries.insert( 0, InquiryItem( id: 'q${_inquiries.length + 1}', title: title, body: body, at: DateTime.now(), status: InquiryStatus.pending, ), ); } @override Future updateInquiry(String id, String title, String body) async { final i = _inquiries.indexWhere((e) => e.id == id); if (i < 0) return; final old = _inquiries[i]; _inquiries[i] = InquiryItem( id: old.id, title: title, body: body, at: old.at, status: old.status, answerBody: old.answerBody, answeredAt: old.answeredAt, ); } @override Future> myInquiries() async => [ ..._inquiries, InquiryItem( id: 'q0', title: '구매 상품 환불 관련 문의 접수', body: '결제한 상품이 지급되지 않았습니다.', at: _base.subtract(const Duration(days: 4)), status: InquiryStatus.answered, answerBody: '확인 후 지급 처리했습니다. 이용에 불편을 드려 죄송합니다.', answeredAt: _base.subtract(const Duration(days: 3)), ), ]; @override Future<({String current, bool latest})> appVersion() async => (current: '0.1.0', latest: true); } /// 닉네임 중복 — 명세 `549:22136` *중복된 닉네임일 경우 닉네임 필드 아래* /// *헬프 텍스트로 오류 메시지 노출*. class NicknameTakenException implements Exception { const NicknameTakenException(); }