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 createState() => _CampaignListScreenState(); } class _CampaignListScreenState extends State { final _scroll = ScrollController(); List _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 _load() async { setState(() => _loading = true); List 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 _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 _open(String id) async { await Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( builder: (_) => CampaignDetailScreen(campaignId: id, repository: widget.repository), ), ); if (mounted) await _load(); } Future _search() async { final id = await Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( builder: (_) => CampaignSearchScreen(repository: widget.repository), ), ); if (id != null && mounted) await _open(id); } Future _create() async { final id = await Navigator.of(context, rootNavigator: true).push( 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, ), ), ], ), ), ), ); }