expense_list_page.dart 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter/services.dart';
  3. import 'package:flutter_riverpod/flutter_riverpod.dart';
  4. import 'package:go_router/go_router.dart';
  5. import 'package:tdesign_flutter/tdesign_flutter.dart';
  6. import 'package:easy_refresh/easy_refresh.dart';
  7. import '../../shared/widgets/nav_bar_config.dart';
  8. import '../../core/theme/app_colors_extension.dart';
  9. import '../../core/utils/date_utils.dart' as du;
  10. import '../../core/utils/responsive.dart';
  11. import '../../core/auth/role_provider.dart';
  12. import '../../shared/widgets/list_card.dart';
  13. import '../../shared/widgets/status_tag.dart';
  14. import '../../shared/widgets/empty_state.dart';
  15. import '../../shared/widgets/skeleton_list_card.dart';
  16. import '../../shared/widgets/list_footer.dart';
  17. import '../../core/i18n/app_localizations.dart';
  18. import 'expense_list_controller.dart';
  19. import 'expense_model.dart';
  20. import 'expense_api.dart';
  21. final _scopeProvider = StateProvider<String>((ref) => 'my');
  22. class ExpenseListPage extends ConsumerStatefulWidget {
  23. const ExpenseListPage({super.key});
  24. @override
  25. ConsumerState<ExpenseListPage> createState() => _ExpenseListPageState();
  26. }
  27. class _ExpenseListPageState extends ConsumerState<ExpenseListPage>
  28. with TickerProviderStateMixin, WidgetsBindingObserver {
  29. List<String> _getTabLabels(AppLocalizations l10n) => [
  30. l10n.get('statusWaitApprove'),
  31. l10n.get('statusApproved'),
  32. ];
  33. static const _tabKeys = ['pending', 'approved'];
  34. late final TabController _tabCtrl;
  35. final _keywordCtrl = TextEditingController();
  36. final _startDateCtrl = TextEditingController();
  37. final _endDateCtrl = TextEditingController();
  38. late final EasyRefreshController _refreshCtrl;
  39. @override
  40. void initState() {
  41. super.initState();
  42. _tabCtrl = TabController(length: _tabKeys.length, vsync: this);
  43. _tabCtrl.addListener(() {
  44. if (!_tabCtrl.indexIsChanging) {
  45. ref.read(expenseStatusFilterProvider.notifier).state = _tabKeys[_tabCtrl.index];
  46. }
  47. });
  48. final now = DateTime.now();
  49. _startDateCtrl.text = '${now.year}-${now.month.toString().padLeft(2, '0')}-01';
  50. _endDateCtrl.text = '${now.year}-${now.month.toString().padLeft(2, '0')}-${_daysInMonth(now.year, now.month).toString().padLeft(2, '0')}';
  51. _refreshCtrl = EasyRefreshController();
  52. WidgetsBinding.instance.addPostFrameCallback((_) {
  53. ref.read(expenseDateStartProvider.notifier).state = DateTime(now.year, now.month, 1);
  54. ref.read(expenseDateEndProvider.notifier).state = DateTime(now.year, now.month, _daysInMonth(now.year, now.month));
  55. });
  56. WidgetsBinding.instance.addObserver(this);
  57. }
  58. @override
  59. void didChangeAppLifecycleState(AppLifecycleState state) {
  60. if (state == AppLifecycleState.resumed) {
  61. _keywordCtrl.clear();
  62. final now = DateTime.now();
  63. _startDateCtrl.text = '${now.year}-${now.month.toString().padLeft(2, '0')}-01';
  64. _endDateCtrl.text = '${now.year}-${now.month.toString().padLeft(2, '0')}-${_daysInMonth(now.year, now.month).toString().padLeft(2, '0')}';
  65. ref.read(expenseKeywordProvider.notifier).state = '';
  66. ref.read(expenseDateStartProvider.notifier).state = DateTime(now.year, now.month, 1);
  67. ref.read(expenseDateEndProvider.notifier).state = DateTime(now.year, now.month, _daysInMonth(now.year, now.month));
  68. ref.read(expenseRefreshProvider.notifier).state++;
  69. }
  70. }
  71. @override
  72. void dispose() {
  73. WidgetsBinding.instance.removeObserver(this);
  74. _tabCtrl.dispose();
  75. _keywordCtrl.dispose();
  76. _startDateCtrl.dispose();
  77. _endDateCtrl.dispose();
  78. _refreshCtrl.dispose();
  79. super.dispose();
  80. }
  81. void _pickDate(TextEditingController ctrl) {
  82. final l10n = AppLocalizations.of(context);
  83. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  84. final now = DateTime.now();
  85. TDPicker.showDatePicker(
  86. context, title: l10n.get('selectDate'), backgroundColor: colors.bgCard,
  87. useYear: true, useMonth: true, useDay: true,
  88. useHour: false, useMinute: false, useSecond: false, useWeekDay: false,
  89. dateStart: const [2020, 1, 1], dateEnd: [now.year + 1, 12, 31],
  90. initialDate: [now.year, now.month, now.day],
  91. onConfirm: (selected) {
  92. ctrl.text = '${selected['year']}-${selected['month'].toString().padLeft(2, '0')}-${selected['day'].toString().padLeft(2, '0')}';
  93. setState(() {});
  94. Navigator.of(context).pop();
  95. },
  96. );
  97. }
  98. int _daysInMonth(int year, int month) => DateTime(year, month + 1, 0).day;
  99. void _applyFilter() {
  100. ref.read(expenseKeywordProvider.notifier).state = _keywordCtrl.text.trim();
  101. ref.read(expenseDateStartProvider.notifier).state = _startDateCtrl.text.isNotEmpty ? DateTime.tryParse(_startDateCtrl.text) : null;
  102. ref.read(expenseDateEndProvider.notifier).state = _endDateCtrl.text.isNotEmpty ? DateTime.tryParse(_endDateCtrl.text) : null;
  103. _refreshCtrl.callRefresh();
  104. }
  105. Widget _dateChip(TextEditingController ctrl, String hint, TDThemeData tdTheme, AppColorsExtension colors) {
  106. final text = ctrl.text;
  107. return Container(
  108. height: 40, padding: const EdgeInsets.symmetric(horizontal: 16),
  109. decoration: BoxDecoration(color: colors.bgSecondaryContainer, borderRadius: BorderRadius.circular(20), border: Border.all(color: tdTheme.componentStrokeColor)),
  110. child: Row(children: [
  111. Icon(Icons.calendar_today, size: 16, color: colors.textSecondary), const SizedBox(width: 8),
  112. Expanded(child: Text(text.isNotEmpty ? text : hint, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 14, color: text.isNotEmpty ? colors.textPrimary : colors.textSecondary))),
  113. if (text.isNotEmpty) GestureDetector(onTap: () { ctrl.clear(); setState(() {}); }, child: Icon(Icons.close, size: 18, color: colors.textSecondary)),
  114. ]),
  115. );
  116. }
  117. @override
  118. Widget build(BuildContext context) {
  119. final status = ref.watch(expenseStatusFilterProvider);
  120. final l10n = AppLocalizations.of(context);
  121. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  122. final isManager = ref.watch(isManagerProvider);
  123. final tdTheme = TDTheme.of(context);
  124. final targetIdx = _tabKeys.indexOf(status);
  125. if (targetIdx >= 0 && _tabCtrl.index != targetIdx && !_tabCtrl.indexIsChanging) {
  126. WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _tabCtrl.animateTo(targetIdx); });
  127. }
  128. ref.read(navBarConfigProvider.notifier).update(NavBarConfig(title: l10n.get('expenseList'), showBack: true, onBack: () => SystemNavigator.pop()));
  129. return Column(children: [
  130. if (isManager) Container(color: colors.bgCard, padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), child: _buildScopeChip(colors)),
  131. Container(
  132. color: colors.bgCard,
  133. child: Column(mainAxisSize: MainAxisSize.min, children: [
  134. Padding(padding: const EdgeInsets.fromLTRB(12, 8, 12, 0), child: Row(children: [
  135. Expanded(child: GestureDetector(onTap: () => _pickDate(_startDateCtrl), child: _dateChip(_startDateCtrl, l10n.get('filterStartDate'), tdTheme, colors))),
  136. const SizedBox(width: 8), Text('—', style: TextStyle(fontSize: 14, color: colors.textSecondary)), const SizedBox(width: 8),
  137. Expanded(child: GestureDetector(onTap: () => _pickDate(_endDateCtrl), child: _dateChip(_endDateCtrl, l10n.get('filterEndDate'), tdTheme, colors))),
  138. ])),
  139. const SizedBox(height: 8),
  140. Padding(padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), child: Row(children: [
  141. Expanded(
  142. child: Container(
  143. height: 40, padding: const EdgeInsets.symmetric(horizontal: 16),
  144. decoration: BoxDecoration(color: colors.bgSecondaryContainer, borderRadius: BorderRadius.circular(20), border: Border.all(color: tdTheme.componentStrokeColor)),
  145. child: Row(children: [
  146. 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(() {}))),
  147. if (_keywordCtrl.text.isNotEmpty) GestureDetector(onTap: () { _keywordCtrl.clear(); setState(() {}); _applyFilter(); }, child: Icon(Icons.close, size: 18, color: colors.textSecondary)),
  148. ]),
  149. ),
  150. ),
  151. const SizedBox(width: 8),
  152. GestureDetector(onTap: _applyFilter, child: Container(width: 64, height: 40, decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(20)), child: const Icon(Icons.search, color: Colors.white, size: 22))),
  153. ])),
  154. ]),
  155. ),
  156. Container(
  157. color: colors.bgCard, padding: const EdgeInsets.symmetric(horizontal: 8),
  158. child: TDTabBar(
  159. tabs: _getTabLabels(l10n).map((l) => TDTab(text: l)).toList(), controller: _tabCtrl,
  160. isScrollable: true, labelColor: colors.primary, unselectedLabelColor: colors.textSecondary,
  161. outlineType: TDTabBarOutlineType.filled, showIndicator: true, indicatorColor: colors.primary, indicatorHeight: 3,
  162. dividerHeight: 0, labelPadding: const EdgeInsets.symmetric(horizontal: 12),
  163. onTap: (index) => ref.read(expenseStatusFilterProvider.notifier).state = _tabKeys[index],
  164. ),
  165. ),
  166. Expanded(child: Container(color: colors.bgPage, child: TabBarView(controller: _tabCtrl, children: List.generate(_tabKeys.length, (tabIdx) => _buildTabContent(tabIdx))))),
  167. ]);
  168. }
  169. Widget _buildScopeChip(AppColorsExtension colors) {
  170. final scope = ref.watch(_scopeProvider);
  171. final l10n = AppLocalizations.of(context);
  172. return Row(children: [
  173. GestureDetector(onTap: () => ref.read(_scopeProvider.notifier).state = 'my', child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), decoration: BoxDecoration(color: scope == 'my' ? colors.primary : colors.bgPage, borderRadius: BorderRadius.circular(16), border: scope == 'my' ? null : Border.all(color: colors.border)), child: Text(l10n.get('scopeMyApplications'), style: TextStyle(fontSize: 13, color: scope == 'my' ? colors.bgCard : colors.textSecondary)))),
  174. const SizedBox(width: 8),
  175. GestureDetector(onTap: () => ref.read(_scopeProvider.notifier).state = 'sub', child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), decoration: BoxDecoration(color: scope == 'sub' ? colors.primary : colors.bgPage, borderRadius: BorderRadius.circular(16), border: scope == 'sub' ? null : Border.all(color: colors.border)), child: Text(l10n.get('scopeSubordinates'), style: TextStyle(fontSize: 13, color: scope == 'sub' ? colors.bgCard : colors.textSecondary)))),
  176. ]);
  177. }
  178. Widget _buildTabContent(int tabIdx) {
  179. final r = ResponsiveHelper.of(context);
  180. return Center(child: ConstrainedBox(constraints: BoxConstraints(maxWidth: r.listMaxWidth), child: _ExpenseTabContent(statusKey: _tabKeys[tabIdx], refreshCtrl: _refreshCtrl)));
  181. }
  182. }
  183. class _ExpenseTabContent extends ConsumerWidget {
  184. final String statusKey;
  185. final EasyRefreshController refreshCtrl;
  186. const _ExpenseTabContent({required this.statusKey, required this.refreshCtrl});
  187. @override
  188. Widget build(BuildContext context, WidgetRef ref) {
  189. final itemsAsync = ref.watch(expenseApprovalListProvider(statusKey));
  190. final scope = ref.watch(_scopeProvider);
  191. if (itemsAsync.isLoading && !itemsAsync.hasValue) return const SkeletonLoadingList();
  192. return EasyRefresh(controller: refreshCtrl, header: TDRefreshHeader(), onRefresh: () async { ref.read(expenseRefreshProvider.notifier).state++; }, child: _buildContent(itemsAsync, context, ref, scope));
  193. }
  194. Widget _buildContent(AsyncValue<List<ExpenseModel>> itemsAsync, BuildContext context, WidgetRef ref, String scope) {
  195. final l10n = AppLocalizations.of(context);
  196. final isSub = scope == 'sub';
  197. if (itemsAsync.isReloading) {
  198. final oldItems = itemsAsync.valueOrNull ?? [];
  199. if (oldItems.isEmpty) return ListView(children: [const SizedBox(height: 120), EmptyState(message: l10n.get('noExpenses'))]);
  200. return ListView.builder(padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), itemCount: oldItems.length, itemBuilder: (_, i) {
  201. final card = ListCard(cardNo: oldItems[i].expenseNo, amount: '¥${oldItems[i].totalAmount.toStringAsFixed(2)}', applicant: isSub ? '${oldItems[i].applicantName} · ${oldItems[i].deptName}' : oldItems[i].applicantName, description: oldItems[i].purpose, date: du.DateUtils.formatDate(oldItems[i].createTime), statusTag: StatusTag.fromStatus(oldItems[i].status, l10n), onTap: () { final queryId = ref.read(expenseStatusFilterProvider) != 'approved' ? 1 : 4; context.push('/expense/detail/${oldItems[i].expenseNo}?queryId=$queryId'); });
  202. if (isSub && oldItems[i].status == 'pending') return Padding(padding: const EdgeInsets.only(bottom: 16), child: _buildSwipeApprove(card, oldItems[i].id));
  203. return Padding(padding: const EdgeInsets.only(bottom: 16), child: card);
  204. });
  205. }
  206. if (itemsAsync.hasError) return ListView(children: [const SizedBox(height: 120), EmptyState(message: l10n.get('loadFailed'))]);
  207. final items = itemsAsync.requireValue;
  208. if (items.isEmpty) return ListView(children: [const SizedBox(height: 120), EmptyState(message: l10n.get('noExpenses'))]);
  209. return ListView.builder(padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), itemCount: items.length + 1, itemBuilder: (_, i) {
  210. if (i == items.length) return ListFooter(itemCount: items.length);
  211. final card = ListCard(cardNo: items[i].expenseNo, amount: '¥${items[i].totalAmount.toStringAsFixed(2)}', applicant: isSub ? '${items[i].applicantName} · ${items[i].deptName}' : items[i].applicantName, description: items[i].purpose, date: du.DateUtils.formatDate(items[i].createTime), statusTag: StatusTag.fromStatus(items[i].status, l10n), onTap: () { final queryId = ref.read(expenseStatusFilterProvider) != 'approved' ? 1 : 4; context.push('/expense/detail/${items[i].expenseNo}?queryId=$queryId'); });
  212. if (isSub && items[i].status == 'pending') return Padding(padding: const EdgeInsets.only(bottom: 16), child: _buildSwipeApprove(card, items[i].id));
  213. return Padding(padding: const EdgeInsets.only(bottom: 16), child: card);
  214. });
  215. }
  216. Widget _buildSwipeApprove(Widget card, String itemId) {
  217. return Builder(builder: (ctx) {
  218. final screenWidth = MediaQuery.of(ctx).size.width;
  219. return TDSwipeCell(groupTag: 'expense_approve', right: TDSwipeCellPanel(extentRatio: 100 / screenWidth, children: [
  220. TDSwipeCellAction(label: '', backgroundColor: Colors.transparent, builder: (_) => Container(margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), decoration: BoxDecoration(color: Colors.green, borderRadius: BorderRadius.circular(8)), alignment: Alignment.center, padding: const EdgeInsets.symmetric(horizontal: 12), child: const Text('一键同意', style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600))), onPressed: (_) async {
  221. final confirmed = await showDialog<bool>(context: ctx, builder: (dCtx) => TDAlertDialog(title: '确认审批', content: '确认同意该报销?', leftBtn: TDDialogButtonOptions(title: '取消', action: () => Navigator.of(dCtx).pop(false)), rightBtn: TDDialogButtonOptions(title: '确认', action: () => Navigator.of(dCtx).pop(true))));
  222. if (confirmed == true) { if (ctx.mounted) TDToast.showSuccess('已审批通过', context: ctx); }
  223. }),
  224. ]), cell: card);
  225. });
  226. }
  227. }