303 lines
9.3 KiB
Dart
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|