// ignore_for_file: use_build_context_synchronously import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; import '../../../core/i18n/app_localizations.dart'; import '../../../core/navigation/host_app_channel.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors_extension.dart'; import '../../../shared/widgets/searchable_picker_sheet.dart'; import '../../expense_apply/expense_apply_api.dart'; import '../overtime_apply_api.dart'; /// 加班明细输入数据。 class OvertimeDetailData { final String? jbNo; final int? itm; final String salNo; final String salName; final String dep; final String jbType; final String jbDate; final String startTime; final String endTime; final double jbHours; final double jbDays; final String attPeriod; final String reason; final String compensationType; final double compensationCount; final String adr; final String rem; const OvertimeDetailData({ this.jbNo, this.itm, this.salNo = '', this.salName = '', this.dep = '', this.jbType = 'WORKING_DAY', this.jbDate = '', this.startTime = '', this.endTime = '', this.jbHours = 0.0, this.jbDays = 0.0, this.attPeriod = '', this.reason = '', this.compensationType = 'OVERTIME_PAY', this.compensationCount = 0.0, this.adr = '', this.rem = '', }); } /// 添加/编辑加班明细弹窗。 class OvertimeApplyDetailDialog extends StatefulWidget { final OvertimeApplyApi api; final AppLocalizations l10n; final OvertimeDetailData? initialData; const OvertimeApplyDetailDialog({ super.key, required this.api, required this.l10n, this.initialData, }); /// 显示弹窗,返回 [OvertimeDetailData] 或 `null`(取消时)。 static Future show( BuildContext context, { required OvertimeApplyApi api, required AppLocalizations l10n, OvertimeDetailData? initialData, }) { FocusScope.of(context).unfocus(); return Navigator.push( context, TDSlidePopupRoute( slideTransitionFrom: SlideTransitionFrom.bottom, isDismissible: true, builder: (_) => OvertimeApplyDetailDialog( api: api, l10n: l10n, initialData: initialData, ), ), ); } @override State createState() => _OvertimeApplyDetailDialogState(); } class _OvertimeApplyDetailDialogState extends State { // 员工 EmployeeItem? _selEmployee; // 部门 String _dep = ''; String _depName = ''; // 加班类型 String _jbType = 'WORKING_DAY'; // 加班日期 String _jbDate = ''; // 开始/结束时间 String _startTime = ''; String _endTime = ''; // 加班时长、天数 final _hoursCtrl = TextEditingController(); final _hoursFocus = FocusNode(); final _daysCtrl = TextEditingController(); final _daysFocus = FocusNode(); // 考勤周期 String _attPeriod = ''; // 事由 final _reasonCtrl = TextEditingController(); final _reasonFocus = FocusNode(); // 补偿类型 String _compensationType = 'OVERTIME_PAY'; // 折算补偿次数 final _compCountCtrl = TextEditingController(); final _compCountFocus = FocusNode(); // 地点 final _adrCtrl = TextEditingController(); final _adrFocus = FocusNode(); // 备注 final _remarkCtrl = TextEditingController(); final _remarkFocus = FocusNode(); // 滚动 final _scrollCtrl = ScrollController(); // 加班类型选项 static const _jbTypeOptions = [ 'WORKING_DAY', 'REST_DAY', 'PUBLIC_HOLIDAY', 'SPECIAL_HOLIDAY', 'OTHER', ]; // 补偿类型选项 static const _compensationTypeOptions = [ 'OVERTIME_PAY', 'COMPENSATORY_LEAVE', 'NO_COMPENSATION', 'OTHER', ]; AppLocalizations get _l10n => widget.l10n; bool get _isEdit => widget.initialData != null; @override void initState() { super.initState(); final d = widget.initialData; _selEmployee = (d != null && d.salNo.isNotEmpty) ? EmployeeItem(salNo: d.salNo, name: d.salName) : null; _dep = d?.dep ?? ''; _depName = d?.dep ?? ''; _jbType = d?.jbType ?? 'WORKING_DAY'; _jbDate = d?.jbDate ?? ''; if (!_isEdit && _jbDate.isEmpty) { final now = DateTime.now(); _jbDate = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; _attPeriod = _jbDate.substring(0, 7); } _startTime = d?.startTime ?? ''; _endTime = d?.endTime ?? ''; if (d != null) { _hoursCtrl.text = d.jbHours > 0 ? d.jbHours.toString() : ''; _daysCtrl.text = d.jbDays > 0 ? d.jbDays.toString() : ''; } _attPeriod = d?.attPeriod ?? ''; if (d != null) { _reasonCtrl.text = d.reason; } _compensationType = d?.compensationType ?? 'OVERTIME_PAY'; if (d != null) { _compCountCtrl.text = d.compensationCount > 0 ? d.compensationCount.toString() : ''; } if (d != null) { _adrCtrl.text = d.adr; _remarkCtrl.text = d.rem; } _reasonFocus.addListener(() => _ensureVisible(_reasonFocus)); _hoursFocus.addListener(() => _ensureVisible(_hoursFocus)); _daysFocus.addListener(() => _ensureVisible(_daysFocus)); _compCountFocus.addListener(() => _ensureVisible(_compCountFocus)); _adrFocus.addListener(() => _ensureVisible(_adrFocus)); _remarkFocus.addListener(() => _ensureVisible(_remarkFocus)); // 非编辑模式自动带出当前用户 if (!_isEdit) { WidgetsBinding.instance.addPostFrameCallback((_) => _doAutoFill()); } } void _ensureVisible(FocusNode node) { if (!node.hasFocus) return; _doEnsureVisible(node, 0, -1); } void _doEnsureVisible(FocusNode node, int attempt, double lastInsets) { if (attempt >= 15) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !node.hasFocus || !_scrollCtrl.hasClients) return; final insets = MediaQuery.of(context).viewInsets.bottom; if (insets != lastInsets) { _doEnsureVisible(node, attempt + 1, insets); return; } final ctx = node.context; if (ctx == null) return; Future.delayed(const Duration(milliseconds: 500), () { if (!mounted || !node.hasFocus || !_scrollCtrl.hasClients) return; Scrollable.ensureVisible( ctx, alignment: 0.5, duration: const Duration(milliseconds: 300), ); }); }); } Future _doAutoFill() async { await HostAppChannel.ensureConfig(); final usr = HostAppChannel.usr; if (usr.isEmpty) return; try { final employees = await widget.api.getEmployees(salNo: usr); if (employees.isNotEmpty && mounted) { setState(() { _selEmployee = employees.first; }); // 自动带出部门 final depCode = HostAppChannel.dep; if (depCode.isNotEmpty) { final depts = await widget.api.getDepartments(); final matched = depts.where((d) => d.dep == depCode); if (matched.isNotEmpty && mounted) { setState(() { _dep = matched.first.dep; _depName = matched.first.name; }); } } } } catch (_) { // 静默失败 } } @override void dispose() { _hoursCtrl.dispose(); _hoursFocus.dispose(); _daysCtrl.dispose(); _daysFocus.dispose(); _reasonCtrl.dispose(); _reasonFocus.dispose(); _compCountCtrl.dispose(); _compCountFocus.dispose(); _adrCtrl.dispose(); _adrFocus.dispose(); _remarkCtrl.dispose(); _remarkFocus.dispose(); _scrollCtrl.dispose(); super.dispose(); } void _confirm() { if (_selEmployee == null) { TDToast.showText( '${_l10n.get('pleaseSelect')}${_l10n.get('employee')}', context: context, ); return; } if (_jbDate.isEmpty) { TDToast.showText( '${_l10n.get('pleaseSelect')}${_l10n.get('date')}', context: context, ); return; } if (_startTime.isEmpty) { TDToast.showText( '${_l10n.get('pleaseSelect')}${_l10n.get('startTime')}', context: context, ); return; } if (_endTime.isEmpty) { TDToast.showText( '${_l10n.get('pleaseSelect')}${_l10n.get('endTime')}', context: context, ); return; } if (_startTime.compareTo(_endTime) >= 0) { TDToast.showText(_l10n.get('endTimeMustLater'), context: context); return; } final hours = double.tryParse(_hoursCtrl.text) ?? 0; if (hours <= 0) { TDToast.showText(_l10n.get('overtimeHoursPositive'), context: context); return; } Navigator.pop( context, OvertimeDetailData( jbNo: widget.initialData?.jbNo, itm: widget.initialData?.itm, salNo: _selEmployee!.salNo, salName: _selEmployee!.name, dep: _dep, jbType: _jbType, jbDate: _jbDate, startTime: _startTime, endTime: _endTime, jbHours: hours, jbDays: double.tryParse(_daysCtrl.text) ?? 0, attPeriod: _attPeriod, reason: _reasonCtrl.text.trim(), compensationType: _compensationType, compensationCount: double.tryParse(_compCountCtrl.text) ?? 0, adr: _adrCtrl.text, rem: _remarkCtrl.text, ), ); } @override Widget build(BuildContext context) { final colors = Theme.of(context).extension()!; return AnimatedPadding( padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom, ), duration: const Duration(milliseconds: 200), child: SafeArea( child: ConstrainedBox( constraints: BoxConstraints( maxHeight: MediaQuery.of(context).size.height * 0.85, ), child: Container( decoration: BoxDecoration( color: colors.bgPage, borderRadius: const BorderRadius.vertical( top: Radius.circular(16), ), ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildHeader(colors), Flexible( child: GestureDetector( onTap: () => FocusScope.of(context).unfocus(), behavior: HitTestBehavior.translucent, child: SingleChildScrollView( controller: _scrollCtrl, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.manual, padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildEmployeePicker(colors), const SizedBox(height: 12), _buildDeptPicker(colors), const SizedBox(height: 12), _buildJbTypeSelector(colors), const SizedBox(height: 12), _buildJbDatePicker(colors), const SizedBox(height: 12), _buildStartTimePicker(colors), const SizedBox(height: 12), _buildEndTimePicker(colors), const SizedBox(height: 12), _buildHoursInput(colors), const SizedBox(height: 12), _buildDaysInput(colors), const SizedBox(height: 12), _buildAttPeriodInput(colors), const SizedBox(height: 12), _buildReasonInput(colors), const SizedBox(height: 12), _buildCompensationTypeSelector(colors), const SizedBox(height: 12), _buildCompCountInput(colors), const SizedBox(height: 12), _buildAdrInput(colors), const SizedBox(height: 12), _buildRemarkInput(colors), ], ), ), ), ), Container( padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), decoration: BoxDecoration( color: colors.bgCard, border: Border( top: BorderSide(color: colors.border, width: 0.5), ), ), child: _buildActions(), ), ], ), ), ), ), ); } // ── 标题栏 ── Widget _buildHeader(AppColorsExtension colors) { return Column( mainAxisSize: MainAxisSize.min, children: [ Center( child: Container( margin: const EdgeInsets.only(top: 8, bottom: 4), width: 36, height: 4, decoration: BoxDecoration( color: colors.border, borderRadius: BorderRadius.circular(2), ), ), ), Padding( padding: const EdgeInsets.fromLTRB(20, 8, 12, 16), child: Row( children: [ const SizedBox(width: 24), Expanded( child: Center( child: Text( _l10n.get('addDetail'), style: TextStyle( fontSize: AppFontSizes.title, fontWeight: FontWeight.w600, color: colors.textPrimary, ), ), ), ), GestureDetector( onTap: () => Navigator.pop(context), child: Padding( padding: const EdgeInsets.all(4), child: Icon( Icons.close, size: 20, color: colors.textSecondary, ), ), ), ], ), ), ], ); } // ── 通用 Picker 卡片 ── Widget _pickerCard({ required String label, required bool required, required String currentLabel, required bool hasValue, required VoidCallback onTap, VoidCallback? onClear, }) { final tdTheme = TDTheme.of(context); return GestureDetector( onTap: () { FocusManager.instance.primaryFocus?.unfocus(); onTap(); }, child: Container( padding: const EdgeInsets.only( left: 16, right: 10, top: 12, bottom: 12, ), decoration: BoxDecoration( color: tdTheme.bgColorContainer, borderRadius: BorderRadius.circular(tdTheme.radiusDefault), border: Border.all(color: tdTheme.componentStrokeColor), ), child: Row( children: [ TDText( label, maxLines: 1, overflow: TextOverflow.visible, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0), ), if (required) Padding( padding: const EdgeInsets.only(left: 4), child: TDText( '*', font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: TextStyle(color: tdTheme.errorColor6), ), ), const SizedBox(width: 12), Expanded( child: Row( mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.max, children: [ Flexible( child: TDText( currentLabel, maxLines: 1, overflow: TextOverflow.ellipsis, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, textColor: hasValue ? tdTheme.textColorPrimary : tdTheme.textColorPlaceholder, textAlign: TextAlign.end, ), ), const SizedBox(width: 4), SizedBox( width: 18, height: 18, child: onClear != null ? GestureDetector( onTap: onClear, child: Icon( Icons.close, size: 18, color: tdTheme.textColorPlaceholder, ), ) : Icon( Icons.chevron_right, size: 18, color: tdTheme.textColorPlaceholder, ), ), ], ), ), ], ), ), ); } // ── 0. 员工 ── Widget _buildEmployeePicker(AppColorsExtension colors) { return _pickerCard( label: _l10n.get('employee'), required: true, hasValue: _selEmployee != null, currentLabel: _selEmployee != null ? '${_selEmployee!.salNo}/${_selEmployee!.name}' : _l10n.get('pleaseSelect'), onTap: () async { final result = await showSearchablePicker( context, title: '${_l10n.get('select')}${_l10n.get('employee')}', searchHint: _l10n.get('search'), loader: (keyword, page) => widget.api.getEmployees(keyword: keyword, page: page, size: 20), labelBuilder: (e) => e.name.isEmpty ? e.salNo : '${e.salNo} ${e.name}', onRefresh: () => widget.api.clearRefCache(), ); if (result != null && mounted) { setState(() => _selEmployee = result); } }, onClear: _selEmployee != null ? () => setState(() => _selEmployee = null) : null, ); } // ── 1. 部门 ── Widget _buildDeptPicker(AppColorsExtension colors) { return _pickerCard( label: _l10n.get('dep'), required: false, hasValue: _dep.isNotEmpty, currentLabel: _dep.isNotEmpty ? (_depName.isNotEmpty ? '$_dep/$_depName' : _dep) : _l10n.get('pleaseSelect'), onTap: () async { final result = await showSearchablePicker( context, title: '${_l10n.get('select')}${_l10n.get('dep')}', searchHint: _l10n.get('search'), loader: (keyword, page) => widget.api.getDepartments(keyword: keyword, page: page, size: 20), labelBuilder: (d) => d.name.isEmpty ? d.dep : '${d.dep} ${d.name}', onRefresh: () => widget.api.clearRefCache(), ); if (result != null && mounted) { setState(() { _dep = result.dep; _depName = result.name; }); } }, onClear: _dep.isNotEmpty ? () => setState(() { _dep = ''; _depName = ''; }) : null, ); } // ── 2. 加班类型 ── Widget _buildJbTypeSelector(AppColorsExtension colors) { final selLabel = _jbTypeLabel(_jbType); return _pickerCard( label: _l10n.get('jbType'), required: true, hasValue: true, currentLabel: selLabel, onTap: () => _showJbTypePicker(), ); } void _showJbTypePicker() { FocusManager.instance.primaryFocus?.unfocus(); final labels = _jbTypeOptions.map((t) => _jbTypeLabel(t)).toList(); TDPicker.showMultiPicker( context, title: _l10n.get('selectJbType'), data: [labels], onConfirm: (selected) { if (selected.isNotEmpty) { final idx = selected.first is int ? selected.first as int : 0; if (idx >= 0 && idx < _jbTypeOptions.length) { setState(() => _jbType = _jbTypeOptions[idx]); } } Navigator.of(context).pop(); }, ); } String _jbTypeLabel(String type) { switch (type) { case 'WORKING_DAY': return _l10n.get('workingDay'); case 'REST_DAY': return _l10n.get('restDay'); case 'PUBLIC_HOLIDAY': return _l10n.get('publicHoliday'); case 'SPECIAL_HOLIDAY': return _l10n.get('specialHoliday'); case 'OTHER': return _l10n.get('other'); default: return type; } } // ── 4. 申请日期 ── Widget _buildJbDatePicker(AppColorsExtension colors) { return _datePickerCard( label: _l10n.get('applyDate'), value: _jbDate, hint: _l10n.get('pleaseSelect'), colors: colors, required: true, onPick: (d) => setState(() => _jbDate = d), onClear: _jbDate.isNotEmpty ? () => setState(() => _jbDate = '') : null, ); } // ── 4. 开始时间 ── Widget _buildStartTimePicker(AppColorsExtension colors) { return _datePickerCard( label: _l10n.get('startTime'), value: _startTime, hint: _l10n.get('pleaseSelect'), colors: colors, required: true, onPick: (d) { setState(() { _startTime = d; if (d.length >= 7) _attPeriod = d.substring(0, 7); _autoCalcHours(); }); }, onClear: _startTime.isNotEmpty ? () { setState(() { _startTime = ''; _autoCalcHours(); }); } : null, ); } // ── 5. 结束时间 ── Widget _buildEndTimePicker(AppColorsExtension colors) { return _datePickerCard( label: _l10n.get('endTime'), value: _endTime, hint: _l10n.get('pleaseSelect'), colors: colors, required: true, onPick: (d) { setState(() { _endTime = d; _autoCalcHours(); }); }, onClear: _endTime.isNotEmpty ? () { setState(() { _endTime = ''; _autoCalcHours(); }); } : null, ); } void _autoCalcHours() { if (_startTime.isEmpty || _endTime.isEmpty) return; try { final stParts = _startTime.split(' '); final etParts = _endTime.split(' '); if (stParts.length < 2 || etParts.length < 2) return; final stTime = stParts[1]; final etTime = etParts[1]; final st = _parseTime(stTime); final et = _parseTime(etTime); if (st == null || et == null) return; double hours = et - st; if (hours < 0) hours += 24.0; if (hours > 0) { // 按0.5半小时模式向上取整 final rounded = (hours * 2).ceil() / 2.0; _hoursCtrl.text = rounded.toStringAsFixed(1); } _autoCalcDays(); } catch (_) {} } void _autoCalcDays() { if (_startTime.isEmpty || _endTime.isEmpty) return; try { final stDateStr = _startTime.split(' ').first; final etDateStr = _endTime.split(' ').first; final stDate = DateTime.tryParse(stDateStr); final etDate = DateTime.tryParse(etDateStr); if (stDate == null || etDate == null) return; final diffDays = etDate.difference(stDate).inDays; if (diffDays < 0) return; // 按0.5半天模式向上取整 final ceilDays = (diffDays * 2).ceil() / 2.0; _daysCtrl.text = ceilDays.toStringAsFixed(1); } catch (_) {} } double? _parseTime(String time) { final parts = time.split(':'); if (parts.length < 2) return null; final h = double.tryParse(parts[0]); final m = double.tryParse(parts[1]); if (h == null || m == null) return null; return h + m / 60.0; } // ── 6. 加班时长 ── Widget _buildHoursInput(AppColorsExtension colors) { return _inputCard( label: _l10n.get('overtimeHours'), required: true, controller: _hoursCtrl, hintText: '>0', keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: [ FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,1}$')), ], focusNode: _hoursFocus, ); } // ── 7. 加班天数 ── Widget _buildDaysInput(AppColorsExtension colors) { return _inputCard( label: _l10n.get('overtimeDays'), required: false, controller: _daysCtrl, hintText: '0', keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: [ FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$')), ], focusNode: _daysFocus, ); } // ── 8. 考勤周期 ── Widget _buildAttPeriodInput(AppColorsExtension colors) { return _datePickerCard( label: _l10n.get('attPeriod'), value: _attPeriod, hint: _l10n.get('autoFromDate'), colors: colors, onPick: (d) => setState(() => _attPeriod = d), onClear: _attPeriod.isNotEmpty ? () => setState(() => _attPeriod = '') : null, ); } // ── 10. 事由 ── Widget _buildReasonInput(AppColorsExtension colors) { final tdTheme = TDTheme.of(context); return TDTextarea( controller: _reasonCtrl, focusNode: _reasonFocus, label: _l10n.get('overtimeReason'), hintText: _l10n.get('enterReason'), maxLines: 3, minLines: 1, maxLength: 1000, indicator: true, decoration: BoxDecoration( color: tdTheme.bgColorContainer, borderRadius: BorderRadius.circular(tdTheme.radiusDefault), border: Border.all(color: tdTheme.componentStrokeColor), ), onChanged: (_) => setState(() {}), ); } // ── 10. 补偿类型 ── Widget _buildCompensationTypeSelector(AppColorsExtension colors) { final selLabel = _compensationTypeLabel(_compensationType); return _pickerCard( label: _l10n.get('compensationType'), required: true, hasValue: true, currentLabel: selLabel, onTap: () => _showCompensationTypePicker(), ); } void _showCompensationTypePicker() { FocusManager.instance.primaryFocus?.unfocus(); final labels = _compensationTypeOptions .map((t) => _compensationTypeLabel(t)) .toList(); TDPicker.showMultiPicker( context, title: _l10n.get('selectCompensationType'), data: [labels], onConfirm: (selected) { if (selected.isNotEmpty) { final idx = selected.first is int ? selected.first as int : 0; if (idx >= 0 && idx < _compensationTypeOptions.length) { setState(() => _compensationType = _compensationTypeOptions[idx]); } } Navigator.of(context).pop(); }, ); } String _compensationTypeLabel(String type) { switch (type) { case 'OVERTIME_PAY': return _l10n.get('overtimePay'); case 'COMPENSATORY_LEAVE': return _l10n.get('compensatoryLeave'); case 'NO_COMPENSATION': return _l10n.get('noCompensation'); case 'OTHER': return _l10n.get('other'); default: return type; } } // ── 11. 折算补偿次数 ── Widget _buildCompCountInput(AppColorsExtension colors) { return _inputCard( label: _l10n.get('compensationCount'), required: false, controller: _compCountCtrl, hintText: '0', keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: [ FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,1}$')), ], focusNode: _compCountFocus, ); } // ── 12. 地点 ── Widget _buildAdrInput(AppColorsExtension colors) { return _inputCard( label: _l10n.get('adr'), required: false, controller: _adrCtrl, hintText: _l10n.get('enterAdr'), focusNode: _adrFocus, ); } // ── 13. 备注 ── Widget _buildRemarkInput(AppColorsExtension colors) { final tdTheme = TDTheme.of(context); return TDTextarea( controller: _remarkCtrl, focusNode: _remarkFocus, label: _l10n.get('remark'), hintText: _l10n.get('enterRemark'), maxLines: 3, minLines: 1, maxLength: 1000, indicator: true, decoration: BoxDecoration( color: tdTheme.bgColorContainer, borderRadius: BorderRadius.circular(tdTheme.radiusDefault), border: Border.all(color: tdTheme.componentStrokeColor), ), onChanged: (_) => setState(() {}), ); } // ── 操作按钮 ── Widget _buildActions() { return Row( children: [ Expanded( child: TDButton( text: _l10n.get('cancel'), size: TDButtonSize.large, type: TDButtonType.outline, shape: TDButtonShape.rectangle, theme: TDButtonTheme.defaultTheme, onTap: () => Navigator.pop(context), ), ), const SizedBox(width: 12), Expanded( child: TDButton( text: _isEdit ? _l10n.get('confirmEdit') : _l10n.get('add'), size: TDButtonSize.large, type: TDButtonType.fill, shape: TDButtonShape.rectangle, theme: TDButtonTheme.primary, onTap: _confirm, ), ), ], ); } // ── 日期选择器卡片 ── Widget _datePickerCard({ required String label, required String value, required String hint, required AppColorsExtension colors, required ValueChanged onPick, VoidCallback? onClear, bool required = false, }) { final tdTheme = TDTheme.of(context); final hasValue = value.isNotEmpty; return GestureDetector( onTap: () => _pickDateTime(label, onPick), child: Container( padding: const EdgeInsets.only( left: 16, right: 10, top: 12, bottom: 12, ), decoration: BoxDecoration( color: tdTheme.bgColorContainer, borderRadius: BorderRadius.circular(tdTheme.radiusDefault), border: Border.all(color: tdTheme.componentStrokeColor), ), child: Row( children: [ if (required) Padding( padding: const EdgeInsets.only(right: 2), child: TDText( '*', font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: TextStyle(color: tdTheme.errorColor6), ), ), TDText( label, maxLines: 1, overflow: TextOverflow.visible, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0), ), const SizedBox(width: 12), Expanded( child: Row( mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.max, children: [ Flexible( child: TDText( value.isEmpty ? hint : value, maxLines: 1, overflow: TextOverflow.ellipsis, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, textColor: value.isEmpty ? tdTheme.textColorPlaceholder : tdTheme.textColorPrimary, textAlign: TextAlign.end, ), ), const SizedBox(width: 4), SizedBox( width: 18, height: 18, child: hasValue ? GestureDetector( onTap: onClear, child: Icon( Icons.close, size: 18, color: tdTheme.textColorPlaceholder, ), ) : Icon( Icons.chevron_right, size: 18, color: tdTheme.textColorPlaceholder, ), ), ], ), ), ], ), ), ); } void _pickDateTime(String label, ValueChanged onPick) { final l10n = _l10n; final colors = Theme.of(context).extension()!; final now = DateTime.now(); final isDateOnly = label == _l10n.get('applyDate') || label == _l10n.get('attPeriod'); final isAttPeriod = label == _l10n.get('attPeriod'); FocusManager.instance.primaryFocus?.unfocus(); TDPicker.showDatePicker( context, title: l10n.get('selectDate'), backgroundColor: colors.bgCard, useYear: true, useMonth: true, useDay: !isAttPeriod, useHour: !isDateOnly, useMinute: !isDateOnly, useSecond: false, useWeekDay: false, dateStart: const [2020, 1, 1], dateEnd: [now.year + 1, 12, 31], initialDate: [now.year, now.month, now.day], onConfirm: (selected) { final year = selected['year']; final month = selected['month'].toString().padLeft(2, '0'); final day = selected['day'].toString().padLeft(2, '0'); final hour = selected['hour']?.toString().padLeft(2, '0') ?? '00'; final minute = selected['minute']?.toString().padLeft(2, '0') ?? '00'; if (isAttPeriod) { onPick('$year-$month'); } else if (isDateOnly) { onPick('$year-$month-$day'); } else { onPick('$year-$month-$day $hour:$minute'); } Navigator.of(context).pop(); }, ); } // ── 通用输入卡片 ── Widget _inputCard({ required String label, required bool required, required TextEditingController controller, required String hintText, TextInputType? keyboardType, List? inputFormatters, FocusNode? focusNode, }) { final tdTheme = TDTheme.of(context); final hasValue = controller.text.isNotEmpty; return Container( padding: const EdgeInsets.only(left: 16, right: 10, top: 12, bottom: 12), decoration: BoxDecoration( color: tdTheme.bgColorContainer, borderRadius: BorderRadius.circular(tdTheme.radiusDefault), border: Border.all(color: tdTheme.componentStrokeColor), ), child: Row( children: [ TDText( label, maxLines: 1, overflow: TextOverflow.visible, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0), ), if (required) Padding( padding: const EdgeInsets.only(left: 4), child: TDText( '*', font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: TextStyle(color: tdTheme.errorColor6), ), ), const SizedBox(width: 12), Expanded( child: Row( mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.max, children: [ Flexible( child: TextField( controller: controller, focusNode: focusNode, textAlign: TextAlign.end, keyboardType: keyboardType, inputFormatters: inputFormatters, style: TextStyle( fontSize: 16, color: tdTheme.textColorPrimary, ), decoration: InputDecoration( hintText: hintText, hintStyle: TextStyle( fontSize: 16, color: tdTheme.textColorPlaceholder, ), border: InputBorder.none, isDense: true, contentPadding: EdgeInsets.zero, ), onChanged: (_) => setState(() {}), ), ), const SizedBox(width: 4), SizedBox( width: 18, height: 18, child: hasValue ? GestureDetector( onTap: () { controller.clear(); setState(() {}); }, child: Icon( Icons.close, size: 18, color: tdTheme.textColorPlaceholder, ), ) : null, ), ], ), ), ], ), ); } }