import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; import 'package:easy_refresh/easy_refresh.dart'; import '../../core/theme/app_colors_extension.dart'; import '../../core/utils/date_utils.dart' as du; import '../../core/utils/responsive.dart'; import '../../shared/widgets/list_card.dart'; import '../../shared/widgets/empty_state.dart'; import '../../shared/widgets/app_skeletons.dart'; import '../../shared/widgets/list_footer.dart'; import '../../core/i18n/app_localizations.dart'; import '../../core/utils/amount_utils.dart'; import 'expense_list_controller.dart'; import 'expense_model.dart'; import 'expense_api.dart'; class ExpenseListPage extends ConsumerStatefulWidget { const ExpenseListPage({super.key}); @override ConsumerState createState() => _ExpenseListPageState(); } class _ExpenseListPageState extends ConsumerState { String _keyword = ''; final _startDateCtrl = TextEditingController(); final _endDateCtrl = TextEditingController(); late final EasyRefreshController _refreshCtrl; bool _firstBuild = true; bool _invalidateDone = false; @override void initState() { super.initState(); final now = DateTime.now(); _startDateCtrl.text = '${now.year}-${now.month.toString().padLeft(2, '0')}-01'; _endDateCtrl.text = '${now.year}-${now.month.toString().padLeft(2, '0')}-${_daysInMonth(now.year, now.month).toString().padLeft(2, '0')}'; _refreshCtrl = EasyRefreshController(); WidgetsBinding.instance.addPostFrameCallback((_) { ref.read(expenseDateStartProvider.notifier).state = DateTime( now.year, now.month, 1, ); ref.read(expenseDateEndProvider.notifier).state = DateTime( now.year, now.month, _daysInMonth(now.year, now.month), ); ref.invalidate(expenseMyListProvider('')); _invalidateDone = true; setState(() {}); }); } @override void dispose() { _startDateCtrl.dispose(); _endDateCtrl.dispose(); _refreshCtrl.dispose(); super.dispose(); } void _pickDate(TextEditingController ctrl) { FocusManager.instance.primaryFocus?.unfocus(); final l10n = AppLocalizations.of(context); final colors = Theme.of(context).extension()!; final now = DateTime.now(); TDPicker.showDatePicker( context, title: l10n.get('selectDate'), backgroundColor: colors.bgCard, useYear: true, useMonth: true, useDay: true, useHour: false, useMinute: false, useSecond: false, useWeekDay: false, dateStart: const [2020, 1, 1], dateEnd: [now.year + 1, 12, 31], initialDate: [now.year, now.month, now.day], onConfirm: (selected) { ctrl.text = '${selected['year']}-${selected['month'].toString().padLeft(2, '0')}-${selected['day'].toString().padLeft(2, '0')}'; setState(() {}); Navigator.of(context).pop(); }, ); } int _daysInMonth(int year, int month) => DateTime(year, month + 1, 0).day; void _applyFilter() { FocusManager.instance.primaryFocus?.unfocus(); _refreshCtrl.callRefresh(); } Future _doRefresh() async { ref.read(expenseKeywordProvider.notifier).state = _keyword; ref .read(expenseDateStartProvider.notifier) .state = _startDateCtrl.text.isNotEmpty ? DateTime.tryParse(_startDateCtrl.text) : null; ref .read(expenseDateEndProvider.notifier) .state = _endDateCtrl.text.isNotEmpty ? DateTime.tryParse(_endDateCtrl.text) : null; ref.read(expenseRefreshProvider.notifier).state++; } Widget _dateChip( TextEditingController ctrl, String hint, TDThemeData tdTheme, AppColorsExtension colors, ) { final text = ctrl.text; return Container( height: 40, padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( color: colors.bgSecondaryContainer, borderRadius: BorderRadius.circular(20), border: Border.all(color: tdTheme.componentStrokeColor), ), child: Row( children: [ Icon(Icons.calendar_today, size: 16, color: colors.textSecondary), const SizedBox(width: 6), Expanded( child: Text( text.isNotEmpty ? text : hint, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14, color: text.isNotEmpty ? colors.textPrimary : colors.textSecondary, ), ), ), if (text.isNotEmpty) GestureDetector( onTap: () { ctrl.clear(); setState(() {}); }, child: Icon(Icons.close, size: 18, color: colors.textSecondary), ), ], ), ); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final colors = Theme.of(context).extension()!; final tdTheme = TDTheme.of(context); if (_invalidateDone) { ref.listen(expenseMyListProvider(''), (prev, next) { if (_firstBuild && !next.isLoading && !next.isReloading) { setState(() => _firstBuild = false); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) setState(() {}); }); } }); } return Stack( children: [ Column( children: [ Container( decoration: BoxDecoration( color: colors.bgCard, border: Border( bottom: BorderSide(color: tdTheme.componentStrokeColor), ), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: Row( children: [ Expanded( child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => _pickDate(_startDateCtrl), child: _dateChip( _startDateCtrl, l10n.get('filterStartDate'), tdTheme, colors, ), ), ), const SizedBox(width: 8), Text( '—', style: TextStyle( fontSize: 14, color: colors.textSecondary, ), ), const SizedBox(width: 8), Expanded( child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => _pickDate(_endDateCtrl), child: _dateChip( _endDateCtrl, l10n.get('filterEndDate'), tdTheme, colors, ), ), ), ], ), ), const SizedBox(height: 8), Padding( padding: const EdgeInsets.fromLTRB(0, 0, 12, 8), child: Row( children: [ Expanded( child: TDSearchBar( placeHolder: l10n.get('searchExpense'), style: TDSearchStyle.round, onTextChanged: (String text) { setState(() { _keyword = text; }); if (text.isEmpty) _applyFilter(); }, onSubmitted: (_) => _applyFilter(), ), ), GestureDetector( onTap: () { final dir = ref.read(expenseSortDirProvider); ref.read(expenseSortDirProvider.notifier).state = dir == 'ASC' ? 'DESC' : 'ASC'; _applyFilter(); }, child: Container( width: 40, height: 40, decoration: BoxDecoration( color: colors.primary, borderRadius: BorderRadius.circular(20), ), child: Center( child: Icon( ref.watch(expenseSortDirProvider) == 'ASC' ? Icons.arrow_upward : Icons.arrow_downward, color: Colors.white, size: 20, ), ), ), ), ], ), ), ], ), ), Expanded( child: Container( color: colors.bgPage, child: _firstBuild ? const Center(child: SkeletonLoadingList()) : _ExpenseListContent( refreshCtrl: _refreshCtrl, onRefresh: _doRefresh, ), ), ), ], ), Positioned( right: 16, bottom: 16, child: FloatingActionButton( onPressed: () => context.push('/report/expense-detail'), backgroundColor: colors.primary, shape: const CircleBorder(), child: const Icon(Icons.bar_chart, color: Colors.white), ), ), ], ); } } class _ExpenseListContent extends ConsumerWidget { final EasyRefreshController refreshCtrl; final Future Function() onRefresh; const _ExpenseListContent({ required this.refreshCtrl, required this.onRefresh, }); @override Widget build(BuildContext context, WidgetRef ref) { final r = ResponsiveHelper.of(context); final itemsAsync = ref.watch(expenseMyListProvider('')); if (itemsAsync.isLoading && !itemsAsync.hasValue) { return Center( child: ConstrainedBox( constraints: BoxConstraints(maxWidth: r.listMaxWidth), child: const SkeletonLoadingList(), ), ); } return Center( child: ConstrainedBox( constraints: BoxConstraints(maxWidth: r.listMaxWidth), child: EasyRefresh( controller: refreshCtrl, header: TDRefreshHeader(), onRefresh: onRefresh, child: _buildContent(itemsAsync, context, ref), ), ), ); } Widget _buildContent( AsyncValue> itemsAsync, BuildContext context, WidgetRef ref, ) { final l10n = AppLocalizations.of(context); if (itemsAsync.isReloading) { final oldItems = itemsAsync.valueOrNull ?? []; if (oldItems.isEmpty) { return ListView( children: [ const SizedBox(height: 120), EmptyState(message: l10n.get('noExpenses')), ], ); } return ListView.builder( padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), itemCount: oldItems.length, itemBuilder: (_, i) { final item = oldItems[i]; final applicant = item.deptName.isNotEmpty ? '${item.applicantName} · ${item.deptName}' : item.applicantName; final card = ListCard( cardNo: item.expenseNo, amount: formatAmount(item.totalAmount), subAmount: item.totalAmtnSh > 0 ? formatAmount(item.totalAmtnSh) : null, applicant: applicant, description: item.purpose, date: du.DateUtils.formatDate(item.expenseDate ?? item.createTime), statusTag: _buildStatusTags(item, l10n, context), onTap: () { context.push('/expense/detail/${item.expenseNo}'); }, ); return Padding( padding: const EdgeInsets.only(bottom: 16), child: card, ); }, ); } if (itemsAsync.hasError) { return ListView( children: [ const SizedBox(height: 120), EmptyState(message: l10n.get('loadFailed')), ], ); } final items = itemsAsync.requireValue; if (items.isEmpty) { return ListView( children: [ const SizedBox(height: 120), EmptyState(message: l10n.get('noExpenses')), ], ); } return ListView.builder( padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), itemCount: items.length + 1, itemBuilder: (_, i) { if (i == items.length) return ListFooter(itemCount: items.length); final item = items[i]; final applicant = item.deptName.isNotEmpty ? '${item.applicantName} · ${item.deptName}' : item.applicantName; final card = ListCard( cardNo: item.expenseNo, amount: formatAmount(item.totalAmount), subAmount: item.totalAmtnSh > 0 ? formatAmount(item.totalAmtnSh) : null, applicant: applicant, description: item.purpose, date: du.DateUtils.formatDate(item.expenseDate ?? item.createTime), statusTag: _buildStatusTags(item, l10n, context), onTap: () { context.push('/expense/detail/${item.expenseNo}'); }, ); return Padding(padding: const EdgeInsets.only(bottom: 16), child: card); }, ); } /// 构建状态 tag,放在卡片底部日期旁边 Widget _buildStatusTags( ExpenseModel item, AppLocalizations l10n, BuildContext context, ) { final tags = []; final colors = Theme.of(context).extension()!; if (item.isTransferred == 1) { tags.add( Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: colors.primary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4), ), child: Text( l10n.get('statusTransferred'), style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: colors.primary), ), ), ); } if (item.isClosed == 1) { tags.add( Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: colors.warning.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4), ), child: Text( l10n.get('statusClosed'), style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: colors.warning), ), ), ); } if (item.isTransferred != 1 && item.isClosed != 1) { if (item.isAuditApproved) { tags.add( Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: colors.success.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4), ), child: Text( l10n.get('statusApproved'), style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: colors.success), ), ), ); } else { tags.add( Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: colors.statusGray.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4), ), child: Text( l10n.get('statusPending'), style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: colors.statusGray), ), ), ); } } if (tags.isEmpty) return const SizedBox.shrink(); return Row(mainAxisSize: MainAxisSize.min, children: tags); } }