expense_list_page.dart 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  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 '../../shared/widgets/list_card.dart';
  12. import '../../shared/widgets/empty_state.dart';
  13. import '../../shared/widgets/skeleton_list_card.dart';
  14. import '../../shared/widgets/list_footer.dart';
  15. import '../../core/i18n/app_localizations.dart';
  16. import 'expense_list_controller.dart';
  17. import 'expense_model.dart';
  18. import 'expense_api.dart';
  19. class ExpenseListPage extends ConsumerStatefulWidget {
  20. const ExpenseListPage({super.key});
  21. @override
  22. ConsumerState<ExpenseListPage> createState() => _ExpenseListPageState();
  23. }
  24. class _ExpenseListPageState extends ConsumerState<ExpenseListPage>
  25. with WidgetsBindingObserver {
  26. final _keywordCtrl = TextEditingController();
  27. final _startDateCtrl = TextEditingController();
  28. final _endDateCtrl = TextEditingController();
  29. late final EasyRefreshController _refreshCtrl;
  30. @override
  31. void initState() {
  32. super.initState();
  33. final now = DateTime.now();
  34. _startDateCtrl.text = '${now.year}-${now.month.toString().padLeft(2, '0')}-01';
  35. _endDateCtrl.text = '${now.year}-${now.month.toString().padLeft(2, '0')}-${_daysInMonth(now.year, now.month).toString().padLeft(2, '0')}';
  36. _refreshCtrl = EasyRefreshController();
  37. WidgetsBinding.instance.addPostFrameCallback((_) {
  38. ref.read(expenseDateStartProvider.notifier).state = DateTime(now.year, now.month, 1);
  39. ref.read(expenseDateEndProvider.notifier).state = DateTime(now.year, now.month, _daysInMonth(now.year, now.month));
  40. });
  41. WidgetsBinding.instance.addObserver(this);
  42. }
  43. @override
  44. void didChangeAppLifecycleState(AppLifecycleState state) {
  45. if (state == AppLifecycleState.resumed) {
  46. FocusScope.of(context).unfocus();
  47. _keywordCtrl.clear();
  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. ref.read(expenseKeywordProvider.notifier).state = '';
  52. ref.read(expenseDateStartProvider.notifier).state = DateTime(now.year, now.month, 1);
  53. ref.read(expenseDateEndProvider.notifier).state = DateTime(now.year, now.month, _daysInMonth(now.year, now.month));
  54. ref.read(expenseRefreshProvider.notifier).state++;
  55. }
  56. }
  57. @override
  58. void dispose() {
  59. WidgetsBinding.instance.removeObserver(this);
  60. _keywordCtrl.dispose();
  61. _startDateCtrl.dispose();
  62. _endDateCtrl.dispose();
  63. _refreshCtrl.dispose();
  64. super.dispose();
  65. }
  66. void _pickDate(TextEditingController ctrl) {
  67. FocusManager.instance.primaryFocus?.unfocus();
  68. final l10n = AppLocalizations.of(context);
  69. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  70. final now = DateTime.now();
  71. TDPicker.showDatePicker(
  72. context, title: l10n.get('selectDate'), backgroundColor: colors.bgCard,
  73. useYear: true, useMonth: true, useDay: true,
  74. useHour: false, useMinute: false, useSecond: false, useWeekDay: false,
  75. dateStart: const [2020, 1, 1], dateEnd: [now.year + 1, 12, 31],
  76. initialDate: [now.year, now.month, now.day],
  77. onConfirm: (selected) {
  78. ctrl.text = '${selected['year']}-${selected['month'].toString().padLeft(2, '0')}-${selected['day'].toString().padLeft(2, '0')}';
  79. setState(() {});
  80. Navigator.of(context).pop();
  81. },
  82. );
  83. }
  84. int _daysInMonth(int year, int month) => DateTime(year, month + 1, 0).day;
  85. void _applyFilter() {
  86. ref.read(expenseKeywordProvider.notifier).state = _keywordCtrl.text.trim();
  87. ref.read(expenseDateStartProvider.notifier).state = _startDateCtrl.text.isNotEmpty ? DateTime.tryParse(_startDateCtrl.text) : null;
  88. ref.read(expenseDateEndProvider.notifier).state = _endDateCtrl.text.isNotEmpty ? DateTime.tryParse(_endDateCtrl.text) : null;
  89. _refreshCtrl.callRefresh();
  90. }
  91. Widget _dateChip(TextEditingController ctrl, String hint, TDThemeData tdTheme, AppColorsExtension colors) {
  92. final text = ctrl.text;
  93. return Container(
  94. height: 40, padding: const EdgeInsets.symmetric(horizontal: 12),
  95. decoration: BoxDecoration(color: colors.bgSecondaryContainer, borderRadius: BorderRadius.circular(20), border: Border.all(color: tdTheme.componentStrokeColor)),
  96. child: Row(children: [
  97. Icon(Icons.calendar_today, size: 16, color: colors.textSecondary), const SizedBox(width: 6),
  98. Expanded(child: Text(text.isNotEmpty ? text : hint, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 14, color: text.isNotEmpty ? colors.textPrimary : colors.textSecondary))),
  99. if (text.isNotEmpty) GestureDetector(onTap: () { ctrl.clear(); setState(() {}); }, child: Icon(Icons.close, size: 18, color: colors.textSecondary)),
  100. ]),
  101. );
  102. }
  103. @override
  104. Widget build(BuildContext context) {
  105. final l10n = AppLocalizations.of(context);
  106. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  107. final tdTheme = TDTheme.of(context);
  108. setNavBarTitle(context, ref, NavBarConfig(
  109. title: l10n.get('expenseList'),
  110. showBack: true,
  111. onBack: () {
  112. FocusManager.instance.primaryFocus?.unfocus();
  113. SystemNavigator.pop();
  114. },
  115. showRight: true,
  116. rightWidget: GestureDetector(
  117. onTap: () => context.push('/expense/create'),
  118. child: Icon(Icons.add, color: colors.textPrimary, size: 22),
  119. ),
  120. ));
  121. return Scaffold(
  122. body: Column(children: [
  123. Container(
  124. decoration: BoxDecoration(
  125. color: colors.bgCard,
  126. border: Border(bottom: BorderSide(color: tdTheme.componentStrokeColor)),
  127. ),
  128. child: Column(mainAxisSize: MainAxisSize.min, children: [
  129. Padding(padding: const EdgeInsets.fromLTRB(12, 8, 12, 0), child: Row(children: [
  130. Expanded(child: GestureDetector(onTap: () => _pickDate(_startDateCtrl), child: _dateChip(_startDateCtrl, l10n.get('filterStartDate'), tdTheme, colors))),
  131. const SizedBox(width: 8), Text('—', style: TextStyle(fontSize: 14, color: colors.textSecondary)), const SizedBox(width: 8),
  132. Expanded(child: GestureDetector(onTap: () => _pickDate(_endDateCtrl), child: _dateChip(_endDateCtrl, l10n.get('filterEndDate'), tdTheme, colors))),
  133. ])),
  134. const SizedBox(height: 8),
  135. Padding(padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), child: Row(children: [
  136. Expanded(
  137. child: Container(
  138. height: 40, padding: const EdgeInsets.symmetric(horizontal: 16),
  139. decoration: BoxDecoration(color: colors.bgSecondaryContainer, borderRadius: BorderRadius.circular(20), border: Border.all(color: tdTheme.componentStrokeColor)),
  140. child: Row(children: [
  141. 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(() {}))),
  142. if (_keywordCtrl.text.isNotEmpty) GestureDetector(onTap: () { _keywordCtrl.clear(); setState(() {}); _applyFilter(); }, child: Icon(Icons.close, size: 18, color: colors.textSecondary)),
  143. ]),
  144. ),
  145. ),
  146. const SizedBox(width: 8),
  147. GestureDetector(
  148. onTap: () {
  149. final dir = ref.read(expenseSortDirProvider);
  150. ref.read(expenseSortDirProvider.notifier).state = dir == 'ASC' ? 'DESC' : 'ASC';
  151. _applyFilter();
  152. },
  153. 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))),
  154. ),
  155. const SizedBox(width: 8),
  156. 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))),
  157. ])),
  158. ]),
  159. ),
  160. Expanded(child: Container(color: colors.bgPage, child: _ExpenseListContent(refreshCtrl: _refreshCtrl))),
  161. ]),
  162. floatingActionButton: FloatingActionButton(
  163. onPressed: () => context.push('/report/expense-detail'),
  164. backgroundColor: colors.primary,
  165. shape: const CircleBorder(),
  166. child: const Icon(Icons.bar_chart, color: Colors.white),
  167. ),
  168. );
  169. }
  170. }
  171. class _ExpenseListContent extends ConsumerWidget {
  172. final EasyRefreshController refreshCtrl;
  173. const _ExpenseListContent({required this.refreshCtrl});
  174. @override
  175. Widget build(BuildContext context, WidgetRef ref) {
  176. final r = ResponsiveHelper.of(context);
  177. final itemsAsync = ref.watch(expenseMyListProvider(''));
  178. if (itemsAsync.isLoading && !itemsAsync.hasValue) return Center(child: ConstrainedBox(constraints: BoxConstraints(maxWidth: r.listMaxWidth), child: const SkeletonLoadingList()));
  179. 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))));
  180. }
  181. Widget _buildContent(AsyncValue<List<ExpenseModel>> itemsAsync, BuildContext context, WidgetRef ref) {
  182. final l10n = AppLocalizations.of(context);
  183. if (itemsAsync.isReloading) {
  184. final oldItems = itemsAsync.valueOrNull ?? [];
  185. if (oldItems.isEmpty) return ListView(children: [const SizedBox(height: 120), EmptyState(message: l10n.get('noExpenses'))]);
  186. return ListView.builder(padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), itemCount: oldItems.length, itemBuilder: (_, i) {
  187. final item = oldItems[i];
  188. final applicant = item.deptName.isNotEmpty
  189. ? '${item.applicantName} · ${item.deptName}'
  190. : item.applicantName;
  191. 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}'); });
  192. return Padding(padding: const EdgeInsets.only(bottom: 16), child: card);
  193. });
  194. }
  195. if (itemsAsync.hasError) return ListView(children: [const SizedBox(height: 120), EmptyState(message: l10n.get('loadFailed'))]);
  196. final items = itemsAsync.requireValue;
  197. if (items.isEmpty) return ListView(children: [const SizedBox(height: 120), EmptyState(message: l10n.get('noExpenses'))]);
  198. return ListView.builder(padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), itemCount: items.length + 1, itemBuilder: (_, i) {
  199. if (i == items.length) return ListFooter(itemCount: items.length);
  200. final item = items[i];
  201. final applicant = item.deptName.isNotEmpty
  202. ? '${item.applicantName} · ${item.deptName}'
  203. : item.applicantName;
  204. 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}'); });
  205. return Padding(padding: const EdgeInsets.only(bottom: 16), child: card);
  206. });
  207. }
  208. }