Files
fanuzu/app/lib/features/campaign/campaign_search_screen.dart

322 lines
10 KiB
Dart

import 'package:flutter/material.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/play_banner.dart';
import '../../core/ui/app_icons.dart';
import 'campaign_create_screen.dart';
import 'campaign_data.dart';
import 'campaign_list_screen.dart';
import 'widgets/campaign_list_card.dart';
import 'widgets/campaign_style.dart';
/// 아카이브 검색 — 화면 대조 원장 F5.
///
/// Figma 노드 `272:241681`(검색) · `272:241724`(검색 결과).
/// **두 화면을 하나로 둔다** — Figma 가 프레임을 나눠 두었으나 사용자에게는
/// 같은 화면의 두 상태이며, 따로 만들면 검색어 입력창이 두 벌이 된다.
///
/// 명세 `523:18429` — *가장 최근 검색 결과 데이터가 첫번째 위치 · 최신순 노출 및*
/// *중복 제거 · 데이터가 없을 경우 「최신 검색어가 없습니다」 노출*
///
/// 검색 결과 시안은 목록과 같은 정렬 칩 · 카드 · 「새 캠페인」 버튼을 쓴다.
/// 명세 `523:18422` 의 광고 배너(AdMob Native)는 SDK 미도입이라 그리지 않는다.
class CampaignSearchScreen extends StatefulWidget {
CampaignSearchScreen({super.key, CampaignRepository? repository})
: repository = repository ?? SampleCampaignRepository();
final CampaignRepository repository;
@override
State<CampaignSearchScreen> createState() => _CampaignSearchScreenState();
}
class _CampaignSearchScreenState extends State<CampaignSearchScreen> {
final _query = TextEditingController();
List<String> _recent = const [];
List<CampaignCardData>? _results;
List<CampaignCardData> _sorted = const [];
CampaignSort _sort = CampaignSort.latest;
@override
void initState() {
super.initState();
_loadRecent();
}
@override
void dispose() {
_query.dispose();
super.dispose();
}
Future<void> _loadRecent() async {
final recent = await widget.repository.recentQueries();
if (mounted) setState(() => _recent = recent);
}
Future<void> _search(String raw) async {
final query = raw.trim();
if (query.isEmpty) return;
_query.text = query;
final results = await widget.repository.search(query);
if (!mounted) return;
setState(() => _results = results);
await _applySort(_sort);
await _loadRecent();
}
/// 결과 안에서 정렬한다. 「기여중」은 내 후원 원장을 아는 저장소 목록과 겹쳐 본다.
Future<void> _applySort(CampaignSort sort) async {
final results = _results ?? const <CampaignCardData>[];
var list = [...results];
switch (sort) {
case CampaignSort.latest:
list.sort((a, b) => b.createdAt.compareTo(a.createdAt));
case CampaignSort.popular:
list.sort((a, b) => a.comparePopularity(b));
case CampaignSort.contributing:
final mine = {
for (final c in await widget.repository.load(sort: sort)) c.id,
};
list = list.where((c) => mine.contains(c.id)).toList();
}
if (mounted) {
setState(() {
_sort = sort;
_sorted = list;
});
}
}
@override
Widget build(BuildContext context) {
final results = _results;
// 시안 — 뒤로 42 칸 옆에 #282828 r10 검색 칸(높이 42 · 돋보기 20 · 글자 16/500).
return Scaffold(
appBar: AppBar(
toolbarHeight: 54,
leadingWidth: 58,
titleSpacing: 0,
leading: const Align(
alignment: Alignment.centerRight,
child: BackButton(),
),
title: 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: AppColors.onCosmicVariant,
),
const SizedBox(width: 6),
Expanded(
child: TextField(
controller: _query,
autofocus: true,
textInputAction: TextInputAction.search,
style: campaignText(
16,
500,
AppColors.onCosmic,
height: 1.4,
spacing: 0,
),
decoration: InputDecoration(
hintText: context.l10n.campaignSearchHint,
hintStyle: campaignText(
16,
500,
AppColors.onCosmicMuted,
height: 1.4,
spacing: 0,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
filled: false,
isDense: true,
contentPadding: EdgeInsets.zero,
),
onSubmitted: _search,
),
),
],
),
),
actions: const [SizedBox(width: 16)],
bottom: results == null
? null
: PreferredSize(
preferredSize: const Size.fromHeight(36),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 2, 16, 2),
child: Row(
children: [
for (final sort in CampaignSort.values) ...[
CampaignSortChip(
label: sort.label,
icon: campaignSortIcon(sort),
selected: _sort == sort,
onTap: () => _applySort(sort),
),
const SizedBox(width: AppSpace.s1),
],
],
),
),
),
),
body: results == null ? _recentView() : _resultView(),
floatingActionButton: results == null
? null
: CampaignFab(label: context.l10n.campaignNew, onTap: _create),
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
);
}
// 시안 `272:241681` — 배너만 좌우 9(357 폭), 나머지는 16 · 배너 아래 24.
Widget _recentView() => ListView(
padding: const EdgeInsets.fromLTRB(9, 16, 9, 16),
children: [
const PlayBanner(),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 7),
child: _recentBody(),
),
],
);
Widget _recentBody() => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
context.l10n.commonRecentSearches,
style: campaignText(
16,
600,
AppColors.onCosmic,
height: 20 / 16,
spacing: 0,
),
),
if (_recent.isNotEmpty)
GestureDetector(
onTap: () async {
await widget.repository.clearQueries();
await _loadRecent();
},
child: Text(
context.l10n.commonClearAll,
style: campaignText(
14,
500,
const Color(0xFFC2C2C2),
height: 20 / 14,
spacing: 0,
),
),
),
],
),
const SizedBox(height: AppSpace.s4),
if (_recent.isEmpty)
Text(
context.l10n.campaignNoRecentSearches,
style: campaignText(14, 500, AppColors.onCosmicVariant),
)
else
Wrap(
spacing: 10,
runSpacing: 10,
children: [for (final q in _recent) _queryChip(q)],
),
],
);
/// 최근 검색어 칩 — 획 #414141 알약 · 안쪽 6/14 · 글자 15/500 #c2c2c2 · ✕ 18.
Widget _queryChip(String q) => Material(
color: Colors.transparent,
shape: const StadiumBorder(side: BorderSide(color: Color(0xFF414141))),
child: InkWell(
onTap: () => _search(q),
customBorder: const StadiumBorder(),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 6, 14, 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
q,
style: campaignText(
15,
500,
const Color(0xFFC2C2C2),
height: 1.4,
spacing: 0,
),
),
const SizedBox(width: 10),
InkResponse(
onTap: () async {
await widget.repository.removeQuery(q);
await _loadRecent();
},
radius: 14,
child: Tooltip(
message: context.l10n.searchRemoveTerm(q),
child: const ImageIcon(
AppIcons.x,
size: 18,
color: Color(0xFFC2C2C2),
),
),
),
],
),
),
),
);
Widget _resultView() {
if (_sorted.isEmpty) {
return Center(
child: Text(
context.l10n.campaignNoResults,
style: campaignText(14, 500, AppColors.onCosmicVariant),
),
);
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 96),
itemCount: _sorted.length,
separatorBuilder: (_, _) => const SizedBox(height: AppSpace.s5),
itemBuilder: (context, i) => CampaignListCard(
card: _sorted[i],
onOpen: () => Navigator.of(context).pop(_sorted[i].id),
),
);
}
Future<void> _create() async {
final id = await Navigator.of(context, rootNavigator: true).push<String>(
MaterialPageRoute(
builder: (_) => CampaignCreateScreen(repository: widget.repository),
),
);
if (id != null && mounted) await _search(_query.text);
}
}