Files
fanuzu/app/lib/features/home/home_data.dart
T

603 lines
22 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 홈 화면이 그리는 값의 형태.
///
/// **화면과 데이터를 갈라 둔 이유** — 서버가 아직 없다. 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 [],
});
/// 활동 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;
}
/// 아이템(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: '팬미팅 비하인드 영상 공개 일정이 예고되었습니다.',
),
];