1034 lines
34 KiB
Dart
1034 lines
34 KiB
Dart
/// 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.passportTier`). 없거나 모르는 값은 기본 SILVER.
|
||
static PassportTier parse(String raw) => PassportTier.values.firstWhere(
|
||
(t) => t.label == raw.toUpperCase(),
|
||
orElse: () => PassportTier.silver,
|
||
);
|
||
}
|
||
|
||
/// 소속 행성별 내 기여 — 명세 `549:22206`.
|
||
///
|
||
/// *갤럭시에서 선택된 기본 행성의 티어를 메인으로 상단에 위치 ·*
|
||
/// *나머지 행성 티어는 티어 높은순으로 소팅*
|
||
///
|
||
/// 칭호는 개인 `userLevel` 에서만 나온다(C-18). 행성별로는 **기여 실적(POP)만**
|
||
/// 보인다 — 행성 기여로 칭호를 매기면 MY 카드와 칭호가 갈린다(인계 결함).
|
||
class PlanetTier {
|
||
const PlanetTier({
|
||
required this.fandomName,
|
||
required this.contributedPop,
|
||
this.isPrimary = false,
|
||
});
|
||
|
||
final String fandomName;
|
||
final int contributedPop;
|
||
|
||
/// 갤럭시에서 고른 기본 행성인가.
|
||
final bool isPrimary;
|
||
}
|
||
|
||
/// 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.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 — `users.passportTier`(B-65).
|
||
final PassportTier passportTier;
|
||
|
||
/// 보유 POP(활동).
|
||
final int balancePop;
|
||
|
||
/// 누적 걸음. 명세 `549:22058` 의 프로필 카드 항목.
|
||
final int steps;
|
||
final int completedQuests;
|
||
|
||
final List<PlanetTier> planetTiers;
|
||
|
||
/// 프로필 사진. 자산이 아직 없어 라벨로 둔다.
|
||
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();
|
||
}
|