expense_apply_create_page.dart 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter/services.dart';
  3. import 'package:flutter_riverpod/flutter_riverpod.dart';
  4. import 'package:go_router/go_router.dart';
  5. import 'package:tdesign_flutter/tdesign_flutter.dart';
  6. import '../../core/i18n/app_localizations.dart';
  7. import '../../core/storage/draft_storage.dart';
  8. import '../../shared/widgets/action_bar.dart';
  9. import '../../shared/widgets/form_section.dart';
  10. import '../../shared/widgets/form_field_row.dart';
  11. import '../../shared/widgets/nav_bar_config.dart';
  12. import '../../shared/widgets/attachment_picker.dart';
  13. import '../../core/theme/app_colors.dart';
  14. import '../../core/theme/app_colors_extension.dart';
  15. import '../../core/constants/enums.dart';
  16. import '../../core/data/mock_api_data.dart';
  17. import 'widgets/expense_apply_detail_dialog.dart';
  18. class ExpenseApplyCreatePage extends ConsumerStatefulWidget {
  19. final String? id;
  20. const ExpenseApplyCreatePage({super.key, this.id});
  21. @override
  22. ConsumerState<ExpenseApplyCreatePage> createState() =>
  23. _ExpenseApplyCreatePageState();
  24. }
  25. class _ExpenseApplyCreatePageState
  26. extends ConsumerState<ExpenseApplyCreatePage> {
  27. static const _draftKey = 'expense_apply';
  28. // ── 基本信息 ──
  29. String _urgency = Urgency.normal.value;
  30. final _purposeController = TextEditingController();
  31. final _purposeFocus = FocusNode();
  32. String _validUntil = '';
  33. final _referenceNoController = TextEditingController();
  34. final _remarkController = TextEditingController();
  35. final _remarkFocus = FocusNode();
  36. final _scrollCtrl = ScrollController();
  37. // ── 费用明细 ──
  38. final List<_DetailItem> _details = [];
  39. int _detailIdCounter = 1;
  40. // ── 附件 ──
  41. late final AttachmentPickerController _attachmentController;
  42. @override
  43. void initState() {
  44. super.initState();
  45. _attachmentController = AttachmentPickerController(maxCount: 9)
  46. ..addListener(() => setState(() {}));
  47. _purposeFocus.addListener(() => _ensureVisible(_purposeFocus));
  48. _remarkFocus.addListener(() => _ensureVisible(_remarkFocus));
  49. WidgetsBinding.instance.addPostFrameCallback((_) => _checkDraft());
  50. }
  51. void _ensureVisible(FocusNode node) {
  52. if (!node.hasFocus) return;
  53. WidgetsBinding.instance.addPostFrameCallback((_) {
  54. if (node.hasFocus && _scrollCtrl.hasClients) {
  55. final ctx = node.context;
  56. if (ctx != null) {
  57. Scrollable.ensureVisible(
  58. ctx,
  59. alignment: 0.3,
  60. duration: const Duration(milliseconds: 300),
  61. );
  62. }
  63. }
  64. });
  65. }
  66. @override
  67. void dispose() {
  68. _purposeController.dispose();
  69. _purposeFocus.dispose();
  70. _referenceNoController.dispose();
  71. _remarkController.dispose();
  72. _remarkFocus.dispose();
  73. _attachmentController.dispose();
  74. _scrollCtrl.dispose();
  75. super.dispose();
  76. }
  77. @override
  78. Widget build(BuildContext context) {
  79. final l10n = AppLocalizations.of(context);
  80. ref
  81. .read(navBarConfigProvider.notifier)
  82. .update(
  83. NavBarConfig(
  84. title: l10n.get('expenseApplyRequest'),
  85. showBack: true,
  86. onBack: () => _doPop(),
  87. ),
  88. );
  89. return PopScope(
  90. canPop: false,
  91. onPopInvokedWithResult: (didPop, _) {
  92. if (didPop) return;
  93. if (_hasUnsaved()) {
  94. _showConfirmDialog(
  95. l10n.get('confirmExit'),
  96. l10n.get('unsavedContentWarning'),
  97. l10n.get('continueEditing'),
  98. l10n.get('discardAndExit'),
  99. () => _doPop(),
  100. );
  101. } else {
  102. _doPop();
  103. }
  104. },
  105. child: Column(
  106. children: [
  107. Expanded(
  108. child: GestureDetector(
  109. onTap: () => FocusScope.of(context).unfocus(),
  110. child: SingleChildScrollView(
  111. controller: _scrollCtrl,
  112. padding: const EdgeInsets.all(16),
  113. child: Column(
  114. children: [
  115. _buildBasicInfo(l10n),
  116. const SizedBox(height: 16),
  117. _buildDetailsSection(l10n),
  118. const SizedBox(height: 16),
  119. _buildAttachmentSection(l10n),
  120. const SizedBox(height: 80),
  121. ],
  122. ),
  123. ),
  124. ),
  125. ),
  126. _buildBottomBar(l10n),
  127. ],
  128. ),
  129. );
  130. }
  131. // ═══ 草稿持久化 ═══
  132. Future<void> _checkDraft() async {
  133. if (!mounted) return;
  134. final has = await DraftStorage.has(_draftKey);
  135. if (!has || !mounted) return;
  136. final l10n = AppLocalizations.of(context);
  137. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  138. final yes = await showDialog<bool>(
  139. context: context,
  140. builder: (ctx) => TDAlertDialog(
  141. title: l10n.get('draftFound'),
  142. content: l10n.get('draftRestorePrompt'),
  143. leftBtn: TDDialogButtonOptions(
  144. title: l10n.get('discard'),
  145. titleColor: colors.textSecondary,
  146. action: () => Navigator.pop(ctx, false),
  147. ),
  148. rightBtn: TDDialogButtonOptions(
  149. title: l10n.get('restore'),
  150. titleColor: colors.primary,
  151. action: () => Navigator.pop(ctx, true),
  152. ),
  153. ),
  154. );
  155. if (yes == true && mounted) {
  156. await _restoreDraft();
  157. } else {
  158. await DraftStorage.delete(_draftKey);
  159. }
  160. }
  161. Future<void> _restoreDraft() async {
  162. final data = await DraftStorage.load(_draftKey);
  163. if (data == null) return;
  164. setState(() {
  165. _urgency = data['urgency'] as String? ?? Urgency.normal.value;
  166. _purposeController.text = data['purpose'] as String? ?? '';
  167. _validUntil = data['validUntil'] as String? ?? '';
  168. _referenceNoController.text = data['referenceNo'] as String? ?? '';
  169. _remarkController.text = data['remark'] as String? ?? '';
  170. final attData = data['attachments'] as List<dynamic>?;
  171. if (attData != null) {
  172. _attachmentController.restoreFromPaths(attData.cast<String>());
  173. }
  174. _details.clear();
  175. final detailList = data['details'] as List<dynamic>?;
  176. if (detailList != null) {
  177. for (final d in detailList) {
  178. final m = d as Map<String, dynamic>;
  179. _details.add(_DetailItem(
  180. id: m['id'] as int? ?? _detailIdCounter++,
  181. category: m['category'] as String? ?? '',
  182. categoryName: m['categoryName'] as String? ?? '',
  183. acctSubjectId: m['acctSubjectId'] as String? ?? '',
  184. acctSubjectName: m['acctSubjectName'] as String? ?? '',
  185. purpose: m['purpose'] as String? ?? '',
  186. projectId: m['projectId'] as int? ?? 0,
  187. projectName: m['projectName'] as String? ?? '',
  188. costDeptId: m['costDeptId'] as String? ?? '',
  189. costDeptName: m['costDeptName'] as String? ?? '',
  190. startDate: m['startDate'] as String? ?? '',
  191. endDate: m['endDate'] as String? ?? '',
  192. estimatedAmount: (m['estimatedAmount'] as num?)?.toDouble() ?? 0,
  193. remark: m['remark'] as String? ?? '',
  194. ));
  195. }
  196. }
  197. _detailIdCounter = _details.isEmpty ? 1 : _details.map((d) => d.id).reduce((a, b) => a > b ? a : b) + 1;
  198. });
  199. }
  200. Future<void> _saveDraftToStorage() async {
  201. final detailList = _details.map((d) => {
  202. 'id': d.id,
  203. 'category': d.category,
  204. 'categoryName': d.categoryName,
  205. 'acctSubjectId': d.acctSubjectId,
  206. 'acctSubjectName': d.acctSubjectName,
  207. 'purpose': d.purpose,
  208. 'projectId': d.projectId,
  209. 'projectName': d.projectName,
  210. 'costDeptId': d.costDeptId,
  211. 'costDeptName': d.costDeptName,
  212. 'startDate': d.startDate,
  213. 'endDate': d.endDate,
  214. 'estimatedAmount': d.estimatedAmount,
  215. 'remark': d.remark,
  216. }).toList();
  217. await DraftStorage.save(_draftKey, {
  218. 'urgency': _urgency,
  219. 'purpose': _purposeController.text,
  220. 'validUntil': _validUntil,
  221. 'referenceNo': _referenceNoController.text,
  222. 'remark': _remarkController.text,
  223. 'attachments': _attachmentController.toPathList(),
  224. 'details': detailList,
  225. });
  226. }
  227. // ═══ 1. 基本信息 ═══
  228. Widget _buildBasicInfo(AppLocalizations l10n) {
  229. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  230. return FormSection(
  231. title: l10n.get('basicInfo'),
  232. leadingIcon: Icons.info_outline,
  233. children: [
  234. FormFieldRow(
  235. label: l10n.get('applicant'),
  236. value: '张三',
  237. readOnly: true,
  238. showArrow: false,
  239. ),
  240. const SizedBox(height: 16),
  241. FormFieldRow(
  242. label: l10n.get('department'),
  243. value: '技术部',
  244. readOnly: true,
  245. showArrow: false,
  246. ),
  247. const SizedBox(height: 16),
  248. FormFieldRow(
  249. label: l10n.get('date'),
  250. value: _today(),
  251. readOnly: true,
  252. showArrow: false,
  253. ),
  254. const SizedBox(height: 16),
  255. _label(l10n.get('emergencyLevel'), required: true),
  256. const SizedBox(height: 8),
  257. _buildUrgencyRadio(l10n),
  258. const SizedBox(height: 16),
  259. _label(l10n.get('feeReason'), required: true),
  260. const SizedBox(height: 8),
  261. TDTextarea(
  262. controller: _purposeController,
  263. focusNode: _purposeFocus,
  264. hintText: l10n.get('enterFeeReason'),
  265. maxLines: 4,
  266. minLines: 1,
  267. maxLength: 500,
  268. indicator: true,
  269. padding: EdgeInsets.zero,
  270. bordered: true,
  271. backgroundColor: colors.bgPage,
  272. ),
  273. const SizedBox(height: 16),
  274. FormFieldRow(
  275. label: l10n.get('validUntil'),
  276. value: _validUntil,
  277. hint: l10n.get('pleaseSelect'),
  278. onTap: () => _pickDate((d) => setState(() => _validUntil = d)),
  279. ),
  280. const SizedBox(height: 16),
  281. FormFieldRow(
  282. label: l10n.get('relatedContractNo'),
  283. value: _referenceNoController.text,
  284. hint: l10n.get('optional'),
  285. onTap: () => _showTextInput(
  286. l10n.get('relatedContractNo'),
  287. (v) => setState(() {
  288. _referenceNoController.text = v;
  289. _referenceNoController.selection = TextSelection.fromPosition(
  290. TextPosition(offset: v.length),
  291. );
  292. }),
  293. initialText: _referenceNoController.text,
  294. ),
  295. ),
  296. const SizedBox(height: 16),
  297. _label(l10n.get('remark')),
  298. const SizedBox(height: 8),
  299. TDTextarea(
  300. controller: _remarkController,
  301. focusNode: _remarkFocus,
  302. hintText: l10n.get('enterRemark'),
  303. maxLines: 3,
  304. minLines: 1,
  305. maxLength: 500,
  306. indicator: true,
  307. padding: EdgeInsets.zero,
  308. bordered: true,
  309. backgroundColor: colors.bgPage,
  310. ),
  311. ],
  312. );
  313. }
  314. Widget _buildUrgencyRadio(AppLocalizations l10n) {
  315. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  316. return Row(
  317. children: Urgency.values.asMap().entries.map((e) {
  318. final sel = _urgency == e.value.value;
  319. final isCritical = e.value.value == Urgency.critical.value;
  320. final activeColor = isCritical ? colors.danger : colors.primary;
  321. return Padding(
  322. padding: EdgeInsets.only(right: e.key < 2 ? 24 : 0),
  323. child: GestureDetector(
  324. behavior: HitTestBehavior.opaque,
  325. onTap: () => setState(() => _urgency = e.value.value),
  326. child: Row(
  327. mainAxisSize: MainAxisSize.min,
  328. children: [
  329. Container(
  330. width: 18,
  331. height: 18,
  332. decoration: BoxDecoration(
  333. shape: BoxShape.circle,
  334. border: Border.all(
  335. color: sel ? activeColor : colors.textPlaceholder,
  336. width: 2,
  337. ),
  338. ),
  339. child: sel
  340. ? Center(
  341. child: Container(
  342. width: 8,
  343. height: 8,
  344. decoration: BoxDecoration(
  345. shape: BoxShape.circle,
  346. color: activeColor,
  347. ),
  348. ),
  349. )
  350. : null,
  351. ),
  352. const SizedBox(width: 6),
  353. Text(
  354. l10n.get(e.value.labelKey),
  355. style: TextStyle(
  356. fontSize: AppFontSizes.subtitle,
  357. color: sel ? activeColor : colors.textPrimary,
  358. ),
  359. ),
  360. ],
  361. ),
  362. ),
  363. );
  364. }).toList(),
  365. );
  366. }
  367. // ═══ 2. 费用明细 ═══
  368. Widget _buildDetailsSection(AppLocalizations l10n) {
  369. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  370. return FormSection(
  371. title: l10n.get('expenseDetails'),
  372. leadingIcon: Icons.receipt_long_outlined,
  373. showAction: true,
  374. actionText: l10n.get('add'),
  375. onActionTap: _showDetailDialog,
  376. children: [
  377. if (_details.isEmpty)
  378. Padding(
  379. padding: const EdgeInsets.symmetric(vertical: 8),
  380. child: Text(
  381. l10n.get('noDetailHint'),
  382. style: TextStyle(
  383. fontSize: AppFontSizes.subtitle,
  384. color: colors.textPlaceholder,
  385. ),
  386. ),
  387. )
  388. else
  389. ..._details.asMap().entries.map((e) {
  390. final d = e.value;
  391. return Container(
  392. margin: const EdgeInsets.symmetric(vertical: 8),
  393. padding: const EdgeInsets.all(12),
  394. decoration: BoxDecoration(
  395. color: colors.bgPage,
  396. borderRadius: BorderRadius.circular(8),
  397. ),
  398. child: Row(
  399. children: [
  400. Expanded(
  401. flex: 3,
  402. child: Column(
  403. crossAxisAlignment: CrossAxisAlignment.start,
  404. children: [
  405. Text(
  406. d.categoryName,
  407. style: TextStyle(
  408. fontSize: AppFontSizes.subtitle,
  409. color: colors.textPrimary,
  410. ),
  411. ),
  412. if (d.purpose.isNotEmpty)
  413. Text(
  414. d.purpose,
  415. maxLines: 2,
  416. overflow: TextOverflow.ellipsis,
  417. style: TextStyle(
  418. fontSize: AppFontSizes.caption,
  419. color: colors.textPrimary,
  420. ),
  421. ),
  422. const SizedBox(height: 4),
  423. Text(
  424. '¥${d.estimatedAmount.toStringAsFixed(2)}',
  425. style: TextStyle(
  426. fontSize: AppFontSizes.caption,
  427. fontWeight: FontWeight.w600,
  428. color: colors.amountPrimary,
  429. ),
  430. ),
  431. if (d.projectName.isNotEmpty)
  432. Text(
  433. '${d.projectName} | ${d.acctSubjectName}',
  434. style: TextStyle(
  435. fontSize: AppFontSizes.caption,
  436. color: colors.textSecondary,
  437. ),
  438. ),
  439. if (d.remark.isNotEmpty)
  440. Text(
  441. d.remark,
  442. maxLines: 1,
  443. overflow: TextOverflow.ellipsis,
  444. style: TextStyle(
  445. fontSize: AppFontSizes.caption,
  446. color: colors.textPlaceholder,
  447. ),
  448. ),
  449. ],
  450. ),
  451. ),
  452. GestureDetector(
  453. onTap: () => setState(() => _details.removeAt(e.key)),
  454. child: Container(
  455. width: 24,
  456. height: 24,
  457. decoration: BoxDecoration(
  458. color: colors.primaryLight,
  459. shape: BoxShape.circle,
  460. ),
  461. child: Icon(
  462. Icons.close,
  463. size: 14,
  464. color: colors.primary700,
  465. ),
  466. ),
  467. ),
  468. ],
  469. ),
  470. );
  471. }),
  472. const SizedBox(height: 8),
  473. Container(
  474. height: 36,
  475. padding: const EdgeInsets.symmetric(vertical: 8),
  476. child: Row(
  477. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  478. children: [
  479. Text(
  480. l10n.get('total'),
  481. style: TextStyle(
  482. fontSize: AppFontSizes.body,
  483. fontWeight: FontWeight.w600,
  484. color: colors.textPrimary,
  485. ),
  486. ),
  487. Text(
  488. '¥${_totalAmount().toStringAsFixed(2)}',
  489. style: TextStyle(
  490. fontSize: AppFontSizes.subtitle,
  491. fontWeight: FontWeight.w700,
  492. color: colors.amountPrimary,
  493. ),
  494. ),
  495. ],
  496. ),
  497. ),
  498. ],
  499. );
  500. }
  501. double _totalAmount() =>
  502. _details.fold(0, (s, d) => s + d.estimatedAmount);
  503. Future<void> _showDetailDialog() async {
  504. final l10n = AppLocalizations.of(context);
  505. final result = await ExpenseApplyDetailDialog.show(
  506. context,
  507. categories: mockCostCategories,
  508. projects: mockProjects,
  509. costDepts: mockCostDepts,
  510. l10n: l10n,
  511. );
  512. if (result != null && mounted) {
  513. setState(
  514. () => _details.add(
  515. _DetailItem(
  516. id: _detailIdCounter++,
  517. category: result.category,
  518. categoryName: result.categoryName,
  519. acctSubjectId: result.acctSubjectId,
  520. acctSubjectName: result.acctSubjectName,
  521. purpose: result.purpose,
  522. projectId: result.projectId,
  523. projectName: result.projectName,
  524. costDeptId: result.costDeptId,
  525. costDeptName: result.costDeptName,
  526. startDate: result.startDate,
  527. endDate: result.endDate,
  528. estimatedAmount: result.estimatedAmount,
  529. remark: result.remark,
  530. ),
  531. ),
  532. );
  533. }
  534. }
  535. // ═══ 3. 附件上传 ═══
  536. Widget _buildAttachmentSection(AppLocalizations l10n) {
  537. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  538. return FormSection(
  539. title: l10n.get('attachmentUpload'),
  540. leadingIcon: Icons.attach_file_outlined,
  541. children: [
  542. Text(
  543. l10n.get('maxAttachment'),
  544. style: TextStyle(
  545. fontSize: AppFontSizes.caption,
  546. color: colors.textPlaceholder,
  547. ),
  548. ),
  549. const SizedBox(height: 8),
  550. AttachmentPicker(
  551. controller: _attachmentController,
  552. maxImageSizeMB: 10,
  553. maxFileSizeMB: 20,
  554. allowedExtensions: const [
  555. 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt',
  556. ],
  557. onFileRejected: (file, reason) {
  558. if (context.mounted) {
  559. TDToast.showText(reason, context: context);
  560. }
  561. },
  562. ),
  563. ],
  564. );
  565. }
  566. // ═══ 4. 底部操作栏 ═══
  567. Widget _buildBottomBar(AppLocalizations l10n) {
  568. final isDraft = widget.id != null;
  569. return ActionBar(
  570. leftLabel: isDraft ? l10n.get('reset') : null,
  571. centerLabel: l10n.get('saveDraft'),
  572. rightLabel: l10n.get('submitApproval'),
  573. showLeft: isDraft,
  574. onLeftTap: isDraft
  575. ? () => _showConfirmDialog(
  576. l10n.get('confirmReset'),
  577. l10n.get('resetWarning'),
  578. l10n.get('cancel'),
  579. l10n.get('confirmReset'),
  580. _resetAll,
  581. )
  582. : null,
  583. onCenterTap: () async {
  584. await _saveDraftToStorage();
  585. if (mounted) {
  586. TDToast.showSuccess(l10n.get('draftSavedToast'), context: context);
  587. context.pop();
  588. }
  589. },
  590. onRightTap: () async {
  591. final err = _validate(l10n);
  592. if (err.isNotEmpty) {
  593. TDToast.showText(err.first, context: context);
  594. return;
  595. }
  596. await DraftStorage.delete(_draftKey);
  597. if (mounted) {
  598. TDToast.showSuccess(
  599. l10n.get('submittedAwaitingApproval'),
  600. context: context,
  601. );
  602. context.pop();
  603. }
  604. },
  605. );
  606. }
  607. List<String> _validate(AppLocalizations l10n) {
  608. final e = <String>[];
  609. if (_purposeController.text.trim().isEmpty) {
  610. e.add(l10n.get('enterFeeReason'));
  611. }
  612. if (_details.isEmpty) e.add(l10n.get('addAtLeastOneDetail'));
  613. return e;
  614. }
  615. void _resetAll() => setState(() {
  616. _purposeController.clear();
  617. _urgency = Urgency.normal.value;
  618. _validUntil = '';
  619. _referenceNoController.clear();
  620. _remarkController.clear();
  621. _details.clear();
  622. _attachmentController.clear();
  623. });
  624. void _doPop() {
  625. final router = GoRouter.of(context);
  626. if (router.canPop()) {
  627. router.pop();
  628. } else {
  629. SystemNavigator.pop();
  630. }
  631. }
  632. bool _hasUnsaved() =>
  633. _purposeController.text.isNotEmpty ||
  634. _details.isNotEmpty ||
  635. _attachmentController.files.isNotEmpty ||
  636. _referenceNoController.text.isNotEmpty ||
  637. _remarkController.text.isNotEmpty;
  638. void _unfocus() => FocusScope.of(context).unfocus();
  639. // ═══ 通用弹窗方法 ═══
  640. void _showConfirmDialog(
  641. String title,
  642. String content,
  643. String leftText,
  644. String rightText,
  645. VoidCallback onConfirm,
  646. ) {
  647. _unfocus();
  648. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  649. showDialog(
  650. context: context,
  651. builder: (ctx) => TDAlertDialog(
  652. title: title,
  653. content: content,
  654. buttonStyle: TDDialogButtonStyle.text,
  655. leftBtn: TDDialogButtonOptions(
  656. title: leftText,
  657. titleColor: colors.primary,
  658. action: () => Navigator.pop(ctx),
  659. ),
  660. rightBtn: TDDialogButtonOptions(
  661. title: rightText,
  662. titleColor: colors.danger,
  663. action: () {
  664. Navigator.pop(ctx);
  665. onConfirm();
  666. },
  667. ),
  668. ),
  669. );
  670. }
  671. void _showTextInput(
  672. String title,
  673. Function(String) onConfirm, {
  674. String initialText = '',
  675. }) {
  676. _unfocus();
  677. final l10n = AppLocalizations.of(context);
  678. final c = TextEditingController(text: initialText);
  679. showGeneralDialog(
  680. context: context,
  681. pageBuilder: (ctx, animation, secondaryAnimation) => TDInputDialog(
  682. textEditingController: c,
  683. title: title,
  684. hintText: l10n.get('pleaseEnter'),
  685. leftBtn: TDDialogButtonOptions(
  686. title: l10n.get('cancel'),
  687. action: () => Navigator.pop(ctx),
  688. ),
  689. rightBtn: TDDialogButtonOptions(
  690. title: l10n.get('confirm'),
  691. action: () {
  692. onConfirm(c.text);
  693. Navigator.pop(ctx);
  694. },
  695. ),
  696. ),
  697. );
  698. }
  699. void _pickDate(Function(String) onPick) {
  700. _unfocus();
  701. final l10n = AppLocalizations.of(context);
  702. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  703. final theme = Theme.of(context);
  704. final now = DateTime.now();
  705. showModalBottomSheet(
  706. context: context,
  707. backgroundColor: Colors.transparent,
  708. builder: (ctx) => Theme(
  709. data: theme,
  710. child: TDDatePicker(
  711. title: l10n.get('selectDate'),
  712. backgroundColor: colors.bgCard,
  713. model: DatePickerModel(
  714. useYear: true,
  715. useMonth: true,
  716. useDay: true,
  717. useHour: false,
  718. useMinute: false,
  719. useSecond: false,
  720. useWeekDay: false,
  721. dateStart: [2020, 1, 1],
  722. dateEnd: [now.year + 1, 12, 31],
  723. dateInitial: [now.year, now.month, now.day],
  724. ),
  725. onConfirm: (selected) {
  726. onPick(
  727. '${selected['year']}-${selected['month']!.toString().padLeft(2, '0')}-${selected['day']!.toString().padLeft(2, '0')}',
  728. );
  729. Navigator.of(ctx).pop();
  730. },
  731. onCancel: (_) => Navigator.of(ctx).pop(),
  732. ),
  733. ),
  734. );
  735. }
  736. Widget _label(String t, {bool required = false}) {
  737. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  738. return Text.rich(
  739. TextSpan(
  740. children: [
  741. TextSpan(
  742. text: t,
  743. style: TextStyle(
  744. fontSize: AppFontSizes.subtitle,
  745. color: colors.textSecondary,
  746. ),
  747. ),
  748. if (required)
  749. TextSpan(
  750. text: ' *',
  751. style: TextStyle(
  752. fontSize: AppFontSizes.subtitle,
  753. color: colors.danger,
  754. ),
  755. ),
  756. ],
  757. ),
  758. );
  759. }
  760. String _today() {
  761. final n = DateTime.now();
  762. return '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}';
  763. }
  764. }
  765. class _DetailItem {
  766. final int id;
  767. final String category;
  768. final String categoryName;
  769. final String acctSubjectId;
  770. final String acctSubjectName;
  771. final String purpose;
  772. final int projectId;
  773. final String projectName;
  774. final String costDeptId;
  775. final String costDeptName;
  776. final String startDate;
  777. final String endDate;
  778. final double estimatedAmount;
  779. final String remark;
  780. const _DetailItem({
  781. required this.id,
  782. required this.category,
  783. required this.categoryName,
  784. required this.acctSubjectId,
  785. required this.acctSubjectName,
  786. required this.purpose,
  787. required this.projectId,
  788. required this.projectName,
  789. required this.costDeptId,
  790. required this.costDeptName,
  791. required this.startDate,
  792. required this.endDate,
  793. required this.estimatedAmount,
  794. required this.remark,
  795. });
  796. }