/// 행성·성운 상세가 그리는 값의 형태. /// /// 근거는 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( Iterable 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 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 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 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 scores; final int members; final List 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 items; final String? nextCursor; } abstract interface class PlanetRepository { Future history({String? cursor, bool personal = false}); Future claimHeroDrop(String dropId); Future load({required bool nebula}); /// 이 행성/성운에 가입 — `fn-fandom-join`. 이미 멤버면 서버가 아무것도 하지 않는다. Future join(); /// 탈퇴 — `fn-fandom-leave`. 대표 팬덤 자동 지정(C-101) · 성운 창립자 승계(C-100)는 서버가 한다. Future leave(); /// 명세 — *최초 5개, 스크롤 시 5개씩 호출*. Future> morePosts({required int offset, int limit = 5}); /// 글 쓰기. 사진은 먼저 Storage 에 올리고 URL 을 글에 붙인다(B-44). Future createPost( String body, { List photos = const [], }); /// 내 글 본문 수정. 사진은 바꾸지 않는다. Future updatePost(LoungePost post, String body); Future deletePost(LoungePost post); /// 좋아요 켜고 끄기. 수는 서버 트리거가 다시 센다 — 돌려주는 값은 화면용 예측이다. Future toggleLike(LoungePost post); Future> comments(String postId); Future addComment(String postId, String body); Future deleteComment(String postId, String commentId); /// 신고 접수 — `reports` 에 쌓고 Admin 이 처리한다(B-45). Future reportPost(LoungePost post, String reason); /// 작성자 차단 — `users/{uid}/blocks/{authorUid}`. 이후 목록에서 빠진다. Future blockAuthor(LoungePost post); /// 지표별 **행성** 랭킹 — Figma `546:14628`·`546:14639`. /// /// 모집단은 별첨 B 「FPI 랭킹」 확정값 — *정식 행성 중 최근 7 일 유효 활동 /// 존재 행성*. 서버가 `fandoms.rankingEligible` 로 표시한다. Future planetRanking({required FpiMetric metric, int limit = 30}); /// 지표별 **사용자** 랭킹 — Figma `546:14668`·`546:14674`. /// 행성 랭킹의 한 행을 눌러 들어오므로 대상 행성을 받는다. Future userRanking({ required FpiMetric metric, required String fandomId, int limit = 30, }); /// 지표별 **국가** 랭킹 — Figma `546:14713`(랭킹 필터 「국가별」). Future countryRanking({ required FpiMetric metric, int limit = 30, }); /// 성운 「실시간 기여 랭킹」 — 멤버 누적 기여 POP(`members.contributedPop`) 상위. Future> 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 join() async {} @override Future> contributors({int limit = 3}) async => const []; @override Future leave() async {} @override Future history({ String? cursor, bool personal = false, }) async => const PlanetHistoryPage(items: []); @override Future 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) 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)>[ ('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 = { 'bts': 12840, 'newjeans': 11230, 'seventeen': 10450, 'ive': 8320, 'aespa': 7640, 'lesserafim': 6910, }; /// 내 행성 안의 사용자 표본. static const _users = <(String, String, List)>[ ('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)>[ ('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)> 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 = [ 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 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> morePosts({ required int offset, int limit = 5, }) async => _posts(offset, limit); /// 표본 댓글. 서버가 없어도 쓰고 지운 댓글이 상세를 다시 열 때 남아 있게 한다. static final _comments = >{}; @override Future createPost( String body, { List 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 updatePost(LoungePost post, String body) async => post.copyWith(body: body); @override Future deletePost(LoungePost post) async {} @override Future toggleLike(LoungePost post) async => post.copyWith( liked: !post.liked, likes: post.likes + (post.liked ? -1 : 1), ); @override Future> comments(String postId) async => [ ...?_comments[postId], ]; @override Future 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 deleteComment(String postId, String commentId) async => _comments[postId]?.removeWhere((c) => c.id == commentId); @override Future reportPost(LoungePost post, String reason) async {} @override Future blockAuthor(LoungePost post) async {} @override Future planetRanking({ required FpiMetric metric, int limit = 30, }) async => _rank(_planets, metric, myId: _myFandomId, limit: limit); /// 표본 사용자는 **내 행성 소속**이다. 남의 행성 랭킹에 「나」를 끼우면 /// 「나의 랭킹 영역」이 가입하지도 않은 행성에서 뜬다. 대상 행성은 인터페이스가 /// 인자로 받으므로(행성 랭킹에서 누른 행) 필드가 아니라 인자로 판정한다. /// /// ponytail: 행의 이름·수치는 행성이 달라도 같다. 표본을 여섯 벌 더 지어내는 /// 것이라 서버가 붙으면 버릴 값이다. 발주사가 지적한 「같은 Ranking 반복」은 /// 지표(metric)축에서 갈리므로 그쪽 가드(f3 회귀 테스트)로 지킨다. @override Future userRanking({ required FpiMetric metric, required String fandomId, int limit = 30, }) async => _rank( _users, metric, myId: fandomId == _myFandomId ? 'u-me' : null, limit: limit, ); @override Future countryRanking({ required FpiMetric metric, int limit = 30, }) async => // 「나의 랭킹 영역」은 국가별 필터에서도 뜬다 — 내 국가 행이다(546:14713). _rank(_countries, metric, myId: 'KR', limit: limit); static List _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), ), ]; }