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

673 lines
25 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_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';
import '../../core/ui/sheet_route.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();
/// Figma `540:19580` 그리드 변경 영역 — 디폴트 격자, 누르면 격자 ↔ 리스트(`354:221533`).
bool _list = false;
/// 명세 §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 snapshot = await _snapshot;
// Figma `540:19592` · `543:19609` — 일 1회. 오늘 이미 만들었으면 화면을 열지 않고 초과 안내 모달.
if (snapshot.nebulaCreatedToday) {
if (mounted)
await showAlertDialog(context, context.l10n.nebulaAlreadyToday);
return;
}
final myPop = snapshot.myPop;
final fandomId = await navigator.push(
SheetRoute<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),
),
],
),
);
}
// 가입하거나 만든 행성/성운 전부, 최신 가입순(Figma `540:19586`). 「선택」 알약은 대표 팬덤(C-15).
final mine = data.memberships;
return Stack(
children: [
Column(
children: [
// 상단 — 상단 고정 노출(Figma `540:19574`). 시안 `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,
onJoin: (p) =>
widget.planetRepository(p.id).join(),
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,
),
),
),
),
],
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.only(bottom: AppSpace.s9),
children: [
// 이클립스 인앱 경보 — 별첨 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(),
// 격자 ↔ 리스트(Figma `540:19580`). 궤도 맵은 선택된 카드를 눌러 연다.
InkResponse(
onTap: () => setState(() => _list = !_list),
radius: 20,
child: Semantics(
container: true,
button: true,
label: _list
? context.l10n.galaxyGridView
: context.l10n.galaxyListView,
child: _list
? const _GridIcon()
: Image.asset(
'assets/figma/icon-list.png',
width: 24,
height: 24,
color: const Color(0xFF777777),
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.all(16),
child: _list
? _PlanetList(
planets: mine,
imageOf: GalaxyHomeScreen.cardImageOf,
onTap: _choose,
)
: _PlanetGrid(planets: mine, onTap: _choose),
),
],
),
),
],
),
// 「성운 생성」 — 시안 하단 가운데 알약
Positioned(
left: 0,
right: 0,
bottom: 16,
child: Center(
child: _NebulaPill(
onTap: _createNebula,
// 일 1회 생성 뒤 비활성화(Figma `540:19592`). 눌러도 초과 안내 모달만 뜬다.
disabled: data.nebulaCreatedToday,
),
),
),
],
);
},
),
),
);
}
}
/// 행성 카드 — 시안 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, this.disabled = false});
final VoidCallback onTap;
final bool disabled;
@override
Widget build(BuildContext context) {
return Opacity(
opacity: disabled ? 0.4 : 1,
child: 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,
),
),
],
),
),
),
),
);
}
}
/// 격자 — 세 칸씩 줄을 채운다. 행성이 셋보다 적으면 빈 칸으로 한 줄을 맞춘다(시안 세 칸).
class _PlanetGrid extends StatelessWidget {
const _PlanetGrid({required this.planets, required this.onTap});
final List<PlanetNode> planets;
final ValueChanged<PlanetNode> onTap;
@override
Widget build(BuildContext context) {
final cells = planets.length < 3 ? 3 : (planets.length + 2) ~/ 3 * 3;
return Column(
children: [
for (var row = 0; row < cells; row += 3) ...[
if (row > 0) const SizedBox(height: AppSpace.s2),
Row(
children: [
for (var i = row; i < row + 3; i++) ...[
if (i > row) const SizedBox(width: AppSpace.s2),
Expanded(
child: i < planets.length
? _PlanetCard(
planet: planets[i],
image: GalaxyHomeScreen.cardImageOf(planets[i], i),
selected: planets[i].isMine,
onTap: () => onTap(planets[i]),
)
: const _EmptySlot(),
),
],
],
),
],
],
);
}
}
/// 리스트 그리드 — 시안 `354:221533`: 80×79 #2c2c2c r8 칸(그림 64) · 간격 15 · 이름 16/600,
/// 줄 사이 1px #2c2c2c. 선택된 행성은 칸 위에 「선택」 알약.
class _PlanetList extends StatelessWidget {
const _PlanetList({
required this.planets,
required this.imageOf,
required this.onTap,
});
final List<PlanetNode> planets;
final String Function(PlanetNode, [int]) imageOf;
final ValueChanged<PlanetNode> onTap;
@override
Widget build(BuildContext context) => Column(
children: [
for (final (i, p) in planets.indexed) ...[
if (i > 0)
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Divider(height: 1, thickness: 1, color: Color(0xFF2C2C2C)),
),
InkWell(
onTap: () => onTap(p),
child: Row(
children: [
SizedBox(
width: 80,
height: 79,
child: Stack(
children: [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
color: const Color(0xFF2C2C2C),
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(8),
child: PlanetArt(
fallback: imageOf(p, i),
planetType: p.stage == 0 ? null : p.planetType,
colorKey: p.colorKey,
semanticLabel: p.name,
),
),
),
),
if (p.isMine)
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,
),
),
),
),
],
),
),
const SizedBox(width: 15),
Expanded(
child: Text(
p.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 16,
fontWeight: FontWeight.w600,
height: 22 / 16,
color: AppColors.onCosmic,
),
),
),
],
),
),
],
],
);
}
/// 리스트 상태의 「격자로」 아이콘 — 시안 9×9 r1 #777777 네 칸(20×20).
class _GridIcon extends StatelessWidget {
const _GridIcon();
@override
Widget build(BuildContext context) {
Widget sq() => Container(
width: 9,
height: 9,
decoration: BoxDecoration(
color: const Color(0xFF777777),
borderRadius: BorderRadius.circular(1),
),
);
return SizedBox.square(
dimension: 24,
child: Center(
child: SizedBox.square(
dimension: 20,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [sq(), sq()],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [sq(), sq()],
),
],
),
),
),
);
}
}