// 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/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 탭 루트 — 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 State createState() => _HomeScreenState(); } class _HomeScreenState extends State { late Future _snapshot; Future? _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 _createThenReload(Widget screen) async { final created = await Navigator.of(context, rootNavigator: true).push( MaterialPageRoute(builder: (_) => screen), ); if (created == null || !mounted) return; if (widget.galaxyRepository case final repository?) { setState(() { _galaxy = repository.load(); }); } await _refresh(); } Future _refresh() async { final next = widget.repository.load(); setState(() => _snapshot = next); await next; } @override Widget build(BuildContext context) => Scaffold( // 시안은 헤더가 우주 배경 위에 얹힌다. AppBar 를 쓰면 배경이 헤더 아래에서 // 끊기므로 본문 안에 헤더 행을 그리고 Scaffold 는 배경만 준다. body: FutureBuilder( 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>( key: ValueKey('live-$fandomId'), // 띠는 한 줄만 그리므로 최신 몇 건만 받는다. create: () => fandomId.isEmpty ? Stream.value(const []) : 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 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( 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 Function(Widget screen) onCreate; final NotificationRepository notifications; final HomeSnapshot data; final Future? galaxy; final ZapController? zap; final HomeRepository repository; final PlanetWatch watchPlanet; final PlanetRepositoryFactory? planetRepository; final EclipseRepository eclipse; final Future 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( key: ValueKey((repository, data.fandomId)), create: () => repository.watchGrowth(data.fandomId), builder: (context, growth) => FutureBuilder( 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( 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( builder: (_) => PlanetDetailScreen( repository: planetRepository!(data.fandomId), watchPlanet: watchPlanet, eclipse: eclipse, ), ), ), onOpenHistory: planetRepository == null ? null : () => Navigator.of(context).push( MaterialPageRoute( 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( 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( 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 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( builder: (_) => CampaignListScreen(repository: repository), ), ); } /// 홈의 오늘의 퀘스트에서 퀘스트 목록으로 — 화면 대조 원장 F1 → F4. /// /// 보유 POP 을 함께 넘긴다. 목록 헤더가 같은 값을 다시 읽으면 왕복이 하나 늘고, /// 두 화면의 숫자가 한순간 어긋난다. void _openQuests( BuildContext context, QuestRepository repository, int popBalance, ) { Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( builder: (_) => QuestListScreen(repository: repository, myPop: popBalance), ), ); }