Files
fanuzu/app/lib/features/galaxy/galaxy_data.dart
T

326 lines
10 KiB
Dart

/// 갤럭시 맵이 그리는 값의 형태.
///
/// 화면과 데이터를 갈라 둔 이유는 홈과 같다 — 5주차 Firestore 연동 때
/// 구현체만 갈아 끼운다.
///
/// 근거는 Figma `03_Wireframe` 「갤럭시맵 화면」 명세다.
/// *데이터 : 3d 갤럭시 — FPI 상세 지수 변동 데이터 · HUD — Rank, FPI 총점, 접속자수(멤버수)*
library;
/// 맵 위의 행성 하나.
class PlanetNode {
const PlanetNode({
required this.id,
required this.name,
required this.rank,
required this.fpi,
required this.members,
required this.online,
this.isMine = false,
this.stage,
this.colorKey,
this.planetType,
bool? isCenter,
bool? ranked,
}) : isCenter = isCenter ?? isMine,
ranked = ranked ?? stage != 0;
/// Firestore `fandoms/{id}` 문서 ID. HUD 에서 행성 상세로 넘어갈 때
/// **이 값으로 저장소를 만든다** — 없으면 어느 행성을 눌러도 같은 상세가 뜬다.
final String id;
/// 팬덤 서버 Stage 0~6; 미제공이면 null.
final int? stage;
/// 팬덤 `colorKey`(행성 색 팔레트 `planetColors` 의 키). 미제공이면 null.
final String? colorKey;
/// 팬덤 `planetType`(생성 때 고른 형태). 미제공이면 null.
final String? planetType;
final String name;
/// 갤럭시 전체 순위. HUD 의 `Rank`.
///
/// Stage0 성운은 랭킹 모집단이 아니다(원장 B-08 · B-17). 성운에는 순위를 매기지 않고
/// 정식 행성 수 + 1 을 넣어 **가장 바깥 궤도 자리로만** 쓴다 — HUD 는 「-」로 그린다.
/// 최근 7 일 유효 활동이 없는 정식 행성도 같다([ranked]).
final int rank;
/// FPI 랭킹 모집단인가(B-17 — 정식 행성 중 최근 7 일 유효 활동 존재). false 면 [rank] 는
/// 궤도 자리일 뿐이라 화면은 순위 대신 「-」를 쓴다.
final bool ranked;
/// 화면에 쓰는 순위 문구 — 모집단 밖이면 「-」.
String get rankLabel => ranked ? '$rank' : '-';
/// FPI 총점. 산출식은 정책 설계서 §P-03 — 초기 MVP 는 단순 합산이다.
final int fpi;
final int members;
/// 접속자 수. 명세의 「접속자수(맴버수)」.
final int online;
/// 내 행성. 명세 규칙 「행성 — 나의행성 한가운데」.
final bool isMine;
/// 맵 한가운데 서는 행성. 기본은 내 행성이고, Ranking View 에서는 FPI 1위다(원장 B-21).
final bool isCenter;
}
/// 하단 이벤트 띠에 흐르는 한 줄.
///
/// 명세 — *이벤트1 아이템 사용(닉네임·아이템명) · 이벤트2 이클립스(행성/성운명)*.
/// 디폴트는 숨김이고 이벤트가 나면 우→좌로 1회 흐른다.
enum GalaxyEventKind { itemUsed, eclipse }
class GalaxyEvent {
const GalaxyEvent({required this.kind, required this.message});
final GalaxyEventKind kind;
final String message;
}
class GalaxySnapshot {
const GalaxySnapshot({
required this.planets,
required this.myPop,
this.zapPop = 10,
this.event,
this.joined = true,
this.memberships = const [],
this.joinedIds = const {},
this.nebulaCreatedToday = false,
});
/// 오늘(KST) 이미 성운을 만들었는가 — Figma `540:19592` 「일1회 생성 가능(이후 비활성화) ·
/// 횟수 초과 시 초과 안내 모달」. 서버 `fn-nebula-create` 의 일 1회 판정(E-FDM-02)과 같은 문서를 본다.
final bool nebulaCreatedToday;
final List<PlanetNode> planets;
/// 내가 가입한 모든 팬덤 ID(`users.fandomIds` + 대표). 10/3 통합회신 #10 「다른 가입 팬덤에서도 Zap」 —
/// 갤럭시에서 고른 행성이 여기에 있으면 그 행성에 Zap 한다.
final Set<String> joinedIds;
/// Zap 이 향할 행성 — 고른 행성이 가입 팬덤이면 그 행성, 아니면 대표 행성(상세정의 5장 「B팬덤 Zap 은 B행성에 귀속」).
PlanetNode? zapTargetFor(PlanetNode? selected) =>
selected != null && (selected.isMine || joinedIds.contains(selected.id))
? selected
: mine;
/// 가입하거나 만든 행성/성운 — 최신 가입순(명세 §2.58 「행성/성운 모아보기」).
/// [PlanetNode.isMine] 이 대표 팬덤(C-15)이다.
final List<PlanetNode> memberships;
/// 내 활동 POP 잔액. Zap 이 여기서 차감된다.
final int myPop;
/// ZAP 1 회 기여 POP. `config/policy.zapPop` — Figma 「Zap 버튼」이
/// 「어드민에서 설정한 기여 POP」이라 못 박은 값이다.
final int zapPop;
final GalaxyEvent? event;
/// 행성·성운에 가입했거나 만든 사용자인가.
/// 명세 규칙 — *행성/성운 가입 또는 생성한 유저에게만 노출* (Zap 버튼).
final bool joined;
PlanetNode? get mine => planets.where((p) => p.isMine).firstOrNull;
/// Ranking View — 원장 B-21 「Ranking View 는 FPI 상위 중심」. FPI 1위 행성을 가운데
/// 두고 나머지는 기본 보기와 같이 순위 궤도를 돈다. Stage0 성운은 랭킹 모집단이 아니므로
/// (B-17) 중심 후보에서 뺀다. 공동 1위면 목록 순서(C-19 고정 정렬키)의 첫 행성이다.
GalaxySnapshot get rankingView {
PlanetNode? top;
for (final p in planets) {
if (p.ranked && (top == null || p.rank < top.rank)) top = p;
}
return GalaxySnapshot(
planets: [
for (final p in planets)
PlanetNode(
id: p.id,
name: p.name,
rank: p.rank,
fpi: p.fpi,
members: p.members,
online: p.online,
isMine: p.isMine,
stage: p.stage,
colorKey: p.colorKey,
planetType: p.planetType,
ranked: p.ranked,
isCenter: identical(p, top),
),
],
myPop: myPop,
zapPop: zapPop,
event: event,
joined: joined,
memberships: memberships,
joinedIds: joinedIds,
nebulaCreatedToday: nebulaCreatedToday,
);
}
}
/// ZAP 한 번(또는 연타 묶음)의 결과. 서버가 확정한 잔액만 담는다.
class ZapResult {
const ZapResult({
required this.txId,
required this.balanceAfter,
this.contributedPop,
});
final String txId;
/// 서버 차감 lot 합계. 누락되면 요청액으로 추정하지 않는다.
final int? contributedPop;
/// 차감 후 잔액. **-1 은 「서버가 알려 주지 않았다」** 로, 화면은 직전 값을 지킨다.
final int balanceAfter;
}
abstract interface class GalaxyRepository {
Future<GalaxySnapshot> load();
/// ZAP — **행성 성장 기여**다. 이클립스 방어가 아니다.
///
/// 별첨 B **B-23** — *ZAP 차감액은 누적 기여 POP 에 실제 값으로 반영되고, 행성
/// 성장용 Growth 진행도에만 가중을 둔다(원장·sFPI 에는 가중 미적용)*. Figma
/// 「Zap 버튼」의 *「어드민에서 설정한 기여 POP 만큼 내 POP 차감 후 행성 POP 에 추가」*
/// 가 이 경로다.
///
/// 차감은 `fn-pop-spend` 가 한다 — 화면이 POP 을 직접 깎지 않는다(별첨 1 ②).
/// [count] 는 연타로 묶은 횟수이며 **원장 행에 그대로 남는다** — MY 의 POP 내역이
/// 「Zap N 회」를 행 수로 세면 실제보다 작게 나온다.
Future<ZapResult> zap({
required String fandomId,
required int count,
required String clientSeq,
});
/// 대표 팬덤 변경 — `fn-primary-fandom-set`(원장 C-15). 30일 대기 중이면
/// E-FDM-04 로 거부된다.
Future<void> setPrimary(String fandomId);
/// 행성 이름 검색 — Figma 「Galaxy_검색」(`353:217832`). 맵에 올라온 행성만이 아니라 **전체** 행성에서 찾는다.
Future<List<PlanetNode>> search(String query);
}
/// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.**
class SampleGalaxyRepository implements GalaxyRepository {
const SampleGalaxyRepository({this.joined = true});
final bool joined;
@override
Future<void> setPrimary(String fandomId) async {}
@override
Future<List<PlanetNode>> search(String query) async {
final q = query.trim().toLowerCase();
return [
for (final p in (await load()).planets)
if (q.isNotEmpty && p.name.toLowerCase().contains(q)) p,
];
}
/// 표본은 원장이 없으므로 잔액을 돌려주지 않는다. 화면은 -1 을 「모름」으로 읽는다.
@override
Future<ZapResult> zap({
required String fandomId,
required int count,
required String clientSeq,
}) async => const ZapResult(txId: '', balanceAfter: -1);
@override
Future<GalaxySnapshot> load() async => GalaxySnapshot(
myPop: 12540,
joined: joined,
event: const GalaxyEvent(
kind: GalaxyEventKind.itemUsed,
message: '아미보이님이 메가 드랍을 사용했습니다',
),
planets: const [
PlanetNode(
id: 'bts',
name: 'BTS',
rank: 1,
fpi: 284310,
members: 12840,
online: 1204,
isMine: true,
),
PlanetNode(
id: 'newjeans',
name: 'NewJeans',
rank: 2,
fpi: 271905,
members: 11230,
online: 980,
),
PlanetNode(
id: 'seventeen',
name: 'SEVENTEEN',
rank: 3,
fpi: 254120,
members: 10450,
online: 872,
),
PlanetNode(
id: 'ive',
name: 'IVE',
rank: 4,
fpi: 198740,
members: 8320,
online: 611,
),
PlanetNode(
id: 'aespa',
name: 'aespa',
rank: 5,
fpi: 176250,
members: 7640,
online: 540,
),
PlanetNode(
id: 'lesserafim',
name: 'LE SSERAFIM',
rank: 6,
fpi: 152080,
members: 6910,
online: 402,
),
],
memberships: const [
PlanetNode(
id: 'bts',
name: 'BTS',
rank: 1,
fpi: 284310,
members: 12840,
online: 1204,
isMine: true,
),
PlanetNode(
id: 'newjeans',
name: 'NewJeans',
rank: 2,
fpi: 271905,
members: 11230,
online: 980,
),
PlanetNode(
id: 'seventeen',
name: 'SEVENTEEN',
rank: 3,
fpi: 254120,
members: 10450,
online: 872,
),
],
);
}