966 lines
40 KiB
Dart
966 lines
40 KiB
Dart
// 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/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 '../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/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 '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,
|
||
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> {
|
||
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(() {});
|
||
|
||
@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();
|
||
// 온보딩을 마친 뒤 첫 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(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) => 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) {
|
||
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,
|
||
});
|
||
|
||
final EclipseRepository eclipse;
|
||
final String fandomId;
|
||
final String? fallback;
|
||
|
||
@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<List<LiveEvent>>(
|
||
key: ValueKey('live-$fandomId'),
|
||
// 띠는 한 줄만 그리므로 최신 몇 건만 받는다.
|
||
create: () => fandomId.isEmpty
|
||
? Stream.value(const <LiveEvent>[])
|
||
: eclipse.liveEvents(fandomId: fandomId, limit: 5),
|
||
builder: (context, events) {
|
||
final event = (events == null || events.isEmpty) ? null : events.first;
|
||
final started = eclipseState?.startAt;
|
||
// 발생 소식은 그 뒤에 온 이벤트(방어 참여 등)가 없을 때 띠에 오른다.
|
||
final eclipseNews =
|
||
eclipseState != null &&
|
||
eclipseState.result == null &&
|
||
(event == null || started == null || event.at.isBefore(started));
|
||
final latest = eclipseNews
|
||
? context.l10n.eclipseLiveOccurred(eclipseState.fandomName)
|
||
: event == null
|
||
? fallback
|
||
: liveEventMessage(event);
|
||
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),
|
||
// 사본 `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,
|
||
icon: const ImageIcon(AppIcons.bell, 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.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,
|
||
),
|
||
onClose: () => onMap(false),
|
||
onSearch: () =>
|
||
Navigator.of(
|
||
context,
|
||
rootNavigator: true,
|
||
).push(
|
||
MaterialPageRoute<void>(
|
||
builder: (_) => GalaxySearchScreen(
|
||
repository: zap!.repository,
|
||
onOpen: (p) =>
|
||
Navigator.of(
|
||
context,
|
||
rootNavigator: true,
|
||
).push(
|
||
MaterialPageRoute<void>(
|
||
builder: (_) =>
|
||
PlanetDetailScreen(
|
||
repository:
|
||
planetRepository!(
|
||
p.id,
|
||
),
|
||
watchPlanet:
|
||
watchPlanet,
|
||
eclipse: eclipse,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
)
|
||
: 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,
|
||
),
|
||
),
|
||
),
|
||
// 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,
|
||
),
|
||
),
|
||
// 행성 가입은 GALAXY 에서 한다(사본 시트 「행성 가입」).
|
||
onJoin: () =>
|
||
GoRouter.maybeOf(context)?.go('/galaxy'),
|
||
zapButtonKey: zapKey,
|
||
onHeroParticipate: _heroParticipate(context),
|
||
eclipseButton: data.fandomId.isEmpty
|
||
? null
|
||
: EclipseEventButton(
|
||
repository: eclipse,
|
||
fandomId: data.fandomId,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
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.
|
||
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),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
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),
|
||
),
|
||
);
|
||
}
|