import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; import '../../core/i18n/app_localizations.dart'; import '../../core/navigation/host_app_channel.dart'; import '../../core/storage/draft_storage.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors_extension.dart'; import '../../shared/widgets/action_bar.dart'; import '../../shared/widgets/app_skeletons.dart'; import '../../shared/widgets/form_field_row.dart'; import '../../shared/widgets/form_section.dart'; import '../../shared/widgets/loading_dialog.dart'; import '../../shared/widgets/nav_bar_config.dart'; import '../../shared/widgets/searchable_picker_sheet.dart'; import 'vehicle_api.dart'; class VehicleCreatePage extends ConsumerStatefulWidget { const VehicleCreatePage({super.key}); @override ConsumerState createState() => _VehicleCreatePageState(); } class _VehicleCreatePageState extends ConsumerState { static const _draftKey = 'vehicle_apply'; // ── 基本信息 ── String _purpose = ''; final _reasonController = TextEditingController(); final _reasonFocus = FocusNode(); String _origin = ''; String _destinAdr = ''; DateTime? _startTime; DateTime? _endTime; // ── 车辆信息 ── String _licensePlate = ''; String _vehicleType = ''; String _brand = ''; int _seats = 0; // ── 驾驶员 ── String _driverName = ''; // ── 同行信息 ── int _passengerCount = 1; String _passengerName = ''; // ── 参考数据 ── List _departments = []; bool _firstBuild = true; bool _refDataLoading = true; String _selectedDeptId = ''; String _selectedDeptName = ''; final _scrollCtrl = ScrollController(); // Mock 车牌 static const _mockLicensePlates = ['粤B12345', '京A88888', '京B66666', '京C12345', '京D99999']; // Mock 车辆类型 static const _mockVehicleTypes = ['sedan', 'suv', 'mpv', 'van']; @override void initState() { super.initState(); SystemChrome.setSystemUIOverlayStyle( const SystemUiOverlayStyle(statusBarColor: Colors.transparent, statusBarIconBrightness: Brightness.dark), ); _reasonFocus.addListener(() => _ensureVisible(_reasonFocus)); _departments = []; _refDataLoading = true; _refDataFuture = null; _loadRefData(); WidgetsBinding.instance.addPostFrameCallback((_) => _checkDataReady()); } void _checkDataReady() { if (!_refDataLoading && mounted) { setState(() => _firstBuild = false); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) setState(() {}); }); } else if (mounted) { WidgetsBinding.instance.addPostFrameCallback((_) => _checkDataReady()); } } Future? _refDataFuture; Future _loadRefData({bool showLoading = false}) async { if (_refDataFuture != null) return _refDataFuture!; final completer = Completer(); _refDataFuture = completer.future; if (showLoading) { LoadingDialog.show(context, text: AppLocalizations.of(context).get('dataLoading')); } try { final api = ref.read(vehicleApiProvider); final results = await Future.wait([api.getDepartments()]); if (!mounted) return; setState(() { _departments = results[0]; _refDataLoading = false; _autoSelectDept(); }); completer.complete(); } catch (_) { if (!mounted) { completer.complete(); return; } setState(() => _refDataLoading = false); completer.complete(); } finally { if (showLoading && mounted) LoadingDialog.hide(context); _refDataFuture = null; } } void _autoSelectDept() { if (_selectedDeptId.isNotEmpty) return; final dep = HostAppChannel.dep; if (dep.isEmpty) return; final match = _departments.where((d) => d.dep == dep); if (match.isNotEmpty) { _selectedDeptId = match.first.dep; _selectedDeptName = match.first.name; } } void _ensureVisible(FocusNode node) { if (!node.hasFocus) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (node.hasFocus && _scrollCtrl.hasClients) { final ctx = node.context; if (ctx != null) { Scrollable.ensureVisible(ctx, alignment: 0.3, duration: const Duration(milliseconds: 300)); } } }); } @override void dispose() { _reasonController.dispose(); _reasonFocus.dispose(); _scrollCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); if (_firstBuild) { return const SkeletonFormPage(); } Future.microtask(() => ref.read(pageBackProvider.notifier).state = () => _doPop()); return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (didPop) return; _doPop(); }, child: Column( children: [ Expanded( child: GestureDetector( onTap: () => FocusScope.of(context).unfocus(), child: SingleChildScrollView( controller: _scrollCtrl, padding: const EdgeInsets.all(16), child: Column( children: [ _buildBasicInfo(l10n), const SizedBox(height: 16), _buildVehicleInfo(l10n), const SizedBox(height: 16), _buildPassengerInfo(l10n), const SizedBox(height: 24), _buildPageFooter(), ], ), ), ), ), _buildBottomBar(l10n), ], ), ); } // ═══ 基本信息 ═══ Widget _buildBasicInfo(AppLocalizations l10n) { final colors = Theme.of(context).extension()!; return FormSection( title: l10n.get('basicInfo'), leadingIcon: Icons.info_outline, children: [ FormFieldRow(label: l10n.get('date'), value: _today(), readOnly: true, showArrow: false), const SizedBox(height: 16), FormFieldRow( label: l10n.get('applyDept'), value: _selectedDeptId.isNotEmpty ? '$_selectedDeptId/$_selectedDeptName' : '', hint: l10n.get('pleaseSelect'), onTap: _refDataLoading ? null : () => _showDeptPicker(), ), const SizedBox(height: 16), _buildPurposeRow(l10n), const SizedBox(height: 16), _label(l10n.get('vehicleReason'), required: true), const SizedBox(height: 8), TDTextarea( controller: _reasonController, focusNode: _reasonFocus, hintText: l10n.get('enterVehicleReason'), maxLines: 4, minLines: 1, maxLength: 500, indicator: true, padding: EdgeInsets.zero, bordered: true, backgroundColor: colors.bgPage, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('origin'), value: _origin.isNotEmpty ? _origin : null, hint: l10n.get('pleaseEnter'), onTap: () => _showTextInput(l10n.get('origin'), (v) => setState(() => _origin = v)), ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('destination'), value: _destinAdr.isNotEmpty ? _destinAdr : null, hint: l10n.get('pleaseEnter'), onTap: () => _showTextInput(l10n.get('destination'), (v) => setState(() => _destinAdr = v)), ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('departTime'), value: _startTime != null ? _formatDateTime(_startTime!) : null, hint: l10n.get('pleaseSelect'), onTap: () => _pickDateTime((d) => setState(() => _startTime = d), _startTime), ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('returnTime'), value: _endTime != null ? _formatDateTime(_endTime!) : null, hint: l10n.get('pleaseSelect'), onTap: () => _pickDateTime((d) => setState(() => _endTime = d), _endTime), ), if (_startTime != null && _endTime != null && !_endTime!.isAfter(_startTime!)) Padding( padding: const EdgeInsets.only(top: 8), child: Text( l10n.get('returnTimeMustLater'), style: TextStyle(fontSize: AppFontSizes.caption, color: colors.danger), ), ), ], ); } Widget _buildPurposeRow(AppLocalizations l10n) { final colors = Theme.of(context).extension()!; final purposes = ['reception', 'business', 'official', 'other']; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _label(l10n.get('vehiclePurpose'), required: true), const SizedBox(height: 8), Wrap( spacing: 12, runSpacing: 8, children: purposes.map((key) { final sel = _purpose == key; return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => setState(() => _purpose = key), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 18, height: 18, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all(color: sel ? colors.primary : colors.textPlaceholder, width: 2), ), child: sel ? Center(child: Container(width: 8, height: 8, decoration: BoxDecoration(shape: BoxShape.circle, color: colors.primary))) : null, ), const SizedBox(width: 5), Text(_purposeLabel(key, l10n), style: TextStyle(fontSize: AppFontSizes.subtitle, color: sel ? colors.primary : colors.textPrimary)), ], ), ); }).toList(), ), ], ); } // ═══ 车辆信息 ═══ Widget _buildVehicleInfo(AppLocalizations l10n) { return FormSection( title: l10n.get('vehicleInfo'), leadingIcon: Icons.directions_car_outlined, children: [ FormFieldRow( label: l10n.get('licensePlate'), value: _licensePlate.isNotEmpty ? _licensePlate : null, hint: l10n.get('pleaseSelect'), onTap: () => _showLicensePlatePicker(), ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('vehicleType'), value: _vehicleType.isNotEmpty ? _vehicleTypeLabel(_vehicleType, l10n) : null, hint: l10n.get('pleaseSelect'), onTap: () => _showVehicleTypePicker(l10n), ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('brand'), value: _brand.isNotEmpty ? _brand : null, hint: l10n.get('pleaseEnter'), onTap: () => _showTextInput(l10n.get('brand'), (v) => setState(() => _brand = v), initialText: _brand), ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('seats'), value: _seats > 0 ? '$_seats' : null, hint: l10n.get('pleaseEnter'), onTap: () => _showNumberInput(l10n.get('seats'), (v) => setState(() => _seats = v), _seats), ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('driverName'), value: _driverName.isNotEmpty ? _driverName : null, hint: l10n.get('pleaseSelect'), onTap: () => _showDriverPicker(), ), ], ); } // ═══ 同行信息 ═══ Widget _buildPassengerInfo(AppLocalizations l10n) { return FormSection( title: l10n.get('companionInfo'), leadingIcon: Icons.people_outline, children: [ FormFieldRow( label: l10n.get('passengerCount'), value: '$_passengerCount', onTap: () => _showNumberInput(l10n.get('passengerCount'), (v) => setState(() => _passengerCount = v < 1 ? 1 : v), _passengerCount), ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('passengerName'), value: _passengerName.isNotEmpty ? _passengerName : null, hint: l10n.get('pleaseEnter'), onTap: () => _showTextInput(l10n.get('passengerName'), (v) => setState(() => _passengerName = v), initialText: _passengerName), ), ], ); } // ═══ 底部操作栏 ═══ Widget _buildBottomBar(AppLocalizations l10n) { return ActionBar( showLeft: false, centerLabel: l10n.get('saveDraft'), rightLabel: l10n.get('submit'), centerTextOnly: true, onCenterTap: () async { FocusScope.of(context).unfocus(); try { await _saveDraftToStorage(); if (mounted) _forcePop(); } catch (_) { if (mounted) TDToast.showFail(l10n.get('saveFailed'), context: context); } }, onRightTap: () async { final err = _validate(l10n); if (err.isNotEmpty) { TDToast.showText(err.first, context: context); return; } FocusScope.of(context).unfocus(); LoadingDialog.show(context, text: l10n.get('submitting')); try { final data = _buildSubmitData(); final api = ref.read(vehicleApiProvider); await api.submit(data); await DraftStorage.delete(_draftKey); if (mounted) { LoadingDialog.hide(context); TDToast.showSuccess(l10n.get('submitSuccess'), context: context); GoRouter.of(context).go('/vehicle/list'); } } catch (_) { if (mounted) { LoadingDialog.hide(context); TDToast.showFail(l10n.get('submitFailedRetry'), context: context); } } }, ); } Map _buildSubmitData() { return { 'HeadData': { 'YC_DD': _today(), 'SAL_NO': HostAppChannel.usr, 'DEP': _selectedDeptId, 'PURPOSE': _purpose, 'REASON': _reasonController.text.trim(), 'ORIGIN': _origin, 'DESTIN_ADR': _destinAdr, 'PASSENGER_COUNT': _passengerCount, 'PASSENGER_NAME': _passengerName, 'LICENSEPLATE': _licensePlate, 'VEHICLE_TYPE': _vehicleType, 'BRAND': _brand, 'SEATS': _seats, 'DRIVER_NAME': _driverName, 'START_TIME': _startTime?.toIso8601String(), 'END_TIME': _endTime?.toIso8601String(), 'USR': HostAppChannel.usr, }, }; } List _validate(AppLocalizations l10n) { final e = []; if (_reasonController.text.trim().isEmpty) e.add(l10n.get('enterVehicleReason')); if (_purpose.isEmpty) e.add('请选择用车目的'); if (_licensePlate.isEmpty) e.add('请选择车牌号'); if (_startTime != null && _endTime != null && !_endTime!.isAfter(_startTime!)) { e.add(l10n.get('returnTimeMustLater')); } return e; } // ═══ 草稿 ═══ Future _saveDraftToStorage() async { await DraftStorage.save(_draftKey, { 'purpose': _purpose, 'reason': _reasonController.text, 'origin': _origin, 'destinAdr': _destinAdr, 'startTime': _startTime?.toIso8601String(), 'endTime': _endTime?.toIso8601String(), 'licensePlate': _licensePlate, 'vehicleType': _vehicleType, 'brand': _brand, 'seats': _seats, 'driverName': _driverName, 'passengerCount': _passengerCount, 'passengerName': _passengerName, 'selectedDeptId': _selectedDeptId, 'selectedDeptName': _selectedDeptName, }); } void _doPop() { if (_hasUnsaved()) { final l10n = AppLocalizations.of(context); _showConfirmDialog(l10n.get('confirmExit'), l10n.get('unsavedContentWarning'), l10n.get('continueEditing'), l10n.get('discardAndExit'), () { DraftStorage.delete(_draftKey); _forcePop(); }); } else { _forcePop(); } } void _forcePop() { FocusManager.instance.primaryFocus?.unfocus(); final router = GoRouter.of(context); if (router.canPop()) { router.pop(); } else { SystemNavigator.pop(); } } bool _hasUnsaved() => _reasonController.text.isNotEmpty || _purpose.isNotEmpty || _origin.isNotEmpty || _destinAdr.isNotEmpty || _licensePlate.isNotEmpty || _vehicleType.isNotEmpty || _brand.isNotEmpty || _driverName.isNotEmpty || _passengerName.isNotEmpty || _passengerCount != 1; void _showConfirmDialog(String title, String content, String leftText, String rightText, VoidCallback onConfirm) { FocusScope.of(context).unfocus(); FocusManager.instance.primaryFocus?.unfocus(); final colors = Theme.of(context).extension()!; showDialog( context: context, useRootNavigator: true, builder: (ctx) => TDAlertDialog( title: title, content: content, buttonStyle: TDDialogButtonStyle.text, leftBtn: TDDialogButtonOptions(title: leftText, titleColor: colors.primary, action: () => Navigator.pop(ctx)), rightBtn: TDDialogButtonOptions(title: rightText, titleColor: colors.danger, action: () { Navigator.pop(ctx); onConfirm(); }), ), ); } // ═══ 弹窗方法 ═══ void _showTextInput(String title, void Function(String) onConfirm, {String initialText = ''}) { FocusScope.of(context).unfocus(); final l10n = AppLocalizations.of(context); final c = TextEditingController(text: initialText); showGeneralDialog( context: context, pageBuilder: (ctx, animation, secondaryAnimation) => TDInputDialog( textEditingController: c, title: title, hintText: l10n.get('pleaseEnter'), leftBtn: TDDialogButtonOptions(title: l10n.get('cancel'), action: () => Navigator.pop(ctx)), rightBtn: TDDialogButtonOptions(title: l10n.get('confirm'), action: () { onConfirm(c.text); Navigator.pop(ctx); }), ), ); } void _showNumberInput(String title, void Function(int) onSave, int current) { FocusScope.of(context).unfocus(); final l10n = AppLocalizations.of(context); final ctrl = TextEditingController(text: '$current'); showDialog( context: context, builder: (_) => TDAlertDialog( title: title, contentWidget: TDInput(controller: ctrl, hintText: '请输入数字', inputType: TextInputType.number), leftBtn: TDDialogButtonOptions(title: l10n.get('cancel'), action: () => Navigator.pop(context)), rightBtn: TDDialogButtonOptions( title: l10n.get('confirm'), theme: TDButtonTheme.primary, action: () { onSave(int.tryParse(ctrl.text) ?? 1); Navigator.pop(context); }, ), ), ); } void _pickDateTime(void Function(DateTime) onPicked, DateTime? initial) { FocusScope.of(context).unfocus(); final l10n = AppLocalizations.of(context); final d = initial ?? DateTime.now(); TDPicker.showDatePicker( context, title: l10n.get('selectDateTime'), useYear: true, useMonth: true, useDay: true, useHour: true, useMinute: true, initialDate: [d.year, d.month, d.day, d.hour, d.minute], onConfirm: (selected) { onPicked(DateTime(selected['year']!, selected['month']!, selected['day']!, selected['hour']!, selected['minute']!)); }, ); } Future _showDeptPicker() async { FocusManager.instance.primaryFocus?.unfocus(); final l10n = AppLocalizations.of(context); final api = ref.read(vehicleApiProvider); final result = await showSearchablePicker( context, title: '${l10n.get('select')}${l10n.get('applyDept')}', searchHint: l10n.get('search'), loader: (keyword, page) => api.getDepartments(keyword: keyword, page: page, size: 20), labelBuilder: (d) => d.name.isEmpty ? d.dep : '${d.dep} ${d.name}', ); if (result != null && mounted) { setState(() { _selectedDeptId = result.dep; _selectedDeptName = result.name; }); } } void _showLicensePlatePicker() { FocusScope.of(context).unfocus(); final l10n = AppLocalizations.of(context); TDPicker.showMultiPicker( context, title: l10n.get('selectLicensePlate'), data: [_mockLicensePlates], onConfirm: (selected) => setState(() => _licensePlate = selected.first), ); } void _showVehicleTypePicker(AppLocalizations l10n) { FocusScope.of(context).unfocus(); final labels = _mockVehicleTypes.map((t) => _vehicleTypeLabel(t, l10n)).toList(); TDPicker.showMultiPicker( context, title: l10n.get('selectVehicleType'), data: [labels], onConfirm: (selected) { final idx = labels.indexOf(selected.first); if (idx >= 0) setState(() => _vehicleType = _mockVehicleTypes[idx]); }, ); } Future _showDriverPicker() async { FocusManager.instance.primaryFocus?.unfocus(); final l10n = AppLocalizations.of(context); final api = ref.read(vehicleApiProvider); final result = await showSearchablePicker( context, title: '${l10n.get('select')}${l10n.get('driverName')}', searchHint: l10n.get('search'), loader: (keyword, page) => api.getEmployees(keyword: keyword, page: page, size: 20), labelBuilder: (e) => e.name.isEmpty ? e.salNo : '${e.salNo} ${e.name}', ); if (result != null && mounted) { setState(() { _driverName = result.name; }); } } // ═══ 工具方法 ═══ Widget _label(String t, {bool required = false}) { final colors = Theme.of(context).extension()!; return Text.rich( TextSpan( children: [ TextSpan(text: t, style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textSecondary)), if (required) TextSpan(text: ' *', style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.danger)), ], ), ); } Widget _buildPageFooter() { final l10n = AppLocalizations.of(context); final colors = Theme.of(context).extension()!; return Center( child: Padding( padding: const EdgeInsets.only(bottom: 16), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.rocket_launch_outlined, size: 16, color: colors.textPlaceholder), const SizedBox(width: 6), Text(l10n.get('pageFooter'), style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPlaceholder)), ], ), ), ); } String _today() { final n = DateTime.now(); return '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}'; } String _formatDateTime(DateTime d) { return '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')} ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; } String _purposeLabel(String key, AppLocalizations l10n) { switch (key) { case 'reception': return l10n.get('customerReception'); case 'business': return l10n.get('businessTrip'); case 'official': return l10n.get('official'); case 'other': return l10n.get('other'); default: return key; } } String _vehicleTypeLabel(String key, AppLocalizations l10n) { switch (key) { case 'sedan': return '轿车'; case 'suv': return 'SUV'; case 'mpv': return '商务车'; case 'van': return '面包车'; default: return key; } } }