import 'dart:typed_data'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; import 'package:open_filex/open_filex.dart'; import 'widgets/attachment_preview_page.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../shared/widgets/nav_bar_config.dart'; import '../../shared/widgets/loading_dialog.dart'; import '../../core/utils/date_utils.dart' as du; import '../../shared/widgets/form_section.dart'; import '../../shared/widgets/form_field_row.dart'; import 'expense_apply_model.dart'; import '../../core/i18n/app_localizations.dart'; import '../../shared/models/bill_attachment.dart'; import 'expense_apply_api.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors_extension.dart'; class ExpenseApplyDetailPage extends ConsumerStatefulWidget { final String billNo; final int queryId; const ExpenseApplyDetailPage({super.key, required this.billNo, this.queryId = 0}); @override ConsumerState createState() => _ExpenseApplyDetailPageState(); } class _ExpenseApplyDetailPageState extends ConsumerState with WidgetsBindingObserver { bool _loading = true; String? _error; ExpenseApplyModel? _data; List _attachments = []; bool _attachAvailable = false; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _loadData(); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); } bool _openingFile = false; @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { if (_openingFile) { _openingFile = false; return; } _attachments = []; _attachAvailable = false; _loadData(); } } Future _loadData() async { setState(() { _loading = true; _error = null; }); try { final api = ref.read(expenseApplyApiProvider); final detail = await api.fetchDetail(widget.billNo); // Load attachments (non-critical, best-effort) bool attachAvailable = false; try { attachAvailable = await api.checkAttachHealth(); debugPrint('[Attach] checkAttachHealth result: $attachAvailable'); } catch (e) { debugPrint('[Attach] checkAttachHealth error: $e'); attachAvailable = false; } List attachments = []; if (attachAvailable) { try { attachments = await api.getAttachments('AE', widget.billNo); debugPrint('[Attach] getAttachments count: ${attachments.length}'); } catch (e) { debugPrint('[Attach] getAttachments error: $e'); // 附件列表加载失败不影响服务可用状态,保持空列表 } } debugPrint('[Attach] final state: attachAvailable=$attachAvailable, count=${attachments.length}'); if (mounted) { setState(() { _data = detail; _attachments = attachments; _attachAvailable = attachAvailable; _loading = false; }); } } catch (e) { if (mounted) { setState(() { _error = e.toString(); _loading = false; }); } } } @override Widget build(BuildContext context) { final colors = Theme.of(context).extension()!; final l10n = AppLocalizations.of(context); setNavBarTitle(context, ref, NavBarConfig( title: l10n.get('expenseApplyDetail'), showBack: true, onBack: () => context.pop(), )); if (_loading) { return const Center( child: TDLoading(size: TDLoadingSize.large, icon: TDLoadingIcon.activity), ); } if (_error != 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( _error!, textAlign: TextAlign.center, style: TextStyle(fontSize: AppFontSizes.body, color: colors.textSecondary), ), ), const SizedBox(height: 16), TDButton( text: l10n.get('retry'), size: TDButtonSize.medium, onTap: _loadData, ), ], ), ); } final app = _data!; return Expanded( child: SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.all(16), child: Column( children: [ _buildBasicInfoSection(app, l10n, colors), const SizedBox(height: 16), _buildExpenseDetailSection(app, l10n, colors), const SizedBox(height: 16), _buildAttachmentSection(l10n, colors), const SizedBox(height: 24), _buildPageFooter(colors), ], ), ), ); } // ═══ 基本信息 ═══ Widget _buildBasicInfoSection( ExpenseApplyModel app, AppLocalizations l10n, AppColorsExtension colors, ) { return FormSection( title: l10n.get('basicInfo'), leadingIcon: Icons.info_outline, children: [ FormFieldRow(label: l10n.get('expenseApplyNo'), value: app.expenseApplyNo, readOnly: true, showArrow: false), const SizedBox(height: 16), FormFieldRow(label: l10n.get('date'), value: du.DateUtils.formatDate(app.createTime), readOnly: true, showArrow: false), const SizedBox(height: 16), FormFieldRow(label: l10n.get('applicant'), value: app.applicantName, readOnly: true, showArrow: false), const SizedBox(height: 16), FormFieldRow(label: l10n.get('department'), value: app.deptName, readOnly: true, showArrow: false), const SizedBox(height: 16), SizedBox( height: 24, child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(l10n.get('emergencyLevel'), style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textSecondary)), _buildUrgencyChip(app.urgency, l10n, colors), ]), ), const SizedBox(height: 16), FormFieldRow(label: l10n.get('applyReason'), value: app.purpose, readOnly: true, showArrow: false), const SizedBox(height: 16), FormFieldRow(label: l10n.get('remark'), value: app.remark.isNotEmpty ? app.remark : '-', readOnly: true, showArrow: false), ], ); } // ═══ 费用明细 ═══ Widget _buildExpenseDetailSection( ExpenseApplyModel app, AppLocalizations l10n, AppColorsExtension colors, ) { return FormSection( title: l10n.get('expenseDetails'), leadingIcon: Icons.receipt_long_outlined, children: [ if (app.details.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text(l10n.get('noDetailData'), style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)), ) else ...app.details.asMap().entries.map((e) { final d = e.value; final catLabel = d.categoryName.isNotEmpty ? '${d.expenseCategory}/${d.categoryName}' : d.expenseCategory; return Container( margin: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: colors.bgPage, borderRadius: BorderRadius.circular(8)), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Text( catLabel, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textPrimary), ), ), const SizedBox(width: 12), Text( '¥${d.estimatedAmount.toStringAsFixed(2)}', style: TextStyle(fontSize: AppFontSizes.caption, fontWeight: FontWeight.w600, color: colors.amountPrimary), ), ], ), if (d.acctSubjectId.isNotEmpty) ...[ const SizedBox(height: 4), 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.projectId.isNotEmpty && d.projectName.isNotEmpty) ...[ const SizedBox(height: 4), Text( '${d.projectId}/${d.projectName}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary), ), ], if (d.costDeptId.isNotEmpty && d.costDeptName.isNotEmpty) ...[ const SizedBox(height: 4), Text( '${l10n.get('costDept')}: ${d.costDeptId}/${d.costDeptName}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary), ), ], if (d.estimatedStartDate != null) ...[ const SizedBox(height: 4), Text( '${l10n.get('estimatedDate')}: ${du.DateUtils.formatDate(d.estimatedStartDate!)}${d.estimatedEndDate != null ? ' ~ ${du.DateUtils.formatDate(d.estimatedEndDate!)}' : ''}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary), ), ], if (d.bxNo.isNotEmpty) ...[ const SizedBox(height: 4), Text( '${l10n.get('expenseNo')}: $d.bxNo', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary), ), ], if (d.remark.isNotEmpty) ...[ const SizedBox(height: 4), Text( d.remark, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary), ), ], ], ), ), ], ), ); }), if (app.details.isNotEmpty) ...[ const SizedBox(height: 8), Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(l10n.get('total'), style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.textPrimary)), Text('¥${app.estimatedAmount.toStringAsFixed(2)}', style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: colors.amountPrimary)), ]), ], ], ); } // ═══ 附件 ═══ Widget _buildAttachmentSection(AppLocalizations l10n, AppColorsExtension colors) { final children = []; if (!_attachAvailable) { children.add(Text(l10n.get('attachServiceUnavailable'), style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder))); } else if (_attachments.isEmpty) { children.add(Text(l10n.get('noAttachment'), style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder))); } else { children.addAll(_attachments.map((a) => _buildAttachmentRow(a, colors))); } return FormSection( title: l10n.get('attachments'), leadingIcon: Icons.attach_file_outlined, children: children, ); } Widget _buildAttachmentRow(BillAttachment a, AppColorsExtension colors) { final isImage = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].contains(a.ext.toLowerCase()); return GestureDetector( onTap: () => _openAttachment(a), child: Container( margin: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: colors.bgPage, borderRadius: BorderRadius.circular(8), ), child: Row(children: [ if (isImage) _AttachmentThumbnail(api: ref.read(expenseApplyApiProvider), attachment: a, size: 40) else Icon(_fileTypeIcon(a.ext), size: 40, color: colors.primary), const SizedBox(width: 10), Expanded( child: Text(a.fileName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPrimary)), ), ]), ), ); } Future _openAttachment(BillAttachment a) async { final l10n = AppLocalizations.of(context); final ext = a.ext.toLowerCase(); final isImage = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].contains(ext); if (isImage) { // 图片 → 弹窗预览,内部自动下载并显示 loading final api = ref.read(expenseApplyApiProvider); AttachmentPreview.show(context, loader: api.downloadAttachment(a.id), fileName: a.fileName, loadingText: l10n.get('loading'), ); return; } // 非图片 → 下载后调用系统工具打开 try { LoadingDialog.show(context, text: l10n.get('downloading')); final api = ref.read(expenseApplyApiProvider); final bytes = await api.downloadAttachment(a.id); if (!mounted) return; LoadingDialog.hide(context); if (bytes == null) { TDToast.showText(l10n.get('downloadFailed'), context: context); return; } final dir = await getTemporaryDirectory(); final file = File('${dir.path}/${a.fileName}'); await file.writeAsBytes(bytes); _openingFile = true; await OpenFilex.open(file.path); } catch (_) { if (mounted) LoadingDialog.hide(context); if (mounted) TDToast.showText(l10n.get('openFailed'), context: context); } } IconData _fileTypeIcon(String ext) { switch (ext.toLowerCase()) { case 'pdf': return Icons.picture_as_pdf; case 'doc': case 'docx': return Icons.description; case 'xls': case 'xlsx': return Icons.table_chart; case 'jpg': case 'jpeg': case 'png': case 'gif': case 'bmp': return Icons.image_outlined; default: return Icons.insert_drive_file; } } Widget _buildPageFooter(AppColorsExtension colors) { final l10n = AppLocalizations.of(context); 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)), ], ), ), ); } Widget _buildUrgencyChip(String urgency, AppLocalizations l10n, AppColorsExtension colors) { final (label, color) = switch (urgency) { '3' || 'critical' => (l10n.get('critical'), colors.danger), '2' || 'urgent' => (l10n.get('urgent'), colors.warning), '1' || 'normal' => (l10n.get('normal'), colors.primary), _ => (l10n.get('normal'), colors.primary), }; return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), decoration: BoxDecoration( color: color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4), border: Border.all(color: color, width: 0.5), ), child: Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: color)), ); } } /// 附件缩略图 — 自动调用 DownloadAttachment 加载图片 class _AttachmentThumbnail extends StatefulWidget { final ExpenseApplyApi api; final BillAttachment attachment; final double size; const _AttachmentThumbnail({required this.api, required this.attachment, required this.size}); @override State<_AttachmentThumbnail> createState() => _AttachmentThumbnailState(); } class _AttachmentThumbnailState extends State<_AttachmentThumbnail> { Uint8List? _bytes; bool _loading = true; @override void initState() { super.initState(); _load(); } Future _load() async { try { final bytes = await widget.api.downloadAttachment(widget.attachment.id); if (mounted) setState(() { _bytes = bytes; _loading = false; }); } catch (_) { if (mounted) setState(() => _loading = false); } } @override Widget build(BuildContext context) { if (_loading) { return SizedBox(width: widget.size, height: widget.size, child: const Center(child: SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)))); } if (_bytes != null) { return ClipRRect( borderRadius: BorderRadius.circular(4), child: Image.memory(_bytes!, width: widget.size, height: widget.size, fit: BoxFit.cover), ); } return Icon(Icons.broken_image, size: widget.size * 0.6, color: Colors.grey); } }