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

1085 lines
44 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.
// formatPop 은 core/format.dart 로 옮겼다. 기존 `show formatPop` 가져오기를 위해 다시 내보낸다.
export '../../core/format.dart' show formatPop;
import 'coach_mark.dart';
import '../planet/planet_history_screen.dart';
import '../galaxy/widgets/galaxy_web_view.dart';
import '../galaxy/galaxy_search_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/push/push_messaging.dart' show liveTickerKey;
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/sheet_route.dart';
import '../../core/ui/app_icons.dart';
import '../../core/data/planet_visual_state.dart';
import '../../core/ui/live_stream.dart';
import '../eclipse/eclipse_data.dart';
import '../eclipse/widgets/eclipse_event_button.dart';
import '../eclipse/widgets/live_proof.dart';
import 'home_data.dart';
import 'nebula_create_screen.dart';
import 'planet_create_screen.dart';
import 'widgets/home_weekly.dart';
import 'widgets/briefing_list.dart';
import 'widgets/home_hero.dart';
import 'widgets/item_sheet.dart';
import 'widgets/home_sections.dart';
import '../../core/router/app_router.dart' show appTabs;
import '../inside/inside_data.dart' show InsideRepository;
import '../inside/inside_detail_screen.dart';
import '../campaign/campaign_detail_screen.dart';
import 'widgets/live_ticker.dart';
import '../campaign/campaign_data.dart' show CampaignRepository;
import '../campaign/campaign_create_screen.dart';
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 '../quest/quest_reward_screen.dart' show questPlanetReaction;
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 연동 때 구현체만 바꾼다.
/// HOME 을 다시 읽으라는 신호 — 포그라운드 Push 수신 · 위에 띄운 화면이 닫힘 · HOME 탭 복귀.
/// HOME 은 진입 때 한 번 읽어서 타임어택 발동 Push · 후원 뒤에도 카드가 낡아 있었다(10/6 실기기).
/// ponytail: 어느 화면이 닫혀도 HOME 을 한 번 다시 읽는다. 읽기 비용이 문제면 HOME 이 보일 때만.
final ValueNotifier<int> homeRefreshSignal = ValueNotifier(0);
/// 페이지(시트 · 대화상자 제외)가 닫히면 [homeRefreshSignal] 을 울린다. 루트 · HOME 탭 내비게이터에 하나씩.
class HomeRefreshObserver extends NavigatorObserver {
@override
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
if (route is PageRoute) homeRefreshSignal.value++;
}
}
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,
this.insideRepository,
});
final HomeRepository repository;
/// 인기 K-INSIDE 카드가 여는 상세의 저장소.
final InsideRepository? insideRepository;
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<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
late Future<HomeSnapshot> _snapshot;
Future<GalaxySnapshot>? _galaxy;
ZapController? _zap;
/// 히어로가 「GALAXY 맵 확장」(`343:211127`) 상태인가. 미니맵 탭으로 열고, 「GALAXY」 탭 ·
/// 뒤로가기로 닫는다.
bool _mapOpen = false;
final _mapKey = GlobalKey(debugLabel: 'home-galaxy-map');
final _zapKey = GlobalKey(debugLabel: 'home-zap');
void _setMap(bool open) => setState(() => _mapOpen = open);
void _onZapChanged() => setState(() {});
/// 퀘스트 보상 확정 뒤 HOME 으로 돌아오면 행성이 반응한다(v1.5 4장).
void _questReaction() => _zap?.react();
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
homeRefreshSignal.removeListener(_refresh);
questPlanetReaction.removeListener(_questReaction);
_zap?.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) _refresh();
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
homeRefreshSignal.addListener(_refresh);
questPlanetReaction.addListener(_questReaction);
_snapshot = widget.repository.load();
if (widget.galaxyRepository case final repository?) {
_galaxy = repository.load();
// 온보딩을 마친 뒤 첫 HOME — ZAP 이 그려진 다음 Coach Mark(1회)를 띄운다.
Future.wait<Object?>([_snapshot, _galaxy!]).then((_) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) maybeShowCoachMark(context, _zapKey);
});
}, onError: (_) {});
_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(SheetRoute<String>(builder: (_) => screen));
if (created == null || !mounted) return;
if (widget.galaxyRepository case final repository?) {
setState(() {
_galaxy = repository.load();
});
}
await _refresh();
}
Future<void> _refresh() async {
if (!mounted) return;
final next = widget.repository.load();
setState(() => _snapshot = next);
await next;
}
@override
Widget build(BuildContext context) => PopScope(
canPop: !_mapOpen,
onPopInvokedWithResult: (didPop, _) {
if (!didPop && _mapOpen) _setMap(false);
},
child: _scaffold(context),
);
Widget _scaffold(BuildContext context) => Scaffold(
// 시안은 헤더가 우주 배경 위에 얹힌다. AppBar 를 쓰면 배경이 헤더 아래에서
// 끊기므로 본문 안에 헤더 행을 그리고 Scaffold 는 배경만 준다.
body: FutureBuilder<HomeSnapshot>(
future: _snapshot,
builder: (context, snap) {
// 다시 읽는 동안은 앞 데이터를 그대로 둔다 — 화면이 스피너로 깜빡이지 않게.
if (snap.connectionState != ConnectionState.done && !snap.hasData) {
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 _StatusBarScrim(
height: MediaQuery.paddingOf(context).top,
child: _HomeBody(
data: snap.data!,
galaxy: _galaxy,
zap: _zap,
mapOpen: _mapOpen,
mapKey: _mapKey,
zapKey: _zapKey,
onMap: _setMap,
insideRepository: widget.insideRepository,
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,
required this.fandomName,
required this.watchPlanet,
this.onHero,
});
final EclipseRepository eclipse;
final String fandomId;
final String? fallback;
final String fandomName;
/// HERO TIME 여부 · 공개 닉네임 — 행성 FX 와 같은 서버 확정 원천.
final PlanetWatch watchPlanet;
/// HERO TIME 줄을 눌렀을 때 — 이 행성의 진행 중 HERO TIME 참여로 바로 간다(v1.5 §6 「진입」).
final VoidCallback? onHero;
@override
Widget build(BuildContext context) => LiveStreamBuilder<EclipseState?>(
key: ValueKey('live-eclipse-$fandomId'),
// 명세 `516:87664` 이벤트2 「이클립스 이벤트 — 행성/성운명」(사본 `373:443187`
// 「BTS 행성에 이클립스가 발생했어요!」). 발생한 회차만 본다.
create: () => fandomId.isEmpty
? Stream<EclipseState?>.value(null)
: eclipse.watch(fandomId: fandomId),
builder: (context, eclipseState) => LiveStreamBuilder<PlanetVisualState>(
key: ValueKey('live-planet-$fandomId'),
create: () => fandomId.isEmpty
? const Stream<PlanetVisualState>.empty()
: watchPlanet(fandomId),
builder: (context, planet) => LiveStreamBuilder<List<LiveEvent>>(
key: ValueKey('live-$fandomId'),
// 띠는 한 줄만 그리므로 최신 몇 건만 받는다.
create: () => fandomId.isEmpty
? Stream.value(const <LiveEvent>[])
: eclipse.liveEvents(fandomId: fandomId, limit: 5),
builder: (context, events) {
final line = tickerLine(
eclipse: eclipseState,
heroTime: planet?.isHeroTime == true,
heroNicknames: planet?.heroNicknames ?? const [],
events: events ?? const [],
fandomName: fandomName,
now: DateTime.now(),
fallback: fallback,
);
// 앱 안 Push 중복 제어용 — 이 띠가 보이는 동안만 적는다(위에 화면이 덮이면 지운다).
liveTickerShowing =
line?.push == null || !(ModalRoute.isCurrentOf(context) ?? true)
? null
: liveTickerKey(line!.push!, fandomId);
if (line == null) return const SizedBox.shrink();
final hero = line.hero && onHero != null;
return LiveTicker(
text: line.text,
action: hero ? context.l10n.heroParticipate : null,
onTap: hero ? onHero : null,
);
},
),
),
);
}
/// 홈 헤더 — 성장 완료 · 첫 사용자 상태 화면도 같은 헤더를 쓴다.
class HomeHeader extends StatefulWidget {
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
State<HomeHeader> createState() => _HomeHeaderState();
}
class _HomeHeaderState extends State<HomeHeader> {
/// 안 읽은 알림이 있는가 — 설명 프레임 `2097:16385` 「알림 - 알림 아이콘 / 알림 상태값 ·
/// 알림 존재 시 배찌 알림으로 변경」. 알림함은 닫힐 때 읽음 상태를 돌려주므로(`518:88347`)
/// 돌아오면 다시 읽는다.
bool _unread = false;
@override
void initState() {
super.initState();
_checkUnread();
}
Future<void> _checkUnread() async {
try {
final items = await widget.notifications.load();
if (mounted) setState(() => _unread = items.any((i) => !i.read));
} catch (_) {
// 읽지 못하면 배지를 띄우지 않는다 — 알림함 자체는 열린다.
}
}
@override
Widget build(BuildContext context) {
final pop = widget.pop;
final items = widget.items;
final fandomId = widget.fandomId;
final notifications = widget.notifications;
return 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),
// 사본 `icon` 42 칸 — 테마 최소 크기(48)로 늘어나 POP 알약이 왼쪽으로 밀리지 않게.
style: IconButton.styleFrom(
minimumSize: const Size.square(42),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
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),
// 사본 `icon` 42 칸 — 테마 최소 크기(48)로 늘어나 POP 알약이 왼쪽으로 밀리지 않게.
style: IconButton.styleFrom(
minimumSize: const Size.square(42),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
padding: EdgeInsets.zero,
// 사본 `icon` 42 칸 안 lucide/bell 20.
iconSize: 20,
// 배지 모양은 Figma 에 그림이 없어 테마 기본 점 배지(강조색)를 쓴다.
icon: Badge(
key: const ValueKey('home-bell-badge'),
isLabelVisible: _unread,
smallSize: 6,
backgroundColor: AppColors.cosmicAccent,
child: const ImageIcon(
AppIcons.bell,
color: AppColors.onCosmic,
),
),
// 알림 — 화면 대조 원장 F0 · `229:11265`.
onPressed: () async {
await Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<Object?>(
builder: (_) =>
NotificationScreen(repository: notifications),
),
);
await _checkUnread();
},
tooltip: context.l10n.homeNotificationsTooltip,
),
],
),
),
);
}
}
class _HomeBody extends StatelessWidget {
const _HomeBody({
required this.watchPlanet,
required this.galaxy,
required this.zap,
required this.mapOpen,
required this.mapKey,
required this.zapKey,
required this.onMap,
required this.planetRepository,
required this.insideRepository,
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 bool mapOpen;
final GlobalKey mapKey;
/// 떠 있는 ZAP 버튼 — 기여 연출(히어로)이 이 자리에서 출발한다.
final GlobalKey zapKey;
final ValueChanged<bool> onMap;
/// 인기 K-INSIDE 상세를 열 저장소. null 이면 상세가 표본을 쓴다.
final InsideRepository? insideRepository;
final HomeRepository repository;
final PlanetWatch watchPlanet;
final PlanetRepositoryFactory? planetRepository;
final EclipseRepository eclipse;
final Future<void> Function() onRefresh;
/// F4 로 들고 갈 저장소. 홈은 값만 옮기고 어느 구현인지는 모른다.
final QuestRepository questRepository;
final CampaignRepository? campaignRepository;
/// HOME GALAXY 지도 — 미니맵(93pt)과 「GALAXY 맵 확장」 모두 이 하나다(같은 [mapKey]).
Widget? _map(BuildContext context, GalaxySnapshot? galaxy) =>
galaxy == null || planetRepository == null || zap == null
? null
: GalaxyWebView(
key: mapKey,
data: galaxy,
expanded: mapOpen,
miniSize: HomeHero.miniMapSize,
// 9/22 D-6 — 미니맵 상세는 HOME 히어로가 「GALAXY 맵 확장」으로 바뀐다.
onOpen: () => onMap(true),
// 명세 `2008:14486` 「HUD - 터치시 FPI 상세 페이지 이동」.
onOpenPlanet: (p) => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => PlanetDetailScreen(
repository: planetRepository!(p.id),
watchPlanet: watchPlanet,
eclipse: eclipse,
),
),
),
);
/// 섹션은 ListView 의 직계 자식으로 둔다. 하나의 Column 으로 감싸면 첫 프레임에
/// 전부 빌드되어 지연 렌더링을 잃고, 테스트의 scrollUntilVisible 도 「이미 보인다」고
/// 오판한다. 좌우 여백은 자식마다 준다.
static const _h = EdgeInsets.symmetric(horizontal: AppSpace.s4);
/// 사본 `Frame 4241` — 구획 사이 36, 구획 머리와 본문 사이 16. 머리 줄은 터치 영역 때문에
/// 사본(20)보다 위아래 [HomeSectionHeader.slack] 씩 크므로 그만큼 뺀다.
static const _sectionGap = 36.0 - HomeSectionHeader.slack;
static const _headerGap = AppSpace.s4 - HomeSectionHeader.slack;
@override
Widget build(BuildContext context) {
final top = MediaQuery.paddingOf(context).top;
return Stack(
children: [
RefreshIndicator(
onRefresh: onRefresh,
child: ListView(
// 사본 `Frame 4241` 아래 패딩 24.
padding: const EdgeInsets.only(bottom: AppSpace.s5),
children: [
// 히어로는 상태 표시줄 아래까지 배경이 이어져야 하므로 SafeArea 를 쓰지 않고
// 헤더 행에 상단 여백을 직접 준다.
Stack(
children: [
LiveStreamBuilder<PlanetGrowth>(
key: ValueKey((repository, data.fandomId)),
create: () => repository.watchGrowth(data.fandomId),
builder: (context, growth) => FutureBuilder<GalaxySnapshot>(
future: galaxy,
// 미니맵과 확장 맵은 같은 GalaxyWebView(같은 키) — 자리만 옮기고 모드는 JS 로 바꾼다.
// 그래서 교차 페이드(AnimatedSwitcher)를 쓰지 않는다(전환 중 키가 둘이 된다).
builder: (context, galaxyState) => KeyedSubtree(
child:
mapOpen && _map(context, galaxyState.data) != null
? HomeGalaxyHero(
key: const ValueKey('home-galaxy-hero'),
map: _map(context, galaxyState.data)!,
ticker: _LiveTickerSlot(
eclipse: eclipse,
fandomId: data.fandomId,
fallback: data.liveTicker,
fandomName: data.growth.fandomName,
watchPlanet: watchPlanet,
onHero: _heroParticipate(context),
),
onClose: () => onMap(false),
onSearch: () => _openSearch(context),
)
: HomeHero(
key: const ValueKey('home-hero'),
zap: zap,
zapData: galaxyState.data,
miniMap: _map(context, galaxyState.data),
// 라이브 띠 — `fandoms/{id}/liveEvents` 를 구독한다. 이벤트가 아직 없으면
// 스냅샷의 `liveTicker` 문자열로 물러난다(폴백).
ticker: _LiveTickerSlot(
eclipse: eclipse,
fandomId: data.fandomId,
fallback: data.liveTicker,
fandomName: data.growth.fandomName,
watchPlanet: watchPlanet,
onHero: _heroParticipate(context),
),
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,
),
),
),
// Figma `2024:20758` — 행성 터치는 흔들림 1번 뒤 커뮤니티(팬덤 라운지)로.
onTapPlanet:
planetRepository == null ||
data.fandomId.isEmpty
? null
: () => Navigator.of(
context,
rootNavigator: true,
).push(
MaterialPageRoute<void>(
builder: (_) => PlanetDetailScreen(
repository: planetRepository!(
data.fandomId,
),
watchPlanet: watchPlanet,
eclipse: eclipse,
initialTab: 1,
),
),
),
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,
),
),
// 설명 프레임 `516:87335` 「행성 가입 버튼 - 클릭 시 Galaxy 검색 화면 이동」.
onJoin: () => _openSearch(context),
zapButtonKey: zapKey,
onHeroParticipate: _heroParticipate(context),
eclipseButton: data.fandomId.isEmpty
? null
: EclipseEventButton(
repository: eclipse,
fandomId: data.fandomId,
nebula: (growth ?? data.growth).isNebula,
),
),
),
),
),
Positioned(
// 확장 맵(`343:211127`)은 라이브 띠가 헤더 위(y48)라 헤더가 띠 높이만큼 내려간다.
top: top + (mapOpen ? HomeGalaxyHero.headerShift : 0),
left: 0,
right: 0,
// 지갑 문서를 구독한다 — 한 번 읽은 값으로 두면 다른 화면의 변동(현상금 예치 ·
// 타임어택 보상)이 칩에 남지 않는다(2026-10-05 실기기).
child: LiveStreamBuilder<int>(
key: ValueKey(('pop', repository)),
create: repository.watchPopBalance,
builder: (context, wallet) => HomeHeader(
pop: wallet ?? zap?.balance ?? data.popBalance,
notifications: notifications,
items: data.items,
fandomId: data.fandomId,
),
),
),
],
),
// 이클립스 인앱 경보(별첨 1 ① 「푸시 미수신 단말 — 인앱 워닝 배너 병행」)는
// Figma 대로 히어로 행성 아래의 이클립스 이벤트 버튼(`516:88124`)이 맡는다.
// 이하 구획은 Figma 「홈」 `2097:16428` `Frame 4241`(패딩 24/16 · 구획 사이 36 · 머리와
// 본문 16) 순서 — TODAY'S QUEST · 팬덤행성 인큐베이터 · 아티스트 소식 · HOT ARCHIVE ·
// 인기 K-INSIDE.
// HOME 주간 정보(A-37 · 정보위계 v1.0 §2 의 2 · 3 · 6 · 7번) — 🟡 자리 임시(REFINE v5A 가 사본에 없음 · Q-A16).
if (data.weekly case final weekly?) ...[
Padding(
padding: _h,
child: HomeSectionHeader(title: context.l10n.homeWeeklyTitle),
),
const SizedBox(height: _headerGap),
Padding(
padding: _h,
child: HomeWeeklyCard(weekly: weekly),
),
const SizedBox(height: _sectionGap),
],
Padding(
padding: _h,
child: HomeSectionHeader(
title: 'TODAY’S QUEST',
onMore: () =>
_openQuests(context, questRepository, data.popBalance),
),
),
const SizedBox(height: _headerGap),
// 설명 프레임 `2097:16397` — 관리자 퀘스트 클릭 시 해당 퀘스트 상세 화면 이동.
Padding(
padding: _h,
child: QuestRows(
quests: data.quests,
onTap: (q) => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => QuestDetailScreen(
questId: q.id,
repository: questRepository,
),
),
),
),
),
const SizedBox(height: _sectionGap),
// 팬덤행성 인큐베이터(성운 추천 `2097:16403`). 성운이 없으면 구획을 두지 않는다 —
// 사본에 빈 상태가 없다.
if (data.nebulas.isNotEmpty) ...[
Padding(
padding: _h,
child: HomeSectionHeader(title: context.l10n.homeIncubator),
),
const SizedBox(height: _headerGap),
NebulaIncubator(
nebulas: data.nebulas,
// 명세 「함께 성장하기 버튼 클릭 시, 성운 상세 화면 이동」.
onOpen: (n) {
if (planetRepository == null) return;
Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => PlanetDetailScreen(
repository: planetRepository!(n.id),
watchPlanet: watchPlanet,
eclipse: eclipse,
),
),
);
},
),
const SizedBox(height: _sectionGap),
],
// 아티스트 소식 = AI Daily Briefing 영역(`2097:16409`). 0 건이면 헤더까지 그리지 않는다
// — 정책 원장 B-47 「News·SNS 모두 0건 시 Briefing 블록 전체 숨김」. 가입 · 생성 유저
// 에게만(명세 규칙) — 브리핑은 소속 팬덤 기준으로만 읽힌다.
if (data.briefings.isNotEmpty) ...[
Padding(
padding: _h,
child: HomeSectionHeader(
title: context.l10n.homeArtistNews,
// 「홈_브리핑 공유」 — 공유 카드 오버레이(`266:90529` · 명세 `179:29117`).
trailing: SharePill(
onTap: () => 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),
],
// HOT ARCHIVE 는 모금 중 캠페인이 없어도 둔다 — 기능명세서 v1.4 §2.114-6
// (`2097:16415`) 「더보기 버튼 - HOT ARCHIVE 화면 이동」에 숨김 규칙이 없고,
// 캠페인 목록·개설로 가는 길이 이 구획뿐이다(공통 기준 5항).
Padding(
padding: _h,
child: HomeSectionHeader(
title: 'HOT ARCHIVE',
onMore: () => _openCampaigns(context, campaignRepository),
),
),
const SizedBox(height: _headerGap),
if (data.campaigns.isNotEmpty)
CampaignCarousel(
campaigns: data.campaigns,
// 명세 「캠페인 보기 버튼 - 해당 캠페인 상세 화면 이동」.
onOpen: (c) => c.id.isEmpty
? _openCampaigns(context, campaignRepository)
: Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => CampaignDetailScreen(
campaignId: c.id,
repository: campaignRepository,
),
),
),
)
else
// 0 건 — 사본에 빈 상태가 없어 「새 캠페인」(`179:29123` 캠페인 개설) 입구를 둔다.
Center(
child: CampaignFab(
label: context.l10n.campaignNew,
onTap: () =>
Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => CampaignCreateScreen(
repository: campaignRepository,
),
),
),
),
),
const SizedBox(height: _sectionGap),
Padding(
padding: _h,
child: HomeSectionHeader(
title: context.l10n.homePopularInside,
// 명세 「더보기 버튼 - 인사이드 화면 이동」.
onMore: () => GoRouter.maybeOf(context)?.go(appTabs[1].path),
),
),
const SizedBox(height: _headerGap),
for (final (i, q) in data.insides.indexed) ...[
if (i > 0) const SizedBox(height: AppSpace.s3),
Padding(
padding: _h,
child: HomeInsideCard(
question: q,
// 명세 「인사이드 리스트 - 해당 인사이드 상세 화면 이동」.
onTap: () =>
Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => InsideDetailScreen(
question: q,
repository: insideRepository,
),
),
),
),
),
],
],
),
),
// ZAP — Figma 「홈」 `Frame 1984081111`. 스크롤과 무관하게 하단 탭 위 가운데에 떠 있다.
Positioned(
left: 0,
right: 0,
bottom: HomeZap.bottomGap,
child: FutureBuilder<GalaxySnapshot>(
future: galaxy,
builder: (context, galaxyState) =>
zap == null || !HomeZap.visible(galaxyState.data, data.fandomId)
? const SizedBox.shrink()
: Center(
child: HomeZap(
buttonKey: zapKey,
zap: zap!,
data: galaxyState.data!,
fandomId: data.fandomId,
watchPlanet: watchPlanet,
onItem: () => showItemSheet(
context,
items: data.items,
purchase: false,
),
onHeroParticipate: _heroParticipate(context),
),
),
),
),
],
);
}
/// 갤럭시 검색 — 확장 맵 돋보기(`2008:14560`)와 첫 사용자 시트 「행성 가입」(`516:87335`).
/// 지도 저장소가 없으면(표본 모드) GALAXY 탭으로 물러난다.
void _openSearch(BuildContext context) {
final galaxyRepository = zap?.repository;
if (galaxyRepository == null || planetRepository == null) {
GoRouter.maybeOf(context)?.go('/galaxy');
return;
}
final navigator = Navigator.of(context, rootNavigator: true);
navigator.push(
MaterialPageRoute<void>(
builder: (_) => GalaxySearchScreen(
repository: galaxyRepository,
onOpen: (p) => navigator.push(
MaterialPageRoute<void>(
builder: (_) => PlanetDetailScreen(
repository: planetRepository!(p.id),
watchPlanet: watchPlanet,
eclipse: eclipse,
),
),
),
),
),
);
}
VoidCallback? _heroParticipate(BuildContext context) =>
planetRepository == null || data.fandomId.isEmpty
? null
: () => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => HeroParticipationSheet(
fandomId: data.fandomId,
watch: watchPlanet,
repository: planetRepository!(data.fandomId),
),
);
}
/// 상태 표시줄 가림막 — 히어로는 상태 표시줄 뒤까지 배경을 깔아야 해서 목록에
/// SafeArea 를 주지 않는다. 대신 세로로 스크롤되면 상태 표시줄 자리를 화면 배경색으로
/// 덮어, 아래 구획 머리글(TODAY'S QUEST 등)이 시계·배터리와 겹쳐 그려지지 않게 한다.
class _StatusBarScrim extends StatefulWidget {
const _StatusBarScrim({required this.height, required this.child});
final double height;
final Widget child;
@override
State<_StatusBarScrim> createState() => _StatusBarScrimState();
}
class _StatusBarScrimState extends State<_StatusBarScrim> {
bool _scrolled = false;
bool _onScroll(ScrollNotification n) {
// 바깥 세로 목록만 본다.
if (n.depth != 0 || n.metrics.axis != Axis.vertical) return false;
final scrolled = n.metrics.pixels > 0;
if (scrolled != _scrolled) setState(() => _scrolled = scrolled);
return false;
}
@override
Widget build(BuildContext context) => Stack(
fit: StackFit.expand,
children: [
NotificationListener<ScrollNotification>(
onNotification: _onScroll,
child: widget.child,
),
if (_scrolled)
Positioned(
key: const ValueKey('home-status-scrim'),
top: 0,
left: 0,
right: 0,
height: widget.height,
child: const ColoredBox(color: AppColors.cosmicVoid),
),
],
);
}
/// 상단 활동 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),
),
],
),
),
);
}
}
/// 홈의 HOT ARCHIVE 에서 캠페인 목록으로 — 화면 대조 원장 F1 → F5.
///
/// 「더 보기」(HOT ARCHIVE 화면 이동)와 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),
),
);
}