expense_detail_edit_dialog.dart 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
  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. class ExpenseDetailInputData {
  10. final String category;
  11. final String categoryName;
  12. final String expenseDesc;
  13. final double amount; // 含税金额
  14. final double taxRate;
  15. final String customerVendorName;
  16. final double offsetAmount;
  17. final String remark;
  18. const ExpenseDetailInputData({
  19. required this.category,
  20. required this.categoryName,
  21. required this.expenseDesc,
  22. required this.amount,
  23. required this.taxRate,
  24. this.customerVendorName = '',
  25. this.offsetAmount = 0.0,
  26. this.remark = '',
  27. });
  28. }
  29. /// 报销明细编辑弹窗。
  30. ///
  31. /// 使用 [TDSlidePopupRoute] 从底部滑出,卡片化展示表单字段。
  32. /// 参照 ExpenseApplicationApplyPage 的 ExpenseDetailDialog 样式。
  33. class ExpenseDetailEditDialog extends StatefulWidget {
  34. final List<CostCategory> categories;
  35. final AppLocalizations l10n;
  36. const ExpenseDetailEditDialog({
  37. super.key,
  38. required this.categories,
  39. required this.l10n,
  40. });
  41. /// 显示弹窗,返回 [ExpenseDetailInputData] 或 `null`(取消时)。
  42. static Future<ExpenseDetailInputData?> show(
  43. BuildContext context, {
  44. required List<CostCategory> categories,
  45. required AppLocalizations l10n,
  46. }) {
  47. FocusScope.of(context).unfocus();
  48. return Navigator.push<ExpenseDetailInputData>(
  49. context,
  50. TDSlidePopupRoute<ExpenseDetailInputData>(
  51. slideTransitionFrom: SlideTransitionFrom.bottom,
  52. isDismissible: false,
  53. builder: (_) => ExpenseDetailEditDialog(
  54. categories: categories,
  55. l10n: l10n,
  56. ),
  57. ),
  58. );
  59. }
  60. @override
  61. State<ExpenseDetailEditDialog> createState() =>
  62. _ExpenseDetailEditDialogState();
  63. }
  64. class _ExpenseDetailEditDialogState extends State<ExpenseDetailEditDialog> {
  65. late String _cat;
  66. late String _catLabel;
  67. late TextEditingController _descCtrl;
  68. late TextEditingController _amountCtrl;
  69. late TextEditingController _customerCtrl;
  70. late TextEditingController _offsetCtrl;
  71. late TextEditingController _remarkCtrl;
  72. double _taxRate = 0.06;
  73. static const _taxOptions = [0.06, 0.09, 0.13];
  74. static const _taxLabels = ['6%', '9%', '13%'];
  75. List<CostCategory> get _cats => widget.categories;
  76. AppLocalizations get _l10n => widget.l10n;
  77. @override
  78. void initState() {
  79. super.initState();
  80. _cat = _cats.isNotEmpty ? _cats.first.code : 'other';
  81. _catLabel = _l10n.get(_cats.firstWhere((c) => c.code == _cat).nameKey);
  82. _descCtrl = TextEditingController();
  83. _amountCtrl = TextEditingController();
  84. _customerCtrl = TextEditingController();
  85. _offsetCtrl = TextEditingController();
  86. _remarkCtrl = TextEditingController();
  87. }
  88. @override
  89. void dispose() {
  90. _descCtrl.dispose();
  91. _amountCtrl.dispose();
  92. _customerCtrl.dispose();
  93. _offsetCtrl.dispose();
  94. _remarkCtrl.dispose();
  95. super.dispose();
  96. }
  97. void _confirm() {
  98. final amount = double.tryParse(_amountCtrl.text) ?? 0;
  99. final desc = _descCtrl.text.trim();
  100. if (desc.isEmpty) {
  101. TDToast.showText(_l10n.get('enterExpenseName'), context: context);
  102. return;
  103. }
  104. if (amount <= 0) {
  105. TDToast.showText(_l10n.get('amountPositive'), context: context);
  106. return;
  107. }
  108. Navigator.pop(
  109. context,
  110. ExpenseDetailInputData(
  111. category: _cat,
  112. categoryName: _l10n.get(
  113. _cats.firstWhere((c) => c.code == _cat).nameKey,
  114. ),
  115. expenseDesc: desc,
  116. amount: amount,
  117. taxRate: _taxRate,
  118. customerVendorName: _customerCtrl.text.trim(),
  119. offsetAmount: double.tryParse(_offsetCtrl.text) ?? 0,
  120. remark: _remarkCtrl.text.trim(),
  121. ),
  122. );
  123. }
  124. double get _amountExclTax => _taxRate > 0
  125. ? (double.tryParse(_amountCtrl.text) ?? 0) / (1 + _taxRate)
  126. : (double.tryParse(_amountCtrl.text) ?? 0);
  127. double get _taxAmount =>
  128. (double.tryParse(_amountCtrl.text) ?? 0) - _amountExclTax;
  129. @override
  130. Widget build(BuildContext context) {
  131. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  132. final bottomInset = MediaQuery.of(context).viewInsets.bottom;
  133. return SafeArea(
  134. child: Padding(
  135. padding: EdgeInsets.only(bottom: bottomInset),
  136. child: Container(
  137. decoration: BoxDecoration(
  138. color: colors.bgPage,
  139. borderRadius:
  140. const BorderRadius.vertical(top: Radius.circular(16)),
  141. ),
  142. child: Column(
  143. mainAxisSize: MainAxisSize.min,
  144. crossAxisAlignment: CrossAxisAlignment.stretch,
  145. children: [
  146. _buildHeader(colors),
  147. Flexible(
  148. child: SingleChildScrollView(
  149. padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
  150. child: Column(
  151. mainAxisSize: MainAxisSize.min,
  152. crossAxisAlignment: CrossAxisAlignment.stretch,
  153. children: [
  154. _buildCategoryCard(colors),
  155. const SizedBox(height: 12),
  156. _buildDescCard(),
  157. const SizedBox(height: 12),
  158. _buildAmountCard(),
  159. const SizedBox(height: 12),
  160. _buildTaxRateCard(colors),
  161. const SizedBox(height: 12),
  162. // 自动计算展示
  163. _buildCalcInfo(colors),
  164. const SizedBox(height: 12),
  165. _buildCustomerCard(),
  166. const SizedBox(height: 12),
  167. _buildOffsetCard(),
  168. const SizedBox(height: 12),
  169. _buildRemarkCard(colors),
  170. ],
  171. ),
  172. ),
  173. ),
  174. Container(
  175. padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
  176. decoration: BoxDecoration(
  177. color: colors.bgCard,
  178. border: Border(
  179. top: BorderSide(color: colors.border, width: 0.5),
  180. ),
  181. ),
  182. child: _buildActions(),
  183. ),
  184. ],
  185. ),
  186. ),
  187. ),
  188. );
  189. }
  190. // ── 标题栏 ──
  191. Widget _buildHeader(AppColorsExtension colors) {
  192. return Column(
  193. mainAxisSize: MainAxisSize.min,
  194. children: [
  195. Center(
  196. child: Container(
  197. margin: const EdgeInsets.only(top: 8, bottom: 4),
  198. width: 36,
  199. height: 4,
  200. decoration: BoxDecoration(
  201. color: colors.border,
  202. borderRadius: BorderRadius.circular(2),
  203. ),
  204. ),
  205. ),
  206. Padding(
  207. padding: const EdgeInsets.fromLTRB(20, 8, 12, 16),
  208. child: Row(
  209. children: [
  210. const SizedBox(width: 28),
  211. Expanded(
  212. child: Center(
  213. child: Text(
  214. _l10n.get('addExpenseDetail'),
  215. style: TextStyle(
  216. fontSize: AppFontSizes.title,
  217. fontWeight: FontWeight.w600,
  218. color: colors.textPrimary,
  219. ),
  220. ),
  221. ),
  222. ),
  223. GestureDetector(
  224. onTap: () => Navigator.pop(context),
  225. child: Padding(
  226. padding: const EdgeInsets.all(4),
  227. child: Icon(
  228. Icons.close,
  229. size: 20,
  230. color: colors.textSecondary,
  231. ),
  232. ),
  233. ),
  234. ],
  235. ),
  236. ),
  237. ],
  238. );
  239. }
  240. // ── picker 卡片 ──
  241. Widget _pickerCard({
  242. required String label,
  243. required bool required,
  244. required String currentLabel,
  245. required List<String> labels,
  246. required ValueChanged<int> onSelected,
  247. required AppColorsExtension colors,
  248. }) {
  249. final tdTheme = TDTheme.of(context);
  250. return GestureDetector(
  251. onTap: () {
  252. TDPicker.showMultiPicker(
  253. context,
  254. title: label,
  255. backgroundColor: colors.bgCard,
  256. data: [labels],
  257. onConfirm: (selected) {
  258. if (selected.isNotEmpty && selected[0] is int) {
  259. final idx = selected[0] as int;
  260. if (idx >= 0 && idx < labels.length) {
  261. Navigator.of(context).pop();
  262. onSelected(idx);
  263. }
  264. }
  265. },
  266. );
  267. },
  268. child: Container(
  269. padding: const EdgeInsets.only(
  270. left: 16, right: 16, top: 12, bottom: 12,
  271. ),
  272. decoration: BoxDecoration(
  273. color: tdTheme.bgColorContainer,
  274. borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
  275. border: Border.all(color: tdTheme.componentStrokeColor),
  276. ),
  277. child: Row(
  278. children: [
  279. TDText(
  280. label,
  281. maxLines: 1,
  282. overflow: TextOverflow.visible,
  283. font: tdTheme.fontBodyLarge,
  284. fontWeight: FontWeight.w400,
  285. style: const TextStyle(letterSpacing: 0),
  286. ),
  287. if (required)
  288. Padding(
  289. padding: const EdgeInsets.only(left: 4),
  290. child: TDText(
  291. '*',
  292. font: tdTheme.fontBodyLarge,
  293. fontWeight: FontWeight.w400,
  294. style: TextStyle(color: tdTheme.errorColor6),
  295. ),
  296. ),
  297. const SizedBox(width: 12),
  298. Expanded(
  299. child: Row(
  300. mainAxisAlignment: MainAxisAlignment.end,
  301. mainAxisSize: MainAxisSize.max,
  302. children: [
  303. Flexible(
  304. child: TDText(
  305. currentLabel,
  306. maxLines: 1,
  307. overflow: TextOverflow.ellipsis,
  308. font: tdTheme.fontBodyLarge,
  309. fontWeight: FontWeight.w400,
  310. textColor: tdTheme.textColorPrimary,
  311. textAlign: TextAlign.end,
  312. ),
  313. ),
  314. const SizedBox(width: 4),
  315. Icon(
  316. Icons.chevron_right,
  317. size: 18,
  318. color: tdTheme.textColorPlaceholder,
  319. ),
  320. ],
  321. ),
  322. ),
  323. ],
  324. ),
  325. ),
  326. );
  327. }
  328. // ── 费用类别 ──
  329. Widget _buildCategoryCard(AppColorsExtension colors) {
  330. return _pickerCard(
  331. label: _l10n.get('expenseCategory'),
  332. required: true,
  333. currentLabel: _catLabel,
  334. labels: _cats.map((c) => _l10n.get(c.nameKey)).toList(),
  335. colors: colors,
  336. onSelected: (idx) => setState(() {
  337. _cat = _cats[idx].code;
  338. _catLabel = _l10n.get(_cats[idx].nameKey);
  339. }),
  340. );
  341. }
  342. // ── 费用项目 ──
  343. Widget _buildDescCard() {
  344. final screenWidth = MediaQuery.of(context).size.width;
  345. return TDInput(
  346. type: TDInputType.cardStyle,
  347. cardStyle: TDCardStyle.topText,
  348. width: screenWidth - 32,
  349. leftLabel: _l10n.get('expenseName'),
  350. required: true,
  351. controller: _descCtrl,
  352. hintText: _l10n.get('enterExpenseName'),
  353. contentAlignment: TextAlign.center,
  354. showBottomDivider: false,
  355. onChanged: (_) => setState(() {}),
  356. onClearTap: () {
  357. _descCtrl.clear();
  358. setState(() {});
  359. },
  360. );
  361. }
  362. // ── 金额(含税) ──
  363. Widget _buildAmountCard() {
  364. final screenWidth = MediaQuery.of(context).size.width;
  365. return TDInput(
  366. type: TDInputType.cardStyle,
  367. cardStyle: TDCardStyle.topText,
  368. width: screenWidth - 32,
  369. leftLabel: _l10n.get('amountInclTax'),
  370. required: true,
  371. controller: _amountCtrl,
  372. hintText: '>0',
  373. contentAlignment: TextAlign.center,
  374. inputType: const TextInputType.numberWithOptions(decimal: true),
  375. inputFormatters: [
  376. FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$')),
  377. ],
  378. showBottomDivider: false,
  379. onChanged: (_) => setState(() {}),
  380. onClearTap: () {
  381. _amountCtrl.clear();
  382. setState(() {});
  383. },
  384. );
  385. }
  386. // ── 税率 ──
  387. Widget _buildTaxRateCard(AppColorsExtension colors) {
  388. final currentLabel =
  389. '${(_taxRate * 100).toStringAsFixed(0)}%';
  390. return _pickerCard(
  391. label: _l10n.get('taxRate'),
  392. required: true,
  393. currentLabel: currentLabel,
  394. labels: _taxLabels.toList(),
  395. colors: colors,
  396. onSelected: (idx) => setState(() {
  397. _taxRate = _taxOptions[idx];
  398. }),
  399. );
  400. }
  401. // ── 计算信息 ──
  402. Widget _buildCalcInfo(AppColorsExtension colors) {
  403. final amount = double.tryParse(_amountCtrl.text) ?? 0;
  404. if (amount <= 0) return const SizedBox.shrink();
  405. return Container(
  406. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
  407. decoration: BoxDecoration(
  408. color: colors.primary50,
  409. borderRadius: BorderRadius.circular(8),
  410. ),
  411. child: Row(
  412. children: [
  413. Expanded(
  414. child: Text(
  415. '${_l10n.get('amountExcludingTax')}: ¥${_amountExclTax.toStringAsFixed(2)}',
  416. style: TextStyle(
  417. fontSize: AppFontSizes.body,
  418. color: colors.textSecondary,
  419. ),
  420. ),
  421. ),
  422. Text(
  423. '${_l10n.get('taxAmount')}: ¥${_taxAmount.toStringAsFixed(2)}',
  424. style: TextStyle(
  425. fontSize: AppFontSizes.body,
  426. color: colors.textSecondary,
  427. ),
  428. ),
  429. ],
  430. ),
  431. );
  432. }
  433. // ── 客户/厂商 ──
  434. Widget _buildCustomerCard() {
  435. final screenWidth = MediaQuery.of(context).size.width;
  436. return TDInput(
  437. type: TDInputType.cardStyle,
  438. cardStyle: TDCardStyle.topText,
  439. width: screenWidth - 32,
  440. leftLabel: _l10n.get('customerVendor'),
  441. controller: _customerCtrl,
  442. hintText: _l10n.get('optional'),
  443. contentAlignment: TextAlign.center,
  444. showBottomDivider: false,
  445. onChanged: (_) => setState(() {}),
  446. onClearTap: () {
  447. _customerCtrl.clear();
  448. setState(() {});
  449. },
  450. );
  451. }
  452. // ── 已充金额 ──
  453. Widget _buildOffsetCard() {
  454. final screenWidth = MediaQuery.of(context).size.width;
  455. return TDInput(
  456. type: TDInputType.cardStyle,
  457. cardStyle: TDCardStyle.topText,
  458. width: screenWidth - 32,
  459. leftLabel: _l10n.get('offsetAmount'),
  460. controller: _offsetCtrl,
  461. hintText: '0',
  462. contentAlignment: TextAlign.center,
  463. inputType: const TextInputType.numberWithOptions(decimal: true),
  464. inputFormatters: [
  465. FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$')),
  466. ],
  467. showBottomDivider: false,
  468. onChanged: (_) => setState(() {}),
  469. onClearTap: () {
  470. _offsetCtrl.clear();
  471. setState(() {});
  472. },
  473. );
  474. }
  475. // ── 备注 ──
  476. Widget _buildRemarkCard(AppColorsExtension colors) {
  477. final tdTheme = TDTheme.of(context);
  478. return Column(
  479. crossAxisAlignment: CrossAxisAlignment.start,
  480. children: [
  481. Padding(
  482. padding: const EdgeInsets.only(left: 4),
  483. child: TDText(
  484. _l10n.get('detailRemark'),
  485. font: tdTheme.fontBodyLarge,
  486. fontWeight: FontWeight.w400,
  487. style: const TextStyle(letterSpacing: 0),
  488. ),
  489. ),
  490. const SizedBox(height: 8),
  491. TDTextarea(
  492. controller: _remarkCtrl,
  493. hintText: _l10n.get('optional'),
  494. maxLines: 3,
  495. minLines: 1,
  496. maxLength: 200,
  497. indicator: true,
  498. padding: EdgeInsets.zero,
  499. bordered: true,
  500. inputType: TextInputType.multiline,
  501. backgroundColor: tdTheme.bgColorContainer,
  502. ),
  503. ],
  504. );
  505. }
  506. // ── 操作按钮 ──
  507. Widget _buildActions() {
  508. return Row(
  509. children: [
  510. Expanded(
  511. child: TDButton(
  512. text: _l10n.get('cancel'),
  513. size: TDButtonSize.large,
  514. type: TDButtonType.outline,
  515. shape: TDButtonShape.rectangle,
  516. theme: TDButtonTheme.defaultTheme,
  517. onTap: () => Navigator.pop(context),
  518. ),
  519. ),
  520. const SizedBox(width: 12),
  521. Expanded(
  522. child: TDButton(
  523. text: _l10n.get('confirmAdd'),
  524. size: TDButtonSize.large,
  525. type: TDButtonType.fill,
  526. shape: TDButtonShape.rectangle,
  527. theme: TDButtonTheme.primary,
  528. onTap: _confirm,
  529. ),
  530. ),
  531. ],
  532. );
  533. }
  534. }