vehicle_create_page.dart 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685
  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/searchable_picker_sheet.dart';
  19. import 'vehicle_api.dart';
  20. class VehicleCreatePage extends ConsumerStatefulWidget {
  21. const VehicleCreatePage({super.key});
  22. @override
  23. ConsumerState<VehicleCreatePage> createState() => _VehicleCreatePageState();
  24. }
  25. class _VehicleCreatePageState extends ConsumerState<VehicleCreatePage> {
  26. static const _draftKey = 'vehicle_apply';
  27. // ── 基本信息 ──
  28. String _purpose = '';
  29. final _reasonController = TextEditingController();
  30. final _reasonFocus = FocusNode();
  31. String _origin = '';
  32. String _destinAdr = '';
  33. DateTime? _startTime;
  34. DateTime? _endTime;
  35. // ── 车辆信息 ──
  36. String _licensePlate = '';
  37. String _vehicleType = '';
  38. String _brand = '';
  39. int _seats = 0;
  40. // ── 驾驶员 ──
  41. String _driverName = '';
  42. // ── 同行信息 ──
  43. int _passengerCount = 1;
  44. String _passengerName = '';
  45. // ── 参考数据 ──
  46. List<DepartmentItem> _departments = [];
  47. bool _firstBuild = true;
  48. bool _refDataLoading = true;
  49. String _selectedDeptId = '';
  50. String _selectedDeptName = '';
  51. final _scrollCtrl = ScrollController();
  52. // Mock 车牌
  53. static const _mockLicensePlates = ['粤B12345', '京A88888', '京B66666', '京C12345', '京D99999'];
  54. // Mock 车辆类型
  55. static const _mockVehicleTypes = ['sedan', 'suv', 'mpv', 'van'];
  56. @override
  57. void initState() {
  58. super.initState();
  59. SystemChrome.setSystemUIOverlayStyle(
  60. const SystemUiOverlayStyle(statusBarColor: Colors.transparent, statusBarIconBrightness: Brightness.dark),
  61. );
  62. _reasonFocus.addListener(() => _ensureVisible(_reasonFocus));
  63. _departments = [];
  64. _refDataLoading = true;
  65. _refDataFuture = null;
  66. _loadRefData();
  67. WidgetsBinding.instance.addPostFrameCallback((_) => _checkDataReady());
  68. }
  69. void _checkDataReady() {
  70. if (!_refDataLoading && mounted) {
  71. setState(() => _firstBuild = false);
  72. WidgetsBinding.instance.addPostFrameCallback((_) {
  73. if (mounted) setState(() {});
  74. });
  75. } else if (mounted) {
  76. WidgetsBinding.instance.addPostFrameCallback((_) => _checkDataReady());
  77. }
  78. }
  79. Future<void>? _refDataFuture;
  80. Future<void> _loadRefData({bool showLoading = false}) async {
  81. if (_refDataFuture != null) return _refDataFuture!;
  82. final completer = Completer<void>();
  83. _refDataFuture = completer.future;
  84. if (showLoading) {
  85. LoadingDialog.show(context, text: AppLocalizations.of(context).get('dataLoading'));
  86. }
  87. try {
  88. final api = ref.read(vehicleApiProvider);
  89. final results = await Future.wait([api.getDepartments()]);
  90. if (!mounted) return;
  91. setState(() {
  92. _departments = results[0];
  93. _refDataLoading = false;
  94. _autoSelectDept();
  95. });
  96. completer.complete();
  97. } catch (_) {
  98. if (!mounted) { completer.complete(); return; }
  99. setState(() => _refDataLoading = false);
  100. completer.complete();
  101. } finally {
  102. if (showLoading && mounted) LoadingDialog.hide(context);
  103. _refDataFuture = null;
  104. }
  105. }
  106. void _autoSelectDept() {
  107. if (_selectedDeptId.isNotEmpty) return;
  108. final dep = HostAppChannel.dep;
  109. if (dep.isEmpty) return;
  110. final match = _departments.where((d) => d.dep == dep);
  111. if (match.isNotEmpty) {
  112. _selectedDeptId = match.first.dep;
  113. _selectedDeptName = match.first.name;
  114. }
  115. }
  116. void _ensureVisible(FocusNode node) {
  117. if (!node.hasFocus) return;
  118. WidgetsBinding.instance.addPostFrameCallback((_) {
  119. if (node.hasFocus && _scrollCtrl.hasClients) {
  120. final ctx = node.context;
  121. if (ctx != null) {
  122. Scrollable.ensureVisible(ctx, alignment: 0.3, duration: const Duration(milliseconds: 300));
  123. }
  124. }
  125. });
  126. }
  127. @override
  128. void dispose() {
  129. _reasonController.dispose();
  130. _reasonFocus.dispose();
  131. _scrollCtrl.dispose();
  132. super.dispose();
  133. }
  134. @override
  135. Widget build(BuildContext context) {
  136. final l10n = AppLocalizations.of(context);
  137. if (_firstBuild) {
  138. return const SkeletonFormPage();
  139. }
  140. Future.microtask(() => ref.read(pageBackProvider.notifier).state = () => _doPop());
  141. return PopScope(
  142. canPop: false,
  143. onPopInvokedWithResult: (didPop, _) {
  144. if (didPop) return;
  145. _doPop();
  146. },
  147. child: Column(
  148. children: [
  149. Expanded(
  150. child: GestureDetector(
  151. onTap: () => FocusScope.of(context).unfocus(),
  152. child: SingleChildScrollView(
  153. controller: _scrollCtrl,
  154. padding: const EdgeInsets.all(16),
  155. child: Column(
  156. children: [
  157. _buildBasicInfo(l10n),
  158. const SizedBox(height: 16),
  159. _buildVehicleInfo(l10n),
  160. const SizedBox(height: 16),
  161. _buildPassengerInfo(l10n),
  162. const SizedBox(height: 24),
  163. _buildPageFooter(),
  164. ],
  165. ),
  166. ),
  167. ),
  168. ),
  169. _buildBottomBar(l10n),
  170. ],
  171. ),
  172. );
  173. }
  174. // ═══ 基本信息 ═══
  175. Widget _buildBasicInfo(AppLocalizations l10n) {
  176. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  177. return FormSection(
  178. title: l10n.get('basicInfo'),
  179. leadingIcon: Icons.info_outline,
  180. children: [
  181. FormFieldRow(label: l10n.get('date'), value: _today(), readOnly: true, showArrow: false),
  182. const SizedBox(height: 16),
  183. FormFieldRow(
  184. label: l10n.get('applyDept'),
  185. value: _selectedDeptId.isNotEmpty ? '$_selectedDeptId/$_selectedDeptName' : '',
  186. hint: l10n.get('pleaseSelect'),
  187. onTap: _refDataLoading ? null : () => _showDeptPicker(),
  188. ),
  189. const SizedBox(height: 16),
  190. _buildPurposeRow(l10n),
  191. const SizedBox(height: 16),
  192. _label(l10n.get('vehicleReason'), required: true),
  193. const SizedBox(height: 8),
  194. TDTextarea(
  195. controller: _reasonController,
  196. focusNode: _reasonFocus,
  197. hintText: l10n.get('enterVehicleReason'),
  198. maxLines: 4,
  199. minLines: 1,
  200. maxLength: 500,
  201. indicator: true,
  202. padding: EdgeInsets.zero,
  203. bordered: true,
  204. backgroundColor: colors.bgPage,
  205. ),
  206. const SizedBox(height: 16),
  207. FormFieldRow(
  208. label: l10n.get('origin'),
  209. value: _origin.isNotEmpty ? _origin : null,
  210. hint: l10n.get('pleaseEnter'),
  211. onTap: () => _showTextInput(l10n.get('origin'), (v) => setState(() => _origin = v)),
  212. ),
  213. const SizedBox(height: 16),
  214. FormFieldRow(
  215. label: l10n.get('destination'),
  216. value: _destinAdr.isNotEmpty ? _destinAdr : null,
  217. hint: l10n.get('pleaseEnter'),
  218. onTap: () => _showTextInput(l10n.get('destination'), (v) => setState(() => _destinAdr = v)),
  219. ),
  220. const SizedBox(height: 16),
  221. FormFieldRow(
  222. label: l10n.get('departTime'),
  223. value: _startTime != null ? _formatDateTime(_startTime!) : null,
  224. hint: l10n.get('pleaseSelect'),
  225. onTap: () => _pickDateTime((d) => setState(() => _startTime = d), _startTime),
  226. ),
  227. const SizedBox(height: 16),
  228. FormFieldRow(
  229. label: l10n.get('returnTime'),
  230. value: _endTime != null ? _formatDateTime(_endTime!) : null,
  231. hint: l10n.get('pleaseSelect'),
  232. onTap: () => _pickDateTime((d) => setState(() => _endTime = d), _endTime),
  233. ),
  234. if (_startTime != null && _endTime != null && !_endTime!.isAfter(_startTime!))
  235. Padding(
  236. padding: const EdgeInsets.only(top: 8),
  237. child: Text(
  238. l10n.get('returnTimeMustLater'),
  239. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.danger),
  240. ),
  241. ),
  242. ],
  243. );
  244. }
  245. Widget _buildPurposeRow(AppLocalizations l10n) {
  246. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  247. final purposes = ['reception', 'business', 'official', 'other'];
  248. return Column(
  249. crossAxisAlignment: CrossAxisAlignment.start,
  250. children: [
  251. _label(l10n.get('vehiclePurpose'), required: true),
  252. const SizedBox(height: 8),
  253. Wrap(
  254. spacing: 12,
  255. runSpacing: 8,
  256. children: purposes.map((key) {
  257. final sel = _purpose == key;
  258. return GestureDetector(
  259. behavior: HitTestBehavior.opaque,
  260. onTap: () => setState(() => _purpose = key),
  261. child: Row(
  262. mainAxisSize: MainAxisSize.min,
  263. children: [
  264. Container(
  265. width: 18,
  266. height: 18,
  267. decoration: BoxDecoration(
  268. shape: BoxShape.circle,
  269. border: Border.all(color: sel ? colors.primary : colors.textPlaceholder, width: 2),
  270. ),
  271. child: sel
  272. ? Center(child: Container(width: 8, height: 8, decoration: BoxDecoration(shape: BoxShape.circle, color: colors.primary)))
  273. : null,
  274. ),
  275. const SizedBox(width: 5),
  276. Text(_purposeLabel(key, l10n), style: TextStyle(fontSize: AppFontSizes.subtitle, color: sel ? colors.primary : colors.textPrimary)),
  277. ],
  278. ),
  279. );
  280. }).toList(),
  281. ),
  282. ],
  283. );
  284. }
  285. // ═══ 车辆信息 ═══
  286. Widget _buildVehicleInfo(AppLocalizations l10n) {
  287. return FormSection(
  288. title: l10n.get('vehicleInfo'),
  289. leadingIcon: Icons.directions_car_outlined,
  290. children: [
  291. FormFieldRow(
  292. label: l10n.get('licensePlate'),
  293. value: _licensePlate.isNotEmpty ? _licensePlate : null,
  294. hint: l10n.get('pleaseSelect'),
  295. onTap: () => _showLicensePlatePicker(),
  296. ),
  297. const SizedBox(height: 16),
  298. FormFieldRow(
  299. label: l10n.get('vehicleType'),
  300. value: _vehicleType.isNotEmpty ? _vehicleTypeLabel(_vehicleType, l10n) : null,
  301. hint: l10n.get('pleaseSelect'),
  302. onTap: () => _showVehicleTypePicker(l10n),
  303. ),
  304. const SizedBox(height: 16),
  305. FormFieldRow(
  306. label: l10n.get('brand'),
  307. value: _brand.isNotEmpty ? _brand : null,
  308. hint: l10n.get('pleaseEnter'),
  309. onTap: () => _showTextInput(l10n.get('brand'), (v) => setState(() => _brand = v), initialText: _brand),
  310. ),
  311. const SizedBox(height: 16),
  312. FormFieldRow(
  313. label: l10n.get('seats'),
  314. value: _seats > 0 ? '$_seats' : null,
  315. hint: l10n.get('pleaseEnter'),
  316. onTap: () => _showNumberInput(l10n.get('seats'), (v) => setState(() => _seats = v), _seats),
  317. ),
  318. const SizedBox(height: 16),
  319. FormFieldRow(
  320. label: l10n.get('driverName'),
  321. value: _driverName.isNotEmpty ? _driverName : null,
  322. hint: l10n.get('pleaseSelect'),
  323. onTap: () => _showDriverPicker(),
  324. ),
  325. ],
  326. );
  327. }
  328. // ═══ 同行信息 ═══
  329. Widget _buildPassengerInfo(AppLocalizations l10n) {
  330. return FormSection(
  331. title: l10n.get('companionInfo'),
  332. leadingIcon: Icons.people_outline,
  333. children: [
  334. FormFieldRow(
  335. label: l10n.get('passengerCount'),
  336. value: '$_passengerCount',
  337. onTap: () => _showNumberInput(l10n.get('passengerCount'), (v) => setState(() => _passengerCount = v < 1 ? 1 : v), _passengerCount),
  338. ),
  339. const SizedBox(height: 16),
  340. FormFieldRow(
  341. label: l10n.get('passengerName'),
  342. value: _passengerName.isNotEmpty ? _passengerName : null,
  343. hint: l10n.get('pleaseEnter'),
  344. onTap: () => _showTextInput(l10n.get('passengerName'), (v) => setState(() => _passengerName = v), initialText: _passengerName),
  345. ),
  346. ],
  347. );
  348. }
  349. // ═══ 底部操作栏 ═══
  350. Widget _buildBottomBar(AppLocalizations l10n) {
  351. return ActionBar(
  352. showLeft: false,
  353. centerLabel: l10n.get('saveDraft'),
  354. rightLabel: l10n.get('submit'),
  355. centerTextOnly: true,
  356. onCenterTap: () async {
  357. FocusScope.of(context).unfocus();
  358. try {
  359. await _saveDraftToStorage();
  360. if (mounted) _forcePop();
  361. } catch (_) {
  362. if (mounted) TDToast.showFail(l10n.get('saveFailed'), context: context);
  363. }
  364. },
  365. onRightTap: () async {
  366. final err = _validate(l10n);
  367. if (err.isNotEmpty) {
  368. TDToast.showText(err.first, context: context);
  369. return;
  370. }
  371. FocusScope.of(context).unfocus();
  372. LoadingDialog.show(context, text: l10n.get('submitting'));
  373. try {
  374. final data = _buildSubmitData();
  375. final api = ref.read(vehicleApiProvider);
  376. await api.submit(data);
  377. await DraftStorage.delete(_draftKey);
  378. if (mounted) {
  379. LoadingDialog.hide(context);
  380. TDToast.showSuccess(l10n.get('submitSuccess'), context: context);
  381. GoRouter.of(context).go('/vehicle/list');
  382. }
  383. } catch (_) {
  384. if (mounted) {
  385. LoadingDialog.hide(context);
  386. TDToast.showFail(l10n.get('submitFailedRetry'), context: context);
  387. }
  388. }
  389. },
  390. );
  391. }
  392. Map<String, dynamic> _buildSubmitData() {
  393. return {
  394. 'HeadData': {
  395. 'YC_DD': _today(),
  396. 'SAL_NO': HostAppChannel.usr,
  397. 'DEP': _selectedDeptId,
  398. 'PURPOSE': _purpose,
  399. 'REASON': _reasonController.text.trim(),
  400. 'ORIGIN': _origin,
  401. 'DESTIN_ADR': _destinAdr,
  402. 'PASSENGER_COUNT': _passengerCount,
  403. 'PASSENGER_NAME': _passengerName,
  404. 'LICENSEPLATE': _licensePlate,
  405. 'VEHICLE_TYPE': _vehicleType,
  406. 'BRAND': _brand,
  407. 'SEATS': _seats,
  408. 'DRIVER_NAME': _driverName,
  409. 'START_TIME': _startTime?.toIso8601String(),
  410. 'END_TIME': _endTime?.toIso8601String(),
  411. 'USR': HostAppChannel.usr,
  412. },
  413. };
  414. }
  415. List<String> _validate(AppLocalizations l10n) {
  416. final e = <String>[];
  417. if (_reasonController.text.trim().isEmpty) e.add(l10n.get('enterVehicleReason'));
  418. if (_purpose.isEmpty) e.add('请选择用车目的');
  419. if (_licensePlate.isEmpty) e.add('请选择车牌号');
  420. if (_startTime != null && _endTime != null && !_endTime!.isAfter(_startTime!)) {
  421. e.add(l10n.get('returnTimeMustLater'));
  422. }
  423. return e;
  424. }
  425. // ═══ 草稿 ═══
  426. Future<void> _saveDraftToStorage() async {
  427. await DraftStorage.save(_draftKey, {
  428. 'purpose': _purpose,
  429. 'reason': _reasonController.text,
  430. 'origin': _origin,
  431. 'destinAdr': _destinAdr,
  432. 'startTime': _startTime?.toIso8601String(),
  433. 'endTime': _endTime?.toIso8601String(),
  434. 'licensePlate': _licensePlate,
  435. 'vehicleType': _vehicleType,
  436. 'brand': _brand,
  437. 'seats': _seats,
  438. 'driverName': _driverName,
  439. 'passengerCount': _passengerCount,
  440. 'passengerName': _passengerName,
  441. 'selectedDeptId': _selectedDeptId,
  442. 'selectedDeptName': _selectedDeptName,
  443. });
  444. }
  445. void _doPop() {
  446. if (_hasUnsaved()) {
  447. final l10n = AppLocalizations.of(context);
  448. _showConfirmDialog(l10n.get('confirmExit'), l10n.get('unsavedContentWarning'), l10n.get('continueEditing'), l10n.get('discardAndExit'), () {
  449. DraftStorage.delete(_draftKey);
  450. _forcePop();
  451. });
  452. } else {
  453. _forcePop();
  454. }
  455. }
  456. void _forcePop() {
  457. FocusManager.instance.primaryFocus?.unfocus();
  458. final router = GoRouter.of(context);
  459. if (router.canPop()) { router.pop(); } else { SystemNavigator.pop(); }
  460. }
  461. bool _hasUnsaved() =>
  462. _reasonController.text.isNotEmpty || _purpose.isNotEmpty || _origin.isNotEmpty || _destinAdr.isNotEmpty ||
  463. _licensePlate.isNotEmpty || _vehicleType.isNotEmpty || _brand.isNotEmpty || _driverName.isNotEmpty ||
  464. _passengerName.isNotEmpty || _passengerCount != 1;
  465. void _showConfirmDialog(String title, String content, String leftText, String rightText, VoidCallback onConfirm) {
  466. FocusScope.of(context).unfocus();
  467. FocusManager.instance.primaryFocus?.unfocus();
  468. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  469. showDialog(
  470. context: context,
  471. useRootNavigator: true,
  472. builder: (ctx) => TDAlertDialog(
  473. title: title,
  474. content: content,
  475. buttonStyle: TDDialogButtonStyle.text,
  476. leftBtn: TDDialogButtonOptions(title: leftText, titleColor: colors.primary, action: () => Navigator.pop(ctx)),
  477. rightBtn: TDDialogButtonOptions(title: rightText, titleColor: colors.danger, action: () { Navigator.pop(ctx); onConfirm(); }),
  478. ),
  479. );
  480. }
  481. // ═══ 弹窗方法 ═══
  482. void _showTextInput(String title, void Function(String) onConfirm, {String initialText = ''}) {
  483. FocusScope.of(context).unfocus();
  484. final l10n = AppLocalizations.of(context);
  485. final c = TextEditingController(text: initialText);
  486. showGeneralDialog(
  487. context: context,
  488. pageBuilder: (ctx, animation, secondaryAnimation) => TDInputDialog(
  489. textEditingController: c,
  490. title: title,
  491. hintText: l10n.get('pleaseEnter'),
  492. leftBtn: TDDialogButtonOptions(title: l10n.get('cancel'), action: () => Navigator.pop(ctx)),
  493. rightBtn: TDDialogButtonOptions(title: l10n.get('confirm'), action: () { onConfirm(c.text); Navigator.pop(ctx); }),
  494. ),
  495. );
  496. }
  497. void _showNumberInput(String title, void Function(int) onSave, int current) {
  498. FocusScope.of(context).unfocus();
  499. final l10n = AppLocalizations.of(context);
  500. final ctrl = TextEditingController(text: '$current');
  501. showDialog(
  502. context: context,
  503. builder: (_) => TDAlertDialog(
  504. title: title,
  505. contentWidget: TDInput(controller: ctrl, hintText: '请输入数字', inputType: TextInputType.number),
  506. leftBtn: TDDialogButtonOptions(title: l10n.get('cancel'), action: () => Navigator.pop(context)),
  507. rightBtn: TDDialogButtonOptions(
  508. title: l10n.get('confirm'),
  509. theme: TDButtonTheme.primary,
  510. action: () { onSave(int.tryParse(ctrl.text) ?? 1); Navigator.pop(context); },
  511. ),
  512. ),
  513. );
  514. }
  515. void _pickDateTime(void Function(DateTime) onPicked, DateTime? initial) {
  516. FocusScope.of(context).unfocus();
  517. final l10n = AppLocalizations.of(context);
  518. final d = initial ?? DateTime.now();
  519. TDPicker.showDatePicker(
  520. context,
  521. title: l10n.get('selectDateTime'),
  522. useYear: true, useMonth: true, useDay: true, useHour: true, useMinute: true,
  523. initialDate: [d.year, d.month, d.day, d.hour, d.minute],
  524. onConfirm: (selected) {
  525. onPicked(DateTime(selected['year']!, selected['month']!, selected['day']!, selected['hour']!, selected['minute']!));
  526. },
  527. );
  528. }
  529. Future<void> _showDeptPicker() async {
  530. FocusManager.instance.primaryFocus?.unfocus();
  531. final l10n = AppLocalizations.of(context);
  532. final api = ref.read(vehicleApiProvider);
  533. final result = await showSearchablePicker<DepartmentItem>(
  534. context,
  535. title: '${l10n.get('select')}${l10n.get('applyDept')}',
  536. searchHint: l10n.get('search'),
  537. loader: (keyword, page) => api.getDepartments(keyword: keyword, page: page, size: 20),
  538. labelBuilder: (d) => d.name.isEmpty ? d.dep : '${d.dep} ${d.name}',
  539. );
  540. if (result != null && mounted) {
  541. setState(() { _selectedDeptId = result.dep; _selectedDeptName = result.name; });
  542. }
  543. }
  544. void _showLicensePlatePicker() {
  545. FocusScope.of(context).unfocus();
  546. final l10n = AppLocalizations.of(context);
  547. TDPicker.showMultiPicker(
  548. context,
  549. title: l10n.get('selectLicensePlate'),
  550. data: [_mockLicensePlates],
  551. onConfirm: (selected) => setState(() => _licensePlate = selected.first),
  552. );
  553. }
  554. void _showVehicleTypePicker(AppLocalizations l10n) {
  555. FocusScope.of(context).unfocus();
  556. final labels = _mockVehicleTypes.map((t) => _vehicleTypeLabel(t, l10n)).toList();
  557. TDPicker.showMultiPicker(
  558. context,
  559. title: l10n.get('selectVehicleType'),
  560. data: [labels],
  561. onConfirm: (selected) {
  562. final idx = labels.indexOf(selected.first);
  563. if (idx >= 0) setState(() => _vehicleType = _mockVehicleTypes[idx]);
  564. },
  565. );
  566. }
  567. Future<void> _showDriverPicker() async {
  568. FocusManager.instance.primaryFocus?.unfocus();
  569. final l10n = AppLocalizations.of(context);
  570. final api = ref.read(vehicleApiProvider);
  571. final result = await showSearchablePicker<EmployeeItem>(
  572. context,
  573. title: '${l10n.get('select')}${l10n.get('driverName')}',
  574. searchHint: l10n.get('search'),
  575. loader: (keyword, page) => api.getEmployees(keyword: keyword, page: page, size: 20),
  576. labelBuilder: (e) => e.name.isEmpty ? e.salNo : '${e.salNo} ${e.name}',
  577. );
  578. if (result != null && mounted) {
  579. setState(() { _driverName = result.name; });
  580. }
  581. }
  582. // ═══ 工具方法 ═══
  583. Widget _label(String t, {bool required = false}) {
  584. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  585. return Text.rich(
  586. TextSpan(
  587. children: [
  588. TextSpan(text: t, style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textSecondary)),
  589. if (required) TextSpan(text: ' *', style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.danger)),
  590. ],
  591. ),
  592. );
  593. }
  594. Widget _buildPageFooter() {
  595. final l10n = AppLocalizations.of(context);
  596. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  597. return Center(
  598. child: Padding(
  599. padding: const EdgeInsets.only(bottom: 16),
  600. child: Row(
  601. mainAxisSize: MainAxisSize.min,
  602. children: [
  603. Icon(Icons.rocket_launch_outlined, size: 16, color: colors.textPlaceholder),
  604. const SizedBox(width: 6),
  605. Text(l10n.get('pageFooter'), style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPlaceholder)),
  606. ],
  607. ),
  608. ),
  609. );
  610. }
  611. String _today() {
  612. final n = DateTime.now();
  613. return '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}';
  614. }
  615. String _formatDateTime(DateTime d) {
  616. 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')}';
  617. }
  618. String _purposeLabel(String key, AppLocalizations l10n) {
  619. switch (key) {
  620. case 'reception': return l10n.get('customerReception');
  621. case 'business': return l10n.get('businessTrip');
  622. case 'official': return l10n.get('official');
  623. case 'other': return l10n.get('other');
  624. default: return key;
  625. }
  626. }
  627. String _vehicleTypeLabel(String key, AppLocalizations l10n) {
  628. switch (key) {
  629. case 'sedan': return '轿车';
  630. case 'suv': return 'SUV';
  631. case 'mpv': return '商务车';
  632. case 'van': return '面包车';
  633. default: return key;
  634. }
  635. }
  636. }