688 lines
25 KiB
Dart
688 lines
25 KiB
Dart
/// 홈 화면이 그리는 값의 형태.
|
||
///
|
||
/// **화면과 데이터를 갈라 둔 이유** — 서버가 아직 없다. Firestore 연동은 5주차부터이고
|
||
/// 그때 [HomeRepository] 구현체만 갈아 끼우면 화면은 손대지 않는다. 지금 화면 안에
|
||
/// 숫자를 박아 두면 그 시점에 화면을 다시 짜야 한다.
|
||
///
|
||
/// 값의 근거는 Figma `03_Wireframe` 「홈」(`252:159354`)이다. 시연용 표본이며
|
||
/// 정책값이 아니다 — 정책은 `정책 설계서 최종본 v1.0` 이 정한다.
|
||
library;
|
||
|
||
import 'package:flutter/painting.dart' show Color;
|
||
import '../../core/i18n/app_locale.dart';
|
||
import '../../core/ui/planet_25d.dart' show planetColors;
|
||
import '../quest/quest_data.dart' show QuestType;
|
||
import '../inside/inside_data.dart' show InsideQuestion, SampleInsideRepository;
|
||
|
||
/// 팬덤 행성의 성장 상태. 정책 근거는 정책 설계서 §P-08.
|
||
class PlanetGrowth {
|
||
const PlanetGrowth({
|
||
this.stage,
|
||
this.currentSfpi,
|
||
this.requiredSfpi,
|
||
this.members,
|
||
this.requiredMembers,
|
||
this.nebulaGoalPassed = false,
|
||
this.isFounder = false,
|
||
required this.fandomName,
|
||
required this.contributorName,
|
||
required this.contributedPop,
|
||
required this.growthPercent,
|
||
required this.currentPop,
|
||
required this.requiredPop,
|
||
});
|
||
|
||
/// 서버 팬덤 Stage 0~6. 누락된 단계는 추정하지 않는다.
|
||
final int? stage;
|
||
final String fandomName;
|
||
|
||
/// 직전 기여자. 「BTS_Hong 님의 1,230 POP 기여로 …」 문구에 쓴다.
|
||
final String contributorName;
|
||
final int contributedPop;
|
||
|
||
bool get hasRecentContribution =>
|
||
contributedPop > 0 && contributorName.trim().isNotEmpty;
|
||
|
||
/// 0~100 POP 진행률. 전체 성장 조건의 충족률은 아니다.
|
||
final int? growthPercent;
|
||
|
||
/// 다음 Stage 진입까지의 누적 진행도. 정책 설계서 §P-08 은 POP 과 sFPI 동시 충족이라
|
||
/// 정한다. 조건 누락은 null이며 개인 기여량으로 대체하지 않는다.
|
||
final num? currentPop;
|
||
final int? requiredPop;
|
||
final num? currentSfpi;
|
||
final num? requiredSfpi;
|
||
|
||
/// 성운(Stage0)의 창립 멤버 수와 전환 기준 인원. 정책 원장 B-10 — 성운은 **10명 이상 +
|
||
/// 1,000 POP 이상**을 동시에 채워야 정식 행성(Stage1)이 된다. 기준값이 서버에서 오지
|
||
/// 않으면 null 이며 정책 수치로 채우지 않는다.
|
||
final int? members;
|
||
final int? requiredMembers;
|
||
|
||
/// 성운의 목표 기간(기본 7일)이 지났지만 전환은 여전히 열려 있는가 — 해석 확인 요청 Q-PN-02
|
||
/// 권고 A안. 남은 일수 대신 「목표 기간이 지났어요 · 조건을 채우면 …」를 보인다.
|
||
final bool nebulaGoalPassed;
|
||
|
||
/// 내가 이 성운을 만든 창립자(`fandoms.founderUid`)인가 — 해석 확인 요청 Q-PN-01 A안.
|
||
/// 탄생(형태 · 컬러 선택)은 창립자만 한다. 서버(`fn-planet-create`)도 같은 검사를 한다.
|
||
final bool isFounder;
|
||
|
||
/// 성운 단계인가(B-08 Stage0 / Pre-Planet).
|
||
bool get isNebula => stage == 0;
|
||
|
||
/// 성운 → 행성 전환 조건을 둘 다 채웠는가. 하나라도 모르면 채우지 않은 것으로 본다.
|
||
bool get nebulaReady =>
|
||
isNebula &&
|
||
_met(currentPop, requiredPop) == true &&
|
||
_met(members, requiredMembers) == true;
|
||
|
||
/// 조건 미조회와 실제 0을 구분한다. 승급 자체는 서버 확정으로만 처리한다.
|
||
String get popConditionLabel => _condition('POP', currentPop, requiredPop);
|
||
String get sfpiConditionLabel =>
|
||
_condition('sFPI', currentSfpi, requiredSfpi);
|
||
String get memberConditionLabel =>
|
||
_condition(appL10n.growthFounders, members, requiredMembers);
|
||
String get growthStatus => stage == 6
|
||
? appL10n.growthFinalStage
|
||
: _met(currentPop, requiredPop) == true &&
|
||
_met(currentSfpi, requiredSfpi) == true
|
||
? appL10n.growthPromotionPending
|
||
: appL10n.growthCheckConditions;
|
||
|
||
/// 「행성 성장률 N%」 · 「현재 / 목표」의 기준 — 두 조건 중 **진행률이 낮은 쪽(병목)**.
|
||
///
|
||
/// Figma 「홈」(`2097:16428` `Frame 1984081073`)은 성장률 하나 · 수치 한 쌍이고, 정책은
|
||
/// 두 조건 동시 충족이다(원장 B-11 · 성운은 POP + 창립 인원 B-10). 다음 진화는 늦은 쪽이
|
||
/// 채워질 때 오므로 그 축을 보인다. 두 조건 상세는 말풍선을 누르면 연다.
|
||
/// ponytail: 해석 확인 전 임시 기준(HOME 정보위계 v1.0 §3 은 「단일 백분율로 합치지 않음」).
|
||
/// 값을 모르는 축은 건너뛰고, 둘 다 모르면 null.
|
||
({double ratio, num current, num required})? get bottleneck {
|
||
final axes = [
|
||
(currentPop, requiredPop),
|
||
isNebula ? (members, requiredMembers) : (currentSfpi, requiredSfpi),
|
||
];
|
||
({double ratio, num current, num required})? low;
|
||
for (final (cur, req) in axes) {
|
||
if (cur == null || req == null || req <= 0 || !cur.isFinite) continue;
|
||
final r = (cur / req).clamp(0.0, 1.0).toDouble();
|
||
if (low == null || r < low.ratio) {
|
||
low = (ratio: r, current: cur, required: req);
|
||
}
|
||
}
|
||
return low;
|
||
}
|
||
|
||
/// 말풍선 문구. Figma 문구는 「다음 진화까지 얼마 안남았어요!」 하나다 — 병목 진행률이
|
||
/// 절반 이상일 때만 그 문구를 쓰고, 그 아래는 「성장 조건 확인」(누르면 두 조건 상세).
|
||
/// ponytail: 절반 기준은 근거 없는 임시값 — 상태별 문구가 정해지면 바꾼다.
|
||
String get growthBubble => stage == 6
|
||
? appL10n.growthFinalStage
|
||
: _met(currentPop, requiredPop) == true &&
|
||
_met(
|
||
isNebula ? members : currentSfpi,
|
||
isNebula ? requiredMembers : requiredSfpi,
|
||
) ==
|
||
true
|
||
? appL10n.growthPromotionPending
|
||
: (bottleneck?.ratio ?? 0) >= 0.5
|
||
? appL10n.heroNextEvolution
|
||
: appL10n.growthCheckConditions;
|
||
|
||
double? get popRatio =>
|
||
currentPop == null || requiredPop == null || requiredPop! <= 0
|
||
? null
|
||
: (currentPop! / requiredPop!).clamp(0.0, 1.0);
|
||
|
||
/// 조건 충족 여부. 값을 모르면 null.
|
||
static bool? _met(num? current, num? required) {
|
||
if (current == null ||
|
||
required == null ||
|
||
required < 0 ||
|
||
!current.isFinite ||
|
||
!required.isFinite)
|
||
return null;
|
||
return current >= required;
|
||
}
|
||
|
||
static String _condition(String name, num? current, num? required) =>
|
||
switch (_met(current, required)) {
|
||
null => appL10n.growthConditionChecking(name),
|
||
true => appL10n.growthConditionMet(name),
|
||
false => appL10n.growthConditionUnmet(name),
|
||
};
|
||
}
|
||
|
||
/// 오늘의 퀘스트 한 줄.
|
||
enum QuestState {
|
||
/// 조건을 채웠고 보상을 아직 받지 않았다.
|
||
claimable,
|
||
|
||
/// 진행 중.
|
||
inProgress,
|
||
|
||
/// 보상까지 받았다.
|
||
done,
|
||
}
|
||
|
||
class QuestSummary {
|
||
const QuestSummary({
|
||
required this.title,
|
||
required this.rewardPop,
|
||
required this.state,
|
||
this.progressLabel,
|
||
this.id = '',
|
||
this.endsAt,
|
||
this.type,
|
||
this.category,
|
||
});
|
||
|
||
final String title;
|
||
|
||
/// 보조 줄 — 퀘스트 카테고리(사본 「오늘의 출석체크」 · 「커뮤니티」). 퀘스트 목록의
|
||
/// 카테고리 이름과 같은 원천(`QuestType.label`).
|
||
final String? category;
|
||
|
||
/// `quests.type`. 줄 왼쪽 아이콘을 고르는 데만 쓴다(없으면 제목으로 고른다).
|
||
final QuestType? type;
|
||
|
||
/// 보상 POP — 서버 지급액과 같은 원천. 못 읽으면 null.
|
||
final int? rewardPop;
|
||
final QuestState state;
|
||
|
||
/// 「0/1,500 걸음」 같은 진행 표기. 없으면 표시하지 않는다.
|
||
final String? progressLabel;
|
||
|
||
/// `quests` 문서 ID. 타임어택 카드가 상세로 바로 들어갈 때 쓴다.
|
||
final String id;
|
||
|
||
/// 타임어택 마감(`quests.activeTo`). 있으면 카드가 남은 시간을 센다.
|
||
final DateTime? endsAt;
|
||
}
|
||
|
||
/// AI Daily Briefing 구획 — 정책 원장 B-47 이 News 와 SNS 둘로 확정했다.
|
||
///
|
||
/// *News/SNS 각각 0 건이면 해당 섹션 숨김, 모두 0 건이면 전체 Briefing 블록을 숨김*
|
||
enum BriefingSection {
|
||
news('News', 'NEWS'),
|
||
sns('SNS', 'SNS');
|
||
|
||
const BriefingSection(this.key, this.label);
|
||
|
||
/// 서버 `briefings.section` 값. 대소문자까지 서버와 같아야 한다.
|
||
final String key;
|
||
|
||
/// 구획 제목.
|
||
final String label;
|
||
|
||
/// 서버 값에서 구획을 찾는다. 모르는 값은 null 이고 화면이 그 줄을 버린다 —
|
||
/// 정책이 정한 둘 밖의 섹션을 임의로 그리면 근거 없는 구획이 생긴다.
|
||
static BriefingSection? of(String key) {
|
||
for (final section in values) {
|
||
if (section.key == key) return section;
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// AI Daily Briefing 한 줄 — 정책 원장 B-47 · B-48 · B-75.
|
||
///
|
||
/// **문안은 발주사 Agent 엔진이 만든다.** B-48 이 「외부 Agent 엔진은 발주사 영역,
|
||
/// 저희 범위는 Candidate/Draft 수신 · Admin 승인 · 노출」로 확정했다.
|
||
///
|
||
/// **5 요소가 전부 필수다.** B-75 — *각 항목에 출처명·게시/발행시각·원본 URL·원문보기·*
|
||
/// *AI 요약 표시를 제공합니다. 출처 확인 불가 AI 내용은 게시하지 않습니다.* 필드를
|
||
/// 선택값으로 두면 메타가 빈 카드가 화면까지 내려오고, 그것이 곧 B-75 위반이다.
|
||
/// 서버도 같은 이유로 수신 단계에서 거부한다(`fn-briefing-ingest`).
|
||
///
|
||
/// 화면 표시는 지금 제목 한 줄이다. 출처명·게시시각·AI 요약을 어디에 배치할지는
|
||
/// 발주사가 HOME 기준으로 지정한 Figma 「REFINE v5A · HOME · Default」 확인 후
|
||
/// 정한다 — 임의로 보조행을 넣지 않는다.
|
||
class BriefingItem {
|
||
const BriefingItem({
|
||
required this.rank,
|
||
required this.headline,
|
||
required this.section,
|
||
required this.sourceName,
|
||
required this.sourceAt,
|
||
required this.sourceUrl,
|
||
required this.articleUrl,
|
||
required this.aiSummary,
|
||
this.sourceKind,
|
||
});
|
||
|
||
/// 구획 안에서의 순서. 구획마다 1 부터 시작한다.
|
||
final int rank;
|
||
|
||
/// 카드에 뜨는 제목.
|
||
final String headline;
|
||
|
||
final BriefingSection section;
|
||
|
||
/// 출처명 — 언론사·계정명.
|
||
final String sourceName;
|
||
|
||
/// 게시·발행 시각.
|
||
final DateTime sourceAt;
|
||
|
||
/// 원본 URL.
|
||
final String sourceUrl;
|
||
|
||
/// 원문보기 링크. 원본과 같을 수 있다.
|
||
final String articleUrl;
|
||
|
||
/// AI 요약 본문.
|
||
final String aiSummary;
|
||
|
||
/// 출처 구분 — `official` · `fandom`(PRD-06 「공식/팬덤 출처 구분 표시」). 수신 규격에서
|
||
/// 선택 필드라 없으면 표시하지 않는다.
|
||
final String? sourceKind;
|
||
}
|
||
|
||
/// HOT ARCHIVE — 진행 중인 캠페인 한 건.
|
||
class CampaignHighlight {
|
||
const CampaignHighlight({
|
||
this.id = '',
|
||
required this.authorName,
|
||
required this.achievedPercent,
|
||
required this.title,
|
||
required this.description,
|
||
required this.supporters,
|
||
required this.comments,
|
||
required this.likes,
|
||
});
|
||
|
||
/// `campaigns/{id}`. 「캠페인 보기」가 상세로 바로 간다(명세 §2.114-6). 비면 목록으로.
|
||
final String id;
|
||
final String authorName;
|
||
final int achievedPercent;
|
||
final String title;
|
||
final String description;
|
||
final int supporters;
|
||
final int comments;
|
||
final int likes;
|
||
}
|
||
|
||
/// 팬덤행성 인큐베이터(성운 추천) 한 장 — 명세 §2.114-4 「성운명 · 가입 인원수 · 성운
|
||
/// 이미지 · 기여율% · 기여율 그래프 · 함께 성장하기」(`2097:16403`).
|
||
class NebulaPick {
|
||
const NebulaPick({
|
||
required this.id,
|
||
required this.name,
|
||
required this.members,
|
||
required this.percent,
|
||
});
|
||
|
||
final String id;
|
||
final String name;
|
||
final int members;
|
||
|
||
/// 기여율 — 누적 POP ÷ 성운 전환 목표 POP(`config/policy.nebulaMinPop`, B-10). 0~100.
|
||
final int percent;
|
||
}
|
||
|
||
/// 홈 한 화면이 필요로 하는 것 전부.
|
||
class HomeSnapshot {
|
||
const HomeSnapshot({
|
||
required this.popBalance,
|
||
required this.liveTicker,
|
||
this.fandomId = '',
|
||
required this.growth,
|
||
required this.quests,
|
||
required this.briefings,
|
||
this.campaigns = const [],
|
||
required this.insides,
|
||
this.items = const [],
|
||
this.nebulas = const [],
|
||
this.weekly,
|
||
});
|
||
|
||
/// HOME 주간 정보(A-37 · HOME 정보위계 v1.0 §2 · §3). 소속 행성이 없으면 null.
|
||
final HomeWeekly? weekly;
|
||
|
||
/// 활동 POP 잔액. 정책 설계서 §P-02 — 활동/지원 원장은 분리되어 있고
|
||
/// 홈 상단에는 활동 POP 을 띄운다.
|
||
final int popBalance;
|
||
|
||
/// 내 소속 팬덤. 이클립스 경보 배너와 라이브 띠가 **어느 팬덤을 구독할지**를
|
||
/// 이 값으로 정한다. 비어 있으면 소속이 없다는 뜻이라 둘 다 뜨지 않는다.
|
||
final String fandomId;
|
||
|
||
/// 상단 라이브 띠의 **폴백** 문구. 원천은 `fandoms/{id}/liveEvents` 구독이고
|
||
/// (별첨 1 ① 시나리오 5) 이 문자열은 라이브 이벤트가 아직 없을 때만 쓴다.
|
||
/// `fandoms/{id}.liveTicker` 를 쓰는 서버 코드는 시드 스크립트뿐이다.
|
||
final String? liveTicker;
|
||
|
||
final PlanetGrowth growth;
|
||
final List<QuestSummary> quests;
|
||
final List<BriefingItem> briefings;
|
||
|
||
/// HOT ARCHIVE 캐러셀 — 최대 5건(명세 §2.114-6).
|
||
final List<CampaignHighlight> campaigns;
|
||
final List<InsideQuestion> insides;
|
||
|
||
/// 팬덤행성 인큐베이터 — 기여율 높은 순 최대 6개(명세 §2.114-4).
|
||
final List<NebulaPick> nebulas;
|
||
|
||
/// 헤더 지갑 · ZAP 「아이템」 시트의 상품. Admin Catalog(B-84 · B-85)
|
||
/// `heroGiftProducts` 에서 온다.
|
||
final List<ItemProduct> items;
|
||
}
|
||
|
||
/// HOME 주간 정보 — 발주사 9/9 검토회신 2항 「행성의 주간 순위 · 이번 주 성장 정도 · 이번 주
|
||
/// 기여량 및 기여 비율 · 보유 POP」(HOME 정보위계 v1.0 §2 의 2 · 3 · 6 · 7번). 값의 뜻은 §3(별첨 B):
|
||
///
|
||
/// - 순위는 **주간 공식 Snapshot**(`rankingSnapshots`, B-20)의 순위다 — 실시간 순위가 아니다.
|
||
/// 변동은 직전 Snapshot 대비(C-43 「순위 동향 · 변동값」). 양수 = 올라감.
|
||
/// - 이번 주 성장은 **두 축(Growth POP · sFPI)** 각각의 증분 — 단일 백분율로 합치지 않는다.
|
||
/// - 기여 비율은 **행성 주간 성장 총량 대비 본인 비중**.
|
||
/// - 보유 POP 은 **일반 POP / 기여 POP** 분리 표기.
|
||
///
|
||
/// 「이번 주」의 시작은 마지막 주간 Snapshot 시각(KST 월요일 00:10, Q-A11 임시값)이다. 그 시각의
|
||
/// 행성 누계가 기준선이라 Snapshot 이 없거나 기준선 필드가 없는 옛 Snapshot 이면 그 값은 null.
|
||
class HomeWeekly {
|
||
const HomeWeekly({
|
||
this.rank,
|
||
this.rankChange,
|
||
this.weekGrowthPop,
|
||
this.weekSfpi,
|
||
this.myWeekPop,
|
||
this.myShare,
|
||
required this.generalPop,
|
||
required this.supportPop,
|
||
});
|
||
|
||
final int? rank;
|
||
final int? rankChange;
|
||
final num? weekGrowthPop;
|
||
final num? weekSfpi;
|
||
/// 이번 주 내가 이 행성 성장에 보탠 Growth POP(`users/{uid}/growthEvents` 합). 못 읽으면 null.
|
||
final num? myWeekPop;
|
||
|
||
/// 0~1. 행성 주간 성장이 0 이거나 모르면 null.
|
||
final double? myShare;
|
||
final int generalPop;
|
||
final int supportPop;
|
||
|
||
/// Snapshot 두 개(최신 · 직전)의 `planets` 와 현재 행성 누계 · 내 이번 주 성장 기여로 만든다.
|
||
factory HomeWeekly.from({
|
||
required String fandomId,
|
||
required List<Map<String, dynamic>> snapshots,
|
||
required Map<String, dynamic> fandom,
|
||
required num? myWeekPop,
|
||
required Map<String, dynamic> wallet,
|
||
}) {
|
||
Map<String, dynamic>? entryOf(Map<String, dynamic>? snap) {
|
||
final planets = snap?['planets'];
|
||
if (planets is! List) return null;
|
||
for (final p in planets) {
|
||
if (p is Map && p['fandomId'] == fandomId) return p.cast<String, dynamic>();
|
||
}
|
||
return null;
|
||
}
|
||
|
||
final latest = entryOf(snapshots.firstOrNull);
|
||
final previous = entryOf(snapshots.length > 1 ? snapshots[1] : null);
|
||
int? rankOf(Map<String, dynamic>? e) =>
|
||
e?['rank'] is int ? e!['rank'] as int : null;
|
||
num? delta(String key) {
|
||
final now = fandom[key];
|
||
final base = latest?[key];
|
||
return now is num && base is num ? now - base : null;
|
||
}
|
||
|
||
final rank = rankOf(latest);
|
||
final prev = rankOf(previous);
|
||
final weekGrowth = delta('growthPop');
|
||
int intOf(dynamic v) => v is num ? v.toInt() : 0;
|
||
return HomeWeekly(
|
||
rank: rank,
|
||
rankChange: rank != null && prev != null ? prev - rank : null,
|
||
weekGrowthPop: weekGrowth,
|
||
weekSfpi: delta('sfpi'),
|
||
myWeekPop: myWeekPop,
|
||
myShare: weekGrowth != null && weekGrowth > 0 && myWeekPop != null
|
||
? (myWeekPop / weekGrowth).clamp(0.0, 1.0).toDouble()
|
||
: null,
|
||
generalPop: intOf(wallet['activityBalance']),
|
||
supportPop: intOf(wallet['supportBalance']),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 아이템(Hero Gift) 상품 한 줄 — 사본 `아이템 구입`(`252:160233`) 카드.
|
||
///
|
||
/// 구매하면 구매자에게 기여 POP, 팬덤 Drop Pool 에 같은 수량의 일반 POP 이 생긴다
|
||
/// (원장 B-84). 수치는 Admin 저장값이며 표본은 사본 문구다.
|
||
class ItemProduct {
|
||
const ItemProduct({
|
||
this.productId = '',
|
||
required this.name,
|
||
required this.description,
|
||
required this.dropPop,
|
||
required this.buyerPop,
|
||
required this.priceWon,
|
||
required this.owned,
|
||
});
|
||
|
||
/// 스토어 상품 ID(= Catalog 문서 ID). 표본은 비어 있다.
|
||
final String productId;
|
||
final String name;
|
||
final String description;
|
||
|
||
/// 「N POP 전송」 — 팬덤 Drop Pool 에 생기는 일반 POP.
|
||
final int dropPop;
|
||
|
||
/// 「구매자 +N POP」.
|
||
final int buyerPop;
|
||
final int priceWon;
|
||
|
||
/// 보유 수량.
|
||
final int owned;
|
||
}
|
||
|
||
/// 홈이 값을 얻어 오는 경로.
|
||
///
|
||
/// 5주차 Firestore 연동 때 이 인터페이스의 구현체만 바꾼다.
|
||
abstract interface class HomeRepository {
|
||
Future<HomeSnapshot> load();
|
||
Stream<PlanetGrowth> watchGrowth(String fandomId);
|
||
|
||
/// 보유 활동 POP — `users/{uid}/wallet/main.activityBalance` 구독. 퀘스트 목록 헤더
|
||
/// (`QuestRepository.popBalance`)와 같은 문서라, 다른 화면에서 생긴 변동(현상금 예치 ·
|
||
/// 보상)이 HOME 헤더에도 바로 반영된다.
|
||
Stream<int> watchPopBalance();
|
||
|
||
/// 성운(Stage0) 생성 — `fn-nebula-create`(원장 B-08 · B-09). 만든 팬덤 ID 와, 대표 팬덤이
|
||
/// 바뀌었는지(C-15 30일 대기 중이면 false — 성운만 생기고 대표는 그대로)를 돌려준다.
|
||
Future<({String fandomId, bool primaryChanged})> createNebula(String name);
|
||
|
||
/// 성운 → 정식 행성 탄생 — `fn-planet-create`. 형태 · 컬러 두 값만 보낸다(C-50).
|
||
Future<void> createPlanet({
|
||
required String fandomId,
|
||
required String planetType,
|
||
required String colorKey,
|
||
});
|
||
|
||
/// 성운 전환 목표 — 서버(`fn-nebula-create` · `fn-planet-create`)와 같은 `config/policy`
|
||
/// 키(`nebulaMinPop` · `nebulaMinMembers` · `nebulaDays`). 읽지 못하면 [defaultNebulaTargets].
|
||
Future<NebulaTargets> nebulaTargets();
|
||
|
||
/// 행성 생성 화면이 내놓을 컬러 — 컬러 마스터 `config/planetColors`(10/3 #6 · C-115). 읽지 못하면 Figma 기본 8색.
|
||
Future<List<(String, Color)>> planetColorOptions();
|
||
}
|
||
|
||
/// 성운 전환 목표 — POP · 창립 인원 · 목표 기간(일).
|
||
typedef NebulaTargets = ({int pop, int members, int days});
|
||
|
||
/// 정책 원장 B-10 확정값. `config/policy` 가 없을 때의 출발점이다.
|
||
const NebulaTargets defaultNebulaTargets = (pop: 1000, members: 10, days: 7);
|
||
|
||
/// `config/policy` 문서에서 성운 목표를 읽는다. 키가 없거나 잘못된 값이면 기본값.
|
||
NebulaTargets nebulaTargetsFrom(Map<String, dynamic>? policy) {
|
||
int read(String key, int fallback) => switch (policy?[key]) {
|
||
num n when n.isFinite && n > 0 => n.toInt(),
|
||
_ => fallback,
|
||
};
|
||
return (
|
||
pop: read('nebulaMinPop', defaultNebulaTargets.pop),
|
||
members: read('nebulaMinMembers', defaultNebulaTargets.members),
|
||
days: read('nebulaDays', defaultNebulaTargets.days),
|
||
);
|
||
}
|
||
|
||
/// 서버가 붙기 전까지 쓰는 표본.
|
||
///
|
||
/// **정책값이 아니다.** 화면이 어떤 모양으로 서는지 확인하기 위한 것이며,
|
||
/// 실제 수치는 수치표 v1.4 와 정책 설계서가 정한다.
|
||
class SampleHomeRepository implements HomeRepository {
|
||
const SampleHomeRepository();
|
||
|
||
@override
|
||
Stream<PlanetGrowth> watchGrowth(String fandomId) => const Stream.empty();
|
||
|
||
@override
|
||
Stream<int> watchPopBalance() => const Stream.empty();
|
||
|
||
@override
|
||
Future<NebulaTargets> nebulaTargets() async => defaultNebulaTargets;
|
||
|
||
@override
|
||
Future<List<(String, Color)>> planetColorOptions() async => planetColors;
|
||
|
||
@override
|
||
Future<({String fandomId, bool primaryChanged})> createNebula(
|
||
String name,
|
||
) async => (fandomId: 'bts', primaryChanged: true);
|
||
|
||
@override
|
||
Future<void> createPlanet({
|
||
required String fandomId,
|
||
required String planetType,
|
||
required String colorKey,
|
||
}) async {}
|
||
|
||
@override
|
||
// 브리핑 표본이 `sourceAt` 에 DateTime 을 담아 const 로 둘 수 없다.
|
||
Future<HomeSnapshot> load() async => HomeSnapshot(
|
||
popBalance: 12540,
|
||
fandomId: 'bts',
|
||
liveTicker: '[LIVE] 아미보이님의 아이템 사용으로 버프가 발동되었습니다!',
|
||
growth: PlanetGrowth(
|
||
fandomName: 'BTS',
|
||
contributorName: 'BTS_Hong',
|
||
contributedPop: 1230,
|
||
growthPercent: 67,
|
||
currentPop: 6270,
|
||
requiredPop: 10000,
|
||
),
|
||
// 사본 「홈」 `2097:16428` 표본 문구.
|
||
quests: const [
|
||
QuestSummary(
|
||
title: '출석체크',
|
||
category: '오늘의 출석체크',
|
||
type: QuestType.attend,
|
||
// 서버 `config/policy.attendPop` 기본값과 맞춘다. Figma 의 50 이 아니다.
|
||
rewardPop: 10,
|
||
state: QuestState.claimable,
|
||
),
|
||
QuestSummary(
|
||
title: '좋아요',
|
||
category: '커뮤니티',
|
||
rewardPop: 5,
|
||
state: QuestState.inProgress,
|
||
),
|
||
QuestSummary(
|
||
title: '댓글',
|
||
category: '커뮤니티',
|
||
rewardPop: 5,
|
||
state: QuestState.inProgress,
|
||
),
|
||
],
|
||
briefings: _sampleBriefings,
|
||
campaigns: const [
|
||
CampaignHighlight(
|
||
authorName: '다이브짱',
|
||
achievedPercent: 92,
|
||
title: '강남역 초대형 LED 광고',
|
||
description: 'BTS 데뷔 기념 팬 연합의 초대형 프로젝트입니다. 도심을 BTS의 색으로 물들여주세요.',
|
||
supporters: 48,
|
||
comments: 24,
|
||
likes: 48,
|
||
),
|
||
],
|
||
nebulas: const [
|
||
NebulaPick(id: 'nebula-rm', name: 'BTS_RM', members: 1280, percent: 98),
|
||
NebulaPick(id: 'nebula-jin', name: 'BTS_JIN', members: 940, percent: 81),
|
||
NebulaPick(
|
||
id: 'nebula-suga',
|
||
name: 'BTS_SUGA',
|
||
members: 610,
|
||
percent: 64,
|
||
),
|
||
],
|
||
// 사본 `아이템 구입` 표본(Admin 값 아님).
|
||
items: const [
|
||
ItemProduct(
|
||
name: '베이직 드랍',
|
||
description: '소소하지만 확실한 팬덤 기여',
|
||
dropPop: 500,
|
||
buyerPop: 100,
|
||
priceWon: 1200,
|
||
owned: 11,
|
||
),
|
||
ItemProduct(
|
||
name: '메가 드랍',
|
||
description: '팬덤의 열기를 달구는 강력한 화력',
|
||
dropPop: 2000,
|
||
buyerPop: 500,
|
||
priceWon: 3900,
|
||
owned: 10,
|
||
),
|
||
ItemProduct(
|
||
name: '에픽 드랍',
|
||
description: '전설적인 영웅의 등장, 압도적 물량',
|
||
dropPop: 5000,
|
||
buyerPop: 1500,
|
||
priceWon: 8900,
|
||
owned: 10,
|
||
),
|
||
],
|
||
insides: SampleInsideRepository.samples.take(3).toList(),
|
||
);
|
||
}
|
||
|
||
/// 브리핑 표본 — **정책값이 아니다.** B-75 5 요소를 전건 채워 두어 화면이 무엇을
|
||
/// 받게 되는지 보인다. News 2 건 · SNS 1 건으로 구획 분기도 함께 확인된다.
|
||
final List<BriefingItem> _sampleBriefings = [
|
||
BriefingItem(
|
||
rank: 1,
|
||
section: BriefingSection.news,
|
||
headline: 'NewJeans, 빌보드 ‘Women in Music’ 어워드 올해의 그룹상 수상 확정',
|
||
sourceName: '연합뉴스',
|
||
sourceAt: DateTime(2026, 9, 14, 8, 20),
|
||
sourceUrl: 'https://example.com/news/newjeans-billboard',
|
||
articleUrl: 'https://example.com/news/newjeans-billboard',
|
||
aiSummary: '빌보드가 올해의 그룹상 수상자를 확정해 발표했습니다.',
|
||
),
|
||
BriefingItem(
|
||
rank: 2,
|
||
section: BriefingSection.news,
|
||
headline: 'NewJeans, ‘Super Shy’, 스포티파이 글로벌 차트 장기 집권 중',
|
||
sourceName: '스포티파이 차트',
|
||
sourceAt: DateTime(2026, 9, 14, 7, 5),
|
||
sourceUrl: 'https://example.com/news/super-shy-chart',
|
||
articleUrl: 'https://example.com/news/super-shy-chart',
|
||
aiSummary: '글로벌 차트 상위권을 여러 주 유지하고 있습니다.',
|
||
),
|
||
BriefingItem(
|
||
rank: 1,
|
||
section: BriefingSection.sns,
|
||
headline: '버니즈를 위한 도쿄돔 팬미팅 비하인드 독점 공개 예정',
|
||
sourceName: '공식 X 계정',
|
||
sourceAt: DateTime(2026, 9, 14, 6, 40),
|
||
sourceUrl: 'https://example.com/sns/tokyo-dome',
|
||
articleUrl: 'https://example.com/sns/tokyo-dome',
|
||
aiSummary: '팬미팅 비하인드 영상 공개 일정이 예고되었습니다.',
|
||
),
|
||
];
|