expense_list_page.dart 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452
  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 '../../shared/widgets/nav_bar_config.dart';
  7. import '../../core/theme/app_colors_extension.dart';
  8. import '../../core/utils/date_utils.dart' as du;
  9. import '../../core/utils/responsive.dart';
  10. import '../../core/auth/role_provider.dart';
  11. import '../../shared/widgets/list_card.dart';
  12. import '../../shared/widgets/status_tag.dart';
  13. import '../../shared/widgets/empty_state.dart';
  14. import '../../shared/widgets/skeleton_list_card.dart';
  15. import '../../shared/widgets/list_filter_panel.dart';
  16. import '../../shared/widgets/list_footer.dart';
  17. import '../../core/i18n/app_localizations.dart';
  18. import 'expense_list_controller.dart';
  19. import 'expense_model.dart';
  20. final _scopeProvider = StateProvider<String>((ref) => 'my');
  21. class ExpenseListPage extends ConsumerStatefulWidget {
  22. const ExpenseListPage({super.key});
  23. @override
  24. ConsumerState<ExpenseListPage> createState() => _ExpenseListPageState();
  25. }
  26. class _ExpenseListPageState extends ConsumerState<ExpenseListPage>
  27. with TickerProviderStateMixin {
  28. List<String> _getTabLabels(AppLocalizations l10n) => [
  29. l10n.get('all'),
  30. l10n.get('draft'),
  31. l10n.get('pending'),
  32. l10n.get('approved'),
  33. l10n.get('statusWaitPay'),
  34. l10n.get('paid'),
  35. l10n.get('rejected'),
  36. l10n.get('withdrawn'),
  37. ];
  38. static const _tabKeys = [
  39. '',
  40. 'draft',
  41. 'pending',
  42. 'approved',
  43. 'unpaid',
  44. 'paid',
  45. 'rejected',
  46. 'withdrawn',
  47. ];
  48. late final TabController _tabCtrl;
  49. @override
  50. void initState() {
  51. super.initState();
  52. _tabCtrl = TabController(length: _tabKeys.length, vsync: this);
  53. _tabCtrl.addListener(() {
  54. if (!_tabCtrl.indexIsChanging) {
  55. ref.read(expenseStatusFilterProvider.notifier).state =
  56. _tabKeys[_tabCtrl.index];
  57. }
  58. });
  59. }
  60. @override
  61. void dispose() {
  62. _tabCtrl.dispose();
  63. super.dispose();
  64. }
  65. @override
  66. Widget build(BuildContext context) {
  67. final status = ref.watch(expenseStatusFilterProvider);
  68. final dateStart = ref.watch(expenseDateStartProvider);
  69. final dateEnd = ref.watch(expenseDateEndProvider);
  70. final l10n = AppLocalizations.of(context);
  71. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  72. final isManager = ref.watch(isManagerProvider);
  73. // Sync TabController with external filter changes
  74. final targetIdx = _tabKeys.indexOf(status);
  75. if (targetIdx >= 0 &&
  76. _tabCtrl.index != targetIdx &&
  77. !_tabCtrl.indexIsChanging) {
  78. WidgetsBinding.instance.addPostFrameCallback((_) {
  79. if (mounted) _tabCtrl.animateTo(targetIdx);
  80. });
  81. }
  82. final filterGroups = [
  83. FilterGroup(
  84. title: l10n.get('filterDateRange'),
  85. type: FilterGroupType.dateRange,
  86. sections: [
  87. FilterSection(
  88. label: l10n.get('filterDateRange'),
  89. type: FilterSectionType.dateRange,
  90. startDate: dateStart,
  91. endDate: dateEnd,
  92. onStartChanged: (v) =>
  93. ref.read(expenseDateStartProvider.notifier).state = v,
  94. onEndChanged: (v) =>
  95. ref.read(expenseDateEndProvider.notifier).state = v,
  96. ),
  97. ],
  98. ),
  99. ];
  100. final hasFilter = ListFilterPanel.hasActiveFilter(filterGroups);
  101. final filterVersion = Object.hash(dateStart, dateEnd);
  102. final now = DateTime.now();
  103. void onFilterReset() {
  104. ref.read(expenseDateStartProvider.notifier).state = null;
  105. ref.read(expenseDateEndProvider.notifier).state = null;
  106. }
  107. ref
  108. .read(navBarConfigProvider.notifier)
  109. .update(
  110. NavBarConfig(
  111. title: l10n.get('expenseList'),
  112. showBack: true,
  113. showRight: true,
  114. rightWidget: GestureDetector(
  115. onTap: () => ListFilterPanel.show(
  116. context,
  117. groups: filterGroups,
  118. onReset: onFilterReset,
  119. onConfirm: () {},
  120. defaultStartDate: DateTime(now.year, now.month, 1),
  121. defaultEndDate: DateTime(now.year, now.month, now.day),
  122. ),
  123. child: Stack(
  124. clipBehavior: Clip.none,
  125. children: [
  126. Icon(
  127. TDIcons.filter,
  128. size: 22,
  129. color: hasFilter ? colors.primary : colors.textPrimary,
  130. ),
  131. if (hasFilter)
  132. Positioned(
  133. right: -2,
  134. top: -2,
  135. child: Container(
  136. width: 6,
  137. height: 6,
  138. decoration: BoxDecoration(
  139. color: colors.danger,
  140. shape: BoxShape.circle,
  141. ),
  142. ),
  143. ),
  144. ],
  145. ),
  146. ),
  147. hasFilter: hasFilter,
  148. filterVersion: filterVersion,
  149. onBack: () => context.pop(),
  150. ),
  151. );
  152. return Column(
  153. children: [
  154. if (isManager)
  155. Container(
  156. color: colors.bgCard,
  157. padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
  158. child: _buildScopeChip(colors),
  159. ),
  160. Container(
  161. color: colors.bgCard,
  162. padding: EdgeInsets.zero,
  163. child: TDSearchBar(
  164. placeHolder: l10n.get('searchExpense'),
  165. needCancel: true,
  166. style: TDSearchStyle.round,
  167. ),
  168. ),
  169. Container(
  170. color: colors.bgCard,
  171. padding: const EdgeInsets.symmetric(horizontal: 8),
  172. child: TDTabBar(
  173. tabs: _getTabLabels(l10n).map((l) => TDTab(text: l)).toList(),
  174. controller: _tabCtrl,
  175. isScrollable: true,
  176. labelColor: colors.primary,
  177. unselectedLabelColor: colors.textSecondary,
  178. outlineType: TDTabBarOutlineType.filled,
  179. showIndicator: true,
  180. indicatorColor: colors.primary,
  181. indicatorHeight: 3,
  182. dividerHeight: 0,
  183. labelPadding: const EdgeInsets.symmetric(horizontal: 12),
  184. onTap: (index) {
  185. ref.read(expenseStatusFilterProvider.notifier).state =
  186. _tabKeys[index];
  187. },
  188. ),
  189. ),
  190. Expanded(
  191. child: Container(
  192. color: colors.bgPage,
  193. child: TabBarView(
  194. controller: _tabCtrl,
  195. children: List.generate(_tabKeys.length, (tabIdx) {
  196. return _buildTabContent(tabIdx);
  197. }),
  198. ),
  199. ),
  200. ),
  201. ],
  202. );
  203. }
  204. Widget _buildScopeChip(AppColorsExtension colors) {
  205. final scope = ref.watch(_scopeProvider);
  206. final l10n = AppLocalizations.of(context);
  207. return Row(
  208. children: [
  209. GestureDetector(
  210. onTap: () => ref.read(_scopeProvider.notifier).state = 'my',
  211. child: Container(
  212. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
  213. decoration: BoxDecoration(
  214. color: scope == 'my' ? colors.primary : colors.bgPage,
  215. borderRadius: BorderRadius.circular(16),
  216. border: scope == 'my' ? null : Border.all(color: colors.border),
  217. ),
  218. child: Text(
  219. l10n.get('scopeMyApplications'),
  220. style: TextStyle(
  221. fontSize: 13,
  222. color: scope == 'my' ? colors.bgCard : colors.textSecondary,
  223. ),
  224. ),
  225. ),
  226. ),
  227. const SizedBox(width: 8),
  228. GestureDetector(
  229. onTap: () => ref.read(_scopeProvider.notifier).state = 'sub',
  230. child: Container(
  231. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
  232. decoration: BoxDecoration(
  233. color: scope == 'sub' ? colors.primary : colors.bgPage,
  234. borderRadius: BorderRadius.circular(16),
  235. border: scope == 'sub' ? null : Border.all(color: colors.border),
  236. ),
  237. child: Text(
  238. l10n.get('scopeSubordinates'),
  239. style: TextStyle(
  240. fontSize: 13,
  241. color: scope == 'sub' ? colors.bgCard : colors.textSecondary,
  242. ),
  243. ),
  244. ),
  245. ),
  246. ],
  247. );
  248. }
  249. Widget _buildTabContent(int tabIdx) {
  250. final r = ResponsiveHelper.of(context);
  251. return Center(
  252. child: ConstrainedBox(
  253. constraints: BoxConstraints(maxWidth: r.listMaxWidth),
  254. child: _ExpenseTabContent(statusKey: _tabKeys[tabIdx]),
  255. ),
  256. );
  257. }
  258. }
  259. class _ExpenseTabContent extends ConsumerWidget {
  260. final String statusKey;
  261. const _ExpenseTabContent({required this.statusKey});
  262. @override
  263. Widget build(BuildContext context, WidgetRef ref) {
  264. final itemsAsync = ref.watch(expenseListProvider(statusKey));
  265. final scope = ref.watch(_scopeProvider);
  266. if (itemsAsync.isLoading && !itemsAsync.hasValue) {
  267. return const SkeletonLoadingList();
  268. }
  269. return EasyRefresh(
  270. header: TDRefreshHeader(),
  271. onRefresh: () async {
  272. ref.read(expenseRefreshProvider.notifier).state++;
  273. },
  274. child: _buildContent(itemsAsync, context, ref, scope),
  275. );
  276. }
  277. Widget _buildContent(
  278. AsyncValue<List<ExpenseModel>> itemsAsync,
  279. BuildContext context,
  280. WidgetRef ref,
  281. String scope,
  282. ) {
  283. final l10n = AppLocalizations.of(context);
  284. final isSub = scope == 'sub';
  285. if (itemsAsync.isReloading) {
  286. final oldItems = itemsAsync.valueOrNull ?? [];
  287. if (oldItems.isEmpty) {
  288. return ListView(
  289. children: [
  290. const SizedBox(height: 120),
  291. EmptyState(message: l10n.get('noExpenses')),
  292. ],
  293. );
  294. }
  295. return ListView.builder(
  296. padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
  297. itemCount: oldItems.length,
  298. itemBuilder: (_, i) {
  299. final desc = isSub
  300. ? '${oldItems[i].expenseType} — ${oldItems[i].applicantName}\n申请人: ${oldItems[i].applicantName} · ${oldItems[i].deptName}'
  301. : '${oldItems[i].expenseType} — ${oldItems[i].applicantName}';
  302. final card = ListCard(
  303. cardNo: oldItems[i].reportNo,
  304. amount: '¥${oldItems[i].totalAmount.toStringAsFixed(2)}',
  305. description: desc,
  306. date: du.DateUtils.formatDate(oldItems[i].createTime),
  307. statusTag: StatusTag.fromStatus(oldItems[i].status, l10n),
  308. onTap: () => context.push('/expense/detail/${oldItems[i].id}'),
  309. );
  310. if (isSub && oldItems[i].status == 'pending') {
  311. return Padding(
  312. padding: const EdgeInsets.only(bottom: 16),
  313. child: _buildSwipeApprove(card, oldItems[i].id),
  314. );
  315. }
  316. return Padding(
  317. padding: const EdgeInsets.only(bottom: 16),
  318. child: card,
  319. );
  320. },
  321. );
  322. }
  323. if (itemsAsync.hasError) {
  324. return ListView(
  325. children: [
  326. const SizedBox(height: 120),
  327. EmptyState(message: l10n.get('loadFailed')),
  328. ],
  329. );
  330. }
  331. final items = itemsAsync.requireValue;
  332. if (items.isEmpty) {
  333. return ListView(
  334. children: [
  335. const SizedBox(height: 120),
  336. EmptyState(message: l10n.get('noExpenses')),
  337. ],
  338. );
  339. }
  340. return ListView.builder(
  341. padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
  342. itemCount: items.length + 1,
  343. itemBuilder: (_, i) {
  344. if (i == items.length) return ListFooter(itemCount: items.length);
  345. final desc = isSub
  346. ? '${items[i].expenseType} — ${items[i].applicantName}\n申请人: ${items[i].applicantName} · ${items[i].deptName}'
  347. : '${items[i].expenseType} — ${items[i].applicantName}';
  348. final card = ListCard(
  349. cardNo: items[i].reportNo,
  350. amount: '¥${items[i].totalAmount.toStringAsFixed(2)}',
  351. description: desc,
  352. date: du.DateUtils.formatDate(items[i].createTime),
  353. statusTag: StatusTag.fromStatus(items[i].status, l10n),
  354. onTap: () => context.push('/expense/detail/${items[i].id}'),
  355. );
  356. if (isSub && items[i].status == 'pending') {
  357. return Padding(
  358. padding: const EdgeInsets.only(bottom: 16),
  359. child: _buildSwipeApprove(card, items[i].id),
  360. );
  361. }
  362. return Padding(padding: const EdgeInsets.only(bottom: 16), child: card);
  363. },
  364. );
  365. }
  366. Widget _buildSwipeApprove(Widget card, String itemId) {
  367. return Builder(
  368. builder: (ctx) {
  369. final screenWidth = MediaQuery.of(ctx).size.width;
  370. return TDSwipeCell(
  371. groupTag: 'expense_approve',
  372. right: TDSwipeCellPanel(
  373. extentRatio: 100 / screenWidth,
  374. children: [
  375. TDSwipeCellAction(
  376. label: '',
  377. backgroundColor: Colors.transparent,
  378. builder: (_) => Container(
  379. margin: const EdgeInsets.symmetric(
  380. horizontal: 4,
  381. vertical: 8,
  382. ),
  383. decoration: BoxDecoration(
  384. color: Colors.green,
  385. borderRadius: BorderRadius.circular(8),
  386. ),
  387. alignment: Alignment.center,
  388. padding: const EdgeInsets.symmetric(horizontal: 12),
  389. child: const Text(
  390. '一键同意',
  391. style: TextStyle(
  392. color: Colors.white,
  393. fontSize: 14,
  394. fontWeight: FontWeight.w600,
  395. ),
  396. ),
  397. ),
  398. onPressed: (_) async {
  399. final confirmed = await showDialog<bool>(
  400. context: ctx,
  401. builder: (dCtx) => TDAlertDialog(
  402. title: '确认审批',
  403. content: '确认同意该报销?',
  404. leftBtn: TDDialogButtonOptions(
  405. title: '取消',
  406. action: () => Navigator.of(dCtx).pop(false),
  407. ),
  408. rightBtn: TDDialogButtonOptions(
  409. title: '确认',
  410. action: () => Navigator.of(dCtx).pop(true),
  411. ),
  412. ),
  413. );
  414. if (confirmed == true) {
  415. // TODO: 接入实际审批 API
  416. if (ctx.mounted) {
  417. TDToast.showSuccess('已审批通过', context: ctx);
  418. }
  419. }
  420. },
  421. ),
  422. ],
  423. ),
  424. cell: card,
  425. );
  426. },
  427. );
  428. }
  429. }