Files
fanuzu/app/lib/features/planet/planet_data.dart
T

771 lines
28 KiB
Dart

/// 행성·성운 상세가 그리는 값의 형태.
///
/// 근거는 Figma `03_Wireframe` 의 명세 구간이다.
/// - TOTAL FPI 영역 — *TOTAL FPI 값 · 육각형 그래프 · 실시간 데이터 기준*
/// - FPI 상세 리스트 — *지표별 값 · 전체 순위 · 최근 동향 · 내 기여도 상위 값*
/// - 팬덤 라운지 리스트 — *최신순 소팅 · 최초 5개, 스크롤 시 5개씩 호출*
library;
import '../campaign/campaign_data.dart' show ContributorRank;
import '../my/my_data.dart' show PickedPhoto, PhotoRejected, checkProfilePhoto;
import '../../core/i18n/app_locale.dart';
/// FPI 지표 하나. **별첨 B 「FPI 5 축」 확정**(2026-09-09) —
/// `BPM / HEALTH / REFERRAL / CONTRIBUTION / COMMUNITY`.
///
/// 앱 Figma 는 `BMP · STEPS · REFERAL · CONTRIBUTION · COMMUNITY` 로 적었으나
/// 별첨 B 가 상위 기준이므로 화면 표기·**나열 순서**를 별첨 B 로 맞춥니다.
/// [name] 이 그대로 Firestore `fpiAxes` 의 키다(설계서 §4.1).
///
/// **별첨 B 「FPI S/A/B」 = 삭제.** Figma `179:29160` 이 지표 카드에 「내 기여도
/// 등급(S/A/B)」을 두었으나 별첨 B 가 삭제로 확정했으므로 모델에 두지 않는다.
enum FpiMetric {
bpm('BPM'),
health('HEALTH'),
referral('REFERRAL'),
contribution('CONTRIBUTION'),
community('COMMUNITY');
const FpiMetric(this.label);
/// 답변서 v1.1 Q3 확정 표기. 화면에 띄우는 이름이다.
final String label;
}
/// 팬덤 규모 — Figma 행성 정보 영역 `544:19765` 「소형 : 1,000명 미만 · 중형 : 50,000명 미만 ·
/// 대형 : 50,000명 이상」. 기준은 가입자 수다(행성 상세 · GALAXY 검색 결과가 같이 쓴다).
enum FandomSize {
small,
medium,
large;
static FandomSize of(int members) => members < 1000
? small
: members < 50000
? medium
: large;
String get label => switch (this) {
small => appL10n.planetSizeSmall,
medium => appL10n.planetSizeMedium,
large => appL10n.planetSizeLarge,
};
}
/// 지표별 값. 명세 — *값 · 전체 순위 · 최근 동향 · 내 기여도 상위 값*.
class FpiScore {
const FpiScore({
required this.metric,
required this.value,
required this.rank,
required this.trend,
required this.myTopPercent,
});
final FpiMetric metric;
final int value;
/// 전체 순위.
final int rank;
/// 최근 동향. 지난 집계 대비 증감.
final int trend;
/// 내 기여도 상위 %. 작을수록 상위다. **서버 산출값이 아직 없으면 null** —
/// 없는 값을 100%(최하위)로 지어내면 화면이 그것을 사실로 띄운다. 대조표 3-5.
final int? myTopPercent;
/// 육각형 그래프용 0~1 정규화. 지표별 상한이 다르므로 화면이 아니라
/// 여기서 맞춘다.
double normalized(int max) => max == 0 ? 0 : (value / max).clamp(0.0, 1.0);
}
/// 공동 순위 — 정책 원장 **C-19**.
///
/// **실제 산정값(반올림 전)** 내림차순. 값이 같으면 같은 순위이고 다음 순위는
/// 건너뛴다(1·1·3). 동점 내부 배치는 [id] 오름차순으로 고정한다 — 최근 활동
/// 시각으로 차등하지 않는다. 표시가 반올림으로 같아도 실값이 다르면 다른 순위다.
List<(T, int)> rankWithTies<T>(
Iterable<T> items,
num Function(T) value,
String Function(T) id,
) {
final sorted = [...items]
..sort((a, b) {
final byValue = value(b).compareTo(value(a));
return byValue != 0 ? byValue : id(a).compareTo(id(b));
});
final out = <(T, int)>[];
for (var i = 0; i < sorted.length; i++) {
final tied = i > 0 && value(sorted[i]) == value(sorted[i - 1]);
out.add((sorted[i], tied ? out[i - 1].$2 : i + 1));
}
return out;
}
/// 랭킹 한 줄. 행성·사용자·국가 랭킹이 모두 같은 모양이라 하나로 둔다
/// (Figma `546:14629` · `546:14674` · `546:14713` — 순위·순위동향·이름·지표 수치).
class FpiRankRow {
const FpiRankRow({
required this.rank,
required this.id,
required this.name,
required this.value,
this.change = 0,
this.isMine = false,
this.planetType,
this.colorKey,
});
/// 행성 랭킹 줄의 행성 그림(Figma `546:14629` 「행성 이미지」) — 생성 때 고른 형태 · 색. 없으면 기본 그림.
final String? planetType;
final String? colorKey;
/// 행성 랭킹이면 `fandomId`, 사용자 랭킹이면 `uid`, 국가 랭킹이면 국가 코드.
final String id;
final int rank;
final String name;
/// 그 지표의 수치. **지표 카드가 띄우는 값과 같은 원천이어야 한다** —
/// 발주사 검토회신서 4절 「지표 카드와 Ranking 사이 수치·순위 불일치」.
final int value;
/// 순위 동향. 직전 집계 대비 상승분(+)·하락분(-).
final int change;
final bool isMine;
}
/// 국가 코드 → 표시명(ko). 없으면 코드를 그대로 띄운다.
///
/// **표시명을 아는 곳은 여기 한 곳뿐이다.** 서버 집계(`fnRankingCountryAggregate`)는
/// 국가 코드만 적는다 — 표기 언어는 화면의 몫이고, 두 곳에 두면 서버에 없는 나라가
/// 늘 때마다 배포가 필요해진다. 지원 국가 목록·표기 언어는 대조표 7-③ 확인 요청 중.
String countryName(String code) {
final name = appL10n.countryName(code);
return name == '-' ? code : name;
}
/// 랭킹 한 판.
class FpiRanking {
const FpiRanking({
required this.rows,
this.mine,
this.mockNote,
this.updatedAt,
this.refreshMinutes,
});
final List<FpiRankRow> rows;
/// 마지막 갱신 시각 · 갱신 주기(분) — 별첨 1 ② 예외처리 「갱신 주기·마지막 갱신 시각 화면 안내」.
/// 행성 랭킹은 1분 집계(`fpiUpdatedAt`), 사용자 · 국가 랭킹은 5분 집계다.
final DateTime? updatedAt;
final int? refreshMinutes;
FpiRanking withUpdate(DateTime? at, int minutes) => FpiRanking(
rows: rows,
mine: mine,
mockNote: mockNote,
updatedAt: at,
refreshMinutes: minutes,
);
/// 내 줄(행성 랭킹이면 내 행성, 사용자 랭킹이면 나). 목록 밖에 따로 띄운다 —
/// Figma `546:14628` 「소속 행성 지표 랭킹 영역」 · `546:14668` 「나의 랭킹 영역」.
final FpiRankRow? mine;
/// 실데이터가 아닌 경우의 사유. null 이면 실연동이다.
/// 개발자 진단 · 테스트용이며 사용자 화면에는 내보내지 않는다(10/4 실기기 — MOCK 문구 노출).
final String? mockNote;
bool get isMock => mockNote != null;
}
/// 팬덤 라운지 글 하나.
class LoungePost {
const LoungePost({
required this.id,
required this.authorName,
required this.createdAt,
required this.title,
required this.body,
required this.likes,
required this.comments,
this.images = const [],
this.authorPhoto,
this.videoDuration,
this.authorUid,
this.isMine = false,
this.liked = false,
});
final String id;
/// 작성자 UID. 차단(`users/{uid}/blocks/{authorUid}`)의 대상이다. 표본 글은 null.
final String? authorUid;
/// 내 글인가 — 더보기가 수정 · 삭제를 띄운다.
final bool isMine;
/// 내가 좋아요를 눌렀는가.
final bool liked;
/// 첫 첨부가 영상이면 그 길이(null 이면 사진). 서버 `posts` 에 아직 필드가 없어
/// 서버 글은 늘 null 이다 — 미디어 타입·길이 필드는 후속 과제.
final Duration? videoDuration;
final String authorName;
/// 작성자 프로필 사진. 없으면 회색 원(Figma `Ellipse 3` 기본 채움 #d9d9d9).
final String? authorPhoto;
/// 첨부 사진. URL 또는 앱 에셋 경로. Figma `Frame 1984081008` 계열 격자.
final List<String> images;
/// 명세 — *리스트 생성일 · 최신순 소팅*.
final DateTime createdAt;
final String title;
final String body;
final int likes;
final int comments;
LoungePost copyWith({String? body, int? likes, int? comments, bool? liked}) =>
LoungePost(
id: id,
authorName: authorName,
createdAt: createdAt,
title: title,
body: body ?? this.body,
likes: likes ?? this.likes,
comments: comments ?? this.comments,
images: images,
authorPhoto: authorPhoto,
videoDuration: videoDuration,
authorUid: authorUid,
isMine: isMine,
liked: liked ?? this.liked,
);
}
/// 라운지 댓글 하나 — 설계서 `posts/{postId}/comments/{commentId}`.
class LoungeComment {
const LoungeComment({
required this.id,
required this.authorName,
required this.body,
required this.createdAt,
this.isMine = false,
});
final String id;
final String authorName;
final String body;
final DateTime createdAt;
final bool isMine;
}
/// 라운지 글 한 편에 붙는 사진 상한 — 정책 원장 **B-44** 「이미지 5 장 10MB」.
/// 1 장 상한 · 형식은 [checkProfilePhoto] 와 같다(Storage 규칙도 같은 수).
const int loungeMaxPhotos = 5;
/// 글 사진을 1차 검증한다. 장수 · 용량 · 형식 중 하나라도 넘으면
/// `PhotoRejected` 를 던진다. Storage · Firestore 규칙이 같은 수를 한 번 더 본다.
void checkLoungePhotos(List<PickedPhoto> photos) {
if (photos.length > loungeMaxPhotos) {
throw PhotoRejected(appL10n.loungeMaxPhotos('$loungeMaxPhotos'));
}
photos.forEach(checkProfilePhoto);
}
/// 행성 또는 성운 하나의 상세.
class PlanetDetail {
const PlanetDetail({
required this.fandomId,
required this.name,
this.stage,
required this.isNebula,
required this.totalFpi,
required this.scores,
required this.members,
required this.posts,
this.mockNote,
this.ownerName,
this.ownerPhoto,
this.fpiUpdatedAt,
this.joined = true,
this.growthPop = 0,
this.nebulaTargetPop = 1000,
this.nebulaTargetMembers = 10,
this.isOwner = false,
});
/// 내가 만든 행성/성운인가(`fandoms.founderUid`). Figma `544:19765` 「가입 버튼 - 내가 생성했을 경우
/// hide」 · `543:19721` 「(타인 행성) 신고/차단 · (내 행성) 편집」 — 편집은 C-50 으로 두지 않는다.
final bool isOwner;
/// 성운 → 정식 행성 전환 조건 — 서버와 같은 `config/policy`(원장 B-10 · `nebulaMinPop` · `nebulaMinMembers`).
final int nebulaTargetPop;
final int nebulaTargetMembers;
/// 성운 누적 POP(`fandoms.growthPop`) — 성운 FPI 탭 「성운 성장률」의 분자.
final int growthPop;
/// 내가 이 행성/성운의 멤버인가(`fandoms/{id}/members/{uid}` 존재). 히어로의
/// 「가입중 / 가입하기」 알약이 이 값을 그린다(명세 §2.72 「가입 버튼 - 소속 상태」).
final bool joined;
/// 서버 FPI 집계 시각(`fandoms.fpiUpdatedAt`, 원장 B-15). 없으면 null.
final DateTime? fpiUpdatedAt;
/// 개설자 표시명 · 사진. `fandoms` 문서에 아직 자리가 없어 서버 상세는 null 이고,
/// null 이면 히어로가 개설자 줄을 그리지 않는다(시안 `BTS_Maker` 를 박지 않는다).
final String? ownerName;
final String? ownerPhoto;
/// Firestore `fandoms/{fandomId}` 문서 ID. 지표별 사용자 랭킹이 이 값으로
/// 하위 `members` 를 질의한다.
final String fandomId;
final String name;
/// 성운은 아직 정식 행성이 아니다. 정책 설계서 §P-08.
final bool isNebula;
final int? stage;
/// 명세 — 실시간 데이터 기준. 산출식은 정책 설계서 §P-03(초기 MVP 단순 합산).
final int totalFpi;
final List<FpiScore> scores;
final int members;
final List<LoungePost> posts;
/// 이 화면에 실데이터가 아닌 자리가 남아 있으면 그 사유.
/// 개발자 진단 · 테스트용이며 사용자 화면에는 내보내지 않는다(10/4 실기기 — MOCK 문구 노출).
final String? mockNote;
/// 육각형 그래프 정규화 기준.
int get maxScore =>
scores.map((s) => s.value).fold(0, (a, b) => a > b ? a : b);
}
/// 행성 상세·지표 카드·지표별 랭킹이 **모두 이 하나를 지난다.**
///
/// 발주사 검토회신서 4절이 「지표 카드와 Ranking 상세 사이 수치·순위 불일치,
/// 서로 다른 지표 화면에 동일 Ranking 반복」을 지적했다. 원인은 랭킹 화면이
/// 저장소를 거치지 않고 화면 안의 상수를 그렸기 때문이다. 원천을 하나로 모으면
/// 값이 갈릴 자리가 없어진다.
class HeroDropReceipt {
const HeroDropReceipt({required this.txId, required this.amount});
final String txId;
final int amount;
}
enum PlanetHistoryKind {
evolution,
hibernation,
wakeUp,
zap,
questReward,
companyAdBonus,
funding,
/// PASSPORT Stamp — Eclipse 성공 참여(FX §5.2) · 진화 기념 Emblem(상세정의 22장) · 최초 100명 Badge(별첨 B).
eclipseSuccess,
emblemEvolution,
founderBadge,
}
class PlanetHistoryEntry {
const PlanetHistoryEntry({
required this.id,
required this.kind,
required this.occurredAt,
this.fromStage,
this.toStage,
this.participationConfirmed = false,
this.amount,
this.memberNo,
});
final String id;
final PlanetHistoryKind kind;
final DateTime occurredAt;
final int? fromStage;
final int? toStage;
final bool participationConfirmed;
final int? amount;
/// 최초 100명 Badge 의 순번.
final int? memberNo;
}
class PlanetHistoryPage {
const PlanetHistoryPage({required this.items, this.nextCursor});
final List<PlanetHistoryEntry> items;
final String? nextCursor;
}
abstract interface class PlanetRepository {
Future<PlanetHistoryPage> history({String? cursor, bool personal = false});
Future<HeroDropReceipt> claimHeroDrop(String dropId);
Future<PlanetDetail> load({required bool nebula});
/// 이 행성/성운에 가입 — `fn-fandom-join`. 이미 멤버면 서버가 아무것도 하지 않는다.
Future<void> join();
/// 탈퇴 — `fn-fandom-leave`. 대표 팬덤 자동 지정(C-101) · 성운 창립자 승계(C-100)는 서버가 한다.
Future<void> leave();
/// 명세 — *최초 5개, 스크롤 시 5개씩 호출*.
Future<List<LoungePost>> morePosts({required int offset, int limit = 5});
/// 글 쓰기. 사진은 먼저 Storage 에 올리고 URL 을 글에 붙인다(B-44).
Future<LoungePost> createPost(
String body, {
List<PickedPhoto> photos = const [],
});
/// 내 글 본문 수정. 사진은 바꾸지 않는다.
Future<LoungePost> updatePost(LoungePost post, String body);
Future<void> deletePost(LoungePost post);
/// 좋아요 켜고 끄기. 수는 서버 트리거가 다시 센다 — 돌려주는 값은 화면용 예측이다.
Future<LoungePost> toggleLike(LoungePost post);
Future<List<LoungeComment>> comments(String postId);
Future<LoungeComment> addComment(String postId, String body);
Future<void> deleteComment(String postId, String commentId);
/// 신고 접수 — `reports` 에 쌓고 Admin 이 처리한다(B-45).
Future<void> reportPost(LoungePost post, String reason);
/// 작성자 차단 — `users/{uid}/blocks/{authorUid}`. 이후 목록에서 빠진다.
Future<void> blockAuthor(LoungePost post);
/// 지표별 **행성** 랭킹 — Figma `546:14628`·`546:14639`.
///
/// 모집단은 별첨 B 「FPI 랭킹」 확정값 — *정식 행성 중 최근 7 일 유효 활동
/// 존재 행성*. 서버가 `fandoms.rankingEligible` 로 표시한다.
Future<FpiRanking> planetRanking({required FpiMetric metric, int limit = 30});
/// 지표별 **사용자** 랭킹 — Figma `546:14668`·`546:14674`.
/// 행성 랭킹의 한 행을 눌러 들어오므로 대상 행성을 받는다.
Future<FpiRanking> userRanking({
required FpiMetric metric,
required String fandomId,
int limit = 30,
});
/// 지표별 **국가** 랭킹 — Figma `546:14713`(랭킹 필터 「국가별」).
Future<FpiRanking> countryRanking({
required FpiMetric metric,
int limit = 30,
});
/// 성운 「실시간 기여 랭킹」 — 멤버 누적 기여 POP(`members.contributedPop`) 상위.
Future<List<ContributorRank>> contributors({int limit = 3});
}
/// 행성 ID 로 저장소를 만드는 함수. 갤럭시 화면이 어느 행성으로 들어가든
/// 그 행성의 저장소를 받도록 [Repositories.planet] 을 그대로 넘겨 받는다.
typedef PlanetRepositoryFactory = PlanetRepository Function(String fandomId);
/// 서버가 없을 때의 기본 팩토리. 상수 기본값으로 쓸 수 있게 최상위 함수로 둔다.
/// **받은 ID 를 표본에 넘긴다** — 버리면 어느 행성을 눌러도 같은 상세가 열린다.
PlanetRepository samplePlanetRepository(String fandomId) =>
SamplePlanetRepository(fandomId: fandomId);
/// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.**
///
/// 표본이지만 **한 표에서 전부 파생시킨다.** 지표 카드 값·행성 랭킹 순위·사용자
/// 랭킹이 각자 다른 상수를 들고 있으면, 서버가 붙기 전 검수에서 다시 「수치가 안
/// 맞는다」가 된다(발주사 검토회신서 4절).
class SamplePlanetRepository implements PlanetRepository {
@override
Future<void> join() async {}
@override
Future<List<ContributorRank>> contributors({int limit = 3}) async => const [];
@override
Future<void> leave() async {}
@override
Future<PlanetHistoryPage> history({
String? cursor,
bool personal = false,
}) async => const PlanetHistoryPage(items: []);
@override
Future<HeroDropReceipt> claimHeroDrop(String dropId) =>
Future.error(StateError('실제 지급 서버가 연결되지 않았습니다.'));
/// [fandomId] 는 갤럭시에서 누른 행성이다. 주지 않으면 내 행성으로 본다.
const SamplePlanetRepository({String? fandomId})
: fandomId = fandomId ?? _myFandomId;
/// 상세를 열 행성. **내 행성과 같지 않다** — 갤럭시 HUD 는 남의 행성도 연다.
final String fandomId;
static final _base = DateTime(2026, 8, 31, 14);
/// 내 행성. 「내 기여도」·「내 행성 순위」의 기준이며 [fandomId] 와 다를 수 있다.
static const _myFandomId = 'bts';
/// 내 행성인가. 내 기여도처럼 **나에게만 있는 값**의 게이트다.
/// [fandomId] 가 아니라 [_row] 로 판정한다 — 빈 문자열도 내 행성으로 떨어지므로
/// 「상세는 내 행성, 기여도는 없음」처럼 갈리지 않는다.
bool get _isMine => _row.$1 == _myFandomId;
/// 상세를 그릴 행의 값.
///
/// ponytail: 표본에 없는 ID(빈 문자열 포함)면 첫 행으로 떨어진다. 목 모드에서
/// ID 를 주는 곳은 [SampleGalaxyRepository] 의 6행성뿐이라 닿지 않는 길이고,
/// 여기서 던지면 발주사 확인용 화면이 빈다. 표본 행성이 늘어나면 두 표를 함께 늘린다.
(String, String, List<int>) get _row => _planets.firstWhere(
(p) => p.$1 == fandomId,
orElse: () => _planets.first,
);
/// 행성별 5축 값. 순서는 [FpiMetric.values] 와 같다 — 별첨 B 확정 순서인
/// `bpm · health · referral · contribution · community`.
///
/// **축마다 1위가 다르도록 짰다.** 발주사가 「서로 다른 지표 화면에 동일한
/// Ranking 데이터가 반복된다」를 지적했으므로, 표본에서도 지표를 바꾸면 순서가
/// 바뀌는 것이 눈으로 확인돼야 한다.
/// 각 행의 합계는 갤럭시 맵 표본([SampleGalaxyRepository])의 `fpi` 와 같다.
static const _planets = <(String, String, List<int>)>[
('bts', 'BTS', [82400, 61200, 38900, 46710, 55100]),
('newjeans', 'NewJeans', [91200, 44300, 52800, 42005, 41600]),
('seventeen', 'SEVENTEEN', [58700, 78400, 31200, 22920, 62900]),
('ive', 'IVE', [46100, 39800, 61500, 22640, 28700]),
('aespa', 'aespa', [37400, 52600, 29100, 23350, 33800]),
('lesserafim', 'LE SSERAFIM', [29800, 31500, 44200, 21480, 25100]),
];
/// 행성별 멤버 수. 갤럭시 맵 표본([SampleGalaxyRepository])의 `members` 와 같은
/// 값이다 — 갤럭시 HUD 와 상세가 다른 인원을 띄우면 그것부터 지적받는다.
static const _members = <String, int>{
'bts': 12840,
'newjeans': 11230,
'seventeen': 10450,
'ive': 8320,
'aespa': 7640,
'lesserafim': 6910,
};
/// 내 행성 안의 사용자 표본.
static const _users = <(String, String, List<int>)>[
('u-bunnies', '버니즈', [4820, 2110, 1240, 1530, 980]),
('u-carat', '캐럿', [3960, 3480, 620, 870, 2140]),
('u-me', '나', [2740, 1290, 3180, 4210, 1620]),
('u-moomin', '무민', [1880, 4020, 940, 760, 3050]),
('u-bora', '보라', [1210, 870, 2260, 2980, 1180]),
('u-dive', '다이브짱', [940, 1640, 1720, 1120, 610]),
];
static const _countries = <(String, String, List<int>)>[
('KR', '대한민국', [1284000, 942000, 610000, 998000, 1105000]),
('JP', '일본', [921000, 388000, 742000, 611000, 530000]),
('US', '미국', [733000, 815000, 402000, 455000, 690000]),
('ID', '인도네시아', [512000, 296000, 588000, 372000, 341000]),
('ZZ', '미지정', [84000, 61000, 47000, 55000, 72000]),
];
/// 내 기여 상위 %. 사용자 표본에서 「나」의 순위로 계산한다.
static int _myTopPercent(FpiMetric metric) {
final rows = _rank(_users, metric, myId: 'u-me', limit: _users.length);
final me = rows.mine;
if (me == null) return 100;
return (me.rank * 100 / _users.length).ceil();
}
/// 표를 지표 하나로 내림차순 정렬해 랭킹 한 판을 만든다.
static FpiRanking _rank(
List<(String, String, List<int>)> table,
FpiMetric metric, {
String? myId,
required int limit,
}) {
final axis = metric.index;
final sorted = [...table]..sort((a, b) => b.$3[axis].compareTo(a.$3[axis]));
final rows = <FpiRankRow>[
for (final (i, row) in sorted.indexed)
FpiRankRow(
id: row.$1,
rank: i + 1,
name: row.$2,
value: row.$3[axis],
// 순위 동향도 지표마다 달라야 화면이 같아 보이지 않는다.
change: (axis + i) % 5 - 2,
isMine: row.$1 == myId,
),
];
return FpiRanking(
rows: rows.take(limit).toList(growable: false),
mine: rows.where((r) => r.isMine).firstOrNull,
);
}
@override
Future<PlanetDetail> load({required bool nebula}) async {
// 성운은 아직 랭킹 모집단이 아니다(별첨 B — 정식 행성 중 최근 7 일 유효 활동).
final row = _row;
final axes = nebula ? [for (final v in row.$3) (v / 60).round()] : row.$3;
return PlanetDetail(
fandomId: nebula ? 'new-${row.$1}' : row.$1,
name: nebula ? 'New_${row.$2}' : row.$2,
isNebula: nebula,
// TOTAL FPI = 5 축 단순 합산(별첨 B · 설계서 §4.1, 가중치 초기 1.0).
// 따로 든 상수가 아니라 축의 합이라 육각형·지표 카드와 어긋날 수 없다.
totalFpi: axes.fold(0, (a, b) => a + b),
members: nebula ? 8 : _members[row.$1] ?? 0,
scores: [
for (final metric in FpiMetric.values)
FpiScore(
metric: metric,
value: axes[metric.index],
// 전체 순위 = 행성 랭킹에서 **이 행성**의 순위. 랭킹 화면과 같은 계산이다.
rank: nebula
? 0
: _rank(
_planets,
metric,
myId: row.$1,
limit: _planets.length,
).mine!.rank,
trend: nebula ? 0 : axes[metric.index] ~/ 60 - metric.index * 40,
// 내 기여도는 **내 행성에만 있다.** 남의 행성에서 내 등수를 지어내면
// 화면이 그것을 사실로 띄운다(대조표 3-5 — 없으면 null).
myTopPercent: _isMine ? _myTopPercent(metric) : null,
),
],
posts: _posts(0, 5),
);
}
@override
Future<List<LoungePost>> morePosts({
required int offset,
int limit = 5,
}) async => _posts(offset, limit);
/// 표본 댓글. 서버가 없어도 쓰고 지운 댓글이 상세를 다시 열 때 남아 있게 한다.
static final _comments = <String, List<LoungeComment>>{};
@override
Future<LoungePost> createPost(
String body, {
List<PickedPhoto> photos = const [],
}) async {
checkLoungePhotos(photos);
// 표본은 올릴 곳이 없어 사진을 붙이지 않는다 — 가짜 URL 을 그리면 깨진 칸이 뜬다.
return LoungePost(
id: 'new-${DateTime.now().microsecondsSinceEpoch}',
authorName: '나',
createdAt: DateTime.now(),
title: '',
body: body,
likes: 0,
comments: 0,
isMine: true,
);
}
@override
Future<LoungePost> updatePost(LoungePost post, String body) async =>
post.copyWith(body: body);
@override
Future<void> deletePost(LoungePost post) async {}
@override
Future<LoungePost> toggleLike(LoungePost post) async => post.copyWith(
liked: !post.liked,
likes: post.likes + (post.liked ? -1 : 1),
);
@override
Future<List<LoungeComment>> comments(String postId) async => [
...?_comments[postId],
];
@override
Future<LoungeComment> addComment(String postId, String body) async {
final c = LoungeComment(
id: 'c${DateTime.now().microsecondsSinceEpoch}',
authorName: '나',
body: body,
createdAt: DateTime.now(),
isMine: true,
);
(_comments[postId] ??= []).add(c);
return c;
}
@override
Future<void> deleteComment(String postId, String commentId) async =>
_comments[postId]?.removeWhere((c) => c.id == commentId);
@override
Future<void> reportPost(LoungePost post, String reason) async {}
@override
Future<void> blockAuthor(LoungePost post) async {}
@override
Future<FpiRanking> planetRanking({
required FpiMetric metric,
int limit = 30,
}) async => _rank(_planets, metric, myId: _myFandomId, limit: limit);
/// 표본 사용자는 **내 행성 소속**이다. 남의 행성 랭킹에 「나」를 끼우면
/// 「나의 랭킹 영역」이 가입하지도 않은 행성에서 뜬다. 대상 행성은 인터페이스가
/// 인자로 받으므로(행성 랭킹에서 누른 행) 필드가 아니라 인자로 판정한다.
///
/// ponytail: 행의 이름·수치는 행성이 달라도 같다. 표본을 여섯 벌 더 지어내는
/// 것이라 서버가 붙으면 버릴 값이다. 발주사가 지적한 「같은 Ranking 반복」은
/// 지표(metric)축에서 갈리므로 그쪽 가드(f3 회귀 테스트)로 지킨다.
@override
Future<FpiRanking> userRanking({
required FpiMetric metric,
required String fandomId,
int limit = 30,
}) async => _rank(
_users,
metric,
myId: fandomId == _myFandomId ? 'u-me' : null,
limit: limit,
);
@override
Future<FpiRanking> countryRanking({
required FpiMetric metric,
int limit = 30,
}) async =>
// 「나의 랭킹 영역」은 국가별 필터에서도 뜬다 — 내 국가 행이다(546:14713).
_rank(_countries, metric, myId: 'KR', limit: limit);
static List<LoungePost> _posts(int offset, int limit) => [
for (var i = offset; i < offset + limit && i < 18; i++)
LoungePost(
id: 'p$i',
authorName: ['아미보이', '다이브짱', '네누', '버니즈', '캐럿'][i % 5],
createdAt: _base.subtract(Duration(hours: i * 3)),
title: [
'오늘 공방 다녀왔어요',
'이번 앨범 최애 트랙 뭐예요?',
'굿즈 정리했습니다',
'콘서트 좌석 후기',
'데뷔일 프로젝트 모아요',
][i % 5],
body: '팬덤 라운지에 올라온 글입니다. 최신순으로 다섯 개씩 불러옵니다.',
likes: 40 - i,
comments: 12 - (i % 7),
),
];
}