312 lines
9.3 KiB
Dart
312 lines
9.3 KiB
Dart
import 'package:flutter/material.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();
|
|
|
|
/// 최근 검색어. 기기 안에만 두는 값이라 서버가 붙어도 그대로다.
|
|
final List<String> _recent = [];
|
|
|
|
String _query = '';
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_snapshot = widget.repository.load();
|
|
_controller.addListener(() => setState(() => _query = _controller.text));
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_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();
|
|
});
|
|
}
|
|
|
|
@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());
|
|
}
|
|
final planets = snap.data?.planets ?? const <PlanetNode>[];
|
|
if (_query.trim().isEmpty) {
|
|
return _Recent(
|
|
terms: _recent,
|
|
onClear: () => setState(_recent.clear),
|
|
onPick: (t) {
|
|
_controller.text = t;
|
|
_submit(t);
|
|
},
|
|
);
|
|
}
|
|
final hits = planets
|
|
.where(
|
|
(p) =>
|
|
p.name.toLowerCase().contains(_query.trim().toLowerCase()),
|
|
)
|
|
.toList();
|
|
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.rank}',
|
|
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),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|