expense_detail_dialog.dart 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter/services.dart';
  3. import 'package:tdesign_flutter/tdesign_flutter.dart';
  4. import '../../../core/i18n/app_localizations.dart';
  5. import '../../../core/theme/app_colors.dart';
  6. import '../../../core/theme/app_colors_extension.dart';
  7. import '../../../core/data/mock_api_data.dart';
  8. /// 数字输入控制配置。
  9. ///
  10. /// 封装键盘类型与输入过滤器,调用方控制:
  11. /// - 是否允许小数
  12. /// - 最多几位小数
  13. /// - 对应的键盘类型
  14. class NumberInputConfig {
  15. final bool allowDecimal;
  16. final int maxDecimalPlaces;
  17. const NumberInputConfig({
  18. this.allowDecimal = false,
  19. this.maxDecimalPlaces = 2,
  20. });
  21. /// 键盘类型:小数用带小数点的数字键盘,整数用纯数字键盘。
  22. TextInputType get keyboardType => allowDecimal
  23. ? const TextInputType.numberWithOptions(decimal: true)
  24. : TextInputType.number;
  25. /// 输入过滤器。
  26. List<TextInputFormatter> get inputFormatters => [
  27. FilteringTextInputFormatter.allow(
  28. allowDecimal
  29. ? RegExp(r'^\d*\.?\d{0,' + maxDecimalPlaces.toString() + r'}$')
  30. : RegExp(r'^\d*$'),
  31. ),
  32. ];
  33. /// 仅整数。
  34. static const integer = NumberInputConfig();
  35. /// 最多 2 位小数(如数量)。
  36. static const qty = NumberInputConfig(allowDecimal: true);
  37. /// 最多 2 位小数(如金额/单价)。
  38. static const money = NumberInputConfig(allowDecimal: true);
  39. }
  40. /// 费用明细输入数据。
  41. class ExpenseDetailData {
  42. final String category;
  43. final String categoryName;
  44. final double quantity;
  45. final String unit;
  46. final double unitPrice;
  47. final String remark;
  48. const ExpenseDetailData({
  49. required this.category,
  50. required this.categoryName,
  51. required this.quantity,
  52. required this.unit,
  53. required this.unitPrice,
  54. required this.remark,
  55. });
  56. }
  57. /// 添加费用明细弹窗。
  58. ///
  59. /// 使用 [TDSlidePopupRoute] 从底部滑出,卡片化展示表单字段。
  60. /// [quantityConfig] 与 [priceConfig] 控制各自输入的数字格式。
  61. /// const NumberInputConfig(allowDecimal: true, maxDecimalPlaces: 3) // 允许小数,最多3位小数
  62. class ExpenseDetailDialog extends StatefulWidget {
  63. final List<CostCategory> categories;
  64. final List<String> unitKeys;
  65. final AppLocalizations l10n;
  66. final NumberInputConfig quantityConfig;
  67. final NumberInputConfig priceConfig;
  68. const ExpenseDetailDialog({
  69. super.key,
  70. required this.categories,
  71. required this.unitKeys,
  72. required this.l10n,
  73. this.quantityConfig = NumberInputConfig.qty,
  74. this.priceConfig = NumberInputConfig.money,
  75. });
  76. /// 显示弹窗,返回 [ExpenseDetailData] 或 `null`(取消时)。
  77. static Future<ExpenseDetailData?> show(
  78. BuildContext context, {
  79. required List<CostCategory> categories,
  80. required List<String> unitKeys,
  81. required AppLocalizations l10n,
  82. NumberInputConfig quantityConfig = NumberInputConfig.money,
  83. NumberInputConfig priceConfig = NumberInputConfig.money,
  84. }) {
  85. FocusScope.of(context).unfocus();
  86. return Navigator.push<ExpenseDetailData>(
  87. context,
  88. TDSlidePopupRoute<ExpenseDetailData>(
  89. slideTransitionFrom: SlideTransitionFrom.bottom,
  90. isDismissible: false,
  91. builder: (_) => ExpenseDetailDialog(
  92. categories: categories,
  93. unitKeys: unitKeys,
  94. l10n: l10n,
  95. quantityConfig: quantityConfig,
  96. priceConfig: priceConfig,
  97. ),
  98. ),
  99. );
  100. }
  101. @override
  102. State<ExpenseDetailDialog> createState() => _ExpenseDetailDialogState();
  103. }
  104. class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
  105. late String _cat;
  106. late String _unit;
  107. late String _catLabel;
  108. late String _unitLabel;
  109. late TextEditingController _qtyCtrl;
  110. late TextEditingController _priceCtrl;
  111. late TextEditingController _remarkCtrl;
  112. List<CostCategory> get _cats => widget.categories;
  113. AppLocalizations get _l10n => widget.l10n;
  114. @override
  115. void initState() {
  116. super.initState();
  117. _cat = _cats.isNotEmpty ? _cats.first.code : 'other';
  118. _unit = widget.unitKeys.first;
  119. _catLabel = _l10n.get(_cats.firstWhere((c) => c.code == _cat).nameKey);
  120. _unitLabel = _l10n.get(_unit);
  121. _qtyCtrl = TextEditingController(text: '1');
  122. _priceCtrl = TextEditingController();
  123. _remarkCtrl = TextEditingController();
  124. }
  125. @override
  126. void dispose() {
  127. _qtyCtrl.dispose();
  128. _priceCtrl.dispose();
  129. _remarkCtrl.dispose();
  130. super.dispose();
  131. }
  132. void _confirm() {
  133. final q = double.tryParse(_qtyCtrl.text) ?? 0;
  134. final p = double.tryParse(_priceCtrl.text) ?? 0;
  135. if (q <= 0 || p <= 0) {
  136. TDToast.showText(_l10n.get('quantityPricePositive'), context: context);
  137. return;
  138. }
  139. Navigator.pop(
  140. context,
  141. ExpenseDetailData(
  142. category: _cat,
  143. categoryName: _l10n.get(
  144. _cats.firstWhere((c) => c.code == _cat).nameKey,
  145. ),
  146. quantity: q,
  147. unit: _unit,
  148. unitPrice: p,
  149. remark: _remarkCtrl.text,
  150. ),
  151. );
  152. }
  153. @override
  154. Widget build(BuildContext context) {
  155. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  156. final bottomInset = MediaQuery.of(context).viewInsets.bottom;
  157. return SafeArea(
  158. child: Padding(
  159. padding: EdgeInsets.only(bottom: bottomInset),
  160. child: Container(
  161. decoration: BoxDecoration(
  162. color: colors.bgPage,
  163. borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
  164. ),
  165. child: Column(
  166. mainAxisSize: MainAxisSize.min,
  167. crossAxisAlignment: CrossAxisAlignment.stretch,
  168. children: [
  169. _buildHeader(colors),
  170. Flexible(
  171. child: SingleChildScrollView(
  172. padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
  173. child: Column(
  174. mainAxisSize: MainAxisSize.min,
  175. crossAxisAlignment: CrossAxisAlignment.stretch,
  176. children: [
  177. _buildCategoryCard(colors),
  178. const SizedBox(height: 12),
  179. _buildQuantityCard(),
  180. const SizedBox(height: 12),
  181. _buildUnitCard(colors),
  182. const SizedBox(height: 12),
  183. _buildPriceCard(),
  184. const SizedBox(height: 12),
  185. _buildRemarkCard(colors),
  186. ],
  187. ),
  188. ),
  189. ),
  190. Container(
  191. padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
  192. decoration: BoxDecoration(
  193. color: colors.bgCard,
  194. border: Border(
  195. top: BorderSide(color: colors.border, width: 0.5),
  196. ),
  197. ),
  198. child: _buildActions(),
  199. ),
  200. ],
  201. ),
  202. ),
  203. ),
  204. );
  205. }
  206. // ── 标题栏(居中标题 + 右侧关闭) ──
  207. Widget _buildHeader(AppColorsExtension colors) {
  208. return Column(
  209. mainAxisSize: MainAxisSize.min,
  210. children: [
  211. Center(
  212. child: Container(
  213. margin: const EdgeInsets.only(top: 8, bottom: 4),
  214. width: 36,
  215. height: 4,
  216. decoration: BoxDecoration(
  217. color: colors.border,
  218. borderRadius: BorderRadius.circular(2),
  219. ),
  220. ),
  221. ),
  222. Padding(
  223. padding: const EdgeInsets.fromLTRB(20, 8, 12, 16),
  224. child: Row(
  225. children: [
  226. const SizedBox(width: 28),
  227. Expanded(
  228. child: Center(
  229. child: Text(
  230. _l10n.get('addExpenseDetail'),
  231. style: TextStyle(
  232. fontSize: AppFontSizes.title,
  233. fontWeight: FontWeight.w600,
  234. color: colors.textPrimary,
  235. ),
  236. ),
  237. ),
  238. ),
  239. GestureDetector(
  240. onTap: () => Navigator.pop(context),
  241. child: Padding(
  242. padding: const EdgeInsets.all(4),
  243. child: Icon(Icons.close, size: 20, color: colors.textSecondary),
  244. ),
  245. ),
  246. ],
  247. ),
  248. ),
  249. ],
  250. );
  251. }
  252. // ── 选择卡片(点击唤起 TDPicker.showMultiPicker,右侧箭头) ──
  253. Widget _pickerCard({
  254. required String label,
  255. required bool required,
  256. required String currentLabel,
  257. required List<String> labels,
  258. required ValueChanged<int> onSelected,
  259. required AppColorsExtension colors,
  260. }) {
  261. final tdTheme = TDTheme.of(context);
  262. return GestureDetector(
  263. onTap: () {
  264. TDPicker.showMultiPicker(
  265. context,
  266. title: label,
  267. backgroundColor: colors.bgCard,
  268. data: [labels.map((e) => e).toList()],
  269. onConfirm: (selected) {
  270. if (selected.isNotEmpty && selected[0] is int) {
  271. final idx = selected[0] as int;
  272. if (idx >= 0 && idx < labels.length) {
  273. Navigator.of(context).pop();
  274. onSelected(idx);
  275. }
  276. }
  277. },
  278. );
  279. },
  280. child: Container(
  281. padding: const EdgeInsets.only(
  282. left: 16, right: 16, top: 12, bottom: 12,
  283. ),
  284. decoration: BoxDecoration(
  285. color: tdTheme.bgColorContainer,
  286. borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
  287. border: Border.all(color: tdTheme.componentStrokeColor),
  288. ),
  289. child: Row(
  290. children: [
  291. TDText(
  292. label,
  293. maxLines: 1,
  294. overflow: TextOverflow.visible,
  295. font: tdTheme.fontBodyLarge,
  296. fontWeight: FontWeight.w400,
  297. style: const TextStyle(letterSpacing: 0),
  298. ),
  299. if (required)
  300. Padding(
  301. padding: const EdgeInsets.only(left: 4.0),
  302. child: TDText(
  303. '*',
  304. font: tdTheme.fontBodyLarge,
  305. fontWeight: FontWeight.w400,
  306. style: TextStyle(color: tdTheme.errorColor6),
  307. ),
  308. ),
  309. const SizedBox(width: 12),
  310. Expanded(
  311. child: Row(
  312. mainAxisAlignment: MainAxisAlignment.end,
  313. mainAxisSize: MainAxisSize.max,
  314. children: [
  315. Flexible(
  316. child: TDText(
  317. currentLabel,
  318. maxLines: 1,
  319. overflow: TextOverflow.ellipsis,
  320. font: tdTheme.fontBodyLarge,
  321. fontWeight: FontWeight.w400,
  322. textColor: tdTheme.textColorPrimary,
  323. textAlign: TextAlign.end,
  324. ),
  325. ),
  326. const SizedBox(width: 4),
  327. Icon(
  328. Icons.chevron_right,
  329. size: 18,
  330. color: tdTheme.textColorPlaceholder,
  331. ),
  332. ],
  333. ),
  334. ),
  335. ],
  336. ),
  337. ),
  338. );
  339. }
  340. // ── 费用类别 ──
  341. Widget _buildCategoryCard(AppColorsExtension colors) {
  342. return _pickerCard(
  343. label: _l10n.get('expenseCategory'),
  344. required: true,
  345. currentLabel: _catLabel,
  346. labels: _cats.map((c) => _l10n.get(c.nameKey)).toList(),
  347. colors: colors,
  348. onSelected: (idx) => setState(() {
  349. _cat = _cats[idx].code;
  350. _catLabel = _l10n.get(_cats[idx].nameKey);
  351. }),
  352. );
  353. }
  354. // ── 数量(cardStyle,独占一行) ──
  355. Widget _buildQuantityCard() {
  356. final screenWidth = MediaQuery.of(context).size.width;
  357. return TDInput(
  358. type: TDInputType.cardStyle,
  359. cardStyle: TDCardStyle.topText,
  360. width: screenWidth - 32,
  361. leftLabel: _l10n.get('quantity'),
  362. required: true,
  363. controller: _qtyCtrl,
  364. hintText: '>0',
  365. contentAlignment: TextAlign.center,
  366. inputType: widget.quantityConfig.keyboardType,
  367. inputFormatters: widget.quantityConfig.inputFormatters,
  368. showBottomDivider: false,
  369. onChanged: (_) => setState(() {}),
  370. onClearTap: () {
  371. _qtyCtrl.clear();
  372. setState(() {});
  373. },
  374. );
  375. }
  376. // ── 单位 ──
  377. Widget _buildUnitCard(AppColorsExtension colors) {
  378. return _pickerCard(
  379. label: _l10n.get('unit'),
  380. required: false,
  381. currentLabel: _unitLabel,
  382. labels: widget.unitKeys.map((u) => _l10n.get(u)).toList(),
  383. colors: colors,
  384. onSelected: (idx) => setState(() {
  385. _unit = widget.unitKeys[idx];
  386. _unitLabel = _l10n.get(_unit);
  387. }),
  388. );
  389. }
  390. // ── 单价(cardStyle) ──
  391. Widget _buildPriceCard() {
  392. final screenWidth = MediaQuery.of(context).size.width;
  393. return TDInput(
  394. type: TDInputType.cardStyle,
  395. cardStyle: TDCardStyle.topText,
  396. width: screenWidth - 32,
  397. leftLabel: _l10n.get('unitPrice'),
  398. required: true,
  399. controller: _priceCtrl,
  400. hintText: '>0',
  401. contentAlignment: TextAlign.center,
  402. inputType: widget.priceConfig.keyboardType,
  403. inputFormatters: widget.priceConfig.inputFormatters,
  404. showBottomDivider: false,
  405. onChanged: (_) => setState(() {}),
  406. onClearTap: () {
  407. _priceCtrl.clear();
  408. setState(() {});
  409. },
  410. );
  411. }
  412. // ── 备注 ──
  413. Widget _buildRemarkCard(AppColorsExtension colors) {
  414. final tdTheme = TDTheme.of(context);
  415. return Column(
  416. crossAxisAlignment: CrossAxisAlignment.start,
  417. children: [
  418. Padding(
  419. padding: const EdgeInsets.only(left: 4),
  420. child: TDText(
  421. _l10n.get('detailRemark'),
  422. font: tdTheme.fontBodyLarge,
  423. fontWeight: FontWeight.w400,
  424. style: const TextStyle(letterSpacing: 0),
  425. ),
  426. ),
  427. const SizedBox(height: 8),
  428. TDTextarea(
  429. controller: _remarkCtrl,
  430. hintText: _l10n.get('optional'),
  431. maxLines: 3,
  432. minLines: 1,
  433. maxLength: 200,
  434. indicator: true,
  435. padding: EdgeInsets.zero,
  436. bordered: true,
  437. inputType: TextInputType.multiline,
  438. backgroundColor: tdTheme.bgColorContainer,
  439. ),
  440. ],
  441. );
  442. }
  443. // ── 操作按钮 ──
  444. Widget _buildActions() {
  445. return Row(
  446. children: [
  447. Expanded(
  448. child: TDButton(
  449. text: _l10n.get('cancel'),
  450. size: TDButtonSize.large,
  451. type: TDButtonType.outline,
  452. shape: TDButtonShape.rectangle,
  453. theme: TDButtonTheme.defaultTheme,
  454. onTap: () => Navigator.pop(context),
  455. ),
  456. ),
  457. const SizedBox(width: 12),
  458. Expanded(
  459. child: TDButton(
  460. text: _l10n.get('confirm'),
  461. size: TDButtonSize.large,
  462. type: TDButtonType.fill,
  463. shape: TDButtonShape.rectangle,
  464. theme: TDButtonTheme.primary,
  465. onTap: _confirm,
  466. ),
  467. ),
  468. ],
  469. );
  470. }
  471. }