vehicle_apply_edit_page.dart 35 KB

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