import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; import '../../../core/i18n/app_localizations.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors_extension.dart'; import '../../../core/data/mock_api_data.dart'; import '../expense_api.dart'; import '../../../shared/widgets/attachment_picker.dart'; /// 报销明细输入数据。 class ExpenseDetailInputData { final String category; final String categoryName; final String acctSubjectId; final String acctSubjectName; final String purpose; final double amount; // 含税金额 final double taxRate; final String projectId; final String projectName; final String costDeptId; final String costDeptName; final String customerVendorId; final String customerVendorName; final double approvedAmount; final double offsetAmount; final String bankName; final String bankAccountName; final String bankAccount; final String remark; final List attachmentPaths; final String sqMan; final String sqManName; final String aeNo; final String aeDd; const ExpenseDetailInputData({ required this.category, required this.categoryName, required this.acctSubjectId, required this.acctSubjectName, required this.purpose, required this.amount, required this.taxRate, this.projectId = '', this.projectName = '', this.costDeptId = '', this.costDeptName = '', this.customerVendorId = '', this.customerVendorName = '', this.approvedAmount = 0.0, this.offsetAmount = 0.0, this.bankName = '', this.bankAccountName = '', this.bankAccount = '', this.remark = '', this.attachmentPaths = const [], this.sqMan = '', this.sqManName = '', this.aeNo = '', this.aeDd = '', }); } /// 报销明细编辑弹窗。 /// /// 使用 [TDSlidePopupRoute] 从底部滑出,卡片化展示表单字段。 /// 参照 ExpenseApplyCreatePage 的 ExpenseDetailDialog 样式。 class ExpenseDetailDialog extends StatefulWidget { final List categories; final List projects; final List costDepts; final List customers; final List employees; final AppLocalizations l10n; final ExpenseDetailInputData? initialData; final Future Function()? checkAttachHealth; const ExpenseDetailDialog({ super.key, required this.categories, required this.projects, required this.costDepts, required this.customers, required this.employees, required this.l10n, this.initialData, this.checkAttachHealth, }); /// 显示弹窗,返回 [ExpenseDetailInputData] 或 `null`(取消时)。 static Future show( BuildContext context, { required List categories, required List projects, required List costDepts, required List customers, required List employees, required AppLocalizations l10n, ExpenseDetailInputData? initialData, Future Function()? checkAttachHealth, }) { FocusScope.of(context).unfocus(); return Navigator.push( context, TDSlidePopupRoute( slideTransitionFrom: SlideTransitionFrom.bottom, isDismissible: true, builder: (_) => ExpenseDetailDialog( categories: categories, projects: projects, costDepts: costDepts, customers: customers, employees: employees, l10n: l10n, initialData: initialData, checkAttachHealth: checkAttachHealth, ), ), ); } @override State createState() => _ExpenseDetailDialogState(); } class _ExpenseDetailDialogState extends State { late String _cat; late TextEditingController _descCtrl; late TextEditingController _amountCtrl; CustomerVendor? _selCustomer; late TextEditingController _approvedAmountCtrl; late TextEditingController _remarkCtrl; late TextEditingController _bankNameCtrl; late TextEditingController _bankAccountNameCtrl; late TextEditingController _bankAccountCtrl; double _taxRate = 0; Project? _selProject; CostDept? _selDept; EmployeeItem? _selEmployee; late final AttachmentPickerController _attachmentCtrl; final ScrollController _scrollCtrl = ScrollController(); final _remarkFocus = FocusNode(); final _bankNameFocus = FocusNode(); final _bankAccountNameFocus = FocusNode(); final _bankAccountFocus = FocusNode(); bool _attachAvailable = false; void _ensureVisible(FocusNode node) { if (!node.hasFocus) return; _doEnsureVisible(node, 0, -1); } void _doEnsureVisible(FocusNode node, int attempt, double lastInsets) { if (attempt >= 15) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !node.hasFocus || !_scrollCtrl.hasClients) return; final insets = MediaQuery.of(context).viewInsets.bottom; if (insets != lastInsets) { _doEnsureVisible(node, attempt + 1, insets); return; } Future.delayed(const Duration(milliseconds: 500), () { if (!mounted || !node.hasFocus || !_scrollCtrl.hasClients) return; final ctx = node.context; if (ctx == null) return; Scrollable.ensureVisible( ctx, alignment: 0.5, duration: const Duration(milliseconds: 300), ); }); }); } static const _taxOptions = [0.0, 0.06, 0.09, 0.13]; static const _taxLabels = ['0%', '6%', '9%', '13%']; List get _cats => widget.categories; AppLocalizations get _l10n => widget.l10n; CostCategory get _selCat => _cats.firstWhere((c) => c.code == _cat); bool get _isEdit => widget.initialData != null; @override void initState() { super.initState(); final d = widget.initialData; _cat = d != null ? (_cats.any((c) => c.code == d.category) ? d.category : _cats.first.code) : _cats.isNotEmpty ? _cats.first.code : 'other'; _descCtrl = TextEditingController(text: d?.purpose ?? ''); _amountCtrl = TextEditingController( text: d != null && d.amount > 0 ? d.amount.toStringAsFixed(2) : '', ); if (d != null && d.customerVendorName.isNotEmpty) { _selCustomer = CustomerVendor(id: '', name: d.customerVendorName); } _approvedAmountCtrl = TextEditingController( text: d != null && d.approvedAmount > 0 ? d.approvedAmount.toStringAsFixed(2) : '', ); _remarkCtrl = TextEditingController(text: d?.remark ?? ''); _bankNameCtrl = TextEditingController(text: d?.bankName ?? ''); _bankAccountNameCtrl = TextEditingController( text: d?.bankAccountName ?? '', ); _bankAccountCtrl = TextEditingController(text: d?.bankAccount ?? ''); _taxRate = d?.taxRate ?? 0; if (d != null && d.sqMan.isNotEmpty && widget.employees.isNotEmpty) { final idx = widget.employees.indexWhere((e) => e.salNo == d.sqMan); if (idx >= 0) _selEmployee = widget.employees[idx]; } if (d != null) { if (d.projectId.isNotEmpty && widget.projects.isNotEmpty) { _selProject = widget.projects.firstWhere( (p) => p.id.toString() == d.projectId, orElse: () => widget.projects.first, ); } if (d.costDeptId.isNotEmpty && widget.costDepts.isNotEmpty) { _selDept = widget.costDepts.firstWhere( (dept) => dept.id == d.costDeptId, orElse: () => widget.costDepts.first, ); } if (d.attachmentPaths.isNotEmpty) { // Restore attachments will be handled after build WidgetsBinding.instance.addPostFrameCallback((_) { _attachmentCtrl.restoreFromPaths(d.attachmentPaths); }); } } _attachmentCtrl = AttachmentPickerController(maxCount: 9) ..addListener(() => setState(() {})); _remarkFocus.addListener(() => _ensureVisible(_remarkFocus)); _bankNameFocus.addListener(() => _ensureVisible(_bankNameFocus)); _bankAccountNameFocus.addListener( () => _ensureVisible(_bankAccountNameFocus), ); _bankAccountFocus.addListener(() => _ensureVisible(_bankAccountFocus)); _checkAttachHealth(); } Future _checkAttachHealth() async { if (widget.checkAttachHealth == null) return; if (mounted) setState(() => _attachAvailable = false); try { final ok = await widget.checkAttachHealth!(); if (mounted) setState(() => _attachAvailable = ok); } catch (_) { if (mounted) setState(() => _attachAvailable = false); } } @override void dispose() { _descCtrl.dispose(); _amountCtrl.dispose(); _approvedAmountCtrl.dispose(); _remarkCtrl.dispose(); _bankNameCtrl.dispose(); _bankAccountNameCtrl.dispose(); _bankAccountCtrl.dispose(); _attachmentCtrl.dispose(); _scrollCtrl.dispose(); _remarkFocus.dispose(); _bankNameFocus.dispose(); _bankAccountNameFocus.dispose(); _bankAccountFocus.dispose(); super.dispose(); } void _confirm() { final amount = double.tryParse(_amountCtrl.text) ?? 0; if (amount <= 0) { TDToast.showText(_l10n.get('amountPositive'), context: context); return; } Navigator.pop( context, ExpenseDetailInputData( category: _cat, categoryName: _l10n.get(_selCat.nameKey), acctSubjectId: _selCat.acctSubjectId, acctSubjectName: _selCat.acctSubjectName, purpose: '', amount: amount, taxRate: _taxRate, projectId: _selProject?.id.toString() ?? '', projectName: _selProject?.name ?? '', costDeptId: _selDept?.id ?? '', costDeptName: _selDept?.name ?? '', customerVendorId: _selCustomer?.id ?? '', customerVendorName: _selCustomer?.name ?? '', approvedAmount: double.tryParse(_approvedAmountCtrl.text) ?? 0, bankName: _bankNameCtrl.text.trim(), bankAccountName: _bankAccountNameCtrl.text.trim(), bankAccount: _bankAccountCtrl.text.trim(), remark: _remarkCtrl.text.trim(), attachmentPaths: _attachmentCtrl.toPathList(), sqMan: _selEmployee?.salNo ?? '', sqManName: _selEmployee?.name ?? '', aeNo: widget.initialData?.aeNo ?? '', aeDd: widget.initialData?.aeDd ?? '', ), ); } double get _amountExclTax => _taxRate > 0 ? (double.tryParse(_amountCtrl.text) ?? 0) / (1 + _taxRate) : (double.tryParse(_amountCtrl.text) ?? 0); double get _taxAmount => (double.tryParse(_amountCtrl.text) ?? 0) - _amountExclTax; @override Widget build(BuildContext context) { final colors = Theme.of(context).extension()!; return AnimatedPadding( padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom, ), duration: const Duration(milliseconds: 200), child: SafeArea( child: ConstrainedBox( constraints: BoxConstraints( maxHeight: MediaQuery.of(context).size.height * 0.8, ), child: Container( decoration: BoxDecoration( color: colors.bgPage, borderRadius: const BorderRadius.vertical( top: Radius.circular(16), ), ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildHeader(colors), Flexible( child: GestureDetector( onTap: () => FocusScope.of(context).unfocus(), behavior: HitTestBehavior.translucent, child: SingleChildScrollView( controller: _scrollCtrl, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.manual, padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (_isEdit && widget.initialData!.aeNo.isNotEmpty) ...[ _buildAeInfoCard(colors), const SizedBox(height: 12), ], _buildCategoryCard(colors), const SizedBox(height: 12), _buildAcctSubjectCard(colors), const SizedBox(height: 12), _buildAmountCard(), const SizedBox(height: 12), _buildTaxRateCard(colors), if ((double.tryParse(_amountCtrl.text) ?? 0) > 0) ...[ const SizedBox(height: 12), _buildCalcInfo(colors), ], const SizedBox(height: 12), _buildApprovedAmountCard(), const SizedBox(height: 12), _buildProjectCard(colors), const SizedBox(height: 12), _buildCostDeptCard(colors), const SizedBox(height: 12), _buildCustomerCard(colors), const SizedBox(height: 12), _buildEmployeeCard(colors), const SizedBox(height: 12), _buildBankInfoCard(colors), const SizedBox(height: 12), _buildRemarkInput(colors), const SizedBox(height: 12), _buildAttachmentCard(colors), ], ), ), ), ), Container( padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), decoration: BoxDecoration( color: colors.bgCard, border: Border( top: BorderSide(color: colors.border, width: 0.5), ), ), child: _buildActions(), ), ], ), ), ), ), ); } // ── 标题栏 ── Widget _buildHeader(AppColorsExtension colors) { return Column( mainAxisSize: MainAxisSize.min, children: [ Center( child: Container( margin: const EdgeInsets.only(top: 8, bottom: 4), width: 36, height: 4, decoration: BoxDecoration( color: colors.border, borderRadius: BorderRadius.circular(2), ), ), ), Padding( padding: const EdgeInsets.fromLTRB(20, 8, 12, 16), child: Row( children: [ const SizedBox(width: 28), Expanded( child: Center( child: Text( _l10n.get('addExpenseDetail'), style: TextStyle( fontSize: AppFontSizes.title, fontWeight: FontWeight.w600, color: colors.textPrimary, ), ), ), ), GestureDetector( onTap: () => Navigator.pop(context), child: Padding( padding: const EdgeInsets.all(4), child: Icon( Icons.close, size: 20, color: colors.textSecondary, ), ), ), ], ), ), ], ); } // ── picker 卡片 ── Widget _pickerCard({ required String label, required bool required, required String currentLabel, required List labels, required ValueChanged onSelected, required AppColorsExtension colors, VoidCallback? onClear, }) { final tdTheme = TDTheme.of(context); final hasValue = onClear != null; return GestureDetector( onTap: () { if (labels.isEmpty) { TDToast.showText(_l10n.get('noData'), context: context); return; } FocusManager.instance.primaryFocus?.unfocus(); TDPicker.showMultiPicker( context, title: label, 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(); onSelected(idx); } } }, ); }, child: Container( padding: const EdgeInsets.only( left: 16, right: 10, top: 12, bottom: 12, ), decoration: BoxDecoration( color: tdTheme.bgColorContainer, borderRadius: BorderRadius.circular(tdTheme.radiusDefault), border: Border.all(color: tdTheme.componentStrokeColor), ), child: Row( children: [ TDText( label, maxLines: 1, overflow: TextOverflow.visible, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0), ), if (required) Padding( padding: const EdgeInsets.only(left: 4), child: TDText( '*', font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: TextStyle(color: tdTheme.errorColor6), ), ), const SizedBox(width: 12), Expanded( child: Row( mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.max, children: [ Flexible( child: TDText( currentLabel, maxLines: 1, overflow: TextOverflow.ellipsis, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, textColor: tdTheme.textColorPrimary, textAlign: TextAlign.end, ), ), const SizedBox(width: 4), SizedBox( width: 18, height: 18, child: hasValue ? GestureDetector( onTap: onClear, child: Icon( Icons.close, size: 18, color: tdTheme.textColorPlaceholder, ), ) : Icon( Icons.chevron_right, size: 18, color: tdTheme.textColorPlaceholder, ), ), ], ), ), ], ), ), ); } // ── 费用类别 ── Widget _buildCategoryCard(AppColorsExtension colors) { return _pickerCard( label: _l10n.get('expenseCategory'), required: true, currentLabel: '${_selCat.code}/${_l10n.get(_selCat.nameKey)}', labels: _cats.map((c) => '${c.code}/${_l10n.get(c.nameKey)}').toList(), colors: colors, onSelected: (idx) => setState(() => _cat = _cats[idx].code), ); } // ── 输入卡片(对齐 pickerCard 样式) ── Widget _inputCard({ required String label, required bool required, required TextEditingController controller, required String hintText, required AppColorsExtension colors, TextInputType? keyboardType, List? inputFormatters, FocusNode? focusNode, }) { final tdTheme = TDTheme.of(context); final hasValue = controller.text.isNotEmpty; return Container( padding: const EdgeInsets.only(left: 16, right: 10, top: 12, bottom: 12), decoration: BoxDecoration( color: tdTheme.bgColorContainer, borderRadius: BorderRadius.circular(tdTheme.radiusDefault), border: Border.all(color: tdTheme.componentStrokeColor), ), child: Row( children: [ TDText( label, maxLines: 1, overflow: TextOverflow.visible, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0), ), if (required) Padding( padding: const EdgeInsets.only(left: 4), child: TDText( '*', font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: TextStyle(color: tdTheme.errorColor6), ), ), const SizedBox(width: 12), Expanded( child: Row( mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.max, children: [ Flexible( child: TextField( controller: controller, focusNode: focusNode, textAlign: TextAlign.end, keyboardType: keyboardType, inputFormatters: inputFormatters, style: TextStyle(fontSize: 16, color: colors.textPrimary), decoration: InputDecoration( hintText: hintText, hintStyle: TextStyle( fontSize: 16, color: colors.textPlaceholder, ), border: InputBorder.none, isDense: true, contentPadding: EdgeInsets.zero, ), onChanged: (_) => setState(() {}), ), ), const SizedBox(width: 4), SizedBox( width: 18, height: 18, child: hasValue ? GestureDetector( onTap: () { controller.clear(); setState(() {}); }, child: Icon( Icons.close, size: 18, color: tdTheme.textColorPlaceholder, ), ) : null, ), ], ), ), ], ), ); } // ── 含税金额 ── Widget _buildAmountCard() { return _inputCard( label: _l10n.get('amountInclTax'), required: true, controller: _amountCtrl, hintText: '>0', colors: Theme.of(context).extension()!, keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: [ FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$')), ], ); } // ── 税率 ── Widget _buildTaxRateCard(AppColorsExtension colors) { final currentLabel = '${(_taxRate * 100).toStringAsFixed(0)}%'; return _pickerCard( label: _l10n.get('taxRate'), required: false, currentLabel: currentLabel, labels: _taxLabels.toList(), colors: colors, onSelected: (idx) => setState(() { _taxRate = _taxOptions[idx]; }), ); } // ── 计算信息 ── Widget _buildCalcInfo(AppColorsExtension colors) { final amount = double.tryParse(_amountCtrl.text) ?? 0; if (amount <= 0) return const SizedBox.shrink(); return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), decoration: BoxDecoration( color: colors.primaryLight, borderRadius: BorderRadius.circular(8), ), child: Row( children: [ Expanded( child: Text( '${_l10n.get('amountExcludingTax')}: ¥${_amountExclTax.toStringAsFixed(2)}', style: TextStyle( fontSize: AppFontSizes.body, color: colors.textSecondary, ), ), ), Text( '${_l10n.get('taxAmount')}: ¥${_taxAmount.toStringAsFixed(2)}', style: TextStyle( fontSize: AppFontSizes.body, color: colors.textSecondary, ), ), ], ), ); } // ── 导入单据信息 ── Widget _buildAeInfoCard(AppColorsExtension colors) { final d = widget.initialData!; final tdTheme = TDTheme.of(context); return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: _cardDecoration(tdTheme), child: Column( children: [ _readOnlyRow(tdTheme, _l10n.get('expenseApplyNo'), d.aeNo), const SizedBox(height: 8), _readOnlyRow(tdTheme, _l10n.get('applyDate'), d.aeDd), ], ), ); } Widget _readOnlyRow(TDThemeData tdTheme, String label, String value) { return Row( children: [ TDText( label, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0), ), const SizedBox(width: 12), Expanded( child: TDText( value, maxLines: 1, overflow: TextOverflow.ellipsis, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, textColor: tdTheme.textColorPrimary, textAlign: TextAlign.end, ), ), ], ); } BoxDecoration _cardDecoration(TDThemeData tdTheme) => BoxDecoration( color: tdTheme.bgColorContainer, borderRadius: BorderRadius.circular(tdTheme.radiusDefault), border: Border.all(color: tdTheme.componentStrokeColor), ); // ── 申请人 ── Widget _buildEmployeeCard(AppColorsExtension colors) { final employees = widget.employees; return _pickerCard( label: _l10n.get('applicant'), required: false, currentLabel: _selEmployee != null ? '${_selEmployee!.salNo}/${_selEmployee!.name}' : _l10n.get('pleaseSelect'), labels: employees.map((e) => '${e.salNo}/${e.name}').toList(), colors: colors, onSelected: (idx) => setState(() { _selEmployee = employees[idx]; _bankNameCtrl.text = _selEmployee!.bnkNo; _bankAccountNameCtrl.text = _selEmployee!.accName; _bankAccountCtrl.text = _selEmployee!.bnkId; }), onClear: _selEmployee != null ? () => setState(() { _selEmployee = null; _bankNameCtrl.clear(); _bankAccountNameCtrl.clear(); _bankAccountCtrl.clear(); }) : null, ); } // ── 客户/厂商 ── Widget _buildCustomerCard(AppColorsExtension colors) { final vendors = widget.customers; return _pickerCard( label: _l10n.get('customerVendor'), required: false, currentLabel: _selCustomer != null ? '${_selCustomer!.id}/${_selCustomer!.name}' : _l10n.get('pleaseSelect'), labels: vendors.map((v) => '${v.id}/${v.name}').toList(), colors: colors, onSelected: (idx) => setState(() => _selCustomer = vendors[idx]), onClear: _selCustomer != null ? () => setState(() => _selCustomer = null) : null, ); } // ── 已充金额 ── Widget _buildApprovedAmountCard() { return _inputCard( label: _l10n.get('approvedAmount'), required: false, controller: _approvedAmountCtrl, hintText: '0', colors: Theme.of(context).extension()!, keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: [ FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$')), ], ); } // ── 备注 ── Widget _buildRemarkInput(AppColorsExtension colors) { final tdTheme = TDTheme.of(context); return TDTextarea( controller: _remarkCtrl, focusNode: _remarkFocus, label: _l10n.get('remark'), hintText: _l10n.get('enterRemark'), maxLines: 3, minLines: 1, maxLength: 500, indicator: true, decoration: BoxDecoration( color: tdTheme.bgColorContainer, borderRadius: BorderRadius.circular(tdTheme.radiusDefault), border: Border.all(color: tdTheme.componentStrokeColor), ), onChanged: (_) => setState(() {}), ); } // ── 操作按钮 ── Widget _buildAttachmentCard(AppColorsExtension colors) { final tdTheme = TDTheme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(left: 4), child: TDText( _l10n.get('attachmentUpload'), font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0), ), ), Padding( padding: const EdgeInsets.only(left: 4, top: 4), child: TDText( _l10n.get('maxAttachment'), font: tdTheme.fontBodySmall, fontWeight: FontWeight.w400, textColor: tdTheme.textColorPlaceholder, ), ), const SizedBox(height: 4), if (!_attachAvailable) TDText( _l10n.get('attachServiceUnavailable'), font: tdTheme.fontBodyMedium, fontWeight: FontWeight.w400, textColor: tdTheme.textColorPlaceholder, ) else AttachmentPicker( controller: _attachmentCtrl, 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 _buildAcctSubjectCard(AppColorsExtension colors) { return _readOnlyCard( label: _l10n.get('acctSubject'), value: '${_selCat.acctSubjectId}/${_selCat.acctSubjectName}', colors: colors, ); } Widget _readOnlyCard({ required String label, required String value, required AppColorsExtension colors, }) { final tdTheme = TDTheme.of(context); return Container( padding: const EdgeInsets.only(left: 16, right: 16, top: 12, bottom: 12), decoration: BoxDecoration( color: tdTheme.bgColorContainer, borderRadius: BorderRadius.circular(tdTheme.radiusDefault), border: Border.all(color: tdTheme.componentStrokeColor), ), child: Row( children: [ TDText( label, maxLines: 1, overflow: TextOverflow.visible, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0), ), const SizedBox(width: 12), Expanded( child: TDText( value, maxLines: 1, overflow: TextOverflow.ellipsis, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, textColor: tdTheme.textColorPrimary, textAlign: TextAlign.end, ), ), ], ), ); } // ── 关联项目 ── Widget _buildProjectCard(AppColorsExtension colors) { final projects = widget.projects; return _pickerCard( label: _l10n.get('relatedProject'), required: false, currentLabel: _selProject != null ? '${_selProject!.id}/${_selProject!.name}' : _l10n.get('pleaseSelect'), labels: projects.map((p) => '${p.id}/${p.name}').toList(), colors: colors, onSelected: (idx) => setState(() => _selProject = projects[idx]), onClear: _selProject != null ? () => setState(() => _selProject = null) : null, ); } // ── 费用承担部门 ── Widget _buildCostDeptCard(AppColorsExtension colors) { final depts = widget.costDepts; return _pickerCard( label: _l10n.get('costDept'), required: false, currentLabel: _selDept != null ? '${_selDept!.id}/${_selDept!.name}' : _l10n.get('pleaseSelect'), labels: depts.map((d) => '${d.id}/${d.name}').toList(), colors: colors, onSelected: (idx) => setState(() => _selDept = depts[idx]), onClear: _selDept != null ? () => setState(() => _selDept = null) : null, ); } // ── 收款银行信息 ── Widget _buildBankInfoCard(AppColorsExtension colors) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _inputCard( label: _l10n.get('bankName'), required: false, controller: _bankNameCtrl, hintText: _l10n.get('pleaseEnter'), colors: colors, focusNode: _bankNameFocus, ), const SizedBox(height: 12), _inputCard( label: _l10n.get('bankAccountName'), required: false, controller: _bankAccountNameCtrl, hintText: _l10n.get('pleaseEnter'), colors: colors, focusNode: _bankAccountNameFocus, ), const SizedBox(height: 12), _inputCard( label: _l10n.get('bankAccount'), required: false, controller: _bankAccountCtrl, hintText: _l10n.get('pleaseEnter'), colors: colors, focusNode: _bankAccountFocus, ), ], ); } Widget _buildActions() { return Row( children: [ Expanded( child: TDButton( text: _l10n.get('cancel'), size: TDButtonSize.large, type: TDButtonType.outline, shape: TDButtonShape.rectangle, theme: TDButtonTheme.defaultTheme, onTap: () => Navigator.pop(context), ), ), const SizedBox(width: 12), Expanded( child: TDButton( text: _isEdit ? _l10n.get('confirmEdit') : _l10n.get('add'), size: TDButtonSize.large, type: TDButtonType.fill, shape: TDButtonShape.rectangle, theme: TDButtonTheme.primary, onTap: _confirm, ), ), ], ); } }