expense_apply_page.dart 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import 'package:tdesign_flutter/tdesign_flutter.dart';
  4. import 'package:go_router/go_router.dart';
  5. import '../../core/theme/app_colors.dart';
  6. import '../shell/nav_bar_config.dart';
  7. import '../../core/utils/responsive.dart';
  8. import '../../shared/widgets/form_section.dart';
  9. import '../../shared/widgets/form_field_row.dart';
  10. import '../../shared/widgets/action_bar.dart';
  11. import 'expense_apply_controller.dart';
  12. import '../../core/i18n/app_localizations.dart';
  13. import 'expense_model.dart';
  14. class ExpenseApplyPage extends ConsumerStatefulWidget {
  15. final String? editId;
  16. const ExpenseApplyPage({super.key, this.editId});
  17. @override
  18. ConsumerState<ExpenseApplyPage> createState() => _ExpenseApplyPageState();
  19. }
  20. class _ExpenseApplyPageState extends ConsumerState<ExpenseApplyPage> {
  21. final _remarkController = TextEditingController();
  22. final _purposeController = TextEditingController();
  23. final _bankNameController = TextEditingController(text: '中国银行');
  24. final _accountNameController = TextEditingController(text: '张三');
  25. @override
  26. void dispose() {
  27. _remarkController.dispose();
  28. _purposeController.dispose();
  29. _bankNameController.dispose();
  30. _accountNameController.dispose();
  31. super.dispose();
  32. }
  33. @override
  34. Widget build(BuildContext context) {
  35. final controller = ref.watch(expenseApplyProvider(widget.editId).notifier);
  36. final state = ref.watch(expenseApplyProvider(widget.editId));
  37. final r = ResponsiveHelper.of(context);
  38. final l10n = AppLocalizations.of(context);
  39. ref
  40. .read(navBarConfigProvider.notifier)
  41. .update(
  42. NavBarConfig(
  43. title: widget.editId != null
  44. ? l10n.get('editExpense')
  45. : l10n.get('expenseApply'),
  46. showBack: true,
  47. onBack: () => context.pop(),
  48. ),
  49. );
  50. return Column(
  51. children: [
  52. Expanded(
  53. child: Align(
  54. alignment: Alignment.topCenter,
  55. child: ConstrainedBox(
  56. constraints: BoxConstraints(maxWidth: r.formMaxWidth),
  57. child: SingleChildScrollView(
  58. padding: const EdgeInsets.all(16),
  59. child: Column(
  60. children: [
  61. _buildImportLink(),
  62. const SizedBox(height: 16),
  63. _buildBasicInfoSection(controller, state),
  64. const SizedBox(height: 16),
  65. _buildAccountSection(controller, state),
  66. const SizedBox(height: 16),
  67. _buildDetailSection(controller, state),
  68. const SizedBox(height: 16),
  69. _buildInvoiceSection(controller, state),
  70. ],
  71. ),
  72. ),
  73. ),
  74. ),
  75. ),
  76. _buildBottomButtons(controller, state),
  77. ],
  78. );
  79. }
  80. Widget _buildImportLink() {
  81. final l10n = AppLocalizations.of(context);
  82. return GestureDetector(
  83. onTap: () {
  84. ScaffoldMessenger.of(
  85. context,
  86. ).showSnackBar(SnackBar(content: Text(l10n.get('expenseApplyImport'))));
  87. },
  88. child: Container(
  89. height: 44,
  90. decoration: BoxDecoration(
  91. color: AppColors.primaryLight,
  92. borderRadius: BorderRadius.circular(8),
  93. ),
  94. child: Row(
  95. mainAxisAlignment: MainAxisAlignment.center,
  96. children: [
  97. const Icon(Icons.download, size: 14, color: AppColors.primary),
  98. const SizedBox(width: 8),
  99. Text(
  100. l10n.get('importApprovedPreApp'),
  101. style: TextStyle(
  102. fontSize: AppFontSizes.body,
  103. color: AppColors.primary,
  104. ),
  105. ),
  106. ],
  107. ),
  108. ),
  109. );
  110. }
  111. Widget _buildBasicInfoSection(
  112. ExpenseApplyController controller,
  113. ExpenseApplyState state,
  114. ) {
  115. final l10n = AppLocalizations.of(context);
  116. final expense = state.expense;
  117. return FormSection(
  118. title: l10n.get('basicInfo'),
  119. children: [
  120. FormFieldRow(label: l10n.get('expenseReason'), hint: l10n.get('enterExpenseReason')),
  121. FormFieldRow(
  122. label: l10n.get('relatedProject'),
  123. value: expense.projectName.isNotEmpty ? expense.projectName : null,
  124. hint: l10n.get('selectProject'),
  125. onTap: () {
  126. ScaffoldMessenger.of(
  127. context,
  128. ).showSnackBar(SnackBar(content: Text(l10n.get('projectSelection'))));
  129. },
  130. ),
  131. FormFieldRow(
  132. label: l10n.get('budgetSubject'),
  133. value: expense.budgetSubjectId.isNotEmpty
  134. ? expense.budgetSubjectId
  135. : null,
  136. hint: l10n.get('selectSubject'),
  137. onTap: () {
  138. ScaffoldMessenger.of(
  139. context,
  140. ).showSnackBar(SnackBar(content: Text(l10n.get('budgetSubjectSelection'))));
  141. },
  142. ),
  143. FormFieldRow(
  144. label: l10n.get('costCenter'),
  145. value: expense.costCenterId.isNotEmpty ? expense.costCenterId : null,
  146. hint: l10n.get('selectCostCenter'),
  147. onTap: () {
  148. ScaffoldMessenger.of(
  149. context,
  150. ).showSnackBar(SnackBar(content: Text(l10n.get('costCenterSelection'))));
  151. },
  152. ),
  153. Container(
  154. height: 44,
  155. padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
  156. child: Row(
  157. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  158. children: [
  159. Text(
  160. l10n.get('totalExpense'),
  161. style: const TextStyle(
  162. fontSize: AppFontSizes.body,
  163. color: AppColors.textSecondary,
  164. ),
  165. ),
  166. Text(
  167. '¥${expense.totalAmount.toStringAsFixed(2)}',
  168. style: const TextStyle(
  169. fontSize: AppFontSizes.subtitle,
  170. fontWeight: FontWeight.w700,
  171. color: AppColors.amountPrimary,
  172. ),
  173. ),
  174. ],
  175. ),
  176. ),
  177. ],
  178. );
  179. }
  180. Widget _buildAccountSection(
  181. ExpenseApplyController controller,
  182. ExpenseApplyState state,
  183. ) {
  184. final l10n = AppLocalizations.of(context);
  185. return FormSection(
  186. title: l10n.get('receiptAccount'),
  187. children: [
  188. FormFieldRow(
  189. label: l10n.get('bankName'),
  190. value: _bankNameController.text.isNotEmpty
  191. ? _bankNameController.text
  192. : null,
  193. hint: l10n.get('selectBank'),
  194. onTap: () {
  195. ScaffoldMessenger.of(
  196. context,
  197. ).showSnackBar(SnackBar(content: Text(l10n.get('bankSelection'))));
  198. },
  199. ),
  200. FormFieldRow(
  201. label: l10n.get('accountName'),
  202. value: _accountNameController.text,
  203. readOnly: true,
  204. showArrow: false,
  205. ),
  206. FormFieldRow(
  207. label: l10n.get('bankAccount'),
  208. hint: l10n.get('enterBankAccount'),
  209. onTap: () {
  210. ScaffoldMessenger.of(
  211. context,
  212. ).showSnackBar(SnackBar(content: Text(l10n.get('bankAccountInput'))));
  213. },
  214. ),
  215. ],
  216. );
  217. }
  218. Widget _buildDetailSection(
  219. ExpenseApplyController controller,
  220. ExpenseApplyState state,
  221. ) {
  222. final l10n = AppLocalizations.of(context);
  223. return FormSection(
  224. title: l10n.get('expenseDetails'),
  225. showAction: state.expense.details.isNotEmpty,
  226. actionText: l10n.get('add'),
  227. onActionTap: () => _showAddDetailDialog(controller),
  228. children: [
  229. if (state.expense.details.isEmpty) ...[
  230. GestureDetector(
  231. onTap: () => _showAddDetailDialog(controller),
  232. child: Container(
  233. padding: const EdgeInsets.symmetric(vertical: 12),
  234. decoration: BoxDecoration(
  235. border: Border.all(
  236. color: AppColors.border,
  237. strokeAlign: BorderSide.strokeAlignInside,
  238. ),
  239. borderRadius: BorderRadius.circular(4),
  240. color: AppColors.bgPage,
  241. ),
  242. child: Row(
  243. mainAxisAlignment: MainAxisAlignment.center,
  244. children: [
  245. const Icon(Icons.add, size: 16, color: AppColors.primary),
  246. const SizedBox(width: 4),
  247. Text(
  248. l10n.get('addExpenseDetail'),
  249. style: TextStyle(
  250. fontSize: AppFontSizes.body,
  251. color: AppColors.primary,
  252. ),
  253. ),
  254. ],
  255. ),
  256. ),
  257. ),
  258. ] else
  259. ...state.expense.details.asMap().entries.map((entry) {
  260. final d = entry.value;
  261. return Container(
  262. height: 38,
  263. padding: const EdgeInsets.symmetric(vertical: 4),
  264. child: Row(
  265. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  266. children: [
  267. Row(
  268. children: [
  269. Icon(
  270. Icons.receipt_long,
  271. size: 14,
  272. color: AppColors.textSecondary,
  273. ),
  274. const SizedBox(width: 8),
  275. Text(
  276. d.expenseDesc,
  277. style: const TextStyle(
  278. fontSize: AppFontSizes.body,
  279. color: AppColors.textPrimary,
  280. ),
  281. ),
  282. ],
  283. ),
  284. Row(
  285. mainAxisSize: MainAxisSize.min,
  286. children: [
  287. Text(
  288. '¥${d.totalAmount.toStringAsFixed(2)}',
  289. style: const TextStyle(
  290. fontSize: AppFontSizes.body,
  291. fontWeight: FontWeight.w500,
  292. color: AppColors.amountPrimary,
  293. ),
  294. ),
  295. const SizedBox(width: 8),
  296. GestureDetector(
  297. onTap: () {
  298. controller.removeDetail(entry.key);
  299. controller.recalculateAmount();
  300. },
  301. child: const Icon(
  302. Icons.close,
  303. size: 16,
  304. color: AppColors.textPlaceholder,
  305. ),
  306. ),
  307. ],
  308. ),
  309. ],
  310. ),
  311. );
  312. }),
  313. if (state.expense.details.isNotEmpty) ...[
  314. Container(height: 1, color: AppColors.border),
  315. Container(
  316. height: 36,
  317. padding: const EdgeInsets.symmetric(vertical: 8),
  318. child: Row(
  319. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  320. children: [
  321. Text(
  322. l10n.get('total'),
  323. style: const TextStyle(
  324. fontSize: AppFontSizes.body,
  325. fontWeight: FontWeight.w600,
  326. color: AppColors.textPrimary,
  327. ),
  328. ),
  329. Text(
  330. '¥${state.expense.totalAmount.toStringAsFixed(2)}',
  331. style: const TextStyle(
  332. fontSize: AppFontSizes.subtitle,
  333. fontWeight: FontWeight.w700,
  334. color: AppColors.amountPrimary,
  335. ),
  336. ),
  337. ],
  338. ),
  339. ),
  340. ],
  341. ],
  342. );
  343. }
  344. Widget _buildInvoiceSection(
  345. ExpenseApplyController controller,
  346. ExpenseApplyState state,
  347. ) {
  348. final l10n = AppLocalizations.of(context);
  349. return FormSection(
  350. title: l10n.get('invoiceUpload'),
  351. children: [
  352. Text(
  353. l10n.get('maxInvoices'),
  354. style: TextStyle(
  355. fontSize: AppFontSizes.caption,
  356. color: AppColors.textPlaceholder,
  357. ),
  358. ),
  359. const SizedBox(height: 8),
  360. Wrap(
  361. spacing: 8,
  362. runSpacing: 8,
  363. children: List.generate(6, (i) {
  364. return Container(
  365. width: 80,
  366. height: 80,
  367. decoration: BoxDecoration(
  368. color: AppColors.bgPage,
  369. borderRadius: BorderRadius.circular(4),
  370. border: Border.all(
  371. color: AppColors.border,
  372. strokeAlign: BorderSide.strokeAlignInside,
  373. ),
  374. ),
  375. child: i == 0
  376. ? const Center(
  377. child: Icon(
  378. Icons.add,
  379. size: 24,
  380. color: AppColors.textPlaceholder,
  381. ),
  382. )
  383. : const SizedBox.shrink(),
  384. );
  385. }),
  386. ),
  387. ],
  388. );
  389. }
  390. Widget _buildBottomButtons(
  391. ExpenseApplyController controller,
  392. ExpenseApplyState state,
  393. ) {
  394. final l10n = AppLocalizations.of(context);
  395. return ActionBar(
  396. leftLabel: l10n.get('reset'),
  397. centerLabel: l10n.get('saveDraft'),
  398. rightLabel: l10n.get('submitApproval'),
  399. onLeftTap: () {
  400. setState(() {
  401. _purposeController.clear();
  402. _remarkController.clear();
  403. });
  404. },
  405. onCenterTap: state.isSubmitting
  406. ? null
  407. : () async {
  408. await controller.saveDraft();
  409. if (!mounted) return;
  410. context.pop();
  411. },
  412. onRightTap: state.isSubmitting
  413. ? null
  414. : () async {
  415. final ok = await controller.submit();
  416. if (!mounted) return;
  417. if (ok) context.pop();
  418. },
  419. showLeft: true,
  420. );
  421. }
  422. void _showAddDetailDialog(ExpenseApplyController controller) {
  423. final l10n = AppLocalizations.of(context);
  424. final nameCtrl = TextEditingController();
  425. final amountCtrl = TextEditingController();
  426. final descCtrl = TextEditingController();
  427. showDialog(
  428. context: context,
  429. builder: (_) => TDAlertDialog(
  430. title: l10n.get('addDetail'),
  431. contentWidget: Column(
  432. mainAxisSize: MainAxisSize.min,
  433. children: [
  434. TDInput(controller: nameCtrl, hintText: l10n.get('expenseName')),
  435. const SizedBox(height: 8),
  436. TDInput(
  437. controller: amountCtrl,
  438. hintText: l10n.get('amount'),
  439. inputType: TextInputType.number,
  440. ),
  441. const SizedBox(height: 8),
  442. TDInput(controller: descCtrl, hintText: l10n.get('description')),
  443. ],
  444. ),
  445. leftBtn: TDDialogButtonOptions(
  446. title: l10n.get('cancel'),
  447. action: () => Navigator.pop(context),
  448. ),
  449. rightBtn: TDDialogButtonOptions(
  450. title: l10n.get('add'),
  451. action: () {
  452. final amount = double.tryParse(amountCtrl.text) ?? 0.0;
  453. controller.addDetail(
  454. ExpenseDetailModel(
  455. id: DateTime.now().millisecondsSinceEpoch.toString(),
  456. expenseId: '',
  457. expenseDate: DateTime.now(),
  458. expenseType: '',
  459. expenseDesc: nameCtrl.text,
  460. amount: amount,
  461. totalAmount: amount,
  462. remark: descCtrl.text,
  463. ),
  464. );
  465. controller.recalculateAmount();
  466. Navigator.pop(context);
  467. },
  468. ),
  469. ),
  470. );
  471. }
  472. }