475 lines
18 KiB
Dart
475 lines
18 KiB
Dart
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 '../../core/ui/planet_25d.dart' show PlanetArt;
|
||
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` · 파란 행성 벡터)을 칸마다 번갈아 쓴다.
|
||
/// 예전엔 칸 순서로 골라 둘째 칸이 행성이어도 성운 그림이 됐다.
|
||
// ponytail: 형태가 없는 행성(옛 데이터)만 고정된 두 그림 중 하나(id 기준). 형태가 있으면
|
||
// 카드가 [PlanetArt] 로 생성 때 고른 형태 · 색을 그린다.
|
||
static String cardImageOf(PlanetNode p, [int _ = 0]) => p.stage == 0
|
||
? 'assets/figma/nebula-card.png'
|
||
: (p.id.codeUnits.fold<int>(0, (a, c) => a + c).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<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,
|
||
onOpen: (p) =>
|
||
Navigator.of(
|
||
context,
|
||
rootNavigator: true,
|
||
).push(
|
||
MaterialPageRoute<void>(
|
||
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<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: 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: PlanetArt(
|
||
fallback: image,
|
||
planetType: planet.stage == 0 ? null : planet.planetType,
|
||
colorKey: planet.colorKey,
|
||
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,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|