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

365 lines
11 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/play_banner.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,
});
final GalaxyRepository repository;
/// 결과 줄을 누르면 그 행성 상세로 — 상세 화면 저장소는 부르는 쪽이 안다.
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 = '';
/// 전체 행성 검색 결과 — 입력이 멈춘 뒤 서버에 묻는다.
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();
}
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 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]),
),
);
},
);
},
),
),
);
}
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
Wrap(
spacing: AppSpace.s2,
runSpacing: AppSpace.s2,
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,
),
),
);
}
class _Hit extends StatelessWidget {
const _Hit({required this.planet, this.onTap});
final PlanetNode planet;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
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),
),
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,
),
],
),
),
Text('FPI ${formatPop(planet.fpi)}', style: t.bodySmall),
],
),
),
);
}
}