Files
fanuzu/app/lib/features/my/my_data.dart
T

1074 lines
36 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 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_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<PlanetTier> planetTiers;
/// 보유 Badge — 별첨 1 ②-5 「등급·배지 부여·프로필 표시」.
final List<MyBadge> 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<PushTopic, bool> 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<String> reservedNicknameWords = [
'fanuzu',
'운영팀',
'관리자',
'admin',
'official',
];
/// 닉네임 없는 작성자 표기([storedFanName] 「팬」)와 그 번역 — 정확히 일치하면 막는다.
/// `firestore.rules` 의 `nicknameOk` 가 같은 목록을 본다.
const List<String> 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<String> 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<MyProfile> profile();
/// 명세 — *디폴트 최신순으로 20개 노출*.
///
/// [after] 다음의 거래만 돌려준다. 화면이 마지막 줄의
/// [PopLedgerEntry.cursor] 를 다시 넣으면 다음 장이 온다.
Future<List<PopLedgerEntry>> popHistory({int limit = 20, PopCursor? after});
Future<List<PaymentEntry>> payments();
Future<PaymentEntry> payment(String id);
Future<void> requestRefund(String paymentId, String reason);
/// Focus 구독 상태. 구독 중(만료 전)이 아니면 null — MY 에 카드를 그리지 않는다.
Future<FocusStatus?> focus();
/// Focus 오늘 몫 받기(원장 B-04). 받은 기여 POP 을 돌려준다. 구독이 아니면 E-PAY-03.
Future<int> claimFocusDaily();
Future<Map<PushTopic, bool>> pushSettings();
Future<void> setPush(PushTopic topic, bool on);
Future<List<BlockedUser>> blockedUsers();
/// 차단 추가 — Figma `527:21092` 차단 안내 모달의 확인.
Future<void> block(String targetUid, {String? nickname});
Future<void> unblock(String targetUid);
Future<List<NoticeItem>> notices();
Future<void> updateNickname(String nickname);
/// 프로필 사진 업로드. 저장된 URL 을 돌려준다.
///
/// 상한·형식 위반은 [PhotoRejected], 그 밖의 실패는 저장소 구현이 던지는
/// 예외가 화면까지 올라온다.
Future<String> uploadProfilePhoto(PickedPhoto photo);
Future<void> submitInquiry(String title, String body);
/// 문의 수정 — Figma `554:24330`. 접수 상태에서만 제목·본문을 고친다.
/// 상태를 올리는 것은 운영이므로 여기서 보내지 않는다.
Future<void> updateInquiry(String id, String title, String body);
/// 내 문의 목록 — Figma `373:634916`.
Future<List<InquiryItem>> myInquiries();
/// 앱 버전. 명세 — *최신버전 → 「최신 버전」 · 이전버전 → 「n.n」*.
Future<({String current, bool latest})> appVersion();
}
/// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.**
class SampleMyRepository implements MyRepository {
SampleMyRepository();
static final _base = DateTime(2026, 9, 1, 12);
String _nickname = '아미보이';
// 정책 기본값 — Marketing 만 OFF(별첨 B). 전부 true 로 두면 표본이 정책과 어긋난다.
final Map<PushTopic, bool> _push = PushTopic.defaults;
final Map<String, String> _blocked = {'u-spam': '스팸계정', 'u-adbot': '광고봇'};
/// 올린 프로필 사진. 실 구현이 `users/{uid}.photoUrl` 을 고치는 자리다.
String? _photoUrl;
final List<InquiryItem> _inquiries = [];
final Map<String, PaymentState> _paymentStates = {};
@override
Future<MyProfile> 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<List<PopLedgerEntry>> 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<List<PaymentEntry>> 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<PaymentEntry> payment(String id) async {
final all = await payments();
return all.firstWhere((p) => p.id == id, orElse: () => all.first);
}
@override
Future<void> requestRefund(String paymentId, String reason) async {
_paymentStates[paymentId] = PaymentState.refundRequested;
}
/// 표본은 구독하지 않은 사용자다 — 결제 없이 혜택 카드를 보여 주지 않는다.
@override
Future<FocusStatus?> focus() async => null;
@override
Future<int> claimFocusDaily() async =>
throw const BackendException('E-PAY-03', 'Focus 구독 중일 때만 받을 수 있습니다.');
@override
Future<Map<PushTopic, bool>> pushSettings() async => {..._push};
@override
Future<void> setPush(PushTopic topic, bool on) async => _push[topic] = on;
@override
Future<List<BlockedUser>> blockedUsers() async => [
for (final e in _blocked.entries)
BlockedUser(uid: e.key, nickname: e.value),
];
@override
Future<void> block(String targetUid, {String? nickname}) async =>
_blocked[targetUid] = nickname ?? targetUid;
@override
Future<void> unblock(String targetUid) async => _blocked.remove(targetUid);
@override
Future<List<NoticeItem>> 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<void> updateNickname(String nickname) async {
// 중복 닉네임을 서버가 판정한다. 표본은 한 이름만 막아 화면 동선을 보인다.
if (nickname == '아미보이2') {
throw const NicknameTakenException();
}
_nickname = nickname;
}
@override
Future<String> uploadProfilePhoto(PickedPhoto photo) async {
checkProfilePhoto(photo);
return _photoUrl = 'https://example.invalid/${photo.name}';
}
@override
Future<void> 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<void> 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<List<InquiryItem>> 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();
}