vehicle_apply_detail_page.dart 19 KB

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