import 'package:flutter/material.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/data/mock_api_data.dart'; import 'widgets/expense_detail_dialog.dart'; import 'package:image_picker/image_picker.dart'; class ExpenseApplyPage extends ConsumerStatefulWidget { final String? editId; const ExpenseApplyPage({super.key, this.editId}); @override ConsumerState createState() => _ExpenseApplyPageState(); } class _ExpenseApplyPageState extends ConsumerState { final _purposeController = TextEditingController(); final List _attachments = []; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) => _checkDraft()); } Future _checkDraft() async { if (!mounted || widget.editId != null) return; final has = await ExpenseCreateController.hasDraft(); 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) { final draft = await ExpenseCreateController.loadDraft(); if (draft != null && mounted) { final api = ref.read(expenseApiProvider); ref.read(expenseCreateProvider(widget.editId).notifier) ..restoreFromDraft(draft, api); } } else { await ExpenseCreateController.deleteDraft(); } } @override void dispose() { _purposeController.dispose(); super.dispose(); } @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: () => context.pop(), ), ); final colors = Theme.of(context).extension()!; final bottomInset = MediaQuery.of(context).padding.bottom; return Column( children: [ Expanded( child: Align( alignment: Alignment.topCenter, child: ConstrainedBox( constraints: BoxConstraints(maxWidth: r.formMaxWidth), child: SingleChildScrollView( 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), _buildInvoiceSection(controller, state), ], ), ), ), ), ), ColoredBox( color: colors.bgCard, child: Column( mainAxisSize: MainAxisSize.min, children: [ _buildBottomButtons(controller, state), if (bottomInset > 0) SizedBox(height: bottomInset), ], ), ), ], ); } Widget _buildImportLink() { final colors = Theme.of(context).extension()!; final l10n = AppLocalizations.of(context); return GestureDetector( onTap: () { TDToast.showText(l10n.get('expenseApplyImport'), 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 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('reportNo'), value: expense.expenseNo.isNotEmpty ? expense.expenseNo : null, hint: l10n.get('autoGenerated'), readOnly: true, showArrow: false, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('currency'), value: expense.currencyCode.isNotEmpty ? expense.currencyCode : 'CNY', hint: l10n.get('selectCurrency'), onTap: () => _showCurrencyPicker(controller, expense.currencyCode), ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('applicant'), value: expense.applicantName.isNotEmpty ? expense.applicantName : '张三', readOnly: true, showArrow: false, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('department'), value: expense.deptName.isNotEmpty ? expense.deptName : '技术部', readOnly: true, showArrow: false, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('paymentMethod'), value: expense.paymentMethod.isNotEmpty ? expense.paymentMethod : null, hint: l10n.get('selectPaymentMethod'), onTap: () => _showPaymentMethodPicker(controller), ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('expenseReason'), value: _purposeController.text.isNotEmpty ? _purposeController.text : null, hint: l10n.get('enterExpenseReason'), onTap: () => _showTextInput( l10n.get('expenseReason'), l10n.get('enterExpenseReason'), (v) { setState(() { _purposeController.text = v; _purposeController.selection = TextSelection.fromPosition( TextPosition(offset: v.length), ); }); controller.updatePurpose(v); }, initialText: _purposeController.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: true, 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 Container( margin: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: colors.bgPage, borderRadius: BorderRadius.circular(8)), child: Stack( children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded(child: Text(d.purpose.isNotEmpty ? d.purpose : d.expenseCategory, style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textPrimary))), Text('¥${d.totalAmount.toStringAsFixed(2)}', style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: colors.amountPrimary)), ], ), const SizedBox(height: 4), Text('¥${d.amount.toStringAsFixed(2)} + 税${d.taxAmount.toStringAsFixed(2)} | ${d.bankName}', style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)), ], ), Positioned( right: 0, top: 0, child: 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 _buildInvoiceSection(ExpenseCreateController controller, ExpenseCreateState state) { final colors = Theme.of(context).extension()!; final l10n = AppLocalizations.of(context); return FormSection( title: l10n.get('invoiceUpload'), leadingIcon: Icons.attach_file_outlined, children: [ Text(l10n.get('maxInvoices'), style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPlaceholder)), const SizedBox(height: 8), if (_attachments.isEmpty) Text(l10n.get('noAttachment'), style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textPlaceholder)) else Wrap( spacing: 8, runSpacing: 8, children: [ ..._attachments.asMap().entries.map((entry) { final i = entry.key; final path = entry.value; final name = path.split('/').last.split('\\').last; return SizedBox( width: 80, child: Stack( children: [ Container( width: 80, height: 80, decoration: BoxDecoration( color: colors.bgPage, borderRadius: BorderRadius.circular(4), border: Border.all(color: colors.border), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.insert_drive_file, size: 28, color: colors.primary), const SizedBox(height: 2), Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: Text( name, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 10, color: colors.textSecondary), textAlign: TextAlign.center, ), ), ], ), ), Positioned( right: 0, top: 0, child: GestureDetector( onTap: () => setState(() => _attachments.removeAt(i)), child: Container( width: 20, height: 20, decoration: BoxDecoration( color: colors.bgCard, shape: BoxShape.circle, border: Border.all(color: colors.border), ), child: Icon(Icons.close, size: 12, color: colors.textSecondary), ), ), ), ], ), ); }), if (_attachments.length < 9) GestureDetector( onTap: _pickFiles, child: Container( width: 80, height: 80, decoration: BoxDecoration( color: colors.bgPage, borderRadius: BorderRadius.circular(4), border: Border.all(color: colors.border), ), child: Center(child: Icon(Icons.add, size: 24, color: colors.textPlaceholder)), ), ), ], ), ], ); } Future _pickFiles() async { final available = 9 - _attachments.length; if (available <= 0) return; final picker = ImagePicker(); final images = await picker.pickMultiImage(limit: available); if (images.isEmpty) return; setState(() { _attachments.addAll(images.map((img) => img.path)); if (_attachments.length > 9) _attachments.length = 9; }); } Widget _buildBottomButtons(ExpenseCreateController controller, ExpenseCreateState state) { final colors = Theme.of(context).extension()!; final l10n = AppLocalizations.of(context); return Container(height: 72, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), decoration: BoxDecoration(color: colors.bgCard), child: Row(children: [ _btn(l10n.get('saveDraftShort'), colors.textSecondary, colors.bgCard, () async { if (state.isSubmitting) return; final ok = await controller.saveDraft(); if (mounted && ok) TDToast.showText(l10n.get('draftSavedToast'), context: context); }), const SizedBox(width: 12), _btn(l10n.get('reset'), colors.textSecondary, colors.bgCard, () => setState(() => _purposeController.clear())), const SizedBox(width: 12), _btn(l10n.get('save'), colors.bgCard, colors.primary, () async { if (state.isSubmitting) return; await controller.saveDraft(); if (mounted) context.pop(); }), ])); } Widget _btn(String label, Color fg, Color bg, VoidCallback onTap) { return Expanded(child: SizedBox(height: 40, child: Material(color: bg, borderRadius: BorderRadius.circular(22), child: InkWell(onTap: onTap, borderRadius: BorderRadius.circular(22), child: Center(child: Text(label, style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w500, color: fg))))))); } Future _showAddDetailDialog(ExpenseCreateController controller) async { final l10n = AppLocalizations.of(context); final result = await ExpenseDetailDialog.show(context, categories: mockCostCategories, l10n: l10n); if (result != null && mounted) { final now = DateTime.now(); controller.addDetail(ExpenseDetailModel( id: now.millisecondsSinceEpoch.toString(), expenseId: '', expenseCategory: result.category, purpose: result.purpose, amount: result.amount, taxRate: result.taxRate, totalAmount: result.amount, customerVendorName: result.customerVendorName, offsetAmount: result.offsetAmount, remark: result.remark, createTime: now, updateTime: now, )); controller.recalculateAmount(); } } void _showCurrencyPicker(ExpenseCreateController controller, String cur) { final l10n = AppLocalizations.of(context); final colors = Theme.of(context).extension()!; const cs = ['CNY', 'USD', 'EUR', 'JPY', 'HKD', 'GBP']; TDPicker.showMultiPicker(context, title: l10n.get('selectCurrency'), backgroundColor: colors.bgCard, data: [cs], onConfirm: (s) { if (s.isNotEmpty && s[0] is int) { final i = s[0] as int; if (i >= 0 && i < cs.length) { Navigator.of(context).pop(); controller.updateCurrencyCode(cs[i]); } } }); } void _showPaymentMethodPicker(ExpenseCreateController controller) { final l10n = AppLocalizations.of(context); final colors = Theme.of(context).extension()!; const ms = ['bankTransfer', 'cash', 'check', 'alipay', 'wechat']; TDPicker.showMultiPicker(context, title: l10n.get('selectPaymentMethod'), backgroundColor: colors.bgCard, data: [ms], onConfirm: (s) { if (s.isNotEmpty && s[0] is int) { final i = s[0] as int; if (i >= 0 && i < ms.length) { Navigator.of(context).pop(); TDToast.showText(ms[i], context: context); } } }); } void _showTextInput(String title, String hint, void Function(String) onConfirm, {String initialText = ''}) { final l10n = AppLocalizations.of(context); final c = TextEditingController(text: initialText); showGeneralDialog(context: context, pageBuilder: (ctx, _, __) => TDInputDialog( textEditingController: c, title: title, hintText: hint, leftBtn: TDDialogButtonOptions(title: l10n.get('cancel'), action: () => Navigator.pop(ctx)), rightBtn: TDDialogButtonOptions(title: l10n.get('confirm'), action: () { onConfirm(c.text); Navigator.pop(ctx); }))); } String _today() { final n = DateTime.now(); return '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}'; } }