HOME 을 Figma 「홈」 구성으로 — TODAY'S QUEST · 미니맵·확장 맵(프로토타입 v6.2) · 행성 정보 박스 · 인큐베이터 · 아티스트 소식 · HOT ARCHIVE · 인기 K-INSIDE

This commit is contained in:
Sn-Kinos
2026-10-05 16:29:41 +09:00
parent f84d696c21
commit 54d0b23260
45 changed files with 3448 additions and 1111 deletions
+429 -278
View File
@@ -2,8 +2,8 @@
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/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';
@@ -15,9 +15,9 @@ import 'package:go_router/go_router.dart';
import '../../core/format.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.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';
@@ -25,10 +25,13 @@ 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/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';
@@ -63,9 +66,13 @@ class HomeScreen extends StatefulWidget {
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;
@@ -93,6 +100,13 @@ class _HomeScreenState extends State<HomeScreen> {
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(() {});
@override
@@ -120,12 +134,15 @@ class _HomeScreenState extends State<HomeScreen> {
/// 성운·행성 생성 화면은 성공하면 팬덤 ID 를 들고 닫힌다. 그때만 다시 읽는다.
Future<void> _createThenReload(Widget screen) async {
final created = await Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<String>(builder: (_) => screen),
);
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(); });
setState(() {
_galaxy = repository.load();
});
}
await _refresh();
}
@@ -137,7 +154,15 @@ class _HomeScreenState extends State<HomeScreen> {
}
@override
Widget build(BuildContext context) => Scaffold(
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>(
@@ -157,6 +182,11 @@ class _HomeScreenState extends State<HomeScreen> {
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,
@@ -249,6 +279,11 @@ class HomeHeader extends StatelessWidget {
_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'),
@@ -256,23 +291,25 @@ class HomeHeader extends StatelessWidget {
height: 42,
),
// 사본 `252:159367` — 홈 주 프레임에 열린 「아이템 구입」 시트.
onPressed: () =>
showItemSheet(
context,
items: items,
purchase: true,
fandomId: fandomId,
),
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,
// 사본 `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,
icon: const ImageIcon(AppIcons.bell, color: AppColors.onCosmic),
// 알림 — 화면 대조 원장 F0 · `229:11265`.
onPressed: () => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
@@ -292,7 +329,12 @@ class _HomeBody extends StatelessWidget {
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,
@@ -311,6 +353,15 @@ class _HomeBody extends StatelessWidget {
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;
@@ -321,277 +372,394 @@ class _HomeBody extends StatelessWidget {
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.
static const _sectionGap = 36.0;
static const _headerGap = 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 RefreshIndicator(
onRefresh: onRefresh,
child: ListView(
padding: const EdgeInsets.only(bottom: AppSpace.s7),
children: [
// 히어로는 상태 표시줄 아래까지 배경이 이어져야 하므로 SafeArea 를 쓰지 않고
// 헤더 행에 상단 여백을 직접 준다.
Stack(
return Stack(
children: [
RefreshIndicator(
onRefresh: onRefresh,
child: ListView(
// 사본 `Frame 4241` 아래 패딩 24.
padding: const EdgeInsets.only(bottom: AppSpace.s5),
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: HomeHero.miniMapSize,
data: galaxyState.data!,
onOpen: () =>
Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => GalaxyScreen(
repository: zap!.repository,
planetRepository: planetRepository!,
watchPlanet: watchPlanet,
eclipse: eclipse,
// 히어로는 상태 표시줄 아래까지 배경이 이어져야 하므로 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,
),
onClose: () => onMap(false),
onSearch: () =>
Navigator.of(
context,
rootNavigator: true,
).push(
MaterialPageRoute<void>(
builder: (_) => GalaxySearchScreen(
repository: zap!.repository,
),
),
),
)
: 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,
),
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,
),
),
),
// 라이브 띠 — `fandoms/{id}/liveEvents` 를 구독한다. 이벤트가 아직 없으면
// 스냅샷의 `liveTicker` 문자열로 물러난다(폴백).
ticker: _LiveTickerSlot(
eclipse: eclipse,
fandomId: data.fandomId,
fallback: data.liveTicker,
onCreateNebula: () => onCreate(
NebulaCreateScreen(
repository: repository,
myPop: data.popBalance,
),
),
// 행성 가입은 GALAXY 에서 한다(사본 시트 「행성 가입」).
onJoin: () =>
GoRouter.maybeOf(context)?.go('/galaxy'),
zapButtonKey: zapKey,
onHeroParticipate: _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,
),
),
),
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,
),
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,
repository: repository,
),
),
onCreateNebula: () => onCreate(
NebulaCreateScreen(
repository: repository,
myPop: data.popBalance,
),
],
),
// 이클립스 인앱 경보 — 별첨 1 ① 예외 처리 「푸시 미수신 단말 — 인앱
// 워닝 배너 병행」. 활성 이클립스가 없으면 자리도 차지하지 않는다.
if (data.fandomId.isNotEmpty)
EclipseBanner(
repository: eclipse,
fandomId: data.fandomId,
// 히어로 아래 24 = 히어로 상자 12 + 여기 12. 구획 사이는 36(`Frame 4241`).
margin: const EdgeInsets.fromLTRB(
AppSpace.s4,
HomeSectionHeader.slack,
AppSpace.s4,
_sectionGap,
),
),
// 이하 구획은 Figma 「홈」 `2097:16428` `Frame 4241`(패딩 24/16 · 구획 사이 36 · 머리와
// 본문 16) 순서 — TODAY'S QUEST · 팬덤행성 인큐베이터 · 아티스트 소식 · HOT ARCHIVE ·
// 인기 K-INSIDE.
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,
),
),
// 행성 가입은 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,
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),
),
),
],
),
// 이클립스 인앱 경보 — 별첨 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(
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: (_) => QuestDetailScreen(
questId: data.quests[i].id,
repository: questRepository,
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),
// 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),
],
// HOT ARCHIVE 는 모금 중 캠페인이 없어도 둔다 — 기능명세서 v1.4 §2.114-6
// (`2097:16415`) 「더보기 버튼 - HOT ARCHIVE 화면 이동」에 숨김 규칙이 없고,
// 캠페인 목록·개설로 가는 길이 이 구획뿐이다(공통 기준 5항).
Padding(
padding: _h,
child: SectionHeader(
title: 'HOT ARCHIVE',
onMore: () => _openCampaigns(context, campaignRepository),
),
),
const SizedBox(height: _headerGap),
Padding(
padding: _h,
child: switch (data.campaign) {
final c? => CampaignCard(
campaign: c,
onTap: () => _openCampaigns(context, campaignRepository),
),
// 0 건 — 「새 캠페인」(Figma `179:29123` 캠페인 개설)으로 바로 간다.
null => Center(
child: CampaignFab(
label: context.l10n.campaignNew,
onTap: () => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) =>
CampaignCreateScreen(repository: campaignRepository),
),
),
),
),
},
),
const SizedBox(height: _sectionGap),
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),
Padding(
padding: _h,
child: HomeSectionHeader(
title: context.l10n.homePopularInside,
// 명세 「더보기 버튼 - 인사이드 화면 이동」.
onMore: () => GoRouter.maybeOf(context)?.go(appTabs[1].path),
),
),
),
const SizedBox(height: AppSpace.s3),
],
],
),
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),
),
),
),
),
],
);
}
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),
),
);
}
/// 상태 표시줄 가림막 — 히어로는 상태 표시줄 뒤까지 배경을 깔아야 해서 목록에
@@ -611,7 +779,7 @@ 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);
@@ -712,26 +880,9 @@ class _LoadFailed extends StatelessWidget {
}
}
/// 아직 라우트가 없는 곳으로 가려 할 때.
///
/// 조용히 무시하면 검수 때 「눌러도 아무 일이 없다」로 보인다. 무엇이 언제
/// 열리는지 알려 주는 편이 낫다.
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 를 홈 데이터에 넣어야 하는데, 그러면 홈이 캠페인
/// 스키마를 알게 된다. 목록을 거쳐 들어간다.
/// 「더 보기」(HOT ARCHIVE 화면 이동)와 ID 없는 카드가 쓴다.
void _openCampaigns(BuildContext context, CampaignRepository? repository) {
Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(