expense_create_page.dart 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  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 '../../shared/widgets/nav_bar_config.dart';
  6. import '../../core/utils/responsive.dart';
  7. import '../../shared/widgets/form_section.dart';
  8. import '../../shared/widgets/form_field_row.dart';
  9. import 'expense_api.dart';
  10. import 'expense_create_controller.dart';
  11. import '../../core/i18n/app_localizations.dart';
  12. import 'expense_model.dart';
  13. import '../../core/theme/app_colors.dart';
  14. import '../../core/theme/app_colors_extension.dart';
  15. import '../../core/data/mock_api_data.dart';
  16. import 'widgets/expense_detail_dialog.dart';
  17. import 'package:image_picker/image_picker.dart';
  18. class ExpenseApplyPage extends ConsumerStatefulWidget {
  19. final String? editId;
  20. const ExpenseApplyPage({super.key, this.editId});
  21. @override
  22. ConsumerState<ExpenseApplyPage> createState() => _ExpenseApplyPageState();
  23. }
  24. class _ExpenseApplyPageState extends ConsumerState<ExpenseApplyPage> {
  25. final _purposeController = TextEditingController();
  26. final List<String> _attachments = [];
  27. @override
  28. void initState() {
  29. super.initState();
  30. WidgetsBinding.instance.addPostFrameCallback((_) => _checkDraft());
  31. }
  32. Future<void> _checkDraft() async {
  33. if (!mounted || widget.editId != null) return;
  34. final has = await ExpenseCreateController.hasDraft();
  35. if (!has || !mounted) return;
  36. final l10n = AppLocalizations.of(context);
  37. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  38. final yes = await showDialog<bool>(
  39. context: context,
  40. builder: (ctx) => TDAlertDialog(
  41. title: l10n.get('draftFound'),
  42. content: l10n.get('draftRestorePrompt'),
  43. leftBtn: TDDialogButtonOptions(
  44. title: l10n.get('discard'),
  45. titleColor: colors.textSecondary,
  46. action: () => Navigator.pop(ctx, false),
  47. ),
  48. rightBtn: TDDialogButtonOptions(
  49. title: l10n.get('restore'),
  50. titleColor: colors.primary,
  51. action: () => Navigator.pop(ctx, true),
  52. ),
  53. ),
  54. );
  55. if (yes == true && mounted) {
  56. final draft = await ExpenseCreateController.loadDraft();
  57. if (draft != null && mounted) {
  58. final api = ref.read(expenseApiProvider);
  59. ref.read(expenseCreateProvider(widget.editId).notifier)
  60. ..restoreFromDraft(draft, api);
  61. }
  62. } else {
  63. await ExpenseCreateController.deleteDraft();
  64. }
  65. }
  66. @override
  67. void dispose() {
  68. _purposeController.dispose();
  69. super.dispose();
  70. }
  71. @override
  72. Widget build(BuildContext context) {
  73. final controller = ref.watch(expenseCreateProvider(widget.editId).notifier);
  74. final state = ref.watch(expenseCreateProvider(widget.editId));
  75. final r = ResponsiveHelper.of(context);
  76. final l10n = AppLocalizations.of(context);
  77. ref
  78. .read(navBarConfigProvider.notifier)
  79. .update(
  80. NavBarConfig(
  81. title: widget.editId != null
  82. ? l10n.get('editExpense')
  83. : l10n.get('expenseApply'),
  84. showBack: true,
  85. onBack: () => context.pop(),
  86. ),
  87. );
  88. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  89. final bottomInset = MediaQuery.of(context).padding.bottom;
  90. return Column(
  91. children: [
  92. Expanded(
  93. child: Align(
  94. alignment: Alignment.topCenter,
  95. child: ConstrainedBox(
  96. constraints: BoxConstraints(maxWidth: r.formMaxWidth),
  97. child: SingleChildScrollView(
  98. padding: const EdgeInsets.all(16),
  99. child: Column(
  100. crossAxisAlignment: CrossAxisAlignment.start,
  101. children: [
  102. _buildImportLink(),
  103. const SizedBox(height: 16),
  104. _buildBasicInfoSection(controller, state),
  105. const SizedBox(height: 16),
  106. _buildDetailSection(controller, state),
  107. const SizedBox(height: 16),
  108. _buildInvoiceSection(controller, state),
  109. ],
  110. ),
  111. ),
  112. ),
  113. ),
  114. ),
  115. ColoredBox(
  116. color: colors.bgCard,
  117. child: Column(
  118. mainAxisSize: MainAxisSize.min,
  119. children: [
  120. _buildBottomButtons(controller, state),
  121. if (bottomInset > 0) SizedBox(height: bottomInset),
  122. ],
  123. ),
  124. ),
  125. ],
  126. );
  127. }
  128. Widget _buildImportLink() {
  129. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  130. final l10n = AppLocalizations.of(context);
  131. return GestureDetector(
  132. onTap: () {
  133. TDToast.showText(l10n.get('expenseApplyImport'), context: context);
  134. },
  135. child: Container(
  136. height: 44,
  137. decoration: BoxDecoration(
  138. color: colors.primaryLight,
  139. borderRadius: BorderRadius.circular(8),
  140. ),
  141. child: Row(
  142. mainAxisAlignment: MainAxisAlignment.center,
  143. children: [
  144. Icon(Icons.download, size: 14, color: colors.primary),
  145. const SizedBox(width: 8),
  146. Text(
  147. l10n.get('importApprovedPreApp'),
  148. style: TextStyle(
  149. fontSize: AppFontSizes.body,
  150. color: colors.primary,
  151. ),
  152. ),
  153. ],
  154. ),
  155. ),
  156. );
  157. }
  158. Widget _buildBasicInfoSection(
  159. ExpenseCreateController controller,
  160. ExpenseCreateState state,
  161. ) {
  162. final l10n = AppLocalizations.of(context);
  163. final expense = state.expense;
  164. return FormSection(
  165. title: l10n.get('basicInfo'),
  166. leadingIcon: Icons.info_outline,
  167. children: [
  168. FormFieldRow(
  169. label: l10n.get('date'),
  170. value: _today(),
  171. readOnly: true,
  172. showArrow: false,
  173. ),
  174. const SizedBox(height: 16),
  175. FormFieldRow(
  176. label: l10n.get('reportNo'),
  177. value: expense.expenseNo.isNotEmpty ? expense.expenseNo : null,
  178. hint: l10n.get('autoGenerated'),
  179. readOnly: true,
  180. showArrow: false,
  181. ),
  182. const SizedBox(height: 16),
  183. FormFieldRow(
  184. label: l10n.get('currency'),
  185. value: expense.currencyCode.isNotEmpty ? expense.currencyCode : 'CNY',
  186. hint: l10n.get('selectCurrency'),
  187. onTap: () => _showCurrencyPicker(controller, expense.currencyCode),
  188. ),
  189. const SizedBox(height: 16),
  190. FormFieldRow(
  191. label: l10n.get('applicant'),
  192. value: expense.applicantName.isNotEmpty
  193. ? expense.applicantName
  194. : '张三',
  195. readOnly: true,
  196. showArrow: false,
  197. ),
  198. const SizedBox(height: 16),
  199. FormFieldRow(
  200. label: l10n.get('department'),
  201. value: expense.deptName.isNotEmpty ? expense.deptName : '技术部',
  202. readOnly: true,
  203. showArrow: false,
  204. ),
  205. const SizedBox(height: 16),
  206. FormFieldRow(
  207. label: l10n.get('paymentMethod'),
  208. value: expense.paymentMethod.isNotEmpty
  209. ? expense.paymentMethod
  210. : null,
  211. hint: l10n.get('selectPaymentMethod'),
  212. onTap: () => _showPaymentMethodPicker(controller),
  213. ),
  214. const SizedBox(height: 16),
  215. FormFieldRow(
  216. label: l10n.get('expenseReason'),
  217. value: _purposeController.text.isNotEmpty
  218. ? _purposeController.text
  219. : null,
  220. hint: l10n.get('enterExpenseReason'),
  221. onTap: () => _showTextInput(
  222. l10n.get('expenseReason'),
  223. l10n.get('enterExpenseReason'),
  224. (v) {
  225. setState(() {
  226. _purposeController.text = v;
  227. _purposeController.selection = TextSelection.fromPosition(
  228. TextPosition(offset: v.length),
  229. );
  230. });
  231. controller.updatePurpose(v);
  232. },
  233. initialText: _purposeController.text,
  234. ),
  235. ),
  236. ],
  237. );
  238. }
  239. Widget _buildDetailSection(
  240. ExpenseCreateController controller,
  241. ExpenseCreateState state,
  242. ) {
  243. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  244. final l10n = AppLocalizations.of(context);
  245. final totalApproved = state.expense.details.fold<double>(
  246. 0,
  247. (sum, d) => sum + d.approvedAmount,
  248. );
  249. return FormSection(
  250. title: l10n.get('expenseDetails'),
  251. leadingIcon: Icons.receipt_long_outlined,
  252. showAction: true,
  253. actionText: l10n.get('add'),
  254. onActionTap: () => _showAddDetailDialog(controller),
  255. children: [
  256. if (state.expense.details.isEmpty)
  257. Padding(
  258. padding: const EdgeInsets.symmetric(vertical: 8),
  259. child: Text(
  260. l10n.get('noDetailHint'),
  261. style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textPlaceholder),
  262. ),
  263. )
  264. else
  265. ...state.expense.details.asMap().entries.map((entry) {
  266. final d = entry.value;
  267. return Container(
  268. margin: const EdgeInsets.symmetric(vertical: 8),
  269. padding: const EdgeInsets.all(12),
  270. decoration: BoxDecoration(color: colors.bgPage, borderRadius: BorderRadius.circular(8)),
  271. child: Stack(
  272. children: [
  273. Column(
  274. crossAxisAlignment: CrossAxisAlignment.start,
  275. children: [
  276. Row(
  277. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  278. children: [
  279. Expanded(child: Text(d.purpose.isNotEmpty ? d.purpose : d.expenseCategory,
  280. style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textPrimary))),
  281. Text('¥${d.totalAmount.toStringAsFixed(2)}',
  282. style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: colors.amountPrimary)),
  283. ],
  284. ),
  285. const SizedBox(height: 4),
  286. Text('¥${d.amount.toStringAsFixed(2)} + 税${d.taxAmount.toStringAsFixed(2)} | ${d.bankName}',
  287. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  288. ],
  289. ),
  290. Positioned(
  291. right: 0, top: 0,
  292. child: GestureDetector(
  293. onTap: () { controller.removeDetail(entry.key); controller.recalculateAmount(); },
  294. child: Container(width: 24, height: 24, decoration: BoxDecoration(color: colors.primaryLight, shape: BoxShape.circle),
  295. child: Icon(Icons.close, size: 14, color: colors.primary700)),
  296. ),
  297. ),
  298. ],
  299. ),
  300. );
  301. }),
  302. const SizedBox(height: 8),
  303. Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
  304. Text(l10n.get('totalExpense'), style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.textPrimary)),
  305. Text('¥${state.expense.totalAmount.toStringAsFixed(2)}', style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: colors.amountPrimary)),
  306. ]),
  307. const SizedBox(height: 4),
  308. Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
  309. Text(l10n.get('approvedTotal'), style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.textPrimary)),
  310. Text('¥${totalApproved.toStringAsFixed(2)}', style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: colors.amountPrimary)),
  311. ]),
  312. ],
  313. );
  314. }
  315. Widget _buildInvoiceSection(ExpenseCreateController controller, ExpenseCreateState state) {
  316. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  317. final l10n = AppLocalizations.of(context);
  318. return FormSection(
  319. title: l10n.get('invoiceUpload'),
  320. leadingIcon: Icons.attach_file_outlined,
  321. children: [
  322. Text(l10n.get('maxInvoices'), style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPlaceholder)),
  323. const SizedBox(height: 8),
  324. if (_attachments.isEmpty)
  325. Text(l10n.get('noAttachment'), style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textPlaceholder))
  326. else
  327. Wrap(
  328. spacing: 8,
  329. runSpacing: 8,
  330. children: [
  331. ..._attachments.asMap().entries.map((entry) {
  332. final i = entry.key;
  333. final path = entry.value;
  334. final name = path.split('/').last.split('\\').last;
  335. return SizedBox(
  336. width: 80,
  337. child: Stack(
  338. children: [
  339. Container(
  340. width: 80,
  341. height: 80,
  342. decoration: BoxDecoration(
  343. color: colors.bgPage,
  344. borderRadius: BorderRadius.circular(4),
  345. border: Border.all(color: colors.border),
  346. ),
  347. child: Column(
  348. mainAxisAlignment: MainAxisAlignment.center,
  349. children: [
  350. Icon(Icons.insert_drive_file, size: 28, color: colors.primary),
  351. const SizedBox(height: 2),
  352. Padding(
  353. padding: const EdgeInsets.symmetric(horizontal: 4),
  354. child: Text(
  355. name,
  356. maxLines: 2,
  357. overflow: TextOverflow.ellipsis,
  358. style: TextStyle(fontSize: 10, color: colors.textSecondary),
  359. textAlign: TextAlign.center,
  360. ),
  361. ),
  362. ],
  363. ),
  364. ),
  365. Positioned(
  366. right: 0,
  367. top: 0,
  368. child: GestureDetector(
  369. onTap: () => setState(() => _attachments.removeAt(i)),
  370. child: Container(
  371. width: 20,
  372. height: 20,
  373. decoration: BoxDecoration(
  374. color: colors.bgCard,
  375. shape: BoxShape.circle,
  376. border: Border.all(color: colors.border),
  377. ),
  378. child: Icon(Icons.close, size: 12, color: colors.textSecondary),
  379. ),
  380. ),
  381. ),
  382. ],
  383. ),
  384. );
  385. }),
  386. if (_attachments.length < 9)
  387. GestureDetector(
  388. onTap: _pickFiles,
  389. child: Container(
  390. width: 80,
  391. height: 80,
  392. decoration: BoxDecoration(
  393. color: colors.bgPage,
  394. borderRadius: BorderRadius.circular(4),
  395. border: Border.all(color: colors.border),
  396. ),
  397. child: Center(child: Icon(Icons.add, size: 24, color: colors.textPlaceholder)),
  398. ),
  399. ),
  400. ],
  401. ),
  402. ],
  403. );
  404. }
  405. Future<void> _pickFiles() async {
  406. final available = 9 - _attachments.length;
  407. if (available <= 0) return;
  408. final picker = ImagePicker();
  409. final images = await picker.pickMultiImage(limit: available);
  410. if (images.isEmpty) return;
  411. setState(() {
  412. _attachments.addAll(images.map((img) => img.path));
  413. if (_attachments.length > 9) _attachments.length = 9;
  414. });
  415. }
  416. Widget _buildBottomButtons(ExpenseCreateController controller, ExpenseCreateState state) {
  417. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  418. final l10n = AppLocalizations.of(context);
  419. return Container(height: 72, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), decoration: BoxDecoration(color: colors.bgCard),
  420. child: Row(children: [
  421. _btn(l10n.get('saveDraftShort'), colors.textSecondary, colors.bgCard, () async {
  422. if (state.isSubmitting) return;
  423. final ok = await controller.saveDraft();
  424. if (mounted && ok) TDToast.showText(l10n.get('draftSavedToast'), context: context);
  425. }),
  426. const SizedBox(width: 12),
  427. _btn(l10n.get('reset'), colors.textSecondary, colors.bgCard, () => setState(() => _purposeController.clear())),
  428. const SizedBox(width: 12),
  429. _btn(l10n.get('save'), colors.bgCard, colors.primary, () async {
  430. if (state.isSubmitting) return;
  431. await controller.saveDraft();
  432. if (mounted) context.pop();
  433. }),
  434. ]));
  435. }
  436. Widget _btn(String label, Color fg, Color bg, VoidCallback onTap) {
  437. return Expanded(child: SizedBox(height: 40,
  438. child: Material(color: bg, borderRadius: BorderRadius.circular(22),
  439. child: InkWell(onTap: onTap, borderRadius: BorderRadius.circular(22),
  440. child: Center(child: Text(label, style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w500, color: fg)))))));
  441. }
  442. Future<void> _showAddDetailDialog(ExpenseCreateController controller) async {
  443. final l10n = AppLocalizations.of(context);
  444. final result = await ExpenseDetailDialog.show(context, categories: mockCostCategories, l10n: l10n);
  445. if (result != null && mounted) {
  446. final now = DateTime.now();
  447. controller.addDetail(ExpenseDetailModel(
  448. id: now.millisecondsSinceEpoch.toString(),
  449. expenseId: '',
  450. expenseCategory: result.category,
  451. purpose: result.purpose,
  452. amount: result.amount,
  453. taxRate: result.taxRate,
  454. totalAmount: result.amount,
  455. customerVendorName: result.customerVendorName,
  456. offsetAmount: result.offsetAmount,
  457. remark: result.remark,
  458. createTime: now,
  459. updateTime: now,
  460. ));
  461. controller.recalculateAmount();
  462. }
  463. }
  464. void _showCurrencyPicker(ExpenseCreateController controller, String cur) {
  465. final l10n = AppLocalizations.of(context);
  466. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  467. const cs = ['CNY', 'USD', 'EUR', 'JPY', 'HKD', 'GBP'];
  468. TDPicker.showMultiPicker(context, title: l10n.get('selectCurrency'), backgroundColor: colors.bgCard, data: [cs], onConfirm: (s) {
  469. if (s.isNotEmpty && s[0] is int) { final i = s[0] as int; if (i >= 0 && i < cs.length) { Navigator.of(context).pop(); controller.updateCurrencyCode(cs[i]); } }
  470. });
  471. }
  472. void _showPaymentMethodPicker(ExpenseCreateController controller) {
  473. final l10n = AppLocalizations.of(context);
  474. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  475. const ms = ['bankTransfer', 'cash', 'check', 'alipay', 'wechat'];
  476. TDPicker.showMultiPicker(context, title: l10n.get('selectPaymentMethod'), backgroundColor: colors.bgCard, data: [ms], onConfirm: (s) {
  477. if (s.isNotEmpty && s[0] is int) { final i = s[0] as int; if (i >= 0 && i < ms.length) { Navigator.of(context).pop(); TDToast.showText(ms[i], context: context); } }
  478. });
  479. }
  480. void _showTextInput(String title, String hint, void Function(String) onConfirm, {String initialText = ''}) {
  481. final l10n = AppLocalizations.of(context);
  482. final c = TextEditingController(text: initialText);
  483. showGeneralDialog(context: context, pageBuilder: (ctx, _, __) => TDInputDialog(
  484. textEditingController: c, title: title, hintText: hint,
  485. leftBtn: TDDialogButtonOptions(title: l10n.get('cancel'), action: () => Navigator.pop(ctx)),
  486. rightBtn: TDDialogButtonOptions(title: l10n.get('confirm'), action: () { onConfirm(c.text); Navigator.pop(ctx); })));
  487. }
  488. String _today() { final n = DateTime.now(); return '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}'; }
  489. }