expense_list_page.dart 16 KB

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