import 'dart:async'; 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:dio/dio.dart'; import 'package:go_router/go_router.dart'; import '../../core/network/api_exception.dart'; import '../../core/utils/responsive.dart'; import '../../core/utils/date_utils.dart' as du; import '../../shared/widgets/form_section.dart'; import '../../shared/widgets/form_field_row.dart'; import '../../shared/widgets/app_skeletons.dart'; import '../../shared/widgets/nav_bar_config.dart'; import 'widgets/expense_detail_dialog.dart'; import '../../shared/widgets/searchable_picker_sheet.dart'; import '../../shared/widgets/loading_dialog.dart'; import '../../shared/widgets/action_bar.dart'; import 'expense_api.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'; /// 费用报销修改页 /// /// 完全参考 [ExpenseCreatePage] 创建页,但: /// - 无草稿功能 /// - 无附件上传区 /// - 无从申请单导入链接 /// - initState 中加载原单数据并回填 /// - 基本信息区顶部加单号、日期只读行 /// - ActionBar 仅含提交按钮 /// - 提交时 HeadData 加 BX_NO,成功 pop(true) 返回详情页 class ExpenseEditPage extends ConsumerStatefulWidget { final String billNo; const ExpenseEditPage({super.key, required this.billNo}); @override ConsumerState createState() => _ExpenseEditPageState(); } class _ExpenseEditPageState extends ConsumerState { final _purposeController = TextEditingController(); final _purposeFocus = FocusNode(); final _remarkController = TextEditingController(); final _remarkFocus = FocusNode(); final _scrollCtrl = ScrollController(); final _detailsSectionKey = GlobalKey(); // ── 参考数据(从 API 加载) ── List _currencies = []; EmployeeItem? _selEmployee; bool _firstBuild = true; bool _refDataLoading = true; bool _addingDetail = false; bool _isSubmitting = false; bool _canEditApprovedAmount = false; String? _loadingError; // ── 原单数据 ── ExpenseModel? _expense; String _billNo = ''; String _expenseDate = ''; // ── 报销部门 ── String _selectedDeptId = ''; String _selectedDeptName = ''; @override void initState() { super.initState(); SystemChrome.setSystemUIOverlayStyle( const SystemUiOverlayStyle( statusBarColor: Colors.transparent, statusBarIconBrightness: Brightness.dark, ), ); _purposeFocus.addListener(() => _ensureVisible(_purposeFocus)); _remarkFocus.addListener(() => _ensureVisible(_remarkFocus)); _billNo = widget.billNo; _loadData(); _checkApprovedAmountRight(); } Future _checkApprovedAmountRight() async { try { final api = ref.read(expenseApiProvider); final ok = await api.checkSpcRight('MONCJ_AMTN_SH'); if (mounted) setState(() => _canEditApprovedAmount = ok); } catch (_) { if (mounted) setState(() => _canEditApprovedAmount = false); } } Future _loadData() async { try { final api = ref.read(expenseApiProvider); final results = await Future.wait([ api.getCurrencies(), api.fetchDetail(widget.billNo), ]); if (!mounted) return; final expense = results[1] as ExpenseModel; setState(() { _currencies = results[0] as List; _expense = expense; _expenseDate = expense.expenseDate != null ? du.DateUtils.formatDate(expense.expenseDate!) : _today(); _selectedDeptId = expense.deptId; _selectedDeptName = expense.deptName; _selEmployee = expense.applicantId.isNotEmpty ? EmployeeItem(salNo: expense.applicantId, name: expense.applicantName) : null; _purposeController.text = expense.purpose; _remarkController.text = expense.remark; _refDataLoading = false; _firstBuild = false; _recalculateAmount(); }); } catch (e) { if (!mounted) return; setState(() { _refDataLoading = false; _firstBuild = false; _loadingError = e.toString(); }); } } Future _showEmployeePicker() async { FocusManager.instance.primaryFocus?.unfocus(); final l10n = AppLocalizations.of(context); final api = ref.read(expenseApiProvider); final result = await showSearchablePicker( context, title: '${l10n.get('select')}${l10n.get('expensePersonnel')}', searchHint: l10n.get('search'), loader: (keyword, page) => api.getEmployees(keyword: keyword, page: page, size: 20), labelBuilder: (e) => e.name.isEmpty ? e.salNo : '${e.salNo} ${e.name}', ); if (result != null && mounted) { setState(() => _selEmployee = result); } } 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() { _purposeController.dispose(); _purposeFocus.dispose(); _remarkController.dispose(); _remarkFocus.dispose(); _scrollCtrl.dispose(); super.dispose(); } // ═══ 状态更新助手(替代 Riverpod controller) ═══ void _updatePurpose(String purpose) { if (_expense == null) return; setState(() { _expense = _expense!.copyWith(purpose: purpose); }); } void _updateRemark(String remark) { if (_expense == null) return; setState(() { _expense = _expense!.copyWith(remark: remark); }); } void _updatePaymentMethod(String method) { if (_expense == null) return; setState(() { _expense = _expense!.copyWith(paymentMethod: method); }); } void _updateCurrencyCode(String code) { if (_expense == null) return; setState(() { _expense = _expense!.copyWith(currencyCode: code); }); } void _setGenerateVoucher(bool value) { if (_expense == null) return; setState(() { _expense = _expense!.copyWith(isGenerateVoucher: value); }); } void _addDetail(ExpenseDetailModel detail) { if (_expense == null) return; setState(() { _expense = _expense!.copyWith(details: [..._expense!.details, detail]); }); _recalculateAmount(); } void _updateDetail(int index, ExpenseDetailModel detail) { if (_expense == null) return; final details = [..._expense!.details]; details[index] = detail; setState(() { _expense = _expense!.copyWith(details: details); }); _recalculateAmount(); } void _removeDetail(int index) { if (_expense == null) return; final details = [..._expense!.details]..removeAt(index); setState(() { _expense = _expense!.copyWith(details: details); }); _recalculateAmount(); } void _recalculateAmount() { if (_expense == null) return; var totalAmount = 0.0; var approvedAmount = 0.0; for (final d in _expense!.details) { totalAmount += d.totalAmount; approvedAmount += d.approvedAmount; } setState(() { _expense = _expense!.copyWith( totalAmount: totalAmount, approvedAmount: approvedAmount, ); }); } String _currencyLabel(String code) { final match = _currencies.where((c) => c.curId == code); return match.isNotEmpty ? '${match.first.curId}/${match.first.name}' : code; } Future _showDeptPicker() async { FocusManager.instance.primaryFocus?.unfocus(); final l10n = AppLocalizations.of(context); final api = ref.read(expenseApiProvider); final result = await showSearchablePicker( context, title: '${l10n.get('select')}${l10n.get('expenseDept')}', searchHint: l10n.get('search'), loader: (keyword, page) => api.getDepartments(keyword: keyword, page: page, size: 20), labelBuilder: (d) => d.name.isEmpty ? d.dep : '${d.dep} ${d.name}', ); if (result != null && mounted) { setState(() { _selectedDeptId = result.dep; _selectedDeptName = result.name; }); } } Map _buildSubmitData() { final expense = _expense!; return { 'HeadData': { 'BX_NO': _billNo, 'BX_DD': _today(), 'DEP': _selectedDeptId, 'USR_NO': _selEmployee?.salNo ?? '', 'PAY_ID': expense.paymentMethod, 'USR': HostAppChannel.usr, 'REM': expense.remark, 'CUR_ID': expense.currencyCode, 'EXC_RTO': 1, 'REASON': expense.purpose, 'VOH_ID': expense.isGenerateVoucher ? 'T' : 'F', }, 'BodyData1': expense.details.asMap().entries.map((e) { final d = e.value; final item = { 'BX_NO': _billNo, 'BX_DD': _today(), 'ACC_NO': d.acctSubjectId, 'AMT': d.totalAmount, 'AMTN': d.amount, 'AMTN_SH': d.approvedAmount, 'REM': d.remark, 'CUST': d.customerVendorId, 'IDX_NO': d.expenseCategory, 'OBJ_NO': d.projectId.isNotEmpty ? d.projectId : '', 'TAX': d.taxAmount, 'TAX_RTO': d.taxRate, 'DEP': d.costDeptId, 'AE_NO': d.aeNo, 'AE_DD': d.aeDd, 'BNK_NO': d.bankName, 'BNK_ID': d.bankAccount, 'ACCNAME': d.bankAccountName, 'SQ_MAN': d.sqMan.isNotEmpty ? d.sqMan : HostAppChannel.usr, 'EST_ITM': d.aeNo.isNotEmpty ? d.sortOrder : 0, }; if (d.preItm != null) { item['PRE_ITM'] = d.preItm; } return item; }).toList(), }; } // ═══ Build ═══ @override Widget build(BuildContext context) { final r = ResponsiveHelper.of(context); final colors = Theme.of(context).extension()!; final l10n = AppLocalizations.of(context); final bottomInset = MediaQuery.of(context).padding.bottom; if (_loadingError != null) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.error_outline, size: 48, color: colors.danger), const SizedBox(height: 16), Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: Text( _loadingError!, textAlign: TextAlign.center, style: TextStyle( fontSize: AppFontSizes.body, color: colors.textSecondary, ), ), ), const SizedBox(height: 16), TDButton( text: l10n.get('retry'), size: TDButtonSize.medium, onTap: () { setState(() { _loadingError = null; _firstBuild = true; _refDataLoading = true; }); _loadData(); }, ), ], ), ); } if (_firstBuild || _expense == null) { return const SkeletonFormPage(showImportLink: false); } Future.microtask( () => ref.read(pageBackProvider.notifier).state = () => _doPop(l10n), ); Widget pageContent = PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (didPop) return; _doPop(l10n); }, 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: [ _buildBasicInfoSection(l10n, colors), const SizedBox(height: 16), Container( key: _detailsSectionKey, child: _buildDetailSection(l10n, colors), ), const SizedBox(height: 24), _buildPageFooter(), ], ), ), ), ), ), ColoredBox( color: colors.bgCard, child: Column( mainAxisSize: MainAxisSize.min, children: [ _buildBottomButtons(l10n), if (bottomInset > 0) SizedBox(height: bottomInset), ], ), ), ], ), ); return pageContent; } Widget _buildBasicInfoSection( AppLocalizations l10n, AppColorsExtension colors, ) { final expense = _expense!; return FormSection( title: l10n.get('basicInfo'), leadingIcon: Icons.info_outline, children: [ FormFieldRow( label: l10n.get('expenseNo'), value: _billNo, readOnly: true, showArrow: false, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('date'), value: _expenseDate, readOnly: true, showArrow: false, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('expensePersonnel'), value: _selEmployee != null ? '${_selEmployee!.salNo}/${_selEmployee!.name}' : '', hint: l10n.get('pleaseSelect'), onTap: _showEmployeePicker, ), 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: (_) => _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) => _updatePaymentMethod(v), initialText: expense.paymentMethod, ), onClear: () => _updatePaymentMethod(''), ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('currency'), value: expense.currencyCode.isNotEmpty ? _currencyLabel(expense.currencyCode) : null, hint: l10n.get('selectCurrency'), onTap: () => _showCurrencyPicker(expense.currencyCode), onClear: () => _updateCurrencyCode(''), ), const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( l10n.get('generateVoucher'), style: TextStyle( fontSize: AppFontSizes.subtitle, color: colors.textSecondary, ), ), TDSwitch( isOn: expense.isGenerateVoucher, onChanged: (v) { _setGenerateVoucher(v); return v; }, ), ], ), 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: (_) => _updateRemark(_remarkController.text), ), ], ); } Widget _buildDetailSection(AppLocalizations l10n, AppColorsExtension colors) { final expense = _expense!; final totalAmount = expense.details.fold( 0, (sum, d) => sum + d.totalAmount, ); final totalApproved = expense.details.fold( 0, (sum, d) => sum + d.approvedAmount, ); return FormSection( title: l10n.get('expenseDetails'), leadingIcon: Icons.receipt_long_outlined, showAction: true, actionText: l10n.get('add'), onActionTap: () => _showAddDetailDialog(), children: [ if (expense.details.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text( l10n.get('noDetailHint'), style: TextStyle( fontSize: AppFontSizes.subtitle, color: colors.textPlaceholder, ), ), ) else ...expense.details.asMap().entries.map((entry) { final d = entry.value; return GestureDetector( onTap: () => _showAddDetailDialog(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.categoryName.isNotEmpty ? '${d.expenseCategory}/${d.categoryName}' : d.expenseCategory, style: TextStyle( fontSize: AppFontSizes.body, fontWeight: FontWeight.w500, color: colors.textPrimary, ), ), ), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( '¥${d.totalAmount.toStringAsFixed(2)}', style: TextStyle( fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.amountPrimary, ), ), if (d.approvedAmount > 0) Text( '¥${d.approvedAmount.toStringAsFixed(2)}', style: TextStyle( fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.success, ), ), ], ), ], ), const SizedBox(height: 2), Text( '${l10n.get('amountExcludingTax')}: ¥${d.amount.toStringAsFixed(2)}', style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), if (d.taxAmount > 0) Text( '${l10n.get('taxAmount')}: ¥${d.taxAmount.toStringAsFixed(2)}', style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), if (d.taxRate > 0) Text( '${l10n.get('taxRate')}: ${d.taxRate.toStringAsFixed(0)}%', style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), if (d.acctSubjectId.isNotEmpty) Text( '${l10n.get('acctSubject')}: ${d.acctSubjectId}${d.acctSubjectName.isNotEmpty ? '/${d.acctSubjectName}' : ''}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), if (d.aeNo.isNotEmpty) Text( '${l10n.get('expenseApplyNo')}: ${d.aeNo}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), if (d.aeDd.isNotEmpty) Text( '${l10n.get('applyDate')}: ${d.aeDd.length >= 10 ? d.aeDd.substring(0, 10) : d.aeDd}', style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), if (d.projectId.isNotEmpty) Text( '${l10n.get('project')}: ${d.projectId}${d.projectName.isNotEmpty ? '/${d.projectName}' : ''}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), if (d.costDeptId.isNotEmpty) Text( '${l10n.get('costDept')}: ${d.costDeptId}${d.costDeptName.isNotEmpty ? '/${d.costDeptName}' : ''}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), if (d.customerVendorId.isNotEmpty) Text( '${l10n.get('customerVendor')}: ${d.customerVendorId}${d.customerVendorName.isNotEmpty ? '/${d.customerVendorName}' : ''}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), if (d.sqMan.isNotEmpty) Text( '${l10n.get('applicant')}: ${d.sqMan}${d.sqManName.isNotEmpty ? '/${d.sqManName}' : ''}', style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), if (d.bankAccountName.isNotEmpty) Text( '${l10n.get('bankAccountName')}: ${d.bankAccountName}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), if (d.bankName.isNotEmpty) Text( '${l10n.get('bankName')}: ${d.bankName}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), if (d.bankAccount.isNotEmpty) Text( '${l10n.get('bankAccount')}: ${d.bankAccount}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), if (d.remark.isNotEmpty) Text( '${l10n.get('remark')}: ${d.remark}', maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), ], ), ), const SizedBox(width: 8), GestureDetector( onTap: () => _removeDetail(entry.key), child: Icon( Icons.close, size: 18, color: colors.textSecondary, ), ), ], ), ), ); }), 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( '¥${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: totalApproved > 0 ? colors.success : colors.textPrimary, ), ), ], ), ], ); } Future _showAddDetailDialog({int? editIndex}) async { if (_addingDetail) return; _addingDetail = true; try { final l10n = AppLocalizations.of(context); final expense = _expense!; ExpenseDetailInputData? initialData; if (editIndex != null) { final d = expense.details[editIndex]; initialData = ExpenseDetailInputData( category: d.expenseCategory, categoryName: d.categoryName, acctSubjectId: d.acctSubjectId, acctSubjectName: d.acctSubjectName, purpose: d.purpose, amount: d.totalAmount, taxRate: d.taxRate, projectId: d.projectId, projectName: d.projectName, costDeptId: d.costDeptId, costDeptName: d.costDeptName, customerVendorId: d.customerVendorId, customerVendorName: d.customerVendorName, approvedAmount: d.approvedAmount, 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, ); } FocusManager.instance.primaryFocus?.unfocus(); final result = await ExpenseDetailDialog.show( context, api: ref.read(expenseApiProvider), l10n: l10n, initialData: initialData, checkAttachHealth: () => ref.read(expenseApiProvider).checkAttachHealth(), showAttachments: false, canEditApprovedAmount: _canEditApprovedAmount, ); if (result != null && mounted) { final now = DateTime.now(); final detail = ExpenseDetailModel( id: editIndex != null ? expense.details[editIndex].id : now.millisecondsSinceEpoch.toString(), expenseId: '', expenseCategory: result.category, categoryName: result.categoryName, purpose: result.purpose, amount: result.taxRate > 0 ? result.amount / (1 + result.taxRate / 100) : result.amount, taxRate: result.taxRate, taxAmount: result.taxRate > 0 ? result.amount - result.amount / (1 + result.taxRate / 100) : 0, totalAmount: result.amount, projectId: result.projectId, projectName: result.projectName, costDeptId: result.costDeptId, costDeptName: result.costDeptName, acctSubjectId: result.acctSubjectId, acctSubjectName: result.acctSubjectName, customerVendorId: result.customerVendorId, customerVendorName: result.customerVendorName, approvedAmount: result.approvedAmount, bankName: result.bankName, bankAccountName: result.bankAccountName, bankAccount: result.bankAccount, sqMan: result.sqMan, sqManName: result.sqManName, aeNo: result.aeNo, aeDd: result.aeDd, remark: result.remark, sortOrder: editIndex != null ? expense.details[editIndex].sortOrder : 1, preItm: editIndex != null ? expense.details[editIndex].preItm : null, attachments: result.attachmentPaths, createTime: now, updateTime: now, ); if (editIndex != null) { _updateDetail(editIndex, detail); } else { _addDetail(detail); } } } finally { _addingDetail = false; } } void _showCurrencyPicker(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(); FocusManager.instance.primaryFocus?.unfocus(); 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(); _updateCurrencyCode(codes[i]); } } }, ); } void _showTextInput( String title, Function(String) onConfirm, { String initialText = '', }) { FocusScope.of(context).unfocus(); FocusManager.instance.primaryFocus?.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, ), ), ], ), ); } Widget _buildBottomButtons(AppLocalizations l10n) { return ActionBar( showLeft: false, showCenter: false, rightLabel: l10n.get('submit'), onRightTap: () async { if (_isSubmitting) return; final err = _validate(l10n); if (err.isNotEmpty) { TDToast.showText(err.first, context: context); return; } FocusScope.of(context).unfocus(); _isSubmitting = true; LoadingDialog.show(context, text: l10n.get('submitting')); try { final data = _buildSubmitData(); final api = ref.read(expenseApiProvider); await api.submit(data); if (mounted) { LoadingDialog.hide(context); TDToast.showSuccess( l10n.get('submitSuccess'), context: context, ); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) GoRouter.of(context).pop(true); }); } } catch (e) { if (mounted) { LoadingDialog.hide(context); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _showSubmitError(e, l10n); }); } } finally { _isSubmitting = false; } }, ); } void _showSubmitError(Object e, AppLocalizations l10n) { final message = _extractErrorMessage(e) ?? l10n.get('submitFailedRetry'); showGeneralDialog( context: context, pageBuilder: (ctx, animation, secondaryAnimation) => TDConfirmDialog( title: l10n.get('submitFailed'), content: message, buttonStyle: TDDialogButtonStyle.text, ), ); } String? _extractErrorMessage(Object e) { if (e is DioException) { if (e.error is ApiException) return (e.error as ApiException).message; if (e.error is NetworkException) { return (e.error as NetworkException).message; } } return null; } List _validate(AppLocalizations l10n) { final e = []; if (_purposeController.text.trim().isEmpty) { e.add(l10n.get('enterExpenseReason')); } if (_expense!.details.isEmpty) { e.add(l10n.get('addAtLeastOneDetail')); } return e; } bool _hasUnsaved() => _purposeController.text.isNotEmpty || (_expense?.paymentMethod.isNotEmpty ?? false) || (_expense?.currencyCode.isNotEmpty ?? false) || _remarkController.text.isNotEmpty || (_expense?.details.isNotEmpty ?? false); void _doPop(AppLocalizations l10n) { if (_hasUnsaved()) { _showConfirmDialog( l10n.get('confirmExit'), l10n.get('unsavedContentWarning'), l10n.get('continueEditing'), l10n.get('discardAndExit'), () { if (!mounted) return; _forcePop(); }, ); } else { _forcePop(); } } void _forcePop() { FocusManager.instance.primaryFocus?.unfocus(); final router = GoRouter.of(context); if (router.canPop()) { router.pop(); } else { SystemNavigator.pop(); } } void _showConfirmDialog( String title, String content, String leftText, String rightText, VoidCallback onConfirm, ) { FocusScope.of(context).unfocus(); FocusManager.instance.primaryFocus?.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(); }, ), ), ); } Widget _buildPageFooter() { final l10n = AppLocalizations.of(context); final colors = Theme.of(context).extension()!; return Center( child: Padding( padding: const EdgeInsets.only(bottom: 16), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.rocket_launch_outlined, size: 16, color: colors.textPlaceholder, ), const SizedBox(width: 6), Text( l10n.get('pageFooter'), style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textPlaceholder, ), ), ], ), ), ); } String _today() { final n = DateTime.now(); return '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}'; } }