290 lines
8.4 KiB
Dart
290 lines
8.4 KiB
Dart
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)),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|