expense_list_page.dart 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  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 'expense_list_controller.dart';
  15. import 'expense_model.dart';
  16. import 'expense_api.dart';
  17. class ExpenseListPage extends ConsumerStatefulWidget {
  18. const ExpenseListPage({super.key});
  19. @override
  20. ConsumerState<ExpenseListPage> createState() => _ExpenseListPageState();
  21. }
  22. class _ExpenseListPageState extends ConsumerState<ExpenseListPage> {
  23. final _keywordCtrl = TextEditingController();
  24. final _startDateCtrl = TextEditingController();
  25. final _endDateCtrl = TextEditingController();
  26. late final EasyRefreshController _refreshCtrl;
  27. bool _firstBuild = true;
  28. bool _invalidateDone = false;
  29. @override
  30. void initState() {
  31. super.initState();
  32. final now = DateTime.now();
  33. _startDateCtrl.text =
  34. '${now.year}-${now.month.toString().padLeft(2, '0')}-01';
  35. _endDateCtrl.text =
  36. '${now.year}-${now.month.toString().padLeft(2, '0')}-${_daysInMonth(now.year, now.month).toString().padLeft(2, '0')}';
  37. _refreshCtrl = EasyRefreshController();
  38. WidgetsBinding.instance.addPostFrameCallback((_) {
  39. ref.read(expenseDateStartProvider.notifier).state = DateTime(
  40. now.year,
  41. now.month,
  42. 1,
  43. );
  44. ref.read(expenseDateEndProvider.notifier).state = DateTime(
  45. now.year,
  46. now.month,
  47. _daysInMonth(now.year, now.month),
  48. );
  49. ref.invalidate(expenseMyListProvider(''));
  50. _invalidateDone = true;
  51. setState(() {});
  52. });
  53. }
  54. @override
  55. void dispose() {
  56. _keywordCtrl.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 = _keywordCtrl.text.trim();
  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(12, 8, 12, 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(12, 0, 12, 8),
  224. child: Row(
  225. children: [
  226. Expanded(
  227. child: Container(
  228. height: 40,
  229. padding: const EdgeInsets.symmetric(horizontal: 16),
  230. decoration: BoxDecoration(
  231. color: colors.bgSecondaryContainer,
  232. borderRadius: BorderRadius.circular(20),
  233. border: Border.all(
  234. color: tdTheme.componentStrokeColor,
  235. ),
  236. ),
  237. child: Row(
  238. children: [
  239. Expanded(
  240. child: TextField(
  241. controller: _keywordCtrl,
  242. style: TextStyle(
  243. fontSize: 14,
  244. color: colors.textPrimary,
  245. ),
  246. decoration: InputDecoration(
  247. hintText: l10n.get('searchExpense'),
  248. hintStyle: TextStyle(
  249. fontSize: 14,
  250. color: colors.textSecondary,
  251. ),
  252. border: InputBorder.none,
  253. isCollapsed: true,
  254. ),
  255. onChanged: (_) => setState(() {}),
  256. ),
  257. ),
  258. if (_keywordCtrl.text.isNotEmpty)
  259. GestureDetector(
  260. onTap: () {
  261. _keywordCtrl.clear();
  262. setState(() {});
  263. _applyFilter();
  264. },
  265. child: Icon(
  266. Icons.close,
  267. size: 18,
  268. color: colors.textSecondary,
  269. ),
  270. ),
  271. ],
  272. ),
  273. ),
  274. ),
  275. const SizedBox(width: 8),
  276. GestureDetector(
  277. onTap: () {
  278. final dir = ref.read(expenseSortDirProvider);
  279. ref.read(expenseSortDirProvider.notifier).state =
  280. dir == 'ASC' ? 'DESC' : 'ASC';
  281. _applyFilter();
  282. },
  283. child: Container(
  284. width: 40,
  285. height: 40,
  286. decoration: BoxDecoration(
  287. color: colors.primary,
  288. borderRadius: BorderRadius.circular(20),
  289. ),
  290. child: Center(
  291. child: Icon(
  292. ref.watch(expenseSortDirProvider) == 'ASC'
  293. ? Icons.arrow_upward
  294. : Icons.arrow_downward,
  295. color: Colors.white,
  296. size: 20,
  297. ),
  298. ),
  299. ),
  300. ),
  301. const SizedBox(width: 8),
  302. GestureDetector(
  303. onTap: _applyFilter,
  304. child: Container(
  305. width: 40,
  306. height: 40,
  307. decoration: BoxDecoration(
  308. color: colors.primary,
  309. borderRadius: BorderRadius.circular(20),
  310. ),
  311. child: const Icon(
  312. Icons.search,
  313. color: Colors.white,
  314. size: 22,
  315. ),
  316. ),
  317. ),
  318. ],
  319. ),
  320. ),
  321. ],
  322. ),
  323. ),
  324. Expanded(
  325. child: Container(
  326. color: colors.bgPage,
  327. child: _firstBuild
  328. ? const Center(child: SkeletonLoadingList())
  329. : _ExpenseListContent(
  330. refreshCtrl: _refreshCtrl,
  331. onRefresh: _doRefresh,
  332. ),
  333. ),
  334. ),
  335. ],
  336. ),
  337. Positioned(
  338. right: 16,
  339. bottom: 16,
  340. child: FloatingActionButton(
  341. onPressed: () => context.push('/report/expense-detail'),
  342. backgroundColor: colors.primary,
  343. shape: const CircleBorder(),
  344. child: const Icon(Icons.bar_chart, color: Colors.white),
  345. ),
  346. ),
  347. ],
  348. );
  349. }
  350. }
  351. class _ExpenseListContent extends ConsumerWidget {
  352. final EasyRefreshController refreshCtrl;
  353. final Future<void> Function() onRefresh;
  354. const _ExpenseListContent({
  355. required this.refreshCtrl,
  356. required this.onRefresh,
  357. });
  358. @override
  359. Widget build(BuildContext context, WidgetRef ref) {
  360. final r = ResponsiveHelper.of(context);
  361. final itemsAsync = ref.watch(expenseMyListProvider(''));
  362. if (itemsAsync.isLoading && !itemsAsync.hasValue) {
  363. return Center(
  364. child: ConstrainedBox(
  365. constraints: BoxConstraints(maxWidth: r.listMaxWidth),
  366. child: const SkeletonLoadingList(),
  367. ),
  368. );
  369. }
  370. return Center(
  371. child: ConstrainedBox(
  372. constraints: BoxConstraints(maxWidth: r.listMaxWidth),
  373. child: EasyRefresh(
  374. controller: refreshCtrl,
  375. header: TDRefreshHeader(),
  376. onRefresh: onRefresh,
  377. child: _buildContent(itemsAsync, context, ref),
  378. ),
  379. ),
  380. );
  381. }
  382. Widget _buildContent(
  383. AsyncValue<List<ExpenseModel>> itemsAsync,
  384. BuildContext context,
  385. WidgetRef ref,
  386. ) {
  387. final l10n = AppLocalizations.of(context);
  388. if (itemsAsync.isReloading) {
  389. final oldItems = itemsAsync.valueOrNull ?? [];
  390. if (oldItems.isEmpty) {
  391. return ListView(
  392. children: [
  393. const SizedBox(height: 120),
  394. EmptyState(message: l10n.get('noExpenses')),
  395. ],
  396. );
  397. }
  398. return ListView.builder(
  399. padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
  400. itemCount: oldItems.length,
  401. itemBuilder: (_, i) {
  402. final item = oldItems[i];
  403. final applicant = item.deptName.isNotEmpty
  404. ? '${item.applicantName} · ${item.deptName}'
  405. : item.applicantName;
  406. final card = ListCard(
  407. cardNo: item.expenseNo,
  408. amount: '¥${item.totalAmount.toStringAsFixed(2)}',
  409. subAmount: item.totalAmtnSh > 0
  410. ? '¥${item.totalAmtnSh.toStringAsFixed(2)}'
  411. : null,
  412. applicant: applicant,
  413. description: item.purpose,
  414. date: du.DateUtils.formatDate(item.expenseDate ?? item.createTime),
  415. statusTag: _buildStatusTags(item, l10n, context),
  416. onTap: () {
  417. context.push('/expense/detail/${item.expenseNo}');
  418. },
  419. );
  420. return Padding(
  421. padding: const EdgeInsets.only(bottom: 16),
  422. child: card,
  423. );
  424. },
  425. );
  426. }
  427. if (itemsAsync.hasError) {
  428. return ListView(
  429. children: [
  430. const SizedBox(height: 120),
  431. EmptyState(message: l10n.get('loadFailed')),
  432. ],
  433. );
  434. }
  435. final items = itemsAsync.requireValue;
  436. if (items.isEmpty) {
  437. return ListView(
  438. children: [
  439. const SizedBox(height: 120),
  440. EmptyState(message: l10n.get('noExpenses')),
  441. ],
  442. );
  443. }
  444. return ListView.builder(
  445. padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
  446. itemCount: items.length + 1,
  447. itemBuilder: (_, i) {
  448. if (i == items.length) return ListFooter(itemCount: items.length);
  449. final item = items[i];
  450. final applicant = item.deptName.isNotEmpty
  451. ? '${item.applicantName} · ${item.deptName}'
  452. : item.applicantName;
  453. final card = ListCard(
  454. cardNo: item.expenseNo,
  455. amount: '¥${item.totalAmount.toStringAsFixed(2)}',
  456. subAmount: item.totalAmtnSh > 0
  457. ? '¥${item.totalAmtnSh.toStringAsFixed(2)}'
  458. : null,
  459. applicant: applicant,
  460. description: item.purpose,
  461. date: du.DateUtils.formatDate(item.expenseDate ?? item.createTime),
  462. statusTag: _buildStatusTags(item, l10n, context),
  463. onTap: () {
  464. context.push('/expense/detail/${item.expenseNo}');
  465. },
  466. );
  467. return Padding(padding: const EdgeInsets.only(bottom: 16), child: card);
  468. },
  469. );
  470. }
  471. /// 构建状态 tag,放在卡片底部日期旁边
  472. Widget _buildStatusTags(
  473. ExpenseModel item,
  474. AppLocalizations l10n,
  475. BuildContext context,
  476. ) {
  477. final tags = <Widget>[];
  478. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  479. final isDark = Theme.of(context).brightness == Brightness.dark;
  480. final tagBg = isDark ? colors.bgSecondaryContainer : Colors.grey.shade200;
  481. final tagFg = isDark ? colors.textSecondary : Colors.grey.shade600;
  482. if (item.isTransferred == 1) {
  483. tags.add(
  484. Container(
  485. padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
  486. decoration: BoxDecoration(
  487. color: tagBg,
  488. borderRadius: BorderRadius.circular(4),
  489. ),
  490. child: Text(
  491. l10n.get('statusTransferred'),
  492. style: TextStyle(fontSize: 11, color: tagFg),
  493. ),
  494. ),
  495. );
  496. tags.add(const SizedBox(width: 6));
  497. }
  498. if (item.isClosed == 1) {
  499. tags.add(
  500. Container(
  501. padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
  502. decoration: BoxDecoration(
  503. color: tagBg,
  504. borderRadius: BorderRadius.circular(4),
  505. ),
  506. child: Text(
  507. l10n.get('statusClosed'),
  508. style: TextStyle(fontSize: 11, color: tagFg),
  509. ),
  510. ),
  511. );
  512. }
  513. if (tags.isEmpty) return const SizedBox.shrink();
  514. return Row(mainAxisSize: MainAxisSize.min, children: tags);
  515. }
  516. }