vehicle_create_page.dart 22 KB

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