import 'package:flutter/material.dart'; import '../../core/data/planet_visual_state.dart'; import '../../core/theme/tokens.dart'; import '../../core/ui/app_dialog.dart'; import '../../core/ui/app_icons.dart'; import '../notification/notification_screen.dart'; import '../eclipse/eclipse_data.dart'; import '../eclipse/widgets/eclipse_banner.dart'; import 'galaxy_data.dart'; import '../planet/planet_data.dart'; import 'galaxy_screen.dart'; import 'galaxy_search_screen.dart'; import '../home/home_data.dart'; import '../../core/data/backend.dart' show BackendException; import '../home/nebula_create_screen.dart'; import '../planet/planet_detail_screen.dart'; import '../../core/i18n/app_locale.dart'; /// GALAXY 탭 루트 — 시안 `354:220228` Galaxy. /// /// 시안은 「내 행성」 카드 그리드다. 카드 109×108 `#2c2c2c` r12, 간격 8, 세 칸. /// 선택된 행성은 `#ff7ddd` 2px 테두리와 「선택」 핑크 알약이 붙는다. 하단 가운데에 /// 「성운 생성」 알약(106×36 · `#19191c` · `#232324` 1px)이 뜬다. /// /// 궤도 맵([GalaxyScreen])은 시안의 갤럭시맵 명세(HUD · 3D 갤럭시)를 구현한 화면이라 /// 그대로 두고, 카드나 우측 상단 목록 아이콘에서 들어간다. class GalaxyHomeScreen extends StatefulWidget { /// 카드 그림 — HOME · 맵과 같이 팬덤 `stage` 로 고른다. Stage0 성운만 성운 그림이고 /// 정식 행성은 시안 행성 그림(`image 8` · 파란 행성 벡터)을 칸마다 번갈아 쓴다. /// 예전엔 칸 순서로 골라 둘째 칸이 행성이어도 성운 그림이 됐다. static String cardImageOf(PlanetNode p, int i) => p.stage == 0 ? 'assets/figma/nebula-card.png' : (i.isEven ? 'assets/figma/planet-detail-hero.png' : 'assets/figma/planet-earth.png'); const GalaxyHomeScreen({ super.key, this.watchPlanet = noPlanetWatch, required this.repository, required this.planetRepository, required this.eclipse, this.notifications = const SampleNotificationRepository(), this.home = const SampleHomeRepository(), }); final GalaxyRepository repository; /// 「성운 생성」 경로(`fn-nebula-create`). final HomeRepository home; final PlanetWatch watchPlanet; /// 상단 알림 입구가 여는 알림함(F0)의 저장소. final NotificationRepository notifications; /// 궤도 맵을 지나 행성 상세까지 그대로 내려간다. final PlanetRepositoryFactory planetRepository; /// 이클립스 경보·방어. 궤도 맵에도 그대로 넘긴다. final EclipseRepository eclipse; @override State createState() => _GalaxyHomeScreenState(); } class _GalaxyHomeScreenState extends State { late Future _snapshot = widget.repository.load(); /// 명세 §2.58-3 — *행성/성운 클릭 시 기본 행성 변경 안내 모달 노출*. 이미 대표면 궤도 맵으로. Future _choose(PlanetNode p) async { if (p.isMine) return _openMap(); final ok = await showConfirmDialog( context, context.l10n.galaxySetPrimaryConfirm, ); if (!ok || !mounted) return; try { await widget.repository.setPrimary(p.id); if (mounted) setState(() { _snapshot = widget.repository.load(); }); } on BackendException catch (e) { // 30일 대기(E-FDM-04) 등 서버 사유를 그대로 알린다. if (mounted) await showAlertDialog(context, e.message); } } void _openMap() => Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( builder: (_) => GalaxyScreen( repository: widget.repository, watchPlanet: widget.watchPlanet, planetRepository: widget.planetRepository, eclipse: widget.eclipse, ), ), ); /// 만든 성운으로 바로 들어간다. 돌아오면 「내 행성」 그리드에 새 성운이 선다. Future _createNebula() async { final navigator = Navigator.of(context, rootNavigator: true); final myPop = (await _snapshot).myPop; final fandomId = await navigator.push( MaterialPageRoute( builder: (_) => NebulaCreateScreen(repository: widget.home, myPop: myPop), ), ); if (fandomId == null || !mounted) return; setState(() { _snapshot = widget.repository.load(); }); await navigator.push( MaterialPageRoute( builder: (_) => PlanetDetailScreen( nebula: true, repository: widget.planetRepository(fandomId), watchPlanet: widget.watchPlanet, eclipse: widget.eclipse, ), ), ); } @override Widget build(BuildContext context) { final t = Theme.of(context).textTheme; return Scaffold( body: SafeArea( child: FutureBuilder( future: _snapshot, builder: (context, snap) { if (snap.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); } final data = snap.data; if (snap.hasError || data == null) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text(context.l10n.galaxyLoadFailed, style: t.titleMedium), const SizedBox(height: AppSpace.s4), FilledButton( onPressed: () => setState(() => _snapshot = widget.repository.load()), child: Text(context.l10n.commonRetry), ), ], ), ); } // 시안은 내 행성 셋을 보여 준다 — 가입하거나 만든 행성/성운, 최신 가입순(§2.58). // 「선택」 알약은 대표 팬덤(C-15)이다. final mine = data.memberships.take(3).toList(); return Stack( children: [ ListView( padding: const EdgeInsets.only(bottom: AppSpace.s9), children: [ // 상단 — 시안 `Top Navigation`(54 · 좌우 16) 「GALAXY」 ABeeZee 20 · 검색 · 알림 42 Padding( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 6, ), child: Row( children: [ const Text( 'GALAXY', style: TextStyle( fontFamily: AppFonts.display, fontWeight: FontWeight.w400, fontSize: 20, height: 1.4, color: AppColors.onCosmic, ), ), const Spacer(), _HeaderIcon( asset: 'assets/figma/icon-search-42.png', label: context.l10n.commonSearch, onTap: () => Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( builder: (_) => GalaxySearchScreen( repository: widget.repository, onOpen: (p) => Navigator.of( context, rootNavigator: true, ).push( MaterialPageRoute( builder: (_) => PlanetDetailScreen( repository: widget .planetRepository(p.id), watchPlanet: widget.watchPlanet, eclipse: widget.eclipse, ), ), ), ), ), ), ), _HeaderIcon( asset: 'assets/figma/icon-bell-42.png', label: context.l10n.homeNotificationsTooltip, onTap: () => Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( builder: (_) => NotificationScreen( repository: widget.notifications, ), ), ), ), ], ), ), // 이클립스 인앱 경보 — 별첨 1 ① 「푸시 미수신 단말 — 인앱 // 워닝 배너 병행」. 대상은 내 행성이다. if (mine.where((p) => p.isMine).firstOrNull case final my?) EclipseBanner( repository: widget.eclipse, fandomId: my.id, margin: const EdgeInsets.fromLTRB( 16, AppSpace.s3, 16, 0, ), onTap: _openMap, ), // 「내 행성」 ABeeZee 26 · 우측 목록 아이콘 24 → 궤도 맵 (줄 44 · 위아래 4) Padding( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 4, ), child: Row( children: [ Text( context.l10n.galaxyMyPlanets, style: const TextStyle( fontFamily: AppFonts.display, fontWeight: FontWeight.w400, fontSize: 26, height: 1.4, color: AppColors.onCosmic, ), ), const Spacer(), InkResponse( onTap: _openMap, radius: 20, child: Image.asset( 'assets/figma/icon-list.png', width: 24, height: 24, color: const Color(0xFF777777), semanticLabel: context.l10n.galaxyMap, ), ), ], ), ), Padding( padding: const EdgeInsets.all(16), child: Row( children: [ for (final (i, p) in mine.indexed) ...[ if (i > 0) const SizedBox(width: AppSpace.s2), Expanded( child: _PlanetCard( planet: p, image: GalaxyHomeScreen.cardImageOf(p, i), selected: p.isMine, onTap: () => _choose(p), ), ), ], // 시안은 항상 세 칸이다. 행성이 모자라면 빈 칸을 둔다. for (var i = mine.length; i < 3; i++) ...[ if (i > 0) const SizedBox(width: AppSpace.s2), const Expanded(child: _EmptySlot()), ], ], ), ), ], ), // 「성운 생성」 — 시안 하단 가운데 알약 Positioned( left: 0, right: 0, bottom: 16, child: Center(child: _NebulaPill(onTap: _createNebula)), ), ], ); }, ), ), ); } } /// 행성 카드 — 시안 109×109 #2c2c2c r12, 그림 88(안쪽 11·10), 선택은 #ff7ddd 테두리와 /// 오른쪽 위에 붙은 「선택」 알약(32×24 · 12/600). class _PlanetCard extends StatelessWidget { const _PlanetCard({ required this.planet, required this.image, required this.selected, required this.onTap, }); final PlanetNode planet; final String image; final bool selected; final VoidCallback onTap; @override Widget build(BuildContext context) => AspectRatio( aspectRatio: 1, child: Material( color: const Color(0xFF2C2C2C), borderRadius: BorderRadius.circular(AppRadius.md), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(AppRadius.md), child: Ink( decoration: BoxDecoration( borderRadius: BorderRadius.circular(AppRadius.md), border: selected ? Border.all(color: AppColors.cosmicAccent, width: 2) : null, ), child: Stack( children: [ Positioned.fill( child: Padding( padding: const EdgeInsets.fromLTRB(11, 10, 10, 11), child: Image.asset( image, fit: BoxFit.contain, semanticLabel: planet.name, ), ), ), if (selected) Positioned( top: 0, right: 0, child: Container( width: 32, height: 24, alignment: Alignment.center, decoration: BoxDecoration( color: AppColors.cosmicAccent, borderRadius: BorderRadius.circular(AppRadius.full), ), child: Text( context.l10n.commonSelect, style: const TextStyle( fontFamily: AppFonts.body, fontSize: 12, fontWeight: FontWeight.w600, height: 1, color: AppColors.onCosmic, ), ), ), ), ], ), ), ), ), ); } /// 상단 42 아이콘(시안 `icon` 42×42 — 24 그림 + 여백). class _HeaderIcon extends StatelessWidget { const _HeaderIcon({ required this.asset, required this.label, required this.onTap, }); final String asset; final String label; final VoidCallback onTap; @override Widget build(BuildContext context) => InkResponse( onTap: onTap, radius: 21, child: Image.asset(asset, width: 42, height: 42, semanticLabel: label), ); } class _EmptySlot extends StatelessWidget { const _EmptySlot(); @override Widget build(BuildContext context) => AspectRatio( aspectRatio: 109 / 108, child: DecoratedBox( decoration: BoxDecoration( color: const Color(0xFF2C2C2C).withValues(alpha: 0.5), borderRadius: BorderRadius.circular(AppRadius.md), ), ), ); } class _NebulaPill extends StatelessWidget { const _NebulaPill({required this.onTap}); final VoidCallback onTap; @override Widget build(BuildContext context) { return Material( color: const Color(0xFF19191C), borderRadius: BorderRadius.circular(AppRadius.full), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(AppRadius.full), child: Container( height: 36, padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4), decoration: BoxDecoration( borderRadius: BorderRadius.circular(AppRadius.full), border: Border.all(color: AppColors.cosmicSurfaceRaised), ), // 시안 — Iconset/badge 16 · 간격 6 · 「성운 생성」 14/500 #a3a2a0 child: Row( mainAxisSize: MainAxisSize.min, children: [ const ImageIcon( AppIcons.badge, size: 16, color: AppColors.cosmicAccent, ), const SizedBox(width: 6), Text( context.l10n.nebulaCreate, style: const TextStyle( fontFamily: AppFonts.body, fontSize: 14, fontWeight: FontWeight.w500, height: 20 / 14, color: AppColors.onCosmicVariant, ), ), ], ), ), ), ); } }