vehicle_apply_detail_page.dart 15 KB

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