vehicle_apply_page.dart 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558
  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_apply_controller.dart';
  12. import '../../core/theme/app_colors.dart';
  13. import '../../core/theme/app_colors_extension.dart';
  14. class VehicleApplyPage extends ConsumerStatefulWidget {
  15. final String? editId;
  16. const VehicleApplyPage({super.key, this.editId});
  17. @override
  18. ConsumerState<VehicleApplyPage> createState() => _VehicleApplyPageState();
  19. }
  20. class _VehicleApplyPageState extends ConsumerState<VehicleApplyPage> {
  21. final _reasonController = TextEditingController();
  22. final _originController = TextEditingController();
  23. final _destinationController = TextEditingController();
  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(vehicleApplyProvider(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. super.dispose();
  60. }
  61. @override
  62. Widget build(BuildContext context) {
  63. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  64. final ctrl = ref.watch(vehicleApplyProvider(widget.editId).notifier);
  65. final state = ref.watch(vehicleApplyProvider(widget.editId));
  66. final l10n = AppLocalizations.of(context);
  67. final v = state.vehicle;
  68. ref
  69. .read(navBarConfigProvider.notifier)
  70. .update(
  71. NavBarConfig(
  72. title: l10n.get('vehicleApply'),
  73. showBack: true,
  74. onBack: () => context.pop(),
  75. ),
  76. );
  77. return Column(
  78. children: [
  79. Expanded(
  80. child: SingleChildScrollView(
  81. padding: const EdgeInsets.all(16),
  82. child: Column(
  83. children: [
  84. FormSection(
  85. title: l10n.get('vehicleInfo'),
  86. children: [
  87. // 车牌号
  88. FormFieldRow(
  89. label: l10n.get('licensePlate'),
  90. value: v.vehicleId.isNotEmpty ? v.vehicleId : null,
  91. hint: l10n.get('selectLicensePlate'),
  92. onTap: () => _showVehiclePicker(ctrl),
  93. ),
  94. // 排期冲突提示
  95. if (state.hasConflict) _buildConflictWarning(),
  96. const SizedBox(height: 8),
  97. // 用车事由 (TDInput)
  98. _buildReasonField(ctrl),
  99. const SizedBox(height: 8),
  100. // 用车目的 (TDPicker)
  101. FormFieldRow(
  102. label: l10n.get('vehiclePurpose'),
  103. value: _purposeLabel(v.purpose),
  104. hint: l10n.get('selectVehicleReason'),
  105. onTap: () => _showPurposePicker(ctrl),
  106. ),
  107. const SizedBox(height: 8),
  108. // 始发地 (auto-filled, editable)
  109. _buildLocationField(
  110. label: l10n.get('origin'),
  111. controller: _originController,
  112. hint: l10n.get('gpsLocating'),
  113. onChanged: ctrl.updateOrigin,
  114. showMapIcon: false,
  115. onMapTap: null,
  116. ),
  117. const SizedBox(height: 8),
  118. // 目的地 (with map icon)
  119. _buildLocationField(
  120. label: l10n.get('destination'),
  121. controller: _destinationController,
  122. hint: l10n.get('enterDestination'),
  123. onChanged: ctrl.updateDestination,
  124. showMapIcon: true,
  125. onMapTap: () {
  126. TDToast.showText(l10n.get('mapPickerComingSoon'), context: context);
  127. },
  128. ),
  129. const SizedBox(height: 8),
  130. // 出车时间
  131. FormFieldRow(
  132. label: l10n.get('departTime'),
  133. value: du.DateUtils.formatDateTime(v.startTime),
  134. onTap: () =>
  135. _pickDateTime(ctrl.updateStartTime, v.startTime),
  136. ),
  137. // 还车时间
  138. FormFieldRow(
  139. label: l10n.get('returnTime'),
  140. value: du.DateUtils.formatDateTime(v.endTime),
  141. onTap: () => _pickDateTime(ctrl.updateEndTime, v.endTime),
  142. ),
  143. if (!v.endTime.isAfter(v.startTime))
  144. Padding(
  145. padding: EdgeInsets.only(top: 4),
  146. child: Text(
  147. l10n.get('returnTimeMustLater'),
  148. style: TextStyle(
  149. fontSize: AppFontSizes.caption,
  150. color: colors.danger,
  151. ),
  152. ),
  153. ),
  154. const SizedBox(height: 8),
  155. // 同行人数
  156. FormFieldRow(
  157. label: l10n.get('passengerCount'),
  158. value: '${v.passengerCount}${l10n.get('personUnit')}',
  159. onTap: () => _showNumberInput(
  160. l10n.get('passengerCount'),
  161. ctrl.updatePassengerCount,
  162. v.passengerCount,
  163. ),
  164. ),
  165. // 同行人
  166. _buildPassengersSection(state, ctrl),
  167. ],
  168. ),
  169. ],
  170. ),
  171. ),
  172. ),
  173. ActionBar(
  174. showLeft: false,
  175. centerLabel: l10n.get('saveDraftShort'),
  176. rightLabel: l10n.get('submitApproval'),
  177. onCenterTap: state.isSubmitting
  178. ? null
  179. : () async {
  180. await ctrl.saveDraft();
  181. if (context.mounted) {
  182. TDToast.showText(l10n.get('draftSavedToast'), context: context);
  183. context.pop();
  184. }
  185. },
  186. onRightTap: (state.isSubmitting || state.hasConflict)
  187. ? null
  188. : () async {
  189. final reasonOk = v.reason.trim().isNotEmpty;
  190. final vehicleOk = v.vehicleId.isNotEmpty;
  191. final timeOk = v.endTime.isAfter(v.startTime);
  192. setState(() => _showReasonError = !reasonOk);
  193. if (!reasonOk || !vehicleOk || !timeOk) {
  194. TDToast.showText(l10n.get('completeFormInfo'), context: context);
  195. return;
  196. }
  197. final ok = await ctrl.submit();
  198. if (context.mounted) {
  199. if (ok) {
  200. TDToast.showText(l10n.get('submittedAwaitingApproval'), context: context);
  201. context.pop();
  202. } else {
  203. TDToast.showText(l10n.get('submitFailedRetry'), context: context);
  204. }
  205. }
  206. },
  207. ),
  208. ],
  209. );
  210. }
  211. String _purposeLabel(String key) {
  212. final l10n = AppLocalizations.of(context);
  213. switch (key) {
  214. case 'reception':
  215. return l10n.get('customerReception');
  216. case 'business':
  217. return l10n.get('businessTrip');
  218. case 'official':
  219. return l10n.get('official');
  220. default:
  221. return key;
  222. }
  223. }
  224. String _purposeKey(String label) {
  225. final l10n = AppLocalizations.of(context);
  226. if (label == l10n.get('customerReception')) return 'reception';
  227. if (label == l10n.get('businessTrip')) return 'business';
  228. if (label == l10n.get('official')) return 'official';
  229. return label;
  230. }
  231. Widget _buildConflictWarning() {
  232. final l10n = AppLocalizations.of(context);
  233. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  234. return Container(
  235. padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
  236. decoration: BoxDecoration(
  237. color: colors.dangerBg,
  238. borderRadius: BorderRadius.circular(4),
  239. border: Border.all(color: colors.danger.withValues(alpha: 0.3)),
  240. ),
  241. child: Row(
  242. children: [
  243. Icon(Icons.warning_amber_rounded, size: 16, color: colors.danger),
  244. const SizedBox(width: 8),
  245. Expanded(
  246. child: Text(
  247. l10n.get('vehicleOccupiedPeriod'),
  248. style: TextStyle(
  249. fontSize: AppFontSizes.caption,
  250. color: colors.danger,
  251. ),
  252. ),
  253. ),
  254. ],
  255. ),
  256. );
  257. }
  258. Widget _buildReasonField(VehicleApplyController ctrl) {
  259. final l10n = AppLocalizations.of(context);
  260. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  261. return Column(
  262. crossAxisAlignment: CrossAxisAlignment.start,
  263. children: [
  264. Text(
  265. l10n.get('vehicleReason'),
  266. style: TextStyle(
  267. fontSize: AppFontSizes.body,
  268. color: colors.textSecondary,
  269. ),
  270. ),
  271. const SizedBox(height: 8),
  272. TDInput(
  273. controller: _reasonController,
  274. hintText: l10n.get('enterVehicleReason'),
  275. onChanged: (v) {
  276. ctrl.updateReason(v);
  277. setState(() => _showReasonError = false);
  278. },
  279. ),
  280. if (_showReasonError)
  281. Padding(
  282. padding: EdgeInsets.only(top: 4),
  283. child: Text(
  284. l10n.get('enterVehicleReason'),
  285. style: TextStyle(
  286. fontSize: AppFontSizes.caption,
  287. color: colors.danger,
  288. ),
  289. ),
  290. ),
  291. ],
  292. );
  293. }
  294. Widget _buildLocationField({
  295. required String label,
  296. required TextEditingController controller,
  297. required String hint,
  298. required ValueChanged<String> onChanged,
  299. required bool showMapIcon,
  300. required VoidCallback? onMapTap,
  301. }) {
  302. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  303. return Column(
  304. crossAxisAlignment: CrossAxisAlignment.start,
  305. children: [
  306. Text(
  307. label,
  308. style: TextStyle(
  309. fontSize: AppFontSizes.body,
  310. color: colors.textSecondary,
  311. ),
  312. ),
  313. const SizedBox(height: 8),
  314. Row(
  315. children: [
  316. Expanded(
  317. child: TDInput(
  318. controller: controller,
  319. hintText: hint,
  320. onChanged: onChanged,
  321. ),
  322. ),
  323. if (showMapIcon) ...[
  324. const SizedBox(width: 8),
  325. GestureDetector(
  326. onTap: onMapTap,
  327. child: Container(
  328. width: 40,
  329. height: 40,
  330. decoration: BoxDecoration(
  331. color: colors.primaryLight,
  332. borderRadius: BorderRadius.circular(8),
  333. ),
  334. child: Icon(
  335. Icons.map_outlined,
  336. color: colors.primary,
  337. size: 22,
  338. ),
  339. ),
  340. ),
  341. ],
  342. ],
  343. ),
  344. ],
  345. );
  346. }
  347. Widget _buildPassengersSection(
  348. VehicleApplyState state,
  349. VehicleApplyController ctrl,
  350. ) {
  351. final l10n = AppLocalizations.of(context);
  352. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  353. return Column(
  354. crossAxisAlignment: CrossAxisAlignment.start,
  355. children: [
  356. const SizedBox(height: 8),
  357. Row(
  358. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  359. children: [
  360. Text(
  361. l10n.get('companion'),
  362. style: TextStyle(
  363. fontSize: AppFontSizes.body,
  364. color: colors.textSecondary,
  365. ),
  366. ),
  367. GestureDetector(
  368. onTap: () => _showContactPicker(ctrl),
  369. child: Container(
  370. padding: const EdgeInsets.symmetric(
  371. horizontal: 12,
  372. vertical: 6,
  373. ),
  374. decoration: BoxDecoration(
  375. color: colors.primaryLight,
  376. borderRadius: BorderRadius.circular(16),
  377. ),
  378. child: Row(
  379. mainAxisSize: MainAxisSize.min,
  380. children: [
  381. Icon(
  382. Icons.person_add_alt_1,
  383. size: 14,
  384. color: colors.primary,
  385. ),
  386. SizedBox(width: 4),
  387. Text(
  388. l10n.get('add'),
  389. style: TextStyle(
  390. fontSize: AppFontSizes.caption,
  391. color: colors.primary,
  392. ),
  393. ),
  394. ],
  395. ),
  396. ),
  397. ),
  398. ],
  399. ),
  400. if (state.passengers.isNotEmpty) ...[
  401. const SizedBox(height: 8),
  402. Wrap(
  403. spacing: 8,
  404. runSpacing: 4,
  405. children: state.passengers.map((name) {
  406. return TDTag(
  407. name,
  408. size: TDTagSize.medium,
  409. theme: TDTagTheme.primary,
  410. isLight: true,
  411. needCloseIcon: true,
  412. onCloseTap: () => ctrl.removePassenger(name),
  413. );
  414. }).toList(),
  415. ),
  416. ],
  417. ],
  418. );
  419. }
  420. void _showVehiclePicker(VehicleApplyController ctrl) {
  421. final l10n = AppLocalizations.of(context);
  422. TDPicker.showMultiPicker(
  423. context,
  424. title: l10n.get('selectLicensePlate'),
  425. data: [_vehiclePool],
  426. onConfirm: (selected) => ctrl.updateVehicleId(selected.first),
  427. );
  428. }
  429. void _showPurposePicker(VehicleApplyController ctrl) {
  430. final l10n = AppLocalizations.of(context);
  431. final purposes = [l10n.get('customerReception'), l10n.get('businessTrip'), l10n.get('official')];
  432. TDPicker.showMultiPicker(
  433. context,
  434. title: l10n.get('selectVehicleReason'),
  435. data: [purposes],
  436. onConfirm: (selected) => ctrl.updatePurpose(_purposeKey(selected.first)),
  437. );
  438. }
  439. void _showContactPicker(VehicleApplyController ctrl) {
  440. // Mock contact picker with multi-select via dialog
  441. final l10n = AppLocalizations.of(context);
  442. final state = ref.read(vehicleApplyProvider(widget.editId));
  443. final selected = <String>{...state.passengers};
  444. showDialog(
  445. context: context,
  446. builder: (ctx) => TDAlertDialog(
  447. title: l10n.get('selectCompanion'),
  448. contentWidget: SizedBox(
  449. height: 300,
  450. child: ListView(
  451. children: _mockContacts.map((name) {
  452. return CheckboxListTile(
  453. title: Text(name),
  454. value: selected.contains(name),
  455. onChanged: (checked) {
  456. if (checked == true) {
  457. selected.add(name);
  458. } else {
  459. selected.remove(name);
  460. }
  461. // Force rebuild
  462. setState(() {});
  463. },
  464. );
  465. }).toList(),
  466. ),
  467. ),
  468. leftBtn: TDDialogButtonOptions(
  469. title: l10n.get('cancel'),
  470. action: () => Navigator.pop(ctx),
  471. ),
  472. rightBtn: TDDialogButtonOptions(
  473. title: l10n.get('confirm'),
  474. theme: TDButtonTheme.primary,
  475. action: () {
  476. for (final name in selected) {
  477. ctrl.addPassenger(name);
  478. }
  479. Navigator.pop(ctx);
  480. },
  481. ),
  482. ),
  483. );
  484. }
  485. void _showNumberInput(String title, void Function(int) onSave, int current) {
  486. final l10n = AppLocalizations.of(context);
  487. final ctrl = TextEditingController(text: '$current');
  488. showDialog(
  489. context: context,
  490. builder: (_) => TDAlertDialog(
  491. title: title,
  492. contentWidget: TDInput(controller: ctrl, hintText: '请输入数字'),
  493. leftBtn: TDDialogButtonOptions(
  494. title: l10n.get('cancel'),
  495. action: () => Navigator.pop(context),
  496. ),
  497. rightBtn: TDDialogButtonOptions(
  498. title: l10n.get('confirm'),
  499. theme: TDButtonTheme.primary,
  500. action: () {
  501. onSave(int.tryParse(ctrl.text) ?? 1);
  502. Navigator.pop(context);
  503. },
  504. ),
  505. ),
  506. );
  507. }
  508. void _pickDateTime(void Function(DateTime) onPicked, DateTime initial) {
  509. final l10n = AppLocalizations.of(context);
  510. TDPicker.showDatePicker(
  511. context,
  512. title: l10n.get('selectDateTime'),
  513. useYear: true,
  514. useMonth: true,
  515. useDay: true,
  516. useHour: true,
  517. useMinute: true,
  518. initialDate: [
  519. initial.year,
  520. initial.month,
  521. initial.day,
  522. initial.hour,
  523. initial.minute,
  524. ],
  525. onConfirm: (selected) {
  526. onPicked(
  527. DateTime(
  528. selected['year']!,
  529. selected['month']!,
  530. selected['day']!,
  531. selected['hour']!,
  532. selected['minute']!,
  533. ),
  534. );
  535. },
  536. );
  537. }
  538. }