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 '../../core/i18n/app_localizations.dart'; import '../../shared/widgets/action_bar.dart'; import '../../shared/widgets/form_section.dart'; import '../../shared/widgets/form_field_row.dart'; import '../../shared/widgets/nav_bar_config.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors_extension.dart'; import '../../core/constants/enums.dart'; import '../../core/data/mock_api_data.dart'; import 'widgets/expense_detail_dialog.dart'; class ExpenseApplicationApplyPage extends ConsumerStatefulWidget { final String? id; const ExpenseApplicationApplyPage({super.key, this.id}); @override ConsumerState createState() => _ExpenseApplicationApplyPageState(); } class _ExpenseApplicationApplyPageState extends ConsumerState { // ── 基本信息 ── String _urgency = Urgency.normal.value; final Set _expenseTypes = {}; bool _isTaxIncluded = false; final _purposeController = TextEditingController(); String _validUntil = ''; // ── 关联管控 ── String? _selectedProjectName; int? _selectedProjectId; String? _selectedSubjectName; int? _selectedSubjectId; double _availableBudget = 0; final _referenceNoController = TextEditingController(); // ── 费用明细 ── final List<_DetailItem> _details = []; int _detailIdCounter = 1; // ── 附件 ── final List _attachments = []; // ── 专用字段 ── String _estimatedStartDate = ''; String _estimatedEndDate = ''; bool _isOvernight = false; String? _transportType; String? _entertainmentTarget; String? _entertainmentLevel; int _guestCount = 1; int _companionCount = 0; String _entertainmentVenue = ''; String _meetingStartDate = ''; String _meetingEndDate = ''; String _meetingVenue = ''; @override void dispose() { _purposeController.dispose(); _referenceNoController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); ref .read(navBarConfigProvider.notifier) .update( NavBarConfig( title: l10n.get('expenseApplyRequest'), 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( padding: const EdgeInsets.all(16), child: Column( children: [ _buildBasicInfo(l10n), _buildTypeSpecificFields(l10n), _buildControlSection(l10n), const SizedBox(height: 16), _buildDetailsSection(l10n), const SizedBox(height: 16), _buildAttachmentSection(l10n), const SizedBox(height: 80), ], ), ), ), ), _buildBottomBar(l10n), ], ), ); } // ═══ 1. 基本信息 ═══ 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('applicant'), value: '张三', readOnly: true, showArrow: false, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('department'), value: '技术部', readOnly: true, showArrow: false, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('date'), value: _today(), readOnly: true, showArrow: false, ), const SizedBox(height: 16), _label(l10n.get('emergencyLevel'), required: true), const SizedBox(height: 8), _buildUrgencyRadio(l10n), const SizedBox(height: 16), _label(l10n.get('expenseType'), required: true), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: ExpenseType.values.map((opt) { final sel = _expenseTypes.contains(opt.value); return GestureDetector( onTap: () => setState(() { if (sel) { _expenseTypes.remove(opt.value); } else { _expenseTypes.add(opt.value); final hints = { 'travel': 'hintTravelFields', 'entertainment': 'hintEntertainmentFields', 'meeting': 'hintMeetingFields', }; final hintKey = hints[opt.value]; if (hintKey != null) { TDMessage.showMessage( context: context, content: l10n.get(hintKey), theme: MessageTheme.info, icon: true, marquee: MessageMarquee(speed: 3000, loop: 1, delay: 300), duration: 3000, ); } } }), child: TDTag( l10n.get(opt.labelKey), size: TDTagSize.large, theme: sel ? TDTagTheme.primary : TDTagTheme.defaultTheme, isOutline: !sel, ), ); }).toList(), ), const SizedBox(height: 16), _label(l10n.get('feeReason'), required: true), const SizedBox(height: 8), TDTextarea( controller: _purposeController, hintText: l10n.get('enterFeeReason'), maxLines: 4, minLines: 1, maxLength: 500, indicator: true, padding: EdgeInsets.zero, bordered: true, backgroundColor: colors.bgPage, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('validUntil'), value: _validUntil, hint: l10n.get('pleaseSelect'), onTap: () => _pickDate((d) => setState(() => _validUntil = d)), ), ], ); } Widget _buildUrgencyRadio(AppLocalizations l10n) { final colors = Theme.of(context).extension()!; return Row( children: Urgency.values.asMap().entries.map((e) { final sel = _urgency == e.value.value; final isCritical = e.value.value == Urgency.critical.value; final activeColor = isCritical ? colors.danger : colors.primary; return Padding( padding: EdgeInsets.only(right: e.key < 2 ? 24 : 0), child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => setState(() => _urgency = e.value.value), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 18, height: 18, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: sel ? activeColor : colors.textPlaceholder, width: 2, ), ), child: sel ? Center( child: Container( width: 8, height: 8, decoration: BoxDecoration( shape: BoxShape.circle, color: activeColor, ), ), ) : null, ), const SizedBox(width: 6), Text( l10n.get(e.value.labelKey), style: TextStyle( fontSize: AppFontSizes.subtitle, color: sel ? activeColor : colors.textPrimary, ), ), ], ), ), ); }).toList(), ); } // ═══ 2. 类型专用字段 ═══ Widget _buildTypeSpecificFields(AppLocalizations l10n) { final ws = []; if (_expenseTypes.contains('travel')) ws.add(_buildTravelFields(l10n)); if (_expenseTypes.contains('entertainment')) { if (ws.isNotEmpty) ws.add(const SizedBox(height: 16)); ws.add(_buildEntertainmentFields(l10n)); } if (_expenseTypes.contains('meeting')) { if (ws.isNotEmpty) ws.add(const SizedBox(height: 16)); ws.add(_buildMeetingFields(l10n)); } if (ws.isEmpty) return const SizedBox(height: 16); return Column( children: [const SizedBox(height: 16), ...ws, const SizedBox(height: 16)], ); } Widget _buildTravelFields(AppLocalizations l10n) { return FormSection( title: l10n.get('travelExpense'), leadingIcon: Icons.flight_outlined, children: [ FormFieldRow( label: l10n.get('estimatedStartDate'), value: _estimatedStartDate, hint: l10n.get('pleaseSelect'), required: true, onTap: () => _pickDate((d) => setState(() => _estimatedStartDate = d)), ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('estimatedEndDate'), value: _estimatedEndDate, hint: l10n.get('pleaseSelect'), required: true, onTap: () => _pickDate((d) { if (_estimatedStartDate.isNotEmpty && _estimatedStartDate.compareTo(d) > 0) { TDToast.showText( l10n.get('startDateNotAfterEndDate'), context: context, ); return; } setState(() => _estimatedEndDate = d); }), ), const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ _label(l10n.get('isOvernight')), TDSwitch( isOn: _isOvernight, onChanged: (v) { setState(() => _isOvernight = v); return true; }, ), ], ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('transportType'), value: _transportType != null ? l10n.get( TransportType.values .firstWhere((t) => t.value == _transportType) .labelKey, ) : null, hint: l10n.get('pleaseSelect'), onTap: () => _showEnumPicker( l10n.get('selectTransport'), TransportType.values, (v) => setState(() => _transportType = v), ), ), ], ); } Widget _buildEntertainmentFields(AppLocalizations l10n) { return FormSection( title: l10n.get('entertainmentExpense'), leadingIcon: Icons.people_outline, children: [ FormFieldRow( label: l10n.get('entertainmentTargetUnit'), value: _entertainmentTarget, hint: l10n.get('pleaseEnter'), required: true, onTap: () => _showTextInput( l10n.get('entertainmentTargetUnit'), (v) => setState(() => _entertainmentTarget = v), initialText: _entertainmentTarget ?? '', ), ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('entertainmentLevel'), value: _entertainmentLevel != null ? l10n.get( EntertainmentLevel.values .firstWhere((e) => e.value == _entertainmentLevel) .labelKey, ) : null, hint: l10n.get('pleaseSelect'), onTap: () => _showEnumPicker( l10n.get('selectEntertainmentLevel'), EntertainmentLevel.values, (v) => setState(() => _entertainmentLevel = v), ), ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('externalCount'), value: '$_guestCount', onTap: () => _showNumberInput( l10n.get('externalCount'), (v) => setState(() => _guestCount = v), initialValue: _guestCount, ), ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('internalCount'), value: '$_companionCount', onTap: () => _showNumberInput( l10n.get('internalCount'), (v) => setState(() => _companionCount = v), initialValue: _companionCount, ), ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('venue'), value: _entertainmentVenue, hint: l10n.get('pleaseEnterLocation'), onTap: () => _showTextInput( l10n.get('venue'), (v) => setState(() => _entertainmentVenue = v), initialText: _entertainmentVenue, ), ), ], ); } Widget _buildMeetingFields(AppLocalizations l10n) { return FormSection( title: l10n.get('meetingExpense'), leadingIcon: Icons.meeting_room_outlined, children: [ FormFieldRow( label: l10n.get('estimatedStartDate'), value: _meetingStartDate, hint: l10n.get('pleaseSelect'), required: true, onTap: () => _pickDate((d) => setState(() => _meetingStartDate = d)), ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('estimatedEndDate'), value: _meetingEndDate, hint: l10n.get('pleaseSelect'), required: true, onTap: () => _pickDate((d) { if (_meetingStartDate.isNotEmpty && _meetingStartDate.compareTo(d) > 0) { TDToast.showText( l10n.get('startDateNotAfterEndDate'), context: context, ); return; } setState(() => _meetingEndDate = d); }), ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('venue'), value: _meetingVenue, hint: l10n.get('pleaseEnterMeetingLocation'), onTap: () => _showTextInput( l10n.get('meetingLocation'), (v) => setState(() => _meetingVenue = v), initialText: _meetingVenue, ), ), ], ); } // ═══ 3. 关联管控 ═══ List> _buildCascadeData() { return mockProjects .map( (p) => { 'label': p.name, 'value': p.id.toString(), 'children': mockBudgetSubjects .map( (s) => { 'label': s.name, 'value': s.id.toString(), }, ) .toList(), }, ) .toList(); } Widget _buildControlSection(AppLocalizations l10n) { final cascadeLabel = _selectedProjectName != null && _selectedSubjectName != null ? '$_selectedProjectName / $_selectedSubjectName' : _selectedProjectName; return FormSection( title: l10n.get('relatedControl'), leadingIcon: Icons.link_outlined, children: [ FormFieldRow( label: l10n.get('relatedProject'), value: cascadeLabel, hint: l10n.get('selectProjectAndSubject'), required: true, onTap: () { _unfocus(); TDCascader.showMultiCascader( context, title: l10n.get('selectProjectAndSubject'), data: _buildCascadeData(), subTitles: [ l10n.get('project'), l10n.get('budgetSubject'), ], onClose: () => Navigator.of(context).pop(), onChange: (selected) { if (selected.length >= 2) { final pId = int.tryParse(selected[0].value ?? ''); final sId = int.tryParse(selected[1].value ?? ''); if (pId != null && sId != null) { setState(() { _selectedProjectId = pId; _selectedProjectName = selected[0].label; _selectedSubjectId = sId; _selectedSubjectName = selected[1].label; _availableBudget = getMockBudget(pId, sId); }); } } }, ); }, ), const SizedBox(height: 16), _buildBudgetRow(l10n), const SizedBox(height: 16), FormFieldRow( label: l10n.get('relatedContractNo'), value: _referenceNoController.text, hint: l10n.get('optional'), onTap: () => _showTextInput( l10n.get('relatedContractNo'), (v) => setState(() { _referenceNoController.text = v; _referenceNoController.selection = TextSelection.fromPosition( TextPosition(offset: v.length), ); }), initialText: _referenceNoController.text, ), ), ], ); } Widget _buildBudgetRow(AppLocalizations l10n) { final colors = Theme.of(context).extension()!; final over = _availableBudget <= 0; return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( l10n.get('availableBudget'), style: TextStyle( fontSize: AppFontSizes.subtitle, color: colors.textSecondary, ), ), Text( '¥${_availableBudget.toStringAsFixed(2)}', style: TextStyle( fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: over ? colors.danger : colors.amountPrimary, ), ), ], ); } // ═══ 4. 费用明细 ═══ Widget _buildDetailsSection(AppLocalizations l10n) { final colors = Theme.of(context).extension()!; return FormSection( title: l10n.get('expenseDetails'), leadingIcon: Icons.receipt_long_outlined, showAction: true, actionText: l10n.get('add'), onActionTap: _showDetailDialog, children: [ Padding( padding: const EdgeInsets.only(bottom: 12), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ _label(l10n.get('isTaxIncluded')), TDSwitch( isOn: _isTaxIncluded, onChanged: (v) { setState(() => _isTaxIncluded = v); return true; }, ), ], ), ), if (_details.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text( l10n.get('noDetailHint'), style: TextStyle( fontSize: AppFontSizes.subtitle, color: colors.textPlaceholder, ), ), ) else ..._details.asMap().entries.map((e) { final d = e.value; return Container( margin: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: colors.bgPage, borderRadius: BorderRadius.circular(8), ), child: Row( children: [ Expanded( flex: 3, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( d.categoryName, style: TextStyle( fontSize: AppFontSizes.subtitle, color: colors.textPrimary, ), ), if (d.remark.isNotEmpty) Text( d.remark, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), ], ), ), Text( '${d.quantity}×¥${d.unitPrice.toStringAsFixed(2)}', style: TextStyle( fontSize: AppFontSizes.body, color: colors.textSecondary, ), ), const SizedBox(width: 8), Text( '¥${d.amount.toStringAsFixed(2)}', style: TextStyle( fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: colors.amountPrimary, ), ), const SizedBox(width: 8), GestureDetector( onTap: () => setState(() => _details.removeAt(e.key)), child: Container( width: 24, height: 24, decoration: BoxDecoration( color: colors.primaryLight, shape: BoxShape.circle, ), child: Icon( Icons.close, size: 14, color: colors.primary700, ), ), ), ], ), ); }), const SizedBox(height: 8), Container( height: 36, padding: const EdgeInsets.symmetric(vertical: 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( l10n.get('total'), style: TextStyle( fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.textPrimary, ), ), Text( '¥${_totalAmount().toStringAsFixed(2)}', style: TextStyle( fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: colors.amountPrimary, ), ), ], ), ), if (_totalAmount() > _availableBudget) Padding( padding: const EdgeInsets.only(top: 8), child: Row( children: [ Icon(Icons.warning_amber, size: 14, color: colors.danger), const SizedBox(width: 6), Expanded( child: Text( l10n.get('overBudgetTriggerApproval'), style: TextStyle( fontSize: AppFontSizes.caption, color: colors.danger, ), ), ), ], ), ), ], ); } double _totalAmount() => _details.fold(0, (s, d) => s + d.amount); List get _availableDetailCategories { if (_expenseTypes.isEmpty) return mockCostCategories; final codes = _expenseTypes .expand((et) => expenseTypeCategories[et] ?? []) .toSet(); return mockCostCategories.where((c) => codes.contains(c.code)).toList(); } Future _showDetailDialog() async { final l10n = AppLocalizations.of(context); final result = await ExpenseDetailDialog.show( context, categories: _availableDetailCategories, unitKeys: unitOptions, l10n: l10n, ); if (result != null && mounted) { setState( () => _details.add( _DetailItem( id: _detailIdCounter++, category: result.category, categoryName: result.categoryName, quantity: result.quantity, unit: result.unit, unitPrice: result.unitPrice, amount: result.quantity * result.unitPrice, remark: result.remark, ), ), ); } } // ═══ 5. 附件上传 ═══ Widget _buildAttachmentSection(AppLocalizations l10n) { final colors = Theme.of(context).extension()!; return FormSection( title: l10n.get('attachmentUpload'), leadingIcon: Icons.attach_file_outlined, children: [ Text( l10n.get('maxAttachment'), style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textPlaceholder, ), ), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: [ ..._attachments.asMap().entries.map( (e) => Stack( clipBehavior: Clip.none, children: [ Container( width: 80, height: 80, decoration: BoxDecoration( color: colors.primaryLight, borderRadius: BorderRadius.circular(4), ), child: Center( child: Icon(Icons.image, color: colors.primary, size: 32), ), ), Positioned( right: -4, top: -4, child: GestureDetector( onTap: () => setState(() => _attachments.removeAt(e.key)), child: Container( width: 20, height: 20, decoration: BoxDecoration( color: colors.danger, shape: BoxShape.circle, ), child: const Icon( Icons.close, size: 12, color: Colors.white, ), ), ), ), ], ), ), if (_attachments.length < 9) GestureDetector( onTap: () { // Mock: add attachment (支持图片/PDF/Word/Excel) final exts = ['.jpg', '.png', '.pdf', '.docx', '.xlsx']; setState( () => _attachments.add( '附件_${DateTime.now().millisecondsSinceEpoch}${exts[_attachments.length % exts.length]}', ), ); TDToast.showText( l10n.get('mockAttachmentAdded'), context: context, ); }, child: Container( width: 80, height: 80, decoration: BoxDecoration( color: colors.bgPage, borderRadius: BorderRadius.circular(4), border: Border.all(color: colors.border, width: 1), ), child: Center( child: Icon( Icons.add, size: 24, color: colors.textPlaceholder, ), ), ), ), ], ), ], ); } // ═══ 6. 底部操作栏 ═══ Widget _buildBottomBar(AppLocalizations l10n) { final isDraft = widget.id != null; return ActionBar( leftLabel: isDraft ? l10n.get('reset') : null, centerLabel: l10n.get('saveDraft'), rightLabel: l10n.get('submitApproval'), showLeft: isDraft, onLeftTap: isDraft ? () => _showConfirmDialog( l10n.get('confirmReset'), l10n.get('resetWarning'), l10n.get('cancel'), l10n.get('confirmReset'), _resetAll, ) : null, onCenterTap: () { TDToast.showSuccess(l10n.get('draftSavedToast'), context: context); context.pop(); }, onRightTap: () { final err = _validate(l10n); if (err.isNotEmpty) { TDToast.showText(err.first, context: context); return; } TDToast.showSuccess( l10n.get('submittedAwaitingApproval'), context: context, ); context.pop(); }, ); } List _validate(AppLocalizations l10n) { final e = []; if (_expenseTypes.isEmpty) e.add(l10n.get('selectAtLeastOneExpenseType')); if (_purposeController.text.trim().isEmpty) { e.add(l10n.get('enterFeeReason')); } if (_selectedProjectId == null) e.add(l10n.get('selectProject')); if (_selectedSubjectId == null) e.add(l10n.get('selectSubject')); if (_details.isEmpty) e.add(l10n.get('addAtLeastOneDetail')); if (_expenseTypes.contains('travel')) { if (_estimatedStartDate.isEmpty) { e.add(l10n.get('selectEstimatedStartDate')); } if (_estimatedEndDate.isEmpty) { e.add(l10n.get('selectEstimatedEndDate')); } if (_estimatedStartDate.isNotEmpty && _estimatedEndDate.isNotEmpty && _estimatedStartDate.compareTo(_estimatedEndDate) > 0) { e.add(l10n.get('startDateNotAfterEndDate')); } } if (_expenseTypes.contains('entertainment')) { if (_entertainmentTarget == null || _entertainmentTarget!.isEmpty) { e.add(l10n.get('entertainmentTargetUnit')); } if (_companionCount > _guestCount) { e.add(l10n.get('companionNotExceedGuest')); } } if (_expenseTypes.contains('meeting')) { if (_meetingStartDate.isEmpty) { e.add(l10n.get('selectEstimatedStartDate')); } if (_meetingEndDate.isEmpty) { e.add(l10n.get('selectEstimatedEndDate')); } if (_meetingStartDate.isNotEmpty && _meetingEndDate.isNotEmpty && _meetingStartDate.compareTo(_meetingEndDate) > 0) { e.add(l10n.get('startDateNotAfterEndDate')); } } return e; } void _resetAll() => setState(() { _purposeController.clear(); _expenseTypes.clear(); _urgency = Urgency.normal.value; _isTaxIncluded = false; _validUntil = ''; _selectedProjectId = null; _selectedProjectName = null; _selectedSubjectId = null; _selectedSubjectName = null; _availableBudget = 0; _referenceNoController.clear(); _details.clear(); _attachments.clear(); _estimatedStartDate = ''; _estimatedEndDate = ''; _isOvernight = false; _transportType = null; _entertainmentTarget = null; _entertainmentLevel = null; _guestCount = 1; _companionCount = 0; _entertainmentVenue = ''; _meetingStartDate = ''; _meetingEndDate = ''; _meetingVenue = ''; }); bool _hasUnsaved() => _purposeController.text.isNotEmpty || _expenseTypes.isNotEmpty || _details.isNotEmpty || _attachments.isNotEmpty || _selectedProjectId != null || _estimatedStartDate.isNotEmpty || _estimatedEndDate.isNotEmpty || _entertainmentTarget != null || _meetingStartDate.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()!; 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 _showEnumPicker( String title, List entries, Function(String) onPick, ) { _showListPicker( title, entries.map((e) => AppLocalizations.of(context).get(e.labelKey)).toList(), (label) { final entry = entries.firstWhere( (e) => AppLocalizations.of(context).get(e.labelKey) == label, ); onPick(entry.value); }, ); } void _showListPicker( String title, List items, Function(String) onPick, ) { _unfocus(); final colors = Theme.of(context).extension()!; TDPicker.showMultiPicker( context, title: title, backgroundColor: colors.bgCard, data: [items], onConfirm: (selected) { if (selected.isNotEmpty && selected[0] is int) { final idx = selected[0] as int; if (idx >= 0 && idx < items.length) { Navigator.of(context).pop(); onPick(items[idx]); } } }, ); } 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 _showNumberInput( String title, Function(int) onConfirm, { int initialValue = 0, }) { _unfocus(); final l10n = AppLocalizations.of(context); final c = TextEditingController( text: initialValue > 0 ? '$initialValue' : '', ); showGeneralDialog( context: context, pageBuilder: (ctx, animation, secondaryAnimation) => TDInputDialog( textEditingController: c, title: title, hintText: l10n.get('enterNumber'), leftBtn: TDDialogButtonOptions( title: l10n.get('cancel'), action: () => Navigator.pop(ctx), ), rightBtn: TDDialogButtonOptions( title: l10n.get('confirm'), action: () { onConfirm(int.tryParse(c.text) ?? 0); Navigator.pop(ctx); }, ), ), ); } void _pickDate(Function(String) onPick) { _unfocus(); final l10n = AppLocalizations.of(context); final colors = Theme.of(context).extension()!; final theme = Theme.of(context); final now = DateTime.now(); showModalBottomSheet( context: context, backgroundColor: Colors.transparent, builder: (ctx) => Theme( data: theme, child: TDDatePicker( title: l10n.get('selectDate'), backgroundColor: colors.bgCard, model: DatePickerModel( useYear: true, useMonth: true, useDay: true, useHour: false, useMinute: false, useSecond: false, useWeekDay: false, dateStart: [2020, 1, 1], dateEnd: [now.year + 1, 12, 31], dateInitial: [now.year, now.month, now.day], ), onConfirm: (selected) { onPick( '${selected['year']}-${selected['month']!.toString().padLeft(2, '0')}-${selected['day']!.toString().padLeft(2, '0')}', ); Navigator.of(ctx).pop(); }, onCancel: (_) => Navigator.of(ctx).pop(), ), ), ); } 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, ), ), ], ), ); } String _today() { final n = DateTime.now(); return '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}'; } } class _DetailItem { final int id; final String category; final String categoryName; final double quantity; final String unit; final double unitPrice; final double amount; final String remark; const _DetailItem({ required this.id, required this.category, required this.categoryName, required this.quantity, required this.unit, required this.unitPrice, required this.amount, required this.remark, }); }