619 lines
20 KiB
Dart
619 lines
20 KiB
Dart
import 'dart:async';
|
||
|
||
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, planetFallbackImage;
|
||
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';
|
||
|
||
/// 갤럭시 검색 — Figma `03_Wireframe` 「Galaxy_검색」(`353:217832`).
|
||
///
|
||
/// 화면 대조 원장 F2 의 11번. 명세 *돋보기 — 검색페이지 이동*.
|
||
///
|
||
/// Figma 검색 화면이 공통으로 갖는 것을 그대로 따랐다 —
|
||
/// 「최근 검색어 / 전체 삭제 / 최근 검색어가 없습니다.」
|
||
class GalaxySearchScreen extends StatefulWidget {
|
||
const GalaxySearchScreen({
|
||
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;
|
||
|
||
@override
|
||
State<GalaxySearchScreen> createState() => _GalaxySearchScreenState();
|
||
}
|
||
|
||
class _GalaxySearchScreenState extends State<GalaxySearchScreen> {
|
||
late Future<GalaxySnapshot> _snapshot;
|
||
final _controller = TextEditingController();
|
||
|
||
/// 최근 검색어 — 기기에 남긴다(재진입해도 유지). 명세 `523:18429` 「최신순 · 중복 제거」.
|
||
final List<String> _recent = [];
|
||
static const _recentKey = 'galaxyRecentSearches';
|
||
|
||
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;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_snapshot = widget.repository.load();
|
||
_controller.addListener(_onChanged);
|
||
_loadRecent();
|
||
}
|
||
|
||
Future<void> _loadRecent() async {
|
||
try {
|
||
final saved = (await SharedPreferences.getInstance()).getStringList(
|
||
_recentKey,
|
||
);
|
||
if (mounted && saved != null)
|
||
setState(
|
||
() => _recent
|
||
..clear()
|
||
..addAll(saved),
|
||
);
|
||
} catch (_) {
|
||
// 기기 저장소를 못 쓰면 이번 화면 안에서만 기억한다.
|
||
}
|
||
}
|
||
|
||
Future<void> _saveRecent() async {
|
||
try {
|
||
await (await SharedPreferences.getInstance()).setStringList(
|
||
_recentKey,
|
||
_recent,
|
||
);
|
||
} catch (_) {}
|
||
}
|
||
|
||
void _onChanged() {
|
||
if (_controller.text == _query) return;
|
||
setState(() => _query = _controller.text);
|
||
_debounce?.cancel();
|
||
_debounce = Timer(const Duration(milliseconds: 300), () {
|
||
if (!mounted) return;
|
||
final q = _query.trim();
|
||
setState(() {
|
||
_hits = q.isEmpty ? null : _search(q);
|
||
});
|
||
});
|
||
}
|
||
|
||
/// 서버 결과에 맵 스냅샷의 순위를 입힌다 — 맵 상위에 있는 행성은 같은 순위로 보인다.
|
||
Future<List<PlanetNode>> _search(String q) async {
|
||
final hits = await widget.repository.search(q);
|
||
final known = {for (final p in (await _snapshot).planets) p.id: p};
|
||
return [for (final h in hits) known[h.id] ?? h];
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_debounce?.cancel();
|
||
_controller.dispose();
|
||
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;
|
||
setState(() {
|
||
_recent
|
||
..remove(term)
|
||
..insert(0, term);
|
||
if (_recent.length > 10) _recent.removeLast();
|
||
});
|
||
_saveRecent();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Scaffold(
|
||
// 시안 `Top Navigation`(54 · 좌우 16) — 뒤로 42 + 검색 칸 #282828 r10 높이 42.
|
||
appBar: AppBar(
|
||
toolbarHeight: 54,
|
||
automaticallyImplyLeading: false,
|
||
titleSpacing: 16,
|
||
centerTitle: false,
|
||
title: Row(
|
||
children: [
|
||
const SizedBox.square(dimension: 42, child: BackButton()),
|
||
Expanded(
|
||
child: Container(
|
||
height: 42,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFF282828),
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Image.asset(
|
||
'assets/figma/icon-search-20.png',
|
||
width: 20,
|
||
height: 20,
|
||
),
|
||
const SizedBox(width: 6),
|
||
Expanded(
|
||
child: TextField(
|
||
controller: _controller,
|
||
autofocus: true,
|
||
textInputAction: TextInputAction.search,
|
||
onSubmitted: _submit,
|
||
style: _t(16, AppColors.onCosmic, FontWeight.w500, 1.4),
|
||
decoration: InputDecoration(
|
||
hintText: context.l10n.galaxySearchHint,
|
||
hintStyle: _t(
|
||
16,
|
||
const Color(0xFF6E6E6E),
|
||
FontWeight.w500,
|
||
1.4,
|
||
),
|
||
border: InputBorder.none,
|
||
enabledBorder: InputBorder.none,
|
||
focusedBorder: InputBorder.none,
|
||
filled: false,
|
||
isDense: true,
|
||
contentPadding: EdgeInsets.zero,
|
||
),
|
||
),
|
||
),
|
||
if (_query.isNotEmpty)
|
||
GestureDetector(
|
||
onTap: _controller.clear,
|
||
child: const Icon(
|
||
Icons.close,
|
||
size: 18,
|
||
color: Color(0xFF6E6E6E),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
body: SafeArea(
|
||
child: FutureBuilder<GalaxySnapshot>(
|
||
future: _snapshot,
|
||
builder: (context, snap) {
|
||
if (snap.connectionState != ConnectionState.done) {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
if (_query.trim().isEmpty) {
|
||
return _Recent(
|
||
terms: _recent,
|
||
onClear: () {
|
||
setState(_recent.clear);
|
||
_saveRecent();
|
||
},
|
||
onPick: (t) {
|
||
_controller.text = t;
|
||
_submit(t);
|
||
},
|
||
);
|
||
}
|
||
return FutureBuilder<List<PlanetNode>>(
|
||
future: _hits,
|
||
builder: (context, result) {
|
||
if (_hits == null ||
|
||
result.connectionState != ConnectionState.done) {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
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),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
},
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
TextStyle _t(double size, Color color, FontWeight weight, double height) =>
|
||
TextStyle(
|
||
fontFamily: AppFonts.body,
|
||
fontSize: size,
|
||
fontWeight: weight,
|
||
height: height,
|
||
color: color,
|
||
);
|
||
|
||
/// 최근 검색어 — 시안 `Frame 4241`(안쪽 16 · 간격 24): 배너, 「최근 검색어 16/600 ·
|
||
/// 전체 삭제 14/500 #c2c2c2」, 비었으면 140 칸 가운데 「최근 검색어가 없습니다.」 14/600 #6e6e6e.
|
||
class _Recent extends StatelessWidget {
|
||
const _Recent({
|
||
required this.terms,
|
||
required this.onClear,
|
||
required this.onPick,
|
||
});
|
||
|
||
final List<String> terms;
|
||
final VoidCallback onClear;
|
||
final ValueChanged<String> onPick;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => ListView(
|
||
// 시안 배너만 좌우 9(357 폭), 나머지는 16.
|
||
padding: const EdgeInsets.fromLTRB(9, 16, 9, 16),
|
||
children: [
|
||
const PlayBanner(),
|
||
const SizedBox(height: 24),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 7),
|
||
child: _body(context),
|
||
),
|
||
],
|
||
);
|
||
|
||
Widget _body(BuildContext context) => Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text(
|
||
context.l10n.commonRecentSearches,
|
||
style: _t(16, AppColors.onCosmic, FontWeight.w600, 20 / 16),
|
||
),
|
||
GestureDetector(
|
||
onTap: onClear,
|
||
child: Text(
|
||
context.l10n.commonClearAll,
|
||
style: _t(14, const Color(0xFFC2C2C2), FontWeight.w500, 20 / 14),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: AppSpace.s4),
|
||
if (terms.isEmpty)
|
||
SizedBox(
|
||
height: 140,
|
||
child: Center(
|
||
child: Text(
|
||
context.l10n.commonNoRecentSearches,
|
||
style: _t(14, const Color(0xFF6E6E6E), FontWeight.w600, 20 / 14),
|
||
),
|
||
),
|
||
)
|
||
else
|
||
// 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)),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
class _NoHit extends StatelessWidget {
|
||
const _NoHit();
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(AppSpace.s5),
|
||
child: Text(
|
||
context.l10n.galaxyNoHit,
|
||
style: Theme.of(context).textTheme.bodySmall,
|
||
textAlign: TextAlign.center,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
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,
|
||
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 nebula = planet.stage == 0;
|
||
return GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: onTap,
|
||
child: Row(
|
||
children: [
|
||
SizedBox.square(
|
||
dimension: 70,
|
||
child: PlanetArt(
|
||
fallback: planetFallbackImage(planet.id, nebula: nebula),
|
||
planetType: nebula ? null : planet.planetType,
|
||
colorKey: planet.colorKey,
|
||
semanticLabel: planet.name,
|
||
),
|
||
),
|
||
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),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|