Files
fanuzu/app/lib/features/galaxy/galaxy_home_screen.dart
T

448 lines
16 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.
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,
),
),
],
),
),
),
);
}
}