Files
fanuzu/app/lib/features/inside/inside_search_screen.dart
T

290 lines
8.4 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 'package:flutter/material.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart';
import '../../core/ui/play_banner.dart';
import '../translation/translation_data.dart';
import 'inside_data.dart';
import 'inside_detail_screen.dart';
import 'inside_screen.dart';
import 'inside_ui.dart';
/// INSIDE 검색 — Figma `200:48573` Inside/검색 · `201:49535` 검색 결과.
///
/// 검색어를 넣기 전에는 배너와 최근 검색어, 넣은 뒤에는 정렬 칩과 같은 카드 목록이다.
///
/// **서버 검색 경로가 없다.** `questions` 에 제목·본문 전문 검색 색인이 없어서, 지금은
/// 정렬별 첫 장을 불러와 제목·본문에 검색어가 든 것만 거른다.
/// ponytail: 첫 장 안에서만 찾는다 — 서버 검색(fn 또는 색인)이 생기면 [_run] 만 바꾼다.
class InsideSearchScreen extends StatefulWidget {
const InsideSearchScreen({
super.key,
this.repository = const SampleInsideRepository(),
this.translation,
});
final InsideRepository repository;
final TranslationRepository? translation;
@override
State<InsideSearchScreen> createState() => _InsideSearchScreenState();
}
class _InsideSearchScreenState extends State<InsideSearchScreen> {
final _controller = TextEditingController();
/// 최근 검색어. 기기 안에만 두는 값이다(갤럭시 검색과 같다).
final List<String> _recent = [];
String? _term;
InsideSort _sort = InsideSort.latest;
List<InsideQuestion>? _hits;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _submit(String q) {
final term = q.trim();
if (term.isEmpty) return;
setState(() {
_term = term;
_recent
..remove(term)
..insert(0, term);
if (_recent.length > 10) _recent.removeLast();
});
_run();
}
Future<void> _run() async {
final term = _term;
if (term == null) return;
setState(() => _hits = null);
final snap = await widget.repository.load(sort: _sort);
if (!mounted || term != _term) return;
final k = term.toLowerCase();
setState(
() => _hits = [
for (final q in snap.questions)
if (q.title.toLowerCase().contains(k) ||
q.body.toLowerCase().contains(k))
q,
],
);
}
void _clear() {
if (_controller.text.isEmpty) return;
_controller.clear();
setState(() {
_term = null;
_hits = null;
});
}
@override
Widget build(BuildContext context) {
final term = _term;
final hits = _hits;
return Scaffold(
appBar: AppBar(
toolbarHeight: 54,
leadingWidth: 58,
leading: Padding(
padding: const EdgeInsets.only(left: AppSpace.s4),
child: BackButton(onPressed: () => Navigator.of(context).maybePop()),
),
titleSpacing: 0,
title: _SearchField(
controller: _controller,
onSubmitted: _submit,
onChanged: (v) {
if (v.isEmpty) _clear();
},
),
actions: const [SizedBox(width: AppSpace.s4)],
bottom: term == null
? null
: PreferredSize(
preferredSize: const Size.fromHeight(36),
child: InsideSortBar(
sort: _sort,
onChanged: (s) {
setState(() => _sort = s);
_run();
},
),
),
),
body: term == null
? _Recent(
terms: _recent,
onClear: () => setState(_recent.clear),
onPick: (t) {
_controller.text = t;
_submit(t);
},
)
: hits == null
? const Center(child: CircularProgressIndicator())
: hits.isEmpty
? Center(
child: Text(
context.l10n.commonNoResults,
style: insideText(14, color: const Color(0xFF6E6E6E)),
),
)
: InsideQuestionList(
questions: hits,
onOpen: (q) => Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => InsideDetailScreen(
question: q,
repository: widget.repository,
translation: widget.translation,
),
),
),
),
);
}
}
/// 검색 칸 — 시안 301×42 · #282828 · 모서리 10 · 돋보기 20 · 16/500.
class _SearchField extends StatelessWidget {
const _SearchField({
required this.controller,
required this.onSubmitted,
required this.onChanged,
});
final TextEditingController controller;
final ValueChanged<String> onSubmitted;
final ValueChanged<String> onChanged;
static const _hint = Color(0xFF6E6E6E);
@override
Widget build(BuildContext context) {
final style = insideText(16, height: 1.4).copyWith(letterSpacing: 0);
return Container(
height: 42,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: const Color(0xFF282828),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
const ImageIcon(AppIcons.search, size: 20, color: _hint),
const SizedBox(width: 6),
Expanded(
child: TextField(
controller: controller,
autofocus: true,
textInputAction: TextInputAction.search,
onSubmitted: onSubmitted,
onChanged: onChanged,
style: style,
cursorColor: AppColors.cosmicAccent,
decoration: InputDecoration(
isCollapsed: true,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
hintText: context.l10n.insideSearchHint,
hintStyle: style.copyWith(color: _hint),
),
),
),
],
),
);
}
}
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) {
return ListView(
padding: const EdgeInsets.symmetric(vertical: AppSpace.s4),
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: 9),
child: PlayBanner(),
),
const SizedBox(height: AppSpace.s5),
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
context.l10n.commonRecentSearches,
style: insideText(
16,
weight: FontWeight.w600,
height: 20 / 16,
).copyWith(letterSpacing: 0),
),
GestureDetector(
onTap: onClear,
child: Text(
context.l10n.commonClearAll,
style: insideText(
14,
color: const Color(0xFFC2C2C2),
height: 20 / 14,
).copyWith(letterSpacing: 0),
),
),
],
),
),
const SizedBox(height: AppSpace.s4),
if (terms.isEmpty)
SizedBox(
height: 140,
child: Center(
child: Text(
context.l10n.commonNoRecentSearches,
style: insideText(
14,
weight: FontWeight.w600,
color: const Color(0xFF6E6E6E),
height: 20 / 14,
).copyWith(letterSpacing: 0),
),
),
)
else
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
child: Wrap(
spacing: AppSpace.s2,
runSpacing: AppSpace.s2,
children: [
for (final term in terms)
ActionChip(label: Text(term), onPressed: () => onPick(term)),
],
),
),
],
);
}
}