vehicle_apply_page.dart 17 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 '../shell/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: '车牌号',
  90. value: v.vehicleId.isNotEmpty ? v.vehicleId : null,
  91. hint: '请选择车牌号',
  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: '用车目的',
  103. value: _purposeLabel(v.purpose),
  104. hint: '请选择用车目的',
  105. onTap: () => _showPurposePicker(ctrl),
  106. ),
  107. const SizedBox(height: 8),
  108. // 始发地 (auto-filled, editable)
  109. _buildLocationField(
  110. label: '始发地',
  111. controller: _originController,
  112. hint: 'GPS定位中…',
  113. onChanged: ctrl.updateOrigin,
  114. showMapIcon: false,
  115. onMapTap: null,
  116. ),
  117. const SizedBox(height: 8),
  118. // 目的地 (with map icon)
  119. _buildLocationField(
  120. label: '目的地',
  121. controller: _destinationController,
  122. hint: '请输入目的地',
  123. onChanged: ctrl.updateDestination,
  124. showMapIcon: true,
  125. onMapTap: () {
  126. TDToast.showText('地图选点即将开放', context: context);
  127. },
  128. ),
  129. const SizedBox(height: 8),
  130. // 出车时间
  131. FormFieldRow(
  132. label: '出车时间',
  133. value: du.DateUtils.formatDateTime(v.startTime),
  134. onTap: () =>
  135. _pickDateTime(ctrl.updateStartTime, v.startTime),
  136. ),
  137. // 还车时间
  138. FormFieldRow(
  139. label: '还车时间',
  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. '还车时间必须晚于出车时间',
  148. style: TextStyle(
  149. fontSize: AppFontSizes.caption,
  150. color: colors.danger,
  151. ),
  152. ),
  153. ),
  154. const SizedBox(height: 8),
  155. // 同行人数
  156. FormFieldRow(
  157. label: '同行人数',
  158. value: '${v.passengerCount}人',
  159. onTap: () => _showNumberInput(
  160. '同行人数',
  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: '存草稿',
  176. rightLabel: '提交审批',
  177. onCenterTap: state.isSubmitting
  178. ? null
  179. : () async {
  180. await ctrl.saveDraft();
  181. if (context.mounted) {
  182. TDToast.showText('已保存为草稿', 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('请完善表单信息', context: context);
  195. return;
  196. }
  197. final ok = await ctrl.submit();
  198. if (context.mounted) {
  199. if (ok) {
  200. TDToast.showText('已提交,等待审批', context: context);
  201. context.pop();
  202. } else {
  203. TDToast.showText('提交失败,请稍后重试', context: context);
  204. }
  205. }
  206. },
  207. ),
  208. ],
  209. );
  210. }
  211. String _purposeLabel(String key) {
  212. switch (key) {
  213. case 'reception':
  214. return '客户接待';
  215. case 'business':
  216. return '商务出行';
  217. case 'official':
  218. return '公务';
  219. default:
  220. return key;
  221. }
  222. }
  223. String _purposeKey(String label) {
  224. switch (label) {
  225. case '客户接待':
  226. return 'reception';
  227. case '商务出行':
  228. return 'business';
  229. case '公务':
  230. return 'official';
  231. default:
  232. return label;
  233. }
  234. }
  235. Widget _buildConflictWarning() {
  236. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  237. return Container(
  238. padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
  239. decoration: BoxDecoration(
  240. color: colors.dangerBg,
  241. borderRadius: BorderRadius.circular(4),
  242. border: Border.all(color: colors.danger.withValues(alpha: 0.3)),
  243. ),
  244. child: Row(
  245. children: [
  246. Icon(Icons.warning_amber_rounded, size: 16, color: colors.danger),
  247. const SizedBox(width: 8),
  248. Expanded(
  249. child: Text(
  250. '该时段车辆已被预订,请选择其他车辆或调整时间',
  251. style: TextStyle(
  252. fontSize: AppFontSizes.caption,
  253. color: colors.danger,
  254. ),
  255. ),
  256. ),
  257. ],
  258. ),
  259. );
  260. }
  261. Widget _buildReasonField(VehicleApplyController ctrl) {
  262. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  263. return Column(
  264. crossAxisAlignment: CrossAxisAlignment.start,
  265. children: [
  266. Text(
  267. '用车事由',
  268. style: TextStyle(
  269. fontSize: AppFontSizes.body,
  270. color: colors.textSecondary,
  271. ),
  272. ),
  273. const SizedBox(height: 8),
  274. TDInput(
  275. controller: _reasonController,
  276. hintText: '请填写用车事由',
  277. onChanged: (v) {
  278. ctrl.updateReason(v);
  279. setState(() => _showReasonError = false);
  280. },
  281. ),
  282. if (_showReasonError)
  283. Padding(
  284. padding: EdgeInsets.only(top: 4),
  285. child: Text(
  286. '请填写用车事由',
  287. style: TextStyle(
  288. fontSize: AppFontSizes.caption,
  289. color: colors.danger,
  290. ),
  291. ),
  292. ),
  293. ],
  294. );
  295. }
  296. Widget _buildLocationField({
  297. required String label,
  298. required TextEditingController controller,
  299. required String hint,
  300. required ValueChanged<String> onChanged,
  301. required bool showMapIcon,
  302. required VoidCallback? onMapTap,
  303. }) {
  304. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  305. return Column(
  306. crossAxisAlignment: CrossAxisAlignment.start,
  307. children: [
  308. Text(
  309. label,
  310. style: TextStyle(
  311. fontSize: AppFontSizes.body,
  312. color: colors.textSecondary,
  313. ),
  314. ),
  315. const SizedBox(height: 8),
  316. Row(
  317. children: [
  318. Expanded(
  319. child: TDInput(
  320. controller: controller,
  321. hintText: hint,
  322. onChanged: onChanged,
  323. ),
  324. ),
  325. if (showMapIcon) ...[
  326. const SizedBox(width: 8),
  327. GestureDetector(
  328. onTap: onMapTap,
  329. child: Container(
  330. width: 40,
  331. height: 40,
  332. decoration: BoxDecoration(
  333. color: colors.primaryLight,
  334. borderRadius: BorderRadius.circular(8),
  335. ),
  336. child: Icon(
  337. Icons.map_outlined,
  338. color: colors.primary,
  339. size: 22,
  340. ),
  341. ),
  342. ),
  343. ],
  344. ],
  345. ),
  346. ],
  347. );
  348. }
  349. Widget _buildPassengersSection(
  350. VehicleApplyState state,
  351. VehicleApplyController ctrl,
  352. ) {
  353. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  354. return Column(
  355. crossAxisAlignment: CrossAxisAlignment.start,
  356. children: [
  357. const SizedBox(height: 8),
  358. Row(
  359. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  360. children: [
  361. Text(
  362. '同行人',
  363. style: TextStyle(
  364. fontSize: AppFontSizes.body,
  365. color: colors.textSecondary,
  366. ),
  367. ),
  368. GestureDetector(
  369. onTap: () => _showContactPicker(ctrl),
  370. child: Container(
  371. padding: const EdgeInsets.symmetric(
  372. horizontal: 12,
  373. vertical: 6,
  374. ),
  375. decoration: BoxDecoration(
  376. color: colors.primaryLight,
  377. borderRadius: BorderRadius.circular(16),
  378. ),
  379. child: Row(
  380. mainAxisSize: MainAxisSize.min,
  381. children: [
  382. Icon(
  383. Icons.person_add_alt_1,
  384. size: 14,
  385. color: colors.primary,
  386. ),
  387. SizedBox(width: 4),
  388. Text(
  389. '添加',
  390. style: TextStyle(
  391. fontSize: AppFontSizes.caption,
  392. color: colors.primary,
  393. ),
  394. ),
  395. ],
  396. ),
  397. ),
  398. ),
  399. ],
  400. ),
  401. if (state.passengers.isNotEmpty) ...[
  402. const SizedBox(height: 8),
  403. Wrap(
  404. spacing: 8,
  405. runSpacing: 4,
  406. children: state.passengers.map((name) {
  407. return TDTag(
  408. name,
  409. size: TDTagSize.medium,
  410. theme: TDTagTheme.primary,
  411. isLight: true,
  412. needCloseIcon: true,
  413. onCloseTap: () => ctrl.removePassenger(name),
  414. );
  415. }).toList(),
  416. ),
  417. ],
  418. ],
  419. );
  420. }
  421. void _showVehiclePicker(VehicleApplyController ctrl) {
  422. final l10n = AppLocalizations.of(context);
  423. TDPicker.showMultiPicker(
  424. context,
  425. title: l10n.get('selectLicensePlate'),
  426. data: [_vehiclePool],
  427. onConfirm: (selected) => ctrl.updateVehicleId(selected.first),
  428. );
  429. }
  430. void _showPurposePicker(VehicleApplyController ctrl) {
  431. final l10n = AppLocalizations.of(context);
  432. const purposes = ['客户接待', '商务出行', '公务'];
  433. TDPicker.showMultiPicker(
  434. context,
  435. title: l10n.get('selectVehicleReason'),
  436. data: [purposes],
  437. onConfirm: (selected) => ctrl.updatePurpose(_purposeKey(selected.first)),
  438. );
  439. }
  440. void _showContactPicker(VehicleApplyController ctrl) {
  441. // Mock contact picker with multi-select via dialog
  442. final l10n = AppLocalizations.of(context);
  443. final state = ref.read(vehicleApplyProvider(widget.editId));
  444. final selected = <String>{...state.passengers};
  445. showDialog(
  446. context: context,
  447. builder: (ctx) => TDAlertDialog(
  448. title: l10n.get('selectCompanion'),
  449. contentWidget: SizedBox(
  450. height: 300,
  451. child: ListView(
  452. children: _mockContacts.map((name) {
  453. return CheckboxListTile(
  454. title: Text(name),
  455. value: selected.contains(name),
  456. onChanged: (checked) {
  457. if (checked == true) {
  458. selected.add(name);
  459. } else {
  460. selected.remove(name);
  461. }
  462. // Force rebuild
  463. setState(() {});
  464. },
  465. );
  466. }).toList(),
  467. ),
  468. ),
  469. leftBtn: TDDialogButtonOptions(
  470. title: l10n.get('cancel'),
  471. action: () => Navigator.pop(ctx),
  472. ),
  473. rightBtn: TDDialogButtonOptions(
  474. title: l10n.get('confirm'),
  475. theme: TDButtonTheme.primary,
  476. action: () {
  477. for (final name in selected) {
  478. ctrl.addPassenger(name);
  479. }
  480. Navigator.pop(ctx);
  481. },
  482. ),
  483. ),
  484. );
  485. }
  486. void _showNumberInput(String title, void Function(int) onSave, int current) {
  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: '取消',
  495. action: () => Navigator.pop(context),
  496. ),
  497. rightBtn: TDDialogButtonOptions(
  498. title: '确定',
  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. }