vehicle_detail_page.dart 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import 'package:go_router/go_router.dart';
  4. import 'package:tdesign_flutter/tdesign_flutter.dart';
  5. import '../../core/i18n/app_localizations.dart';
  6. import '../../core/theme/app_colors.dart';
  7. import '../../core/theme/app_colors_extension.dart';
  8. import '../../core/utils/amount_utils.dart';
  9. import '../../core/utils/date_utils.dart' as du;
  10. import '../../shared/widgets/app_skeletons.dart';
  11. import '../../shared/widgets/form_field_row.dart';
  12. import '../../shared/widgets/form_section.dart';
  13. import 'vehicle_api.dart';
  14. import 'vehicle_list_controller.dart';
  15. import 'vehicle_model.dart';
  16. class VehicleDetailPage extends ConsumerStatefulWidget {
  17. final String billNo;
  18. const VehicleDetailPage({super.key, required this.billNo});
  19. @override
  20. ConsumerState<VehicleDetailPage> createState() => _VehicleDetailPageState();
  21. }
  22. class _VehicleDetailPageState extends ConsumerState<VehicleDetailPage> {
  23. bool _loading = true;
  24. String? _error;
  25. VehicleModel? _data;
  26. // ── 还车登记字段 ──
  27. DateTime? _returnTime;
  28. final _odometerController = TextEditingController();
  29. final _amtnController = TextEditingController();
  30. final _remarkController = TextEditingController();
  31. bool _isSubmittingReturn = false;
  32. @override
  33. void initState() {
  34. super.initState();
  35. _loadData();
  36. }
  37. @override
  38. void dispose() {
  39. _odometerController.dispose();
  40. _amtnController.dispose();
  41. _remarkController.dispose();
  42. super.dispose();
  43. }
  44. Future<void> _loadData() async {
  45. setState(() { _loading = true; _error = null; });
  46. try {
  47. // 先用 mock 数据匹配 billNo
  48. final match = mockVehicles.where((e) => e.ycNo == widget.billNo);
  49. if (match.isNotEmpty) {
  50. if (mounted) {
  51. setState(() { _data = match.first; _loading = false; });
  52. }
  53. } else {
  54. // 尝试 API
  55. final api = ref.read(vehicleApiProvider);
  56. final detail = await api.fetchDetail(widget.billNo);
  57. if (mounted) setState(() { _data = detail; _loading = false; });
  58. }
  59. // 单据状态
  60. try {
  61. final api = ref.read(vehicleApiProvider);
  62. await api.getBillStatus(widget.billNo);
  63. } catch (_) { /* 非关键 */ }
  64. } catch (e) {
  65. if (mounted) setState(() { _error = e.toString(); _loading = false; });
  66. }
  67. }
  68. @override
  69. Widget build(BuildContext context) {
  70. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  71. final l10n = AppLocalizations.of(context);
  72. if (_loading) return const SkeletonDetailPage();
  73. if (_error != null) {
  74. return Center(
  75. child: Column(
  76. mainAxisSize: MainAxisSize.min,
  77. children: [
  78. Icon(Icons.error_outline, size: 48, color: colors.danger),
  79. const SizedBox(height: 16),
  80. Padding(
  81. padding: const EdgeInsets.symmetric(horizontal: 32),
  82. child: Text(_error!, textAlign: TextAlign.center, style: TextStyle(fontSize: AppFontSizes.body, color: colors.textSecondary)),
  83. ),
  84. const SizedBox(height: 16),
  85. TDButton(text: l10n.get('retry'), size: TDButtonSize.medium, onTap: _loadData),
  86. ],
  87. ),
  88. );
  89. }
  90. final app = _data!;
  91. return Column(
  92. children: [
  93. Expanded(
  94. child: SingleChildScrollView(
  95. physics: const AlwaysScrollableScrollPhysics(),
  96. padding: const EdgeInsets.all(16),
  97. child: Column(
  98. children: [
  99. _buildBasicInfoSection(app, l10n, colors),
  100. const SizedBox(height: 16),
  101. _buildVehicleInfoSection(app, l10n, colors),
  102. if (app.isReturn)
  103. _buildReturnedInfoSection(app, l10n, colors)
  104. else if (app.isAuditApproved)
  105. _buildReturnRegistrationSection(l10n, colors),
  106. const SizedBox(height: 24),
  107. _buildPageFooter(colors),
  108. ],
  109. ),
  110. ),
  111. ),
  112. _buildActionBar(app, l10n, colors),
  113. ],
  114. );
  115. }
  116. // ═══ 状态 tag ═══
  117. Widget _buildStatusTag(AppLocalizations l10n) {
  118. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  119. final approved = _data?.isAuditApproved ?? false;
  120. final returned = _data?.isReturn ?? false;
  121. if (returned) {
  122. return Container(
  123. padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
  124. decoration: BoxDecoration(
  125. color: colors.infoText.withValues(alpha: 0.1),
  126. borderRadius: BorderRadius.circular(6),
  127. border: Border.all(color: colors.infoText.withValues(alpha: 0.3), width: 0.5),
  128. ),
  129. child: Row(
  130. mainAxisSize: MainAxisSize.min,
  131. children: [
  132. Icon(Icons.assignment_return, size: 18, color: colors.infoText),
  133. const SizedBox(width: 4),
  134. Text('已还车', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.infoText)),
  135. ],
  136. ),
  137. );
  138. }
  139. if (approved) {
  140. return Container(
  141. padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
  142. decoration: BoxDecoration(
  143. color: colors.success.withValues(alpha: 0.1),
  144. borderRadius: BorderRadius.circular(6),
  145. border: Border.all(color: colors.success.withValues(alpha: 0.3), width: 0.5),
  146. ),
  147. child: Row(
  148. mainAxisSize: MainAxisSize.min,
  149. children: [
  150. Icon(Icons.check_circle_outline, size: 18, color: colors.success),
  151. const SizedBox(width: 4),
  152. Text(l10n.get('statusApproved'), style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.success)),
  153. ],
  154. ),
  155. );
  156. }
  157. return Container(
  158. padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
  159. decoration: BoxDecoration(
  160. color: colors.statusGray.withValues(alpha: 0.1),
  161. borderRadius: BorderRadius.circular(6),
  162. border: Border.all(color: colors.statusGray.withValues(alpha: 0.3), width: 0.5),
  163. ),
  164. child: Row(
  165. mainAxisSize: MainAxisSize.min,
  166. children: [
  167. Icon(Icons.access_time, size: 18, color: colors.statusGray),
  168. const SizedBox(width: 4),
  169. Text(l10n.get('statusPending'), style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.statusGray)),
  170. ],
  171. ),
  172. );
  173. }
  174. // ═══ 基本信息 ═══
  175. Widget _buildBasicInfoSection(VehicleModel app, AppLocalizations l10n, AppColorsExtension colors) {
  176. return FormSection(
  177. title: l10n.get('basicInfo'),
  178. leadingIcon: Icons.info_outline,
  179. trailing: _buildStatusTag(l10n),
  180. children: [
  181. FormFieldRow(label: '用车申请单号', value: app.ycNo, readOnly: true, showArrow: false),
  182. const SizedBox(height: 16),
  183. FormFieldRow(label: l10n.get('date'), value: app.ycDd != null ? du.DateUtils.formatDate(app.ycDd!) : '-', readOnly: true, showArrow: false),
  184. const SizedBox(height: 16),
  185. FormFieldRow(label: '申请人', value: app.applicantName.isNotEmpty ? app.applicantName : app.salNo, readOnly: true, showArrow: false),
  186. const SizedBox(height: 16),
  187. FormFieldRow(
  188. label: l10n.get('applyDept'),
  189. value: app.dep.isNotEmpty ? '${app.dep}${app.deptName.isNotEmpty ? '/${app.deptName}' : ''}' : '-',
  190. readOnly: true,
  191. showArrow: false,
  192. ),
  193. const SizedBox(height: 16),
  194. FormFieldRow(label: '用车目的', value: _purposeLabel(app.purpose, l10n), readOnly: true, showArrow: false),
  195. const SizedBox(height: 16),
  196. FormFieldRow(label: '用车事由', value: app.reason.isNotEmpty ? app.reason : '-', readOnly: true, showArrow: false, bold: true, showMoreOnOverflow: true),
  197. const SizedBox(height: 16),
  198. FormFieldRow(label: '始发地', value: app.origin.isNotEmpty ? app.origin : '-', readOnly: true, showArrow: false, showMoreOnOverflow: true),
  199. const SizedBox(height: 16),
  200. FormFieldRow(label: '目的地', value: app.destinAdr.isNotEmpty ? app.destinAdr : '-', readOnly: true, showArrow: false, showMoreOnOverflow: true),
  201. const SizedBox(height: 16),
  202. FormFieldRow(
  203. label: '预计出车时间',
  204. value: app.startTime != null ? du.DateUtils.formatDateTime(app.startTime!) : '-',
  205. readOnly: true,
  206. showArrow: false,
  207. ),
  208. const SizedBox(height: 16),
  209. FormFieldRow(
  210. label: '预计还车时间',
  211. value: app.endTime != null ? du.DateUtils.formatDateTime(app.endTime!) : '-',
  212. readOnly: true,
  213. showArrow: false,
  214. ),
  215. const SizedBox(height: 16),
  216. FormFieldRow(label: '同行人数', value: '${app.passengerCount}', readOnly: true, showArrow: false),
  217. const SizedBox(height: 16),
  218. FormFieldRow(label: '同行人姓名', value: app.passengerName.isNotEmpty ? app.passengerName : '-', readOnly: true, showArrow: false, showMoreOnOverflow: true),
  219. ],
  220. );
  221. }
  222. // ═══ 车辆信息 ═══
  223. Widget _buildVehicleInfoSection(VehicleModel app, AppLocalizations l10n, AppColorsExtension colors) {
  224. return FormSection(
  225. title: '车辆信息',
  226. leadingIcon: Icons.directions_car_outlined,
  227. children: [
  228. FormFieldRow(label: '车牌号', value: app.licensePlate.isNotEmpty ? app.licensePlate : '-', readOnly: true, showArrow: false),
  229. const SizedBox(height: 16),
  230. FormFieldRow(label: '车辆类型', value: _vehicleTypeLabel(app.vehicleType, l10n), readOnly: true, showArrow: false),
  231. const SizedBox(height: 16),
  232. FormFieldRow(label: '品牌型号', value: app.brand.isNotEmpty ? app.brand : '-', readOnly: true, showArrow: false),
  233. const SizedBox(height: 16),
  234. FormFieldRow(label: '核定座位数', value: app.seats > 0 ? '${app.seats}' : '-', readOnly: true, showArrow: false),
  235. const SizedBox(height: 16),
  236. FormFieldRow(label: '默认驾驶员', value: app.driverName.isNotEmpty ? app.driverName : '-', readOnly: true, showArrow: false),
  237. ],
  238. );
  239. }
  240. // ═══ 已还车信息 ═══
  241. Widget _buildReturnedInfoSection(VehicleModel app, AppLocalizations l10n, AppColorsExtension colors) {
  242. return FormSection(
  243. title: '还车信息',
  244. leadingIcon: Icons.assignment_return,
  245. children: [
  246. FormFieldRow(
  247. label: '实际归还时间',
  248. value: app.returnTime != null ? du.DateUtils.formatDateTime(app.returnTime!) : '-',
  249. readOnly: true,
  250. showArrow: false,
  251. ),
  252. const SizedBox(height: 16),
  253. FormFieldRow(label: '里程表读数', value: app.odometer.toString(), readOnly: true, showArrow: false),
  254. const SizedBox(height: 16),
  255. FormFieldRow(label: '实际费用', value: formatAmount(app.amtn), readOnly: true, showArrow: false, bold: true),
  256. const SizedBox(height: 16),
  257. FormFieldRow(label: '费用备注', value: app.remark.isNotEmpty ? app.remark : '-', readOnly: true, showArrow: false, showMoreOnOverflow: true),
  258. ],
  259. );
  260. }
  261. // ═══ 还车登记 ═══
  262. Widget _buildReturnRegistrationSection(AppLocalizations l10n, AppColorsExtension colors) {
  263. return FormSection(
  264. title: '还车登记',
  265. leadingIcon: Icons.drive_eta,
  266. children: [
  267. FormFieldRow(
  268. label: '实际归还时间',
  269. value: _returnTime != null ? du.DateUtils.formatDateTime(_returnTime!) : null,
  270. hint: l10n.get('pleaseSelect'),
  271. onTap: () => _pickReturnDateTime(),
  272. ),
  273. const SizedBox(height: 16),
  274. _label('里程表读数', colors),
  275. const SizedBox(height: 8),
  276. TDInput(
  277. controller: _odometerController,
  278. hintText: '请输入里程表读数',
  279. inputType: const TextInputType.numberWithOptions(decimal: true),
  280. backgroundColor: colors.bgPage,
  281. ),
  282. const SizedBox(height: 16),
  283. _label('路桥费/停车费等实际费用', colors),
  284. const SizedBox(height: 8),
  285. TDInput(
  286. controller: _amtnController,
  287. hintText: '请输入实际费用',
  288. inputType: const TextInputType.numberWithOptions(decimal: true),
  289. backgroundColor: colors.bgPage,
  290. onChanged: (_) => setState(() {}),
  291. ),
  292. if (_amtnController.text.isNotEmpty) ...[
  293. const SizedBox(height: 4),
  294. Align(
  295. alignment: Alignment.centerRight,
  296. child: Text(
  297. '金额: ${formatAmount(double.tryParse(_amtnController.text) ?? 0)}',
  298. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.amountPrimary, fontWeight: FontWeight.w600),
  299. ),
  300. ),
  301. ],
  302. const SizedBox(height: 16),
  303. _label('费用明细备注', colors),
  304. const SizedBox(height: 8),
  305. TDTextarea(
  306. controller: _remarkController,
  307. hintText: '请输入费用明细备注',
  308. maxLines: 3,
  309. minLines: 1,
  310. maxLength: 500,
  311. indicator: true,
  312. padding: EdgeInsets.zero,
  313. bordered: true,
  314. backgroundColor: colors.bgPage,
  315. ),
  316. const SizedBox(height: 16),
  317. SizedBox(
  318. width: double.infinity,
  319. height: 40,
  320. child: Material(
  321. color: _canSubmitReturn ? colors.primary : colors.textPlaceholder,
  322. borderRadius: BorderRadius.circular(22),
  323. child: InkWell(
  324. onTap: _canSubmitReturn && !_isSubmittingReturn ? _submitReturn : null,
  325. borderRadius: BorderRadius.circular(22),
  326. child: Center(
  327. child: Text('确认还车', style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w500, color: Colors.white)),
  328. ),
  329. ),
  330. ),
  331. ),
  332. ],
  333. );
  334. }
  335. bool get _canSubmitReturn =>
  336. _returnTime != null &&
  337. _odometerController.text.isNotEmpty;
  338. void _pickReturnDateTime() {
  339. final d = DateTime.now();
  340. TDPicker.showDatePicker(
  341. context,
  342. title: '选择实际归还时间',
  343. useYear: true, useMonth: true, useDay: true, useHour: true, useMinute: true,
  344. initialDate: [d.year, d.month, d.day, d.hour, d.minute],
  345. onConfirm: (selected) {
  346. setState(() {
  347. _returnTime = DateTime(selected['year']!, selected['month']!, selected['day']!, selected['hour']!, selected['minute']!);
  348. });
  349. },
  350. );
  351. }
  352. Future<void> _submitReturn() async {
  353. final l10n = AppLocalizations.of(context);
  354. final confirmed = await showDialog<bool>(
  355. context: context,
  356. builder: (ctx) => TDAlertDialog(
  357. title: '确认还车',
  358. content: '确认提交还车登记?',
  359. leftBtn: TDDialogButtonOptions(title: l10n.get('cancel'), action: () => Navigator.pop(ctx, false)),
  360. rightBtn: TDDialogButtonOptions(title: l10n.get('confirm'), theme: TDButtonTheme.primary, action: () => Navigator.pop(ctx, true)),
  361. ),
  362. );
  363. if (confirmed != true) return;
  364. setState(() => _isSubmittingReturn = true);
  365. try {
  366. final api = ref.read(vehicleApiProvider);
  367. await api.submitReturn(widget.billNo, {
  368. 'returnTime': _returnTime?.toIso8601String(),
  369. 'odometer': double.tryParse(_odometerController.text) ?? 0,
  370. 'amtn': double.tryParse(_amtnController.text) ?? 0,
  371. 'remark': _remarkController.text,
  372. });
  373. if (mounted) {
  374. TDToast.showSuccess('还车登记成功', context: context);
  375. _loadData();
  376. }
  377. } catch (_) {
  378. if (mounted) TDToast.showFail('还车登记失败', context: context);
  379. } finally {
  380. if (mounted) setState(() => _isSubmittingReturn = false);
  381. }
  382. }
  383. // ═══ 底部操作栏 ═══
  384. Widget _buildActionBar(VehicleModel app, AppLocalizations l10n, AppColorsExtension colors) {
  385. // 只有待审核状态显示编辑/撤回按钮
  386. if (app.isReturn) return const SizedBox.shrink();
  387. if (!app.isAuditApproved) {
  388. // 待审核:可编辑/撤回
  389. return Container(
  390. height: 72,
  391. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
  392. decoration: BoxDecoration(
  393. color: colors.bgCard,
  394. border: Border(top: BorderSide(color: colors.border, width: 0.5)),
  395. ),
  396. child: Row(
  397. children: [
  398. Expanded(
  399. child: SizedBox(
  400. height: 40,
  401. child: Material(
  402. color: colors.bgCard,
  403. borderRadius: BorderRadius.circular(22),
  404. child: InkWell(
  405. onTap: () {
  406. TDToast.showText('已撤回', context: context);
  407. if (context.mounted) GoRouter.of(context).pop();
  408. },
  409. borderRadius: BorderRadius.circular(22),
  410. child: Center(
  411. child: Text('撤回', style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w500, color: colors.danger)),
  412. ),
  413. ),
  414. ),
  415. ),
  416. ),
  417. ],
  418. ),
  419. );
  420. }
  421. return const SizedBox.shrink();
  422. }
  423. // ═══ 工具方法 ═══
  424. Widget _label(String t, AppColorsExtension colors) {
  425. return Text(t, style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textSecondary));
  426. }
  427. Widget _buildPageFooter(AppColorsExtension colors) {
  428. final l10n = AppLocalizations.of(context);
  429. return Center(
  430. child: Padding(
  431. padding: const EdgeInsets.only(bottom: 16),
  432. child: Row(
  433. mainAxisSize: MainAxisSize.min,
  434. children: [
  435. Icon(Icons.rocket_launch_outlined, size: 16, color: colors.textPlaceholder),
  436. const SizedBox(width: 6),
  437. Text(l10n.get('pageFooter'), style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPlaceholder)),
  438. ],
  439. ),
  440. ),
  441. );
  442. }
  443. String _purposeLabel(String key, AppLocalizations l10n) {
  444. switch (key) {
  445. case 'reception': return l10n.get('customerReception');
  446. case 'business': return l10n.get('businessTrip');
  447. case 'official': return l10n.get('official');
  448. case 'other': return l10n.get('other');
  449. default: return key;
  450. }
  451. }
  452. String _vehicleTypeLabel(String key, AppLocalizations l10n) {
  453. switch (key) {
  454. case 'sedan': return '轿车';
  455. case 'suv': return 'SUV';
  456. case 'mpv': return '商务车';
  457. case 'van': return '面包车';
  458. default: return key;
  459. }
  460. }
  461. }