import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; import 'package:go_router/go_router.dart'; import '../../shared/widgets/nav_bar_config.dart'; import '../../core/utils/responsive.dart'; import '../../shared/widgets/form_section.dart'; import '../../shared/widgets/form_field_row.dart'; import 'expense_api.dart'; import 'expense_create_controller.dart'; import '../../core/i18n/app_localizations.dart'; import 'expense_model.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors_extension.dart'; import '../../core/navigation/host_app_channel.dart'; import '../../core/data/mock_api_data.dart'; import 'widgets/expense_detail_dialog.dart'; import '../../shared/widgets/action_bar.dart'; import '../../shared/widgets/submitting_dialog.dart'; import '../../shared/widgets/attachment_picker.dart'; import 'expense_apply_import_page.dart'; class ExpenseApplyPage extends ConsumerStatefulWidget { final String? editId; const ExpenseApplyPage({super.key, this.editId}); @override ConsumerState createState() => _ExpenseApplyPageState(); } class _ExpenseApplyPageState extends ConsumerState with WidgetsBindingObserver { final _purposeController = TextEditingController(); final _purposeFocus = FocusNode(); final _remarkController = TextEditingController(); final _remarkFocus = FocusNode(); final _scrollCtrl = ScrollController(); late final AttachmentPickerController _attachmentController; late Future _draftFuture; bool _draftHandled = false; bool _isPoppingToNative = false; // ── 参考数据(从 API 加载) ── List _costTypes = []; List _projects = []; List _departments = []; List _customers = []; List _currencies = []; List _employees = []; bool _refDataLoading = true; // ── 报销部门 ── String _selectedDeptId = ''; String _selectedDeptName = ''; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); SystemChrome.setSystemUIOverlayStyle( const SystemUiOverlayStyle( statusBarColor: Colors.transparent, statusBarIconBrightness: Brightness.dark, ), ); _attachmentController = AttachmentPickerController(maxCount: 9) ..addListener(() => setState(() {})); _purposeFocus.addListener(() => _ensureVisible(_purposeFocus)); _remarkFocus.addListener(() => _ensureVisible(_remarkFocus)); _draftFuture = widget.editId == null ? ExpenseCreateController.hasDraft() : Future.value(false); _loadRefData(); } Future _loadRefData() async { try { final api = ref.read(expenseApiProvider); final results = await Future.wait([ api.getCostTypes(), api.getProjectCodes(), api.getDepartments(), api.getCustomers(), api.getCurrencies(), api.getEmployees(), ]); if (!mounted) return; setState(() { _costTypes = results[0] as List; _projects = results[1] as List; _departments = results[2] as List; _customers = results[3] as List; _currencies = results[4] as List; _employees = results[5] as List; _refDataLoading = false; _autoSelectDept(); }); } catch (_) { if (!mounted) return; setState(() => _refDataLoading = false); } } void _ensureVisible(FocusNode node) { if (!node.hasFocus) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (node.hasFocus && _scrollCtrl.hasClients) { final ctx = node.context; if (ctx != null) { Scrollable.ensureVisible(ctx, alignment: 0.3, duration: const Duration(milliseconds: 300)); } } }); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _purposeController.dispose(); _purposeFocus.dispose(); _remarkController.dispose(); _remarkFocus.dispose(); _scrollCtrl.dispose(); _attachmentController.dispose(); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed && _isPoppingToNative) { _isPoppingToNative = false; HostAppChannel.refresh(); setState(() { _draftHandled = false; _draftFuture = widget.editId == null ? ExpenseCreateController.hasDraft() : Future.value(false); _refDataLoading = true; }); _loadRefData(); } } @override Widget build(BuildContext context) { final controller = ref.watch(expenseCreateProvider(widget.editId).notifier); final state = ref.watch(expenseCreateProvider(widget.editId)); final r = ResponsiveHelper.of(context); final l10n = AppLocalizations.of(context); ref .read(navBarConfigProvider.notifier) .update( NavBarConfig( title: widget.editId != null ? l10n.get('editExpense') : l10n.get('expenseApply'), showBack: true, onBack: () => _doPop(), ), ); final colors = Theme.of(context).extension()!; final bottomInset = MediaQuery.of(context).padding.bottom; Widget pageContent = PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (didPop) return; _doPop(); }, child: Column( children: [ Expanded( child: Align( alignment: Alignment.topCenter, child: ConstrainedBox( constraints: BoxConstraints(maxWidth: r.formMaxWidth), child: SingleChildScrollView( controller: _scrollCtrl, padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildImportLink(), const SizedBox(height: 16), _buildBasicInfoSection(controller, state), const SizedBox(height: 16), _buildDetailSection(controller, state), const SizedBox(height: 16), _buildAttachmentSection(controller, state), ], ), ), ), ), ), ColoredBox( color: colors.bgCard, child: Column( mainAxisSize: MainAxisSize.min, children: [ _buildBottomButtons(controller, state), if (bottomInset > 0) SizedBox(height: bottomInset), ], ), ), ], ), ); return FutureBuilder( future: _draftFuture, builder: (ctx, snapshot) { final hasDraft = snapshot.hasData && snapshot.data == true; if (hasDraft && !_draftHandled) { _draftHandled = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _showDraftDialog(); }); } return pageContent; }, ); } void _showDraftDialog() { final l10n = AppLocalizations.of(context); final colors = Theme.of(context).extension()!; showDialog( context: context, barrierDismissible: false, builder: (ctx) => TDAlertDialog( title: l10n.get('draftFound'), content: l10n.get('draftRestorePrompt'), leftBtn: TDDialogButtonOptions( title: l10n.get('discard'), titleColor: colors.textSecondary, action: () { Navigator.pop(ctx); ExpenseCreateController.deleteDraft(); }, ), rightBtn: TDDialogButtonOptions( title: l10n.get('restore'), titleColor: colors.primary, action: () async { Navigator.pop(ctx); final draft = await ExpenseCreateController.loadDraft(); if (draft != null && mounted) { final api = ref.read(expenseApiProvider); ref.read(expenseCreateProvider(widget.editId).notifier) .restoreFromDraft(draft, api); _purposeController.text = draft.purpose; _remarkController.text = draft.remark; if (draft.attachments.isNotEmpty) { await _attachmentController.restoreFromPaths(draft.attachments); } } }, ), ), ); } // ═══ API 数据 → 弹窗类型转换 ═══ List get _dialogCategories => _costTypes .map((c) => CostCategory(code: c.typeNo, nameKey: c.typeName, acctSubjectId: c.accNo, acctSubjectName: c.accName)) .toList(); List get _dialogProjects => _projects .map((p) => Project(id: int.tryParse(p.objNo) ?? 0, name: p.name)) .toList(); List get _dialogCostDepts => _departments .map((d) => CostDept(id: d.dep, name: d.name)) .toList(); String _currencyLabel(String code) { final match = _currencies.where((c) => c.curId == code); return match.isNotEmpty ? '${match.first.curId}/${match.first.name}' : code; } List get _dialogCustomers => _customers .map((c) => CustomerVendor(id: c.cusNo, name: c.name)) .toList(); List get _dialogEmployees => _employees; void _autoSelectDept() { if (_selectedDeptId.isNotEmpty) return; final dep = HostAppChannel.dep; if (dep.isEmpty) return; final match = _departments.where((d) => d.dep == dep); if (match.isNotEmpty) { _selectedDeptId = match.first.dep; _selectedDeptName = match.first.name; } } void _showDeptPicker() { if (_departments.isEmpty) { TDToast.showText(AppLocalizations.of(context).get('noData'), context: context); return; } final l10n = AppLocalizations.of(context); final colors = Theme.of(context).extension()!; final labels = _departments.map((d) => d.name).toList(); TDPicker.showMultiPicker( context, title: l10n.get('expenseDept'), backgroundColor: colors.bgCard, data: [labels], onConfirm: (selected) { if (selected.isNotEmpty && selected[0] is int) { final idx = selected[0] as int; if (idx >= 0 && idx < labels.length) { Navigator.of(context).pop(); setState(() { _selectedDeptId = _departments[idx].dep; _selectedDeptName = _departments[idx].name; }); } } }, ); } Map _buildSubmitData(ExpenseCreateState state) { final expense = state.expense; return { 'HeadData': { 'BX_DD': _today(), 'DEP': _selectedDeptId, 'USR_NO': HostAppChannel.usr, 'PAY_ID': expense.paymentMethod, 'PRT_SW': 'N', 'USR': HostAppChannel.usr, 'REM': expense.remark, 'CUR_ID': expense.currencyCode, 'EXC_RTO': 1, 'REASON': expense.purpose, }, 'BodyData1': expense.details.asMap().entries.map((e) { final i = e.key; final d = e.value; return { 'ITM': i + 1, 'BX_DD': _today(), 'ACC_NO': d.acctSubjectId, 'AMT': d.amount, 'AMTN': d.totalAmount, 'REM': d.remark, 'CUST': d.customerVendorId, 'OBJ_NO': d.projectId.isNotEmpty ? d.projectId : '', 'TAX': d.taxAmount, 'TAX_RTO': d.taxRate, 'DEP': d.costDeptId, 'SQ_MAN': d.sqMan.isNotEmpty ? d.sqMan : HostAppChannel.usr, }; }).toList(), }; } Widget _buildImportLink() { final colors = Theme.of(context).extension()!; final l10n = AppLocalizations.of(context); return GestureDetector( onTap: () async { final result = await GoRouter.of(context).push>('/expense/import-apply'); if (result == null || result.isEmpty || !mounted) return; // 将选中的导入数据转换为明细 final controller = ref.read(expenseCreateProvider(widget.editId).notifier); final now = DateTime.now(); for (final item in result) { controller.addDetail(ExpenseDetailModel( id: '${now.millisecondsSinceEpoch}_${item.itm}', expenseId: '', expenseApplyId: '', expenseApplyNo: item.aeNo, expenseApplyDate: item.aeDd.isNotEmpty ? DateTime.tryParse(item.aeDd) : null, expenseCategory: item.typeNo, purpose: item.rem, projectId: item.objNo, projectName: '', costDeptId: item.dep, costDeptName: '', acctSubjectId: item.accNo, acctSubjectName: item.accName, amount: item.amtnYj, taxRate: 0.13, taxAmount: item.amtnYj - item.amtnYj / 1.13, totalAmount: item.amtnYj, currencyCode: '', exchangeRate: 1.0, baseAmount: item.amtnYj, approvedAmount: 0, customerVendorId: '', customerVendorName: '', offsetAmount: 0, bankName: '', bankAccountName: '', bankAccount: '', remark: item.rem, sortOrder: item.itm, attachments: const [], sqMan: item.sqMan, sqManName: '', aeNo: item.aeNo, aeDd: item.aeDd, createTime: now, updateTime: now, )); } controller.recalculateAmount(); TDToast.showSuccess(l10n.get('importSuccess'), context: context); }, child: Container( height: 44, decoration: BoxDecoration( color: colors.primaryLight, borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.download, size: 14, color: colors.primary), const SizedBox(width: 8), Text( l10n.get('importApprovedPreApp'), style: TextStyle( fontSize: AppFontSizes.body, color: colors.primary, ), ), ], ), ), ); } Widget _buildBasicInfoSection( ExpenseCreateController controller, ExpenseCreateState state, ) { final l10n = AppLocalizations.of(context); final colors = Theme.of(context).extension()!; final expense = state.expense; return FormSection( title: l10n.get('basicInfo'), leadingIcon: Icons.info_outline, children: [ FormFieldRow( label: l10n.get('date'), value: _today(), readOnly: true, showArrow: false, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('expensePersonnel'), value: HostAppChannel.usr.isNotEmpty && HostAppChannel.usrName.isNotEmpty ? '${HostAppChannel.usr}/${HostAppChannel.usrName}' : '--', readOnly: true, showArrow: false, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('expenseDept'), value: _selectedDeptName.isNotEmpty ? '$_selectedDeptId/$_selectedDeptName' : null, hint: l10n.get('pleaseSelect'), onTap: _refDataLoading ? null : () => _showDeptPicker(), ), const SizedBox(height: 16), _label(l10n.get('expenseReason'), required: true), const SizedBox(height: 8), TDTextarea( controller: _purposeController, focusNode: _purposeFocus, hintText: l10n.get('enterExpenseReason'), maxLines: 4, minLines: 1, maxLength: 500, indicator: true, padding: EdgeInsets.zero, bordered: true, backgroundColor: colors.bgPage, onChanged: (_) => controller.updatePurpose(_purposeController.text), ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('paymentMethod'), value: expense.paymentMethod, hint: l10n.get('pleaseEnter'), onTap: () => _showTextInput( l10n.get('paymentMethod'), (v) => controller.updatePaymentMethod(v), initialText: expense.paymentMethod, ), onClear: () => controller.updatePaymentMethod(''), ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('currency'), value: expense.currencyCode.isNotEmpty ? _currencyLabel(expense.currencyCode) : null, hint: l10n.get('selectCurrency'), onTap: () => _showCurrencyPicker(controller, expense.currencyCode), onClear: () => controller.updateCurrencyCode(''), ), const SizedBox(height: 16), _label(l10n.get('remark')), const SizedBox(height: 8), TDTextarea( controller: _remarkController, focusNode: _remarkFocus, hintText: l10n.get('enterRemark'), maxLines: 3, minLines: 1, maxLength: 500, indicator: true, padding: EdgeInsets.zero, bordered: true, backgroundColor: colors.bgPage, onChanged: (_) => controller.updateRemark(_remarkController.text), ), ], ); } Widget _buildDetailSection( ExpenseCreateController controller, ExpenseCreateState state, ) { final colors = Theme.of(context).extension()!; final l10n = AppLocalizations.of(context); final totalApproved = state.expense.details.fold( 0, (sum, d) => sum + d.approvedAmount, ); return FormSection( title: l10n.get('expenseDetails'), leadingIcon: Icons.receipt_long_outlined, showAction: !_refDataLoading, actionText: l10n.get('add'), onActionTap: () => _showAddDetailDialog(controller), children: [ if (state.expense.details.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text( l10n.get('noDetailHint'), style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textPlaceholder), ), ) else ...state.expense.details.asMap().entries.map((entry) { final d = entry.value; return GestureDetector( onTap: () => _showAddDetailDialog(controller, editIndex: entry.key), child: Container( margin: const EdgeInsets.symmetric(vertical: 6), padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: colors.bgPage, borderRadius: BorderRadius.circular(8)), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Expanded(child: Text(d.expenseCategory.isNotEmpty ? d.expenseCategory : d.purpose, style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w500, color: colors.textPrimary))), Text('¥${d.totalAmount.toStringAsFixed(2)}', style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.amountPrimary)), ]), const SizedBox(height: 4), if (d.purpose.isNotEmpty) Text(d.purpose, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPrimary)), const SizedBox(height: 2), Text('¥${d.amount.toStringAsFixed(2)} ${l10n.get('tax')}¥${d.taxAmount.toStringAsFixed(2)} | ${d.acctSubjectName}', style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)), if (d.projectName.isNotEmpty || d.costDeptName.isNotEmpty) Text('${d.projectName}${d.costDeptName.isNotEmpty ? " | ${d.costDeptName}" : ""}', style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)), if (d.customerVendorName.isNotEmpty) Text('${l10n.get('customerVendor')}: ${d.customerVendorName}', style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)), if (d.bankName.isNotEmpty || d.bankAccount.isNotEmpty) Text('${d.bankName}${d.bankAccount.isNotEmpty ? " | ${d.bankAccount}" : ""}', style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)), if (d.sqManName.isNotEmpty) Text('${l10n.get('applicant')}: ${d.sqManName.isNotEmpty ? d.sqManName : d.sqMan}', style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)), ], ), ), const SizedBox(width: 8), GestureDetector( onTap: () { controller.removeDetail(entry.key); controller.recalculateAmount(); }, child: Container(width: 24, height: 24, decoration: BoxDecoration(color: colors.primaryLight, shape: BoxShape.circle), child: Icon(Icons.close, size: 14, color: colors.primary700)), ), ], ), ), ); }), const SizedBox(height: 8), Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(l10n.get('totalExpense'), style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.textPrimary)), Text('¥${state.expense.totalAmount.toStringAsFixed(2)}', style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: colors.amountPrimary)), ]), const SizedBox(height: 4), Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(l10n.get('approvedTotal'), style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.textPrimary)), Text('¥${totalApproved.toStringAsFixed(2)}', style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: colors.amountPrimary)), ]), ], ); } Widget _buildAttachmentSection(ExpenseCreateController controller, ExpenseCreateState state) { final colors = Theme.of(context).extension()!; final l10n = AppLocalizations.of(context); return FormSection( title: l10n.get('attachmentUpload'), leadingIcon: Icons.attach_file_outlined, children: [ Text(l10n.get('maxAttachment'), style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPlaceholder)), const SizedBox(height: 8), AttachmentPicker( controller: _attachmentController, maxImageSizeMB: 10, maxFileSizeMB: 20, allowedExtensions: const ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt'], onFileRejected: (file, reason) { if (context.mounted) TDToast.showText(reason, context: context); }, ), ], ); } Widget _buildBottomButtons(ExpenseCreateController controller, ExpenseCreateState state) { final l10n = AppLocalizations.of(context); return ActionBar( showLeft: false, centerLabel: l10n.get('saveDraft'), rightLabel: l10n.get('submit'), centerTextOnly: true, onCenterTap: () async { if (state.isSubmitting) return; controller.updateAttachments(_attachmentController.toPathList()); controller.updateDept(_selectedDeptId, _selectedDeptName); final ok = await controller.saveDraft(); if (mounted) { if (ok) { _forcePop(); } else { TDToast.showFail(l10n.get('saveFailed'), context: context); } } }, onRightTap: () async { if (state.isSubmitting) return; final err = _validate(l10n, state); if (err.isNotEmpty) { TDToast.showText(err.first, context: context); return; } SubmittingDialog.show(context); try { final data = _buildSubmitData(state); await ref.read(expenseApiProvider).submit(data); await ExpenseCreateController.deleteDraft(); if (mounted) { SubmittingDialog.hide(context); TDToast.showSuccess(l10n.get('submittedAwaitingApproval'), context: context); GoRouter.of(context).go('/expense/list'); } } catch (_) { if (mounted) { SubmittingDialog.hide(context); TDToast.showFail(l10n.get('submitFailedRetry'), context: context); } } }, ); } Future _showAddDetailDialog(ExpenseCreateController controller, {int? editIndex}) async { final l10n = AppLocalizations.of(context); if (_costTypes.isEmpty) { TDToast.showText(l10n.get('noCostTypeData'), context: context); return; } final state = controller.currentState; ExpenseDetailInputData? initialData; if (editIndex != null) { final d = state.expense.details[editIndex]; initialData = ExpenseDetailInputData( category: d.expenseCategory, categoryName: '', acctSubjectId: d.acctSubjectId, acctSubjectName: d.acctSubjectName, purpose: d.purpose, amount: d.amount, taxRate: d.taxRate, projectId: d.projectId, projectName: d.projectName, costDeptId: d.costDeptId, costDeptName: d.costDeptName, customerVendorId: d.customerVendorId, customerVendorName: d.customerVendorName, offsetAmount: d.offsetAmount, bankName: d.bankName, bankAccountName: d.bankAccountName, bankAccount: d.bankAccount, remark: d.remark, attachmentPaths: d.attachments, sqMan: d.sqMan, sqManName: d.sqManName, aeNo: d.aeNo, aeDd: d.aeDd, ); } final result = await ExpenseDetailDialog.show( context, categories: _dialogCategories, projects: _dialogProjects, costDepts: _dialogCostDepts, customers: _dialogCustomers, employees: _dialogEmployees, l10n: l10n, initialData: initialData, ); if (result != null && mounted) { final now = DateTime.now(); final detail = ExpenseDetailModel( id: editIndex != null ? state.expense.details[editIndex].id : now.millisecondsSinceEpoch.toString(), expenseId: '', expenseCategory: result.category, purpose: result.purpose, amount: result.amount, taxRate: result.taxRate, taxAmount: result.amount - result.amount / (1 + result.taxRate), totalAmount: result.amount, projectId: result.projectId, projectName: result.projectName, costDeptId: result.costDeptId, costDeptName: result.costDeptName, acctSubjectId: result.acctSubjectId, acctSubjectName: result.acctSubjectName, customerVendorName: result.customerVendorName, offsetAmount: result.offsetAmount, bankName: result.bankName, bankAccountName: result.bankAccountName, bankAccount: result.bankAccount, remark: result.remark, attachments: result.attachmentPaths, createTime: now, updateTime: now, ); if (editIndex != null) { controller.updateDetail(editIndex, detail); } else { controller.addDetail(detail); } controller.recalculateAmount(); } } void _showCurrencyPicker(ExpenseCreateController controller, String cur) { if (_currencies.isEmpty) { TDToast.showText(AppLocalizations.of(context).get('noData'), context: context); return; } final l10n = AppLocalizations.of(context); final colors = Theme.of(context).extension()!; final codes = _currencies.map((c) => c.curId).toList(); final labels = _currencies.map((c) => '${c.curId} ${c.name}').toList(); TDPicker.showMultiPicker(context, title: l10n.get('selectCurrency'), backgroundColor: colors.bgCard, data: [labels], onConfirm: (s) { if (s.isNotEmpty && s[0] is int) { final i = s[0] as int; if (i >= 0 && i < codes.length) { Navigator.of(context).pop(); controller.updateCurrencyCode(codes[i]); } } }); } void _showTextInput( String title, Function(String) onConfirm, { String initialText = '', }) { FocusScope.of(context).unfocus(); final l10n = AppLocalizations.of(context); final c = TextEditingController(text: initialText); showGeneralDialog( context: context, pageBuilder: (ctx, animation, secondaryAnimation) => TDInputDialog( textEditingController: c, title: title, hintText: l10n.get('pleaseEnter'), leftBtn: TDDialogButtonOptions( title: l10n.get('cancel'), action: () => Navigator.pop(ctx), ), rightBtn: TDDialogButtonOptions( title: l10n.get('confirm'), action: () { onConfirm(c.text); Navigator.pop(ctx); }, ), ), ); } Widget _label(String t, {bool required = false}) { final colors = Theme.of(context).extension()!; return Text.rich( TextSpan( children: [ TextSpan(text: t, style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textSecondary)), if (required) TextSpan(text: ' *', style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.danger)), ], ), ); } List _validate(AppLocalizations l10n, ExpenseCreateState state) { final e = []; if (_purposeController.text.trim().isEmpty) { e.add(l10n.get('enterExpenseReason')); } if (state.expense.details.isEmpty) { e.add(l10n.get('addAtLeastOneDetail')); } return e; } bool _hasUnsaved(ExpenseCreateState state) => _purposeController.text.isNotEmpty || state.expense.paymentMethod.isNotEmpty || state.expense.currencyCode.isNotEmpty || _remarkController.text.isNotEmpty || state.expense.details.isNotEmpty || _attachmentController.files.isNotEmpty || _selectedDeptId.isNotEmpty; void _doPop() { final l10n = AppLocalizations.of(context); final state = ref.read(expenseCreateProvider(widget.editId)); if (_hasUnsaved(state)) { _showConfirmDialog( l10n.get('confirmExit'), l10n.get('unsavedContentWarning'), l10n.get('continueEditing'), l10n.get('discardAndExit'), () async { try { await ExpenseCreateController.deleteDraft(); } catch (_) {} if (!mounted) return; setState(() { _selectedDeptId = ''; _selectedDeptName = ''; }); ref.read(expenseCreateProvider(widget.editId).notifier).reset(); _forcePop(); }, ); } else { _forcePop(); } } void _forcePop() { _isPoppingToNative = true; SystemNavigator.pop(); } void _showConfirmDialog( String title, String content, String leftText, String rightText, VoidCallback onConfirm, ) { FocusScope.of(context).unfocus(); final colors = Theme.of(context).extension()!; showDialog( context: context, useRootNavigator: true, builder: (ctx) => TDAlertDialog( title: title, content: content, buttonStyle: TDDialogButtonStyle.text, leftBtn: TDDialogButtonOptions( title: leftText, titleColor: colors.primary, action: () => Navigator.pop(ctx), ), rightBtn: TDDialogButtonOptions( title: rightText, titleColor: colors.danger, action: () { Navigator.pop(ctx); onConfirm(); }, ), ), ); } String _today() { final n = DateTime.now(); return '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}'; } }