vehicle_create_page.dart 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import 'package:go_router/go_router.dart';
  4. import 'package:tdesign_flutter/tdesign_flutter.dart';
  5. import '../../shared/widgets/nav_bar_config.dart';
  6. import '../../core/utils/date_utils.dart' as du;
  7. import '../../shared/widgets/action_bar.dart';
  8. import '../../shared/widgets/form_section.dart';
  9. import '../../shared/widgets/form_field_row.dart';
  10. import '../../core/i18n/app_localizations.dart';
  11. import 'vehicle_create_controller.dart';
  12. import '../../core/theme/app_colors.dart';
  13. import '../../core/theme/app_colors_extension.dart';
  14. class VehicleCreatePage extends ConsumerStatefulWidget {
  15. final String? editId;
  16. const VehicleCreatePage({super.key, this.editId});
  17. @override
  18. ConsumerState<VehicleCreatePage> createState() => _VehicleCreatePageState();
  19. }
  20. class _VehicleCreatePageState extends ConsumerState<VehicleCreatePage> {
  21. final _reasonController = TextEditingController();
  22. final _originController = TextEditingController();
  23. final _destinationController = TextEditingController();
  24. final _scrollCtrl = ScrollController();
  25. bool _showReasonError = false;
  26. // Mock vehicle pool (车牌号列表)
  27. static const _vehiclePool = [
  28. '京A88888',
  29. '京B66666',
  30. '京C12345',
  31. '京D99999',
  32. '京E55555',
  33. ];
  34. // Mock passengers for contact picker
  35. static const _mockContacts = [
  36. '赵六',
  37. '钱七',
  38. '孙八',
  39. '周九',
  40. '吴十',
  41. '郑十一',
  42. '王十二',
  43. '冯十三',
  44. '陈十四',
  45. '褚十五',
  46. ];
  47. @override
  48. void initState() {
  49. super.initState();
  50. final state = ref.read(vehicleCreateProvider(widget.editId));
  51. _reasonController.text = state.vehicle.reason;
  52. _originController.text = state.vehicle.origin;
  53. _destinationController.text = state.vehicle.destination;
  54. }
  55. @override
  56. void dispose() {
  57. _reasonController.dispose();
  58. _originController.dispose();
  59. _destinationController.dispose();
  60. _scrollCtrl.dispose();
  61. super.dispose();
  62. }
  63. @override
  64. Widget build(BuildContext context) {
  65. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  66. final ctrl = ref.watch(vehicleCreateProvider(widget.editId).notifier);
  67. final state = ref.watch(vehicleCreateProvider(widget.editId));
  68. final l10n = AppLocalizations.of(context);
  69. final v = state.vehicle;
  70. setNavBarTitle(context, ref, NavBarConfig(
  71. title: l10n.get('vehicleApply'),
  72. showBack: true,
  73. onBack: () {
  74. if (_hasUnsaved()) {
  75. _showConfirmDialog(
  76. l10n.get('confirmExit'),
  77. l10n.get('unsavedContentWarning'),
  78. l10n.get('continueEditing'),
  79. l10n.get('discardAndExit'),
  80. () => context.pop(),
  81. );
  82. } else {
  83. context.pop();
  84. }
  85. },
  86. ));
  87. return PopScope(
  88. canPop: false,
  89. onPopInvokedWithResult: (didPop, _) {
  90. if (!didPop) {
  91. if (_hasUnsaved()) {
  92. _showConfirmDialog(
  93. l10n.get('confirmExit'),
  94. l10n.get('unsavedContentWarning'),
  95. l10n.get('continueEditing'),
  96. l10n.get('discardAndExit'),
  97. () => context.pop(),
  98. );
  99. } else {
  100. context.pop();
  101. }
  102. }
  103. },
  104. child: Column(
  105. children: [
  106. Expanded(
  107. child: GestureDetector(
  108. onTap: () => FocusScope.of(context).unfocus(),
  109. child: SingleChildScrollView(
  110. controller: _scrollCtrl,
  111. padding: const EdgeInsets.all(16),
  112. child: Column(
  113. children: [
  114. FormSection(
  115. title: l10n.get('vehicleInfo'),
  116. leadingIcon: Icons.directions_car_outlined,
  117. children: [
  118. // 车牌号
  119. FormFieldRow(
  120. label: l10n.get('licensePlate'),
  121. value: v.vehicleId.isNotEmpty ? v.vehicleId : null,
  122. hint: l10n.get('selectLicensePlate'),
  123. onTap: () => _showVehiclePicker(ctrl),
  124. ),
  125. // 排期冲突提示
  126. if (state.hasConflict) _buildConflictWarning(),
  127. const SizedBox(height: 16),
  128. // 用车事由
  129. _label(l10n.get('vehicleReason'), required: true),
  130. const SizedBox(height: 8),
  131. TDTextarea(
  132. controller: _reasonController,
  133. hintText: l10n.get('enterVehicleReason'),
  134. maxLines: 4,
  135. minLines: 1,
  136. maxLength: 500,
  137. indicator: true,
  138. padding: EdgeInsets.zero,
  139. bordered: true,
  140. backgroundColor: colors.bgPage,
  141. onChanged: (val) {
  142. ctrl.updateReason(val);
  143. setState(() => _showReasonError = false);
  144. },
  145. ),
  146. if (_showReasonError)
  147. Padding(
  148. padding: EdgeInsets.only(top: 4),
  149. child: Text(
  150. l10n.get('enterVehicleReason'),
  151. style: TextStyle(
  152. fontSize: AppFontSizes.caption,
  153. color: colors.danger,
  154. ),
  155. ),
  156. ),
  157. const SizedBox(height: 16),
  158. // 用车目的
  159. FormFieldRow(
  160. label: l10n.get('vehiclePurpose'),
  161. value: _purposeLabel(v.purpose),
  162. hint: l10n.get('selectVehicleReason'),
  163. onTap: () => _showPurposePicker(ctrl),
  164. ),
  165. const SizedBox(height: 16),
  166. // 始发地
  167. FormFieldRow(
  168. label: l10n.get('origin'),
  169. value: _originController.text.isNotEmpty
  170. ? _originController.text
  171. : null,
  172. hint: l10n.get('gpsLocating'),
  173. onTap: () => _showTextInput(
  174. l10n.get('origin'),
  175. (val) {
  176. _originController.text = val;
  177. ctrl.updateOrigin(val);
  178. },
  179. initialText: _originController.text,
  180. ),
  181. ),
  182. const SizedBox(height: 16),
  183. // 目的地
  184. FormFieldRow(
  185. label: l10n.get('destination'),
  186. value: _destinationController.text.isNotEmpty
  187. ? _destinationController.text
  188. : null,
  189. hint: l10n.get('enterDestination'),
  190. onTap: () => _showDestinationOptions(ctrl),
  191. ),
  192. const SizedBox(height: 16),
  193. // 出车时间
  194. FormFieldRow(
  195. label: l10n.get('departTime'),
  196. value: du.DateUtils.formatDateTime(v.startTime),
  197. onTap: () =>
  198. _pickDateTime(ctrl.updateStartTime, v.startTime),
  199. ),
  200. const SizedBox(height: 16),
  201. // 还车时间
  202. FormFieldRow(
  203. label: l10n.get('returnTime'),
  204. value: du.DateUtils.formatDateTime(v.endTime),
  205. onTap: () =>
  206. _pickDateTime(ctrl.updateEndTime, v.endTime),
  207. ),
  208. if (!v.endTime.isAfter(v.startTime))
  209. Padding(
  210. padding: EdgeInsets.only(top: 4),
  211. child: Text(
  212. l10n.get('returnTimeMustLater'),
  213. style: TextStyle(
  214. fontSize: AppFontSizes.caption,
  215. color: colors.danger,
  216. ),
  217. ),
  218. ),
  219. const SizedBox(height: 16),
  220. // 同行人数
  221. FormFieldRow(
  222. label: l10n.get('passengerCount'),
  223. value: '${v.passengerCount}${l10n.get('personUnit')}',
  224. onTap: () => _showNumberInput(
  225. l10n.get('passengerCount'),
  226. ctrl.updatePassengerCount,
  227. v.passengerCount,
  228. ),
  229. ),
  230. const SizedBox(height: 16),
  231. // 同行人
  232. _buildPassengersSection(state, ctrl),
  233. ],
  234. ),
  235. const SizedBox(height: 80),
  236. ],
  237. ),
  238. ),
  239. ),
  240. ),
  241. ActionBar(
  242. showLeft: false,
  243. centerLabel: l10n.get('saveDraftShort'),
  244. rightLabel: l10n.get('submitApproval'),
  245. onCenterTap: state.isSubmitting
  246. ? null
  247. : () async {
  248. await ctrl.saveDraft();
  249. if (context.mounted) {
  250. TDToast.showText(l10n.get('draftSavedToast'), context: context);
  251. context.pop();
  252. }
  253. },
  254. onRightTap: (state.isSubmitting || state.hasConflict)
  255. ? null
  256. : () async {
  257. final reasonOk = v.reason.trim().isNotEmpty;
  258. final vehicleOk = v.vehicleId.isNotEmpty;
  259. final timeOk = v.endTime.isAfter(v.startTime);
  260. setState(() => _showReasonError = !reasonOk);
  261. if (!reasonOk || !vehicleOk || !timeOk) {
  262. TDToast.showText(l10n.get('completeFormInfo'), context: context);
  263. return;
  264. }
  265. final ok = await ctrl.submit();
  266. if (context.mounted) {
  267. if (ok) {
  268. TDToast.showText(l10n.get('submittedAwaitingApproval'), context: context);
  269. context.pop();
  270. } else {
  271. TDToast.showText(l10n.get('submitFailedRetry'), context: context);
  272. }
  273. }
  274. },
  275. ),
  276. ],
  277. ),
  278. );
  279. }
  280. // ── 通用方法 ──
  281. bool _hasUnsaved() {
  282. final s = ref.read(vehicleCreateProvider(widget.editId));
  283. final veh = s.vehicle;
  284. return _reasonController.text.isNotEmpty ||
  285. _originController.text.isNotEmpty ||
  286. _destinationController.text.isNotEmpty ||
  287. veh.vehicleId.isNotEmpty ||
  288. s.passengers.isNotEmpty;
  289. }
  290. void _unfocus() => FocusScope.of(context).unfocus();
  291. void _showConfirmDialog(
  292. String title,
  293. String content,
  294. String leftText,
  295. String rightText,
  296. VoidCallback onConfirm,
  297. ) {
  298. _unfocus();
  299. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  300. showDialog(
  301. context: context,
  302. builder: (ctx) => TDAlertDialog(
  303. title: title,
  304. content: content,
  305. buttonStyle: TDDialogButtonStyle.text,
  306. leftBtn: TDDialogButtonOptions(
  307. title: leftText,
  308. titleColor: colors.primary,
  309. action: () => Navigator.pop(ctx),
  310. ),
  311. rightBtn: TDDialogButtonOptions(
  312. title: rightText,
  313. titleColor: colors.danger,
  314. action: () {
  315. Navigator.pop(ctx);
  316. onConfirm();
  317. },
  318. ),
  319. ),
  320. );
  321. }
  322. void _showTextInput(
  323. String title,
  324. Function(String) onConfirm, {
  325. String initialText = '',
  326. }) {
  327. _unfocus();
  328. final l10n = AppLocalizations.of(context);
  329. final c = TextEditingController(text: initialText);
  330. showGeneralDialog(
  331. context: context,
  332. pageBuilder: (ctx, animation, secondaryAnimation) => TDInputDialog(
  333. textEditingController: c,
  334. title: title,
  335. hintText: l10n.get('pleaseEnter'),
  336. leftBtn: TDDialogButtonOptions(
  337. title: l10n.get('cancel'),
  338. action: () => Navigator.pop(ctx),
  339. ),
  340. rightBtn: TDDialogButtonOptions(
  341. title: l10n.get('confirm'),
  342. action: () {
  343. onConfirm(c.text);
  344. Navigator.pop(ctx);
  345. },
  346. ),
  347. ),
  348. );
  349. }
  350. void _showDestinationOptions(VehicleCreateController ctrl) {
  351. _unfocus();
  352. final l10n = AppLocalizations.of(context);
  353. showModalBottomSheet(
  354. context: context,
  355. builder: (ctx) => SafeArea(
  356. child: Column(
  357. mainAxisSize: MainAxisSize.min,
  358. children: [
  359. ListTile(
  360. leading: Icon(Icons.edit_outlined),
  361. title: Text(l10n.get('enterDestination')),
  362. onTap: () {
  363. Navigator.pop(ctx);
  364. _showTextInput(
  365. l10n.get('destination'),
  366. (val) {
  367. _destinationController.text = val;
  368. ctrl.updateDestination(val);
  369. },
  370. initialText: _destinationController.text,
  371. );
  372. },
  373. ),
  374. ListTile(
  375. leading: Icon(Icons.map_outlined),
  376. title: const Text('地图选点'),
  377. onTap: () {
  378. Navigator.pop(ctx);
  379. TDToast.showText(l10n.get('mapPickerComingSoon'), context: context);
  380. },
  381. ),
  382. ],
  383. ),
  384. ),
  385. );
  386. }
  387. Widget _label(String text, {bool required = false}) {
  388. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  389. return Text.rich(
  390. TextSpan(
  391. children: [
  392. TextSpan(
  393. text: text,
  394. style: TextStyle(
  395. fontSize: AppFontSizes.subtitle,
  396. color: colors.textSecondary,
  397. ),
  398. ),
  399. if (required)
  400. TextSpan(
  401. text: ' *',
  402. style: TextStyle(
  403. fontSize: AppFontSizes.subtitle,
  404. color: colors.danger,
  405. ),
  406. ),
  407. ],
  408. ),
  409. );
  410. }
  411. // ── 表单字段方法 ──
  412. String _purposeLabel(String key) {
  413. final l10n = AppLocalizations.of(context);
  414. switch (key) {
  415. case 'reception':
  416. return l10n.get('customerReception');
  417. case 'business':
  418. return l10n.get('businessTrip');
  419. case 'official':
  420. return l10n.get('official');
  421. default:
  422. return key;
  423. }
  424. }
  425. String _purposeKey(String label) {
  426. final l10n = AppLocalizations.of(context);
  427. if (label == l10n.get('customerReception')) return 'reception';
  428. if (label == l10n.get('businessTrip')) return 'business';
  429. if (label == l10n.get('official')) return 'official';
  430. return label;
  431. }
  432. Widget _buildConflictWarning() {
  433. final l10n = AppLocalizations.of(context);
  434. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  435. return Container(
  436. padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
  437. decoration: BoxDecoration(
  438. color: colors.dangerBg,
  439. borderRadius: BorderRadius.circular(4),
  440. border: Border.all(color: colors.danger.withValues(alpha: 0.3)),
  441. ),
  442. child: Row(
  443. children: [
  444. Icon(Icons.warning_amber_rounded, size: 16, color: colors.danger),
  445. const SizedBox(width: 8),
  446. Expanded(
  447. child: Text(
  448. l10n.get('vehicleOccupiedPeriod'),
  449. style: TextStyle(
  450. fontSize: AppFontSizes.caption,
  451. color: colors.danger,
  452. ),
  453. ),
  454. ),
  455. ],
  456. ),
  457. );
  458. }
  459. Widget _buildPassengersSection(
  460. VehicleCreateState state,
  461. VehicleCreateController ctrl,
  462. ) {
  463. final l10n = AppLocalizations.of(context);
  464. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  465. return Column(
  466. crossAxisAlignment: CrossAxisAlignment.start,
  467. children: [
  468. Row(
  469. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  470. children: [
  471. Text(
  472. l10n.get('companion'),
  473. style: TextStyle(
  474. fontSize: AppFontSizes.subtitle,
  475. color: colors.textSecondary,
  476. ),
  477. ),
  478. GestureDetector(
  479. onTap: () => _showContactPicker(ctrl),
  480. child: Container(
  481. padding: const EdgeInsets.symmetric(
  482. horizontal: 12,
  483. vertical: 6,
  484. ),
  485. decoration: BoxDecoration(
  486. color: colors.primaryLight,
  487. borderRadius: BorderRadius.circular(16),
  488. ),
  489. child: Row(
  490. mainAxisSize: MainAxisSize.min,
  491. children: [
  492. Icon(
  493. Icons.person_add_alt_1,
  494. size: 14,
  495. color: colors.primary,
  496. ),
  497. SizedBox(width: 4),
  498. Text(
  499. l10n.get('add'),
  500. style: TextStyle(
  501. fontSize: AppFontSizes.caption,
  502. color: colors.primary,
  503. ),
  504. ),
  505. ],
  506. ),
  507. ),
  508. ),
  509. ],
  510. ),
  511. if (state.passengers.isNotEmpty) ...[
  512. const SizedBox(height: 8),
  513. Wrap(
  514. spacing: 8,
  515. runSpacing: 4,
  516. children: state.passengers.map((name) {
  517. return TDTag(
  518. name,
  519. size: TDTagSize.medium,
  520. theme: TDTagTheme.primary,
  521. isLight: true,
  522. needCloseIcon: true,
  523. onCloseTap: () => ctrl.removePassenger(name),
  524. );
  525. }).toList(),
  526. ),
  527. ],
  528. ],
  529. );
  530. }
  531. void _showVehiclePicker(VehicleCreateController ctrl) {
  532. final l10n = AppLocalizations.of(context);
  533. _unfocus();
  534. TDPicker.showMultiPicker(
  535. context,
  536. title: l10n.get('selectLicensePlate'),
  537. data: [_vehiclePool],
  538. onConfirm: (selected) => ctrl.updateVehicleId(selected.first),
  539. );
  540. }
  541. void _showPurposePicker(VehicleCreateController ctrl) {
  542. final l10n = AppLocalizations.of(context);
  543. _unfocus();
  544. final purposes = [
  545. l10n.get('customerReception'),
  546. l10n.get('businessTrip'),
  547. l10n.get('official'),
  548. ];
  549. TDPicker.showMultiPicker(
  550. context,
  551. title: l10n.get('selectVehicleReason'),
  552. data: [purposes],
  553. onConfirm: (selected) => ctrl.updatePurpose(_purposeKey(selected.first)),
  554. );
  555. }
  556. void _showContactPicker(VehicleCreateController ctrl) {
  557. _unfocus();
  558. final l10n = AppLocalizations.of(context);
  559. final state = ref.read(vehicleCreateProvider(widget.editId));
  560. final selected = <String>{...state.passengers};
  561. showDialog(
  562. context: context,
  563. builder: (ctx) => TDAlertDialog(
  564. title: l10n.get('selectCompanion'),
  565. contentWidget: SizedBox(
  566. height: 300,
  567. child: ListView(
  568. children: _mockContacts.map((name) {
  569. return CheckboxListTile(
  570. title: Text(name),
  571. value: selected.contains(name),
  572. onChanged: (checked) {
  573. if (checked == true) {
  574. selected.add(name);
  575. } else {
  576. selected.remove(name);
  577. }
  578. setState(() {});
  579. },
  580. );
  581. }).toList(),
  582. ),
  583. ),
  584. leftBtn: TDDialogButtonOptions(
  585. title: l10n.get('cancel'),
  586. action: () => Navigator.pop(ctx),
  587. ),
  588. rightBtn: TDDialogButtonOptions(
  589. title: l10n.get('confirm'),
  590. theme: TDButtonTheme.primary,
  591. action: () {
  592. for (final name in selected) {
  593. ctrl.addPassenger(name);
  594. }
  595. Navigator.pop(ctx);
  596. },
  597. ),
  598. ),
  599. );
  600. }
  601. void _showNumberInput(String title, void Function(int) onSave, int current) {
  602. _unfocus();
  603. final l10n = AppLocalizations.of(context);
  604. final ctrl = TextEditingController(text: '$current');
  605. showDialog(
  606. context: context,
  607. builder: (_) => TDAlertDialog(
  608. title: title,
  609. contentWidget: TDInput(controller: ctrl, hintText: '请输入数字'),
  610. leftBtn: TDDialogButtonOptions(
  611. title: l10n.get('cancel'),
  612. action: () => Navigator.pop(context),
  613. ),
  614. rightBtn: TDDialogButtonOptions(
  615. title: l10n.get('confirm'),
  616. theme: TDButtonTheme.primary,
  617. action: () {
  618. onSave(int.tryParse(ctrl.text) ?? 1);
  619. Navigator.pop(context);
  620. },
  621. ),
  622. ),
  623. );
  624. }
  625. void _pickDateTime(void Function(DateTime) onPicked, DateTime initial) {
  626. _unfocus();
  627. final l10n = AppLocalizations.of(context);
  628. TDPicker.showDatePicker(
  629. context,
  630. title: l10n.get('selectDateTime'),
  631. useYear: true,
  632. useMonth: true,
  633. useDay: true,
  634. useHour: true,
  635. useMinute: true,
  636. initialDate: [
  637. initial.year,
  638. initial.month,
  639. initial.day,
  640. initial.hour,
  641. initial.minute,
  642. ],
  643. onConfirm: (selected) {
  644. onPicked(
  645. DateTime(
  646. selected['year']!,
  647. selected['month']!,
  648. selected['day']!,
  649. selected['hour']!,
  650. selected['minute']!,
  651. ),
  652. );
  653. },
  654. );
  655. }
  656. }