import 'package:flutter/material.dart'; import 'package:flutter/services.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 '../../shared/widgets/nav_bar_config.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/skeleton_list_card.dart'; import '../../shared/widgets/list_footer.dart'; import '../../core/i18n/app_localizations.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 with WidgetsBindingObserver { final _keywordCtrl = TextEditingController(); final _startDateCtrl = TextEditingController(); final _endDateCtrl = TextEditingController(); late final EasyRefreshController _refreshCtrl; @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)); }); WidgetsBinding.instance.addObserver(this); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { _keywordCtrl.clear(); 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')}'; ref.read(expenseKeywordProvider.notifier).state = ''; 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.read(expenseRefreshProvider.notifier).state++; } } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _keywordCtrl.dispose(); _startDateCtrl.dispose(); _endDateCtrl.dispose(); _refreshCtrl.dispose(); super.dispose(); } void _pickDate(TextEditingController ctrl) { 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() { ref.read(expenseKeywordProvider.notifier).state = _keywordCtrl.text.trim(); 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; _refreshCtrl.callRefresh(); } Widget _dateChip(TextEditingController ctrl, String hint, TDThemeData tdTheme, AppColorsExtension colors) { final text = ctrl.text; return Container( height: 40, padding: const EdgeInsets.symmetric(horizontal: 16), 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: 8), 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); setNavBarTitle(context, ref, NavBarConfig(title: l10n.get('expenseList'), showBack: true, onBack: () => SystemNavigator.pop())); return Column(children: [ Container( color: colors.bgCard, child: Column(mainAxisSize: MainAxisSize.min, children: [ Padding(padding: const EdgeInsets.fromLTRB(12, 8, 12, 0), child: Row(children: [ Expanded(child: GestureDetector(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(onTap: () => _pickDate(_endDateCtrl), child: _dateChip(_endDateCtrl, l10n.get('filterEndDate'), tdTheme, colors))), ])), const SizedBox(height: 8), Padding(padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), child: Row(children: [ Expanded( child: Container( height: 40, padding: const EdgeInsets.symmetric(horizontal: 16), decoration: BoxDecoration(color: colors.bgSecondaryContainer, borderRadius: BorderRadius.circular(20), border: Border.all(color: tdTheme.componentStrokeColor)), child: Row(children: [ Expanded(child: TextField(controller: _keywordCtrl, style: TextStyle(fontSize: 14, color: colors.textPrimary), decoration: InputDecoration(hintText: l10n.get('searchExpense'), hintStyle: TextStyle(fontSize: 14, color: colors.textSecondary), border: InputBorder.none, isCollapsed: true), onChanged: (_) => setState(() {}))), if (_keywordCtrl.text.isNotEmpty) GestureDetector(onTap: () { _keywordCtrl.clear(); setState(() {}); _applyFilter(); }, child: Icon(Icons.close, size: 18, color: colors.textSecondary)), ]), ), ), const SizedBox(width: 8), 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))), ), const SizedBox(width: 8), GestureDetector(onTap: _applyFilter, child: Container(width: 40, height: 40, decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(20)), child: const Icon(Icons.search, color: Colors.white, size: 22))), ])), ]), ), Expanded(child: Container(color: colors.bgPage, child: _ExpenseListContent(refreshCtrl: _refreshCtrl))), ]); } } class _ExpenseListContent extends ConsumerWidget { final EasyRefreshController refreshCtrl; const _ExpenseListContent({required this.refreshCtrl}); @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: () async { ref.read(expenseRefreshProvider.notifier).state++; }, 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: '¥${item.totalAmount.toStringAsFixed(2)}', applicant: applicant, description: item.purpose, date: du.DateUtils.formatDate(item.expenseDate ?? item.createTime), 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: '¥${item.totalAmount.toStringAsFixed(2)}', applicant: applicant, description: item.purpose, date: du.DateUtils.formatDate(item.expenseDate ?? item.createTime), onTap: () { context.push('/expense/detail/${item.expenseNo}'); }); return Padding(padding: const EdgeInsets.only(bottom: 16), child: card); }); } }