import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; import '../../core/i18n/app_localizations.dart'; import '../../core/storage/draft_storage.dart'; import '../../shared/widgets/action_bar.dart'; import '../../shared/widgets/form_section.dart'; import '../../shared/widgets/form_field_row.dart'; import '../../shared/widgets/nav_bar_config.dart'; import '../../shared/widgets/attachment_picker.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors_extension.dart'; import '../../core/constants/enums.dart'; import '../../core/data/mock_api_data.dart'; import 'widgets/expense_apply_detail_dialog.dart'; class ExpenseApplyCreatePage extends ConsumerStatefulWidget { final String? id; const ExpenseApplyCreatePage({super.key, this.id}); @override ConsumerState createState() => _ExpenseApplyCreatePageState(); } class _ExpenseApplyCreatePageState extends ConsumerState { static const _draftKey = 'expense_apply'; // ── 基本信息 ── String _urgency = Urgency.normal.value; final _purposeController = TextEditingController(); final _purposeFocus = FocusNode(); String _validUntil = ''; final _referenceNoController = TextEditingController(); final _remarkController = TextEditingController(); final _remarkFocus = FocusNode(); final _scrollCtrl = ScrollController(); // ── 费用明细 ── final List<_DetailItem> _details = []; int _detailIdCounter = 1; // ── 附件 ── late final AttachmentPickerController _attachmentController; @override void initState() { super.initState(); _attachmentController = AttachmentPickerController(maxCount: 9) ..addListener(() => setState(() {})); _purposeFocus.addListener(() => _ensureVisible(_purposeFocus)); _remarkFocus.addListener(() => _ensureVisible(_remarkFocus)); WidgetsBinding.instance.addPostFrameCallback((_) => _checkDraft()); } 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(); _referenceNoController.dispose(); _remarkController.dispose(); _remarkFocus.dispose(); _attachmentController.dispose(); _scrollCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); ref .read(navBarConfigProvider.notifier) .update( NavBarConfig( title: l10n.get('expenseApplyRequest'), showBack: true, onBack: () => _doPop(), ), ); return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (didPop) return; if (_hasUnsaved()) { _showConfirmDialog( l10n.get('confirmExit'), l10n.get('unsavedContentWarning'), l10n.get('continueEditing'), l10n.get('discardAndExit'), () => _doPop(), ); } else { _doPop(); } }, child: Column( children: [ Expanded( child: GestureDetector( onTap: () => FocusScope.of(context).unfocus(), child: SingleChildScrollView( controller: _scrollCtrl, padding: const EdgeInsets.all(16), child: Column( children: [ _buildBasicInfo(l10n), const SizedBox(height: 16), _buildDetailsSection(l10n), const SizedBox(height: 16), _buildAttachmentSection(l10n), const SizedBox(height: 80), ], ), ), ), ), _buildBottomBar(l10n), ], ), ); } // ═══ 草稿持久化 ═══ Future _checkDraft() async { if (!mounted) return; final has = await DraftStorage.has(_draftKey); if (!has || !mounted) return; final l10n = AppLocalizations.of(context); final colors = Theme.of(context).extension()!; final yes = await showDialog( context: context, builder: (ctx) => TDAlertDialog( title: l10n.get('draftFound'), content: l10n.get('draftRestorePrompt'), leftBtn: TDDialogButtonOptions( title: l10n.get('discard'), titleColor: colors.textSecondary, action: () => Navigator.pop(ctx, false), ), rightBtn: TDDialogButtonOptions( title: l10n.get('restore'), titleColor: colors.primary, action: () => Navigator.pop(ctx, true), ), ), ); if (yes == true && mounted) { await _restoreDraft(); } else { await DraftStorage.delete(_draftKey); } } Future _restoreDraft() async { final data = await DraftStorage.load(_draftKey); if (data == null) return; setState(() { _urgency = data['urgency'] as String? ?? Urgency.normal.value; _purposeController.text = data['purpose'] as String? ?? ''; _validUntil = data['validUntil'] as String? ?? ''; _referenceNoController.text = data['referenceNo'] as String? ?? ''; _remarkController.text = data['remark'] as String? ?? ''; final attData = data['attachments'] as List?; if (attData != null) { _attachmentController.restoreFromPaths(attData.cast()); } _details.clear(); final detailList = data['details'] as List?; if (detailList != null) { for (final d in detailList) { final m = d as Map; _details.add(_DetailItem( id: m['id'] as int? ?? _detailIdCounter++, category: m['category'] as String? ?? '', categoryName: m['categoryName'] as String? ?? '', acctSubjectId: m['acctSubjectId'] as String? ?? '', acctSubjectName: m['acctSubjectName'] as String? ?? '', purpose: m['purpose'] as String? ?? '', projectId: m['projectId'] as int? ?? 0, projectName: m['projectName'] as String? ?? '', costDeptId: m['costDeptId'] as String? ?? '', costDeptName: m['costDeptName'] as String? ?? '', startDate: m['startDate'] as String? ?? '', endDate: m['endDate'] as String? ?? '', estimatedAmount: (m['estimatedAmount'] as num?)?.toDouble() ?? 0, remark: m['remark'] as String? ?? '', )); } } _detailIdCounter = _details.isEmpty ? 1 : _details.map((d) => d.id).reduce((a, b) => a > b ? a : b) + 1; }); } Future _saveDraftToStorage() async { final detailList = _details.map((d) => { 'id': d.id, 'category': d.category, 'categoryName': d.categoryName, 'acctSubjectId': d.acctSubjectId, 'acctSubjectName': d.acctSubjectName, 'purpose': d.purpose, 'projectId': d.projectId, 'projectName': d.projectName, 'costDeptId': d.costDeptId, 'costDeptName': d.costDeptName, 'startDate': d.startDate, 'endDate': d.endDate, 'estimatedAmount': d.estimatedAmount, 'remark': d.remark, }).toList(); await DraftStorage.save(_draftKey, { 'urgency': _urgency, 'purpose': _purposeController.text, 'validUntil': _validUntil, 'referenceNo': _referenceNoController.text, 'remark': _remarkController.text, 'attachments': _attachmentController.toPathList(), 'details': detailList, }); } // ═══ 1. 基本信息 ═══ Widget _buildBasicInfo(AppLocalizations l10n) { final colors = Theme.of(context).extension()!; return FormSection( title: l10n.get('basicInfo'), leadingIcon: Icons.info_outline, children: [ FormFieldRow( label: l10n.get('applicant'), value: '张三', readOnly: true, showArrow: false, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('department'), value: '技术部', readOnly: true, showArrow: false, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('date'), value: _today(), readOnly: true, showArrow: false, ), const SizedBox(height: 16), _label(l10n.get('emergencyLevel'), required: true), const SizedBox(height: 8), _buildUrgencyRadio(l10n), const SizedBox(height: 16), _label(l10n.get('feeReason'), required: true), const SizedBox(height: 8), TDTextarea( controller: _purposeController, focusNode: _purposeFocus, hintText: l10n.get('enterFeeReason'), maxLines: 4, minLines: 1, maxLength: 500, indicator: true, padding: EdgeInsets.zero, bordered: true, backgroundColor: colors.bgPage, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('validUntil'), value: _validUntil, hint: l10n.get('pleaseSelect'), onTap: () => _pickDate((d) => setState(() => _validUntil = d)), ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('relatedContractNo'), value: _referenceNoController.text, hint: l10n.get('optional'), onTap: () => _showTextInput( l10n.get('relatedContractNo'), (v) => setState(() { _referenceNoController.text = v; _referenceNoController.selection = TextSelection.fromPosition( TextPosition(offset: v.length), ); }), initialText: _referenceNoController.text, ), ), 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, ), ], ); } Widget _buildUrgencyRadio(AppLocalizations l10n) { final colors = Theme.of(context).extension()!; return Row( children: Urgency.values.asMap().entries.map((e) { final sel = _urgency == e.value.value; final isCritical = e.value.value == Urgency.critical.value; final activeColor = isCritical ? colors.danger : colors.primary; return Padding( padding: EdgeInsets.only(right: e.key < 2 ? 24 : 0), child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => setState(() => _urgency = e.value.value), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 18, height: 18, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: sel ? activeColor : colors.textPlaceholder, width: 2, ), ), child: sel ? Center( child: Container( width: 8, height: 8, decoration: BoxDecoration( shape: BoxShape.circle, color: activeColor, ), ), ) : null, ), const SizedBox(width: 6), Text( l10n.get(e.value.labelKey), style: TextStyle( fontSize: AppFontSizes.subtitle, color: sel ? activeColor : colors.textPrimary, ), ), ], ), ), ); }).toList(), ); } // ═══ 2. 费用明细 ═══ Widget _buildDetailsSection(AppLocalizations l10n) { final colors = Theme.of(context).extension()!; return FormSection( title: l10n.get('expenseDetails'), leadingIcon: Icons.receipt_long_outlined, showAction: true, actionText: l10n.get('add'), onActionTap: _showDetailDialog, children: [ if (_details.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text( l10n.get('noDetailHint'), style: TextStyle( fontSize: AppFontSizes.subtitle, color: colors.textPlaceholder, ), ), ) else ..._details.asMap().entries.map((e) { final d = e.value; return Container( margin: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: colors.bgPage, borderRadius: BorderRadius.circular(8), ), child: Row( children: [ Expanded( flex: 3, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( d.categoryName, style: TextStyle( fontSize: AppFontSizes.subtitle, color: colors.textPrimary, ), ), if (d.purpose.isNotEmpty) Text( d.purpose, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textPrimary, ), ), const SizedBox(height: 4), Text( '¥${d.estimatedAmount.toStringAsFixed(2)}', style: TextStyle( fontSize: AppFontSizes.caption, fontWeight: FontWeight.w600, color: colors.amountPrimary, ), ), if (d.projectName.isNotEmpty) Text( '${d.projectName} | ${d.acctSubjectName}', style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), if (d.remark.isNotEmpty) Text( d.remark, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textPlaceholder, ), ), ], ), ), GestureDetector( onTap: () => setState(() => _details.removeAt(e.key)), 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), Container( height: 36, padding: const EdgeInsets.symmetric(vertical: 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( l10n.get('total'), 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, ), ), ], ), ), ], ); } double _totalAmount() => _details.fold(0, (s, d) => s + d.estimatedAmount); Future _showDetailDialog() async { final l10n = AppLocalizations.of(context); final result = await ExpenseApplyDetailDialog.show( context, categories: mockCostCategories, projects: mockProjects, costDepts: mockCostDepts, l10n: l10n, ); if (result != null && mounted) { setState( () => _details.add( _DetailItem( id: _detailIdCounter++, category: result.category, categoryName: result.categoryName, acctSubjectId: result.acctSubjectId, acctSubjectName: result.acctSubjectName, purpose: result.purpose, projectId: result.projectId, projectName: result.projectName, costDeptId: result.costDeptId, costDeptName: result.costDeptName, startDate: result.startDate, endDate: result.endDate, estimatedAmount: result.estimatedAmount, remark: result.remark, ), ), ); } } // ═══ 3. 附件上传 ═══ Widget _buildAttachmentSection(AppLocalizations l10n) { final colors = Theme.of(context).extension()!; 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); } }, ), ], ); } // ═══ 4. 底部操作栏 ═══ Widget _buildBottomBar(AppLocalizations l10n) { final isDraft = widget.id != null; return ActionBar( leftLabel: isDraft ? l10n.get('reset') : null, centerLabel: l10n.get('saveDraft'), rightLabel: l10n.get('submitApproval'), showLeft: isDraft, onLeftTap: isDraft ? () => _showConfirmDialog( l10n.get('confirmReset'), l10n.get('resetWarning'), l10n.get('cancel'), l10n.get('confirmReset'), _resetAll, ) : null, onCenterTap: () async { await _saveDraftToStorage(); if (mounted) { TDToast.showSuccess(l10n.get('draftSavedToast'), context: context); context.pop(); } }, onRightTap: () async { final err = _validate(l10n); if (err.isNotEmpty) { TDToast.showText(err.first, context: context); return; } await DraftStorage.delete(_draftKey); if (mounted) { TDToast.showSuccess( l10n.get('submittedAwaitingApproval'), context: context, ); context.pop(); } }, ); } List _validate(AppLocalizations l10n) { final e = []; if (_purposeController.text.trim().isEmpty) { e.add(l10n.get('enterFeeReason')); } if (_details.isEmpty) e.add(l10n.get('addAtLeastOneDetail')); return e; } void _resetAll() => setState(() { _purposeController.clear(); _urgency = Urgency.normal.value; _validUntil = ''; _referenceNoController.clear(); _remarkController.clear(); _details.clear(); _attachmentController.clear(); }); void _doPop() { final router = GoRouter.of(context); if (router.canPop()) { router.pop(); } else { SystemNavigator.pop(); } } bool _hasUnsaved() => _purposeController.text.isNotEmpty || _details.isNotEmpty || _attachmentController.files.isNotEmpty || _referenceNoController.text.isNotEmpty || _remarkController.text.isNotEmpty; void _unfocus() => FocusScope.of(context).unfocus(); // ═══ 通用弹窗方法 ═══ void _showConfirmDialog( String title, String content, String leftText, String rightText, VoidCallback onConfirm, ) { _unfocus(); final colors = Theme.of(context).extension()!; showDialog( context: context, 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(); }, ), ), ); } void _showTextInput( String title, Function(String) onConfirm, { String initialText = '', }) { _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); }, ), ), ); } void _pickDate(Function(String) onPick) { _unfocus(); final l10n = AppLocalizations.of(context); final colors = Theme.of(context).extension()!; final theme = Theme.of(context); final now = DateTime.now(); showModalBottomSheet( context: context, backgroundColor: Colors.transparent, builder: (ctx) => Theme( data: theme, child: TDDatePicker( title: l10n.get('selectDate'), backgroundColor: colors.bgCard, model: DatePickerModel( useYear: true, useMonth: true, useDay: true, useHour: false, useMinute: false, useSecond: false, useWeekDay: false, dateStart: [2020, 1, 1], dateEnd: [now.year + 1, 12, 31], dateInitial: [now.year, now.month, now.day], ), onConfirm: (selected) { onPick( '${selected['year']}-${selected['month']!.toString().padLeft(2, '0')}-${selected['day']!.toString().padLeft(2, '0')}', ); Navigator.of(ctx).pop(); }, onCancel: (_) => Navigator.of(ctx).pop(), ), ), ); } 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, ), ), ], ), ); } String _today() { final n = DateTime.now(); return '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}'; } } class _DetailItem { final int id; final String category; final String categoryName; final String acctSubjectId; final String acctSubjectName; final String purpose; final int projectId; final String projectName; final String costDeptId; final String costDeptName; final String startDate; final String endDate; final double estimatedAmount; final String remark; const _DetailItem({ required this.id, required this.category, required this.categoryName, required this.acctSubjectId, required this.acctSubjectName, required this.purpose, required this.projectId, required this.projectName, required this.costDeptId, required this.costDeptName, required this.startDate, required this.endDate, required this.estimatedAmount, required this.remark, }); }