Figma 설명 프레임 반영 — 로그인·온보딩·스플래시 · HOME · GALAXY · 퀘스트·이클립스·알림

This commit is contained in:
Sn-Kinos
2026-10-06 03:03:20 +09:00
parent 64825e72b0
commit ecf5998725
77 changed files with 2703 additions and 640 deletions
+306 -50
View File
@@ -4,7 +4,10 @@ import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../core/theme/tokens.dart';
import '../../core/data/backend.dart' show BackendException;
import '../../core/ui/planet_25d.dart' show PlanetArt;
import '../../core/ui/play_banner.dart';
import '../planet/widgets/fandom_size_badge.dart';
import 'galaxy_data.dart';
import '../../core/format.dart';
import '../../core/i18n/app_locale.dart';
@@ -20,10 +23,14 @@ class GalaxySearchScreen extends StatefulWidget {
super.key,
this.repository = const SampleGalaxyRepository(),
this.onOpen,
this.onJoin,
});
final GalaxyRepository repository;
/// 결과 줄의 「가입」 — Figma `543:19694` 「가입 처리 후 가입중 버튼스타일로 변경」.
final Future<void> Function(PlanetNode planet)? onJoin;
/// 결과 줄을 누르면 그 행성 상세로 — 상세 화면 저장소는 부르는 쪽이 안다.
final void Function(PlanetNode planet)? onOpen;
@@ -41,6 +48,17 @@ class _GalaxySearchScreenState extends State<GalaxySearchScreen> {
String _query = '';
/// Figma `543:19672` 탭 — 디폴트 행성(false) · 성운(true).
bool _nebulaTab = false;
/// Figma `543:19683` 필터 — 디폴트 전체. 행성: 전체 · 가입자 수 · FPI / 성운: 전체 · 가입자 수.
/// 성운의 「기여율」 필터 · 칩(`543:19704` · `543:19714`)은 기여율의 정의가 자료에 없어 해석 확인 후 붙인다.
_Sort _sort = _Sort.all;
/// 이 화면에서 가입한 행성 — 서버 확인 뒤 「가입중」으로 바꾼다.
final _joined = <String>{};
final _joining = <String>{};
/// 전체 행성 검색 결과 — 입력이 멈춘 뒤 서버에 묻는다.
Future<List<PlanetNode>>? _hits;
Timer? _debounce;
@@ -55,8 +73,15 @@ class _GalaxySearchScreenState extends State<GalaxySearchScreen> {
Future<void> _loadRecent() async {
try {
final saved = (await SharedPreferences.getInstance()).getStringList(_recentKey);
if (mounted && saved != null) setState(() => _recent..clear()..addAll(saved));
final saved = (await SharedPreferences.getInstance()).getStringList(
_recentKey,
);
if (mounted && saved != null)
setState(
() => _recent
..clear()
..addAll(saved),
);
} catch (_) {
// 기기 저장소를 못 쓰면 이번 화면 안에서만 기억한다.
}
@@ -64,7 +89,10 @@ class _GalaxySearchScreenState extends State<GalaxySearchScreen> {
Future<void> _saveRecent() async {
try {
await (await SharedPreferences.getInstance()).setStringList(_recentKey, _recent);
await (await SharedPreferences.getInstance()).setStringList(
_recentKey,
_recent,
);
} catch (_) {}
}
@@ -95,6 +123,24 @@ class _GalaxySearchScreenState extends State<GalaxySearchScreen> {
super.dispose();
}
Future<void> _join(PlanetNode p) async {
final join = widget.onJoin;
if (join == null || _joining.contains(p.id)) return;
setState(() => _joining.add(p.id));
try {
await join(p);
if (mounted) setState(() => _joined.add(p.id));
} on BackendException catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(e.message)));
}
} finally {
if (mounted) setState(() => _joining.remove(p.id));
}
}
void _submit(String q) {
final term = q.trim();
if (term.isEmpty) return;
@@ -201,19 +247,64 @@ class _GalaxySearchScreenState extends State<GalaxySearchScreen> {
result.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
final hits = result.data ?? const <PlanetNode>[];
if (hits.isEmpty) return const _NoHit();
return ListView.separated(
padding: const EdgeInsets.all(AppSpace.s4),
itemCount: hits.length,
separatorBuilder: (_, _) =>
const SizedBox(height: AppSpace.s2),
itemBuilder: (context, i) => _Hit(
planet: hits[i],
onTap: widget.onOpen == null
? null
: () => widget.onOpen!(hits[i]),
),
final all = result.data ?? const <PlanetNode>[];
final joinedIds = {
...(snap.data?.joinedIds ?? const <String>{}),
..._joined,
};
final hits = [
for (final p in all)
if ((p.stage == 0) == _nebulaTab) p,
];
switch (_sort) {
case _Sort.all:
break;
case _Sort.members:
hits.sort((a, b) => b.members.compareTo(a.members));
case _Sort.fpi:
hits.sort((a, b) => b.fpi.compareTo(a.fpi));
}
return Column(
children: [
_Tabs(
nebula: _nebulaTab,
onPick: (v) => setState(() {
_nebulaTab = v;
if (v && _sort == _Sort.fpi) _sort = _Sort.all;
}),
),
_Filters(
nebula: _nebulaTab,
sort: _sort,
onPick: (v) => setState(() => _sort = v),
),
Expanded(
child: hits.isEmpty
? const _NoHit()
: ListView.separated(
padding: const EdgeInsets.all(AppSpace.s4),
itemCount: hits.length,
separatorBuilder: (_, _) =>
const SizedBox(height: 24),
itemBuilder: (context, i) {
final p = hits[i];
final joined =
p.isMine || joinedIds.contains(p.id);
return _Hit(
planet: p,
joined: joined,
busy: _joining.contains(p.id),
onJoin: joined || widget.onJoin == null
? null
: () => _join(p),
onTap: widget.onOpen == null
? null
: () => widget.onOpen!(p),
);
},
),
),
],
);
},
);
@@ -290,13 +381,19 @@ class _Recent extends StatelessWidget {
),
)
else
Wrap(
spacing: AppSpace.s2,
runSpacing: AppSpace.s2,
children: [
for (final term in terms)
ActionChip(label: Text(term), onPressed: () => onPick(term)),
],
// Figma `543:19637` 「검색 결과 칩의 최대 높이는 3줄」 — 칩 33 · 줄 간격 10.
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 33 * 3 + 10 * 2),
child: ClipRect(
child: Wrap(
spacing: AppSpace.s2,
runSpacing: 10,
children: [
for (final term in terms)
ActionChip(label: Text(term), onPressed: () => onPick(term)),
],
),
),
),
],
);
@@ -318,46 +415,205 @@ class _NoHit extends StatelessWidget {
);
}
enum _Sort { all, members, fpi }
/// 탭 — 시안 `353:218091` 375×44 · 칸 171 · 16/500(선택 #4bc2ae 밑줄).
class _Tabs extends StatelessWidget {
const _Tabs({required this.nebula, required this.onPick});
final bool nebula;
final ValueChanged<bool> onPick;
@override
Widget build(BuildContext context) => SizedBox(
height: 44,
child: Row(
children: [
for (final (v, label) in [
(false, context.l10n.galaxyTabPlanet),
(true, context.l10n.galaxyTabNebula),
])
Expanded(
child: InkWell(
onTap: () => onPick(v),
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: v == nebula
? AppColors.cosmicTeal
: const Color(0xFF1A1A1A),
width: v == nebula ? 2 : 1,
),
),
),
child: Text(
label,
style: _t(
16,
v == nebula
? AppColors.cosmicTeal
: const Color(0xFFBBBBBB),
FontWeight.w500,
1,
),
),
),
),
),
],
),
);
}
/// 필터 칩 — 시안 32 높이 r8 · 선택 #ffffff(글자 검정) · 비선택 #1a1a1a · 12/500.
class _Filters extends StatelessWidget {
const _Filters({
required this.nebula,
required this.sort,
required this.onPick,
});
final bool nebula;
final _Sort sort;
final ValueChanged<_Sort> onPick;
@override
Widget build(BuildContext context) => SizedBox(
height: 48,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: [
for (final (v, label) in [
(_Sort.all, context.l10n.commonAll),
(_Sort.members, context.l10n.galaxySortMembers),
if (!nebula) (_Sort.fpi, 'FPI'),
]) ...[
GestureDetector(
onTap: () => onPick(v),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10),
alignment: Alignment.center,
decoration: BoxDecoration(
color: v == sort ? Colors.white : const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(8),
),
child: Text(
label,
style: _t(
12,
v == sort ? Colors.black : AppColors.onCosmic,
FontWeight.w500,
20 / 12,
),
),
),
),
const SizedBox(width: 4),
],
],
),
);
}
/// 결과 한 줄 — 시안 `353:218091`: 그림 70 · 간격 15 · 이름 16/600 · 규모 칩 · 사람 14 + 수 12/500 ·
/// 「FPI : n」 12/500, 오른쪽 「가입」(#ff7ddd) / 「가입중」(#1a1a1a) 알약 30 높이.
class _Hit extends StatelessWidget {
const _Hit({required this.planet, this.onTap});
const _Hit({
required this.planet,
required this.joined,
this.busy = false,
this.onJoin,
this.onTap,
});
final PlanetNode planet;
final bool joined;
final bool busy;
final VoidCallback? onJoin;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final nebula = planet.stage == 0;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(AppSpace.s4),
decoration: BoxDecoration(
color: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Row(
children: [
Text(
planet.rankLabel,
style: t.titleMedium?.copyWith(color: AppColors.cosmicAccent),
child: Row(
children: [
SizedBox.square(
dimension: 70,
child: PlanetArt(
fallback: nebula
? 'assets/figma/nebula-card.png'
: 'assets/figma/planet-detail-hero.png',
planetType: nebula ? null : planet.planetType,
colorKey: planet.colorKey,
semanticLabel: planet.name,
),
const SizedBox(width: AppSpace.s4),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(planet.name, style: t.bodyMedium),
Text(
context.l10n.galaxyMembers(formatPop(planet.members)),
style: t.labelSmall,
),
],
),
const SizedBox(width: 15),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
planet.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: _t(16, AppColors.onCosmic, FontWeight.w600, 1),
),
const SizedBox(height: 6),
Row(
children: [
if (!nebula) ...[
FandomSizeBadge(members: planet.members),
const SizedBox(width: 8),
],
const Icon(
Icons.person,
size: 14,
color: Color(0xFF777777),
),
const SizedBox(width: 3),
Text(
formatPop(planet.members),
style: _t(12, AppColors.onCosmic, FontWeight.w500, 1),
),
if (!nebula) ...[
const SizedBox(width: 8),
Text(
'FPI : ${formatPop(planet.fpi)}',
style: _t(12, AppColors.onCosmic, FontWeight.w500, 1),
),
],
],
),
],
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: busy ? null : onJoin,
child: Container(
height: 30,
padding: const EdgeInsets.symmetric(horizontal: 12),
alignment: Alignment.center,
decoration: BoxDecoration(
color: joined
? const Color(0xFF1A1A1A)
: AppColors.cosmicAccent,
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Text(
joined ? context.l10n.planetJoined : context.l10n.galaxyJoin,
style: _t(14, AppColors.onCosmic, FontWeight.w500, 1),
),
),
Text('FPI ${formatPop(planet.fpi)}', style: t.bodySmall),
],
),
),
],
),
);
}