vehicle_apply_page.dart 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  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/theme/app_colors.dart';
  6. import '../shell/nav_bar_config.dart';
  7. import '../../core/utils/date_utils.dart' as du;
  8. import '../../shared/widgets/action_bar.dart';
  9. import '../../shared/widgets/form_section.dart';
  10. import '../../shared/widgets/form_field_row.dart';
  11. import '../../core/i18n/app_localizations.dart';
  12. import 'vehicle_apply_controller.dart';
  13. class VehicleApplyPage extends ConsumerStatefulWidget {
  14. final String? editId;
  15. const VehicleApplyPage({super.key, this.editId});
  16. @override
  17. ConsumerState<VehicleApplyPage> createState() => _VehicleApplyPageState();
  18. }
  19. class _VehicleApplyPageState extends ConsumerState<VehicleApplyPage> {
  20. final _reasonController = TextEditingController();
  21. final _originController = TextEditingController();
  22. final _destinationController = TextEditingController();
  23. bool _showReasonError = false;
  24. // Mock vehicle pool (车牌号列表)
  25. static const _vehiclePool = ['京A88888', '京B66666', '京C12345', '京D99999', '京E55555'];
  26. // Mock passengers for contact picker
  27. static const _mockContacts = [
  28. '赵六', '钱七', '孙八', '周九', '吴十',
  29. '郑十一', '王十二', '冯十三', '陈十四', '褚十五',
  30. ];
  31. @override
  32. void initState() {
  33. super.initState();
  34. final state = ref.read(vehicleApplyProvider(widget.editId));
  35. _reasonController.text = state.vehicle.reason;
  36. _originController.text = state.vehicle.origin;
  37. _destinationController.text = state.vehicle.destination;
  38. }
  39. @override
  40. void dispose() {
  41. _reasonController.dispose();
  42. _originController.dispose();
  43. _destinationController.dispose();
  44. super.dispose();
  45. }
  46. @override
  47. Widget build(BuildContext context) {
  48. final ctrl = ref.watch(vehicleApplyProvider(widget.editId).notifier);
  49. final state = ref.watch(vehicleApplyProvider(widget.editId));
  50. final l10n = AppLocalizations.of(context);
  51. final v = state.vehicle;
  52. ref
  53. .read(navBarConfigProvider.notifier)
  54. .update(
  55. NavBarConfig(
  56. title: l10n.get('vehicleApply'),
  57. showBack: true,
  58. onBack: () => context.pop(),
  59. ),
  60. );
  61. return Column(
  62. children: [
  63. Expanded(
  64. child: SingleChildScrollView(
  65. padding: const EdgeInsets.all(16),
  66. child: Column(
  67. children: [
  68. FormSection(
  69. title: l10n.get('vehicleInfo'),
  70. children: [
  71. // 车牌号
  72. FormFieldRow(
  73. label: '车牌号',
  74. value: v.vehicleId.isNotEmpty ? v.vehicleId : null,
  75. hint: '请选择车牌号',
  76. onTap: () => _showVehiclePicker(ctrl),
  77. ),
  78. // 排期冲突提示
  79. if (state.hasConflict) _buildConflictWarning(),
  80. const SizedBox(height: 8),
  81. // 用车事由 (TDInput)
  82. _buildReasonField(ctrl),
  83. const SizedBox(height: 8),
  84. // 用车目的 (TDPicker)
  85. FormFieldRow(
  86. label: '用车目的',
  87. value: _purposeLabel(v.purpose),
  88. hint: '请选择用车目的',
  89. onTap: () => _showPurposePicker(ctrl),
  90. ),
  91. const SizedBox(height: 8),
  92. // 始发地 (auto-filled, editable)
  93. _buildLocationField(
  94. label: '始发地',
  95. controller: _originController,
  96. hint: 'GPS定位中…',
  97. onChanged: ctrl.updateOrigin,
  98. showMapIcon: false,
  99. onMapTap: null,
  100. ),
  101. const SizedBox(height: 8),
  102. // 目的地 (with map icon)
  103. _buildLocationField(
  104. label: '目的地',
  105. controller: _destinationController,
  106. hint: '请输入目的地',
  107. onChanged: ctrl.updateDestination,
  108. showMapIcon: true,
  109. onMapTap: () {
  110. TDToast.showText(
  111. '地图选点即将开放',
  112. context: context,
  113. );
  114. },
  115. ),
  116. const SizedBox(height: 8),
  117. // 出车时间
  118. FormFieldRow(
  119. label: '出车时间',
  120. value: du.DateUtils.formatDateTime(v.startTime),
  121. onTap: () => _pickDateTime(ctrl.updateStartTime, v.startTime),
  122. ),
  123. // 还车时间
  124. FormFieldRow(
  125. label: '还车时间',
  126. value: du.DateUtils.formatDateTime(v.endTime),
  127. onTap: () => _pickDateTime(ctrl.updateEndTime, v.endTime),
  128. ),
  129. if (!v.endTime.isAfter(v.startTime))
  130. const Padding(
  131. padding: EdgeInsets.only(top: 4),
  132. child: Text(
  133. '还车时间必须晚于出车时间',
  134. style: TextStyle(
  135. fontSize: AppFontSizes.caption,
  136. color: AppColors.danger,
  137. ),
  138. ),
  139. ),
  140. const SizedBox(height: 8),
  141. // 同行人数
  142. FormFieldRow(
  143. label: '同行人数',
  144. value: '${v.passengerCount}人',
  145. onTap: () => _showNumberInput(
  146. '同行人数',
  147. ctrl.updatePassengerCount,
  148. v.passengerCount,
  149. ),
  150. ),
  151. // 同行人
  152. _buildPassengersSection(state, ctrl),
  153. ],
  154. ),
  155. ],
  156. ),
  157. ),
  158. ),
  159. ActionBar(
  160. showLeft: false,
  161. centerLabel: '存草稿',
  162. rightLabel: '提交审批',
  163. onCenterTap: state.isSubmitting
  164. ? null
  165. : () async {
  166. await ctrl.saveDraft();
  167. if (context.mounted) {
  168. TDToast.showText('已保存为草稿', context: context);
  169. context.pop();
  170. }
  171. },
  172. onRightTap: (state.isSubmitting || state.hasConflict)
  173. ? null
  174. : () async {
  175. final reasonOk = v.reason.trim().isNotEmpty;
  176. final vehicleOk = v.vehicleId.isNotEmpty;
  177. final timeOk = v.endTime.isAfter(v.startTime);
  178. setState(() => _showReasonError = !reasonOk);
  179. if (!reasonOk || !vehicleOk || !timeOk) {
  180. TDToast.showText('请完善表单信息', context: context);
  181. return;
  182. }
  183. final ok = await ctrl.submit();
  184. if (context.mounted) {
  185. if (ok) {
  186. TDToast.showText('已提交,等待审批', context: context);
  187. context.pop();
  188. } else {
  189. TDToast.showText('提交失败,请稍后重试', context: context);
  190. }
  191. }
  192. },
  193. ),
  194. ],
  195. );
  196. }
  197. String _purposeLabel(String key) {
  198. switch (key) {
  199. case 'reception': return '客户接待';
  200. case 'business': return '商务出行';
  201. case 'official': return '公务';
  202. default: return key;
  203. }
  204. }
  205. String _purposeKey(String label) {
  206. switch (label) {
  207. case '客户接待': return 'reception';
  208. case '商务出行': return 'business';
  209. case '公务': return 'official';
  210. default: return label;
  211. }
  212. }
  213. Widget _buildConflictWarning() {
  214. return Container(
  215. padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
  216. decoration: BoxDecoration(
  217. color: AppColors.dangerBg,
  218. borderRadius: BorderRadius.circular(4),
  219. border: Border.all(color: AppColors.danger.withValues(alpha: 0.3)),
  220. ),
  221. child: Row(
  222. children: [
  223. const Icon(Icons.warning_amber_rounded, size: 16, color: AppColors.danger),
  224. const SizedBox(width: 8),
  225. const Expanded(
  226. child: Text(
  227. '该时段车辆已被预订,请选择其他车辆或调整时间',
  228. style: TextStyle(
  229. fontSize: AppFontSizes.caption,
  230. color: AppColors.danger,
  231. ),
  232. ),
  233. ),
  234. ],
  235. ),
  236. );
  237. }
  238. Widget _buildReasonField(VehicleApplyController ctrl) {
  239. return Column(
  240. crossAxisAlignment: CrossAxisAlignment.start,
  241. children: [
  242. const Text(
  243. '用车事由',
  244. style: TextStyle(
  245. fontSize: AppFontSizes.body,
  246. color: AppColors.textSecondary,
  247. ),
  248. ),
  249. const SizedBox(height: 8),
  250. TDInput(
  251. controller: _reasonController,
  252. hintText: '请填写用车事由',
  253. onChanged: (v) {
  254. ctrl.updateReason(v);
  255. setState(() => _showReasonError = false);
  256. },
  257. ),
  258. if (_showReasonError)
  259. const Padding(
  260. padding: EdgeInsets.only(top: 4),
  261. child: Text(
  262. '请填写用车事由',
  263. style: TextStyle(
  264. fontSize: AppFontSizes.caption,
  265. color: AppColors.danger,
  266. ),
  267. ),
  268. ),
  269. ],
  270. );
  271. }
  272. Widget _buildLocationField({
  273. required String label,
  274. required TextEditingController controller,
  275. required String hint,
  276. required ValueChanged<String> onChanged,
  277. required bool showMapIcon,
  278. required VoidCallback? onMapTap,
  279. }) {
  280. return Column(
  281. crossAxisAlignment: CrossAxisAlignment.start,
  282. children: [
  283. Text(
  284. label,
  285. style: const TextStyle(
  286. fontSize: AppFontSizes.body,
  287. color: AppColors.textSecondary,
  288. ),
  289. ),
  290. const SizedBox(height: 8),
  291. Row(
  292. children: [
  293. Expanded(
  294. child: TDInput(
  295. controller: controller,
  296. hintText: hint,
  297. onChanged: onChanged,
  298. ),
  299. ),
  300. if (showMapIcon) ...[
  301. const SizedBox(width: 8),
  302. GestureDetector(
  303. onTap: onMapTap,
  304. child: Container(
  305. width: 40,
  306. height: 40,
  307. decoration: BoxDecoration(
  308. color: AppColors.primaryLight,
  309. borderRadius: BorderRadius.circular(8),
  310. ),
  311. child: const Icon(
  312. Icons.map_outlined,
  313. color: AppColors.primary,
  314. size: 22,
  315. ),
  316. ),
  317. ),
  318. ],
  319. ],
  320. ),
  321. ],
  322. );
  323. }
  324. Widget _buildPassengersSection(VehicleApplyState state, VehicleApplyController ctrl) {
  325. return Column(
  326. crossAxisAlignment: CrossAxisAlignment.start,
  327. children: [
  328. const SizedBox(height: 8),
  329. Row(
  330. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  331. children: [
  332. const Text(
  333. '同行人',
  334. style: TextStyle(
  335. fontSize: AppFontSizes.body,
  336. color: AppColors.textSecondary,
  337. ),
  338. ),
  339. GestureDetector(
  340. onTap: () => _showContactPicker(ctrl),
  341. child: Container(
  342. padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
  343. decoration: BoxDecoration(
  344. color: AppColors.primaryLight,
  345. borderRadius: BorderRadius.circular(16),
  346. ),
  347. child: const Row(
  348. mainAxisSize: MainAxisSize.min,
  349. children: [
  350. Icon(Icons.person_add_alt_1, size: 14, color: AppColors.primary),
  351. SizedBox(width: 4),
  352. Text(
  353. '添加',
  354. style: TextStyle(
  355. fontSize: AppFontSizes.caption,
  356. color: AppColors.primary,
  357. ),
  358. ),
  359. ],
  360. ),
  361. ),
  362. ),
  363. ],
  364. ),
  365. if (state.passengers.isNotEmpty) ...[
  366. const SizedBox(height: 8),
  367. Wrap(
  368. spacing: 8,
  369. runSpacing: 4,
  370. children: state.passengers.map((name) {
  371. return Chip(
  372. label: Text(name, style: const TextStyle(fontSize: 12)),
  373. deleteIcon: const Icon(Icons.close, size: 16),
  374. onDeleted: () => ctrl.removePassenger(name),
  375. backgroundColor: AppColors.primaryLight,
  376. labelStyle: const TextStyle(color: AppColors.primary),
  377. side: BorderSide.none,
  378. materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
  379. visualDensity: VisualDensity.compact,
  380. );
  381. }).toList(),
  382. ),
  383. ],
  384. ],
  385. );
  386. }
  387. void _showVehiclePicker(VehicleApplyController ctrl) {
  388. final l10n = AppLocalizations.of(context);
  389. TDPicker.showMultiPicker(
  390. context,
  391. title: l10n.get('selectLicensePlate'),
  392. data: [_vehiclePool],
  393. onConfirm: (selected) => ctrl.updateVehicleId(selected.first),
  394. );
  395. }
  396. void _showPurposePicker(VehicleApplyController ctrl) {
  397. final l10n = AppLocalizations.of(context);
  398. const purposes = ['客户接待', '商务出行', '公务'];
  399. TDPicker.showMultiPicker(
  400. context,
  401. title: l10n.get('selectVehicleReason'),
  402. data: [purposes],
  403. onConfirm: (selected) => ctrl.updatePurpose(_purposeKey(selected.first)),
  404. );
  405. }
  406. void _showContactPicker(VehicleApplyController ctrl) {
  407. // Mock contact picker with multi-select via dialog
  408. final l10n = AppLocalizations.of(context);
  409. final state = ref.read(vehicleApplyProvider(widget.editId));
  410. final selected = <String>{...state.passengers};
  411. showDialog(
  412. context: context,
  413. builder: (ctx) => TDAlertDialog(
  414. title: l10n.get('selectCompanion'),
  415. contentWidget: SizedBox(
  416. height: 300,
  417. child: ListView(
  418. children: _mockContacts.map((name) {
  419. return CheckboxListTile(
  420. title: Text(name),
  421. value: selected.contains(name),
  422. onChanged: (checked) {
  423. if (checked == true) {
  424. selected.add(name);
  425. } else {
  426. selected.remove(name);
  427. }
  428. // Force rebuild
  429. setState(() {});
  430. },
  431. );
  432. }).toList(),
  433. ),
  434. ),
  435. leftBtn: TDDialogButtonOptions(
  436. title: l10n.get('cancel'),
  437. action: () => Navigator.pop(ctx),
  438. ),
  439. rightBtn: TDDialogButtonOptions(
  440. title: l10n.get('confirm'),
  441. theme: TDButtonTheme.primary,
  442. action: () {
  443. for (final name in selected) {
  444. ctrl.addPassenger(name);
  445. }
  446. Navigator.pop(ctx);
  447. },
  448. ),
  449. ),
  450. );
  451. }
  452. void _showNumberInput(String title, void Function(int) onSave, int current) {
  453. final ctrl = TextEditingController(text: '$current');
  454. showDialog(
  455. context: context,
  456. builder: (_) => TDAlertDialog(
  457. title: title,
  458. contentWidget: TextField(
  459. controller: ctrl,
  460. keyboardType: TextInputType.number,
  461. decoration: const InputDecoration(border: OutlineInputBorder()),
  462. ),
  463. leftBtn: TDDialogButtonOptions(
  464. title: '取消',
  465. action: () => Navigator.pop(context),
  466. ),
  467. rightBtn: TDDialogButtonOptions(
  468. title: '确定',
  469. theme: TDButtonTheme.primary,
  470. action: () {
  471. onSave(int.tryParse(ctrl.text) ?? 1);
  472. Navigator.pop(context);
  473. },
  474. ),
  475. ),
  476. );
  477. }
  478. void _pickDateTime(void Function(DateTime) onPicked, DateTime initial) {
  479. final l10n = AppLocalizations.of(context);
  480. TDPicker.showDatePicker(
  481. context,
  482. title: l10n.get('selectDateTime'),
  483. useYear: true,
  484. useMonth: true,
  485. useDay: true,
  486. useHour: true,
  487. useMinute: true,
  488. initialDate: [
  489. initial.year,
  490. initial.month,
  491. initial.day,
  492. initial.hour,
  493. initial.minute,
  494. ],
  495. onConfirm: (selected) {
  496. onPicked(
  497. DateTime(
  498. selected['year']!,
  499. selected['month']!,
  500. selected['day']!,
  501. selected['hour']!,
  502. selected['minute']!,
  503. ),
  504. );
  505. },
  506. );
  507. }
  508. }