/// 홈 화면이 그리는 값의 형태. /// /// **화면과 데이터를 갈라 둔 이유** — 서버가 아직 없다. Firestore 연동은 5주차부터이고 /// 그때 [HomeRepository] 구현체만 갈아 끼우면 화면은 손대지 않는다. 지금 화면 안에 /// 숫자를 박아 두면 그 시점에 화면을 다시 짜야 한다. /// /// 값의 근거는 Figma `03_Wireframe` 「홈」(`252:159354`)이다. 시연용 표본이며 /// 정책값이 아니다 — 정책은 `정책 설계서 최종본 v1.0` 이 정한다. library; import '../../core/i18n/app_locale.dart'; /// 팬덤 행성의 성장 상태. 정책 근거는 정책 설계서 §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; 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, }); final String title; /// 보상 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, }); /// 구획 안에서의 순서. 구획마다 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; } /// HOT ARCHIVE — 진행 중인 캠페인 한 건. class CampaignHighlight { const CampaignHighlight({ required this.authorName, required this.achievedPercent, required this.title, required this.description, required this.supporters, required this.comments, required this.likes, }); final String authorName; final int achievedPercent; final String title; final String description; final int supporters; final int comments; final int likes; } /// 인기 K-INSIDE 한 건. class InsideDigest { const InsideDigest({ required this.authorName, required this.timeAgo, required this.bountyPop, required this.title, required this.body, required this.answers, }); final String authorName; final String timeAgo; /// 현상금. 정책 설계서 §P-11 — 채택 기한은 아직 미확정이다. final int bountyPop; final String title; final String body; final int answers; } /// 홈 한 화면이 필요로 하는 것 전부. class HomeSnapshot { const HomeSnapshot({ required this.popBalance, required this.liveTicker, this.fandomId = '', required this.growth, required this.quests, required this.briefings, required this.campaign, required this.insides, this.items = const [], }); /// 활동 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 quests; final List briefings; final CampaignHighlight? campaign; final List insides; /// 헤더 지갑 · ZAP 「아이템」 시트의 상품. Admin Catalog(B-84 · B-85) /// `heroGiftProducts` 에서 온다. final List items; } /// 아이템(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 load(); Stream watchGrowth(String fandomId); /// 성운(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 createPlanet({ required String fandomId, required String planetType, required String colorKey, }); /// 성운 전환 목표 — 서버(`fn-nebula-create` · `fn-planet-create`)와 같은 `config/policy` /// 키(`nebulaMinPop` · `nebulaMinMembers` · `nebulaDays`). 읽지 못하면 [defaultNebulaTargets]. Future nebulaTargets(); } /// 성운 전환 목표 — 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? 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 watchGrowth(String fandomId) => const Stream.empty(); @override Future nebulaTargets() async => defaultNebulaTargets; @override Future<({String fandomId, bool primaryChanged})> createNebula( String name, ) async => (fandomId: 'bts', primaryChanged: true); @override Future createPlanet({ required String fandomId, required String planetType, required String colorKey, }) async {} @override // 브리핑 표본이 `sourceAt` 에 DateTime 을 담아 const 로 둘 수 없다. Future load() async => HomeSnapshot( popBalance: 12540, fandomId: 'bts', liveTicker: '[LIVE] 아미보이님의 아이템 사용으로 버프가 발동되었습니다!', growth: PlanetGrowth( fandomName: 'BTS', contributorName: 'BTS_Hong', contributedPop: 1230, growthPercent: 67, currentPop: 6270, requiredPop: 10000, ), quests: [ QuestSummary( title: '오늘의 출석체크', // 서버 `config/policy.attendPop` 기본값과 맞춘다. Figma 의 50 이 아니다. rewardPop: 10, state: QuestState.claimable, ), QuestSummary( title: '만보기', rewardPop: 150, state: QuestState.inProgress, progressLabel: '0/1,500 걸음', ), ], briefings: _sampleBriefings, campaign: CampaignHighlight( authorName: '다이브짱', achievedPercent: 92, title: '강남역 초대형 LED 광고', description: 'BTS 데뷔 기념 팬 연합의 초대형 프로젝트입니다. 도심을 BTS의 색으로 물들여주세요.', supporters: 48, comments: 24, likes: 82, ), // 사본 `아이템 구입` 표본(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: [ InsideDigest( authorName: '아미보이', timeAgo: '10분 전', bountyPop: 500, title: '화양연화 세계관 해석해주실 분?', body: '이번 뮤비랑 연결되는 부분이 헷갈리네요. 타임라인 정리해주실 천재 아미 구합니다!', answers: 3, ), InsideDigest( authorName: '다이브짱', timeAgo: '1시간 전', bountyPop: 200, title: '공방 신청 꿀팁 공유합니다.', body: '공방 신청할 때 폼 가장 빨리 제출하는 손크로 팁 알려드립! 궁금하신 분 질문 남겨주세요', answers: 8, ), InsideDigest( authorName: '네누', timeAgo: '3시간 전', bountyPop: 100, title: 'BTS 이 포즈 뭔가요?', body: '아미 분들 이 포즈 뭔지 알려주실 수 있나요?', answers: 8, ), ], ); } /// 브리핑 표본 — **정책값이 아니다.** B-75 5 요소를 전건 채워 두어 화면이 무엇을 /// 받게 되는지 보인다. News 2 건 · SNS 1 건으로 구획 분기도 함께 확인된다. final List _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: '팬미팅 비하인드 영상 공개 일정이 예고되었습니다.', ), ];