vehicle_apply_detail_page.dart 14 KB

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