HOME · GALAXY · 행성·성운 상세 · 팬덤 라운지 · 이클립스 화면

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent c4e9a9430e
commit 5de04ac281
44 changed files with 14898 additions and 16 deletions
@@ -0,0 +1,447 @@
import 'package:flutter/material.dart';
import '../../core/data/planet_visual_state.dart';
import '../../core/theme/tokens.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 {
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<GalaxyHomeScreen> createState() => _GalaxyHomeScreenState();
}
class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
late Future<GalaxySnapshot> _snapshot = widget.repository.load();
/// 명세 §2.58-3 — *행성/성운 클릭 시 기본 행성 변경 안내 모달 노출*. 이미 대표면 궤도 맵으로.
Future<void> _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<void>(
builder: (_) => GalaxyScreen(
repository: widget.repository,
watchPlanet: widget.watchPlanet,
planetRepository: widget.planetRepository,
eclipse: widget.eclipse,
),
),
);
/// 만든 성운으로 바로 들어간다. 돌아오면 「내 행성」 그리드에 새 성운이 선다.
Future<void> _createNebula() async {
final navigator = Navigator.of(context, rootNavigator: true);
final myPop = (await _snapshot).myPop;
final fandomId = await navigator.push(
MaterialPageRoute<String>(
builder: (_) => NebulaCreateScreen(repository: widget.home, myPop: myPop),
),
);
if (fandomId == null || !mounted) return;
setState(() { _snapshot = widget.repository.load(); });
await navigator.push(
MaterialPageRoute<void>(
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<GalaxySnapshot>(
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<void>(
builder: (_) => GalaxySearchScreen(
repository: widget.repository,
),
),
),
),
_HeaderIcon(
asset: 'assets/figma/icon-bell-42.png',
label: context.l10n.homeNotificationsTooltip,
onTap: () =>
Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
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: _imageFor(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),
),
),
],
);
},
),
),
);
}
/// 카드 그림. 시안 세 칸(`image 8` · `image 15` · 파란 행성 벡터)을 순서대로 쓴다.
static String _imageFor(int i) => switch (i) {
0 => 'assets/figma/planet-detail-hero.png',
1 => 'assets/figma/nebula-card.png',
_ => 'assets/figma/planet-earth.png',
};
}
/// 행성 카드 — 시안 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,
),
),
],
),
),
),
);
}
}