expense_detail_dialog.dart 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302
  1. // ignore_for_file: use_build_context_synchronously
  2. import 'package:flutter/material.dart';
  3. import 'package:flutter/services.dart';
  4. import 'package:tdesign_flutter/tdesign_flutter.dart';
  5. import '../../../core/i18n/app_localizations.dart';
  6. import '../../../core/theme/app_colors.dart';
  7. import '../../../core/theme/app_colors_extension.dart';
  8. import '../../../core/utils/amount_utils.dart';
  9. import '../../../shared/models/bill_file_rights.dart';
  10. import '../expense_api.dart';
  11. import '../../../shared/widgets/attachment_picker.dart';
  12. import '../../../shared/widgets/searchable_picker_sheet.dart';
  13. /// 报销明细输入数据。
  14. class ExpenseDetailInputData {
  15. final String category;
  16. final String categoryName;
  17. final String acctSubjectId;
  18. final String acctSubjectName;
  19. final String purpose;
  20. final double amount;
  21. final double taxRate;
  22. final String projectId;
  23. final String projectName;
  24. final String costDeptId;
  25. final String costDeptName;
  26. final String customerVendorId;
  27. final String customerVendorName;
  28. final double approvedAmount;
  29. final double offsetAmount;
  30. final String bankName;
  31. final String bankAccountName;
  32. final String bankAccount;
  33. final String remark;
  34. final List<String> attachmentPaths;
  35. final String sqMan;
  36. final String sqManName;
  37. final String aeNo;
  38. final String aeDd;
  39. const ExpenseDetailInputData({
  40. required this.category,
  41. required this.categoryName,
  42. required this.acctSubjectId,
  43. required this.acctSubjectName,
  44. required this.purpose,
  45. required this.amount,
  46. required this.taxRate,
  47. this.projectId = '',
  48. this.projectName = '',
  49. this.costDeptId = '',
  50. this.costDeptName = '',
  51. this.customerVendorId = '',
  52. this.customerVendorName = '',
  53. this.approvedAmount = 0.0,
  54. this.offsetAmount = 0.0,
  55. this.bankName = '',
  56. this.bankAccountName = '',
  57. this.bankAccount = '',
  58. this.remark = '',
  59. this.attachmentPaths = const [],
  60. this.sqMan = '',
  61. this.sqManName = '',
  62. this.aeNo = '',
  63. this.aeDd = '',
  64. });
  65. }
  66. /// 报销明细编辑弹窗。
  67. ///
  68. /// 使用 [TDSlidePopupRoute] 从底部滑出,卡片化展示表单字段。
  69. /// 参照 ExpenseApplyCreatePage 的 ExpenseDetailDialog 样式。
  70. class ExpenseDetailDialog extends StatefulWidget {
  71. final ExpenseApi api;
  72. final AppLocalizations l10n;
  73. final ExpenseDetailInputData? initialData;
  74. final Future<bool> Function()? checkAttachHealth;
  75. final bool showAttachments;
  76. final bool canEditApprovedAmount;
  77. final BillFileRights? billFileRights;
  78. final dynamic acctTree;
  79. final String? defaultSqMan;
  80. final String? defaultSqManName;
  81. const ExpenseDetailDialog({
  82. super.key,
  83. required this.api,
  84. required this.l10n,
  85. this.initialData,
  86. this.checkAttachHealth,
  87. this.showAttachments = true,
  88. this.canEditApprovedAmount = false,
  89. this.billFileRights,
  90. required this.acctTree,
  91. this.defaultSqMan,
  92. this.defaultSqManName,
  93. });
  94. /// 显示弹窗,返回 [ExpenseDetailInputData] 或 `null`(取消时)。
  95. static Future<ExpenseDetailInputData?> show(
  96. BuildContext context, {
  97. required ExpenseApi api,
  98. required AppLocalizations l10n,
  99. ExpenseDetailInputData? initialData,
  100. Future<bool> Function()? checkAttachHealth,
  101. bool showAttachments = true,
  102. bool canEditApprovedAmount = false,
  103. BillFileRights? billFileRights,
  104. required dynamic acctTree,
  105. String? defaultSqMan,
  106. String? defaultSqManName,
  107. }) {
  108. FocusScope.of(context).unfocus();
  109. return Navigator.push<ExpenseDetailInputData>(
  110. context,
  111. TDSlidePopupRoute<ExpenseDetailInputData>(
  112. slideTransitionFrom: SlideTransitionFrom.bottom,
  113. isDismissible: true,
  114. builder: (_) => ExpenseDetailDialog(
  115. api: api,
  116. l10n: l10n,
  117. initialData: initialData,
  118. checkAttachHealth: checkAttachHealth,
  119. showAttachments: showAttachments,
  120. canEditApprovedAmount: canEditApprovedAmount,
  121. defaultSqMan: defaultSqMan,
  122. defaultSqManName: defaultSqManName,
  123. billFileRights: billFileRights,
  124. acctTree: acctTree,
  125. ),
  126. ),
  127. );
  128. }
  129. @override
  130. State<ExpenseDetailDialog> createState() => _ExpenseDetailDialogState();
  131. }
  132. class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
  133. late CostProjectItem _selCat;
  134. late TextEditingController _descCtrl;
  135. late TextEditingController _amountCtrl;
  136. CustomerItem? _selCustomer;
  137. late TextEditingController _approvedAmountCtrl;
  138. late TextEditingController _remarkCtrl;
  139. late TextEditingController _bankNameCtrl;
  140. late TextEditingController _bankAccountNameCtrl;
  141. late TextEditingController _bankAccountCtrl;
  142. double _taxRate = 0;
  143. ProjectCodeItem? _selProject;
  144. DepartmentItem? _selDept;
  145. EmployeeItem? _selEmployee;
  146. late final AttachmentPickerController _attachmentCtrl;
  147. final ScrollController _scrollCtrl = ScrollController();
  148. final _remarkFocus = FocusNode();
  149. final _bankNameFocus = FocusNode();
  150. final _bankAccountNameFocus = FocusNode();
  151. final _bankAccountFocus = FocusNode();
  152. bool _attachAvailable = false;
  153. void _ensureVisible(FocusNode node) {
  154. if (!node.hasFocus) return;
  155. _doEnsureVisible(node, 0, -1);
  156. }
  157. void _doEnsureVisible(FocusNode node, int attempt, double lastInsets) {
  158. if (attempt >= 15) return;
  159. WidgetsBinding.instance.addPostFrameCallback((_) {
  160. if (!mounted || !node.hasFocus || !_scrollCtrl.hasClients) return;
  161. final insets = MediaQuery.of(context).viewInsets.bottom;
  162. if (insets != lastInsets) {
  163. _doEnsureVisible(node, attempt + 1, insets);
  164. return;
  165. }
  166. final ctx = node.context;
  167. if (ctx == null) return;
  168. Future.delayed(const Duration(milliseconds: 500), () {
  169. if (!mounted || !node.hasFocus || !_scrollCtrl.hasClients) return;
  170. Scrollable.ensureVisible(
  171. ctx,
  172. alignment: 0.5,
  173. duration: const Duration(milliseconds: 300),
  174. );
  175. });
  176. });
  177. }
  178. static const _taxOptions = [0, 6, 9, 13];
  179. static const _taxLabels = ['0%', '6%', '9%', '13%'];
  180. AppLocalizations get _l10n => widget.l10n;
  181. bool get _isEdit => widget.initialData != null;
  182. @override
  183. void initState() {
  184. super.initState();
  185. final d = widget.initialData;
  186. _selCat = CostProjectItem(
  187. idx1: d?.category ?? '',
  188. name: d?.categoryName ?? '',
  189. accNo: d?.acctSubjectId ?? '',
  190. accName: d?.acctSubjectName ?? '',
  191. );
  192. _descCtrl = TextEditingController(text: d?.purpose ?? '');
  193. _amountCtrl = TextEditingController(
  194. text: d != null && d.amount > 0 ? formatAmountNumeric(d.amount) : '',
  195. );
  196. _selCustomer = (d != null && d.customerVendorId.isNotEmpty)
  197. ? CustomerItem(cusNo: '', name: '')
  198. : null;
  199. _approvedAmountCtrl = TextEditingController(
  200. text: d != null && d.approvedAmount > 0
  201. ? formatAmountNumeric(d.approvedAmount)
  202. : '',
  203. );
  204. _remarkCtrl = TextEditingController(text: d?.remark ?? '');
  205. _bankNameCtrl = TextEditingController(text: d?.bankName ?? '');
  206. _bankAccountNameCtrl = TextEditingController(
  207. text: d?.bankAccountName ?? '',
  208. );
  209. _bankAccountCtrl = TextEditingController(text: d?.bankAccount ?? '');
  210. _taxRate = d?.taxRate ?? 0;
  211. _selEmployee = (d != null && d.sqMan.isNotEmpty)
  212. ? EmployeeItem(salNo: d.sqMan, name: d.sqManName)
  213. : (widget.defaultSqMan != null
  214. ? EmployeeItem(
  215. salNo: widget.defaultSqMan!,
  216. name: widget.defaultSqManName ?? '',
  217. )
  218. : null);
  219. if (_selEmployee != null && (d == null || d.sqMan.isEmpty)) {
  220. WidgetsBinding.instance.addPostFrameCallback(
  221. (_) => _loadDefaultBankInfo(),
  222. );
  223. }
  224. _selProject = (d != null && d.projectId.isNotEmpty)
  225. ? ProjectCodeItem(objNo: d.projectId, name: d.projectName)
  226. : null;
  227. _selDept = (d != null && d.costDeptId.isNotEmpty)
  228. ? DepartmentItem(dep: d.costDeptId, name: d.costDeptName)
  229. : null;
  230. if (d != null && d.attachmentPaths.isNotEmpty) {
  231. WidgetsBinding.instance.addPostFrameCallback((_) {
  232. _attachmentCtrl.restoreFromPaths(d.attachmentPaths);
  233. });
  234. }
  235. _attachmentCtrl = AttachmentPickerController(maxCount: 9)
  236. ..addListener(() => setState(() {}));
  237. _remarkFocus.addListener(() => _ensureVisible(_remarkFocus));
  238. _bankNameFocus.addListener(() => _ensureVisible(_bankNameFocus));
  239. _bankAccountNameFocus.addListener(
  240. () => _ensureVisible(_bankAccountNameFocus),
  241. );
  242. _bankAccountFocus.addListener(() => _ensureVisible(_bankAccountFocus));
  243. _checkAttachHealth();
  244. }
  245. Future<void> _loadDefaultBankInfo() async {
  246. if (_selEmployee == null || _selEmployee!.salNo.isEmpty) return;
  247. try {
  248. final list = await widget.api.getEmployees(
  249. salNo: _selEmployee!.salNo,
  250. size: 1,
  251. );
  252. if (!mounted || list.isEmpty) return;
  253. setState(() {
  254. _selEmployee = list.first;
  255. _bankNameCtrl.text = list.first.bnkNo;
  256. _bankAccountNameCtrl.text = list.first.accName;
  257. _bankAccountCtrl.text = list.first.bnkId;
  258. });
  259. } catch (_) {}
  260. }
  261. Future<void> _checkAttachHealth() async {
  262. if (widget.checkAttachHealth == null) return;
  263. if (mounted) setState(() => _attachAvailable = false);
  264. try {
  265. final ok = await widget.checkAttachHealth!();
  266. if (mounted) setState(() => _attachAvailable = ok);
  267. } catch (_) {
  268. if (mounted) setState(() => _attachAvailable = false);
  269. }
  270. }
  271. @override
  272. void dispose() {
  273. _descCtrl.dispose();
  274. _amountCtrl.dispose();
  275. _approvedAmountCtrl.dispose();
  276. _remarkCtrl.dispose();
  277. _bankNameCtrl.dispose();
  278. _bankAccountNameCtrl.dispose();
  279. _bankAccountCtrl.dispose();
  280. _attachmentCtrl.dispose();
  281. _scrollCtrl.dispose();
  282. _remarkFocus.dispose();
  283. _bankNameFocus.dispose();
  284. _bankAccountNameFocus.dispose();
  285. _bankAccountFocus.dispose();
  286. super.dispose();
  287. }
  288. void _confirm() {
  289. final amount = double.tryParse(_amountCtrl.text) ?? 0;
  290. if (amount <= 0) {
  291. TDToast.showText(_l10n.get('amountPositive'), context: context);
  292. return;
  293. }
  294. Navigator.pop(
  295. context,
  296. ExpenseDetailInputData(
  297. category: _selCat.idx1,
  298. categoryName: _selCat.name,
  299. acctSubjectId: _selCat.accNo,
  300. acctSubjectName: _selCat.accName,
  301. purpose: '',
  302. amount: amount,
  303. taxRate: _taxRate,
  304. projectId: _selProject?.objNo ?? '',
  305. projectName: _selProject?.name ?? '',
  306. costDeptId: _selDept?.dep ?? '',
  307. costDeptName: _selDept?.name ?? '',
  308. customerVendorId: _selCustomer?.cusNo ?? '',
  309. customerVendorName: _selCustomer?.name ?? '',
  310. approvedAmount: double.tryParse(_approvedAmountCtrl.text) ?? 0,
  311. bankName: _bankNameCtrl.text.trim(),
  312. bankAccountName: _bankAccountNameCtrl.text.trim(),
  313. bankAccount: _bankAccountCtrl.text.trim(),
  314. remark: _remarkCtrl.text.trim(),
  315. attachmentPaths: _attachmentCtrl.toPathList(),
  316. sqMan: _selEmployee?.salNo ?? '',
  317. sqManName: _selEmployee?.name ?? '',
  318. aeNo: widget.initialData?.aeNo ?? '',
  319. aeDd: widget.initialData?.aeDd ?? '',
  320. ),
  321. );
  322. }
  323. double get _amountExclTax => _taxRate > 0
  324. ? (double.tryParse(_amountCtrl.text) ?? 0) / (1 + _taxRate / 100)
  325. : (double.tryParse(_amountCtrl.text) ?? 0);
  326. double get _taxAmount =>
  327. (double.tryParse(_amountCtrl.text) ?? 0) - _amountExclTax;
  328. @override
  329. Widget build(BuildContext context) {
  330. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  331. return AnimatedPadding(
  332. padding: EdgeInsets.only(
  333. bottom: MediaQuery.of(context).viewInsets.bottom,
  334. ),
  335. duration: const Duration(milliseconds: 200),
  336. child: SafeArea(
  337. child: ConstrainedBox(
  338. constraints: BoxConstraints(
  339. maxHeight: MediaQuery.of(context).size.height * 0.8,
  340. ),
  341. child: Container(
  342. decoration: BoxDecoration(
  343. color: colors.bgPage,
  344. borderRadius: const BorderRadius.vertical(
  345. top: Radius.circular(16),
  346. ),
  347. ),
  348. child: Column(
  349. mainAxisSize: MainAxisSize.min,
  350. crossAxisAlignment: CrossAxisAlignment.stretch,
  351. children: [
  352. _buildHeader(colors),
  353. Flexible(
  354. child: GestureDetector(
  355. onTap: () => FocusScope.of(context).unfocus(),
  356. behavior: HitTestBehavior.translucent,
  357. child: SingleChildScrollView(
  358. controller: _scrollCtrl,
  359. keyboardDismissBehavior:
  360. ScrollViewKeyboardDismissBehavior.manual,
  361. padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
  362. child: Column(
  363. mainAxisSize: MainAxisSize.min,
  364. crossAxisAlignment: CrossAxisAlignment.stretch,
  365. children: [
  366. if (_isEdit &&
  367. widget.initialData!.aeNo.isNotEmpty) ...[
  368. _buildAeInfoCard(colors),
  369. const SizedBox(height: 12),
  370. ],
  371. _buildCategoryCard(colors),
  372. const SizedBox(height: 12),
  373. _buildAcctSubjectCard(colors),
  374. const SizedBox(height: 12),
  375. _buildAmountCard(),
  376. const SizedBox(height: 12),
  377. _buildTaxRateCard(colors),
  378. if ((double.tryParse(_amountCtrl.text) ?? 0) > 0) ...[
  379. const SizedBox(height: 12),
  380. _buildCalcInfo(colors),
  381. ],
  382. const SizedBox(height: 12),
  383. _buildApprovedAmountCard(),
  384. const SizedBox(height: 12),
  385. _buildProjectCard(colors),
  386. const SizedBox(height: 12),
  387. _buildCostDeptCard(colors),
  388. const SizedBox(height: 12),
  389. _buildCustomerCard(colors),
  390. const SizedBox(height: 12),
  391. _buildEmployeeCard(colors),
  392. const SizedBox(height: 12),
  393. _buildBankInfoCard(colors),
  394. const SizedBox(height: 12),
  395. _buildRemarkInput(colors),
  396. if (widget.showAttachments) ...[
  397. const SizedBox(height: 12),
  398. _buildAttachmentCard(colors),
  399. ],
  400. ],
  401. ),
  402. ),
  403. ),
  404. ),
  405. Container(
  406. padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
  407. decoration: BoxDecoration(
  408. color: colors.bgCard,
  409. border: Border(
  410. top: BorderSide(color: colors.border, width: 0.5),
  411. ),
  412. ),
  413. child: _buildActions(),
  414. ),
  415. ],
  416. ),
  417. ),
  418. ),
  419. ),
  420. );
  421. }
  422. // ── 标题栏 ──
  423. Widget _buildHeader(AppColorsExtension colors) {
  424. return Column(
  425. mainAxisSize: MainAxisSize.min,
  426. children: [
  427. Center(
  428. child: Container(
  429. margin: const EdgeInsets.only(top: 8, bottom: 4),
  430. width: 36,
  431. height: 4,
  432. decoration: BoxDecoration(
  433. color: colors.border,
  434. borderRadius: BorderRadius.circular(2),
  435. ),
  436. ),
  437. ),
  438. Padding(
  439. padding: const EdgeInsets.fromLTRB(20, 8, 12, 16),
  440. child: Row(
  441. children: [
  442. const SizedBox(width: 24),
  443. Expanded(
  444. child: Center(
  445. child: Text(
  446. _l10n.get('addExpenseDetail'),
  447. style: TextStyle(
  448. fontSize: AppFontSizes.title,
  449. fontWeight: FontWeight.w600,
  450. color: colors.textPrimary,
  451. ),
  452. ),
  453. ),
  454. ),
  455. GestureDetector(
  456. onTap: () => Navigator.pop(context),
  457. child: Padding(
  458. padding: const EdgeInsets.all(4),
  459. child: Icon(
  460. Icons.close,
  461. size: 20,
  462. color: colors.textSecondary,
  463. ),
  464. ),
  465. ),
  466. ],
  467. ),
  468. ),
  469. ],
  470. );
  471. }
  472. // ── picker 卡片 ──
  473. Widget _pickerCard({
  474. required String label,
  475. required bool required,
  476. required String currentLabel,
  477. required bool hasValue,
  478. required VoidCallback onTap,
  479. VoidCallback? onClear,
  480. }) {
  481. final tdTheme = TDTheme.of(context);
  482. return GestureDetector(
  483. onTap: () {
  484. FocusManager.instance.primaryFocus?.unfocus();
  485. onTap();
  486. },
  487. child: Container(
  488. padding: const EdgeInsets.only(
  489. left: 16,
  490. right: 10,
  491. top: 12,
  492. bottom: 12,
  493. ),
  494. decoration: BoxDecoration(
  495. color: tdTheme.bgColorContainer,
  496. borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
  497. border: Border.all(color: tdTheme.componentStrokeColor),
  498. ),
  499. child: Row(
  500. children: [
  501. TDText(
  502. label,
  503. maxLines: 1,
  504. overflow: TextOverflow.visible,
  505. font: tdTheme.fontBodyLarge,
  506. fontWeight: FontWeight.w400,
  507. style: const TextStyle(letterSpacing: 0),
  508. ),
  509. if (required)
  510. Padding(
  511. padding: const EdgeInsets.only(left: 4),
  512. child: TDText(
  513. '*',
  514. font: tdTheme.fontBodyLarge,
  515. fontWeight: FontWeight.w400,
  516. style: TextStyle(color: tdTheme.errorColor6),
  517. ),
  518. ),
  519. const SizedBox(width: 12),
  520. Expanded(
  521. child: Row(
  522. mainAxisAlignment: MainAxisAlignment.end,
  523. mainAxisSize: MainAxisSize.max,
  524. children: [
  525. Flexible(
  526. child: TDText(
  527. currentLabel,
  528. maxLines: 1,
  529. overflow: TextOverflow.ellipsis,
  530. font: tdTheme.fontBodyLarge,
  531. fontWeight: FontWeight.w400,
  532. textColor: hasValue
  533. ? tdTheme.textColorPrimary
  534. : tdTheme.textColorPlaceholder,
  535. textAlign: TextAlign.end,
  536. ),
  537. ),
  538. const SizedBox(width: 4),
  539. SizedBox(
  540. width: 18,
  541. height: 18,
  542. child: onClear != null
  543. ? GestureDetector(
  544. onTap: onClear,
  545. child: Icon(
  546. Icons.close,
  547. size: 18,
  548. color: tdTheme.textColorPlaceholder,
  549. ),
  550. )
  551. : Icon(
  552. Icons.chevron_right,
  553. size: 18,
  554. color: tdTheme.textColorPlaceholder,
  555. ),
  556. ),
  557. ],
  558. ),
  559. ),
  560. ],
  561. ),
  562. ),
  563. );
  564. }
  565. // ── 费用项目 ──
  566. Widget _buildCategoryCard(AppColorsExtension colors) {
  567. return _pickerCard(
  568. label: _l10n.get('expenseCategory'),
  569. required: true,
  570. hasValue: _selCat.idx1.isNotEmpty,
  571. currentLabel: _selCat.idx1.isNotEmpty
  572. ? '${_selCat.idx1}/${_selCat.name}'
  573. : _l10n.get('pleaseSelect'),
  574. onTap: () async {
  575. final result = await showSearchablePicker<CostProjectItem>(
  576. context,
  577. title: '${_l10n.get('select')}${_l10n.get('expenseCategory')}',
  578. searchHint: _l10n.get('search'),
  579. loader: (keyword, page) => widget.api
  580. .getCostProjects(keyword: keyword, page: page, size: 20)
  581. .then((list) => list.cast<CostProjectItem>()),
  582. labelBuilder: (c) => '${c.idx1}/${c.name}',
  583. onRefresh: () => widget.api.clearRefCache(),
  584. );
  585. if (result != null && mounted) {
  586. setState(() => _selCat = result);
  587. }
  588. },
  589. );
  590. }
  591. // ── 输入卡片(对齐 pickerCard 样式) ──
  592. Widget _inputCard({
  593. required String label,
  594. required bool required,
  595. required TextEditingController controller,
  596. required String hintText,
  597. required AppColorsExtension colors,
  598. TextInputType? keyboardType,
  599. List<TextInputFormatter>? inputFormatters,
  600. FocusNode? focusNode,
  601. }) {
  602. final tdTheme = TDTheme.of(context);
  603. final hasValue = controller.text.isNotEmpty;
  604. return Container(
  605. padding: const EdgeInsets.only(left: 16, right: 10, top: 12, bottom: 12),
  606. decoration: BoxDecoration(
  607. color: tdTheme.bgColorContainer,
  608. borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
  609. border: Border.all(color: tdTheme.componentStrokeColor),
  610. ),
  611. child: Row(
  612. children: [
  613. TDText(
  614. label,
  615. maxLines: 1,
  616. overflow: TextOverflow.visible,
  617. font: tdTheme.fontBodyLarge,
  618. fontWeight: FontWeight.w400,
  619. style: const TextStyle(letterSpacing: 0),
  620. ),
  621. if (required)
  622. Padding(
  623. padding: const EdgeInsets.only(left: 4),
  624. child: TDText(
  625. '*',
  626. font: tdTheme.fontBodyLarge,
  627. fontWeight: FontWeight.w400,
  628. style: TextStyle(color: tdTheme.errorColor6),
  629. ),
  630. ),
  631. const SizedBox(width: 12),
  632. Expanded(
  633. child: Row(
  634. mainAxisAlignment: MainAxisAlignment.end,
  635. mainAxisSize: MainAxisSize.max,
  636. children: [
  637. Flexible(
  638. child: TextField(
  639. controller: controller,
  640. focusNode: focusNode,
  641. textAlign: TextAlign.end,
  642. keyboardType: keyboardType,
  643. inputFormatters: inputFormatters,
  644. style: TextStyle(fontSize: 16, color: colors.textPrimary),
  645. decoration: InputDecoration(
  646. hintText: hintText,
  647. hintStyle: TextStyle(
  648. fontSize: 16,
  649. color: colors.textPlaceholder,
  650. ),
  651. border: InputBorder.none,
  652. isDense: true,
  653. contentPadding: EdgeInsets.zero,
  654. ),
  655. onChanged: (_) => setState(() {}),
  656. ),
  657. ),
  658. const SizedBox(width: 4),
  659. SizedBox(
  660. width: 18,
  661. height: 18,
  662. child: hasValue
  663. ? GestureDetector(
  664. onTap: () {
  665. controller.clear();
  666. setState(() {});
  667. },
  668. child: Icon(
  669. Icons.close,
  670. size: 18,
  671. color: tdTheme.textColorPlaceholder,
  672. ),
  673. )
  674. : null,
  675. ),
  676. ],
  677. ),
  678. ),
  679. ],
  680. ),
  681. );
  682. }
  683. // ── 含税金额 ──
  684. Widget _buildAmountCard() {
  685. return _inputCard(
  686. label: _l10n.get('amountInclTax'),
  687. required: true,
  688. controller: _amountCtrl,
  689. hintText: '>0',
  690. colors: Theme.of(context).extension<AppColorsExtension>()!,
  691. keyboardType: const TextInputType.numberWithOptions(decimal: true),
  692. inputFormatters: [
  693. FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$')),
  694. ],
  695. );
  696. }
  697. // ── 税率 ──
  698. Widget _buildTaxRateCard(AppColorsExtension colors) {
  699. final currentLabel = '${_taxRate.toStringAsFixed(0)}%';
  700. final labels = _taxLabels.toList();
  701. return _pickerCard(
  702. label: _l10n.get('taxRate'),
  703. required: false,
  704. hasValue: true,
  705. currentLabel: currentLabel,
  706. onTap: () {
  707. FocusManager.instance.primaryFocus?.unfocus();
  708. TDPicker.showMultiPicker(
  709. context,
  710. title: _l10n.get('taxRate'),
  711. backgroundColor: colors.bgCard,
  712. data: [labels],
  713. onConfirm: (selected) {
  714. if (selected.isNotEmpty && selected[0] is int) {
  715. final idx = selected[0] as int;
  716. if (idx >= 0 && idx < labels.length) {
  717. Navigator.of(context).pop();
  718. setState(() => _taxRate = _taxOptions[idx].toDouble());
  719. }
  720. }
  721. },
  722. );
  723. },
  724. );
  725. }
  726. // ── 计算信息 ──
  727. Widget _buildCalcInfo(AppColorsExtension colors) {
  728. final amount = double.tryParse(_amountCtrl.text) ?? 0;
  729. if (amount <= 0) return const SizedBox.shrink();
  730. return Container(
  731. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
  732. decoration: BoxDecoration(
  733. color: colors.primaryLight,
  734. borderRadius: BorderRadius.circular(8),
  735. ),
  736. child: Row(
  737. children: [
  738. Expanded(
  739. child: Text(
  740. '${_l10n.get('amountExcludingTax')}: ${formatAmount(_amountExclTax)}',
  741. style: TextStyle(
  742. fontSize: AppFontSizes.body,
  743. color: colors.textSecondary,
  744. ),
  745. ),
  746. ),
  747. Text(
  748. '${_l10n.get('taxAmount')}: ${formatAmount(_taxAmount)}',
  749. style: TextStyle(
  750. fontSize: AppFontSizes.body,
  751. color: colors.textSecondary,
  752. ),
  753. ),
  754. ],
  755. ),
  756. );
  757. }
  758. // ── 导入单据信息 ──
  759. Widget _buildAeInfoCard(AppColorsExtension colors) {
  760. final d = widget.initialData!;
  761. final tdTheme = TDTheme.of(context);
  762. final dateStr = d.aeDd.isNotEmpty ? _formatDate(d.aeDd) : d.aeDd;
  763. return Container(
  764. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
  765. decoration: _cardDecoration(tdTheme),
  766. child: Column(
  767. children: [
  768. _readOnlyRow(tdTheme, _l10n.get('expenseApplyNo'), d.aeNo),
  769. const SizedBox(height: 8),
  770. _readOnlyRow(tdTheme, _l10n.get('applyDate'), dateStr),
  771. ],
  772. ),
  773. );
  774. }
  775. String _formatDate(String raw) {
  776. final dt = DateTime.tryParse(raw);
  777. if (dt == null) return raw;
  778. return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
  779. }
  780. Widget _readOnlyRow(TDThemeData tdTheme, String label, String value) {
  781. return Row(
  782. children: [
  783. TDText(
  784. label,
  785. font: tdTheme.fontBodyLarge,
  786. fontWeight: FontWeight.w400,
  787. style: const TextStyle(letterSpacing: 0),
  788. ),
  789. const SizedBox(width: 12),
  790. Expanded(
  791. child: TDText(
  792. value,
  793. maxLines: 1,
  794. overflow: TextOverflow.ellipsis,
  795. font: tdTheme.fontBodyLarge,
  796. fontWeight: FontWeight.w400,
  797. textColor: tdTheme.textColorPrimary,
  798. textAlign: TextAlign.end,
  799. ),
  800. ),
  801. ],
  802. );
  803. }
  804. BoxDecoration _cardDecoration(TDThemeData tdTheme) => BoxDecoration(
  805. color: tdTheme.bgColorContainer,
  806. borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
  807. border: Border.all(color: tdTheme.componentStrokeColor),
  808. );
  809. // ── 申请人 ──
  810. Widget _buildEmployeeCard(AppColorsExtension colors) {
  811. return _pickerCard(
  812. label: _l10n.get('applicant'),
  813. required: false,
  814. hasValue: _selEmployee != null,
  815. currentLabel: _selEmployee != null
  816. ? '${_selEmployee!.salNo}/${_selEmployee!.name}'
  817. : _l10n.get('pleaseSelect'),
  818. onTap: () async {
  819. final result = await showSearchablePicker<EmployeeItem>(
  820. context,
  821. title: '${_l10n.get('select')}${_l10n.get('applicant')}',
  822. searchHint: _l10n.get('search'),
  823. loader: (keyword, page) =>
  824. widget.api.getEmployees(keyword: keyword, page: page, size: 20),
  825. labelBuilder: (e) =>
  826. e.name.isEmpty ? e.salNo : '${e.salNo} ${e.name}',
  827. onRefresh: () => widget.api.clearRefCache(),
  828. );
  829. if (result != null && mounted) {
  830. setState(() {
  831. _selEmployee = result;
  832. _bankNameCtrl.text = result.bnkNo;
  833. _bankAccountNameCtrl.text = result.accName;
  834. _bankAccountCtrl.text = result.bnkId;
  835. });
  836. }
  837. },
  838. onClear: _selEmployee != null
  839. ? () => setState(() {
  840. _selEmployee = null;
  841. _bankNameCtrl.clear();
  842. _bankAccountNameCtrl.clear();
  843. _bankAccountCtrl.clear();
  844. })
  845. : null,
  846. );
  847. }
  848. // ── 客户/厂商 ──
  849. Widget _buildCustomerCard(AppColorsExtension colors) {
  850. return _pickerCard(
  851. label: _l10n.get('customerVendor'),
  852. required: false,
  853. hasValue: _selCustomer != null,
  854. currentLabel: _selCustomer != null
  855. ? '${_selCustomer!.cusNo}/${_selCustomer!.name}'
  856. : _l10n.get('pleaseSelect'),
  857. onTap: () async {
  858. final result = await showSearchablePicker<CustomerItem>(
  859. context,
  860. title: '${_l10n.get('select')}${_l10n.get('customerVendor')}',
  861. searchHint: _l10n.get('search'),
  862. loader: (keyword, page) =>
  863. widget.api.getCustomers(keyword: keyword, page: page, size: 20),
  864. labelBuilder: (v) =>
  865. v.name.isEmpty ? v.cusNo : '${v.cusNo} ${v.name}',
  866. onRefresh: () => widget.api.clearRefCache(),
  867. );
  868. if (result != null && mounted) {
  869. setState(() => _selCustomer = result);
  870. }
  871. },
  872. onClear: _selCustomer != null
  873. ? () => setState(() => _selCustomer = null)
  874. : null,
  875. );
  876. }
  877. // ── 核准金额 ──
  878. Widget _buildApprovedAmountCard() {
  879. if (!widget.canEditApprovedAmount) {
  880. final value = _approvedAmountCtrl.text.isNotEmpty
  881. ? formatAmount(double.tryParse(_approvedAmountCtrl.text) ?? 0)
  882. : '-';
  883. return _readOnlyCard(
  884. label: _l10n.get('approvedAmount'),
  885. value: value,
  886. colors: Theme.of(context).extension<AppColorsExtension>()!,
  887. );
  888. }
  889. return _inputCard(
  890. label: _l10n.get('approvedAmount'),
  891. required: false,
  892. controller: _approvedAmountCtrl,
  893. hintText: '0',
  894. colors: Theme.of(context).extension<AppColorsExtension>()!,
  895. keyboardType: const TextInputType.numberWithOptions(decimal: true),
  896. inputFormatters: [
  897. FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$')),
  898. ],
  899. );
  900. }
  901. // ── 备注 ──
  902. Widget _buildRemarkInput(AppColorsExtension colors) {
  903. final tdTheme = TDTheme.of(context);
  904. return TDTextarea(
  905. controller: _remarkCtrl,
  906. focusNode: _remarkFocus,
  907. label: _l10n.get('remark'),
  908. hintText: _l10n.get('enterRemark'),
  909. maxLines: 3,
  910. minLines: 1,
  911. maxLength: 500,
  912. indicator: true,
  913. decoration: BoxDecoration(
  914. color: tdTheme.bgColorContainer,
  915. borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
  916. border: Border.all(color: tdTheme.componentStrokeColor),
  917. ),
  918. onChanged: (_) => setState(() {}),
  919. );
  920. }
  921. // ── 操作按钮 ──
  922. Widget _buildAttachmentCard(AppColorsExtension colors) {
  923. final tdTheme = TDTheme.of(context);
  924. Widget? hint;
  925. if (!_attachAvailable) {
  926. hint = TDText(
  927. _l10n.get('attachServiceUnavailable'),
  928. font: tdTheme.fontBodySmall,
  929. fontWeight: FontWeight.w400,
  930. textColor: tdTheme.textColorPlaceholder,
  931. );
  932. } else if (widget.billFileRights != null &&
  933. !widget.billFileRights!.canManageAttachments) {
  934. hint = TDText(
  935. _l10n.get('noAttachmentPermission'),
  936. font: tdTheme.fontBodySmall,
  937. fontWeight: FontWeight.w400,
  938. textColor: tdTheme.textColorPlaceholder,
  939. );
  940. }
  941. return Column(
  942. crossAxisAlignment: CrossAxisAlignment.start,
  943. children: [
  944. Padding(
  945. padding: const EdgeInsets.only(left: 4),
  946. child: TDText(
  947. _l10n.get('attachmentUpload'),
  948. font: tdTheme.fontBodyLarge,
  949. fontWeight: FontWeight.w400,
  950. style: const TextStyle(letterSpacing: 0),
  951. ),
  952. ),
  953. const SizedBox(height: 4),
  954. if (hint != null) ...[
  955. Padding(padding: const EdgeInsets.only(left: 4), child: hint),
  956. ] else ...[
  957. Padding(
  958. padding: const EdgeInsets.only(left: 4, top: 4),
  959. child: TDText(
  960. _l10n.get('maxAttachment'),
  961. font: tdTheme.fontBodySmall,
  962. fontWeight: FontWeight.w400,
  963. textColor: tdTheme.textColorPlaceholder,
  964. ),
  965. ),
  966. const SizedBox(height: 4),
  967. AttachmentPicker(
  968. controller: _attachmentCtrl,
  969. maxImageSizeMB: 10,
  970. maxFileSizeMB: 20,
  971. allowedExtensions: const [
  972. 'pdf',
  973. 'doc',
  974. 'docx',
  975. 'xls',
  976. 'xlsx',
  977. 'ppt',
  978. 'pptx',
  979. 'txt',
  980. ],
  981. onFileRejected: (file, reason) {
  982. if (context.mounted) TDToast.showText(reason, context: context);
  983. },
  984. ),
  985. ],
  986. ],
  987. );
  988. }
  989. // ── 会计科目(级联选择器,支持手动修改) ──
  990. Widget _buildAcctSubjectCard(AppColorsExtension colors) {
  991. final tdTheme = TDTheme.of(context);
  992. final hasValue = _selCat.accNo.isNotEmpty;
  993. final displayValue = hasValue
  994. ? (_selCat.accName.startsWith(_selCat.accNo)
  995. ? _selCat.accName
  996. : (_selCat.accName.toString().isNotEmpty
  997. ? '${_selCat.accNo}/${_selCat.accName}'
  998. : _selCat.accNo))
  999. : _l10n.get('pleaseSelect');
  1000. return GestureDetector(
  1001. onTap: () {
  1002. if (widget.acctTree == null) {
  1003. TDToast.showText(_l10n.get('loading'), context: context);
  1004. return;
  1005. }
  1006. FocusManager.instance.primaryFocus?.unfocus();
  1007. TDCascader.showMultiCascader(
  1008. context,
  1009. title: _l10n.get('acctSubject'),
  1010. data: widget.acctTree as List<Map>,
  1011. theme: 'step',
  1012. onChange: (List<MultiCascaderListModel> selected) {
  1013. setState(() {
  1014. _selCat = CostProjectItem(
  1015. idx1: _selCat.idx1,
  1016. name: _selCat.name,
  1017. accNo: (selected.last.value ?? '').replaceFirst('self:', ''),
  1018. accName: (selected.last.label ?? '')
  1019. .replaceFirst(RegExp(r'^> '), '')
  1020. .replaceFirst('${selected.last.value ?? ''}/', ''),
  1021. );
  1022. });
  1023. },
  1024. );
  1025. },
  1026. child: Container(
  1027. padding: const EdgeInsets.only(
  1028. left: 16,
  1029. right: 10,
  1030. top: 12,
  1031. bottom: 12,
  1032. ),
  1033. decoration: BoxDecoration(
  1034. color: tdTheme.bgColorContainer,
  1035. borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
  1036. border: Border.all(color: tdTheme.componentStrokeColor),
  1037. ),
  1038. child: Row(
  1039. children: [
  1040. TDText(
  1041. _l10n.get('acctSubject'),
  1042. maxLines: 1,
  1043. overflow: TextOverflow.visible,
  1044. font: tdTheme.fontBodyLarge,
  1045. fontWeight: FontWeight.w400,
  1046. style: const TextStyle(letterSpacing: 0),
  1047. ),
  1048. const SizedBox(width: 12),
  1049. Expanded(
  1050. child: Row(
  1051. mainAxisAlignment: MainAxisAlignment.end,
  1052. mainAxisSize: MainAxisSize.max,
  1053. children: [
  1054. Flexible(
  1055. child: TDText(
  1056. displayValue,
  1057. maxLines: 1,
  1058. overflow: TextOverflow.ellipsis,
  1059. font: tdTheme.fontBodyLarge,
  1060. fontWeight: FontWeight.w400,
  1061. textColor: hasValue
  1062. ? tdTheme.textColorPrimary
  1063. : tdTheme.textColorPlaceholder,
  1064. textAlign: TextAlign.end,
  1065. ),
  1066. ),
  1067. const SizedBox(width: 4),
  1068. SizedBox(
  1069. width: 18,
  1070. height: 18,
  1071. child: hasValue
  1072. ? GestureDetector(
  1073. onTap: () {
  1074. setState(() {
  1075. _selCat = CostProjectItem(
  1076. idx1: _selCat.idx1,
  1077. name: _selCat.name,
  1078. accNo: '',
  1079. accName: '',
  1080. );
  1081. });
  1082. },
  1083. child: Icon(
  1084. Icons.close,
  1085. size: 18,
  1086. color: tdTheme.textColorPlaceholder,
  1087. ),
  1088. )
  1089. : Icon(
  1090. Icons.chevron_right,
  1091. size: 18,
  1092. color: tdTheme.textColorPlaceholder,
  1093. ),
  1094. ),
  1095. ],
  1096. ),
  1097. ),
  1098. ],
  1099. ),
  1100. ),
  1101. );
  1102. }
  1103. Widget _readOnlyCard({
  1104. required String label,
  1105. required String value,
  1106. required AppColorsExtension colors,
  1107. }) {
  1108. final tdTheme = TDTheme.of(context);
  1109. return Container(
  1110. padding: const EdgeInsets.only(left: 16, right: 16, top: 12, bottom: 12),
  1111. decoration: BoxDecoration(
  1112. color: tdTheme.bgColorContainer,
  1113. borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
  1114. border: Border.all(color: tdTheme.componentStrokeColor),
  1115. ),
  1116. child: Row(
  1117. children: [
  1118. TDText(
  1119. label,
  1120. maxLines: 1,
  1121. overflow: TextOverflow.visible,
  1122. font: tdTheme.fontBodyLarge,
  1123. fontWeight: FontWeight.w400,
  1124. style: const TextStyle(letterSpacing: 0),
  1125. ),
  1126. const SizedBox(width: 12),
  1127. Expanded(
  1128. child: TDText(
  1129. value,
  1130. maxLines: 1,
  1131. overflow: TextOverflow.ellipsis,
  1132. font: tdTheme.fontBodyLarge,
  1133. fontWeight: FontWeight.w400,
  1134. textColor: tdTheme.textColorPrimary,
  1135. textAlign: TextAlign.end,
  1136. ),
  1137. ),
  1138. ],
  1139. ),
  1140. );
  1141. }
  1142. // ── 关联项目 ──
  1143. Widget _buildProjectCard(AppColorsExtension colors) {
  1144. return _pickerCard(
  1145. label: _l10n.get('relatedProject'),
  1146. required: false,
  1147. hasValue: _selProject != null,
  1148. currentLabel: _selProject != null
  1149. ? '${_selProject!.objNo}/${_selProject!.name}'
  1150. : _l10n.get('pleaseSelect'),
  1151. onTap: () async {
  1152. final result = await showSearchablePicker<ProjectCodeItem>(
  1153. context,
  1154. title: '${_l10n.get('select')}${_l10n.get('relatedProject')}',
  1155. searchHint: _l10n.get('search'),
  1156. loader: (keyword, page) => widget.api
  1157. .getProjectCodes(keyword: keyword, page: page, size: 20)
  1158. .then((list) => list.cast<ProjectCodeItem>()),
  1159. labelBuilder: (p) => '${p.objNo}/${p.name}',
  1160. onRefresh: () => widget.api.clearRefCache(),
  1161. );
  1162. if (result != null && mounted) {
  1163. setState(() => _selProject = result);
  1164. }
  1165. },
  1166. onClear: _selProject != null
  1167. ? () => setState(() => _selProject = null)
  1168. : null,
  1169. );
  1170. }
  1171. // ── 费用承担部门 ──
  1172. Widget _buildCostDeptCard(AppColorsExtension colors) {
  1173. return _pickerCard(
  1174. label: _l10n.get('costDept'),
  1175. required: false,
  1176. hasValue: _selDept != null,
  1177. currentLabel: _selDept != null
  1178. ? '${_selDept!.dep}/${_selDept!.name}'
  1179. : _l10n.get('pleaseSelect'),
  1180. onTap: () async {
  1181. final result = await showSearchablePicker<DepartmentItem>(
  1182. context,
  1183. title: '${_l10n.get('select')}${_l10n.get('costDept')}',
  1184. searchHint: _l10n.get('search'),
  1185. loader: (keyword, page) =>
  1186. widget.api.getDepartments(keyword: keyword, page: page, size: 20),
  1187. labelBuilder: (d) => d.name.isEmpty ? d.dep : '${d.dep} ${d.name}',
  1188. onRefresh: () => widget.api.clearRefCache(),
  1189. );
  1190. if (result != null && mounted) {
  1191. setState(() => _selDept = result);
  1192. }
  1193. },
  1194. onClear: _selDept != null ? () => setState(() => _selDept = null) : null,
  1195. );
  1196. }
  1197. // ── 收款银行信息 ──
  1198. Widget _buildBankInfoCard(AppColorsExtension colors) {
  1199. return Column(
  1200. crossAxisAlignment: CrossAxisAlignment.start,
  1201. children: [
  1202. _inputCard(
  1203. label: _l10n.get('bankName'),
  1204. required: false,
  1205. controller: _bankNameCtrl,
  1206. hintText: _l10n.get('pleaseEnter'),
  1207. colors: colors,
  1208. focusNode: _bankNameFocus,
  1209. ),
  1210. const SizedBox(height: 12),
  1211. _inputCard(
  1212. label: _l10n.get('bankAccountName'),
  1213. required: false,
  1214. controller: _bankAccountNameCtrl,
  1215. hintText: _l10n.get('pleaseEnter'),
  1216. colors: colors,
  1217. focusNode: _bankAccountNameFocus,
  1218. ),
  1219. const SizedBox(height: 12),
  1220. _inputCard(
  1221. label: _l10n.get('bankAccount'),
  1222. required: false,
  1223. controller: _bankAccountCtrl,
  1224. hintText: _l10n.get('pleaseEnter'),
  1225. colors: colors,
  1226. focusNode: _bankAccountFocus,
  1227. ),
  1228. ],
  1229. );
  1230. }
  1231. Widget _buildActions() {
  1232. return Row(
  1233. children: [
  1234. Expanded(
  1235. child: TDButton(
  1236. text: _l10n.get('cancel'),
  1237. size: TDButtonSize.large,
  1238. type: TDButtonType.outline,
  1239. shape: TDButtonShape.rectangle,
  1240. theme: TDButtonTheme.defaultTheme,
  1241. onTap: () => Navigator.pop(context),
  1242. ),
  1243. ),
  1244. const SizedBox(width: 12),
  1245. Expanded(
  1246. child: TDButton(
  1247. text: _isEdit ? _l10n.get('confirmEdit') : _l10n.get('add'),
  1248. size: TDButtonSize.large,
  1249. type: TDButtonType.fill,
  1250. shape: TDButtonShape.rectangle,
  1251. theme: TDButtonTheme.primary,
  1252. onTap: _confirm,
  1253. ),
  1254. ),
  1255. ],
  1256. );
  1257. }
  1258. }