vehicle_apply_detail_page.dart 17 KB

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