import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; import '../shell/nav_bar_config.dart'; import '../../core/utils/date_utils.dart' as du; import '../../shared/widgets/status_banner.dart'; import '../../shared/widgets/approval_timeline.dart'; import '../../shared/models/approval_status.dart'; import 'vehicle_model.dart'; import '../../core/i18n/app_localizations.dart'; import 'vehicle_list_controller.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors_extension.dart'; class VehicleDetailPage extends ConsumerStatefulWidget { final String id; const VehicleDetailPage({super.key, required this.id}); @override ConsumerState createState() => _VehicleDetailPageState(); } class _VehicleDetailPageState extends ConsumerState { // Return registration fields final _startOdometerCtrl = TextEditingController(); final _endOdometerCtrl = TextEditingController(); final _actualCostCtrl = TextEditingController(); final _costRemarkCtrl = TextEditingController(); DateTime? _actualReturnTime; bool _isSubmittingReturn = false; String _purposeLabel(String key) { final l10n = AppLocalizations.of(context); switch (key) { case 'reception': return l10n.get('customerReception'); case 'business': return l10n.get('businessTrip'); case 'official': return l10n.get('official'); default: return key; } } @override void dispose() { _startOdometerCtrl.dispose(); _endOdometerCtrl.dispose(); _actualCostCtrl.dispose(); _costRemarkCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final colors = Theme.of(context).extension()!; final vehicle = mockVehicles.firstWhere( (e) => e.id == widget.id, orElse: () => mockVehicles.first, ); final l10n = AppLocalizations.of(context); ref .read(navBarConfigProvider.notifier) .update( NavBarConfig( title: l10n.get('vehicleDetail'), showBack: true, onBack: () => context.pop(), ), ); final (icon, color, statusText) = _statusProps(vehicle.status); return Column( children: [ Expanded( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ StatusBanner( icon: icon, statusText: statusText, subText: _statusSubText(vehicle), color: color, ), const SizedBox(height: 8), Text( '${l10n.get('submitTimeText')}:${du.DateUtils.formatDateTime(vehicle.createTime)}', style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), const SizedBox(height: 16), _buildInfoSection(vehicle), const SizedBox(height: 16), _buildMapSection(vehicle), const SizedBox(height: 16), // Return registration (only for approved) if (vehicle.status == 'approved') _buildReturnRegistration(vehicle), const SizedBox(height: 16), _buildApprovalTimeline(vehicle), ], ), ), ), _buildActionBar(context, vehicle), ], ); } Widget _buildInfoSection(VehicleModel vehicle) { final colors = Theme.of(context).extension()!; final l10n = AppLocalizations.of(context); return Container( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), decoration: BoxDecoration( color: colors.bgCard, borderRadius: BorderRadius.circular(8), ), child: Column( children: [ _infoRow(l10n.get('applicant'), vehicle.applicantName), _infoRow(l10n.get('department'), vehicle.deptName), _infoRow( l10n.get('licensePlate'), vehicle.vehicleId.isNotEmpty ? vehicle.vehicleId : l10n.get('noVehicle'), ), _infoRow(l10n.get('vehiclePurpose'), _purposeLabel(vehicle.purpose)), _infoRow(l10n.get('origin'), vehicle.origin.isNotEmpty ? vehicle.origin : l10n.get('unknown')), _infoRow( l10n.get('destination'), vehicle.destination.isNotEmpty ? vehicle.destination : l10n.get('unknown'), ), _infoRow(l10n.get('departTime'), du.DateUtils.formatDateTime(vehicle.startTime)), _infoRow(l10n.get('returnTime'), du.DateUtils.formatDateTime(vehicle.endTime)), _infoRow(l10n.get('passengerCount'), '${vehicle.passengerCount}${l10n.get('personUnit')}'), ], ), ); } Widget _infoRow(String label, String value) { final colors = Theme.of(context).extension()!; return Container( height: 44, padding: const EdgeInsets.symmetric(vertical: 0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( label, style: TextStyle( fontSize: AppFontSizes.body, color: colors.textSecondary, ), ), Flexible( child: Text( value, style: TextStyle( fontSize: AppFontSizes.body, color: colors.textPrimary, ), textAlign: TextAlign.right, overflow: TextOverflow.ellipsis, ), ), ], ), ); } Widget _buildMapSection(VehicleModel vehicle) { final colors = Theme.of(context).extension()!; final l10n = AppLocalizations.of(context); return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: colors.bgCard, borderRadius: BorderRadius.circular(8), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.get('tripRoute'), style: TextStyle( fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: colors.textPrimary, ), ), const SizedBox(height: 8), Container( height: 160, width: double.infinity, decoration: BoxDecoration( color: colors.infoLightBg, borderRadius: BorderRadius.circular(8), ), child: Stack( children: [ Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.location_on, color: colors.success, size: 20, ), const SizedBox(width: 4), Text( vehicle.origin.isNotEmpty ? vehicle.origin : l10n.get('origin'), style: TextStyle( fontSize: 13, color: colors.textPrimary, ), ), ], ), Padding( padding: EdgeInsets.symmetric(vertical: 4), child: Icon( Icons.arrow_downward, color: colors.primary, size: 18, ), ), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.location_on, color: colors.danger, size: 20, ), const SizedBox(width: 4), Text( vehicle.destination.isNotEmpty ? vehicle.destination : l10n.get('destination'), style: TextStyle( fontSize: 13, color: colors.textPrimary, ), ), ], ), ], ), ), Positioned( bottom: 8, right: 8, child: GestureDetector( onTap: () { TDToast.showText(l10n.get('navigationComingSoon'), context: context); }, child: Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 4, ), decoration: BoxDecoration( color: colors.primary, borderRadius: BorderRadius.circular(12), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.navigation, size: 14, color: Colors.white), const SizedBox(width: 4), Text( l10n.get('navigation'), style: const TextStyle(fontSize: 12, color: Colors.white), ), ], ), ), ), ), ], ), ), ], ), ); } Widget _buildReturnRegistration(VehicleModel vehicle) { final colors = Theme.of(context).extension()!; final l10n = AppLocalizations.of(context); final isEndOdometerValid = _endOdometerCtrl.text.isNotEmpty && (double.tryParse(_endOdometerCtrl.text) ?? 0) >= (double.tryParse(_startOdometerCtrl.text) ?? 0); return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: colors.bgCard, borderRadius: BorderRadius.circular(8), border: Border.all(color: colors.primary.withValues(alpha: 0.3)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.drive_eta, size: 20, color: colors.primary), const SizedBox(width: 8), Text( l10n.get('returnCarRegister'), style: TextStyle( fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: colors.textPrimary, ), ), ], ), const SizedBox(height: 16), // 实还时间 GestureDetector( onTap: () => _pickReturnDateTime(vehicle), child: Container( height: 44, padding: const EdgeInsets.symmetric(horizontal: 0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( l10n.get('actualReturnTime'), style: TextStyle(fontSize: 14, color: colors.textSecondary), ), Text( _actualReturnTime != null ? du.DateUtils.formatDateTime(_actualReturnTime!) : l10n.get('pleaseSelect'), style: TextStyle( fontSize: 14, color: _actualReturnTime != null ? colors.textPrimary : colors.textPlaceholder, ), ), ], ), ), ), if (_actualReturnTime != null) Padding( padding: const EdgeInsets.only(bottom: 8), child: Text( _actualReturnTime!.isBefore(vehicle.endTime) ? l10n.get('earlyReturn') : _actualReturnTime!.isAfter(vehicle.endTime) ? l10n.get('overReturnTime') : '', style: TextStyle( fontSize: AppFontSizes.caption, color: _actualReturnTime!.isAfter(vehicle.endTime) ? colors.danger : colors.textSecondary, ), ), ), const SizedBox(height: 8), // 出车前里程 TDInput( controller: _startOdometerCtrl, hintText: l10n.get('mileageBefore'), inputType: TextInputType.number, onChanged: (v) => setState(() {}), ), const SizedBox(height: 8), // 还车后里程 TDInput( controller: _endOdometerCtrl, hintText: l10n.get('mileageAfter'), inputType: TextInputType.number, onChanged: (v) => setState(() {}), ), if (_endOdometerCtrl.text.isNotEmpty && !isEndOdometerValid) Padding( padding: EdgeInsets.only(top: 4), child: Text( l10n.get('mileageInvalid'), style: TextStyle( fontSize: AppFontSizes.caption, color: colors.danger, ), ), ), const SizedBox(height: 8), // 实际费用 TDInput( controller: _actualCostCtrl, hintText: l10n.get('actualCost'), inputType: TextInputType.number, onChanged: (v) => setState(() {}), ), const SizedBox(height: 8), // 费用备注 TDInput( controller: _costRemarkCtrl, hintText: l10n.get('costRemarkLabel'), inputType: TextInputType.text, onChanged: (v) => setState(() {}), ), const SizedBox(height: 16), // 确认提交按钮 SizedBox( width: double.infinity, height: 40, child: Material( color: _canSubmitReturn(isEndOdometerValid) ? colors.primary : colors.textPlaceholder, borderRadius: BorderRadius.circular(22), child: InkWell( onTap: _canSubmitReturn(isEndOdometerValid) && !_isSubmittingReturn ? _submitReturn : null, borderRadius: BorderRadius.circular(22), child: Center( child: Text( l10n.get('confirmReturnCar'), style: TextStyle( fontSize: AppFontSizes.body, fontWeight: FontWeight.w500, color: Colors.white, ), ), ), ), ), ), ], ), ); } bool _canSubmitReturn(bool isEndOdometerValid) { return _actualReturnTime != null && _startOdometerCtrl.text.isNotEmpty && _endOdometerCtrl.text.isNotEmpty && isEndOdometerValid; } void _submitReturn() { final l10n = AppLocalizations.of(context); showDialog( context: context, builder: (ctx) => TDAlertDialog( title: l10n.get('confirmSubmit'), contentWidget: Text(l10n.get('submitConfirmContent')), leftBtn: TDDialogButtonOptions( title: l10n.get('cancel'), action: () => Navigator.pop(ctx), ), rightBtn: TDDialogButtonOptions( title: l10n.get('confirm'), theme: TDButtonTheme.primary, action: () { Navigator.pop(ctx); setState(() => _isSubmittingReturn = true); TDToast.showText(l10n.get('returnCarSubmitted'), context: context); setState(() => _isSubmittingReturn = false); }, ), ), ); } Widget _buildApprovalTimeline(VehicleModel vehicle) { final colors = Theme.of(context).extension()!; final mockRecords = _generateMockRecords(vehicle); final mockChain = ['u-mgr']; return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: colors.bgCard, borderRadius: BorderRadius.circular(8), ), child: ApprovalTimeline( records: mockRecords, chain: mockChain, currentApproverId: vehicle.status == 'pending' ? 'u-mgr' : '', ), ); } List _generateMockRecords(VehicleModel v) { if (v.status == 'draft' || v.status == 'withdrawn') return []; final records = [ ApprovalRecord( id: 'ar-${v.id}-init', bizId: v.id, bizType: 'vehicle', approverId: 'u-init', approverName: '系统', approvalLevel: 0, action: 'approve', opinion: '发起申请', approvalTime: v.createTime, ), ]; if (v.status == 'approved' || v.status == 'returned') { records.add( ApprovalRecord( id: 'ar-${v.id}-mgr', bizId: v.id, bizType: 'vehicle', approverId: 'u-mgr', approverName: '李四(经理)', approvalLevel: 1, action: 'approve', opinion: '同意', approvalTime: v.updateTime, ), ); } else if (v.status == 'rejected') { records.add( ApprovalRecord( id: 'ar-${v.id}-mgr', bizId: v.id, bizType: 'vehicle', approverId: 'u-mgr', approverName: '李四(经理)', approvalLevel: 1, action: 'reject', opinion: '请提供更详细的事由', approvalTime: v.updateTime, ), ); } return records; } void _pickReturnDateTime(VehicleModel vehicle) { final l10n = AppLocalizations.of(context); TDPicker.showDatePicker( context, title: l10n.get('selectReturnTime'), useYear: true, useMonth: true, useDay: true, useHour: true, useMinute: true, initialDate: [ DateTime.now().year, DateTime.now().month, DateTime.now().day, DateTime.now().hour, DateTime.now().minute, ], onConfirm: (selected) { setState(() { _actualReturnTime = DateTime( selected['year']!, selected['month']!, selected['day']!, selected['hour']!, selected['minute']!, ); }); }, ); } Widget _buildActionBar(BuildContext context, VehicleModel vehicle) { final colors = Theme.of(context).extension()!; final l10n = AppLocalizations.of(context); Widget? actionButton; if (vehicle.status == 'draft') { actionButton = _singleButton(l10n.get('edit'), colors.primary, () { context.push('/vehicle/apply', extra: vehicle.id); }); } else if (vehicle.status == 'pending') { actionButton = _singleButton(l10n.get('withdrawApplication'), colors.primary, () { TDToast.showText(l10n.get('withdrawn'), context: context); }); } else if (vehicle.status == 'rejected') { actionButton = _singleButton(l10n.get('reEdit'), colors.primary, () { context.push('/vehicle/apply', extra: vehicle.id); }); } else if (vehicle.status == 'returned') { return Container( height: 72, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), decoration: BoxDecoration(color: colors.bgCard), child: Center( child: Text( l10n.getString('returnCarArchivedAt', args: {'time': vehicle.actualReturnTime != null ? du.DateUtils.formatDateTime(vehicle.actualReturnTime!) : ''}), style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), ), ); } if (actionButton == null) return const SizedBox.shrink(); return Container( height: 72, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), decoration: BoxDecoration(color: colors.bgCard), child: Row(children: [const Spacer(), actionButton, const Spacer()]), ); } Widget _singleButton(String label, Color color, VoidCallback onTap) { return SizedBox( height: 40, child: Material( color: color, borderRadius: BorderRadius.circular(22), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(22), child: Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: Text( label, style: const TextStyle( fontSize: AppFontSizes.body, fontWeight: FontWeight.w500, color: Colors.white, ), ), ), ), ), ), ); } (IconData, Color, String) _statusProps(String status) { final colors = Theme.of(context).extension()!; final l10n = AppLocalizations.of(context); switch (status) { case 'approved': return (Icons.check_circle, colors.success, l10n.get('approved')); case 'rejected': return (Icons.cancel, colors.danger, l10n.get('rejected')); case 'draft': return (Icons.edit_note, colors.statusGray, l10n.get('draft')); case 'withdrawn': return (Icons.cancel_outlined, colors.revokedText, l10n.get('revoked')); case 'returned': return (Icons.assignment_return, colors.primary, l10n.get('returned')); default: return (Icons.access_time, colors.warning, l10n.get('pending')); } } String _statusSubText(VehicleModel vehicle) { switch (vehicle.status) { case 'pending': return vehicle.approvalInstanceId.isNotEmpty ? '审批中,请耐心等待' : '等待审批'; case 'approved': return '已通过,请按时出车'; case 'returned': return vehicle.actualReturnTime != null ? '还车时间:${du.DateUtils.formatDateTime(vehicle.actualReturnTime!)}' : '已还车归档'; default: return ''; } } }