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 createState() => _CampaignSearchScreenState(); } class _CampaignSearchScreenState extends State { final _query = TextEditingController(); List _recent = const []; List? _results; List _sorted = const []; CampaignSort _sort = CampaignSort.latest; @override void initState() { super.initState(); _loadRecent(); } @override void dispose() { _query.dispose(); super.dispose(); } Future _loadRecent() async { final recent = await widget.repository.recentQueries(); if (mounted) setState(() => _recent = recent); } Future _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 _applySort(CampaignSort sort) async { final results = _results ?? const []; 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 _create() async { final id = await Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( builder: (_) => CampaignCreateScreen(repository: widget.repository), ), ); if (id != null && mounted) await _search(_query.text); } }