vehicle_apply_edit_page.dart 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885
  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 'package:dio/dio.dart';
  8. import '../../core/i18n/app_localizations.dart';
  9. import '../../core/network/api_exception.dart';
  10. import '../../core/navigation/host_app_channel.dart';
  11. import '../../core/theme/app_colors.dart';
  12. import '../../core/theme/app_colors_extension.dart';
  13. import '../../shared/widgets/action_bar.dart';
  14. import '../../shared/widgets/app_skeletons.dart';
  15. import '../../shared/widgets/form_field_row.dart';
  16. import '../../shared/widgets/form_section.dart';
  17. import '../../shared/widgets/loading_dialog.dart';
  18. import '../../shared/widgets/nav_bar_config.dart';
  19. import '../../shared/widgets/app_input_dialog.dart';
  20. import '../../shared/widgets/searchable_picker_sheet.dart';
  21. import 'vehicle_apply_api.dart';
  22. import 'vehicle_apply_list_controller.dart';
  23. import 'vehicle_apply_model.dart';
  24. class VehicleApplyEditPage extends ConsumerStatefulWidget {
  25. final String billNo;
  26. const VehicleApplyEditPage({super.key, required this.billNo});
  27. @override
  28. ConsumerState<VehicleApplyEditPage> createState() =>
  29. _VehicleApplyEditPageState();
  30. }
  31. class _VehicleApplyEditPageState extends ConsumerState<VehicleApplyEditPage> {
  32. // ── 原单数据 ──
  33. String _billNo = '';
  34. String _applyDate = '';
  35. // ── 基本信息 ──
  36. final _reasonController = TextEditingController();
  37. final _reasonFocus = FocusNode();
  38. String _originAdr = '';
  39. String _destAdr = '';
  40. DateTime? _startTime;
  41. DateTime? _endTime;
  42. // ── 车辆信息 ──
  43. String _licensePlate = '';
  44. String _vehicleType = '';
  45. String _brand = '';
  46. int _seats = 0;
  47. int _odometerBegin = 0;
  48. // ── 驾驶员 ──
  49. String _driverName = '';
  50. // ── 同行信息 ──
  51. int _passengerCount = 1;
  52. String _passengerName = '';
  53. // ── 参考数据 ──
  54. bool _firstBuild = true;
  55. bool _refDataLoading = true;
  56. bool _loadingBill = true;
  57. String? _loadingError;
  58. String _selectedDeptId = '';
  59. String _selectedDeptName = '';
  60. final _scrollCtrl = ScrollController();
  61. // Mock 车牌
  62. static const _mockLicensePlates = [
  63. '粤B12345',
  64. '京A88888',
  65. '京B66666',
  66. '京C12345',
  67. '京D99999',
  68. ];
  69. // Mock 车辆类型
  70. static const _mockVehicleTypes = ['sedan', 'suv', 'mpv', 'van', 'truck', 'pickup', 'minibus', 'bus'];
  71. @override
  72. void initState() {
  73. super.initState();
  74. SystemChrome.setSystemUIOverlayStyle(
  75. const SystemUiOverlayStyle(
  76. statusBarColor: Colors.transparent,
  77. statusBarIconBrightness: Brightness.dark,
  78. ),
  79. );
  80. _reasonFocus.addListener(() => _ensureVisible(_reasonFocus));
  81. _refDataLoading = true;
  82. _loadingBill = true;
  83. _loadRefData();
  84. _loadBillData();
  85. WidgetsBinding.instance.addPostFrameCallback((_) => _checkDataReady());
  86. }
  87. void _checkDataReady() {
  88. if (!_refDataLoading && !_loadingBill && mounted) {
  89. setState(() => _firstBuild = false);
  90. WidgetsBinding.instance.addPostFrameCallback((_) {
  91. if (mounted) setState(() {});
  92. });
  93. } else if (mounted) {
  94. WidgetsBinding.instance.addPostFrameCallback((_) => _checkDataReady());
  95. }
  96. }
  97. Future<void>? _refDataFuture;
  98. Future<void> _loadRefData({bool showLoading = false}) async {
  99. if (_refDataFuture != null) return _refDataFuture!;
  100. final completer = Completer<void>();
  101. _refDataFuture = completer.future;
  102. if (showLoading) {
  103. LoadingDialog.show(
  104. context,
  105. text: AppLocalizations.of(context).get('dataLoading'),
  106. );
  107. }
  108. try {
  109. final api = ref.read(vehicleApplyApiProvider);
  110. await Future.wait([api.getDepartments()]);
  111. if (!mounted) return;
  112. setState(() => _refDataLoading = false);
  113. completer.complete();
  114. } catch (_) {
  115. if (!mounted) {
  116. completer.complete();
  117. return;
  118. }
  119. setState(() => _refDataLoading = false);
  120. completer.complete();
  121. } finally {
  122. if (showLoading && mounted) LoadingDialog.hide(context);
  123. _refDataFuture = null;
  124. }
  125. }
  126. Future<void> _loadBillData() async {
  127. try {
  128. // 先用 mock 数据匹配
  129. final match = mockVehicles.where((e) => e.ycNo == widget.billNo);
  130. if (match.isNotEmpty) {
  131. _fillFromModel(match.first);
  132. if (mounted) setState(() => _loadingBill = false);
  133. return;
  134. }
  135. // 尝试 API
  136. final api = ref.read(vehicleApplyApiProvider);
  137. final detail = await api.fetchDetail(widget.billNo);
  138. if (!mounted) return;
  139. _fillFromModel(detail);
  140. setState(() => _loadingBill = false);
  141. } catch (e) {
  142. if (!mounted) return;
  143. setState(() {
  144. _loadingBill = false;
  145. _loadingError = e.toString();
  146. });
  147. }
  148. }
  149. void _fillFromModel(VehicleApplyModel model) {
  150. _billNo = model.ycNo;
  151. _applyDate = model.ycDd != null
  152. ? '${model.ycDd!.year}-${model.ycDd!.month.toString().padLeft(2, '0')}-${model.ycDd!.day.toString().padLeft(2, '0')}'
  153. : '';
  154. _selectedDeptId = model.dep;
  155. _selectedDeptName = model.deptName;
  156. _reasonController.text = model.reason;
  157. _originAdr = model.originAdr;
  158. _destAdr = model.destAdr;
  159. _startTime = model.startTime;
  160. _endTime = model.endTime;
  161. _licensePlate = model.licensePlate;
  162. _vehicleType = model.vehicleType;
  163. _brand = model.brand;
  164. _seats = model.seats;
  165. _odometerBegin = model.odometerBegin;
  166. _driverName = model.driverName;
  167. _passengerCount = model.passengerCount;
  168. _passengerName = model.passengerName;
  169. }
  170. void _ensureVisible(FocusNode node) {
  171. if (!node.hasFocus) return;
  172. WidgetsBinding.instance.addPostFrameCallback((_) {
  173. if (node.hasFocus && _scrollCtrl.hasClients) {
  174. final ctx = node.context;
  175. if (ctx != null) {
  176. Scrollable.ensureVisible(
  177. ctx,
  178. alignment: 0.3,
  179. duration: const Duration(milliseconds: 300),
  180. );
  181. }
  182. }
  183. });
  184. }
  185. @override
  186. void dispose() {
  187. _reasonController.dispose();
  188. _reasonFocus.dispose();
  189. _scrollCtrl.dispose();
  190. super.dispose();
  191. }
  192. @override
  193. Widget build(BuildContext context) {
  194. final l10n = AppLocalizations.of(context);
  195. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  196. if (_loadingError != null) {
  197. return Center(
  198. child: Column(
  199. mainAxisSize: MainAxisSize.min,
  200. children: [
  201. Icon(Icons.error_outline, size: 48, color: colors.danger),
  202. const SizedBox(height: 16),
  203. Padding(
  204. padding: const EdgeInsets.symmetric(horizontal: 32),
  205. child: Text(
  206. _loadingError!,
  207. textAlign: TextAlign.center,
  208. style: TextStyle(
  209. fontSize: AppFontSizes.body,
  210. color: colors.textSecondary,
  211. ),
  212. ),
  213. ),
  214. const SizedBox(height: 16),
  215. TDButton(
  216. text: l10n.get('retry'),
  217. size: TDButtonSize.medium,
  218. onTap: () {
  219. setState(() {
  220. _loadingError = null;
  221. _loadingBill = true;
  222. });
  223. _loadBillData();
  224. },
  225. ),
  226. ],
  227. ),
  228. );
  229. }
  230. if (_firstBuild) {
  231. return const SkeletonFormPage();
  232. }
  233. Future.microtask(
  234. () => ref.read(pageBackProvider.notifier).state = () => _doPop(),
  235. );
  236. return PopScope(
  237. canPop: false,
  238. onPopInvokedWithResult: (didPop, _) {
  239. if (didPop) return;
  240. _doPop();
  241. },
  242. child: Column(
  243. children: [
  244. Expanded(
  245. child: GestureDetector(
  246. onTap: () => FocusScope.of(context).unfocus(),
  247. child: SingleChildScrollView(
  248. controller: _scrollCtrl,
  249. padding: const EdgeInsets.all(16),
  250. child: Column(
  251. children: [
  252. _buildBasicInfo(l10n),
  253. const SizedBox(height: 16),
  254. _buildVehicleInfo(l10n),
  255. const SizedBox(height: 16),
  256. _buildPassengerInfo(l10n),
  257. const SizedBox(height: 24),
  258. _buildPageFooter(),
  259. ],
  260. ),
  261. ),
  262. ),
  263. ),
  264. _buildBottomBar(l10n),
  265. ],
  266. ),
  267. );
  268. }
  269. // ═══ 1. 基本信息 ═══
  270. Widget _buildBasicInfo(AppLocalizations l10n) {
  271. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  272. return FormSection(
  273. title: l10n.get('basicInfo'),
  274. leadingIcon: Icons.info_outline,
  275. children: [
  276. FormFieldRow(
  277. label: l10n.get('vehicleApplyNo'),
  278. value: _billNo,
  279. readOnly: true,
  280. showArrow: false,
  281. ),
  282. const SizedBox(height: 16),
  283. FormFieldRow(
  284. label: l10n.get('date'),
  285. value: _applyDate,
  286. readOnly: true,
  287. showArrow: false,
  288. ),
  289. const SizedBox(height: 16),
  290. FormFieldRow(
  291. label: l10n.get('applyDept'),
  292. value: _selectedDeptId.isNotEmpty
  293. ? '$_selectedDeptId/$_selectedDeptName'
  294. : '',
  295. hint: l10n.get('pleaseSelect'),
  296. onTap: () => _showDeptPicker(),
  297. ),
  298. const SizedBox(height: 16),
  299. _label(l10n.get('vehicleReason'), required: true),
  300. const SizedBox(height: 8),
  301. TDTextarea(
  302. controller: _reasonController,
  303. focusNode: _reasonFocus,
  304. hintText: l10n.get('enterVehicleReason'),
  305. maxLines: 4,
  306. minLines: 1,
  307. maxLength: 500,
  308. indicator: true,
  309. padding: EdgeInsets.zero,
  310. bordered: true,
  311. backgroundColor: colors.bgPage,
  312. ),
  313. const SizedBox(height: 16),
  314. FormFieldRow(
  315. label: l10n.get('origin'),
  316. value: _originAdr.isNotEmpty ? _originAdr : null,
  317. hint: l10n.get('pleaseEnter'),
  318. onTap: () => _showTextInput(
  319. l10n.get('origin'),
  320. (v) => setState(() => _originAdr = v),
  321. initialText: _originAdr,
  322. ),
  323. ),
  324. const SizedBox(height: 16),
  325. FormFieldRow(
  326. label: l10n.get('destination'),
  327. value: _destAdr.isNotEmpty ? _destAdr : null,
  328. hint: l10n.get('pleaseEnter'),
  329. onTap: () => _showTextInput(
  330. l10n.get('destination'),
  331. (v) => setState(() => _destAdr = v),
  332. initialText: _destAdr,
  333. ),
  334. ),
  335. const SizedBox(height: 16),
  336. FormFieldRow(
  337. label: l10n.get('departTime'),
  338. value: _startTime != null ? _formatDateTime(_startTime!) : null,
  339. hint: l10n.get('pleaseSelect'),
  340. onTap: () =>
  341. _pickDateTime((d) => setState(() => _startTime = d), _startTime),
  342. onClear: _startTime != null
  343. ? () => setState(() => _startTime = null)
  344. : null,
  345. ),
  346. const SizedBox(height: 16),
  347. FormFieldRow(
  348. label: l10n.get('returnTime'),
  349. value: _endTime != null ? _formatDateTime(_endTime!) : null,
  350. hint: l10n.get('pleaseSelect'),
  351. onTap: () =>
  352. _pickDateTime((d) => setState(() => _endTime = d), _endTime),
  353. onClear: _endTime != null
  354. ? () => setState(() => _endTime = null)
  355. : null,
  356. ),
  357. if (_startTime != null &&
  358. _endTime != null &&
  359. !_endTime!.isAfter(_startTime!))
  360. Padding(
  361. padding: const EdgeInsets.only(top: 8),
  362. child: Row(
  363. mainAxisAlignment: MainAxisAlignment.end,
  364. children: [
  365. Icon(Icons.warning_amber_rounded,
  366. size: 14, color: colors.danger),
  367. const SizedBox(width: 4),
  368. Text(
  369. l10n.get('returnTimeMustLater'),
  370. style: TextStyle(
  371. fontSize: AppFontSizes.caption,
  372. color: colors.danger,
  373. ),
  374. ),
  375. ],
  376. ),
  377. ),
  378. ],
  379. );
  380. }
  381. // ═══ 2. 车辆信息 ═══
  382. Widget _buildVehicleInfo(AppLocalizations l10n) {
  383. return FormSection(
  384. title: l10n.get('vehicleInfo'),
  385. leadingIcon: Icons.directions_car_outlined,
  386. children: [
  387. FormFieldRow(
  388. label: l10n.get('licensePlate'),
  389. value: _licensePlate.isNotEmpty ? _licensePlate : null,
  390. hint: l10n.get('pleaseSelect'),
  391. onTap: () => _showLicensePlatePicker(),
  392. ),
  393. const SizedBox(height: 16),
  394. FormFieldRow(
  395. label: l10n.get('vehicleType'),
  396. value: _vehicleType.isNotEmpty
  397. ? _vehicleTypeLabel(_vehicleType, l10n)
  398. : null,
  399. hint: l10n.get('pleaseSelect'),
  400. onTap: () => _showVehicleTypePicker(l10n),
  401. ),
  402. const SizedBox(height: 16),
  403. FormFieldRow(
  404. label: l10n.get('brand'),
  405. value: _brand.isNotEmpty ? _brand : null,
  406. hint: l10n.get('pleaseEnter'),
  407. onTap: () => _showTextInput(
  408. l10n.get('brand'),
  409. (v) => setState(() => _brand = v),
  410. initialText: _brand,
  411. ),
  412. ),
  413. const SizedBox(height: 16),
  414. FormFieldRow(
  415. label: l10n.get('seats'),
  416. value: _seats > 0 ? '$_seats' : null,
  417. hint: l10n.get('pleaseEnter'),
  418. onTap: () => _showNumberInput(
  419. l10n.get('seats'),
  420. (v) => setState(() => _seats = v),
  421. _seats,
  422. ),
  423. ),
  424. const SizedBox(height: 16),
  425. FormFieldRow(
  426. label: l10n.get('odometerBegin'),
  427. value: _odometerBegin > 0 ? '$_odometerBegin' : null,
  428. hint: l10n.get('pleaseEnter'),
  429. onTap: () => _showNumberInput(
  430. l10n.get('odometerBegin'),
  431. (v) => setState(() => _odometerBegin = v),
  432. _odometerBegin,
  433. ),
  434. ),
  435. const SizedBox(height: 16),
  436. FormFieldRow(
  437. label: l10n.get('driverName'),
  438. value: _driverName.isNotEmpty ? _driverName : null,
  439. hint: l10n.get('pleaseSelect'),
  440. onTap: () => _showDriverPicker(),
  441. ),
  442. ],
  443. );
  444. }
  445. // ═══ 3. 同行信息 ═══
  446. Widget _buildPassengerInfo(AppLocalizations l10n) {
  447. return FormSection(
  448. title: l10n.get('companionInfo'),
  449. leadingIcon: Icons.people_outline,
  450. children: [
  451. FormFieldRow(
  452. label: l10n.get('passengerCount'),
  453. value: '$_passengerCount',
  454. onTap: () => _showNumberInput(
  455. l10n.get('passengerCount'),
  456. (v) => setState(() => _passengerCount = v < 1 ? 1 : v),
  457. _passengerCount,
  458. ),
  459. ),
  460. const SizedBox(height: 16),
  461. FormFieldRow(
  462. label: l10n.get('passengerName'),
  463. value: _passengerName.isNotEmpty ? _passengerName : null,
  464. hint: l10n.get('pleaseEnter'),
  465. onTap: () => _showTextInput(
  466. l10n.get('passengerName'),
  467. (v) => setState(() => _passengerName = v),
  468. initialText: _passengerName,
  469. ),
  470. ),
  471. ],
  472. );
  473. }
  474. // ═══ 4. 底部操作栏 ═══
  475. Widget _buildBottomBar(AppLocalizations l10n) {
  476. return ActionBar(
  477. showLeft: false,
  478. showCenter: false,
  479. rightLabel: l10n.get('submit'),
  480. onRightTap: () async {
  481. final err = _validate(l10n);
  482. if (err.isNotEmpty) {
  483. TDToast.showText(err.first, context: context);
  484. return;
  485. }
  486. FocusScope.of(context).unfocus();
  487. LoadingDialog.show(context, text: l10n.get('submitting'));
  488. try {
  489. final data = _buildSubmitData();
  490. final api = ref.read(vehicleApplyApiProvider);
  491. await api.submit(data);
  492. if (mounted) {
  493. LoadingDialog.hide(context);
  494. TDToast.showSuccess(l10n.get('submitSuccess'), context: context);
  495. WidgetsBinding.instance.addPostFrameCallback((_) {
  496. if (mounted) GoRouter.of(context).pop(true);
  497. });
  498. }
  499. } catch (e) {
  500. if (mounted) {
  501. LoadingDialog.hide(context);
  502. WidgetsBinding.instance.addPostFrameCallback((_) {
  503. if (mounted) _showSubmitError(e, l10n);
  504. });
  505. }
  506. }
  507. },
  508. );
  509. }
  510. void _showSubmitError(Object e, AppLocalizations l10n) {
  511. final message = _extractErrorMessage(e) ?? l10n.get('submitFailedRetry');
  512. showGeneralDialog(
  513. context: context,
  514. pageBuilder: (ctx, animation, secondaryAnimation) => TDConfirmDialog(
  515. title: l10n.get('submitFailed'),
  516. content: message,
  517. buttonStyle: TDDialogButtonStyle.text,
  518. ),
  519. );
  520. }
  521. String? _extractErrorMessage(Object e) {
  522. if (e is DioException) {
  523. if (e.error is ApiException) return (e.error as ApiException).message;
  524. if (e.error is NetworkException) {
  525. return (e.error as NetworkException).message;
  526. }
  527. }
  528. return null;
  529. }
  530. Map<String, dynamic> _buildSubmitData() {
  531. return {
  532. 'HeadData': {
  533. 'YC_NO': _billNo,
  534. 'YC_DD': _applyDate,
  535. 'SAL_NO': HostAppChannel.usr,
  536. 'DEP': _selectedDeptId,
  537. 'REASON': _reasonController.text.trim(),
  538. 'ORIGIN_ADR': _originAdr,
  539. 'DEST_ADR': _destAdr,
  540. 'PASSENGER_COUNT': _passengerCount,
  541. 'PASSENGER_NAME': _passengerName,
  542. 'LICENSEPLATE': _licensePlate,
  543. 'VEHICLE_TYPE': _vehicleType,
  544. 'BRAND': _brand,
  545. 'ODOMETER_BEGIN': _odometerBegin,
  546. 'SEATS': _seats,
  547. 'DRIVER_NAME': _driverName,
  548. 'START_TIME': _startTime?.toIso8601String(),
  549. 'END_TIME': _endTime?.toIso8601String(),
  550. 'USR': HostAppChannel.usr,
  551. },
  552. };
  553. }
  554. List<String> _validate(AppLocalizations l10n) {
  555. final e = <String>[];
  556. if (_reasonController.text.trim().isEmpty) {
  557. e.add(l10n.get('enterVehicleReason'));
  558. }
  559. if (_licensePlate.isEmpty) e.add(l10n.get('selectLicensePlateHint'));
  560. if (_startTime != null &&
  561. _endTime != null &&
  562. !_endTime!.isAfter(_startTime!)) {
  563. e.add(l10n.get('returnTimeMustLater'));
  564. }
  565. return e;
  566. }
  567. // ═══ 弹窗方法 ═══
  568. Future<void> _showTextInput(
  569. String title,
  570. void Function(String) onConfirm, {
  571. String initialText = '',
  572. }) async {
  573. FocusScope.of(context).unfocus();
  574. FocusManager.instance.primaryFocus?.unfocus();
  575. final result = await AppInputDialog.show(
  576. context: context,
  577. title: title,
  578. initialText: initialText,
  579. );
  580. if (result != null && mounted) {
  581. onConfirm(result);
  582. }
  583. }
  584. Future<void> _showNumberInput(String title, void Function(int) onSave, int current) async {
  585. FocusScope.of(context).unfocus();
  586. FocusManager.instance.primaryFocus?.unfocus();
  587. final result = await AppInputDialog.show(
  588. context: context,
  589. title: title,
  590. initialText: current > 0 ? '$current' : '',
  591. inputType: AppInputType.integer,
  592. min: 0,
  593. );
  594. if (result != null && mounted) {
  595. onSave(int.tryParse(result) ?? 0);
  596. }
  597. }
  598. void _pickDateTime(void Function(DateTime) onPicked, DateTime? initial) {
  599. FocusScope.of(context).unfocus();
  600. final l10n = AppLocalizations.of(context);
  601. final now = DateTime.now();
  602. final d = initial ?? now;
  603. TDPicker.showDatePicker(
  604. context,
  605. title: l10n.get('selectDateTime'),
  606. useYear: true,
  607. useMonth: true,
  608. useDay: true,
  609. useHour: true,
  610. useMinute: true,
  611. dateStart: [now.year - 1, now.month, now.day, now.hour, now.minute],
  612. dateEnd: [now.year + 10, 12, 31, 23, 59],
  613. initialDate: [d.year, d.month, d.day, d.hour, d.minute],
  614. onConfirm: (selected) {
  615. Navigator.of(context).pop();
  616. onPicked(
  617. DateTime(
  618. selected['year']!,
  619. selected['month']!,
  620. selected['day']!,
  621. selected['hour']!,
  622. selected['minute']!,
  623. ),
  624. );
  625. },
  626. );
  627. }
  628. Future<void> _showDeptPicker() async {
  629. FocusManager.instance.primaryFocus?.unfocus();
  630. final l10n = AppLocalizations.of(context);
  631. final api = ref.read(vehicleApplyApiProvider);
  632. final result = await showSearchablePicker<DepartmentItem>(
  633. context,
  634. title: '${l10n.get('select')}${l10n.get('applyDept')}',
  635. searchHint: l10n.get('search'),
  636. loader: (keyword, page) =>
  637. api.getDepartments(keyword: keyword, page: page, size: 20),
  638. labelBuilder: (d) => d.name.isEmpty ? d.dep : '${d.dep} ${d.name}',
  639. );
  640. if (result != null && mounted) {
  641. setState(() {
  642. _selectedDeptId = result.dep;
  643. _selectedDeptName = result.name;
  644. });
  645. }
  646. }
  647. void _showLicensePlatePicker() {
  648. FocusScope.of(context).unfocus();
  649. final l10n = AppLocalizations.of(context);
  650. TDPicker.showMultiPicker(
  651. context,
  652. title: l10n.get('selectLicensePlate'),
  653. data: [_mockLicensePlates],
  654. onConfirm: (selected) => setState(() => _licensePlate = selected.first),
  655. );
  656. }
  657. void _showVehicleTypePicker(AppLocalizations l10n) {
  658. FocusScope.of(context).unfocus();
  659. final labels = _mockVehicleTypes
  660. .map((t) => _vehicleTypeLabel(t, l10n))
  661. .toList();
  662. TDPicker.showMultiPicker(
  663. context,
  664. title: l10n.get('selectVehicleType'),
  665. data: [labels],
  666. onConfirm: (selected) {
  667. final idx = labels.indexOf(selected.first);
  668. if (idx >= 0) setState(() => _vehicleType = _mockVehicleTypes[idx]);
  669. },
  670. );
  671. }
  672. Future<void> _showDriverPicker() async {
  673. FocusManager.instance.primaryFocus?.unfocus();
  674. final l10n = AppLocalizations.of(context);
  675. final api = ref.read(vehicleApplyApiProvider);
  676. final result = await showSearchablePicker<EmployeeItem>(
  677. context,
  678. title: '${l10n.get('select')}${l10n.get('driverName')}',
  679. searchHint: l10n.get('search'),
  680. loader: (keyword, page) =>
  681. api.getEmployees(keyword: keyword, page: page, size: 20),
  682. labelBuilder: (e) => e.name.isEmpty ? e.salNo : '${e.salNo} ${e.name}',
  683. );
  684. if (result != null && mounted) {
  685. setState(() {
  686. _driverName = result.name;
  687. });
  688. }
  689. }
  690. // ═══ 对话框 ═══
  691. void _doPop() {
  692. if (_hasUnsaved()) {
  693. final l10n = AppLocalizations.of(context);
  694. _showConfirmDialog(
  695. l10n.get('confirmExit'),
  696. l10n.get('unsavedContentWarning'),
  697. l10n.get('continueEditing'),
  698. l10n.get('discardAndExit'),
  699. _forcePop,
  700. );
  701. } else {
  702. _forcePop();
  703. }
  704. }
  705. void _forcePop() {
  706. FocusManager.instance.primaryFocus?.unfocus();
  707. final router = GoRouter.of(context);
  708. if (router.canPop()) {
  709. router.pop();
  710. } else {
  711. SystemNavigator.pop();
  712. }
  713. }
  714. bool _hasUnsaved() =>
  715. _reasonController.text.isNotEmpty ||
  716. _originAdr.isNotEmpty ||
  717. _destAdr.isNotEmpty ||
  718. _licensePlate.isNotEmpty ||
  719. _vehicleType.isNotEmpty ||
  720. _brand.isNotEmpty ||
  721. _driverName.isNotEmpty ||
  722. _passengerName.isNotEmpty ||
  723. _passengerCount != 1 ||
  724. _odometerBegin > 0;
  725. void _showConfirmDialog(
  726. String title,
  727. String content,
  728. String leftText,
  729. String rightText,
  730. VoidCallback onConfirm,
  731. ) {
  732. FocusScope.of(context).unfocus();
  733. FocusManager.instance.primaryFocus?.unfocus();
  734. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  735. showDialog(
  736. context: context,
  737. useRootNavigator: true,
  738. builder: (ctx) => TDAlertDialog(
  739. title: title,
  740. content: content,
  741. buttonStyle: TDDialogButtonStyle.text,
  742. leftBtn: TDDialogButtonOptions(
  743. title: leftText,
  744. titleColor: colors.primary,
  745. action: () => Navigator.pop(ctx),
  746. ),
  747. rightBtn: TDDialogButtonOptions(
  748. title: rightText,
  749. titleColor: colors.danger,
  750. action: () {
  751. Navigator.pop(ctx);
  752. onConfirm();
  753. },
  754. ),
  755. ),
  756. );
  757. }
  758. // ═══ 工具方法 ═══
  759. Widget _label(String t, {bool required = false}) {
  760. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  761. return Text.rich(
  762. TextSpan(
  763. children: [
  764. TextSpan(
  765. text: t,
  766. style: TextStyle(
  767. fontSize: AppFontSizes.subtitle,
  768. color: colors.textSecondary,
  769. ),
  770. ),
  771. if (required)
  772. TextSpan(
  773. text: ' *',
  774. style: TextStyle(
  775. fontSize: AppFontSizes.subtitle,
  776. color: colors.danger,
  777. ),
  778. ),
  779. ],
  780. ),
  781. );
  782. }
  783. Widget _buildPageFooter() {
  784. final l10n = AppLocalizations.of(context);
  785. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  786. return Center(
  787. child: Padding(
  788. padding: const EdgeInsets.only(bottom: 16),
  789. child: Row(
  790. mainAxisSize: MainAxisSize.min,
  791. children: [
  792. Icon(
  793. Icons.rocket_launch_outlined,
  794. size: 16,
  795. color: colors.textPlaceholder,
  796. ),
  797. const SizedBox(width: 6),
  798. Text(
  799. l10n.get('pageFooter'),
  800. style: TextStyle(
  801. fontSize: AppFontSizes.caption,
  802. color: colors.textPlaceholder,
  803. ),
  804. ),
  805. ],
  806. ),
  807. ),
  808. );
  809. }
  810. String _formatDateTime(DateTime d) {
  811. 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')}';
  812. }
  813. String _vehicleTypeLabel(String key, AppLocalizations l10n) {
  814. switch (key) {
  815. case 'sedan':
  816. return l10n.get('sedan');
  817. case 'suv':
  818. return 'SUV';
  819. case 'mpv':
  820. return l10n.get('businessVan');
  821. case 'van':
  822. return l10n.get('van');
  823. case 'truck':
  824. return l10n.get('truck');
  825. case 'pickup':
  826. return l10n.get('pickup');
  827. case 'minibus':
  828. return l10n.get('minibus');
  829. case 'bus':
  830. return l10n.get('bus');
  831. default:
  832. return key;
  833. }
  834. }
  835. }