vehicle_detail_page.dart 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681
  1. import 'package:flutter/material.dart';
  2. import 'package:go_router/go_router.dart';
  3. import 'package:flutter_riverpod/flutter_riverpod.dart';
  4. import 'package:tdesign_flutter/tdesign_flutter.dart';
  5. import '../shell/nav_bar_config.dart';
  6. import '../../core/utils/date_utils.dart' as du;
  7. import '../../shared/widgets/status_banner.dart';
  8. import '../../shared/widgets/approval_timeline.dart';
  9. import '../../shared/models/approval_status.dart';
  10. import 'vehicle_model.dart';
  11. import '../../core/i18n/app_localizations.dart';
  12. import 'vehicle_list_controller.dart';
  13. import '../../core/theme/app_colors.dart';
  14. import '../../core/theme/app_colors_extension.dart';
  15. class VehicleDetailPage extends ConsumerStatefulWidget {
  16. final String id;
  17. const VehicleDetailPage({super.key, required this.id});
  18. @override
  19. ConsumerState<VehicleDetailPage> createState() => _VehicleDetailPageState();
  20. }
  21. class _VehicleDetailPageState extends ConsumerState<VehicleDetailPage> {
  22. // Return registration fields
  23. final _startOdometerCtrl = TextEditingController();
  24. final _endOdometerCtrl = TextEditingController();
  25. final _actualCostCtrl = TextEditingController();
  26. final _costRemarkCtrl = TextEditingController();
  27. DateTime? _actualReturnTime;
  28. bool _isSubmittingReturn = false;
  29. String _purposeLabel(String key) {
  30. switch (key) {
  31. case 'reception':
  32. return '客户接待';
  33. case 'business':
  34. return '商务出行';
  35. case 'official':
  36. return '公务';
  37. default:
  38. return key;
  39. }
  40. }
  41. @override
  42. void dispose() {
  43. _startOdometerCtrl.dispose();
  44. _endOdometerCtrl.dispose();
  45. _actualCostCtrl.dispose();
  46. _costRemarkCtrl.dispose();
  47. super.dispose();
  48. }
  49. @override
  50. Widget build(BuildContext context) {
  51. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  52. final vehicle = mockVehicles.firstWhere(
  53. (e) => e.id == widget.id,
  54. orElse: () => mockVehicles.first,
  55. );
  56. final l10n = AppLocalizations.of(context);
  57. ref
  58. .read(navBarConfigProvider.notifier)
  59. .update(
  60. NavBarConfig(
  61. title: l10n.get('vehicleDetail'),
  62. showBack: true,
  63. onBack: () => context.pop(),
  64. ),
  65. );
  66. final (icon, color, statusText) = _statusProps(vehicle.status);
  67. return Column(
  68. children: [
  69. Expanded(
  70. child: SingleChildScrollView(
  71. padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
  72. child: Column(
  73. crossAxisAlignment: CrossAxisAlignment.start,
  74. children: [
  75. StatusBanner(
  76. icon: icon,
  77. statusText: statusText,
  78. subText: _statusSubText(vehicle),
  79. color: color,
  80. ),
  81. const SizedBox(height: 8),
  82. Text(
  83. '提交时间:${du.DateUtils.formatDateTime(vehicle.createTime)}',
  84. style: TextStyle(
  85. fontSize: AppFontSizes.caption,
  86. color: colors.textSecondary,
  87. ),
  88. ),
  89. const SizedBox(height: 16),
  90. _buildInfoSection(vehicle),
  91. const SizedBox(height: 16),
  92. _buildMapSection(vehicle),
  93. const SizedBox(height: 16),
  94. // Return registration (only for approved)
  95. if (vehicle.status == 'approved')
  96. _buildReturnRegistration(vehicle),
  97. const SizedBox(height: 16),
  98. _buildApprovalTimeline(vehicle),
  99. ],
  100. ),
  101. ),
  102. ),
  103. _buildActionBar(context, vehicle),
  104. ],
  105. );
  106. }
  107. Widget _buildInfoSection(VehicleModel vehicle) {
  108. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  109. return Container(
  110. padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
  111. decoration: BoxDecoration(
  112. color: colors.bgCard,
  113. borderRadius: BorderRadius.circular(8),
  114. ),
  115. child: Column(
  116. children: [
  117. _infoRow('申请人', vehicle.applicantName),
  118. _infoRow('所属部门', vehicle.deptName),
  119. _infoRow(
  120. '车牌号',
  121. vehicle.vehicleId.isNotEmpty ? vehicle.vehicleId : '未指定',
  122. ),
  123. _infoRow('用车目的', _purposeLabel(vehicle.purpose)),
  124. _infoRow('始发地', vehicle.origin.isNotEmpty ? vehicle.origin : '未填写'),
  125. _infoRow(
  126. '目的地',
  127. vehicle.destination.isNotEmpty ? vehicle.destination : '未填写',
  128. ),
  129. _infoRow('出车时间', du.DateUtils.formatDateTime(vehicle.startTime)),
  130. _infoRow('还车时间', du.DateUtils.formatDateTime(vehicle.endTime)),
  131. _infoRow('同行人数', '${vehicle.passengerCount}人'),
  132. ],
  133. ),
  134. );
  135. }
  136. Widget _infoRow(String label, String value) {
  137. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  138. return Container(
  139. height: 44,
  140. padding: const EdgeInsets.symmetric(vertical: 0),
  141. child: Row(
  142. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  143. children: [
  144. Text(
  145. label,
  146. style: TextStyle(
  147. fontSize: AppFontSizes.body,
  148. color: colors.textSecondary,
  149. ),
  150. ),
  151. Flexible(
  152. child: Text(
  153. value,
  154. style: TextStyle(
  155. fontSize: AppFontSizes.body,
  156. color: colors.textPrimary,
  157. ),
  158. textAlign: TextAlign.right,
  159. overflow: TextOverflow.ellipsis,
  160. ),
  161. ),
  162. ],
  163. ),
  164. );
  165. }
  166. Widget _buildMapSection(VehicleModel vehicle) {
  167. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  168. return Container(
  169. padding: const EdgeInsets.all(16),
  170. decoration: BoxDecoration(
  171. color: colors.bgCard,
  172. borderRadius: BorderRadius.circular(8),
  173. ),
  174. child: Column(
  175. crossAxisAlignment: CrossAxisAlignment.start,
  176. children: [
  177. Text(
  178. '行程路线',
  179. style: TextStyle(
  180. fontSize: AppFontSizes.subtitle,
  181. fontWeight: FontWeight.w600,
  182. color: colors.textPrimary,
  183. ),
  184. ),
  185. const SizedBox(height: 8),
  186. Container(
  187. height: 160,
  188. width: double.infinity,
  189. decoration: BoxDecoration(
  190. color: colors.infoLightBg,
  191. borderRadius: BorderRadius.circular(8),
  192. ),
  193. child: Stack(
  194. children: [
  195. Center(
  196. child: Column(
  197. mainAxisAlignment: MainAxisAlignment.center,
  198. children: [
  199. Row(
  200. mainAxisAlignment: MainAxisAlignment.center,
  201. children: [
  202. Icon(
  203. Icons.location_on,
  204. color: colors.success,
  205. size: 20,
  206. ),
  207. const SizedBox(width: 4),
  208. Text(
  209. vehicle.origin.isNotEmpty ? vehicle.origin : '始发地',
  210. style: TextStyle(
  211. fontSize: 13,
  212. color: colors.textPrimary,
  213. ),
  214. ),
  215. ],
  216. ),
  217. Padding(
  218. padding: EdgeInsets.symmetric(vertical: 4),
  219. child: Icon(
  220. Icons.arrow_downward,
  221. color: colors.primary,
  222. size: 18,
  223. ),
  224. ),
  225. Row(
  226. mainAxisAlignment: MainAxisAlignment.center,
  227. children: [
  228. Icon(
  229. Icons.location_on,
  230. color: colors.danger,
  231. size: 20,
  232. ),
  233. const SizedBox(width: 4),
  234. Text(
  235. vehicle.destination.isNotEmpty
  236. ? vehicle.destination
  237. : '目的地',
  238. style: TextStyle(
  239. fontSize: 13,
  240. color: colors.textPrimary,
  241. ),
  242. ),
  243. ],
  244. ),
  245. ],
  246. ),
  247. ),
  248. Positioned(
  249. bottom: 8,
  250. right: 8,
  251. child: GestureDetector(
  252. onTap: () {
  253. TDToast.showText('导航即将开放', context: context);
  254. },
  255. child: Container(
  256. padding: const EdgeInsets.symmetric(
  257. horizontal: 10,
  258. vertical: 4,
  259. ),
  260. decoration: BoxDecoration(
  261. color: colors.primary,
  262. borderRadius: BorderRadius.circular(12),
  263. ),
  264. child: const Row(
  265. mainAxisSize: MainAxisSize.min,
  266. children: [
  267. Icon(Icons.navigation, size: 14, color: Colors.white),
  268. SizedBox(width: 4),
  269. Text(
  270. '导航',
  271. style: TextStyle(fontSize: 12, color: Colors.white),
  272. ),
  273. ],
  274. ),
  275. ),
  276. ),
  277. ),
  278. ],
  279. ),
  280. ),
  281. ],
  282. ),
  283. );
  284. }
  285. Widget _buildReturnRegistration(VehicleModel vehicle) {
  286. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  287. final isEndOdometerValid =
  288. _endOdometerCtrl.text.isNotEmpty &&
  289. (double.tryParse(_endOdometerCtrl.text) ?? 0) >=
  290. (double.tryParse(_startOdometerCtrl.text) ?? 0);
  291. return Container(
  292. padding: const EdgeInsets.all(16),
  293. decoration: BoxDecoration(
  294. color: colors.bgCard,
  295. borderRadius: BorderRadius.circular(8),
  296. border: Border.all(color: colors.primary.withValues(alpha: 0.3)),
  297. ),
  298. child: Column(
  299. crossAxisAlignment: CrossAxisAlignment.start,
  300. children: [
  301. Row(
  302. children: [
  303. Icon(Icons.drive_eta, size: 20, color: colors.primary),
  304. const SizedBox(width: 8),
  305. Text(
  306. '还车登记',
  307. style: TextStyle(
  308. fontSize: AppFontSizes.subtitle,
  309. fontWeight: FontWeight.w600,
  310. color: colors.textPrimary,
  311. ),
  312. ),
  313. ],
  314. ),
  315. const SizedBox(height: 16),
  316. // 实还时间
  317. GestureDetector(
  318. onTap: () => _pickReturnDateTime(vehicle),
  319. child: Container(
  320. height: 44,
  321. padding: const EdgeInsets.symmetric(horizontal: 0),
  322. child: Row(
  323. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  324. children: [
  325. Text(
  326. '实还时间',
  327. style: TextStyle(fontSize: 14, color: colors.textSecondary),
  328. ),
  329. Text(
  330. _actualReturnTime != null
  331. ? du.DateUtils.formatDateTime(_actualReturnTime!)
  332. : '请选择',
  333. style: TextStyle(
  334. fontSize: 14,
  335. color: _actualReturnTime != null
  336. ? colors.textPrimary
  337. : colors.textPlaceholder,
  338. ),
  339. ),
  340. ],
  341. ),
  342. ),
  343. ),
  344. if (_actualReturnTime != null)
  345. Padding(
  346. padding: const EdgeInsets.only(bottom: 8),
  347. child: Text(
  348. _actualReturnTime!.isBefore(vehicle.endTime)
  349. ? '提示:提前还车'
  350. : _actualReturnTime!.isAfter(vehicle.endTime)
  351. ? '警告:已超出原计划还车时间'
  352. : '',
  353. style: TextStyle(
  354. fontSize: AppFontSizes.caption,
  355. color: _actualReturnTime!.isAfter(vehicle.endTime)
  356. ? colors.danger
  357. : colors.textSecondary,
  358. ),
  359. ),
  360. ),
  361. const SizedBox(height: 8),
  362. // 出车前里程
  363. TDInput(
  364. controller: _startOdometerCtrl,
  365. hintText: '出车前里程(公里)',
  366. inputType: TextInputType.number,
  367. onChanged: (v) => setState(() {}),
  368. ),
  369. const SizedBox(height: 8),
  370. // 还车后里程
  371. TDInput(
  372. controller: _endOdometerCtrl,
  373. hintText: '还车后里程(公里)',
  374. inputType: TextInputType.number,
  375. onChanged: (v) => setState(() {}),
  376. ),
  377. if (_endOdometerCtrl.text.isNotEmpty && !isEndOdometerValid)
  378. Padding(
  379. padding: EdgeInsets.only(top: 4),
  380. child: Text(
  381. '还车后里程不能小于出车前里程',
  382. style: TextStyle(
  383. fontSize: AppFontSizes.caption,
  384. color: colors.danger,
  385. ),
  386. ),
  387. ),
  388. const SizedBox(height: 8),
  389. // 实际费用
  390. TDInput(
  391. controller: _actualCostCtrl,
  392. hintText: '实际费用金额(元)',
  393. inputType: TextInputType.number,
  394. onChanged: (v) => setState(() {}),
  395. ),
  396. const SizedBox(height: 8),
  397. // 费用备注
  398. TDInput(
  399. controller: _costRemarkCtrl,
  400. hintText: '费用备注(路桥费/停车费等)',
  401. inputType: TextInputType.text,
  402. onChanged: (v) => setState(() {}),
  403. ),
  404. const SizedBox(height: 16),
  405. // 确认提交按钮
  406. SizedBox(
  407. width: double.infinity,
  408. height: 40,
  409. child: Material(
  410. color: _canSubmitReturn(isEndOdometerValid)
  411. ? colors.primary
  412. : colors.textPlaceholder,
  413. borderRadius: BorderRadius.circular(22),
  414. child: InkWell(
  415. onTap:
  416. _canSubmitReturn(isEndOdometerValid) && !_isSubmittingReturn
  417. ? _submitReturn
  418. : null,
  419. borderRadius: BorderRadius.circular(22),
  420. child: const Center(
  421. child: Text(
  422. '确认还车',
  423. style: TextStyle(
  424. fontSize: AppFontSizes.body,
  425. fontWeight: FontWeight.w500,
  426. color: Colors.white,
  427. ),
  428. ),
  429. ),
  430. ),
  431. ),
  432. ),
  433. ],
  434. ),
  435. );
  436. }
  437. bool _canSubmitReturn(bool isEndOdometerValid) {
  438. return _actualReturnTime != null &&
  439. _startOdometerCtrl.text.isNotEmpty &&
  440. _endOdometerCtrl.text.isNotEmpty &&
  441. isEndOdometerValid;
  442. }
  443. void _submitReturn() {
  444. final l10n = AppLocalizations.of(context);
  445. showDialog(
  446. context: context,
  447. builder: (ctx) => TDAlertDialog(
  448. title: l10n.get('confirmSubmit'),
  449. contentWidget: Text(l10n.get('submitConfirmContent')),
  450. leftBtn: TDDialogButtonOptions(
  451. title: l10n.get('cancel'),
  452. action: () => Navigator.pop(ctx),
  453. ),
  454. rightBtn: TDDialogButtonOptions(
  455. title: l10n.get('confirm'),
  456. theme: TDButtonTheme.primary,
  457. action: () {
  458. Navigator.pop(ctx);
  459. setState(() => _isSubmittingReturn = true);
  460. TDToast.showText('还车登记已提交', context: context);
  461. setState(() => _isSubmittingReturn = false);
  462. },
  463. ),
  464. ),
  465. );
  466. }
  467. Widget _buildApprovalTimeline(VehicleModel vehicle) {
  468. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  469. final mockRecords = _generateMockRecords(vehicle);
  470. final mockChain = <String>['u-mgr'];
  471. return Container(
  472. padding: const EdgeInsets.all(16),
  473. decoration: BoxDecoration(
  474. color: colors.bgCard,
  475. borderRadius: BorderRadius.circular(8),
  476. ),
  477. child: ApprovalTimeline(
  478. records: mockRecords,
  479. chain: mockChain,
  480. currentApproverId: vehicle.status == 'pending' ? 'u-mgr' : '',
  481. ),
  482. );
  483. }
  484. List<ApprovalRecord> _generateMockRecords(VehicleModel v) {
  485. if (v.status == 'draft' || v.status == 'withdrawn') return [];
  486. final records = <ApprovalRecord>[
  487. ApprovalRecord(
  488. id: 'ar-${v.id}-init',
  489. bizId: v.id,
  490. bizType: 'vehicle',
  491. approverId: 'u-init',
  492. approverName: '系统',
  493. approvalLevel: 0,
  494. action: 'approve',
  495. opinion: '发起申请',
  496. approvalTime: v.createTime,
  497. ),
  498. ];
  499. if (v.status == 'approved' || v.status == 'returned') {
  500. records.add(
  501. ApprovalRecord(
  502. id: 'ar-${v.id}-mgr',
  503. bizId: v.id,
  504. bizType: 'vehicle',
  505. approverId: 'u-mgr',
  506. approverName: '李四(经理)',
  507. approvalLevel: 1,
  508. action: 'approve',
  509. opinion: '同意',
  510. approvalTime: v.updateTime,
  511. ),
  512. );
  513. } else if (v.status == 'rejected') {
  514. records.add(
  515. ApprovalRecord(
  516. id: 'ar-${v.id}-mgr',
  517. bizId: v.id,
  518. bizType: 'vehicle',
  519. approverId: 'u-mgr',
  520. approverName: '李四(经理)',
  521. approvalLevel: 1,
  522. action: 'reject',
  523. opinion: '请提供更详细的事由',
  524. approvalTime: v.updateTime,
  525. ),
  526. );
  527. }
  528. return records;
  529. }
  530. void _pickReturnDateTime(VehicleModel vehicle) {
  531. final l10n = AppLocalizations.of(context);
  532. TDPicker.showDatePicker(
  533. context,
  534. title: l10n.get('selectReturnTime'),
  535. useYear: true,
  536. useMonth: true,
  537. useDay: true,
  538. useHour: true,
  539. useMinute: true,
  540. initialDate: [
  541. DateTime.now().year,
  542. DateTime.now().month,
  543. DateTime.now().day,
  544. DateTime.now().hour,
  545. DateTime.now().minute,
  546. ],
  547. onConfirm: (selected) {
  548. setState(() {
  549. _actualReturnTime = DateTime(
  550. selected['year']!,
  551. selected['month']!,
  552. selected['day']!,
  553. selected['hour']!,
  554. selected['minute']!,
  555. );
  556. });
  557. },
  558. );
  559. }
  560. Widget _buildActionBar(BuildContext context, VehicleModel vehicle) {
  561. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  562. Widget? actionButton;
  563. if (vehicle.status == 'draft') {
  564. actionButton = _singleButton('编辑', colors.primary, () {
  565. context.push('/vehicle/apply', extra: vehicle.id);
  566. });
  567. } else if (vehicle.status == 'pending') {
  568. actionButton = _singleButton('撤回申请', colors.primary, () {
  569. TDToast.showText('已撤回', context: context);
  570. });
  571. } else if (vehicle.status == 'rejected') {
  572. actionButton = _singleButton('重新编辑', colors.primary, () {
  573. context.push('/vehicle/apply', extra: vehicle.id);
  574. });
  575. } else if (vehicle.status == 'returned') {
  576. return Container(
  577. height: 72,
  578. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
  579. decoration: BoxDecoration(color: colors.bgCard),
  580. child: Center(
  581. child: Text(
  582. '已还车归档于 ${vehicle.actualReturnTime != null ? du.DateUtils.formatDateTime(vehicle.actualReturnTime!) : ''}',
  583. style: TextStyle(
  584. fontSize: AppFontSizes.caption,
  585. color: colors.textSecondary,
  586. ),
  587. ),
  588. ),
  589. );
  590. }
  591. if (actionButton == null) return const SizedBox.shrink();
  592. return Container(
  593. height: 72,
  594. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
  595. decoration: BoxDecoration(color: colors.bgCard),
  596. child: Row(children: [const Spacer(), actionButton, const Spacer()]),
  597. );
  598. }
  599. Widget _singleButton(String label, Color color, VoidCallback onTap) {
  600. return SizedBox(
  601. height: 40,
  602. child: Material(
  603. color: color,
  604. borderRadius: BorderRadius.circular(22),
  605. child: InkWell(
  606. onTap: onTap,
  607. borderRadius: BorderRadius.circular(22),
  608. child: Center(
  609. child: Padding(
  610. padding: const EdgeInsets.symmetric(horizontal: 32),
  611. child: Text(
  612. label,
  613. style: const TextStyle(
  614. fontSize: AppFontSizes.body,
  615. fontWeight: FontWeight.w500,
  616. color: Colors.white,
  617. ),
  618. ),
  619. ),
  620. ),
  621. ),
  622. ),
  623. );
  624. }
  625. (IconData, Color, String) _statusProps(String status) {
  626. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  627. switch (status) {
  628. case 'approved':
  629. return (Icons.check_circle, colors.success, '已通过');
  630. case 'rejected':
  631. return (Icons.cancel, colors.danger, '已拒绝');
  632. case 'draft':
  633. return (Icons.edit_note, colors.statusGray, '草稿');
  634. case 'withdrawn':
  635. return (Icons.cancel_outlined, colors.revokedText, '已撤回');
  636. case 'returned':
  637. return (Icons.assignment_return, colors.primary, '已还车');
  638. default:
  639. return (Icons.access_time, colors.warning, '审批中');
  640. }
  641. }
  642. String _statusSubText(VehicleModel vehicle) {
  643. switch (vehicle.status) {
  644. case 'pending':
  645. return vehicle.approvalInstanceId.isNotEmpty ? '审批中,请耐心等待' : '等待审批';
  646. case 'approved':
  647. return '已通过,请按时出车';
  648. case 'returned':
  649. return vehicle.actualReturnTime != null
  650. ? '还车时间:${du.DateUtils.formatDateTime(vehicle.actualReturnTime!)}'
  651. : '已还车归档';
  652. default:
  653. return '';
  654. }
  655. }
  656. }