| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686 |
- 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 '../../shared/widgets/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_create_controller.dart';
- import '../../core/theme/app_colors.dart';
- import '../../core/theme/app_colors_extension.dart';
- class VehicleCreatePage extends ConsumerStatefulWidget {
- final String? editId;
- const VehicleCreatePage({super.key, this.editId});
- @override
- ConsumerState<VehicleCreatePage> createState() => _VehicleCreatePageState();
- }
- class _VehicleCreatePageState extends ConsumerState<VehicleCreatePage> {
- final _reasonController = TextEditingController();
- final _originController = TextEditingController();
- final _destinationController = TextEditingController();
- final _scrollCtrl = ScrollController();
- 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(vehicleCreateProvider(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();
- _scrollCtrl.dispose();
- super.dispose();
- }
- @override
- Widget build(BuildContext context) {
- final colors = Theme.of(context).extension<AppColorsExtension>()!;
- final ctrl = ref.watch(vehicleCreateProvider(widget.editId).notifier);
- final state = ref.watch(vehicleCreateProvider(widget.editId));
- final l10n = AppLocalizations.of(context);
- final v = state.vehicle;
- ref
- .read(navBarConfigProvider.notifier)
- .update(
- NavBarConfig(
- title: l10n.get('vehicleApply'),
- showBack: true,
- onBack: () {
- if (_hasUnsaved()) {
- _showConfirmDialog(
- l10n.get('confirmExit'),
- l10n.get('unsavedContentWarning'),
- l10n.get('continueEditing'),
- l10n.get('discardAndExit'),
- () => context.pop(),
- );
- } else {
- context.pop();
- }
- },
- ),
- );
- return PopScope(
- canPop: false,
- onPopInvokedWithResult: (didPop, _) {
- if (!didPop) {
- if (_hasUnsaved()) {
- _showConfirmDialog(
- l10n.get('confirmExit'),
- l10n.get('unsavedContentWarning'),
- l10n.get('continueEditing'),
- l10n.get('discardAndExit'),
- () => context.pop(),
- );
- } else {
- context.pop();
- }
- }
- },
- child: Column(
- children: [
- Expanded(
- child: GestureDetector(
- onTap: () => FocusScope.of(context).unfocus(),
- child: SingleChildScrollView(
- controller: _scrollCtrl,
- padding: const EdgeInsets.all(16),
- child: Column(
- children: [
- FormSection(
- title: l10n.get('vehicleInfo'),
- leadingIcon: Icons.directions_car_outlined,
- 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: 16),
- // 用车事由
- _label(l10n.get('vehicleReason'), required: true),
- const SizedBox(height: 8),
- TDTextarea(
- controller: _reasonController,
- hintText: l10n.get('enterVehicleReason'),
- maxLines: 4,
- minLines: 1,
- maxLength: 500,
- indicator: true,
- padding: EdgeInsets.zero,
- bordered: true,
- backgroundColor: colors.bgPage,
- onChanged: (val) {
- ctrl.updateReason(val);
- setState(() => _showReasonError = false);
- },
- ),
- if (_showReasonError)
- Padding(
- padding: EdgeInsets.only(top: 4),
- child: Text(
- l10n.get('enterVehicleReason'),
- style: TextStyle(
- fontSize: AppFontSizes.caption,
- color: colors.danger,
- ),
- ),
- ),
- const SizedBox(height: 16),
- // 用车目的
- FormFieldRow(
- label: l10n.get('vehiclePurpose'),
- value: _purposeLabel(v.purpose),
- hint: l10n.get('selectVehicleReason'),
- onTap: () => _showPurposePicker(ctrl),
- ),
- const SizedBox(height: 16),
- // 始发地
- FormFieldRow(
- label: l10n.get('origin'),
- value: _originController.text.isNotEmpty
- ? _originController.text
- : null,
- hint: l10n.get('gpsLocating'),
- onTap: () => _showTextInput(
- l10n.get('origin'),
- (val) {
- _originController.text = val;
- ctrl.updateOrigin(val);
- },
- initialText: _originController.text,
- ),
- ),
- const SizedBox(height: 16),
- // 目的地
- FormFieldRow(
- label: l10n.get('destination'),
- value: _destinationController.text.isNotEmpty
- ? _destinationController.text
- : null,
- hint: l10n.get('enterDestination'),
- onTap: () => _showDestinationOptions(ctrl),
- ),
- const SizedBox(height: 16),
- // 出车时间
- FormFieldRow(
- label: l10n.get('departTime'),
- value: du.DateUtils.formatDateTime(v.startTime),
- onTap: () =>
- _pickDateTime(ctrl.updateStartTime, v.startTime),
- ),
- const SizedBox(height: 16),
- // 还车时间
- 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: 16),
- // 同行人数
- FormFieldRow(
- label: l10n.get('passengerCount'),
- value: '${v.passengerCount}${l10n.get('personUnit')}',
- onTap: () => _showNumberInput(
- l10n.get('passengerCount'),
- ctrl.updatePassengerCount,
- v.passengerCount,
- ),
- ),
- const SizedBox(height: 16),
- // 同行人
- _buildPassengersSection(state, ctrl),
- ],
- ),
- const SizedBox(height: 80),
- ],
- ),
- ),
- ),
- ),
- 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);
- }
- }
- },
- ),
- ],
- ),
- );
- }
- // ── 通用方法 ──
- bool _hasUnsaved() {
- final s = ref.read(vehicleCreateProvider(widget.editId));
- final veh = s.vehicle;
- return _reasonController.text.isNotEmpty ||
- _originController.text.isNotEmpty ||
- _destinationController.text.isNotEmpty ||
- veh.vehicleId.isNotEmpty ||
- s.passengers.isNotEmpty;
- }
- void _unfocus() => FocusScope.of(context).unfocus();
- void _showConfirmDialog(
- String title,
- String content,
- String leftText,
- String rightText,
- VoidCallback onConfirm,
- ) {
- _unfocus();
- final colors = Theme.of(context).extension<AppColorsExtension>()!;
- showDialog(
- context: context,
- 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,
- Function(String) onConfirm, {
- String initialText = '',
- }) {
- _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 _showDestinationOptions(VehicleCreateController ctrl) {
- _unfocus();
- final l10n = AppLocalizations.of(context);
- showModalBottomSheet(
- context: context,
- builder: (ctx) => SafeArea(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- ListTile(
- leading: Icon(Icons.edit_outlined),
- title: Text(l10n.get('enterDestination')),
- onTap: () {
- Navigator.pop(ctx);
- _showTextInput(
- l10n.get('destination'),
- (val) {
- _destinationController.text = val;
- ctrl.updateDestination(val);
- },
- initialText: _destinationController.text,
- );
- },
- ),
- ListTile(
- leading: Icon(Icons.map_outlined),
- title: const Text('地图选点'),
- onTap: () {
- Navigator.pop(ctx);
- TDToast.showText(l10n.get('mapPickerComingSoon'), context: context);
- },
- ),
- ],
- ),
- ),
- );
- }
- Widget _label(String text, {bool required = false}) {
- final colors = Theme.of(context).extension<AppColorsExtension>()!;
- return Text.rich(
- TextSpan(
- children: [
- TextSpan(
- text: text,
- style: TextStyle(
- fontSize: AppFontSizes.subtitle,
- color: colors.textSecondary,
- ),
- ),
- if (required)
- TextSpan(
- text: ' *',
- style: TextStyle(
- fontSize: AppFontSizes.subtitle,
- color: colors.danger,
- ),
- ),
- ],
- ),
- );
- }
- // ── 表单字段方法 ──
- 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 _buildPassengersSection(
- VehicleCreateState state,
- VehicleCreateController ctrl,
- ) {
- final l10n = AppLocalizations.of(context);
- final colors = Theme.of(context).extension<AppColorsExtension>()!;
- return Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(
- l10n.get('companion'),
- style: TextStyle(
- fontSize: AppFontSizes.subtitle,
- 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(VehicleCreateController ctrl) {
- final l10n = AppLocalizations.of(context);
- _unfocus();
- TDPicker.showMultiPicker(
- context,
- title: l10n.get('selectLicensePlate'),
- data: [_vehiclePool],
- onConfirm: (selected) => ctrl.updateVehicleId(selected.first),
- );
- }
- void _showPurposePicker(VehicleCreateController ctrl) {
- final l10n = AppLocalizations.of(context);
- _unfocus();
- 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(VehicleCreateController ctrl) {
- _unfocus();
- final l10n = AppLocalizations.of(context);
- final state = ref.read(vehicleCreateProvider(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);
- }
- 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) {
- _unfocus();
- 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) {
- _unfocus();
- 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']!,
- ),
- );
- },
- );
- }
- }
|