vehicle_create_page.dart 22 KB

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