expense_create_page.dart 47 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394
  1. import 'dart:async';
  2. import 'dart:io';
  3. import 'package:flutter/material.dart';
  4. import 'package:flutter/services.dart';
  5. import 'package:flutter_riverpod/flutter_riverpod.dart';
  6. import 'package:tdesign_flutter/tdesign_flutter.dart';
  7. import 'package:go_router/go_router.dart';
  8. import '../../core/utils/responsive.dart';
  9. import '../../shared/widgets/form_section.dart';
  10. import '../../shared/widgets/form_field_row.dart';
  11. import '../../shared/widgets/app_skeletons.dart';
  12. import '../../shared/widgets/nav_bar_config.dart';
  13. import 'expense_api.dart';
  14. import 'expense_create_controller.dart';
  15. import '../../core/i18n/app_localizations.dart';
  16. import 'expense_model.dart';
  17. import '../../core/theme/app_colors.dart';
  18. import '../../core/theme/app_colors_extension.dart';
  19. import '../../core/navigation/host_app_channel.dart';
  20. import '../../core/data/mock_api_data.dart';
  21. import 'widgets/expense_detail_dialog.dart';
  22. import '../../shared/widgets/action_bar.dart';
  23. import '../../shared/widgets/loading_dialog.dart';
  24. import '../../shared/widgets/attachment_picker.dart';
  25. import 'expense_apply_import_page.dart';
  26. class ExpenseCreatePage extends ConsumerStatefulWidget {
  27. final String? editId;
  28. const ExpenseCreatePage({super.key, this.editId});
  29. @override
  30. ConsumerState<ExpenseCreatePage> createState() => _ExpenseCreatePageState();
  31. }
  32. class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
  33. final _purposeController = TextEditingController();
  34. final _purposeFocus = FocusNode();
  35. final _remarkController = TextEditingController();
  36. final _remarkFocus = FocusNode();
  37. final _scrollCtrl = ScrollController();
  38. final _detailsSectionKey = GlobalKey();
  39. late final AttachmentPickerController _attachmentController;
  40. bool _attachAvailable = false;
  41. late Future<bool> _draftFuture;
  42. bool _draftHandled = false;
  43. // ── 参考数据(从 API 加载) ──
  44. List<CostTypeItem> _costTypes = [];
  45. List<ProjectCodeItem> _projects = [];
  46. List<DepartmentItem> _departments = [];
  47. List<CustomerItem> _customers = [];
  48. List<CurrencyItem> _currencies = [];
  49. List<EmployeeItem> _employees = [];
  50. bool _firstBuild = true;
  51. bool _refDataLoading = true;
  52. bool _addingDetail = false;
  53. // ── 报销部门 ──
  54. String _selectedDeptId = '';
  55. String _selectedDeptName = '';
  56. @override
  57. void initState() {
  58. super.initState();
  59. SystemChrome.setSystemUIOverlayStyle(
  60. const SystemUiOverlayStyle(
  61. statusBarColor: Colors.transparent,
  62. statusBarIconBrightness: Brightness.dark,
  63. ),
  64. );
  65. _attachmentController = AttachmentPickerController(maxCount: 9)
  66. ..addListener(() => setState(() {}));
  67. _checkAttachHealth();
  68. _purposeFocus.addListener(() => _ensureVisible(_purposeFocus));
  69. _remarkFocus.addListener(() => _ensureVisible(_remarkFocus));
  70. _costTypes = [];
  71. _projects = [];
  72. _departments = [];
  73. _customers = [];
  74. _employees = [];
  75. _refDataLoading = true;
  76. _refDataFuture = null;
  77. _draftFuture = widget.editId == null
  78. ? ExpenseCreateController.hasDraft()
  79. : Future.value(false);
  80. _loadRefData();
  81. WidgetsBinding.instance.addPostFrameCallback((_) => _checkDataReady());
  82. }
  83. void _checkDataReady() {
  84. if (!_refDataLoading && mounted) {
  85. setState(() => _firstBuild = false);
  86. WidgetsBinding.instance.addPostFrameCallback((_) {
  87. if (mounted) setState(() {});
  88. });
  89. } else if (mounted) {
  90. WidgetsBinding.instance.addPostFrameCallback((_) => _checkDataReady());
  91. }
  92. }
  93. Future<void>? _refDataFuture;
  94. Future<void> _loadRefData() async {
  95. if (_refDataFuture != null) return _refDataFuture!;
  96. final completer = Completer<void>();
  97. _refDataFuture = completer.future;
  98. try {
  99. final api = ref.read(expenseApiProvider);
  100. final results = await Future.wait([
  101. api.getCostTypes(),
  102. api.getProjectCodes(),
  103. api.getDepartments(),
  104. api.getCustomers(),
  105. api.getCurrencies(),
  106. api.getEmployees(),
  107. ]);
  108. if (!mounted) {
  109. completer.complete();
  110. return;
  111. }
  112. setState(() {
  113. _costTypes = results[0] as List<CostTypeItem>;
  114. _projects = results[1] as List<ProjectCodeItem>;
  115. _departments = results[2] as List<DepartmentItem>;
  116. _customers = results[3] as List<CustomerItem>;
  117. _currencies = results[4] as List<CurrencyItem>;
  118. _employees = results[5] as List<EmployeeItem>;
  119. _refDataLoading = false;
  120. _autoSelectDept();
  121. });
  122. completer.complete();
  123. } catch (_) {
  124. if (!mounted) {
  125. completer.complete();
  126. return;
  127. }
  128. setState(() => _refDataLoading = false);
  129. completer.complete();
  130. } finally {
  131. _refDataFuture = null;
  132. }
  133. }
  134. void _scrollToDetailSection() {
  135. WidgetsBinding.instance.addPostFrameCallback((_) {
  136. if (_scrollCtrl.hasClients && _detailsSectionKey.currentContext != null) {
  137. Scrollable.ensureVisible(
  138. _detailsSectionKey.currentContext!,
  139. alignment: 0.0,
  140. duration: const Duration(milliseconds: 300),
  141. );
  142. }
  143. });
  144. }
  145. void _ensureVisible(FocusNode node) {
  146. if (!node.hasFocus) return;
  147. WidgetsBinding.instance.addPostFrameCallback((_) {
  148. if (node.hasFocus && _scrollCtrl.hasClients) {
  149. final ctx = node.context;
  150. if (ctx != null) {
  151. Scrollable.ensureVisible(
  152. ctx,
  153. alignment: 0.3,
  154. duration: const Duration(milliseconds: 300),
  155. );
  156. }
  157. }
  158. });
  159. }
  160. @override
  161. void dispose() {
  162. _purposeController.dispose();
  163. _purposeFocus.dispose();
  164. _remarkController.dispose();
  165. _remarkFocus.dispose();
  166. _scrollCtrl.dispose();
  167. _attachmentController.dispose();
  168. super.dispose();
  169. }
  170. @override
  171. Widget build(BuildContext context) {
  172. final controller = ref.watch(expenseCreateProvider(widget.editId).notifier);
  173. final state = ref.watch(expenseCreateProvider(widget.editId));
  174. final r = ResponsiveHelper.of(context);
  175. final l10n = AppLocalizations.of(context);
  176. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  177. final bottomInset = MediaQuery.of(context).padding.bottom;
  178. if (_firstBuild) return const Scaffold(body: SkeletonFormPage(showImportLink: true));
  179. Future.microtask(() => ref.read(pageBackProvider.notifier).state = () => _doPop());
  180. Widget pageContent = PopScope(
  181. canPop: false,
  182. onPopInvokedWithResult: (didPop, _) {
  183. if (didPop) return;
  184. _doPop();
  185. },
  186. child: Column(
  187. children: [
  188. Expanded(
  189. child: Align(
  190. alignment: Alignment.topCenter,
  191. child: ConstrainedBox(
  192. constraints: BoxConstraints(maxWidth: r.formMaxWidth),
  193. child: SingleChildScrollView(
  194. controller: _scrollCtrl,
  195. padding: const EdgeInsets.all(16),
  196. child: Column(
  197. crossAxisAlignment: CrossAxisAlignment.start,
  198. children: [
  199. _buildImportLink(),
  200. const SizedBox(height: 16),
  201. _buildBasicInfoSection(controller, state),
  202. const SizedBox(height: 16),
  203. Container(
  204. key: _detailsSectionKey,
  205. child: _buildDetailSection(controller, state),
  206. ),
  207. const SizedBox(height: 16),
  208. _buildAttachmentSection(controller, state),
  209. const SizedBox(height: 24),
  210. _buildPageFooter(),
  211. ],
  212. ),
  213. ),
  214. ),
  215. ),
  216. ),
  217. ColoredBox(
  218. color: colors.bgCard,
  219. child: Column(
  220. mainAxisSize: MainAxisSize.min,
  221. children: [
  222. _buildBottomButtons(controller, state),
  223. if (bottomInset > 0) SizedBox(height: bottomInset),
  224. ],
  225. ),
  226. ),
  227. ],
  228. ),
  229. );
  230. return FutureBuilder<bool>(
  231. future: _draftFuture,
  232. builder: (ctx, snapshot) {
  233. final hasDraft = snapshot.hasData && snapshot.data == true;
  234. if (hasDraft && !_draftHandled) {
  235. _draftHandled = true;
  236. WidgetsBinding.instance.addPostFrameCallback((_) {
  237. if (mounted) _showDraftDialog();
  238. });
  239. }
  240. return pageContent;
  241. },
  242. );
  243. }
  244. void _showDraftDialog() {
  245. final l10n = AppLocalizations.of(context);
  246. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  247. FocusManager.instance.primaryFocus?.unfocus();
  248. showDialog(
  249. context: context,
  250. barrierDismissible: false,
  251. builder: (ctx) => TDAlertDialog(
  252. title: l10n.get('draftFound'),
  253. content: l10n.get('draftRestorePrompt'),
  254. leftBtn: TDDialogButtonOptions(
  255. title: l10n.get('discard'),
  256. titleColor: colors.textSecondary,
  257. action: () {
  258. Navigator.pop(ctx);
  259. ExpenseCreateController.deleteDraft();
  260. },
  261. ),
  262. rightBtn: TDDialogButtonOptions(
  263. title: l10n.get('restore'),
  264. titleColor: colors.primary,
  265. action: () async {
  266. Navigator.pop(ctx);
  267. final draft = await ExpenseCreateController.loadDraft();
  268. if (draft != null && mounted) {
  269. final api = ref.read(expenseApiProvider);
  270. ref
  271. .read(expenseCreateProvider(widget.editId).notifier)
  272. .restoreFromDraft(draft, api);
  273. _purposeController.text = draft.purpose;
  274. _remarkController.text = draft.remark;
  275. if (draft.attachments.isNotEmpty) {
  276. await _attachmentController.restoreFromPaths(draft.attachments);
  277. }
  278. }
  279. },
  280. ),
  281. ),
  282. );
  283. }
  284. // ═══ API 数据 → 弹窗类型转换 ═══
  285. List<CostCategory> get _dialogCategories => _costTypes
  286. .map(
  287. (c) => CostCategory(
  288. code: c.typeNo,
  289. nameKey: c.typeName,
  290. acctSubjectId: c.accNo,
  291. acctSubjectName: c.accName,
  292. ),
  293. )
  294. .toList();
  295. List<Project> get _dialogProjects => _projects
  296. .map((p) => Project(id: int.tryParse(p.objNo) ?? 0, name: p.name))
  297. .toList();
  298. List<CostDept> get _dialogCostDepts =>
  299. _departments.map((d) => CostDept(id: d.dep, name: d.name)).toList();
  300. String _currencyLabel(String code) {
  301. final match = _currencies.where((c) => c.curId == code);
  302. return match.isNotEmpty ? '${match.first.curId}/${match.first.name}' : code;
  303. }
  304. List<CustomerVendor> get _dialogCustomers =>
  305. _customers.map((c) => CustomerVendor(id: c.cusNo, name: c.name)).toList();
  306. List<EmployeeItem> get _dialogEmployees => _employees;
  307. void _autoSelectDept() {
  308. if (_selectedDeptId.isNotEmpty) return;
  309. final dep = HostAppChannel.dep;
  310. if (dep.isEmpty) return;
  311. final match = _departments.where((d) => d.dep == dep);
  312. if (match.isNotEmpty) {
  313. _selectedDeptId = match.first.dep;
  314. _selectedDeptName = match.first.name;
  315. }
  316. }
  317. void _showDeptPicker() {
  318. if (_departments.isEmpty) {
  319. TDToast.showText(
  320. AppLocalizations.of(context).get('noData'),
  321. context: context,
  322. );
  323. return;
  324. }
  325. final l10n = AppLocalizations.of(context);
  326. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  327. final labels = _departments.map((d) => '${d.dep}/${d.name}').toList();
  328. FocusManager.instance.primaryFocus?.unfocus();
  329. TDPicker.showMultiPicker(
  330. context,
  331. title: l10n.get('expenseDept'),
  332. backgroundColor: colors.bgCard,
  333. data: [labels],
  334. onConfirm: (selected) {
  335. if (selected.isNotEmpty && selected[0] is int) {
  336. final idx = selected[0] as int;
  337. if (idx >= 0 && idx < labels.length) {
  338. Navigator.of(context).pop();
  339. setState(() {
  340. _selectedDeptId = _departments[idx].dep;
  341. _selectedDeptName = _departments[idx].name;
  342. });
  343. }
  344. }
  345. },
  346. );
  347. }
  348. Map<String, dynamic> _buildSubmitData(ExpenseCreateState state) {
  349. final expense = state.expense;
  350. return {
  351. 'HeadData': {
  352. 'BX_DD': _today(),
  353. 'DEP': _selectedDeptId,
  354. 'USR_NO': HostAppChannel.usr,
  355. 'PAY_ID': expense.paymentMethod,
  356. //'PRT_SW': 'N',
  357. 'USR': HostAppChannel.usr,
  358. 'REM': expense.remark,
  359. 'CUR_ID': expense.currencyCode,
  360. 'EXC_RTO': 1,
  361. 'REASON': expense.purpose,
  362. 'VOH_ID': expense.isGenerateVoucher ? 'T' : 'F',
  363. },
  364. 'BodyData1': expense.details.asMap().entries.map((e) {
  365. final i = e.key;
  366. final d = e.value;
  367. return {
  368. 'ITM': i + 1,
  369. 'BX_DD': _today(),
  370. 'ACC_NO': d.acctSubjectId,
  371. 'AMT': d.totalAmount,
  372. 'AMTN': d.amount,
  373. 'AMTN_SH': d.approvedAmount > 0 ? d.approvedAmount : d.totalAmount,
  374. 'REM': d.remark,
  375. 'CUST': d.customerVendorId,
  376. 'IDX_NO': d.expenseCategory,
  377. 'OBJ_NO': d.projectId.isNotEmpty ? d.projectId : '',
  378. 'TAX': d.taxAmount,
  379. 'TAX_RTO': d.taxRate,
  380. 'DEP': d.costDeptId,
  381. 'AE_NO': d.aeNo,
  382. 'AE_DD': d.aeDd,
  383. 'BNK_NO': d.bankName,
  384. 'BNK_ID': d.bankAccount,
  385. 'ACCNAME': d.bankAccountName,
  386. 'SQ_MAN': d.sqMan.isNotEmpty ? d.sqMan : HostAppChannel.usr,
  387. };
  388. }).toList(),
  389. };
  390. }
  391. Widget _buildImportLink() {
  392. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  393. final l10n = AppLocalizations.of(context);
  394. return GestureDetector(
  395. onTap: () async {
  396. final result = await GoRouter.of(
  397. context,
  398. ).push<List<ImportableItem>>('/expense/import-apply');
  399. if (result == null || result.isEmpty || !mounted) return;
  400. // 将选中的导入数据转换为明细,先移除同单号的旧数据
  401. final controller = ref.read(
  402. expenseCreateProvider(widget.editId).notifier,
  403. );
  404. final aeNos = result.map((e) => e.aeNo).toSet();
  405. for (final aeNo in aeNos) {
  406. controller.removeDetailsByAeNo(aeNo);
  407. }
  408. final now = DateTime.now();
  409. for (final item in result) {
  410. controller.addDetail(
  411. ExpenseDetailModel(
  412. id: '${now.millisecondsSinceEpoch}_${item.itm}',
  413. expenseId: '',
  414. expenseApplyId: '',
  415. expenseApplyNo: item.aeNo,
  416. expenseApplyDate: item.aeDd.isNotEmpty
  417. ? DateTime.tryParse(item.aeDd)
  418. : null,
  419. expenseCategory: item.typeNo,
  420. categoryName: item.typeName,
  421. purpose: item.rem,
  422. priority: item.priority,
  423. projectId: item.objNo,
  424. projectName: item.objName,
  425. costDeptId: item.dep,
  426. costDeptName: item.depName,
  427. acctSubjectId: item.accNo,
  428. acctSubjectName: item.accName,
  429. amount: item.amtnYj,
  430. taxRate: 0,
  431. taxAmount: 0,
  432. totalAmount: item.amtnYj,
  433. currencyCode: '',
  434. exchangeRate: 1.0,
  435. baseAmount: item.amtnYj,
  436. approvedAmount: item.amtnYj,
  437. customerVendorId: '',
  438. customerVendorName: '',
  439. bankName: '',
  440. bankAccountName: '',
  441. bankAccount: '',
  442. remark: item.rem,
  443. sortOrder: item.itm,
  444. attachments: const [],
  445. sqMan: item.sqMan,
  446. sqManName: item.sqName,
  447. aeNo: item.aeNo,
  448. aeDd: item.aeDd,
  449. createTime: now,
  450. updateTime: now,
  451. ),
  452. );
  453. }
  454. controller.recalculateAmount();
  455. TDToast.showSuccess(l10n.get('importSuccess'), context: context);
  456. _scrollToDetailSection();
  457. },
  458. child: Container(
  459. height: 44,
  460. decoration: BoxDecoration(
  461. color: colors.primaryLight,
  462. borderRadius: BorderRadius.circular(8),
  463. ),
  464. child: Row(
  465. mainAxisAlignment: MainAxisAlignment.center,
  466. children: [
  467. Icon(Icons.download, size: 14, color: colors.primary),
  468. const SizedBox(width: 8),
  469. Text(
  470. l10n.get('importApprovedPreApp'),
  471. style: TextStyle(
  472. fontSize: AppFontSizes.body,
  473. color: colors.primary,
  474. ),
  475. ),
  476. ],
  477. ),
  478. ),
  479. );
  480. }
  481. Widget _buildBasicInfoSection(
  482. ExpenseCreateController controller,
  483. ExpenseCreateState state,
  484. ) {
  485. final l10n = AppLocalizations.of(context);
  486. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  487. final expense = state.expense;
  488. return FormSection(
  489. title: l10n.get('basicInfo'),
  490. leadingIcon: Icons.info_outline,
  491. children: [
  492. FormFieldRow(
  493. label: l10n.get('date'),
  494. value: _today(),
  495. readOnly: true,
  496. showArrow: false,
  497. ),
  498. const SizedBox(height: 16),
  499. FormFieldRow(
  500. label: l10n.get('expensePersonnel'),
  501. value:
  502. HostAppChannel.usr.isNotEmpty && HostAppChannel.usrName.isNotEmpty
  503. ? '${HostAppChannel.usr}/${HostAppChannel.usrName}'
  504. : '--',
  505. readOnly: true,
  506. showArrow: false,
  507. ),
  508. const SizedBox(height: 16),
  509. FormFieldRow(
  510. label: l10n.get('expenseDept'),
  511. value: _selectedDeptName.isNotEmpty
  512. ? '$_selectedDeptId/$_selectedDeptName'
  513. : null,
  514. hint: l10n.get('pleaseSelect'),
  515. onTap: _refDataLoading ? null : () => _showDeptPicker(),
  516. ),
  517. const SizedBox(height: 16),
  518. _label(l10n.get('expenseReason'), required: true),
  519. const SizedBox(height: 8),
  520. TDTextarea(
  521. controller: _purposeController,
  522. focusNode: _purposeFocus,
  523. hintText: l10n.get('enterExpenseReason'),
  524. maxLines: 4,
  525. minLines: 1,
  526. maxLength: 500,
  527. indicator: true,
  528. padding: EdgeInsets.zero,
  529. bordered: true,
  530. backgroundColor: colors.bgPage,
  531. onChanged: (_) => controller.updatePurpose(_purposeController.text),
  532. ),
  533. const SizedBox(height: 16),
  534. FormFieldRow(
  535. label: l10n.get('paymentMethod'),
  536. value: expense.paymentMethod,
  537. hint: l10n.get('pleaseEnter'),
  538. onTap: () => _showTextInput(
  539. l10n.get('paymentMethod'),
  540. (v) => controller.updatePaymentMethod(v),
  541. initialText: expense.paymentMethod,
  542. ),
  543. onClear: () => controller.updatePaymentMethod(''),
  544. ),
  545. const SizedBox(height: 16),
  546. FormFieldRow(
  547. label: l10n.get('currency'),
  548. value: expense.currencyCode.isNotEmpty
  549. ? _currencyLabel(expense.currencyCode)
  550. : null,
  551. hint: l10n.get('selectCurrency'),
  552. onTap: () => _showCurrencyPicker(controller, expense.currencyCode),
  553. onClear: () => controller.updateCurrencyCode(''),
  554. ),
  555. const SizedBox(height: 16),
  556. Row(
  557. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  558. children: [
  559. Text(
  560. l10n.get('generateVoucher'),
  561. style: TextStyle(
  562. fontSize: AppFontSizes.subtitle,
  563. color: colors.textSecondary,
  564. ),
  565. ),
  566. TDSwitch(
  567. isOn: expense.isGenerateVoucher,
  568. onChanged: (v) {
  569. controller.setGenerateVoucher(v);
  570. return v;
  571. },
  572. ),
  573. ],
  574. ),
  575. const SizedBox(height: 16),
  576. _label(l10n.get('remark')),
  577. const SizedBox(height: 8),
  578. TDTextarea(
  579. controller: _remarkController,
  580. focusNode: _remarkFocus,
  581. hintText: l10n.get('enterRemark'),
  582. maxLines: 3,
  583. minLines: 1,
  584. maxLength: 500,
  585. indicator: true,
  586. padding: EdgeInsets.zero,
  587. bordered: true,
  588. backgroundColor: colors.bgPage,
  589. onChanged: (_) => controller.updateRemark(_remarkController.text),
  590. ),
  591. ],
  592. );
  593. }
  594. Widget _buildDetailSection(
  595. ExpenseCreateController controller,
  596. ExpenseCreateState state,
  597. ) {
  598. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  599. final l10n = AppLocalizations.of(context);
  600. final totalApproved = state.expense.details.fold<double>(
  601. 0,
  602. (sum, d) => sum + d.approvedAmount,
  603. );
  604. return FormSection(
  605. title: l10n.get('expenseDetails'),
  606. leadingIcon: Icons.receipt_long_outlined,
  607. showAction: true,
  608. actionText: l10n.get('add'),
  609. onActionTap: () => _showAddDetailDialog(controller),
  610. children: [
  611. if (state.expense.details.isEmpty)
  612. Padding(
  613. padding: const EdgeInsets.symmetric(vertical: 8),
  614. child: Text(
  615. l10n.get('noDetailHint'),
  616. style: TextStyle(
  617. fontSize: AppFontSizes.subtitle,
  618. color: colors.textPlaceholder,
  619. ),
  620. ),
  621. )
  622. else
  623. ...state.expense.details.asMap().entries.map((entry) {
  624. final d = entry.value;
  625. return GestureDetector(
  626. onTap: () =>
  627. _showAddDetailDialog(controller, editIndex: entry.key),
  628. child: Container(
  629. margin: const EdgeInsets.symmetric(vertical: 6),
  630. padding: const EdgeInsets.all(12),
  631. decoration: BoxDecoration(
  632. color: colors.bgPage,
  633. borderRadius: BorderRadius.circular(8),
  634. ),
  635. child: Row(
  636. children: [
  637. Expanded(
  638. child: Column(
  639. crossAxisAlignment: CrossAxisAlignment.start,
  640. children: [
  641. Row(
  642. children: [
  643. Expanded(
  644. child: Text(
  645. d.categoryName.isNotEmpty
  646. ? '${d.expenseCategory}/${d.categoryName}'
  647. : d.expenseCategory,
  648. style: TextStyle(
  649. fontSize: AppFontSizes.body,
  650. fontWeight: FontWeight.w500,
  651. color: colors.textPrimary,
  652. ),
  653. ),
  654. ),
  655. Column(
  656. crossAxisAlignment: CrossAxisAlignment.end,
  657. children: [
  658. Text(
  659. '¥${d.totalAmount.toStringAsFixed(2)}',
  660. style: TextStyle(
  661. fontSize: AppFontSizes.body,
  662. fontWeight: FontWeight.w600,
  663. color: colors.amountPrimary,
  664. ),
  665. ),
  666. if (d.approvedAmount > 0)
  667. Text(
  668. '¥${d.approvedAmount.toStringAsFixed(2)}',
  669. style: TextStyle(
  670. fontSize: AppFontSizes.body,
  671. fontWeight: FontWeight.w600,
  672. color: colors.success,
  673. ),
  674. ),
  675. ],
  676. ),
  677. ],
  678. ),
  679. const SizedBox(height: 2),
  680. Text(
  681. '${l10n.get('amountExcludingTax')}: ¥${d.amount.toStringAsFixed(2)}',
  682. style: TextStyle(
  683. fontSize: AppFontSizes.caption,
  684. color: colors.textSecondary,
  685. ),
  686. ),
  687. if (d.taxAmount > 0)
  688. Text(
  689. '${l10n.get('taxAmount')}: ¥${d.taxAmount.toStringAsFixed(2)}',
  690. style: TextStyle(
  691. fontSize: AppFontSizes.caption,
  692. color: colors.textSecondary,
  693. ),
  694. ),
  695. if (d.acctSubjectName.isNotEmpty)
  696. Text(
  697. '${l10n.get('acctSubject')}: ${d.acctSubjectId}/${d.acctSubjectName}',
  698. maxLines: 1,
  699. overflow: TextOverflow.ellipsis,
  700. style: TextStyle(
  701. fontSize: AppFontSizes.caption,
  702. color: colors.textSecondary,
  703. ),
  704. ),
  705. if (d.aeNo.isNotEmpty)
  706. Text(
  707. '${l10n.get('expenseApplyNo')}: ${d.aeNo}',
  708. maxLines: 1,
  709. overflow: TextOverflow.ellipsis,
  710. style: TextStyle(
  711. fontSize: AppFontSizes.caption,
  712. color: colors.textSecondary,
  713. ),
  714. ),
  715. if (d.aeDd.isNotEmpty)
  716. Text(
  717. '${l10n.get('applyDate')}: ${d.aeDd}',
  718. style: TextStyle(
  719. fontSize: AppFontSizes.caption,
  720. color: colors.textSecondary,
  721. ),
  722. ),
  723. if (d.projectName.isNotEmpty)
  724. Text(
  725. '${l10n.get('project')}: ${d.projectId}/${d.projectName}',
  726. maxLines: 1,
  727. overflow: TextOverflow.ellipsis,
  728. style: TextStyle(
  729. fontSize: AppFontSizes.caption,
  730. color: colors.textSecondary,
  731. ),
  732. ),
  733. if (d.costDeptName.isNotEmpty)
  734. Text(
  735. '${l10n.get('dept')}: ${d.costDeptId}/${d.costDeptName}',
  736. maxLines: 1,
  737. overflow: TextOverflow.ellipsis,
  738. style: TextStyle(
  739. fontSize: AppFontSizes.caption,
  740. color: colors.textSecondary,
  741. ),
  742. ),
  743. if (d.customerVendorName.isNotEmpty)
  744. Text(
  745. '${l10n.get('customerVendor')}: ${d.customerVendorId}/${d.customerVendorName}',
  746. maxLines: 1,
  747. overflow: TextOverflow.ellipsis,
  748. style: TextStyle(
  749. fontSize: AppFontSizes.caption,
  750. color: colors.textSecondary,
  751. ),
  752. ),
  753. if (d.bankAccountName.isNotEmpty)
  754. Text(
  755. '${l10n.get('bankAccountName')}: ${d.bankAccountName}',
  756. maxLines: 1,
  757. overflow: TextOverflow.ellipsis,
  758. style: TextStyle(
  759. fontSize: AppFontSizes.caption,
  760. color: colors.textSecondary,
  761. ),
  762. ),
  763. if (d.bankName.isNotEmpty)
  764. Text(
  765. '${l10n.get('bankName')}: ${d.bankName}',
  766. maxLines: 1,
  767. overflow: TextOverflow.ellipsis,
  768. style: TextStyle(
  769. fontSize: AppFontSizes.caption,
  770. color: colors.textSecondary,
  771. ),
  772. ),
  773. if (d.bankAccount.isNotEmpty)
  774. Text(
  775. '${l10n.get('bankAccount')}: ${d.bankAccount}',
  776. maxLines: 1,
  777. overflow: TextOverflow.ellipsis,
  778. style: TextStyle(
  779. fontSize: AppFontSizes.caption,
  780. color: colors.textSecondary,
  781. ),
  782. ),
  783. if (d.sqManName.isNotEmpty)
  784. Text(
  785. '${l10n.get('applicant')}: ${d.sqManName.isNotEmpty ? d.sqManName : d.sqMan}',
  786. style: TextStyle(
  787. fontSize: AppFontSizes.caption,
  788. color: colors.textSecondary,
  789. ),
  790. ),
  791. if (d.remark.isNotEmpty)
  792. Text(
  793. '${l10n.get('remark')}: ${d.remark}',
  794. maxLines: 2,
  795. overflow: TextOverflow.ellipsis,
  796. style: TextStyle(
  797. fontSize: AppFontSizes.caption,
  798. color: colors.textSecondary,
  799. ),
  800. ),
  801. ],
  802. ),
  803. ),
  804. const SizedBox(width: 8),
  805. GestureDetector(
  806. onTap: () {
  807. controller.removeDetail(entry.key);
  808. controller.recalculateAmount();
  809. },
  810. child: Icon(
  811. Icons.close,
  812. size: 18,
  813. color: colors.textSecondary,
  814. ),
  815. ),
  816. ],
  817. ),
  818. ),
  819. );
  820. }),
  821. const SizedBox(height: 8),
  822. Row(
  823. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  824. children: [
  825. Text(
  826. l10n.get('totalExpense'),
  827. style: TextStyle(
  828. fontSize: AppFontSizes.body,
  829. fontWeight: FontWeight.w600,
  830. color: colors.textPrimary,
  831. ),
  832. ),
  833. Text(
  834. '¥${state.expense.totalAmount.toStringAsFixed(2)}',
  835. style: TextStyle(
  836. fontSize: AppFontSizes.subtitle,
  837. fontWeight: FontWeight.w700,
  838. color: colors.amountPrimary,
  839. ),
  840. ),
  841. ],
  842. ),
  843. const SizedBox(height: 4),
  844. Row(
  845. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  846. children: [
  847. Text(
  848. l10n.get('approvedTotal'),
  849. style: TextStyle(
  850. fontSize: AppFontSizes.body,
  851. fontWeight: FontWeight.w600,
  852. color: colors.textPrimary,
  853. ),
  854. ),
  855. Text(
  856. '¥${totalApproved.toStringAsFixed(2)}',
  857. style: TextStyle(
  858. fontSize: AppFontSizes.subtitle,
  859. fontWeight: FontWeight.w700,
  860. color: totalApproved > 0 ? colors.success : colors.textPrimary,
  861. ),
  862. ),
  863. ],
  864. ),
  865. ],
  866. );
  867. }
  868. Future<void> _checkAttachHealth() async {
  869. if (mounted) setState(() => _attachAvailable = false);
  870. try {
  871. final api = ref.read(expenseApiProvider);
  872. final ok = await api.checkAttachHealth();
  873. if (mounted) setState(() => _attachAvailable = ok);
  874. } catch (_) {
  875. if (mounted) setState(() => _attachAvailable = false);
  876. }
  877. }
  878. Widget _buildAttachmentSection(
  879. ExpenseCreateController controller,
  880. ExpenseCreateState state,
  881. ) {
  882. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  883. final l10n = AppLocalizations.of(context);
  884. final children = <Widget>[];
  885. if (!_attachAvailable) {
  886. children.add(
  887. Text(
  888. l10n.get('attachServiceUnavailable'),
  889. style: TextStyle(
  890. fontSize: AppFontSizes.caption,
  891. color: colors.textPlaceholder,
  892. ),
  893. ),
  894. );
  895. } else {
  896. children.addAll([
  897. Text(
  898. l10n.get('maxAttachment'),
  899. style: TextStyle(
  900. fontSize: AppFontSizes.caption,
  901. color: colors.textPlaceholder,
  902. ),
  903. ),
  904. const SizedBox(height: 8),
  905. ]);
  906. }
  907. return FormSection(
  908. title: l10n.get('attachmentUpload'),
  909. leadingIcon: Icons.attach_file_outlined,
  910. children: [
  911. ...children,
  912. if (_attachAvailable)
  913. AttachmentPicker(
  914. controller: _attachmentController,
  915. maxImageSizeMB: 10,
  916. maxFileSizeMB: 20,
  917. allowedExtensions: const [
  918. 'pdf',
  919. 'doc',
  920. 'docx',
  921. 'xls',
  922. 'xlsx',
  923. 'ppt',
  924. 'pptx',
  925. 'txt',
  926. ],
  927. onFileRejected: (file, reason) {
  928. if (context.mounted) TDToast.showText(reason, context: context);
  929. },
  930. ),
  931. ],
  932. );
  933. }
  934. Widget _buildBottomButtons(
  935. ExpenseCreateController controller,
  936. ExpenseCreateState state,
  937. ) {
  938. final l10n = AppLocalizations.of(context);
  939. return ActionBar(
  940. showLeft: false,
  941. centerLabel: l10n.get('saveDraft'),
  942. rightLabel: l10n.get('submit'),
  943. centerTextOnly: true,
  944. onCenterTap: () async {
  945. if (state.isSubmitting) return;
  946. FocusScope.of(context).unfocus();
  947. controller.updateAttachments(_attachmentController.toPathList());
  948. controller.updateDept(_selectedDeptId, _selectedDeptName);
  949. final ok = await controller.saveDraft();
  950. if (mounted) {
  951. if (ok) {
  952. _forcePop();
  953. } else {
  954. TDToast.showFail(l10n.get('saveFailed'), context: context);
  955. }
  956. }
  957. },
  958. onRightTap: () async {
  959. if (state.isSubmitting) return;
  960. final err = _validate(l10n, state);
  961. if (err.isNotEmpty) {
  962. TDToast.showText(err.first, context: context);
  963. return;
  964. }
  965. FocusScope.of(context).unfocus();
  966. LoadingDialog.show(context, text: l10n.get('submitting'));
  967. try {
  968. final data = _buildSubmitData(state);
  969. final api = ref.read(expenseApiProvider);
  970. final billNo = await api.submit(data);
  971. // 上传附件(billNo 提取失败则跳过,不影响主流程)
  972. if (billNo != null) {
  973. final now = DateTime.now();
  974. final effDd =
  975. '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} '
  976. '${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}.'
  977. '${now.millisecond.toString().padLeft(3, '0')}';
  978. final usr = HostAppChannel.usr;
  979. // 表头附件
  980. for (var i = 0; i < _attachmentController.files.length; i++) {
  981. final file = _attachmentController.files[i];
  982. try {
  983. await api.uploadAttachment(file.path, {
  984. 'BIL_ID': 'BX',
  985. 'BIL_NO': billNo,
  986. 'SRCITM': 0,
  987. 'ITM': i + 1,
  988. 'TAG': 1,
  989. 'EFF_DD': effDd,
  990. 'USR': usr,
  991. 'FILENAME': file.name,
  992. 'EXT': file.name.split('.').last,
  993. });
  994. } catch (_) {
  995. // Attachment upload failure is non-fatal
  996. }
  997. }
  998. // 明细附件
  999. for (var i = 0; i < state.expense.details.length; i++) {
  1000. final detail = state.expense.details[i];
  1001. if (detail.attachments.isEmpty) continue;
  1002. for (var j = 0; j < detail.attachments.length; j++) {
  1003. final file = File(detail.attachments[j]);
  1004. try {
  1005. if (!await file.exists()) continue;
  1006. final fileName = file.path.split('/').last;
  1007. await api.uploadAttachment(file.path, {
  1008. 'BIL_ID': 'BX',
  1009. 'BIL_NO': billNo,
  1010. 'SRCITM': i + 1,
  1011. 'ITM': j + 1,
  1012. 'TAG': 1,
  1013. 'EFF_DD': effDd,
  1014. 'USR': usr,
  1015. 'FILENAME': fileName,
  1016. 'EXT': fileName.split('.').last,
  1017. });
  1018. } catch (_) {
  1019. // Detail attachment upload failure is non-fatal
  1020. }
  1021. }
  1022. }
  1023. }
  1024. await ExpenseCreateController.deleteDraft();
  1025. if (mounted) {
  1026. LoadingDialog.hide(context);
  1027. TDToast.showSuccess(
  1028. l10n.get('submittedAwaitingApproval'),
  1029. context: context,
  1030. );
  1031. GoRouter.of(context).go('/expense/list');
  1032. }
  1033. } catch (_) {
  1034. if (mounted) {
  1035. LoadingDialog.hide(context);
  1036. TDToast.showFail(l10n.get('submitFailedRetry'), context: context);
  1037. }
  1038. }
  1039. },
  1040. );
  1041. }
  1042. Future<void> _showAddDetailDialog(
  1043. ExpenseCreateController controller, {
  1044. int? editIndex,
  1045. }) async {
  1046. if (_addingDetail) return;
  1047. _addingDetail = true;
  1048. try {
  1049. final l10n = AppLocalizations.of(context);
  1050. if (_costTypes.isEmpty) {
  1051. TDToast.showText(l10n.get('noCostTypeData'), context: context);
  1052. return;
  1053. }
  1054. final state = controller.currentState;
  1055. ExpenseDetailInputData? initialData;
  1056. if (editIndex != null) {
  1057. final d = state.expense.details[editIndex];
  1058. initialData = ExpenseDetailInputData(
  1059. category: d.expenseCategory,
  1060. categoryName: d.categoryName,
  1061. acctSubjectId: d.acctSubjectId,
  1062. acctSubjectName: d.acctSubjectName,
  1063. purpose: d.purpose,
  1064. amount: d.amount,
  1065. taxRate: d.taxRate,
  1066. projectId: d.projectId,
  1067. projectName: d.projectName,
  1068. costDeptId: d.costDeptId,
  1069. costDeptName: d.costDeptName,
  1070. customerVendorId: d.customerVendorId,
  1071. customerVendorName: d.customerVendorName,
  1072. approvedAmount: d.approvedAmount,
  1073. bankName: d.bankName,
  1074. bankAccountName: d.bankAccountName,
  1075. bankAccount: d.bankAccount,
  1076. remark: d.remark,
  1077. attachmentPaths: d.attachments,
  1078. sqMan: d.sqMan,
  1079. sqManName: d.sqManName,
  1080. aeNo: d.aeNo,
  1081. aeDd: d.aeDd,
  1082. );
  1083. }
  1084. FocusManager.instance.primaryFocus?.unfocus();
  1085. final result = await ExpenseDetailDialog.show(
  1086. context,
  1087. categories: _dialogCategories,
  1088. projects: _dialogProjects,
  1089. costDepts: _dialogCostDepts,
  1090. customers: _dialogCustomers,
  1091. employees: _dialogEmployees,
  1092. l10n: l10n,
  1093. initialData: initialData,
  1094. checkAttachHealth: () =>
  1095. ref.read(expenseApiProvider).checkAttachHealth(),
  1096. );
  1097. if (result != null && mounted) {
  1098. final now = DateTime.now();
  1099. final detail = ExpenseDetailModel(
  1100. id: editIndex != null
  1101. ? state.expense.details[editIndex].id
  1102. : now.millisecondsSinceEpoch.toString(),
  1103. expenseId: '',
  1104. expenseCategory: result.category,
  1105. categoryName: result.categoryName,
  1106. purpose: result.purpose,
  1107. amount: result.taxRate > 0
  1108. ? result.amount / (1 + result.taxRate)
  1109. : result.amount,
  1110. taxRate: result.taxRate,
  1111. taxAmount: result.taxRate > 0
  1112. ? result.amount - result.amount / (1 + result.taxRate)
  1113. : 0,
  1114. totalAmount: result.amount,
  1115. projectId: result.projectId,
  1116. projectName: result.projectName,
  1117. costDeptId: result.costDeptId,
  1118. costDeptName: result.costDeptName,
  1119. acctSubjectId: result.acctSubjectId,
  1120. acctSubjectName: result.acctSubjectName,
  1121. customerVendorId: result.customerVendorId,
  1122. customerVendorName: result.customerVendorName,
  1123. approvedAmount: result.approvedAmount,
  1124. bankName: result.bankName,
  1125. bankAccountName: result.bankAccountName,
  1126. bankAccount: result.bankAccount,
  1127. sqMan: result.sqMan,
  1128. sqManName: result.sqManName,
  1129. aeNo: result.aeNo,
  1130. aeDd: result.aeDd,
  1131. remark: result.remark,
  1132. attachments: result.attachmentPaths,
  1133. createTime: now,
  1134. updateTime: now,
  1135. );
  1136. if (editIndex != null) {
  1137. controller.updateDetail(editIndex, detail);
  1138. } else {
  1139. controller.addDetail(detail);
  1140. }
  1141. controller.recalculateAmount();
  1142. }
  1143. } finally {
  1144. _addingDetail = false;
  1145. }
  1146. }
  1147. void _showCurrencyPicker(ExpenseCreateController controller, String cur) {
  1148. if (_currencies.isEmpty) {
  1149. TDToast.showText(
  1150. AppLocalizations.of(context).get('noData'),
  1151. context: context,
  1152. );
  1153. return;
  1154. }
  1155. final l10n = AppLocalizations.of(context);
  1156. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  1157. final codes = _currencies.map((c) => c.curId).toList();
  1158. final labels = _currencies.map((c) => '${c.curId}/${c.name}').toList();
  1159. FocusManager.instance.primaryFocus?.unfocus();
  1160. TDPicker.showMultiPicker(
  1161. context,
  1162. title: l10n.get('selectCurrency'),
  1163. backgroundColor: colors.bgCard,
  1164. data: [labels],
  1165. onConfirm: (s) {
  1166. if (s.isNotEmpty && s[0] is int) {
  1167. final i = s[0] as int;
  1168. if (i >= 0 && i < codes.length) {
  1169. Navigator.of(context).pop();
  1170. controller.updateCurrencyCode(codes[i]);
  1171. }
  1172. }
  1173. },
  1174. );
  1175. }
  1176. void _showTextInput(
  1177. String title,
  1178. Function(String) onConfirm, {
  1179. String initialText = '',
  1180. }) {
  1181. FocusScope.of(context).unfocus();
  1182. FocusManager.instance.primaryFocus?.unfocus();
  1183. final l10n = AppLocalizations.of(context);
  1184. final c = TextEditingController(text: initialText);
  1185. showGeneralDialog(
  1186. context: context,
  1187. pageBuilder: (ctx, animation, secondaryAnimation) => TDInputDialog(
  1188. textEditingController: c,
  1189. title: title,
  1190. hintText: l10n.get('pleaseEnter'),
  1191. leftBtn: TDDialogButtonOptions(
  1192. title: l10n.get('cancel'),
  1193. action: () => Navigator.pop(ctx),
  1194. ),
  1195. rightBtn: TDDialogButtonOptions(
  1196. title: l10n.get('confirm'),
  1197. action: () {
  1198. onConfirm(c.text);
  1199. Navigator.pop(ctx);
  1200. },
  1201. ),
  1202. ),
  1203. );
  1204. }
  1205. Widget _label(String t, {bool required = false}) {
  1206. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  1207. return Text.rich(
  1208. TextSpan(
  1209. children: [
  1210. TextSpan(
  1211. text: t,
  1212. style: TextStyle(
  1213. fontSize: AppFontSizes.subtitle,
  1214. color: colors.textSecondary,
  1215. ),
  1216. ),
  1217. if (required)
  1218. TextSpan(
  1219. text: ' *',
  1220. style: TextStyle(
  1221. fontSize: AppFontSizes.subtitle,
  1222. color: colors.danger,
  1223. ),
  1224. ),
  1225. ],
  1226. ),
  1227. );
  1228. }
  1229. List<String> _validate(AppLocalizations l10n, ExpenseCreateState state) {
  1230. final e = <String>[];
  1231. if (_purposeController.text.trim().isEmpty) {
  1232. e.add(l10n.get('enterExpenseReason'));
  1233. }
  1234. if (state.expense.details.isEmpty) {
  1235. e.add(l10n.get('addAtLeastOneDetail'));
  1236. }
  1237. return e;
  1238. }
  1239. bool _hasUnsaved(ExpenseCreateState state) =>
  1240. _purposeController.text.isNotEmpty ||
  1241. state.expense.paymentMethod.isNotEmpty ||
  1242. state.expense.currencyCode.isNotEmpty ||
  1243. _remarkController.text.isNotEmpty ||
  1244. state.expense.details.isNotEmpty ||
  1245. _attachmentController.files.isNotEmpty ||
  1246. _selectedDeptId.isNotEmpty;
  1247. void _doPop() {
  1248. final l10n = AppLocalizations.of(context);
  1249. final state = ref.read(expenseCreateProvider(widget.editId));
  1250. if (_hasUnsaved(state)) {
  1251. _showConfirmDialog(
  1252. l10n.get('confirmExit'),
  1253. l10n.get('unsavedContentWarning'),
  1254. l10n.get('continueEditing'),
  1255. l10n.get('discardAndExit'),
  1256. () async {
  1257. try {
  1258. await ExpenseCreateController.deleteDraft();
  1259. } catch (_) {}
  1260. if (!mounted) return;
  1261. setState(() {
  1262. _selectedDeptId = '';
  1263. _selectedDeptName = '';
  1264. });
  1265. ref.read(expenseCreateProvider(widget.editId).notifier).reset();
  1266. _forcePop();
  1267. },
  1268. );
  1269. } else {
  1270. _forcePop();
  1271. }
  1272. }
  1273. void _forcePop() {
  1274. FocusManager.instance.primaryFocus?.unfocus();
  1275. final router = GoRouter.of(context);
  1276. if (router.canPop()) {
  1277. router.pop();
  1278. } else {
  1279. SystemNavigator.pop();
  1280. }
  1281. }
  1282. void _showConfirmDialog(
  1283. String title,
  1284. String content,
  1285. String leftText,
  1286. String rightText,
  1287. VoidCallback onConfirm,
  1288. ) {
  1289. FocusScope.of(context).unfocus();
  1290. FocusManager.instance.primaryFocus?.unfocus();
  1291. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  1292. showDialog(
  1293. context: context,
  1294. useRootNavigator: true,
  1295. builder: (ctx) => TDAlertDialog(
  1296. title: title,
  1297. content: content,
  1298. buttonStyle: TDDialogButtonStyle.text,
  1299. leftBtn: TDDialogButtonOptions(
  1300. title: leftText,
  1301. titleColor: colors.primary,
  1302. action: () => Navigator.pop(ctx),
  1303. ),
  1304. rightBtn: TDDialogButtonOptions(
  1305. title: rightText,
  1306. titleColor: colors.danger,
  1307. action: () {
  1308. Navigator.pop(ctx);
  1309. onConfirm();
  1310. },
  1311. ),
  1312. ),
  1313. );
  1314. }
  1315. Widget _buildPageFooter() {
  1316. final l10n = AppLocalizations.of(context);
  1317. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  1318. return Center(
  1319. child: Padding(
  1320. padding: const EdgeInsets.only(bottom: 16),
  1321. child: Row(
  1322. mainAxisSize: MainAxisSize.min,
  1323. children: [
  1324. Icon(
  1325. Icons.rocket_launch_outlined,
  1326. size: 16,
  1327. color: colors.textPlaceholder,
  1328. ),
  1329. const SizedBox(width: 6),
  1330. Text(
  1331. l10n.get('pageFooter'),
  1332. style: TextStyle(
  1333. fontSize: AppFontSizes.caption,
  1334. color: colors.textPlaceholder,
  1335. ),
  1336. ),
  1337. ],
  1338. ),
  1339. ),
  1340. );
  1341. }
  1342. String _today() {
  1343. final n = DateTime.now();
  1344. return '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}';
  1345. }
  1346. }