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/theme/app_colors.dart'; import '../../../core/theme/app_colors_extension.dart'; import '../../../core/data/mock_api_data.dart'; /// 数字输入控制配置。 /// /// 封装键盘类型与输入过滤器,调用方控制: /// - 是否允许小数 /// - 最多几位小数 /// - 对应的键盘类型 class NumberInputConfig { final bool allowDecimal; final int maxDecimalPlaces; const NumberInputConfig({ this.allowDecimal = false, this.maxDecimalPlaces = 2, }); /// 键盘类型:小数用带小数点的数字键盘,整数用纯数字键盘。 TextInputType get keyboardType => allowDecimal ? const TextInputType.numberWithOptions(decimal: true) : TextInputType.number; /// 输入过滤器。 List get inputFormatters => [ FilteringTextInputFormatter.allow( allowDecimal ? RegExp(r'^\d*\.?\d{0,' + maxDecimalPlaces.toString() + r'}$') : RegExp(r'^\d*$'), ), ]; /// 仅整数。 static const integer = NumberInputConfig(); /// 最多 2 位小数(如数量)。 static const qty = NumberInputConfig(allowDecimal: true); /// 最多 2 位小数(如金额/单价)。 static const money = NumberInputConfig(allowDecimal: true); } /// 费用明细输入数据。 class ExpenseDetailData { final String category; final String categoryName; final double quantity; final String unit; final double unitPrice; final String remark; const ExpenseDetailData({ required this.category, required this.categoryName, required this.quantity, required this.unit, required this.unitPrice, required this.remark, }); } /// 添加费用明细弹窗。 /// /// 使用 [TDSlidePopupRoute] 从底部滑出,卡片化展示表单字段。 /// [quantityConfig] 与 [priceConfig] 控制各自输入的数字格式。 /// const NumberInputConfig(allowDecimal: true, maxDecimalPlaces: 3) // 允许小数,最多3位小数 class ExpenseDetailDialog extends StatefulWidget { final List categories; final List unitKeys; final AppLocalizations l10n; final NumberInputConfig quantityConfig; final NumberInputConfig priceConfig; const ExpenseDetailDialog({ super.key, required this.categories, required this.unitKeys, required this.l10n, this.quantityConfig = NumberInputConfig.qty, this.priceConfig = NumberInputConfig.money, }); /// 显示弹窗,返回 [ExpenseDetailData] 或 `null`(取消时)。 static Future show( BuildContext context, { required List categories, required List unitKeys, required AppLocalizations l10n, NumberInputConfig quantityConfig = NumberInputConfig.money, NumberInputConfig priceConfig = NumberInputConfig.money, }) { FocusScope.of(context).unfocus(); return Navigator.push( context, TDSlidePopupRoute( slideTransitionFrom: SlideTransitionFrom.bottom, isDismissible: false, builder: (_) => ExpenseDetailDialog( categories: categories, unitKeys: unitKeys, l10n: l10n, quantityConfig: quantityConfig, priceConfig: priceConfig, ), ), ); } @override State createState() => _ExpenseDetailDialogState(); } class _ExpenseDetailDialogState extends State { late String _cat; late String _unit; late String _catLabel; late String _unitLabel; late TextEditingController _qtyCtrl; late TextEditingController _priceCtrl; late TextEditingController _remarkCtrl; List get _cats => widget.categories; AppLocalizations get _l10n => widget.l10n; @override void initState() { super.initState(); _cat = _cats.isNotEmpty ? _cats.first.code : 'other'; _unit = widget.unitKeys.first; _catLabel = _l10n.get(_cats.firstWhere((c) => c.code == _cat).nameKey); _unitLabel = _l10n.get(_unit); _qtyCtrl = TextEditingController(text: '1'); _priceCtrl = TextEditingController(); _remarkCtrl = TextEditingController(); } @override void dispose() { _qtyCtrl.dispose(); _priceCtrl.dispose(); _remarkCtrl.dispose(); super.dispose(); } void _confirm() { final q = double.tryParse(_qtyCtrl.text) ?? 0; final p = double.tryParse(_priceCtrl.text) ?? 0; if (q <= 0 || p <= 0) { TDToast.showText(_l10n.get('quantityPricePositive'), context: context); return; } Navigator.pop( context, ExpenseDetailData( category: _cat, categoryName: _l10n.get( _cats.firstWhere((c) => c.code == _cat).nameKey, ), quantity: q, unit: _unit, unitPrice: p, remark: _remarkCtrl.text, ), ); } @override Widget build(BuildContext context) { final colors = Theme.of(context).extension()!; final bottomInset = MediaQuery.of(context).viewInsets.bottom; return SafeArea( child: Padding( padding: EdgeInsets.only(bottom: bottomInset), 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: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildCategoryCard(colors), const SizedBox(height: 12), _buildQuantityCard(), const SizedBox(height: 12), _buildUnitCard(colors), const SizedBox(height: 12), _buildPriceCard(), const SizedBox(height: 12), _buildRemarkCard(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: 28), Expanded( child: Center( child: Text( _l10n.get('addExpenseDetail'), 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), ), ), ], ), ), ], ); } // ── 选择卡片(点击唤起 TDPicker.showMultiPicker,右侧箭头) ── Widget _pickerCard({ required String label, required bool required, required String currentLabel, required List labels, required ValueChanged onSelected, required AppColorsExtension colors, }) { final tdTheme = TDTheme.of(context); return GestureDetector( onTap: () { TDPicker.showMultiPicker( context, title: label, backgroundColor: colors.bgCard, data: [labels.map((e) => e).toList()], onConfirm: (selected) { if (selected.isNotEmpty && selected[0] is int) { final idx = selected[0] as int; if (idx >= 0 && idx < labels.length) { Navigator.of(context).pop(); onSelected(idx); } } }, ); }, child: Container( padding: const EdgeInsets.only( left: 16, right: 16, 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.0), 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: tdTheme.textColorPrimary, textAlign: TextAlign.end, ), ), const SizedBox(width: 4), Icon( Icons.chevron_right, size: 18, color: tdTheme.textColorPlaceholder, ), ], ), ), ], ), ), ); } // ── 费用类别 ── Widget _buildCategoryCard(AppColorsExtension colors) { return _pickerCard( label: _l10n.get('expenseCategory'), required: true, currentLabel: _catLabel, labels: _cats.map((c) => _l10n.get(c.nameKey)).toList(), colors: colors, onSelected: (idx) => setState(() { _cat = _cats[idx].code; _catLabel = _l10n.get(_cats[idx].nameKey); }), ); } // ── 数量(cardStyle,独占一行) ── Widget _buildQuantityCard() { final screenWidth = MediaQuery.of(context).size.width; return TDInput( type: TDInputType.cardStyle, cardStyle: TDCardStyle.topText, width: screenWidth - 32, leftLabel: _l10n.get('quantity'), required: true, controller: _qtyCtrl, hintText: '>0', contentAlignment: TextAlign.center, inputType: widget.quantityConfig.keyboardType, inputFormatters: widget.quantityConfig.inputFormatters, showBottomDivider: false, onChanged: (_) => setState(() {}), onClearTap: () { _qtyCtrl.clear(); setState(() {}); }, ); } // ── 单位 ── Widget _buildUnitCard(AppColorsExtension colors) { return _pickerCard( label: _l10n.get('unit'), required: false, currentLabel: _unitLabel, labels: widget.unitKeys.map((u) => _l10n.get(u)).toList(), colors: colors, onSelected: (idx) => setState(() { _unit = widget.unitKeys[idx]; _unitLabel = _l10n.get(_unit); }), ); } // ── 单价(cardStyle) ── Widget _buildPriceCard() { final screenWidth = MediaQuery.of(context).size.width; return TDInput( type: TDInputType.cardStyle, cardStyle: TDCardStyle.topText, width: screenWidth - 32, leftLabel: _l10n.get('unitPrice'), required: true, controller: _priceCtrl, hintText: '>0', contentAlignment: TextAlign.center, inputType: widget.priceConfig.keyboardType, inputFormatters: widget.priceConfig.inputFormatters, showBottomDivider: false, onChanged: (_) => setState(() {}), onClearTap: () { _priceCtrl.clear(); setState(() {}); }, ); } // ── 备注 ── Widget _buildRemarkCard(AppColorsExtension colors) { final tdTheme = TDTheme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(left: 4), child: TDText( _l10n.get('detailRemark'), font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0), ), ), const SizedBox(height: 8), TDTextarea( controller: _remarkCtrl, hintText: _l10n.get('optional'), maxLines: 3, minLines: 1, maxLength: 200, indicator: true, padding: EdgeInsets.zero, bordered: true, inputType: TextInputType.multiline, backgroundColor: tdTheme.bgColorContainer, ), ], ); } // ── 操作按钮 ── 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: _l10n.get('confirm'), size: TDButtonSize.large, type: TDButtonType.fill, shape: TDButtonShape.rectangle, theme: TDButtonTheme.primary, onTap: _confirm, ), ), ], ); } }