QUEST · CAMPAIGN 화면 — 퀘스트 참여·생성·인증 · 캠페인 목록·상세·후원·랭킹
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/ui/app_icons.dart';
|
||||
import 'campaign_create_screen.dart';
|
||||
import 'campaign_data.dart';
|
||||
import 'campaign_detail_screen.dart';
|
||||
import 'campaign_search_screen.dart';
|
||||
import 'widgets/campaign_list_card.dart';
|
||||
import 'widgets/campaign_style.dart';
|
||||
|
||||
/// HOT ARCHIVE — 화면 대조 원장 F5 · Figma `272:241184`.
|
||||
///
|
||||
/// 명세 `523:18327` — *뒤로가기 버튼 홈 화면 이동 · 검색 버튼 아카이브 검색 화면 이동*
|
||||
/// 명세 `523:18347` — *디폴트 최신순 선택 · 단일 선택*
|
||||
/// 명세 `523:18388` — *기본 10개 노출 · 아래 스크롤 시 10개 업데이트*
|
||||
/// 명세 `523:18410` — *새 캠페인 버튼 · 하단 중앙 정렬*
|
||||
class CampaignListScreen extends StatefulWidget {
|
||||
CampaignListScreen({super.key, CampaignRepository? repository})
|
||||
: repository = repository ?? SampleCampaignRepository();
|
||||
|
||||
final CampaignRepository repository;
|
||||
|
||||
@override
|
||||
State<CampaignListScreen> createState() => _CampaignListScreenState();
|
||||
}
|
||||
|
||||
class _CampaignListScreenState extends State<CampaignListScreen> {
|
||||
final _scroll = ScrollController();
|
||||
|
||||
List<CampaignCardData> _cards = const [];
|
||||
CampaignSort _sort = CampaignSort.latest;
|
||||
bool _loading = true;
|
||||
bool _more = false;
|
||||
bool _end = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scroll.addListener(() {
|
||||
if (_scroll.position.pixels > _scroll.position.maxScrollExtent - 240) {
|
||||
_loadMore();
|
||||
}
|
||||
});
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() => _loading = true);
|
||||
List<CampaignCardData> cards;
|
||||
try {
|
||||
cards = await widget.repository.load(sort: _sort);
|
||||
} on Object {
|
||||
// 불러오지 못하면 빈 목록으로 닫고 알린다. 스피너에 고착되면 고장으로 읽힌다.
|
||||
cards = const [];
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.campaignLoadFailed)),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_cards = cards;
|
||||
_loading = false;
|
||||
_end = cards.length < 10;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
if (_more || _end) return;
|
||||
setState(() => _more = true);
|
||||
final next = await widget.repository.more(offset: _cards.length);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_cards = [..._cards, ...next];
|
||||
_more = false;
|
||||
_end = next.isEmpty;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
// 시안(272:241184) — 제목 없는 Top Navigation 54(뒤로 · 검색 42 칸), 그 아래 칩 줄 36.
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
toolbarHeight: 54,
|
||||
leadingWidth: 61,
|
||||
leading: const Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: BackButton(),
|
||||
),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 13),
|
||||
child: IconButton(
|
||||
tooltip: context.l10n.commonSearch,
|
||||
icon: const ImageIcon(AppIcons.search, size: 24),
|
||||
onPressed: _search,
|
||||
),
|
||||
),
|
||||
],
|
||||
bottom: 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: () {
|
||||
setState(() => _sort = sort);
|
||||
_load();
|
||||
},
|
||||
),
|
||||
const SizedBox(width: AppSpace.s1),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _cards.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
_sort == CampaignSort.contributing
|
||||
? context.l10n.campaignEmptyContributing
|
||||
: context.l10n.campaignEmpty,
|
||||
style: t.bodyMedium,
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
controller: _scroll,
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 96),
|
||||
itemCount: _cards.length + (_more ? 1 : 0),
|
||||
separatorBuilder: (_, _) => const SizedBox(height: AppSpace.s5),
|
||||
itemBuilder: (context, i) {
|
||||
if (i >= _cards.length) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(AppSpace.s4),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
final card = _cards[i];
|
||||
return CampaignListCard(
|
||||
card: card,
|
||||
onOpen: () => _open(card.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: CampaignFab(
|
||||
label: context.l10n.campaignNew,
|
||||
onTap: _create,
|
||||
),
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _open(String id) async {
|
||||
await Navigator.of(context, rootNavigator: true).push<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
CampaignDetailScreen(campaignId: id, repository: widget.repository),
|
||||
),
|
||||
);
|
||||
if (mounted) await _load();
|
||||
}
|
||||
|
||||
Future<void> _search() async {
|
||||
final id = await Navigator.of(context, rootNavigator: true).push<String>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CampaignSearchScreen(repository: widget.repository),
|
||||
),
|
||||
);
|
||||
if (id != null && mounted) await _open(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 _load();
|
||||
}
|
||||
}
|
||||
|
||||
/// 정렬 칩 아이콘 — 시계 · 하트 · 번개(시안 아이콘 16).
|
||||
Widget campaignSortIcon(CampaignSort sort) => switch (sort) {
|
||||
CampaignSort.latest => const ImageIcon(
|
||||
AssetImage('assets/figma/campaign-clock.png'),
|
||||
),
|
||||
CampaignSort.popular => const Icon(Icons.favorite_border),
|
||||
CampaignSort.contributing => const ImageIcon(
|
||||
AssetImage('assets/figma/icon-bolt.png'),
|
||||
),
|
||||
};
|
||||
|
||||
/// 정렬 칩 — 시안 Chip: 높이 32 · r8 · 안쪽 6/10 · 아이콘 16 · 글자 12/500(줄 20).
|
||||
/// 선택 흰 바탕·검정 글자, 비선택 #1a1a1a · 획 #232324 · #d5d7d9. 테스트가 selected 를 읽는다.
|
||||
class CampaignSortChip extends StatelessWidget {
|
||||
const CampaignSortChip({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Widget icon;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fg = selected ? Colors.black : const Color(0xFFD5D7D9);
|
||||
return Material(
|
||||
color: selected ? AppColors.onCosmic : const Color(0xFF1A1A1A),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
side: const BorderSide(color: AppColors.cosmicSurfaceRaised),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
child: Container(
|
||||
height: 32,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconTheme(
|
||||
data: IconThemeData(size: 16, color: fg),
|
||||
child: icon,
|
||||
),
|
||||
const SizedBox(width: AppSpace.s1),
|
||||
Text(
|
||||
label,
|
||||
style: campaignText(12, 500, fg, height: 20 / 12, spacing: 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 하단 가운데 알약 버튼 — 시안 `새 캠페인`: #19191c · 획 #232324 · 안쪽 8/16 · 간격 6 ·
|
||||
/// 핑크 연필 16 · 글자 14/500 #a3a2a0.
|
||||
class CampaignFab extends StatelessWidget {
|
||||
const CampaignFab({super.key, required this.label, required this.onTap});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Material(
|
||||
color: const Color(0xFF19191C),
|
||||
shape: const StadiumBorder(
|
||||
side: BorderSide(color: AppColors.cosmicSurfaceRaised),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
customBorder: const StadiumBorder(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Image.asset('assets/figma/icon-pencil.png', width: 16, height: 16),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: campaignText(
|
||||
14,
|
||||
500,
|
||||
AppColors.onCosmicVariant,
|
||||
height: 20 / 14,
|
||||
spacing: 0,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user