expense_list_page.dart 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import 'package:go_router/go_router.dart';
  4. import 'package:tdesign_flutter/tdesign_flutter.dart';
  5. import 'package:easy_refresh/easy_refresh.dart';
  6. import '../../core/theme/app_colors_extension.dart';
  7. import '../../core/utils/date_utils.dart' as du;
  8. import '../../core/utils/responsive.dart';
  9. import '../../shared/widgets/list_card.dart';
  10. import '../../shared/widgets/empty_state.dart';
  11. import '../../shared/widgets/app_skeletons.dart';
  12. import '../../shared/widgets/list_footer.dart';
  13. import '../../core/i18n/app_localizations.dart';
  14. import '../../core/utils/amount_utils.dart';
  15. import 'expense_list_controller.dart';
  16. import 'expense_model.dart';
  17. import 'expense_api.dart';
  18. class ExpenseListPage extends ConsumerStatefulWidget {
  19. const ExpenseListPage({super.key});
  20. @override
  21. ConsumerState<ExpenseListPage> createState() => _ExpenseListPageState();
  22. }
  23. class _ExpenseListPageState extends ConsumerState<ExpenseListPage> {
  24. String _keyword = '';
  25. final _startDateCtrl = TextEditingController();
  26. final _endDateCtrl = TextEditingController();
  27. late final EasyRefreshController _refreshCtrl;
  28. bool _firstBuild = true;
  29. bool _invalidateDone = false;
  30. @override
  31. void initState() {
  32. super.initState();
  33. final now = DateTime.now();
  34. _startDateCtrl.text =
  35. '${now.year}-${now.month.toString().padLeft(2, '0')}-01';
  36. _endDateCtrl.text =
  37. '${now.year}-${now.month.toString().padLeft(2, '0')}-${_daysInMonth(now.year, now.month).toString().padLeft(2, '0')}';
  38. _refreshCtrl = EasyRefreshController();
  39. WidgetsBinding.instance.addPostFrameCallback((_) {
  40. ref.read(expenseDateStartProvider.notifier).state = DateTime(
  41. now.year,
  42. now.month,
  43. 1,
  44. );
  45. ref.read(expenseDateEndProvider.notifier).state = DateTime(
  46. now.year,
  47. now.month,
  48. _daysInMonth(now.year, now.month),
  49. );
  50. ref.invalidate(expenseMyListProvider(''));
  51. _invalidateDone = true;
  52. setState(() {});
  53. });
  54. }
  55. @override
  56. void dispose() {
  57. _startDateCtrl.dispose();
  58. _endDateCtrl.dispose();
  59. _refreshCtrl.dispose();
  60. super.dispose();
  61. }
  62. void _pickDate(TextEditingController ctrl) {
  63. FocusManager.instance.primaryFocus?.unfocus();
  64. final l10n = AppLocalizations.of(context);
  65. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  66. final now = DateTime.now();
  67. TDPicker.showDatePicker(
  68. context,
  69. title: l10n.get('selectDate'),
  70. backgroundColor: colors.bgCard,
  71. useYear: true,
  72. useMonth: true,
  73. useDay: true,
  74. useHour: false,
  75. useMinute: false,
  76. useSecond: false,
  77. useWeekDay: false,
  78. dateStart: const [2020, 1, 1],
  79. dateEnd: [now.year + 1, 12, 31],
  80. initialDate: [now.year, now.month, now.day],
  81. onConfirm: (selected) {
  82. ctrl.text =
  83. '${selected['year']}-${selected['month'].toString().padLeft(2, '0')}-${selected['day'].toString().padLeft(2, '0')}';
  84. setState(() {});
  85. Navigator.of(context).pop();
  86. },
  87. );
  88. }
  89. int _daysInMonth(int year, int month) => DateTime(year, month + 1, 0).day;
  90. void _applyFilter() {
  91. FocusManager.instance.primaryFocus?.unfocus();
  92. _refreshCtrl.callRefresh();
  93. }
  94. Future<void> _doRefresh() async {
  95. ref.read(expenseKeywordProvider.notifier).state = _keyword;
  96. ref
  97. .read(expenseDateStartProvider.notifier)
  98. .state = _startDateCtrl.text.isNotEmpty
  99. ? DateTime.tryParse(_startDateCtrl.text)
  100. : null;
  101. ref
  102. .read(expenseDateEndProvider.notifier)
  103. .state = _endDateCtrl.text.isNotEmpty
  104. ? DateTime.tryParse(_endDateCtrl.text)
  105. : null;
  106. ref.read(expenseRefreshProvider.notifier).state++;
  107. }
  108. Widget _dateChip(
  109. TextEditingController ctrl,
  110. String hint,
  111. TDThemeData tdTheme,
  112. AppColorsExtension colors,
  113. ) {
  114. final text = ctrl.text;
  115. return Container(
  116. height: 40,
  117. padding: const EdgeInsets.symmetric(horizontal: 12),
  118. decoration: BoxDecoration(
  119. color: colors.bgSecondaryContainer,
  120. borderRadius: BorderRadius.circular(20),
  121. border: Border.all(color: tdTheme.componentStrokeColor),
  122. ),
  123. child: Row(
  124. children: [
  125. Icon(Icons.calendar_today, size: 16, color: colors.textSecondary),
  126. const SizedBox(width: 6),
  127. Expanded(
  128. child: Text(
  129. text.isNotEmpty ? text : hint,
  130. maxLines: 1,
  131. overflow: TextOverflow.ellipsis,
  132. style: TextStyle(
  133. fontSize: 14,
  134. color: text.isNotEmpty
  135. ? colors.textPrimary
  136. : colors.textSecondary,
  137. ),
  138. ),
  139. ),
  140. if (text.isNotEmpty)
  141. GestureDetector(
  142. onTap: () {
  143. ctrl.clear();
  144. setState(() {});
  145. },
  146. child: Icon(Icons.close, size: 18, color: colors.textSecondary),
  147. ),
  148. ],
  149. ),
  150. );
  151. }
  152. @override
  153. Widget build(BuildContext context) {
  154. final l10n = AppLocalizations.of(context);
  155. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  156. final tdTheme = TDTheme.of(context);
  157. if (_invalidateDone) {
  158. ref.listen(expenseMyListProvider(''), (prev, next) {
  159. if (_firstBuild && !next.isLoading && !next.isReloading) {
  160. setState(() => _firstBuild = false);
  161. WidgetsBinding.instance.addPostFrameCallback((_) {
  162. if (mounted) setState(() {});
  163. });
  164. }
  165. });
  166. }
  167. return Stack(
  168. children: [
  169. Column(
  170. children: [
  171. Container(
  172. decoration: BoxDecoration(
  173. color: colors.bgCard,
  174. border: Border(
  175. bottom: BorderSide(color: tdTheme.componentStrokeColor),
  176. ),
  177. ),
  178. child: Column(
  179. mainAxisSize: MainAxisSize.min,
  180. children: [
  181. Padding(
  182. padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
  183. child: Row(
  184. children: [
  185. Expanded(
  186. child: GestureDetector(
  187. behavior: HitTestBehavior.opaque,
  188. onTap: () => _pickDate(_startDateCtrl),
  189. child: _dateChip(
  190. _startDateCtrl,
  191. l10n.get('filterStartDate'),
  192. tdTheme,
  193. colors,
  194. ),
  195. ),
  196. ),
  197. const SizedBox(width: 8),
  198. Text(
  199. '—',
  200. style: TextStyle(
  201. fontSize: 14,
  202. color: colors.textSecondary,
  203. ),
  204. ),
  205. const SizedBox(width: 8),
  206. Expanded(
  207. child: GestureDetector(
  208. behavior: HitTestBehavior.opaque,
  209. onTap: () => _pickDate(_endDateCtrl),
  210. child: _dateChip(
  211. _endDateCtrl,
  212. l10n.get('filterEndDate'),
  213. tdTheme,
  214. colors,
  215. ),
  216. ),
  217. ),
  218. ],
  219. ),
  220. ),
  221. const SizedBox(height: 8),
  222. Padding(
  223. padding: const EdgeInsets.fromLTRB(0, 0, 12, 8),
  224. child: Row(
  225. children: [
  226. Expanded(
  227. child: TDSearchBar(
  228. placeHolder: l10n.get('searchExpense'),
  229. style: TDSearchStyle.round,
  230. onTextChanged: (String text) {
  231. setState(() {
  232. _keyword = text;
  233. });
  234. if (text.isEmpty) _applyFilter();
  235. },
  236. onSubmitted: (_) => _applyFilter(),
  237. ),
  238. ),
  239. GestureDetector(
  240. onTap: () {
  241. final dir = ref.read(expenseSortDirProvider);
  242. ref.read(expenseSortDirProvider.notifier).state =
  243. dir == 'ASC' ? 'DESC' : 'ASC';
  244. _applyFilter();
  245. },
  246. child: Container(
  247. width: 40,
  248. height: 40,
  249. decoration: BoxDecoration(
  250. color: colors.primary,
  251. borderRadius: BorderRadius.circular(20),
  252. ),
  253. child: Center(
  254. child: Icon(
  255. ref.watch(expenseSortDirProvider) == 'ASC'
  256. ? Icons.arrow_upward
  257. : Icons.arrow_downward,
  258. color: Colors.white,
  259. size: 20,
  260. ),
  261. ),
  262. ),
  263. ),
  264. ],
  265. ),
  266. ),
  267. ],
  268. ),
  269. ),
  270. Expanded(
  271. child: Container(
  272. color: colors.bgPage,
  273. child: _firstBuild
  274. ? const Center(child: SkeletonLoadingList())
  275. : _ExpenseListContent(
  276. refreshCtrl: _refreshCtrl,
  277. onRefresh: _doRefresh,
  278. ),
  279. ),
  280. ),
  281. ],
  282. ),
  283. Positioned(
  284. right: 16,
  285. bottom: 16,
  286. child: FloatingActionButton(
  287. onPressed: () => context.push('/report/expense-detail'),
  288. backgroundColor: colors.primary,
  289. shape: const CircleBorder(),
  290. child: const Icon(Icons.bar_chart, color: Colors.white),
  291. ),
  292. ),
  293. ],
  294. );
  295. }
  296. }
  297. class _ExpenseListContent extends ConsumerWidget {
  298. final EasyRefreshController refreshCtrl;
  299. final Future<void> Function() onRefresh;
  300. const _ExpenseListContent({
  301. required this.refreshCtrl,
  302. required this.onRefresh,
  303. });
  304. @override
  305. Widget build(BuildContext context, WidgetRef ref) {
  306. final r = ResponsiveHelper.of(context);
  307. final itemsAsync = ref.watch(expenseMyListProvider(''));
  308. if (itemsAsync.isLoading && !itemsAsync.hasValue) {
  309. return Center(
  310. child: ConstrainedBox(
  311. constraints: BoxConstraints(maxWidth: r.listMaxWidth),
  312. child: const SkeletonLoadingList(),
  313. ),
  314. );
  315. }
  316. return Center(
  317. child: ConstrainedBox(
  318. constraints: BoxConstraints(maxWidth: r.listMaxWidth),
  319. child: EasyRefresh(
  320. controller: refreshCtrl,
  321. header: TDRefreshHeader(),
  322. onRefresh: onRefresh,
  323. child: _buildContent(itemsAsync, context, ref),
  324. ),
  325. ),
  326. );
  327. }
  328. Widget _buildContent(
  329. AsyncValue<List<ExpenseModel>> itemsAsync,
  330. BuildContext context,
  331. WidgetRef ref,
  332. ) {
  333. final l10n = AppLocalizations.of(context);
  334. if (itemsAsync.isReloading) {
  335. final oldItems = itemsAsync.valueOrNull ?? [];
  336. if (oldItems.isEmpty) {
  337. return ListView(
  338. children: [
  339. const SizedBox(height: 120),
  340. EmptyState(message: l10n.get('noExpenses')),
  341. ],
  342. );
  343. }
  344. return ListView.builder(
  345. padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
  346. itemCount: oldItems.length,
  347. itemBuilder: (_, i) {
  348. final item = oldItems[i];
  349. final applicant = item.deptName.isNotEmpty
  350. ? '${item.applicantName} · ${item.deptName}'
  351. : item.applicantName;
  352. final card = ListCard(
  353. cardNo: item.expenseNo,
  354. amount: formatAmount(item.totalAmount),
  355. subAmount: item.totalAmtnSh > 0
  356. ? formatAmount(item.totalAmtnSh)
  357. : null,
  358. applicant: applicant,
  359. description: item.purpose,
  360. date: du.DateUtils.formatDate(item.expenseDate ?? item.createTime),
  361. statusTag: _buildStatusTags(item, l10n, context),
  362. onTap: () {
  363. context.push('/expense/detail/${item.expenseNo}');
  364. },
  365. );
  366. return Padding(
  367. padding: const EdgeInsets.only(bottom: 16),
  368. child: card,
  369. );
  370. },
  371. );
  372. }
  373. if (itemsAsync.hasError) {
  374. return ListView(
  375. children: [
  376. const SizedBox(height: 120),
  377. EmptyState(message: l10n.get('loadFailed')),
  378. ],
  379. );
  380. }
  381. final items = itemsAsync.requireValue;
  382. if (items.isEmpty) {
  383. return ListView(
  384. children: [
  385. const SizedBox(height: 120),
  386. EmptyState(message: l10n.get('noExpenses')),
  387. ],
  388. );
  389. }
  390. return ListView.builder(
  391. padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
  392. itemCount: items.length + 1,
  393. itemBuilder: (_, i) {
  394. if (i == items.length) return ListFooter(itemCount: items.length);
  395. final item = items[i];
  396. final applicant = item.deptName.isNotEmpty
  397. ? '${item.applicantName} · ${item.deptName}'
  398. : item.applicantName;
  399. final card = ListCard(
  400. cardNo: item.expenseNo,
  401. amount: formatAmount(item.totalAmount),
  402. subAmount: item.totalAmtnSh > 0
  403. ? formatAmount(item.totalAmtnSh)
  404. : null,
  405. applicant: applicant,
  406. description: item.purpose,
  407. date: du.DateUtils.formatDate(item.expenseDate ?? item.createTime),
  408. statusTag: _buildStatusTags(item, l10n, context),
  409. onTap: () {
  410. context.push('/expense/detail/${item.expenseNo}');
  411. },
  412. );
  413. return Padding(padding: const EdgeInsets.only(bottom: 16), child: card);
  414. },
  415. );
  416. }
  417. /// 构建状态 tag,放在卡片底部日期旁边
  418. Widget _buildStatusTags(
  419. ExpenseModel item,
  420. AppLocalizations l10n,
  421. BuildContext context,
  422. ) {
  423. final tags = <Widget>[];
  424. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  425. if (item.isTransferred == 1) {
  426. tags.add(
  427. Container(
  428. padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
  429. decoration: BoxDecoration(
  430. color: colors.primary.withValues(alpha: 0.1),
  431. borderRadius: BorderRadius.circular(4),
  432. ),
  433. child: Text(
  434. l10n.get('statusTransferred'),
  435. style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: colors.primary),
  436. ),
  437. ),
  438. );
  439. }
  440. if (item.isClosed == 1) {
  441. tags.add(
  442. Container(
  443. padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
  444. decoration: BoxDecoration(
  445. color: colors.warning.withValues(alpha: 0.1),
  446. borderRadius: BorderRadius.circular(4),
  447. ),
  448. child: Text(
  449. l10n.get('statusClosed'),
  450. style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: colors.warning),
  451. ),
  452. ),
  453. );
  454. }
  455. if (item.isTransferred != 1 && item.isClosed != 1) {
  456. if (item.isAuditApproved) {
  457. tags.add(
  458. Container(
  459. padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
  460. decoration: BoxDecoration(
  461. color: colors.success.withValues(alpha: 0.1),
  462. borderRadius: BorderRadius.circular(4),
  463. ),
  464. child: Text(
  465. l10n.get('statusApproved'),
  466. style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: colors.success),
  467. ),
  468. ),
  469. );
  470. } else {
  471. tags.add(
  472. Container(
  473. padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
  474. decoration: BoxDecoration(
  475. color: colors.statusGray.withValues(alpha: 0.1),
  476. borderRadius: BorderRadius.circular(4),
  477. ),
  478. child: Text(
  479. l10n.get('statusPending'),
  480. style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: colors.statusGray),
  481. ),
  482. ),
  483. );
  484. }
  485. }
  486. if (tags.isEmpty) return const SizedBox.shrink();
  487. return Row(mainAxisSize: MainAxisSize.min, children: tags);
  488. }
  489. }