vehicle_apply_detail_page.dart 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648
  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/navigation/host_app_channel.dart';
  7. import '../../core/theme/app_colors.dart';
  8. import '../../core/theme/app_colors_extension.dart';
  9. import '../../core/utils/date_utils.dart' as du;
  10. import '../../core/utils/amount_utils.dart';
  11. import '../../shared/widgets/app_skeletons.dart';
  12. import '../../shared/widgets/bill_status_bar.dart';
  13. import '../../shared/widgets/loading_dialog.dart';
  14. import '../../shared/widgets/trip_route_card.dart';
  15. import '../../shared/widgets/form_field_row.dart';
  16. import '../../shared/widgets/form_section.dart';
  17. import 'vehicle_apply_api.dart';
  18. import 'vehicle_apply_list_controller.dart';
  19. import 'vehicle_apply_model.dart';
  20. import 'widgets/return_car_dialog.dart';
  21. class VehicleApplyDetailPage extends ConsumerStatefulWidget {
  22. final String billNo;
  23. const VehicleApplyDetailPage({super.key, required this.billNo});
  24. @override
  25. ConsumerState<VehicleApplyDetailPage> createState() =>
  26. _VehicleApplyDetailPageState();
  27. }
  28. class _VehicleApplyDetailPageState
  29. extends ConsumerState<VehicleApplyDetailPage> {
  30. bool _loading = true;
  31. String? _error;
  32. VehicleApplyModel? _data;
  33. BillStatusBar? _billStatusBar;
  34. @override
  35. void initState() {
  36. super.initState();
  37. _loadData();
  38. }
  39. Future<void> _loadData() async {
  40. setState(() {
  41. _loading = true;
  42. _error = null;
  43. });
  44. try {
  45. // 先用 mock 数据匹配 billNo
  46. final match = mockVehicles.where((e) => e.ycNo == widget.billNo);
  47. if (match.isNotEmpty) {
  48. if (mounted) {
  49. setState(() {
  50. _data = match.first;
  51. _loading = false;
  52. });
  53. }
  54. } else {
  55. final api = ref.read(vehicleApplyApiProvider);
  56. final detail = await api.fetchDetail(widget.billNo);
  57. if (!mounted) return;
  58. // 获取单据状态 + 审核配置 + 还车权限(非致命),与详情合并到一个 setState 避免中间闪烁
  59. Map<String, dynamic>? status;
  60. Map<String, dynamic>? auditConfig;
  61. Map<String, dynamic>? rights;
  62. try {
  63. final results = await Future.wait([
  64. api.getBillStatus(widget.billNo),
  65. api.getBillAuditConfig('YC'),
  66. api.getUserRights('RHB'),
  67. ]);
  68. final Map<String, dynamic> s = results[0];
  69. final Map<String, dynamic> c = results[1];
  70. final Map<String, dynamic> r = results[2];
  71. status = s;
  72. auditConfig = c;
  73. rights = r;
  74. } catch (_) {}
  75. final hasAuditFlow = auditConfig?['hasAuditFlow'] == true;
  76. final canReturn =
  77. rights?['canAdd'] == true || rights?['canModify'] == true;
  78. if (mounted) {
  79. setState(() {
  80. _data = detail;
  81. _billStatusBar = (status != null)
  82. ? BillStatusBar(
  83. billStatus: status,
  84. hasAuditFlow: hasAuditFlow,
  85. returnedText: AppLocalizations.of(
  86. context,
  87. ).get('vehicleReturned'),
  88. onEdit: () {
  89. GoRouter.of(context)
  90. .push('/vehicle-apply/edit/${widget.billNo}')
  91. .then((result) {
  92. if (result == true && mounted) _loadData();
  93. });
  94. },
  95. onSubmit: () async {
  96. final l10n = AppLocalizations.of(context);
  97. final dd = _data?.ycDd != null
  98. ? du.DateUtils.formatDate(_data!.ycDd!)
  99. : '';
  100. LoadingDialog.show(context, text: l10n.get('submitting'));
  101. try {
  102. await api.shSubmit(
  103. bilNo: widget.billNo,
  104. bilDd: dd,
  105. );
  106. } finally {
  107. if (mounted) LoadingDialog.hide(context);
  108. }
  109. if (mounted) _loadData();
  110. },
  111. onCancelSubmit: () async {
  112. final l10n = AppLocalizations.of(context);
  113. final dd = _data?.ycDd != null
  114. ? du.DateUtils.formatDate(_data!.ycDd!)
  115. : '';
  116. LoadingDialog.show(context, text: l10n.get('cancelling'));
  117. try {
  118. await api.shSubmit(
  119. bilNo: widget.billNo,
  120. bilDd: dd,
  121. isCancel: true,
  122. );
  123. } finally {
  124. if (mounted) LoadingDialog.hide(context);
  125. }
  126. if (mounted) _loadData();
  127. },
  128. onTapStatusTag: () => _showAuditTrail('YC'),
  129. onReturn: canReturn
  130. ? () {
  131. ReturnCarDialog.show(
  132. context,
  133. billNo: widget.billNo,
  134. odometerBegin: _data?.odometerBegin ?? 0,
  135. recordDd: _data?.recordDd,
  136. onSubmit: (returnData) async {
  137. final api = ref.read(vehicleApplyApiProvider);
  138. returnData['usrCheck'] = HostAppChannel.usr;
  139. try {
  140. await api.submitReturn(
  141. widget.billNo,
  142. returnData,
  143. );
  144. if (mounted) _loadData();
  145. return true;
  146. } catch (_) {
  147. return false;
  148. }
  149. },
  150. );
  151. }
  152. : null,
  153. onEditReturn: () {
  154. ReturnCarDialog.show(
  155. context,
  156. billNo: widget.billNo,
  157. odometerBegin: _data?.odometerBegin ?? 0,
  158. recordDd: _data?.recordDd,
  159. existingReturnTime: _data?.returnTime,
  160. existingOdometerEnd: _data?.odometerEnd ?? 0,
  161. existingAmtn: _data?.amtn ?? 0.0,
  162. existingRemark: _data?.remark ?? '',
  163. onSubmit: (returnData) async {
  164. final api = ref.read(vehicleApplyApiProvider);
  165. returnData['usrCheck'] = HostAppChannel.usr;
  166. try {
  167. await api.submitReturn(
  168. widget.billNo,
  169. returnData,
  170. );
  171. if (mounted) _loadData();
  172. return true;
  173. } catch (_) {
  174. return false;
  175. }
  176. },
  177. );
  178. },
  179. )
  180. : null;
  181. _loading = false;
  182. });
  183. }
  184. }
  185. } catch (e) {
  186. if (mounted) {
  187. setState(() {
  188. _error = e.toString();
  189. _loading = false;
  190. });
  191. }
  192. }
  193. }
  194. @override
  195. Widget build(BuildContext context) {
  196. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  197. final l10n = AppLocalizations.of(context);
  198. if (_loading) return const SkeletonDetailPage(sectionRows: [5, 6, 4, 1]);
  199. if (_error != null) {
  200. return Center(
  201. child: Column(
  202. mainAxisSize: MainAxisSize.min,
  203. children: [
  204. Icon(Icons.error_outline, size: 48, color: colors.danger),
  205. const SizedBox(height: 16),
  206. Padding(
  207. padding: const EdgeInsets.symmetric(horizontal: 32),
  208. child: Text(
  209. _error!,
  210. textAlign: TextAlign.center,
  211. style: TextStyle(
  212. fontSize: AppFontSizes.body,
  213. color: colors.textSecondary,
  214. ),
  215. ),
  216. ),
  217. const SizedBox(height: 16),
  218. TDButton(
  219. text: l10n.get('retry'),
  220. size: TDButtonSize.medium,
  221. onTap: _loadData,
  222. ),
  223. ],
  224. ),
  225. );
  226. }
  227. final app = _data!;
  228. return Column(
  229. children: [
  230. Expanded(
  231. child: SingleChildScrollView(
  232. physics: const AlwaysScrollableScrollPhysics(),
  233. padding: const EdgeInsets.all(16),
  234. child: Column(
  235. children: [
  236. _buildBasicInfoSection(app, l10n, colors),
  237. const SizedBox(height: 16),
  238. _buildVehicleInfoSection(app, l10n, colors),
  239. const SizedBox(height: 16),
  240. _buildTripInfoSection(app, l10n, colors),
  241. const SizedBox(height: 16),
  242. _buildPassengerInfoSection(app, l10n, colors),
  243. if (app.isReturn) ...[
  244. const SizedBox(height: 16),
  245. _buildReturnInfoSection(app, l10n, colors),
  246. ],
  247. const SizedBox(height: 24),
  248. _buildPageFooter(colors),
  249. ],
  250. ),
  251. ),
  252. ),
  253. _billStatusBar?.buildActions(context) ?? const SizedBox.shrink(),
  254. ],
  255. );
  256. }
  257. // ═══ 基本信息 ═══
  258. Widget _buildBasicInfoSection(
  259. VehicleApplyModel app,
  260. AppLocalizations l10n,
  261. AppColorsExtension colors,
  262. ) {
  263. return FormSection(
  264. title: l10n.get('basicInfo'),
  265. leadingIcon: Icons.info_outline,
  266. trailing:
  267. _billStatusBar?.buildStatusTag(context) ?? const SizedBox.shrink(),
  268. children: [
  269. FormFieldRow(
  270. label: l10n.get('vehicleApplyNo'),
  271. value: app.ycNo,
  272. readOnly: true,
  273. showArrow: false,
  274. ),
  275. const SizedBox(height: 16),
  276. FormFieldRow(
  277. label: l10n.get('date'),
  278. value: app.ycDd != null ? du.DateUtils.formatDate(app.ycDd!) : '-',
  279. readOnly: true,
  280. showArrow: false,
  281. ),
  282. const SizedBox(height: 16),
  283. FormFieldRow(
  284. label: l10n.get('applicant'),
  285. value: app.applicantName.isNotEmpty ? app.applicantName : app.salNo,
  286. readOnly: true,
  287. showArrow: false,
  288. ),
  289. const SizedBox(height: 16),
  290. FormFieldRow(
  291. label: l10n.get('dep'),
  292. value: app.dep.isNotEmpty
  293. ? '${app.dep}${app.deptName.isNotEmpty ? '/${app.deptName}' : ''}'
  294. : '-',
  295. readOnly: true,
  296. showArrow: false,
  297. ),
  298. const SizedBox(height: 16),
  299. FormFieldRow(
  300. label: l10n.get('vehicleReason'),
  301. value: app.reason.isNotEmpty ? app.reason : '-',
  302. readOnly: true,
  303. showArrow: false,
  304. bold: true,
  305. showMoreOnOverflow: true,
  306. ),
  307. ],
  308. );
  309. }
  310. // ═══ 车辆信息 ═══
  311. Widget _buildVehicleInfoSection(
  312. VehicleApplyModel app,
  313. AppLocalizations l10n,
  314. AppColorsExtension colors,
  315. ) {
  316. return FormSection(
  317. title: l10n.get('vehicleInfo'),
  318. leadingIcon: Icons.directions_car_outlined,
  319. children: [
  320. FormFieldRow(
  321. label: l10n.get('licensePlate'),
  322. value: app.licensePlate.isNotEmpty ? app.licensePlate : '-',
  323. readOnly: true,
  324. showArrow: false,
  325. bold: true,
  326. valueColor: colors.platePrimary,
  327. ),
  328. const SizedBox(height: 16),
  329. FormFieldRow(
  330. label: l10n.get('vehicleType'),
  331. value: app.vehicleType.isNotEmpty
  332. ? _vehicleTypeLabel(app.vehicleType, l10n)
  333. : '-',
  334. readOnly: true,
  335. showArrow: false,
  336. ),
  337. const SizedBox(height: 16),
  338. FormFieldRow(
  339. label: l10n.get('brand'),
  340. value: app.brand.isNotEmpty ? app.brand : '-',
  341. readOnly: true,
  342. showArrow: false,
  343. ),
  344. const SizedBox(height: 16),
  345. FormFieldRow(
  346. label: l10n.get('seats'),
  347. value: app.seats > 0 ? '${app.seats}' : '-',
  348. readOnly: true,
  349. showArrow: false,
  350. ),
  351. const SizedBox(height: 16),
  352. FormFieldRow(
  353. label: l10n.get('odometerBegin'),
  354. value: app.odometerBegin > 0 ? '${app.odometerBegin} km' : '-',
  355. readOnly: true,
  356. showArrow: false,
  357. ),
  358. const SizedBox(height: 16),
  359. FormFieldRow(
  360. label: l10n.get('driverName'),
  361. value: app.driverName.isNotEmpty ? app.driverName : '-',
  362. readOnly: true,
  363. showArrow: false,
  364. ),
  365. ],
  366. );
  367. }
  368. // ═══ 行程信息 ═══
  369. Widget _buildTripInfoSection(
  370. VehicleApplyModel app,
  371. AppLocalizations l10n,
  372. AppColorsExtension colors,
  373. ) {
  374. return FormSection(
  375. title: l10n.get('tripInfo'),
  376. leadingIcon: Icons.route_outlined,
  377. children: [
  378. FormFieldRow(
  379. label: l10n.get('departTime'),
  380. value: app.startTime != null
  381. ? du.DateUtils.formatDateTime(app.startTime!)
  382. : '-',
  383. readOnly: true,
  384. showArrow: false,
  385. ),
  386. const SizedBox(height: 16),
  387. FormFieldRow(
  388. label: l10n.get('returnTime'),
  389. value: app.endTime != null
  390. ? du.DateUtils.formatDateTime(app.endTime!)
  391. : '-',
  392. readOnly: true,
  393. showArrow: false,
  394. ),
  395. const SizedBox(height: 16),
  396. if (app.originAdr.isEmpty && app.destAdr.isEmpty) ...[
  397. FormFieldRow(
  398. label: l10n.get('origin'),
  399. value: '-',
  400. readOnly: true,
  401. showArrow: false,
  402. ),
  403. const SizedBox(height: 16),
  404. FormFieldRow(
  405. label: l10n.get('destination'),
  406. value: '-',
  407. readOnly: true,
  408. showArrow: false,
  409. ),
  410. ] else
  411. TripRouteCard(
  412. originLabel: app.originAdr.isNotEmpty ? app.originAdr : '-',
  413. destLabel: app.destAdr.isNotEmpty ? app.destAdr : '-',
  414. originLat: app.originLatitude,
  415. originLng: app.originLongitude,
  416. destLat: app.destLatitude,
  417. destLng: app.destLongitude,
  418. ),
  419. ],
  420. );
  421. }
  422. // ═══ 同行信息 ═══
  423. Widget _buildPassengerInfoSection(
  424. VehicleApplyModel app,
  425. AppLocalizations l10n,
  426. AppColorsExtension colors,
  427. ) {
  428. final names = app.passengerName.isNotEmpty
  429. ? app.passengerName
  430. .split(';')
  431. .where((n) => n.trim().isNotEmpty)
  432. .toList()
  433. : <String>[];
  434. return FormSection(
  435. title: l10n.get('companionInfo'),
  436. leadingIcon: Icons.people_outline,
  437. children: [
  438. if (names.isEmpty)
  439. Padding(
  440. padding: const EdgeInsets.symmetric(vertical: 8),
  441. child: Text(
  442. '-',
  443. style: TextStyle(
  444. fontSize: AppFontSizes.subtitle,
  445. color: colors.textPlaceholder,
  446. ),
  447. ),
  448. )
  449. else
  450. Wrap(
  451. spacing: 10,
  452. runSpacing: 10,
  453. children: names.map((name) {
  454. return Container(
  455. padding: const EdgeInsets.symmetric(
  456. horizontal: 14,
  457. vertical: 10,
  458. ),
  459. decoration: BoxDecoration(
  460. color: colors.bgCard,
  461. borderRadius: BorderRadius.circular(20),
  462. border: Border.all(color: colors.border, width: 0.5),
  463. boxShadow: [
  464. BoxShadow(
  465. color: Colors.black.withValues(alpha: 0.04),
  466. blurRadius: 4,
  467. offset: const Offset(0, 1),
  468. ),
  469. ],
  470. ),
  471. child: Row(
  472. mainAxisSize: MainAxisSize.min,
  473. children: [
  474. Icon(Icons.person_outline, size: 16, color: colors.primary),
  475. const SizedBox(width: 8),
  476. Text(
  477. name.trim(),
  478. style: TextStyle(
  479. fontSize: AppFontSizes.body,
  480. color: colors.textPrimary,
  481. ),
  482. ),
  483. ],
  484. ),
  485. );
  486. }).toList(),
  487. ),
  488. const SizedBox(height: 16),
  489. Row(
  490. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  491. children: [
  492. Text(
  493. l10n.get('passengerCount'),
  494. style: TextStyle(
  495. fontSize: AppFontSizes.body,
  496. fontWeight: FontWeight.w600,
  497. color: colors.textPrimary,
  498. ),
  499. ),
  500. Text(
  501. '${app.passengerCount}',
  502. style: TextStyle(
  503. fontSize: AppFontSizes.subtitle,
  504. fontWeight: FontWeight.w700,
  505. color: colors.textPrimary,
  506. ),
  507. ),
  508. ],
  509. ),
  510. ],
  511. );
  512. }
  513. // ═══ 审批轨迹 ═══
  514. Future<void> _showAuditTrail(String billId) async {
  515. await HostAppChannel.showAuditTrail(billId, widget.billNo);
  516. }
  517. // ═══ 还车信息 ═══
  518. Widget _buildReturnInfoSection(
  519. VehicleApplyModel app,
  520. AppLocalizations l10n,
  521. AppColorsExtension colors,
  522. ) {
  523. return FormSection(
  524. title: l10n.get('returnCarInfo'),
  525. leadingIcon: Icons.assignment_return_outlined,
  526. children: [
  527. FormFieldRow(
  528. label: l10n.get('actualReturnTime'),
  529. value: app.returnTime != null
  530. ? du.DateUtils.formatDateTime(app.returnTime!)
  531. : '-',
  532. readOnly: true,
  533. showArrow: false,
  534. ),
  535. const SizedBox(height: 16),
  536. FormFieldRow(
  537. label: l10n.get('odometerEnd'),
  538. value: app.odometerEnd > 0 ? '${app.odometerEnd} km' : '-',
  539. readOnly: true,
  540. showArrow: false,
  541. ),
  542. const SizedBox(height: 16),
  543. FormFieldRow(
  544. label: l10n.get('amtn'),
  545. value: app.amtn > 0 ? formatAmount(app.amtn) : '-',
  546. readOnly: true,
  547. showArrow: false,
  548. valueColor: colors.amountPrimary,
  549. ),
  550. const SizedBox(height: 16),
  551. FormFieldRow(
  552. label: l10n.get('remark'),
  553. value: app.remark.isNotEmpty ? app.remark : '-',
  554. readOnly: true,
  555. showArrow: false,
  556. showMoreOnOverflow: true,
  557. ),
  558. const SizedBox(height: 16),
  559. FormFieldRow(
  560. label: l10n.get('usrCheck'),
  561. value: app.usrCheck.isNotEmpty ? app.usrCheck : '-',
  562. readOnly: true,
  563. showArrow: false,
  564. ),
  565. const SizedBox(height: 16),
  566. FormFieldRow(
  567. label: l10n.get('checkDd'),
  568. value: app.checkDd != null
  569. ? du.DateUtils.formatDateTime(app.checkDd!)
  570. : '-',
  571. readOnly: true,
  572. showArrow: false,
  573. ),
  574. ],
  575. );
  576. }
  577. Widget _buildPageFooter(AppColorsExtension colors) {
  578. final l10n = AppLocalizations.of(context);
  579. return Center(
  580. child: Padding(
  581. padding: const EdgeInsets.only(bottom: 16),
  582. child: Row(
  583. mainAxisSize: MainAxisSize.min,
  584. children: [
  585. Icon(
  586. Icons.rocket_launch_outlined,
  587. size: 16,
  588. color: colors.textPlaceholder,
  589. ),
  590. const SizedBox(width: 6),
  591. Text(
  592. l10n.get('pageFooter'),
  593. style: TextStyle(
  594. fontSize: AppFontSizes.caption,
  595. color: colors.textPlaceholder,
  596. ),
  597. ),
  598. ],
  599. ),
  600. ),
  601. );
  602. }
  603. String _vehicleTypeLabel(String key, AppLocalizations l10n) {
  604. switch (key) {
  605. case 'sedan':
  606. return l10n.get('sedan');
  607. case 'suv':
  608. return 'SUV';
  609. case 'mpv':
  610. return l10n.get('businessVan');
  611. case 'van':
  612. return l10n.get('van');
  613. case 'truck':
  614. return l10n.get('truck');
  615. case 'pickup':
  616. return l10n.get('pickup');
  617. case 'minibus':
  618. return l10n.get('minibus');
  619. case 'bus':
  620. return l10n.get('bus');
  621. default:
  622. return key;
  623. }
  624. }
  625. }