|
|
@@ -0,0 +1,501 @@
|
|
|
+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<TextInputFormatter> 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<CostCategory> categories;
|
|
|
+ final List<String> 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<ExpenseDetailData?> show(
|
|
|
+ BuildContext context, {
|
|
|
+ required List<CostCategory> categories,
|
|
|
+ required List<String> unitKeys,
|
|
|
+ required AppLocalizations l10n,
|
|
|
+ NumberInputConfig quantityConfig = NumberInputConfig.money,
|
|
|
+ NumberInputConfig priceConfig = NumberInputConfig.money,
|
|
|
+ }) {
|
|
|
+ FocusScope.of(context).unfocus();
|
|
|
+ return Navigator.push<ExpenseDetailData>(
|
|
|
+ context,
|
|
|
+ TDSlidePopupRoute<ExpenseDetailData>(
|
|
|
+ slideTransitionFrom: SlideTransitionFrom.bottom,
|
|
|
+ isDismissible: false,
|
|
|
+ builder: (_) => ExpenseDetailDialog(
|
|
|
+ categories: categories,
|
|
|
+ unitKeys: unitKeys,
|
|
|
+ l10n: l10n,
|
|
|
+ quantityConfig: quantityConfig,
|
|
|
+ priceConfig: priceConfig,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ @override
|
|
|
+ State<ExpenseDetailDialog> createState() => _ExpenseDetailDialogState();
|
|
|
+}
|
|
|
+
|
|
|
+class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
|
|
|
+ late String _cat;
|
|
|
+ late String _unit;
|
|
|
+ late String _catLabel;
|
|
|
+ late String _unitLabel;
|
|
|
+ late TextEditingController _qtyCtrl;
|
|
|
+ late TextEditingController _priceCtrl;
|
|
|
+ late TextEditingController _remarkCtrl;
|
|
|
+
|
|
|
+ List<CostCategory> 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<AppColorsExtension>()!;
|
|
|
+ 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<String> labels,
|
|
|
+ required ValueChanged<int> 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,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|