vehicle_apply_create_page.dart 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194
  1. import 'dart:async';
  2. import 'package:flutter/material.dart';
  3. import 'package:flutter/services.dart';
  4. import 'package:flutter_riverpod/flutter_riverpod.dart';
  5. import 'package:go_router/go_router.dart';
  6. import 'package:tdesign_flutter/tdesign_flutter.dart';
  7. import '../../core/i18n/app_localizations.dart';
  8. import '../../core/navigation/host_app_channel.dart';
  9. import '../../core/storage/draft_storage.dart';
  10. import '../../core/theme/app_colors.dart';
  11. import '../../core/theme/app_colors_extension.dart';
  12. import '../../shared/widgets/action_bar.dart';
  13. import '../../shared/widgets/app_skeletons.dart';
  14. import '../../shared/widgets/form_field_row.dart';
  15. import '../../shared/widgets/form_section.dart';
  16. import '../../shared/widgets/loading_dialog.dart';
  17. import '../../shared/widgets/nav_bar_config.dart';
  18. import '../../shared/widgets/app_input_dialog.dart';
  19. import '../../shared/widgets/searchable_picker_sheet.dart';
  20. import '../../shared/widgets/location_picker.dart';
  21. import '../../shared/helpers/audit_flow_helper.dart';
  22. import 'vehicle_apply_api.dart';
  23. class VehicleApplyCreatePage extends ConsumerStatefulWidget {
  24. const VehicleApplyCreatePage({super.key});
  25. @override
  26. ConsumerState<VehicleApplyCreatePage> createState() =>
  27. _VehicleApplyCreatePageState();
  28. }
  29. class _VehicleApplyCreatePageState
  30. extends ConsumerState<VehicleApplyCreatePage> {
  31. static const _draftKey = 'vehicle_apply';
  32. // ── 基本信息 ──
  33. final _reasonController = TextEditingController();
  34. String _originAdr = '';
  35. String _destAdr = '';
  36. double? _originLng;
  37. double? _originLat;
  38. double? _destLng;
  39. double? _destLat;
  40. DateTime? _startTime;
  41. DateTime? _endTime;
  42. // ── 车辆信息 ──
  43. String _licensePlate = '';
  44. String _vehicleType = '';
  45. String _brand = '';
  46. int _seats = 0;
  47. // ── 车辆信息 ──
  48. int _odometerBegin = 0;
  49. // ── 驾驶员 ──
  50. String _driverName = '';
  51. // ── 同行信息 ──
  52. final List<EmployeeItem> _passengers = [];
  53. // ── 草稿 ──
  54. late Future<bool> _draftFuture;
  55. bool _draftHandled = false;
  56. // ── 参考数据 ──
  57. List<DepartmentItem> _departments = [];
  58. List<EmployeeItem> _employees = [];
  59. bool _firstBuild = true;
  60. bool _refDataLoading = true;
  61. String _selectedDeptId = '';
  62. String _selectedDeptName = '';
  63. String _selectedApplicantId = '';
  64. String _selectedApplicantName = '';
  65. final _scrollCtrl = ScrollController();
  66. // Mock 车辆类型
  67. static const _mockVehicleTypes = [
  68. 'sedan',
  69. 'suv',
  70. 'mpv',
  71. 'van',
  72. 'truck',
  73. 'pickup',
  74. 'minibus',
  75. 'bus',
  76. ];
  77. @override
  78. void initState() {
  79. super.initState();
  80. SystemChrome.setSystemUIOverlayStyle(
  81. const SystemUiOverlayStyle(
  82. statusBarColor: Colors.transparent,
  83. statusBarIconBrightness: Brightness.dark,
  84. ),
  85. );
  86. _departments = [];
  87. _refDataLoading = true;
  88. _refDataFuture = null;
  89. _draftFuture = DraftStorage.has(_draftKey);
  90. _loadRefData();
  91. WidgetsBinding.instance.addPostFrameCallback((_) => _checkDataReady());
  92. }
  93. void _showDraftDialog() {
  94. final l10n = AppLocalizations.of(context);
  95. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  96. FocusManager.instance.primaryFocus?.unfocus();
  97. showDialog(
  98. context: context,
  99. barrierDismissible: false,
  100. builder: (ctx) => TDAlertDialog(
  101. title: l10n.get('draftFound'),
  102. content: l10n.get('draftRestorePrompt'),
  103. leftBtn: TDDialogButtonOptions(
  104. title: l10n.get('discard'),
  105. titleColor: colors.textSecondary,
  106. action: () {
  107. Navigator.pop(ctx);
  108. DraftStorage.delete(_draftKey);
  109. },
  110. ),
  111. rightBtn: TDDialogButtonOptions(
  112. title: l10n.get('restore'),
  113. titleColor: colors.primary,
  114. action: () async {
  115. Navigator.pop(ctx);
  116. final data = await DraftStorage.load(_draftKey);
  117. if (data != null && mounted) {
  118. setState(() => _applyDraftData(data));
  119. }
  120. },
  121. ),
  122. ),
  123. );
  124. }
  125. void _applyDraftData(Map<String, dynamic> data) {
  126. _reasonController.text = data['reason'] as String? ?? '';
  127. _originAdr = data['originAdr'] as String? ?? '';
  128. _originLat = (data['originLat'] as num?)?.toDouble();
  129. _originLng = (data['originLng'] as num?)?.toDouble();
  130. _destAdr = data['destAdr'] as String? ?? '';
  131. _destLat = (data['destLat'] as num?)?.toDouble();
  132. _destLng = (data['destLng'] as num?)?.toDouble();
  133. final startStr = data['startTime'] as String?;
  134. _startTime = startStr != null ? DateTime.tryParse(startStr) : null;
  135. final endStr = data['endTime'] as String?;
  136. _endTime = endStr != null ? DateTime.tryParse(endStr) : null;
  137. _licensePlate = data['licensePlate'] as String? ?? '';
  138. _vehicleType = data['vehicleType'] as String? ?? '';
  139. _brand = data['brand'] as String? ?? '';
  140. _odometerBegin = data['odometerBegin'] as int? ?? 0;
  141. _seats = data['seats'] as int? ?? 0;
  142. _driverName = data['driverName'] as String? ?? '';
  143. _selectedDeptId = data['selectedDeptId'] as String? ?? '';
  144. _selectedDeptName = data['selectedDeptName'] as String? ?? '';
  145. _selectedApplicantId = data['selectedApplicantId'] as String? ?? '';
  146. _selectedApplicantName = data['selectedApplicantName'] as String? ?? '';
  147. // 恢复同行人
  148. final pCount = data['passengerCount'] as int? ?? 0;
  149. final pName = data['passengerName'] as String? ?? '';
  150. if (pCount > 0 && pName.isNotEmpty) {
  151. final parts = pName.split(';');
  152. for (final part in parts) {
  153. if (part.isEmpty) continue;
  154. final idx = part.indexOf('/');
  155. if (idx > 0) {
  156. _passengers.add(
  157. EmployeeItem(
  158. salNo: part.substring(0, idx),
  159. name: part.substring(idx + 1),
  160. ),
  161. );
  162. } else {
  163. _passengers.add(EmployeeItem(salNo: part, name: part));
  164. }
  165. }
  166. }
  167. }
  168. void _checkDataReady() {
  169. if (!_refDataLoading && mounted) {
  170. setState(() => _firstBuild = false);
  171. WidgetsBinding.instance.addPostFrameCallback((_) {
  172. if (mounted) setState(() {});
  173. });
  174. } else if (mounted) {
  175. WidgetsBinding.instance.addPostFrameCallback((_) => _checkDataReady());
  176. }
  177. }
  178. Future<void>? _refDataFuture;
  179. Future<void> _loadRefData({bool showLoading = false}) async {
  180. if (_refDataFuture != null) return _refDataFuture!;
  181. final completer = Completer<void>();
  182. _refDataFuture = completer.future;
  183. if (showLoading) {
  184. LoadingDialog.show(
  185. context,
  186. text: AppLocalizations.of(context).get('dataLoading'),
  187. );
  188. }
  189. try {
  190. final api = ref.read(vehicleApplyApiProvider);
  191. final results = await Future.wait([
  192. api.getDepartments(),
  193. api.getEmployees(),
  194. ]);
  195. if (!mounted) return;
  196. setState(() {
  197. _departments = results[0] as List<DepartmentItem>;
  198. _employees = results[1] as List<EmployeeItem>;
  199. _refDataLoading = false;
  200. _autoSelectDept();
  201. _autoSelectApplicant();
  202. _autoSelectPassenger();
  203. _autoSelectDriver();
  204. });
  205. completer.complete();
  206. } catch (_) {
  207. if (!mounted) {
  208. completer.complete();
  209. return;
  210. }
  211. setState(() => _refDataLoading = false);
  212. completer.complete();
  213. } finally {
  214. if (showLoading && mounted) LoadingDialog.hide(context);
  215. _refDataFuture = null;
  216. }
  217. }
  218. void _autoSelectDept() {
  219. if (_selectedDeptId.isNotEmpty) return;
  220. final dep = HostAppChannel.dep;
  221. if (dep.isEmpty) return;
  222. final match = _departments.where((d) => d.dep == dep);
  223. if (match.isNotEmpty) {
  224. _selectedDeptId = match.first.dep;
  225. _selectedDeptName = match.first.name;
  226. }
  227. }
  228. void _autoSelectApplicant() {
  229. if (_selectedApplicantId.isNotEmpty) return;
  230. final usr = HostAppChannel.usr;
  231. if (usr.isEmpty) return;
  232. final match = _employees.where((e) => e.salNo == usr);
  233. if (match.isNotEmpty) {
  234. _selectedApplicantId = match.first.salNo;
  235. _selectedApplicantName = match.first.name;
  236. }
  237. }
  238. void _autoSelectPassenger() {
  239. final usr = HostAppChannel.usr;
  240. if (usr.isEmpty) return;
  241. final match = _employees.where((e) => e.salNo == usr);
  242. if (match.isNotEmpty && !_passengers.any((p) => p.salNo == usr)) {
  243. _passengers.add(match.first);
  244. }
  245. }
  246. void _autoSelectDriver() {
  247. if (_driverName.isNotEmpty) return;
  248. final usr = HostAppChannel.usr;
  249. if (usr.isEmpty) return;
  250. final match = _employees.where((e) => e.salNo == usr);
  251. if (match.isNotEmpty) {
  252. _driverName = match.first.name;
  253. }
  254. }
  255. @override
  256. void dispose() {
  257. _reasonController.dispose();
  258. _scrollCtrl.dispose();
  259. super.dispose();
  260. }
  261. @override
  262. Widget build(BuildContext context) {
  263. final l10n = AppLocalizations.of(context);
  264. if (_firstBuild) {
  265. return const SkeletonFormPage(
  266. sectionRows: [4, 6, 4, 1],
  267. bottomButtonCount: 2,
  268. );
  269. }
  270. Future.microtask(
  271. () => ref.read(pageBackProvider.notifier).state = () => _doPop(),
  272. );
  273. return FutureBuilder<bool>(
  274. future: _draftFuture,
  275. builder: (ctx, snapshot) {
  276. final hasDraft = snapshot.hasData && snapshot.data == true;
  277. if (hasDraft && !_draftHandled) {
  278. _draftHandled = true;
  279. WidgetsBinding.instance.addPostFrameCallback((_) {
  280. if (mounted) _showDraftDialog();
  281. });
  282. }
  283. return PopScope(
  284. canPop: false,
  285. onPopInvokedWithResult: (didPop, _) {
  286. if (didPop) return;
  287. _doPop();
  288. },
  289. child: Column(
  290. children: [
  291. Expanded(
  292. child: GestureDetector(
  293. onTap: () => FocusScope.of(context).unfocus(),
  294. child: SingleChildScrollView(
  295. controller: _scrollCtrl,
  296. padding: const EdgeInsets.all(16),
  297. child: Column(
  298. children: [
  299. _buildBasicInfo(l10n),
  300. const SizedBox(height: 16),
  301. _buildVehicleInfo(l10n),
  302. const SizedBox(height: 16),
  303. _buildTripInfo(l10n),
  304. const SizedBox(height: 16),
  305. _buildPassengerInfo(l10n),
  306. const SizedBox(height: 24),
  307. _buildPageFooter(),
  308. ],
  309. ),
  310. ),
  311. ),
  312. ),
  313. _buildBottomBar(l10n),
  314. ],
  315. ),
  316. );
  317. },
  318. );
  319. }
  320. // ═══ 基本信息 ═══
  321. Widget _buildBasicInfo(AppLocalizations l10n) {
  322. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  323. return FormSection(
  324. title: l10n.get('basicInfo'),
  325. leadingIcon: Icons.info_outline,
  326. children: [
  327. FormFieldRow(
  328. label: l10n.get('date'),
  329. value: _today(),
  330. readOnly: true,
  331. showArrow: false,
  332. ),
  333. const SizedBox(height: 16),
  334. FormFieldRow(
  335. label: l10n.get('dep'),
  336. value: _selectedDeptId.isNotEmpty
  337. ? '$_selectedDeptId/$_selectedDeptName'
  338. : '',
  339. hint: l10n.get('pleaseSelect'),
  340. onTap: _refDataLoading ? null : () => _showDeptPicker(),
  341. ),
  342. const SizedBox(height: 16),
  343. FormFieldRow(
  344. label: l10n.get('applicant'),
  345. required: true,
  346. value: _selectedApplicantId.isNotEmpty
  347. ? '$_selectedApplicantId/$_selectedApplicantName'
  348. : '',
  349. hint: l10n.get('pleaseSelect'),
  350. onTap: () => _showApplicantPicker(),
  351. ),
  352. const SizedBox(height: 16),
  353. _label(l10n.get('vehicleReason'), required: true),
  354. const SizedBox(height: 8),
  355. TDTextarea(
  356. controller: _reasonController,
  357. hintText: l10n.get('enterVehicleReason'),
  358. maxLines: 4,
  359. minLines: 1,
  360. maxLength: 500,
  361. indicator: true,
  362. padding: EdgeInsets.zero,
  363. bordered: true,
  364. backgroundColor: colors.bgPage,
  365. ),
  366. ],
  367. );
  368. }
  369. // ═══ 行程信息 ═══
  370. Widget _buildTripInfo(AppLocalizations l10n) {
  371. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  372. final timeError =
  373. _startTime != null &&
  374. _endTime != null &&
  375. !_endTime!.isAfter(_startTime!);
  376. return FormSection(
  377. title: l10n.get('tripInfo'),
  378. leadingIcon: Icons.route_outlined,
  379. children: [
  380. FormFieldRow(
  381. label: l10n.get('departTime'),
  382. value: _startTime != null ? _formatDateTime(_startTime!) : null,
  383. hint: l10n.get('pleaseSelect'),
  384. required: true,
  385. onTap: () => _pickDateTime((d) {
  386. setState(() => _startTime = d);
  387. }, _startTime),
  388. onClear: _startTime != null
  389. ? () => setState(() => _startTime = null)
  390. : null,
  391. ),
  392. const SizedBox(height: 16),
  393. FormFieldRow(
  394. label: l10n.get('returnTime'),
  395. value: _endTime != null ? _formatDateTime(_endTime!) : null,
  396. hint: l10n.get('pleaseSelect'),
  397. required: true,
  398. onTap: () => _pickDateTime((d) {
  399. setState(() => _endTime = d);
  400. }, _endTime),
  401. onClear: _endTime != null
  402. ? () => setState(() => _endTime = null)
  403. : null,
  404. ),
  405. if (timeError)
  406. Padding(
  407. padding: const EdgeInsets.only(top: 8),
  408. child: Row(
  409. mainAxisAlignment: MainAxisAlignment.end,
  410. children: [
  411. Icon(
  412. Icons.warning_amber_rounded,
  413. size: 14,
  414. color: colors.danger,
  415. ),
  416. const SizedBox(width: 4),
  417. Text(
  418. l10n.get('returnTimeMustLater'),
  419. style: TextStyle(
  420. fontSize: AppFontSizes.caption,
  421. color: colors.danger,
  422. ),
  423. ),
  424. ],
  425. ),
  426. ),
  427. const SizedBox(height: 16),
  428. FormFieldRow(
  429. label: l10n.get('origin'),
  430. value: _originAdr.isNotEmpty ? _originAdr : null,
  431. hint: l10n.get('pleaseSelect'),
  432. onTap: () async {
  433. final result = await LocationPicker.show(
  434. context,
  435. initialAddress: _originAdr,
  436. );
  437. if (result != null && mounted) {
  438. setState(() {
  439. _originAdr = result.address;
  440. _originLat = result.latitude;
  441. _originLng = result.longitude;
  442. });
  443. }
  444. },
  445. onClear: _originAdr.isNotEmpty
  446. ? () => setState(() {
  447. _originAdr = '';
  448. _originLat = null;
  449. _originLng = null;
  450. })
  451. : null,
  452. ),
  453. const SizedBox(height: 16),
  454. FormFieldRow(
  455. label: l10n.get('destination'),
  456. value: _destAdr.isNotEmpty ? _destAdr : null,
  457. hint: l10n.get('pleaseSelect'),
  458. onTap: () async {
  459. final result = await LocationPicker.show(
  460. context,
  461. initialAddress: _destAdr,
  462. );
  463. if (result != null && mounted) {
  464. setState(() {
  465. _destAdr = result.address;
  466. _destLat = result.latitude;
  467. _destLng = result.longitude;
  468. });
  469. }
  470. },
  471. onClear: _destAdr.isNotEmpty
  472. ? () => setState(() {
  473. _destAdr = '';
  474. _destLat = null;
  475. _destLng = null;
  476. })
  477. : null,
  478. ),
  479. ],
  480. );
  481. }
  482. // ═══ 车辆信息 ═══
  483. Widget _buildVehicleInfo(AppLocalizations l10n) {
  484. return FormSection(
  485. title: l10n.get('vehicleInfo'),
  486. leadingIcon: Icons.directions_car_outlined,
  487. children: [
  488. FormFieldRow(
  489. label: l10n.get('licensePlate'),
  490. value: _licensePlate.isNotEmpty ? _licensePlate : null,
  491. hint: l10n.get('pleaseEnter'),
  492. required: true,
  493. onTap: () => _showTextInput(
  494. l10n.get('licensePlate'),
  495. (v) => setState(() => _licensePlate = v),
  496. initialText: _licensePlate,
  497. ),
  498. onClear: _licensePlate.isNotEmpty
  499. ? () => setState(() => _licensePlate = '')
  500. : null,
  501. ),
  502. const SizedBox(height: 16),
  503. FormFieldRow(
  504. label: l10n.get('vehicleType'),
  505. value: _vehicleType.isNotEmpty
  506. ? _vehicleTypeLabel(_vehicleType, l10n)
  507. : null,
  508. hint: l10n.get('pleaseSelect'),
  509. onTap: () => _showVehicleTypePicker(l10n),
  510. onClear: _vehicleType.isNotEmpty
  511. ? () => setState(() => _vehicleType = '')
  512. : null,
  513. ),
  514. const SizedBox(height: 16),
  515. FormFieldRow(
  516. label: l10n.get('brand'),
  517. value: _brand.isNotEmpty ? _brand : null,
  518. hint: l10n.get('pleaseEnter'),
  519. onTap: () => _showTextInput(
  520. l10n.get('brand'),
  521. (v) => setState(() => _brand = v),
  522. initialText: _brand,
  523. ),
  524. onClear: _brand.isNotEmpty ? () => setState(() => _brand = '') : null,
  525. ),
  526. const SizedBox(height: 16),
  527. FormFieldRow(
  528. label: l10n.get('seats'),
  529. value: _seats > 0 ? '$_seats' : null,
  530. hint: l10n.get('pleaseEnter'),
  531. onTap: () => _showNumberInput(
  532. l10n.get('seats'),
  533. (v) => setState(() => _seats = v),
  534. _seats,
  535. min: 1,
  536. ),
  537. onClear: _seats > 0 ? () => setState(() => _seats = 0) : null,
  538. ),
  539. const SizedBox(height: 16),
  540. FormFieldRow(
  541. label: l10n.get('odometerBegin'),
  542. value: _odometerBegin > 0 ? '$_odometerBegin' : null,
  543. hint: l10n.get('pleaseEnter'),
  544. onTap: () => _showNumberInput(
  545. l10n.get('odometerBegin'),
  546. (v) => setState(() => _odometerBegin = v),
  547. _odometerBegin,
  548. ),
  549. onClear: _odometerBegin > 0
  550. ? () => setState(() => _odometerBegin = 0)
  551. : null,
  552. ),
  553. const SizedBox(height: 16),
  554. FormFieldRow(
  555. label: l10n.get('driverName'),
  556. value: _driverName.isNotEmpty ? _driverName : null,
  557. hint: l10n.get('pleaseSelect'),
  558. onTap: () => _showDriverPicker(),
  559. onClear: _driverName.isNotEmpty
  560. ? () => setState(() => _driverName = '')
  561. : null,
  562. ),
  563. ],
  564. );
  565. }
  566. // ═══ 同行信息 ═══
  567. Widget _buildPassengerInfo(AppLocalizations l10n) {
  568. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  569. return FormSection(
  570. title: l10n.get('companionInfo'),
  571. leadingIcon: Icons.people_outline,
  572. showAction: true,
  573. actionText: l10n.get('add'),
  574. onActionTap: () => _showMultiEmployeePicker(l10n),
  575. children: [
  576. if (_passengers.isEmpty)
  577. Padding(
  578. padding: const EdgeInsets.symmetric(vertical: 8),
  579. child: Text(
  580. l10n.get('noPassengerHint'),
  581. style: TextStyle(
  582. fontSize: AppFontSizes.subtitle,
  583. color: colors.textPlaceholder,
  584. ),
  585. ),
  586. )
  587. else
  588. Wrap(
  589. spacing: 10,
  590. runSpacing: 10,
  591. children: _passengers.map((p) {
  592. final label =
  593. '${p.salNo}/${p.name.isNotEmpty ? p.name : p.salNo}';
  594. return Container(
  595. padding: const EdgeInsets.symmetric(
  596. horizontal: 14,
  597. vertical: 10,
  598. ),
  599. decoration: BoxDecoration(
  600. color: colors.bgCard,
  601. borderRadius: BorderRadius.circular(20),
  602. border: Border.all(color: colors.border, width: 0.5),
  603. boxShadow: [
  604. BoxShadow(
  605. color: Colors.black.withValues(alpha: 0.04),
  606. blurRadius: 4,
  607. offset: const Offset(0, 1),
  608. ),
  609. ],
  610. ),
  611. child: Row(
  612. mainAxisSize: MainAxisSize.min,
  613. children: [
  614. Icon(Icons.person_outline, size: 16, color: colors.primary),
  615. const SizedBox(width: 8),
  616. Text(
  617. label,
  618. style: TextStyle(
  619. fontSize: AppFontSizes.body,
  620. color: colors.textPrimary,
  621. ),
  622. ),
  623. const SizedBox(width: 8),
  624. GestureDetector(
  625. onTap: () {
  626. setState(() {
  627. _passengers.removeWhere((e) => e.salNo == p.salNo);
  628. });
  629. },
  630. child: Container(
  631. width: 20,
  632. height: 20,
  633. decoration: BoxDecoration(
  634. color: colors.bgPage,
  635. shape: BoxShape.circle,
  636. ),
  637. child: Icon(
  638. Icons.close,
  639. size: 12,
  640. color: colors.textSecondary,
  641. ),
  642. ),
  643. ),
  644. ],
  645. ),
  646. );
  647. }).toList(),
  648. ),
  649. const SizedBox(height: 16),
  650. Row(
  651. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  652. children: [
  653. Text(
  654. l10n.get('passengerCount'),
  655. style: TextStyle(
  656. fontSize: AppFontSizes.body,
  657. fontWeight: FontWeight.w600,
  658. color: colors.textPrimary,
  659. ),
  660. ),
  661. Text(
  662. '${_passengers.length}',
  663. style: TextStyle(
  664. fontSize: AppFontSizes.subtitle,
  665. fontWeight: FontWeight.w700,
  666. color: colors.textPrimary,
  667. ),
  668. ),
  669. ],
  670. ),
  671. ],
  672. );
  673. }
  674. // ═══ 底部操作栏 ═══
  675. Widget _buildBottomBar(AppLocalizations l10n) {
  676. return ActionBar(
  677. showLeft: false,
  678. centerLabel: l10n.get('saveDraft'),
  679. rightLabel: l10n.get('submit'),
  680. centerTextOnly: true,
  681. onCenterTap: () async {
  682. FocusScope.of(context).unfocus();
  683. try {
  684. await _saveDraftToStorage();
  685. if (mounted) _forcePop();
  686. } catch (_) {
  687. if (mounted) {
  688. TDToast.showFail(l10n.get('saveFailed'), context: context);
  689. }
  690. }
  691. },
  692. onRightTap: () async {
  693. final err = _validate(l10n);
  694. if (err.isNotEmpty) {
  695. TDToast.showText(err.first, context: context);
  696. return;
  697. }
  698. FocusScope.of(context).unfocus();
  699. LoadingDialog.show(context, text: l10n.get('submitting'));
  700. try {
  701. final data = _buildSubmitData();
  702. final api = ref.read(vehicleApplyApiProvider);
  703. final billNo = await api.submit(data);
  704. await DraftStorage.delete(_draftKey);
  705. if (!mounted) return;
  706. LoadingDialog.hide(context);
  707. if (billNo != null) {
  708. final dd = data['HeadData']['YC_DD']?.toString() ?? '';
  709. await AuditFlowHelper.handle(
  710. context: context,
  711. l10n: l10n,
  712. getConfig: () => api.getBillAuditConfig('YC'),
  713. onShSubmit: () =>
  714. api.shSubmit(bilId: 'YC', bilNo: billNo, bilDd: dd),
  715. );
  716. }
  717. if (mounted) {
  718. TDToast.showSuccess(l10n.get('billCreated'), context: context);
  719. GoRouter.of(context).go('/vehicle-apply/list');
  720. }
  721. } catch (e) {
  722. if (mounted) LoadingDialog.hide(context);
  723. }
  724. },
  725. );
  726. }
  727. Map<String, dynamic> _buildSubmitData() {
  728. return {
  729. 'HeadData': {
  730. 'YC_DD': _today(),
  731. 'SAL_NO': _selectedApplicantId.isNotEmpty
  732. ? _selectedApplicantId
  733. : HostAppChannel.usr,
  734. 'DEP': _selectedDeptId,
  735. 'REASON': _reasonController.text.trim(),
  736. 'ORIGIN_ADR': _originAdr,
  737. 'ORIGIN_LONGITUDE': _originLng,
  738. 'ORIGIN_LATITUDE': _originLat,
  739. 'DEST_ADR': _destAdr,
  740. 'DEST_LONGITUDE': _destLng,
  741. 'DEST_LATITUDE': _destLat,
  742. 'PASSENGER_COUNT': _passengers.length,
  743. 'PASSENGER_NAME': _passengers
  744. .map((p) => '${p.salNo}/${p.name.isNotEmpty ? p.name : p.salNo}')
  745. .join(';'),
  746. 'LICENSEPLATE': _licensePlate,
  747. 'VEHICLE_TYPE': _vehicleType,
  748. 'BRAND': _brand,
  749. 'ODOMETER_BEGIN': _odometerBegin,
  750. 'SEATS': _seats,
  751. 'DRIVER_NAME': _driverName,
  752. 'START_TIME': _startTime?.toIso8601String(),
  753. 'END_TIME': _endTime?.toIso8601String(),
  754. 'USR': HostAppChannel.usr,
  755. },
  756. };
  757. }
  758. List<String> _validate(AppLocalizations l10n) {
  759. final e = <String>[];
  760. if (_selectedApplicantId.isEmpty) {
  761. e.add(l10n.get('selectApplicantHint'));
  762. }
  763. if (_reasonController.text.trim().isEmpty) {
  764. e.add(l10n.get('enterVehicleReason'));
  765. }
  766. if (_licensePlate.isEmpty) e.add(l10n.get('selectLicensePlateHint'));
  767. if (_startTime == null) {
  768. e.add(l10n.get('selectDepartTime'));
  769. }
  770. if (_endTime == null) {
  771. e.add(l10n.get('selectReturnTime'));
  772. }
  773. if (_startTime != null &&
  774. _endTime != null &&
  775. !_endTime!.isAfter(_startTime!)) {
  776. e.add(l10n.get('returnTimeMustLater'));
  777. }
  778. return e;
  779. }
  780. // ═══ 草稿 ═══
  781. Future<void> _saveDraftToStorage() async {
  782. if (!_hasUnsaved()) return;
  783. await DraftStorage.save(_draftKey, {
  784. 'reason': _reasonController.text,
  785. 'originAdr': _originAdr,
  786. 'originLat': _originLat,
  787. 'originLng': _originLng,
  788. 'destAdr': _destAdr,
  789. 'destLat': _destLat,
  790. 'destLng': _destLng,
  791. 'startTime': _startTime?.toIso8601String(),
  792. 'endTime': _endTime?.toIso8601String(),
  793. 'licensePlate': _licensePlate,
  794. 'vehicleType': _vehicleType,
  795. 'brand': _brand,
  796. 'odometerBegin': _odometerBegin,
  797. 'seats': _seats,
  798. 'driverName': _driverName,
  799. 'passengerCount': _passengers.length,
  800. 'passengerName': _passengers.map((p) => '${p.salNo}/${p.name}').join(';'),
  801. 'selectedDeptId': _selectedDeptId,
  802. 'selectedDeptName': _selectedDeptName,
  803. 'selectedApplicantId': _selectedApplicantId,
  804. 'selectedApplicantName': _selectedApplicantName,
  805. });
  806. }
  807. void _doPop() {
  808. if (_hasUnsaved()) {
  809. final l10n = AppLocalizations.of(context);
  810. _showConfirmDialog(
  811. l10n.get('confirmExit'),
  812. l10n.get('unsavedContentWarning'),
  813. l10n.get('continueEditing'),
  814. l10n.get('discardAndExit'),
  815. () {
  816. DraftStorage.delete(_draftKey);
  817. _forcePop();
  818. },
  819. );
  820. } else {
  821. _forcePop();
  822. }
  823. }
  824. void _forcePop() {
  825. FocusManager.instance.primaryFocus?.unfocus();
  826. final router = GoRouter.of(context);
  827. if (router.canPop()) {
  828. router.pop();
  829. } else {
  830. SystemNavigator.pop();
  831. }
  832. }
  833. bool _hasUnsaved() {
  834. // 同行人排除自动带出的当前用户
  835. final usr = HostAppChannel.usr;
  836. final hasCustomPassengers = _passengers.any((p) => p.salNo != usr);
  837. // 驾驶员排除自动带出的当前用户
  838. final selfEmployee = _employees.where((e) => e.salNo == usr);
  839. final isDefaultDriver =
  840. selfEmployee.isNotEmpty && _driverName == selfEmployee.first.name;
  841. return _reasonController.text.isNotEmpty ||
  842. _originAdr.isNotEmpty ||
  843. _destAdr.isNotEmpty ||
  844. _startTime != null ||
  845. _endTime != null ||
  846. _licensePlate.isNotEmpty ||
  847. _vehicleType.isNotEmpty ||
  848. _brand.isNotEmpty ||
  849. _seats > 0 ||
  850. (_driverName.isNotEmpty && !isDefaultDriver) ||
  851. hasCustomPassengers ||
  852. _odometerBegin > 0;
  853. }
  854. void _showConfirmDialog(
  855. String title,
  856. String content,
  857. String leftText,
  858. String rightText,
  859. VoidCallback onConfirm,
  860. ) {
  861. FocusScope.of(context).unfocus();
  862. FocusManager.instance.primaryFocus?.unfocus();
  863. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  864. showDialog(
  865. context: context,
  866. useRootNavigator: true,
  867. builder: (ctx) => TDAlertDialog(
  868. title: title,
  869. content: content,
  870. buttonStyle: TDDialogButtonStyle.text,
  871. leftBtn: TDDialogButtonOptions(
  872. title: leftText,
  873. titleColor: colors.primary,
  874. action: () => Navigator.pop(ctx),
  875. ),
  876. rightBtn: TDDialogButtonOptions(
  877. title: rightText,
  878. titleColor: colors.danger,
  879. action: () {
  880. Navigator.pop(ctx);
  881. onConfirm();
  882. },
  883. ),
  884. ),
  885. );
  886. }
  887. // ═══ 弹窗方法 ═══
  888. Future<void> _showTextInput(
  889. String title,
  890. void Function(String) onConfirm, {
  891. String initialText = '',
  892. }) async {
  893. FocusScope.of(context).unfocus();
  894. FocusManager.instance.primaryFocus?.unfocus();
  895. final result = await AppInputDialog.show(
  896. context: context,
  897. title: title,
  898. initialText: initialText,
  899. );
  900. if (result != null && mounted) {
  901. onConfirm(result);
  902. }
  903. }
  904. Future<void> _showNumberInput(
  905. String title,
  906. void Function(int) onSave,
  907. int current, {
  908. int min = 0,
  909. }) async {
  910. FocusScope.of(context).unfocus();
  911. FocusManager.instance.primaryFocus?.unfocus();
  912. final result = await AppInputDialog.show(
  913. context: context,
  914. title: title,
  915. initialText: current > 0 ? '$current' : '',
  916. inputType: AppInputType.integer,
  917. min: min,
  918. );
  919. if (result != null && mounted) {
  920. onSave(int.tryParse(result) ?? 0);
  921. }
  922. }
  923. void _pickDateTime(void Function(DateTime) onPicked, DateTime? initial) {
  924. FocusScope.of(context).unfocus();
  925. final l10n = AppLocalizations.of(context);
  926. final now = DateTime.now();
  927. final d = initial ?? now;
  928. TDPicker.showDatePicker(
  929. context,
  930. title: l10n.get('selectDateTime'),
  931. useYear: true,
  932. useMonth: true,
  933. useDay: true,
  934. useHour: true,
  935. useMinute: true,
  936. dateStart: [now.year - 1, 1, 1, 0, 0],
  937. dateEnd: [now.year + 10, 12, 31, 23, 59],
  938. initialDate: [d.year, d.month, d.day, d.hour, d.minute],
  939. onConfirm: (selected) {
  940. Navigator.of(context).pop();
  941. onPicked(
  942. DateTime(
  943. selected['year']!,
  944. selected['month']!,
  945. selected['day']!,
  946. selected['hour']!,
  947. selected['minute']!,
  948. ),
  949. );
  950. },
  951. );
  952. }
  953. Future<void> _showApplicantPicker() async {
  954. FocusScope.of(context).unfocus();
  955. FocusManager.instance.primaryFocus?.unfocus();
  956. final l10n = AppLocalizations.of(context);
  957. final api = ref.read(vehicleApplyApiProvider);
  958. final result = await showSearchablePicker<EmployeeItem>(
  959. context,
  960. title: '${l10n.get('select')}${l10n.get('applicant')}',
  961. searchHint: l10n.get('search'),
  962. loader: (keyword, page) =>
  963. api.getEmployees(keyword: keyword, page: page, size: 20),
  964. labelBuilder: (e) => e.name.isEmpty ? e.salNo : '${e.salNo} ${e.name}',
  965. );
  966. if (result != null && mounted) {
  967. setState(() {
  968. _selectedApplicantId = result.salNo;
  969. _selectedApplicantName = result.name;
  970. });
  971. }
  972. }
  973. Future<void> _showDeptPicker() async {
  974. FocusScope.of(context).unfocus();
  975. FocusManager.instance.primaryFocus?.unfocus();
  976. final l10n = AppLocalizations.of(context);
  977. final api = ref.read(vehicleApplyApiProvider);
  978. final result = await showSearchablePicker<DepartmentItem>(
  979. context,
  980. title: '${l10n.get('select')}${l10n.get('applyDept')}',
  981. searchHint: l10n.get('search'),
  982. loader: (keyword, page) =>
  983. api.getDepartments(keyword: keyword, page: page, size: 20),
  984. labelBuilder: (d) => d.name.isEmpty ? d.dep : '${d.dep} ${d.name}',
  985. );
  986. if (result != null && mounted) {
  987. setState(() {
  988. _selectedDeptId = result.dep;
  989. _selectedDeptName = result.name;
  990. });
  991. }
  992. }
  993. void _showVehicleTypePicker(AppLocalizations l10n) {
  994. FocusScope.of(context).unfocus();
  995. FocusManager.instance.primaryFocus?.unfocus();
  996. final labels = _mockVehicleTypes
  997. .map((t) => _vehicleTypeLabel(t, l10n))
  998. .toList();
  999. TDPicker.showMultiPicker(
  1000. context,
  1001. title: l10n.get('selectVehicleType'),
  1002. data: [labels],
  1003. onConfirm: (selected) {
  1004. if (selected.isNotEmpty) {
  1005. final idx = selected.first is int ? selected.first as int : 0;
  1006. if (idx >= 0 && idx < _mockVehicleTypes.length) {
  1007. setState(() => _vehicleType = _mockVehicleTypes[idx]);
  1008. }
  1009. }
  1010. Navigator.of(context).pop();
  1011. },
  1012. );
  1013. }
  1014. Future<void> _showMultiEmployeePicker(AppLocalizations l10n) async {
  1015. FocusScope.of(context).unfocus();
  1016. FocusManager.instance.primaryFocus?.unfocus();
  1017. final api = ref.read(vehicleApplyApiProvider);
  1018. final selected = await showSearchableMultiPicker<EmployeeItem>(
  1019. context,
  1020. title: '${l10n.get('select')}${l10n.get('companion')}',
  1021. searchHint: l10n.get('search'),
  1022. loader: (keyword, page) =>
  1023. api.getEmployees(keyword: keyword, page: page, size: 20),
  1024. labelBuilder: (e) => e.name.isEmpty ? e.salNo : '${e.salNo} ${e.name}',
  1025. );
  1026. if (mounted) {
  1027. setState(() {
  1028. for (final emp in selected) {
  1029. if (!_passengers.any((p) => p.salNo == emp.salNo)) {
  1030. _passengers.add(emp);
  1031. }
  1032. }
  1033. });
  1034. }
  1035. }
  1036. Future<void> _showDriverPicker() async {
  1037. FocusManager.instance.primaryFocus?.unfocus();
  1038. final l10n = AppLocalizations.of(context);
  1039. final api = ref.read(vehicleApplyApiProvider);
  1040. final result = await showSearchablePicker<EmployeeItem>(
  1041. context,
  1042. title: '${l10n.get('select')}${l10n.get('driverName')}',
  1043. searchHint: l10n.get('search'),
  1044. loader: (keyword, page) =>
  1045. api.getEmployees(keyword: keyword, page: page, size: 20),
  1046. labelBuilder: (e) => e.name.isEmpty ? e.salNo : '${e.salNo} ${e.name}',
  1047. );
  1048. if (result != null && mounted) {
  1049. setState(() {
  1050. _driverName = result.name;
  1051. });
  1052. }
  1053. }
  1054. // ═══ 工具方法 ═══
  1055. Widget _label(String t, {bool required = false}) {
  1056. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  1057. return Text.rich(
  1058. TextSpan(
  1059. children: [
  1060. TextSpan(
  1061. text: t,
  1062. style: TextStyle(
  1063. fontSize: AppFontSizes.subtitle,
  1064. color: colors.textSecondary,
  1065. ),
  1066. ),
  1067. if (required)
  1068. TextSpan(
  1069. text: ' *',
  1070. style: TextStyle(
  1071. fontSize: AppFontSizes.subtitle,
  1072. color: colors.danger,
  1073. ),
  1074. ),
  1075. ],
  1076. ),
  1077. );
  1078. }
  1079. Widget _buildPageFooter() {
  1080. final l10n = AppLocalizations.of(context);
  1081. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  1082. return Center(
  1083. child: Padding(
  1084. padding: const EdgeInsets.only(bottom: 16),
  1085. child: Row(
  1086. mainAxisSize: MainAxisSize.min,
  1087. children: [
  1088. Icon(
  1089. Icons.rocket_launch_outlined,
  1090. size: 16,
  1091. color: colors.textPlaceholder,
  1092. ),
  1093. const SizedBox(width: 6),
  1094. Text(
  1095. l10n.get('pageFooter'),
  1096. style: TextStyle(
  1097. fontSize: AppFontSizes.caption,
  1098. color: colors.textPlaceholder,
  1099. ),
  1100. ),
  1101. ],
  1102. ),
  1103. ),
  1104. );
  1105. }
  1106. String _today() {
  1107. final n = DateTime.now();
  1108. return '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}';
  1109. }
  1110. String _formatDateTime(DateTime d) {
  1111. return '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')} ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
  1112. }
  1113. String _vehicleTypeLabel(String key, AppLocalizations l10n) {
  1114. switch (key) {
  1115. case 'sedan':
  1116. return l10n.get('sedan');
  1117. case 'suv':
  1118. return 'SUV';
  1119. case 'mpv':
  1120. return l10n.get('businessVan');
  1121. case 'van':
  1122. return l10n.get('van');
  1123. case 'truck':
  1124. return l10n.get('truck');
  1125. case 'pickup':
  1126. return l10n.get('pickup');
  1127. case 'minibus':
  1128. return l10n.get('minibus');
  1129. case 'bus':
  1130. return l10n.get('bus');
  1131. default:
  1132. return key;
  1133. }
  1134. }
  1135. }