INSIDE · STATION · 참여형 번역 · 알림 · MY 화면
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user