expense_list_page.dart 18 KB

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