HOME · GALAXY · 행성·성운 상세 · 팬덤 라운지 · 이클립스 화면
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user