| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490 |
- import 'package:flutter/material.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/theme/app_colors.dart';
- import '../../core/theme/app_colors_extension.dart';
- import '../../core/utils/amount_utils.dart';
- import '../../core/utils/date_utils.dart' as du;
- import '../../shared/widgets/app_skeletons.dart';
- import '../../shared/widgets/form_field_row.dart';
- import '../../shared/widgets/form_section.dart';
- import 'vehicle_api.dart';
- import 'vehicle_list_controller.dart';
- import 'vehicle_model.dart';
- class VehicleDetailPage extends ConsumerStatefulWidget {
- final String billNo;
- const VehicleDetailPage({super.key, required this.billNo});
- @override
- ConsumerState<VehicleDetailPage> createState() => _VehicleDetailPageState();
- }
- class _VehicleDetailPageState extends ConsumerState<VehicleDetailPage> {
- bool _loading = true;
- String? _error;
- VehicleModel? _data;
- // ── 还车登记字段 ──
- DateTime? _returnTime;
- final _odometerController = TextEditingController();
- final _amtnController = TextEditingController();
- final _remarkController = TextEditingController();
- bool _isSubmittingReturn = false;
- @override
- void initState() {
- super.initState();
- _loadData();
- }
- @override
- void dispose() {
- _odometerController.dispose();
- _amtnController.dispose();
- _remarkController.dispose();
- super.dispose();
- }
- Future<void> _loadData() async {
- setState(() { _loading = true; _error = null; });
- try {
- // 先用 mock 数据匹配 billNo
- final match = mockVehicles.where((e) => e.ycNo == widget.billNo);
- if (match.isNotEmpty) {
- if (mounted) {
- setState(() { _data = match.first; _loading = false; });
- }
- } else {
- // 尝试 API
- final api = ref.read(vehicleApiProvider);
- final detail = await api.fetchDetail(widget.billNo);
- if (mounted) setState(() { _data = detail; _loading = false; });
- }
- // 单据状态
- try {
- final api = ref.read(vehicleApiProvider);
- await api.getBillStatus(widget.billNo);
- } catch (_) { /* 非关键 */ }
- } catch (e) {
- if (mounted) setState(() { _error = e.toString(); _loading = false; });
- }
- }
- @override
- Widget build(BuildContext context) {
- final colors = Theme.of(context).extension<AppColorsExtension>()!;
- final l10n = AppLocalizations.of(context);
- if (_loading) return const SkeletonDetailPage();
- 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 Column(
- children: [
- Expanded(
- child: SingleChildScrollView(
- physics: const AlwaysScrollableScrollPhysics(),
- padding: const EdgeInsets.all(16),
- child: Column(
- children: [
- _buildBasicInfoSection(app, l10n, colors),
- const SizedBox(height: 16),
- _buildVehicleInfoSection(app, l10n, colors),
- if (app.isReturn)
- _buildReturnedInfoSection(app, l10n, colors)
- else if (app.isAuditApproved)
- _buildReturnRegistrationSection(l10n, colors),
- const SizedBox(height: 24),
- _buildPageFooter(colors),
- ],
- ),
- ),
- ),
- _buildActionBar(app, l10n, colors),
- ],
- );
- }
- // ═══ 状态 tag ═══
- Widget _buildStatusTag(AppLocalizations l10n) {
- final colors = Theme.of(context).extension<AppColorsExtension>()!;
- final approved = _data?.isAuditApproved ?? false;
- final returned = _data?.isReturn ?? false;
- if (returned) {
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
- decoration: BoxDecoration(
- color: colors.infoText.withValues(alpha: 0.1),
- borderRadius: BorderRadius.circular(6),
- border: Border.all(color: colors.infoText.withValues(alpha: 0.3), width: 0.5),
- ),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(Icons.assignment_return, size: 18, color: colors.infoText),
- const SizedBox(width: 4),
- Text('已还车', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.infoText)),
- ],
- ),
- );
- }
- if (approved) {
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
- decoration: BoxDecoration(
- color: colors.success.withValues(alpha: 0.1),
- borderRadius: BorderRadius.circular(6),
- border: Border.all(color: colors.success.withValues(alpha: 0.3), width: 0.5),
- ),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(Icons.check_circle_outline, size: 18, color: colors.success),
- const SizedBox(width: 4),
- Text(l10n.get('statusApproved'), style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.success)),
- ],
- ),
- );
- }
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
- decoration: BoxDecoration(
- color: colors.statusGray.withValues(alpha: 0.1),
- borderRadius: BorderRadius.circular(6),
- border: Border.all(color: colors.statusGray.withValues(alpha: 0.3), width: 0.5),
- ),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(Icons.access_time, size: 18, color: colors.statusGray),
- const SizedBox(width: 4),
- Text(l10n.get('statusPending'), style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.statusGray)),
- ],
- ),
- );
- }
- // ═══ 基本信息 ═══
- Widget _buildBasicInfoSection(VehicleModel app, AppLocalizations l10n, AppColorsExtension colors) {
- return FormSection(
- title: l10n.get('basicInfo'),
- leadingIcon: Icons.info_outline,
- trailing: _buildStatusTag(l10n),
- children: [
- FormFieldRow(label: '用车申请单号', value: app.ycNo, readOnly: true, showArrow: false),
- const SizedBox(height: 16),
- FormFieldRow(label: l10n.get('date'), value: app.ycDd != null ? du.DateUtils.formatDate(app.ycDd!) : '-', readOnly: true, showArrow: false),
- const SizedBox(height: 16),
- FormFieldRow(label: '申请人', value: app.applicantName.isNotEmpty ? app.applicantName : app.salNo, readOnly: true, showArrow: false),
- const SizedBox(height: 16),
- FormFieldRow(
- label: l10n.get('applyDept'),
- value: app.dep.isNotEmpty ? '${app.dep}${app.deptName.isNotEmpty ? '/${app.deptName}' : ''}' : '-',
- readOnly: true,
- showArrow: false,
- ),
- const SizedBox(height: 16),
- FormFieldRow(label: '用车目的', value: _purposeLabel(app.purpose, l10n), readOnly: true, showArrow: false),
- const SizedBox(height: 16),
- FormFieldRow(label: '用车事由', value: app.reason.isNotEmpty ? app.reason : '-', readOnly: true, showArrow: false, bold: true, showMoreOnOverflow: true),
- const SizedBox(height: 16),
- FormFieldRow(label: '始发地', value: app.origin.isNotEmpty ? app.origin : '-', readOnly: true, showArrow: false, showMoreOnOverflow: true),
- const SizedBox(height: 16),
- FormFieldRow(label: '目的地', value: app.destinAdr.isNotEmpty ? app.destinAdr : '-', readOnly: true, showArrow: false, showMoreOnOverflow: true),
- const SizedBox(height: 16),
- FormFieldRow(
- label: '预计出车时间',
- value: app.startTime != null ? du.DateUtils.formatDateTime(app.startTime!) : '-',
- readOnly: true,
- showArrow: false,
- ),
- const SizedBox(height: 16),
- FormFieldRow(
- label: '预计还车时间',
- value: app.endTime != null ? du.DateUtils.formatDateTime(app.endTime!) : '-',
- readOnly: true,
- showArrow: false,
- ),
- const SizedBox(height: 16),
- FormFieldRow(label: '同行人数', value: '${app.passengerCount}', readOnly: true, showArrow: false),
- const SizedBox(height: 16),
- FormFieldRow(label: '同行人姓名', value: app.passengerName.isNotEmpty ? app.passengerName : '-', readOnly: true, showArrow: false, showMoreOnOverflow: true),
- ],
- );
- }
- // ═══ 车辆信息 ═══
- Widget _buildVehicleInfoSection(VehicleModel app, AppLocalizations l10n, AppColorsExtension colors) {
- return FormSection(
- title: '车辆信息',
- leadingIcon: Icons.directions_car_outlined,
- children: [
- FormFieldRow(label: '车牌号', value: app.licensePlate.isNotEmpty ? app.licensePlate : '-', readOnly: true, showArrow: false),
- const SizedBox(height: 16),
- FormFieldRow(label: '车辆类型', value: _vehicleTypeLabel(app.vehicleType, l10n), readOnly: true, showArrow: false),
- const SizedBox(height: 16),
- FormFieldRow(label: '品牌型号', value: app.brand.isNotEmpty ? app.brand : '-', readOnly: true, showArrow: false),
- const SizedBox(height: 16),
- FormFieldRow(label: '核定座位数', value: app.seats > 0 ? '${app.seats}' : '-', readOnly: true, showArrow: false),
- const SizedBox(height: 16),
- FormFieldRow(label: '默认驾驶员', value: app.driverName.isNotEmpty ? app.driverName : '-', readOnly: true, showArrow: false),
- ],
- );
- }
- // ═══ 已还车信息 ═══
- Widget _buildReturnedInfoSection(VehicleModel app, AppLocalizations l10n, AppColorsExtension colors) {
- return FormSection(
- title: '还车信息',
- leadingIcon: Icons.assignment_return,
- children: [
- FormFieldRow(
- label: '实际归还时间',
- value: app.returnTime != null ? du.DateUtils.formatDateTime(app.returnTime!) : '-',
- readOnly: true,
- showArrow: false,
- ),
- const SizedBox(height: 16),
- FormFieldRow(label: '里程表读数', value: app.odometer.toString(), readOnly: true, showArrow: false),
- const SizedBox(height: 16),
- FormFieldRow(label: '实际费用', value: formatAmount(app.amtn), readOnly: true, showArrow: false, bold: true),
- const SizedBox(height: 16),
- FormFieldRow(label: '费用备注', value: app.remark.isNotEmpty ? app.remark : '-', readOnly: true, showArrow: false, showMoreOnOverflow: true),
- ],
- );
- }
- // ═══ 还车登记 ═══
- Widget _buildReturnRegistrationSection(AppLocalizations l10n, AppColorsExtension colors) {
- return FormSection(
- title: '还车登记',
- leadingIcon: Icons.drive_eta,
- children: [
- FormFieldRow(
- label: '实际归还时间',
- value: _returnTime != null ? du.DateUtils.formatDateTime(_returnTime!) : null,
- hint: l10n.get('pleaseSelect'),
- onTap: () => _pickReturnDateTime(),
- ),
- const SizedBox(height: 16),
- _label('里程表读数', colors),
- const SizedBox(height: 8),
- TDInput(
- controller: _odometerController,
- hintText: '请输入里程表读数',
- inputType: const TextInputType.numberWithOptions(decimal: true),
- backgroundColor: colors.bgPage,
- ),
- const SizedBox(height: 16),
- _label('路桥费/停车费等实际费用', colors),
- const SizedBox(height: 8),
- TDInput(
- controller: _amtnController,
- hintText: '请输入实际费用',
- inputType: const TextInputType.numberWithOptions(decimal: true),
- backgroundColor: colors.bgPage,
- onChanged: (_) => setState(() {}),
- ),
- if (_amtnController.text.isNotEmpty) ...[
- const SizedBox(height: 4),
- Align(
- alignment: Alignment.centerRight,
- child: Text(
- '金额: ${formatAmount(double.tryParse(_amtnController.text) ?? 0)}',
- style: TextStyle(fontSize: AppFontSizes.caption, color: colors.amountPrimary, fontWeight: FontWeight.w600),
- ),
- ),
- ],
- const SizedBox(height: 16),
- _label('费用明细备注', colors),
- const SizedBox(height: 8),
- TDTextarea(
- controller: _remarkController,
- hintText: '请输入费用明细备注',
- maxLines: 3,
- minLines: 1,
- maxLength: 500,
- indicator: true,
- padding: EdgeInsets.zero,
- bordered: true,
- backgroundColor: colors.bgPage,
- ),
- const SizedBox(height: 16),
- SizedBox(
- width: double.infinity,
- height: 40,
- child: Material(
- color: _canSubmitReturn ? colors.primary : colors.textPlaceholder,
- borderRadius: BorderRadius.circular(22),
- child: InkWell(
- onTap: _canSubmitReturn && !_isSubmittingReturn ? _submitReturn : null,
- borderRadius: BorderRadius.circular(22),
- child: Center(
- child: Text('确认还车', style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w500, color: Colors.white)),
- ),
- ),
- ),
- ),
- ],
- );
- }
- bool get _canSubmitReturn =>
- _returnTime != null &&
- _odometerController.text.isNotEmpty;
- void _pickReturnDateTime() {
- final d = DateTime.now();
- TDPicker.showDatePicker(
- context,
- title: '选择实际归还时间',
- useYear: true, useMonth: true, useDay: true, useHour: true, useMinute: true,
- initialDate: [d.year, d.month, d.day, d.hour, d.minute],
- onConfirm: (selected) {
- setState(() {
- _returnTime = DateTime(selected['year']!, selected['month']!, selected['day']!, selected['hour']!, selected['minute']!);
- });
- },
- );
- }
- Future<void> _submitReturn() async {
- final l10n = AppLocalizations.of(context);
- final confirmed = await showDialog<bool>(
- context: context,
- builder: (ctx) => TDAlertDialog(
- title: '确认还车',
- content: '确认提交还车登记?',
- leftBtn: TDDialogButtonOptions(title: l10n.get('cancel'), action: () => Navigator.pop(ctx, false)),
- rightBtn: TDDialogButtonOptions(title: l10n.get('confirm'), theme: TDButtonTheme.primary, action: () => Navigator.pop(ctx, true)),
- ),
- );
- if (confirmed != true) return;
- setState(() => _isSubmittingReturn = true);
- try {
- final api = ref.read(vehicleApiProvider);
- await api.submitReturn(widget.billNo, {
- 'returnTime': _returnTime?.toIso8601String(),
- 'odometer': double.tryParse(_odometerController.text) ?? 0,
- 'amtn': double.tryParse(_amtnController.text) ?? 0,
- 'remark': _remarkController.text,
- });
- if (mounted) {
- TDToast.showSuccess('还车登记成功', context: context);
- _loadData();
- }
- } catch (_) {
- if (mounted) TDToast.showFail('还车登记失败', context: context);
- } finally {
- if (mounted) setState(() => _isSubmittingReturn = false);
- }
- }
- // ═══ 底部操作栏 ═══
- Widget _buildActionBar(VehicleModel app, AppLocalizations l10n, AppColorsExtension colors) {
- // 只有待审核状态显示编辑/撤回按钮
- if (app.isReturn) return const SizedBox.shrink();
- if (!app.isAuditApproved) {
- // 待审核:可编辑/撤回
- return Container(
- height: 72,
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
- decoration: BoxDecoration(
- color: colors.bgCard,
- border: Border(top: BorderSide(color: colors.border, width: 0.5)),
- ),
- child: Row(
- children: [
- Expanded(
- child: SizedBox(
- height: 40,
- child: Material(
- color: colors.bgCard,
- borderRadius: BorderRadius.circular(22),
- child: InkWell(
- onTap: () {
- TDToast.showText('已撤回', context: context);
- if (context.mounted) GoRouter.of(context).pop();
- },
- borderRadius: BorderRadius.circular(22),
- child: Center(
- child: Text('撤回', style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w500, color: colors.danger)),
- ),
- ),
- ),
- ),
- ),
- ],
- ),
- );
- }
- return const SizedBox.shrink();
- }
- // ═══ 工具方法 ═══
- Widget _label(String t, AppColorsExtension colors) {
- return Text(t, style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textSecondary));
- }
- 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)),
- ],
- ),
- ),
- );
- }
- String _purposeLabel(String key, AppLocalizations l10n) {
- switch (key) {
- case 'reception': return l10n.get('customerReception');
- case 'business': return l10n.get('businessTrip');
- case 'official': return l10n.get('official');
- case 'other': return l10n.get('other');
- default: return key;
- }
- }
- String _vehicleTypeLabel(String key, AppLocalizations l10n) {
- switch (key) {
- case 'sedan': return '轿车';
- case 'suv': return 'SUV';
- case 'mpv': return '商务车';
- case 'van': return '面包车';
- default: return key;
- }
- }
- }
|