INSIDE · STATION · 참여형 번역 · 알림 · MY 화면

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent 18afc9cb98
commit 3aca678072
31 changed files with 10046 additions and 25 deletions
@@ -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)),
],
),
),
],
);
}
}