| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558 |
- import 'package:flutter/material.dart';
- import 'package:flutter_riverpod/flutter_riverpod.dart';
- import 'package:go_router/go_router.dart';
- import 'package:tdesign_flutter/tdesign_flutter.dart';
- import '../shell/nav_bar_config.dart';
- import '../../core/utils/date_utils.dart' as du;
- import '../../shared/widgets/action_bar.dart';
- import '../../shared/widgets/form_section.dart';
- import '../../shared/widgets/form_field_row.dart';
- import '../../core/i18n/app_localizations.dart';
- import 'vehicle_apply_controller.dart';
- import '../../core/theme/app_colors.dart';
- import '../../core/theme/app_colors_extension.dart';
- class VehicleApplyPage extends ConsumerStatefulWidget {
- final String? editId;
- const VehicleApplyPage({super.key, this.editId});
- @override
- ConsumerState<VehicleApplyPage> createState() => _VehicleApplyPageState();
- }
- class _VehicleApplyPageState extends ConsumerState<VehicleApplyPage> {
- final _reasonController = TextEditingController();
- final _originController = TextEditingController();
- final _destinationController = TextEditingController();
- bool _showReasonError = false;
- // Mock vehicle pool (车牌号列表)
- static const _vehiclePool = [
- '京A88888',
- '京B66666',
- '京C12345',
- '京D99999',
- '京E55555',
- ];
- // Mock passengers for contact picker
- static const _mockContacts = [
- '赵六',
- '钱七',
- '孙八',
- '周九',
- '吴十',
- '郑十一',
- '王十二',
- '冯十三',
- '陈十四',
- '褚十五',
- ];
- @override
- void initState() {
- super.initState();
- final state = ref.read(vehicleApplyProvider(widget.editId));
- _reasonController.text = state.vehicle.reason;
- _originController.text = state.vehicle.origin;
- _destinationController.text = state.vehicle.destination;
- }
- @override
- void dispose() {
- _reasonController.dispose();
- _originController.dispose();
- _destinationController.dispose();
- super.dispose();
- }
- @override
- Widget build(BuildContext context) {
- final colors = Theme.of(context).extension<AppColorsExtension>()!;
- final ctrl = ref.watch(vehicleApplyProvider(widget.editId).notifier);
- final state = ref.watch(vehicleApplyProvider(widget.editId));
- final l10n = AppLocalizations.of(context);
- final v = state.vehicle;
- ref
- .read(navBarConfigProvider.notifier)
- .update(
- NavBarConfig(
- title: l10n.get('vehicleApply'),
- showBack: true,
- onBack: () => context.pop(),
- ),
- );
- return Column(
- children: [
- Expanded(
- child: SingleChildScrollView(
- padding: const EdgeInsets.all(16),
- child: Column(
- children: [
- FormSection(
- title: l10n.get('vehicleInfo'),
- children: [
- // 车牌号
- FormFieldRow(
- label: l10n.get('licensePlate'),
- value: v.vehicleId.isNotEmpty ? v.vehicleId : null,
- hint: l10n.get('selectLicensePlate'),
- onTap: () => _showVehiclePicker(ctrl),
- ),
- // 排期冲突提示
- if (state.hasConflict) _buildConflictWarning(),
- const SizedBox(height: 8),
- // 用车事由 (TDInput)
- _buildReasonField(ctrl),
- const SizedBox(height: 8),
- // 用车目的 (TDPicker)
- FormFieldRow(
- label: l10n.get('vehiclePurpose'),
- value: _purposeLabel(v.purpose),
- hint: l10n.get('selectVehicleReason'),
- onTap: () => _showPurposePicker(ctrl),
- ),
- const SizedBox(height: 8),
- // 始发地 (auto-filled, editable)
- _buildLocationField(
- label: l10n.get('origin'),
- controller: _originController,
- hint: l10n.get('gpsLocating'),
- onChanged: ctrl.updateOrigin,
- showMapIcon: false,
- onMapTap: null,
- ),
- const SizedBox(height: 8),
- // 目的地 (with map icon)
- _buildLocationField(
- label: l10n.get('destination'),
- controller: _destinationController,
- hint: l10n.get('enterDestination'),
- onChanged: ctrl.updateDestination,
- showMapIcon: true,
- onMapTap: () {
- TDToast.showText(l10n.get('mapPickerComingSoon'), context: context);
- },
- ),
- const SizedBox(height: 8),
- // 出车时间
- FormFieldRow(
- label: l10n.get('departTime'),
- value: du.DateUtils.formatDateTime(v.startTime),
- onTap: () =>
- _pickDateTime(ctrl.updateStartTime, v.startTime),
- ),
- // 还车时间
- FormFieldRow(
- label: l10n.get('returnTime'),
- value: du.DateUtils.formatDateTime(v.endTime),
- onTap: () => _pickDateTime(ctrl.updateEndTime, v.endTime),
- ),
- if (!v.endTime.isAfter(v.startTime))
- Padding(
- padding: EdgeInsets.only(top: 4),
- child: Text(
- l10n.get('returnTimeMustLater'),
- style: TextStyle(
- fontSize: AppFontSizes.caption,
- color: colors.danger,
- ),
- ),
- ),
- const SizedBox(height: 8),
- // 同行人数
- FormFieldRow(
- label: l10n.get('passengerCount'),
- value: '${v.passengerCount}${l10n.get('personUnit')}',
- onTap: () => _showNumberInput(
- l10n.get('passengerCount'),
- ctrl.updatePassengerCount,
- v.passengerCount,
- ),
- ),
- // 同行人
- _buildPassengersSection(state, ctrl),
- ],
- ),
- ],
- ),
- ),
- ),
- ActionBar(
- showLeft: false,
- centerLabel: l10n.get('saveDraftShort'),
- rightLabel: l10n.get('submitApproval'),
- onCenterTap: state.isSubmitting
- ? null
- : () async {
- await ctrl.saveDraft();
- if (context.mounted) {
- TDToast.showText(l10n.get('draftSavedToast'), context: context);
- context.pop();
- }
- },
- onRightTap: (state.isSubmitting || state.hasConflict)
- ? null
- : () async {
- final reasonOk = v.reason.trim().isNotEmpty;
- final vehicleOk = v.vehicleId.isNotEmpty;
- final timeOk = v.endTime.isAfter(v.startTime);
- setState(() => _showReasonError = !reasonOk);
- if (!reasonOk || !vehicleOk || !timeOk) {
- TDToast.showText(l10n.get('completeFormInfo'), context: context);
- return;
- }
- final ok = await ctrl.submit();
- if (context.mounted) {
- if (ok) {
- TDToast.showText(l10n.get('submittedAwaitingApproval'), context: context);
- context.pop();
- } else {
- TDToast.showText(l10n.get('submitFailedRetry'), context: context);
- }
- }
- },
- ),
- ],
- );
- }
- String _purposeLabel(String key) {
- final l10n = AppLocalizations.of(context);
- switch (key) {
- case 'reception':
- return l10n.get('customerReception');
- case 'business':
- return l10n.get('businessTrip');
- case 'official':
- return l10n.get('official');
- default:
- return key;
- }
- }
- String _purposeKey(String label) {
- final l10n = AppLocalizations.of(context);
- if (label == l10n.get('customerReception')) return 'reception';
- if (label == l10n.get('businessTrip')) return 'business';
- if (label == l10n.get('official')) return 'official';
- return label;
- }
- Widget _buildConflictWarning() {
- final l10n = AppLocalizations.of(context);
- final colors = Theme.of(context).extension<AppColorsExtension>()!;
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
- decoration: BoxDecoration(
- color: colors.dangerBg,
- borderRadius: BorderRadius.circular(4),
- border: Border.all(color: colors.danger.withValues(alpha: 0.3)),
- ),
- child: Row(
- children: [
- Icon(Icons.warning_amber_rounded, size: 16, color: colors.danger),
- const SizedBox(width: 8),
- Expanded(
- child: Text(
- l10n.get('vehicleOccupiedPeriod'),
- style: TextStyle(
- fontSize: AppFontSizes.caption,
- color: colors.danger,
- ),
- ),
- ),
- ],
- ),
- );
- }
- Widget _buildReasonField(VehicleApplyController ctrl) {
- final l10n = AppLocalizations.of(context);
- final colors = Theme.of(context).extension<AppColorsExtension>()!;
- return Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- l10n.get('vehicleReason'),
- style: TextStyle(
- fontSize: AppFontSizes.body,
- color: colors.textSecondary,
- ),
- ),
- const SizedBox(height: 8),
- TDInput(
- controller: _reasonController,
- hintText: l10n.get('enterVehicleReason'),
- onChanged: (v) {
- ctrl.updateReason(v);
- setState(() => _showReasonError = false);
- },
- ),
- if (_showReasonError)
- Padding(
- padding: EdgeInsets.only(top: 4),
- child: Text(
- l10n.get('enterVehicleReason'),
- style: TextStyle(
- fontSize: AppFontSizes.caption,
- color: colors.danger,
- ),
- ),
- ),
- ],
- );
- }
- Widget _buildLocationField({
- required String label,
- required TextEditingController controller,
- required String hint,
- required ValueChanged<String> onChanged,
- required bool showMapIcon,
- required VoidCallback? onMapTap,
- }) {
- final colors = Theme.of(context).extension<AppColorsExtension>()!;
- return Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- label,
- style: TextStyle(
- fontSize: AppFontSizes.body,
- color: colors.textSecondary,
- ),
- ),
- const SizedBox(height: 8),
- Row(
- children: [
- Expanded(
- child: TDInput(
- controller: controller,
- hintText: hint,
- onChanged: onChanged,
- ),
- ),
- if (showMapIcon) ...[
- const SizedBox(width: 8),
- GestureDetector(
- onTap: onMapTap,
- child: Container(
- width: 40,
- height: 40,
- decoration: BoxDecoration(
- color: colors.primaryLight,
- borderRadius: BorderRadius.circular(8),
- ),
- child: Icon(
- Icons.map_outlined,
- color: colors.primary,
- size: 22,
- ),
- ),
- ),
- ],
- ],
- ),
- ],
- );
- }
- Widget _buildPassengersSection(
- VehicleApplyState state,
- VehicleApplyController ctrl,
- ) {
- final l10n = AppLocalizations.of(context);
- final colors = Theme.of(context).extension<AppColorsExtension>()!;
- return Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- const SizedBox(height: 8),
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(
- l10n.get('companion'),
- style: TextStyle(
- fontSize: AppFontSizes.body,
- color: colors.textSecondary,
- ),
- ),
- GestureDetector(
- onTap: () => _showContactPicker(ctrl),
- child: Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 12,
- vertical: 6,
- ),
- decoration: BoxDecoration(
- color: colors.primaryLight,
- borderRadius: BorderRadius.circular(16),
- ),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(
- Icons.person_add_alt_1,
- size: 14,
- color: colors.primary,
- ),
- SizedBox(width: 4),
- Text(
- l10n.get('add'),
- style: TextStyle(
- fontSize: AppFontSizes.caption,
- color: colors.primary,
- ),
- ),
- ],
- ),
- ),
- ),
- ],
- ),
- if (state.passengers.isNotEmpty) ...[
- const SizedBox(height: 8),
- Wrap(
- spacing: 8,
- runSpacing: 4,
- children: state.passengers.map((name) {
- return TDTag(
- name,
- size: TDTagSize.medium,
- theme: TDTagTheme.primary,
- isLight: true,
- needCloseIcon: true,
- onCloseTap: () => ctrl.removePassenger(name),
- );
- }).toList(),
- ),
- ],
- ],
- );
- }
- void _showVehiclePicker(VehicleApplyController ctrl) {
- final l10n = AppLocalizations.of(context);
- TDPicker.showMultiPicker(
- context,
- title: l10n.get('selectLicensePlate'),
- data: [_vehiclePool],
- onConfirm: (selected) => ctrl.updateVehicleId(selected.first),
- );
- }
- void _showPurposePicker(VehicleApplyController ctrl) {
- final l10n = AppLocalizations.of(context);
- final purposes = [l10n.get('customerReception'), l10n.get('businessTrip'), l10n.get('official')];
- TDPicker.showMultiPicker(
- context,
- title: l10n.get('selectVehicleReason'),
- data: [purposes],
- onConfirm: (selected) => ctrl.updatePurpose(_purposeKey(selected.first)),
- );
- }
- void _showContactPicker(VehicleApplyController ctrl) {
- // Mock contact picker with multi-select via dialog
- final l10n = AppLocalizations.of(context);
- final state = ref.read(vehicleApplyProvider(widget.editId));
- final selected = <String>{...state.passengers};
- showDialog(
- context: context,
- builder: (ctx) => TDAlertDialog(
- title: l10n.get('selectCompanion'),
- contentWidget: SizedBox(
- height: 300,
- child: ListView(
- children: _mockContacts.map((name) {
- return CheckboxListTile(
- title: Text(name),
- value: selected.contains(name),
- onChanged: (checked) {
- if (checked == true) {
- selected.add(name);
- } else {
- selected.remove(name);
- }
- // Force rebuild
- setState(() {});
- },
- );
- }).toList(),
- ),
- ),
- leftBtn: TDDialogButtonOptions(
- title: l10n.get('cancel'),
- action: () => Navigator.pop(ctx),
- ),
- rightBtn: TDDialogButtonOptions(
- title: l10n.get('confirm'),
- theme: TDButtonTheme.primary,
- action: () {
- for (final name in selected) {
- ctrl.addPassenger(name);
- }
- Navigator.pop(ctx);
- },
- ),
- ),
- );
- }
- void _showNumberInput(String title, void Function(int) onSave, int current) {
- final l10n = AppLocalizations.of(context);
- final ctrl = TextEditingController(text: '$current');
- showDialog(
- context: context,
- builder: (_) => TDAlertDialog(
- title: title,
- contentWidget: TDInput(controller: ctrl, hintText: '请输入数字'),
- 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) {
- final l10n = AppLocalizations.of(context);
- TDPicker.showDatePicker(
- context,
- title: l10n.get('selectDateTime'),
- useYear: true,
- useMonth: true,
- useDay: true,
- useHour: true,
- useMinute: true,
- initialDate: [
- initial.year,
- initial.month,
- initial.day,
- initial.hour,
- initial.minute,
- ],
- onConfirm: (selected) {
- onPicked(
- DateTime(
- selected['year']!,
- selected['month']!,
- selected['day']!,
- selected['hour']!,
- selected['minute']!,
- ),
- );
- },
- );
- }
- }
|