import 'package:flutter/material.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 '../../core/utils/date_utils.dart' as du; import '../../shared/widgets/form_section.dart'; import '../../shared/widgets/form_field_row.dart'; import '../../shared/widgets/status_banner.dart'; import '../../shared/widgets/action_bar.dart'; import 'expense_apply_model.dart'; import '../../core/i18n/app_localizations.dart'; import 'expense_apply_list_controller.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors_extension.dart'; class ExpenseApplyDetailPage extends ConsumerWidget { final String id; const ExpenseApplyDetailPage({super.key, required this.id}); @override Widget build(BuildContext context, WidgetRef ref) { final colors = Theme.of(context).extension()!; final app = mockExpenseApplies.firstWhere( (e) => e.id == id, orElse: () => mockExpenseApplies.first, ); final l10n = AppLocalizations.of(context); ref .read(navBarConfigProvider.notifier) .update( NavBarConfig( title: l10n.get('expenseApplyDetail'), showBack: true, onBack: () => context.pop(), ), ); return Column( children: [ Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( children: [ _buildStatusBanner(context, app, colors), const SizedBox(height: 8), _buildSubmitTime(context, app, colors), const SizedBox(height: 16), _buildBasicInfoSection(app, l10n, colors), const SizedBox(height: 16), _buildExpenseDetailSection(app, l10n, colors), const SizedBox(height: 16), _buildAttachmentSection(app, l10n, colors), const SizedBox(height: 16), _buildApprovalSection(l10n, colors), ], ), ), ), _buildBottomBar(context, app), ], ); } Widget _buildStatusBanner( BuildContext context, ExpenseApplyModel app, AppColorsExtension colors, ) { final l10n = AppLocalizations.of(context); final (icon, color, label) = switch (app.status) { 'approved' => (Icons.check_circle, colors.success, l10n.get('approved')), 'rejected' => (Icons.cancel, colors.danger, l10n.get('rejected')), 'draft' => (Icons.edit, colors.statusGray, l10n.get('draft')), _ => (Icons.schedule, colors.warning, l10n.get('pending')), }; final approverText = switch (app.status) { 'approved' when app.approvalRecords.isNotEmpty => '${l10n.get('approver')}:${app.approvalRecords.last.approverName}', 'rejected' when app.approvalRecords.isNotEmpty => '${l10n.get('rejecter')}:${app.approvalRecords.last.approverName}', 'pending' when app.currentApproverId.isNotEmpty => '${l10n.get('currentApprover')}:${app.currentApproverId}', _ => '', }; return StatusBanner( icon: icon, statusText: label, subText: approverText, color: color, ); } Widget _buildSubmitTime( BuildContext context, ExpenseApplyModel app, AppColorsExtension colors, ) { final l10n = AppLocalizations.of(context); return Padding( padding: const EdgeInsets.only(left: 4), child: Text( '${l10n.get('submitTimeText')}:${du.DateUtils.formatDateTime(app.createTime)}', style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary), ), ); } // ═══ 基本信息 ═══ Widget _buildBasicInfoSection( ExpenseApplyModel app, AppLocalizations l10n, AppColorsExtension colors, ) { String urgencyLabel = switch (app.urgency) { 'urgent' => l10n.get('urgent'), 'normal' => l10n.get('normal'), 'critical' => l10n.get('critical'), _ => app.urgency, }; String usageLabel = switch (app.usageStatus) { 'unused' => l10n.get('unused'), 'partially_used' => l10n.get('partiallyUsed'), 'fully_used' => l10n.get('fullyUsed'), _ => app.usageStatus, }; 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('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), FormFieldRow(label: l10n.get('date'), value: du.DateUtils.formatDateTime(app.createTime), 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)), Text(urgencyLabel, style: TextStyle( fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: app.urgency == 'urgent' || app.urgency == 'critical' ? colors.danger : colors.textPrimary, )), ]), ), const SizedBox(height: 16), FormFieldRow(label: l10n.get('feeReason'), value: app.purpose, readOnly: true, showArrow: false), const SizedBox(height: 16), FormFieldRow(label: l10n.get('validUntil'), value: app.validUntil != null ? du.DateUtils.formatDate(app.validUntil!) : '-', readOnly: true, showArrow: false), const SizedBox(height: 16), FormFieldRow(label: l10n.get('relatedContractNo'), value: app.referenceNo.isNotEmpty ? app.referenceNo : '-', readOnly: true, showArrow: false), const SizedBox(height: 16), FormFieldRow(label: l10n.get('usageStatus'), value: usageLabel, readOnly: true, showArrow: false), const SizedBox(height: 16), FormFieldRow(label: l10n.get('remark'), value: app.remark.isNotEmpty ? app.remark : '-', readOnly: true, showArrow: false), ], ); } // ═══ 费用明细 — 对应 create 页 _buildDetailsSection ═══ 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; return Container( margin: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: colors.bgPage, borderRadius: BorderRadius.circular(8)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Text(d.purpose.isNotEmpty ? d.purpose : d.expenseCategory, style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: colors.textPrimary)), ), const SizedBox(width: 16), Text('¥${d.estimatedAmount.toStringAsFixed(2)}', style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: colors.amountPrimary)), ]), if (d.expenseCategory.isNotEmpty && d.purpose != d.expenseCategory) ...[ const SizedBox(height: 4), Text(d.expenseCategory, style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)), ], if (d.projectName.isNotEmpty) ...[ const SizedBox(height: 4), Text('${l10n.get('relatedProject')}:${d.projectName} | ${l10n.get('budgetSubject')}:${d.acctSubjectName}', style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)), ], if (d.costDeptName.isNotEmpty) ...[ const SizedBox(height: 4), Text('${l10n.get('costDept')}:${d.costDeptName}', style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)), ], if (d.estimatedStartDate != null) ...[ const SizedBox(height: 4), Text('${du.DateUtils.formatDate(d.estimatedStartDate!)} ~ ${d.estimatedEndDate != null ? du.DateUtils.formatDate(d.estimatedEndDate!) : ''}', 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(ExpenseApplyModel app, AppLocalizations l10n, AppColorsExtension colors) { return FormSection( title: l10n.get('attachments'), leadingIcon: Icons.attach_file_outlined, children: [ if (app.attachments.isEmpty) Text(l10n.get('noAttachment'), style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)) else Wrap(spacing: 8, runSpacing: 8, children: app.attachments.map((a) { return 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.image_outlined, size: 24, color: colors.textPlaceholder))); }).toList()), ], ); } // ═══ 审核流程 — 时间线组件 ═══ Widget _buildApprovalSection(AppLocalizations l10n, AppColorsExtension colors) { final steps = <({String title, String desc, String? time, IconData icon, Color iconColor})>[ (title: l10n.get('approvalStepSubmitted'), desc: l10n.get('approvalDescSubmitted'), time: '2026-06-29 09:15', icon: Icons.check_circle, iconColor: colors.success), (title: l10n.get('approvalStepApproved'), desc: l10n.get('approvalDescApproved'), time: '2026-06-29 14:30', icon: Icons.check_circle, iconColor: colors.success), (title: l10n.get('approvalStepFinanceReview'), desc: l10n.get('approvalDescFinanceReview'), time: null, icon: Icons.schedule, iconColor: colors.warning), (title: l10n.get('approvalStepArchive'), desc: l10n.get('approvalStepArchiveDesc'), time: null, icon: Icons.hourglass_empty, iconColor: colors.textPlaceholder), ]; return FormSection( title: l10n.get('approvalFlow'), leadingIcon: Icons.fact_check_outlined, children: [ ...steps.asMap().entries.map((e) { final s = e.value; final isLast = e.key == steps.length - 1; final isActive = e.key <= 2; final iconColor = isActive ? s.iconColor : colors.textPlaceholder; final textColor = isActive ? colors.textPrimary : colors.textPlaceholder; final subColor = isActive ? colors.textSecondary : colors.textPlaceholder; return IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 24, child: Column( children: [ Container( width: 24, height: 24, decoration: BoxDecoration( color: isActive ? s.iconColor.withAlpha(30) : colors.bgDisabled, shape: BoxShape.circle, ), child: Icon(s.icon, size: 14, color: iconColor), ), if (!isLast) Expanded( child: Container( width: 2, margin: const EdgeInsets.symmetric(vertical: 4), color: isActive ? s.iconColor.withAlpha(60) : colors.border, ), ), ], ), ), const SizedBox(width: 12), Expanded( child: Padding( padding: EdgeInsets.only(bottom: isLast ? 0 : 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(s.title, style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: textColor)), const SizedBox(height: 4), Text(s.desc, style: TextStyle(fontSize: AppFontSizes.caption, color: subColor)), if (s.time != null) ...[ const SizedBox(height: 2), Text(s.time!, style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPlaceholder)), ], ], ), ), ), ], ), ); }), ], ); } Widget _buildBottomBar(BuildContext context, ExpenseApplyModel app) { final l10n = AppLocalizations.of(context); if (app.status != 'pending' && app.status != 'draft') return const SizedBox.shrink(); return ActionBar( showLeft: false, centerLabel: l10n.get('withdrawApplication'), rightLabel: l10n.get('submitApproval'), onCenterTap: () { TDToast.showText(l10n.get('withdrawn'), context: context); context.pop(); }, onRightTap: null, ); } }