QUEST · CAMPAIGN 화면 — 퀘스트 참여·생성·인증 · 캠페인 목록·상세·후원·랭킹
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user