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

619 lines
20 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 '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),
),
),
),
],
),
);
}
}