Files
fanuzu/app/lib/features/campaign/campaign_list_screen.dart
T

303 lines
9.3 KiB
Dart

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,
),
),
],
),
),
),
);
}