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 createState() => _InsideSearchScreenState(); } class _InsideSearchScreenState extends State { final _controller = TextEditingController(); /// 최근 검색어. 기기 안에만 두는 값이다(갤럭시 검색과 같다). final List _recent = []; String? _term; InsideSort _sort = InsideSort.latest; List? _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 _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( 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 onSubmitted; final ValueChanged 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 terms; final VoidCallback onClear; final ValueChanged 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)), ], ), ), ], ); } }