HOME · GALAXY · 행성·성운 상세 · 팬덤 라운지 · 이클립스 화면

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent c4e9a9430e
commit 5de04ac281
44 changed files with 14898 additions and 16 deletions
+687 -8
View File
@@ -1,15 +1,694 @@
// formatPop 은 core/format.dart 로 옮겼다. 기존 `show formatPop` 가져오기를 위해 다시 내보낸다.
export '../../core/format.dart' show formatPop;
import '../planet/planet_history_screen.dart';
import '../galaxy/widgets/galaxy_mini_map.dart';
import '../galaxy/galaxy_screen.dart';
import '../galaxy/galaxy_data.dart';
import '../galaxy/zap_controller.dart';
import '../planet/hero_participation_sheet.dart';
import '../planet/planet_data.dart';
import '../planet/planet_detail_screen.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../core/ui/tab_placeholder.dart';
import '../../core/format.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
import '../../core/data/planet_visual_state.dart';
import '../../core/ui/live_stream.dart';
import '../../core/ui/section_header.dart';
import '../eclipse/eclipse_data.dart';
import '../eclipse/widgets/eclipse_banner.dart';
import '../eclipse/widgets/live_proof.dart';
import 'home_data.dart';
import 'nebula_create_screen.dart';
import 'planet_create_screen.dart';
import 'widgets/briefing_list.dart';
import 'widgets/campaign_card.dart';
import 'widgets/home_hero.dart';
import 'widgets/item_sheet.dart';
import 'widgets/inside_card.dart';
import 'widgets/live_ticker.dart';
import '../campaign/campaign_data.dart' show CampaignRepository;
import '../campaign/campaign_list_screen.dart';
import '../notification/notification_screen.dart';
import '../quest/quest_data.dart' show QuestRepository;
import '../quest/quest_list_screen.dart';
import '../quest/quest_detail_screen.dart';
import 'home_repository_firestore.dart' show kstDayKey;
import 'widgets/quest_row.dart';
import 'widgets/briefing_share.dart';
/// HOME 탭 루트 — S-H-HOME 오늘의 퀘스트.
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
/// HOME 탭 루트 — Figma `03_Wireframe` 「홈」(`252:159354`).
///
/// 화면 프레임은 `252:159354` 다. 그 아래 `252:159361` 이하가 이 화면의 구성
/// 요소이며, 화면 대조 원장은 프레임 번호로 화면을 셈한다.
///
/// 화면 대조 원장 F1 의 1번 화면이다. 구성 순서는 Figma 를 따른다 —
/// 잔액 · 라이브 띠 · 행성 성장 · TODAY'S QUEST · AI Daily Briefing ·
/// HOT ARCHIVE · 인기 K-INSIDE.
///
/// 값은 [HomeRepository] 가 준다. 지금은 [SampleHomeRepository] 라 서버 없이 뜨고,
/// 5주차 Firestore 연동 때 구현체만 바꾼다.
class HomeScreen extends StatefulWidget {
const HomeScreen({
super.key,
this.watchPlanet = noPlanetWatch,
this.planetRepository,
this.galaxyRepository,
required this.repository,
required this.questRepository,
required this.eclipse,
this.notifications = const SampleNotificationRepository(),
this.campaignRepository,
});
final HomeRepository repository;
final PlanetWatch watchPlanet;
final PlanetRepositoryFactory? planetRepository;
final GalaxyRepository? galaxyRepository;
/// F4 로 넘겨줄 저장소. 홈이 퀘스트 화면을 여는 유일한 입구라, 여기서 받지 않으면
/// 퀘스트 화면이 스스로 표본을 만들게 된다(저장소 선택은 repositories.dart 한 곳).
final QuestRepository questRepository;
/// HOT ARCHIVE 로 넘겨줄 캠페인 저장소. null 이면 목록이 표본을 쓴다.
final CampaignRepository? campaignRepository;
/// 이클립스 경보 배너와 라이브 띠의 원천. 저장소 선택은 [Repositories] 한 곳에서만
/// 하므로(공통 규칙) 화면이 표본을 기본값으로 만들지 않는다.
final EclipseRepository eclipse;
/// 헤더의 알림 입구가 여는 알림함(F0)의 저장소.
final NotificationRepository notifications;
@override
Widget build(BuildContext context) => const TabPlaceholder(
tab: 'HOME',
screenId: 'S-H-HOME',
screenName: '오늘의 퀘스트',
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
late Future<HomeSnapshot> _snapshot;
Future<GalaxySnapshot>? _galaxy;
ZapController? _zap;
void _onZapChanged() => setState(() {});
@override
void dispose() {
_zap?.dispose();
super.dispose();
}
@override
void initState() {
super.initState();
_snapshot = widget.repository.load();
if (widget.galaxyRepository case final repository?) {
_galaxy = repository.load();
_zap = ZapController(
repository,
onFailure: (message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
},
)..addListener(_onZapChanged);
}
}
/// 성운·행성 생성 화면은 성공하면 팬덤 ID 를 들고 닫힌다. 그때만 다시 읽는다.
Future<void> _createThenReload(Widget screen) async {
final created = await Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<String>(builder: (_) => screen),
);
if (created == null || !mounted) return;
if (widget.galaxyRepository case final repository?) {
setState(() { _galaxy = repository.load(); });
}
await _refresh();
}
Future<void> _refresh() async {
final next = widget.repository.load();
setState(() => _snapshot = next);
await next;
}
@override
Widget build(BuildContext context) => Scaffold(
// 시안은 헤더가 우주 배경 위에 얹힌다. AppBar 를 쓰면 배경이 헤더 아래에서
// 끊기므로 본문 안에 헤더 행을 그리고 Scaffold 는 배경만 준다.
body: FutureBuilder<HomeSnapshot>(
future: _snapshot,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
// 원인이 화면에 보이지 않으므로 로그로 남긴다 — adb logcat | grep '\[HOME\]'
debugPrint('[HOME] load failed: ${snap.error}');
return SafeArea(child: _LoadFailed(onRetry: _refresh));
}
return _HomeBody(
data: snap.data!,
galaxy: _galaxy,
zap: _zap,
repository: widget.repository,
watchPlanet: widget.watchPlanet,
planetRepository: widget.planetRepository,
onRefresh: _refresh,
onCreate: _createThenReload,
questRepository: widget.questRepository,
campaignRepository: widget.campaignRepository,
eclipse: widget.eclipse,
notifications: widget.notifications,
);
},
),
);
}
/// 히어로 위에 얹는 헤더 — Figma 홈 상단 「Fancake 로고 · POP 알약 · 알림」.
/// 브랜드는 공통 기준 4항대로 FANUZU 다.
/// 라이브 띠 한 자리. 스트림에 값이 오면 그 문구를, 없으면 폴백을 그린다.
///
/// 폴백을 남겨 둔 이유는 시드 데이터로 도는 목 모드와 옛 문서 때문이다. 둘 다
/// 없으면 띠 자체를 접는다 — 빈 알약이 홈 상단에 남으면 고장으로 읽힌다.
class _LiveTickerSlot extends StatelessWidget {
const _LiveTickerSlot({
required this.eclipse,
required this.fandomId,
required this.fallback,
});
final EclipseRepository eclipse;
final String fandomId;
final String? fallback;
@override
Widget build(BuildContext context) => LiveStreamBuilder<List<LiveEvent>>(
key: ValueKey('live-$fandomId'),
// 띠는 한 줄만 그리므로 최신 몇 건만 받는다.
create: () => fandomId.isEmpty
? Stream.value(const <LiveEvent>[])
: eclipse.liveEvents(fandomId: fandomId, limit: 5),
builder: (context, events) {
final latest = (events == null || events.isEmpty)
? fallback
: liveEventMessage(events.first);
if (latest == null) return const SizedBox.shrink();
return LiveTicker(text: latest);
},
);
}
/// 홈 헤더 — 성장 완료 · 첫 사용자 상태 화면도 같은 헤더를 쓴다.
class HomeHeader extends StatelessWidget {
const HomeHeader({
super.key,
required this.pop,
this.notifications = const SampleNotificationRepository(),
this.items = const [],
this.fandomId = '',
});
final int pop;
final NotificationRepository notifications;
/// 소속 팬덤. 비었으면 Hero Gift 결제 창을 열지 않는다(Drop 을 열 곳이 없다).
final String fandomId;
/// 지갑 아이콘이 여는 「아이템 구입」 시트의 상품.
final List<ItemProduct> items;
@override
Widget build(BuildContext context) => Padding(
// 사본 `Top Navigation` — 높이 54 · 패딩 6/16 · 오른쪽 묶음은 알약 + 42 아이콘 두 개.
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4, vertical: 6),
child: SizedBox(
height: 42,
child: Row(
children: [
const Text(
'FANUZU',
style: TextStyle(
fontFamily: 'Pretendard',
fontSize: 20,
fontWeight: FontWeight.w900,
height: 1.4,
letterSpacing: -0.5,
color: AppColors.cosmicAccent,
),
),
const Spacer(),
_PopBalance(value: pop),
IconButton(
constraints: const BoxConstraints.tightFor(width: 42, height: 42),
padding: EdgeInsets.zero,
icon: const Image(
image: AssetImage('assets/figma/icon-wallet.png'),
width: 42,
height: 42,
),
// 사본 `252:159367` — 홈 주 프레임에 열린 「아이템 구입」 시트.
onPressed: () =>
showItemSheet(
context,
items: items,
purchase: true,
fandomId: fandomId,
),
tooltip: context.l10n.homeItemsTooltip,
),
IconButton(
constraints: const BoxConstraints.tightFor(width: 42, height: 42),
padding: EdgeInsets.zero,
iconSize: 24,
icon: const Icon(
Icons.notifications_none,
color: AppColors.onCosmic,
),
// 알림 — 화면 대조 원장 F0 · `229:11265`.
onPressed: () => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => NotificationScreen(repository: notifications),
),
),
tooltip: context.l10n.homeNotificationsTooltip,
),
],
),
),
);
}
class _HomeBody extends StatelessWidget {
const _HomeBody({
required this.watchPlanet,
required this.galaxy,
required this.zap,
required this.planetRepository,
required this.data,
required this.repository,
required this.onRefresh,
required this.onCreate,
required this.questRepository,
required this.campaignRepository,
required this.eclipse,
required this.notifications,
});
/// 성운·행성 생성 화면을 열고, 만들어졌으면 HOME 을 다시 읽는다.
final Future<void> Function(Widget screen) onCreate;
final NotificationRepository notifications;
final HomeSnapshot data;
final Future<GalaxySnapshot>? galaxy;
final ZapController? zap;
final HomeRepository repository;
final PlanetWatch watchPlanet;
final PlanetRepositoryFactory? planetRepository;
final EclipseRepository eclipse;
final Future<void> Function() onRefresh;
/// F4 로 들고 갈 저장소. 홈은 값만 옮기고 어느 구현인지는 모른다.
final QuestRepository questRepository;
final CampaignRepository? campaignRepository;
/// 섹션은 ListView 의 직계 자식으로 둔다. 하나의 Column 으로 감싸면 첫 프레임에
/// 전부 빌드되어 지연 렌더링을 잃고, 테스트의 scrollUntilVisible 도 「이미 보인다」고
/// 오판한다. 좌우 여백은 자식마다 준다.
static const _h = EdgeInsets.symmetric(horizontal: AppSpace.s4);
/// 사본 `Frame 4241` — 구획 사이 36, 구획 머리와 본문 사이 16.
static const _sectionGap = 36.0;
static const _headerGap = AppSpace.s4;
@override
Widget build(BuildContext context) {
final top = MediaQuery.paddingOf(context).top;
return RefreshIndicator(
onRefresh: onRefresh,
child: ListView(
padding: const EdgeInsets.only(bottom: AppSpace.s7),
children: [
// 히어로는 상태 표시줄 아래까지 배경이 이어져야 하므로 SafeArea 를 쓰지 않고
// 헤더 행에 상단 여백을 직접 준다.
Stack(
children: [
LiveStreamBuilder<PlanetGrowth>(
key: ValueKey((repository, data.fandomId)),
create: () => repository.watchGrowth(data.fandomId),
builder: (context, growth) => FutureBuilder<GalaxySnapshot>(
future: galaxy,
builder: (context, galaxyState) => HomeHero(
zap: zap,
zapData: galaxyState.data,
miniMap:
galaxyState.data == null ||
planetRepository == null ||
zap == null
? null
: GalaxyMiniMap(
size: 140,
data: galaxyState.data!,
onOpen: () =>
Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => GalaxyScreen(
repository: zap!.repository,
planetRepository: planetRepository!,
watchPlanet: watchPlanet,
eclipse: eclipse,
),
),
),
),
// 라이브 띠 — `fandoms/{id}/liveEvents` 를 구독한다. 이벤트가 아직 없으면
// 스냅샷의 `liveTicker` 문자열로 물러난다(폴백).
ticker: _LiveTickerSlot(
eclipse: eclipse,
fandomId: data.fandomId,
fallback: data.liveTicker,
),
growth: growth ?? data.growth,
fandomId: data.fandomId,
unjoined: data.fandomId.isEmpty,
watchPlanet: watchPlanet,
onOpenPlanet:
planetRepository == null || data.fandomId.isEmpty
? null
: () => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => PlanetDetailScreen(
repository: planetRepository!(data.fandomId),
watchPlanet: watchPlanet,
eclipse: eclipse,
),
),
),
onOpenHistory: planetRepository == null
? null
: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => PlanetHistoryScreen(
repository: planetRepository!(data.fandomId),
fandomName:
galaxyState.data?.mine?.name ??
context.l10n.homeFandomHistory,
),
),
),
// 만들고 돌아오면 HOME 이 새 성운/행성(내 Primary)을 다시 그린다.
onBirth: () => onCreate(
PlanetCreateScreen(
nebulaName: (growth ?? data.growth).fandomName,
fandomId: data.fandomId,
repository: repository,
),
),
onCreateNebula: () => onCreate(
NebulaCreateScreen(
repository: repository,
myPop: data.popBalance,
),
),
// 행성 가입은 GALAXY 에서 한다(사본 시트 「행성 가입」).
onJoin: () => GoRouter.maybeOf(context)?.go('/galaxy'),
onItem: () => showItemSheet(
context,
items: data.items,
purchase: false,
),
onHeroParticipate:
planetRepository == null || data.fandomId.isEmpty
? null
: () {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => HeroParticipationSheet(
fandomId: data.fandomId,
watch: watchPlanet,
repository: planetRepository!(data.fandomId),
),
);
},
),
),
),
Positioned(
top: top,
left: 0,
right: 0,
child: HomeHeader(
pop: zap?.balance ?? data.popBalance,
notifications: notifications,
items: data.items,
fandomId: data.fandomId,
),
),
],
),
// 이클립스 인앱 경보 — 별첨 1 ① 예외 처리 「푸시 미수신 단말 — 인앱
// 워닝 배너 병행」. 활성 이클립스가 없으면 자리도 차지하지 않는다.
if (data.fandomId.isNotEmpty)
EclipseBanner(
repository: eclipse,
fandomId: data.fandomId,
// 히어로 아래 24 는 히어로 상자(ZAP 걸침 몫)가 이미 준다. 구획 사이는 36(`Frame 4241`).
margin: const EdgeInsets.fromLTRB(
AppSpace.s4,
0,
AppSpace.s4,
_sectionGap,
),
),
Padding(
padding: _h,
child: SectionHeader(
title: 'TODAY’S QUEST',
onMore: () =>
_openQuests(context, questRepository, data.popBalance),
),
),
const SizedBox(height: _headerGap),
// 사본 카드(196×137)를 가로로 늘어놓는다(`Frame 1984081088` · 간격 8).
SizedBox(
// 글자 확대 시 잘리지 않게 글자 몫(22+6+20+28)만 배율을 따른다.
height: 12 * 2 + 37 + MediaQuery.textScalerOf(context).scale(76),
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: _h,
itemCount: data.quests.length,
separatorBuilder: (_, _) => const SizedBox(width: AppSpace.s2),
itemBuilder: (context, i) => SizedBox(
width: QuestCard.width,
child: QuestCard(
quest: data.quests[i],
// 타임어택은 목록 첫 페이지에 든다는 보장이 없어 상세로 바로 간다.
onTap: data.quests[i].endsAt == null
? () =>
_openQuests(context, questRepository, data.popBalance)
: () => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => QuestDetailScreen(
questId: data.quests[i].id,
repository: questRepository,
),
),
),
),
),
),
),
const SizedBox(height: _sectionGap),
// 0 건이면 헤더까지 그리지 않는다 — 정책 원장 B-47 「News·SNS 모두 0건 시
// Briefing 블록 전체 숨김(빈 상태 문구도 노출하지 않음)」.
if (data.briefings.isNotEmpty) ...[
Padding(
padding: _h,
child: SectionHeader(
title: 'AI Daily Briefing',
// 「홈_브리핑 공유」 — 이 자리가 공유 버튼이다(`266:90529` · 명세 `179:29117`).
moreLabel: context.l10n.commonShare,
onMore: () => BriefingShareDialog.show(
context,
items: data.briefings,
fandomName: data.growth.fandomName,
link: briefingShareLink(data.fandomId, kstDayKey()),
),
),
),
const SizedBox(height: _headerGap),
Padding(
padding: _h,
child: BriefingList(items: data.briefings),
),
const SizedBox(height: _sectionGap),
],
if (data.campaign case final c?) ...[
Padding(
padding: _h,
child: SectionHeader(
title: 'HOT ARCHIVE',
onMore: () => _openCampaigns(context, campaignRepository),
),
),
const SizedBox(height: _headerGap),
Padding(
padding: _h,
child: CampaignCard(
campaign: c,
onTap: () => _openCampaigns(context, campaignRepository),
),
),
const SizedBox(height: _sectionGap),
],
Padding(
padding: _h,
child: SectionHeader(
title: context.l10n.homePopularInside,
onMore: () => _notYet(context, context.l10n.homeWhereInsideList),
),
),
const SizedBox(height: _headerGap),
for (final i in data.insides) ...[
Padding(
padding: _h,
child: InsideCard(
digest: i,
onTap: () =>
_notYet(context, context.l10n.homeWhereInsideDetail),
),
),
const SizedBox(height: AppSpace.s3),
],
],
),
);
}
}
/// 상단 활동 POP 잔액. 정책 설계서 §P-02 — 화면 표기는 「활동/지원」이다.
class _PopBalance extends StatelessWidget {
const _PopBalance({this.value});
final int? value;
@override
Widget build(BuildContext context) => Container(
// 사본 `Frame 1984081119` — 민트 10% 알약 · 1 테두리 · 패딩 4/8 · 간격 5.
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s2, vertical: 4),
decoration: BoxDecoration(
color: AppColors.cosmicMint.withValues(alpha: 0.1),
border: Border.all(color: AppColors.cosmicMint),
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Image(
image: AssetImage('assets/figma/icon-bolt.png'),
width: 10,
height: 10.4,
),
const SizedBox(width: 5),
Text(
value == null ? '—' : formatPop(value!),
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 12,
fontWeight: FontWeight.w600,
height: 1.4,
letterSpacing: -0.3,
color: AppColors.cosmicMint,
),
),
],
),
);
}
class _LoadFailed extends StatelessWidget {
const _LoadFailed({required this.onRetry});
final Future<void> Function() onRetry;
@override
Widget build(BuildContext context) {
final text = Theme.of(context).textTheme;
return Center(
child: Padding(
padding: const EdgeInsets.all(AppSpace.s5),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(context.l10n.homeLoadFailed, style: text.titleMedium),
const SizedBox(height: AppSpace.s2),
Text(
context.l10n.homeRetryLater,
style: text.bodySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: AppSpace.s4),
FilledButton(
onPressed: onRetry,
child: Text(context.l10n.commonRetry),
),
],
),
),
);
}
}
/// 아직 라우트가 없는 곳으로 가려 할 때.
///
/// 조용히 무시하면 검수 때 「눌러도 아무 일이 없다」로 보인다. 무엇이 언제
/// 열리는지 알려 주는 편이 낫다.
void _notYet(BuildContext context, String where) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(context.l10n.commonNotYet(where)),
behavior: SnackBarBehavior.floating,
),
);
}
/// 홈의 HOT ARCHIVE 에서 캠페인 목록으로 — 화면 대조 원장 F1 → F5.
///
/// 홈 카드에는 캠페인 ID 가 없다([CampaignHighlight] 는 표시용 요약이다).
/// 상세로 바로 보내려면 ID 를 홈 데이터에 넣어야 하는데, 그러면 홈이 캠페인
/// 스키마를 알게 된다. 목록을 거쳐 들어간다.
void _openCampaigns(BuildContext context, CampaignRepository? repository) {
Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => CampaignListScreen(repository: repository),
),
);
}
/// 홈의 오늘의 퀘스트에서 퀘스트 목록으로 — 화면 대조 원장 F1 → F4.
///
/// 보유 POP 을 함께 넘긴다. 목록 헤더가 같은 값을 다시 읽으면 왕복이 하나 늘고,
/// 두 화면의 숫자가 한순간 어긋난다.
void _openQuests(
BuildContext context,
QuestRepository repository,
int popBalance,
) {
Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) =>
QuestListScreen(repository: repository, myPop: popBalance),
),
);
}