expense_detail_page.dart 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961
  1. import 'dart:typed_data';
  2. import 'package:flutter/material.dart';
  3. import 'package:tdesign_flutter/tdesign_flutter.dart';
  4. import 'package:flutter_riverpod/flutter_riverpod.dart';
  5. import 'package:go_router/go_router.dart';
  6. import '../../shared/widgets/loading_dialog.dart';
  7. import '../../core/utils/date_utils.dart' as du;
  8. import '../../shared/widgets/form_section.dart';
  9. import '../../shared/widgets/form_field_row.dart';
  10. import '../../shared/widgets/app_skeletons.dart';
  11. import '../../shared/widgets/attachment_download_helper.dart';
  12. import '../../shared/widgets/action_bar.dart';
  13. import '../../shared/widgets/status_banner.dart';
  14. import 'expense_model.dart';
  15. import '../../core/i18n/app_localizations.dart';
  16. import '../../shared/models/bill_attachment.dart';
  17. import '../../shared/models/bill_file_rights.dart';
  18. import '../../core/theme/app_colors.dart';
  19. import '../../core/theme/app_colors_extension.dart';
  20. import 'expense_api.dart';
  21. import 'dart:io';
  22. import 'package:path_provider/path_provider.dart';
  23. import 'package:open_filex/open_filex.dart';
  24. import '../../shared/widgets/attachment_preview_page.dart';
  25. import 'widgets/expense_detail_view_dialog.dart';
  26. class ExpenseDetailPage extends ConsumerStatefulWidget {
  27. final String billNo;
  28. final int queryId;
  29. const ExpenseDetailPage({super.key, required this.billNo, this.queryId = 0});
  30. @override
  31. ConsumerState<ExpenseDetailPage> createState() => _ExpenseDetailPageState();
  32. }
  33. class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
  34. ExpenseModel? _expense;
  35. List<BillAttachment> _attachments = [];
  36. bool _attachAvailable = false;
  37. BillFileRights _billFileRights = BillFileRights.none;
  38. bool _isLoading = true;
  39. String? _error;
  40. Map<String, dynamic>? _billStatus;
  41. bool _statusLoading = false;
  42. bool _canEdit = false;
  43. @override
  44. void initState() {
  45. super.initState();
  46. _loadData();
  47. }
  48. @override
  49. void dispose() {
  50. super.dispose();
  51. }
  52. Future<void> _loadData() async {
  53. setState(() {
  54. _isLoading = true;
  55. _error = null;
  56. });
  57. try {
  58. final api = ref.read(expenseApiProvider);
  59. // 1. 加载报销详情(主表 + 明细)
  60. final expense = await api.fetchDetail(widget.billNo);
  61. setState(() => _expense = expense);
  62. // 2. 加载附件权限(非致命)
  63. BillFileRights billFileRights = BillFileRights.none;
  64. try {
  65. billFileRights = await api.getBillFileRights('BX');
  66. } catch (_) {}
  67. // 3. 加载附件(非致命)
  68. try {
  69. _attachAvailable = await api.checkAttachHealth();
  70. } catch (_) {
  71. _attachAvailable = false;
  72. }
  73. _billFileRights = billFileRights;
  74. if (_attachAvailable && billFileRights.canBrowseAttachments) {
  75. try {
  76. _attachments = await api.getAttachments('BX', widget.billNo);
  77. } catch (_) {
  78. _attachments = [];
  79. }
  80. }
  81. // 3. 获取单据状态(非致命)
  82. setState(() => _statusLoading = true);
  83. try {
  84. final status = await api.getBillStatus(widget.billNo);
  85. if (mounted) {
  86. setState(() {
  87. _billStatus = status;
  88. _canEdit = status['canEdit'] == true;
  89. _statusLoading = false;
  90. });
  91. }
  92. } catch (_) {
  93. if (mounted) setState(() => _statusLoading = false);
  94. }
  95. } catch (e) {
  96. setState(() => _error = e.toString());
  97. } finally {
  98. setState(() {
  99. _isLoading = false;
  100. });
  101. }
  102. }
  103. @override
  104. Widget build(BuildContext context) {
  105. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  106. final l10n = AppLocalizations.of(context);
  107. if (_isLoading) {
  108. return const SkeletonDetailPage();
  109. }
  110. if (_error != null) {
  111. return Center(
  112. child: Column(
  113. mainAxisAlignment: MainAxisAlignment.center,
  114. children: [
  115. Icon(Icons.error_outline, size: 48, color: colors.danger),
  116. const SizedBox(height: 12),
  117. Text(
  118. _error!,
  119. style: TextStyle(
  120. fontSize: AppFontSizes.body,
  121. color: colors.danger,
  122. ),
  123. textAlign: TextAlign.center,
  124. ),
  125. const SizedBox(height: 16),
  126. TDButton(
  127. text: l10n.get('retry'),
  128. theme: TDButtonTheme.primary,
  129. onTap: _loadData,
  130. ),
  131. ],
  132. ),
  133. );
  134. }
  135. final expense = _expense!;
  136. return Column(
  137. children: [
  138. Expanded(
  139. child: SingleChildScrollView(
  140. physics: const AlwaysScrollableScrollPhysics(),
  141. padding: const EdgeInsets.all(16),
  142. child: Column(
  143. children: [
  144. if (_statusLoading) ...[
  145. const Padding(
  146. padding: EdgeInsets.symmetric(vertical: 8),
  147. child: Center(
  148. child: TDLoading(
  149. size: TDLoadingSize.small,
  150. icon: TDLoadingIcon.activity,
  151. ),
  152. ),
  153. ),
  154. ],
  155. Builder(
  156. builder: (ctx) {
  157. final badge = _buildStatusBadge(l10n);
  158. if (badge == null) return const SizedBox.shrink();
  159. return Column(
  160. children: [badge, const SizedBox(height: 16)],
  161. );
  162. },
  163. ),
  164. _buildBasicInfoSection(expense, l10n, colors),
  165. const SizedBox(height: 16),
  166. _buildExpenseDetailSection(expense, l10n, colors),
  167. const SizedBox(height: 16),
  168. _buildAttachmentSection(l10n, colors),
  169. const SizedBox(height: 24),
  170. _buildPageFooter(colors),
  171. ],
  172. ),
  173. ),
  174. ),
  175. if (_canEdit)
  176. ActionBar(
  177. showLeft: false,
  178. showCenter: false,
  179. rightLabel: l10n.get('editExpense'),
  180. onRightTap: () async {
  181. final result = await GoRouter.of(
  182. context,
  183. ).push('/expense/edit/${widget.billNo}');
  184. if (result == true && mounted) _loadData();
  185. },
  186. ),
  187. ],
  188. );
  189. }
  190. // ═══ 状态 badge ═══
  191. Widget? _buildStatusBadge(AppLocalizations l10n) {
  192. final isClosed = _billStatus?['isClosed'] == true;
  193. final isTransferred = _billStatus?['isTransferred'] == true;
  194. final approvalStatus = _billStatus?['approvalStatus'] as String?;
  195. if (isClosed) {
  196. return StatusBanner(
  197. icon: Icons.lock_outline,
  198. statusText: l10n.get('statusClosed'),
  199. subText: '',
  200. color: Colors.blueGrey,
  201. );
  202. }
  203. if (isTransferred) {
  204. return StatusBanner(
  205. icon: Icons.swap_horiz,
  206. statusText: l10n.get('statusTransferred'),
  207. subText: '',
  208. color: Colors.blueGrey,
  209. );
  210. }
  211. switch (approvalStatus) {
  212. case 'SHCOUNT4':
  213. return StatusBanner(
  214. icon: Icons.check_circle_outline,
  215. statusText: l10n.get('statusApproved'),
  216. subText: '',
  217. color: Colors.green,
  218. );
  219. case 'SHCOUNT5':
  220. return StatusBanner(
  221. icon: Icons.cancel_outlined,
  222. statusText: l10n.get('statusFinalRejected'),
  223. subText: '',
  224. color: Colors.red,
  225. );
  226. case 'SHCOUNT1':
  227. return StatusBanner(
  228. icon: Icons.hourglass_empty,
  229. statusText: l10n.get('statusPendingReview'),
  230. subText: '',
  231. color: Colors.blue,
  232. );
  233. case 'SHCOUNT2':
  234. return StatusBanner(
  235. icon: Icons.block_outlined,
  236. statusText: l10n.get('statusReviewRejected'),
  237. subText: '',
  238. color: Colors.deepOrange,
  239. );
  240. default:
  241. return null;
  242. }
  243. }
  244. // ═══ 基本信息 ═══
  245. Widget _buildBasicInfoSection(
  246. ExpenseModel expense,
  247. AppLocalizations l10n,
  248. AppColorsExtension colors,
  249. ) {
  250. return FormSection(
  251. title: l10n.get('basicInfo'),
  252. leadingIcon: Icons.info_outline,
  253. children: [
  254. FormFieldRow(
  255. label: l10n.get('expenseNo'),
  256. value: expense.expenseNo,
  257. readOnly: true,
  258. showArrow: false,
  259. ),
  260. const SizedBox(height: 16),
  261. FormFieldRow(
  262. label: l10n.get('date'),
  263. value: du.DateUtils.formatDate(expense.createTime),
  264. readOnly: true,
  265. showArrow: false,
  266. ),
  267. const SizedBox(height: 16),
  268. FormFieldRow(
  269. label: l10n.get('expensePersonnel'),
  270. value: expense.applicantId.isNotEmpty
  271. ? '${expense.applicantId}${expense.applicantName.isNotEmpty ? '/${expense.applicantName}' : ''}'
  272. : '-',
  273. readOnly: true,
  274. showArrow: false,
  275. ),
  276. const SizedBox(height: 16),
  277. FormFieldRow(
  278. label: l10n.get('expenseDept'),
  279. value: expense.deptId.isNotEmpty
  280. ? '${expense.deptId}${expense.deptName.isNotEmpty ? '/${expense.deptName}' : ''}'
  281. : '-',
  282. readOnly: true,
  283. showArrow: false,
  284. ),
  285. const SizedBox(height: 16),
  286. SizedBox(
  287. height: 24,
  288. child: Row(
  289. crossAxisAlignment: CrossAxisAlignment.center,
  290. children: [
  291. Text(
  292. l10n.get('expenseReason'),
  293. style: TextStyle(
  294. fontSize: AppFontSizes.subtitle,
  295. color: colors.textSecondary,
  296. ),
  297. ),
  298. const SizedBox(width: 8),
  299. Expanded(
  300. child: Text(
  301. expense.purpose.isNotEmpty ? expense.purpose : '-',
  302. textAlign: TextAlign.end,
  303. maxLines: 2,
  304. overflow: TextOverflow.ellipsis,
  305. style: TextStyle(
  306. fontSize: AppFontSizes.subtitle,
  307. fontWeight: FontWeight.w500,
  308. color: colors.textPrimary,
  309. ),
  310. ),
  311. ),
  312. ],
  313. ),
  314. ),
  315. const SizedBox(height: 16),
  316. FormFieldRow(
  317. label: l10n.get('voucherNo'),
  318. value: expense.voucherNo.isNotEmpty ? expense.voucherNo : '-',
  319. readOnly: true,
  320. showArrow: false,
  321. ),
  322. const SizedBox(height: 16),
  323. FormFieldRow(
  324. label: l10n.get('currency'),
  325. value: expense.currencyCode.isNotEmpty ? expense.currencyCode : '-',
  326. readOnly: true,
  327. showArrow: false,
  328. ),
  329. const SizedBox(height: 16),
  330. FormFieldRow(
  331. label: l10n.get('paymentMethod'),
  332. value: expense.paymentMethod.isNotEmpty ? expense.paymentMethod : '-',
  333. readOnly: true,
  334. showArrow: false,
  335. ),
  336. const SizedBox(height: 16),
  337. FormFieldRow(
  338. label: l10n.get('remark'),
  339. value: expense.remark.isNotEmpty ? expense.remark : '-',
  340. readOnly: true,
  341. showArrow: false,
  342. ),
  343. ],
  344. );
  345. }
  346. // ═══ 费用明细 ═══
  347. Widget _buildExpenseDetailSection(
  348. ExpenseModel expense,
  349. AppLocalizations l10n,
  350. AppColorsExtension colors,
  351. ) {
  352. final totalAmount = expense.details.fold<double>(
  353. 0,
  354. (sum, d) => sum + d.totalAmount,
  355. );
  356. final totalApproved = expense.details.fold<double>(
  357. 0,
  358. (sum, d) => sum + d.approvedAmount,
  359. );
  360. return FormSection(
  361. title: l10n.get('expenseDetails'),
  362. leadingIcon: Icons.receipt_long_outlined,
  363. children: [
  364. if (expense.details.isEmpty)
  365. Padding(
  366. padding: const EdgeInsets.symmetric(vertical: 8),
  367. child: Text(
  368. l10n.get('noDetailData'),
  369. style: TextStyle(
  370. fontSize: AppFontSizes.body,
  371. color: colors.textPlaceholder,
  372. ),
  373. ),
  374. )
  375. else
  376. ...expense.details.asMap().entries.map((e) {
  377. final d = e.value;
  378. final title = d.categoryName.isNotEmpty
  379. ? '${d.expenseCategory}/${d.categoryName}'
  380. : d.expenseCategory;
  381. return GestureDetector(
  382. onTap: () => _showExpenseDetailDialog(context, d),
  383. child: Container(
  384. margin: const EdgeInsets.symmetric(vertical: 6),
  385. padding: const EdgeInsets.all(12),
  386. decoration: BoxDecoration(
  387. color: colors.bgPage,
  388. borderRadius: BorderRadius.circular(8),
  389. ),
  390. child: Row(
  391. children: [
  392. Expanded(
  393. child: Column(
  394. crossAxisAlignment: CrossAxisAlignment.start,
  395. children: [
  396. Row(
  397. children: [
  398. Expanded(
  399. child: Text(
  400. title,
  401. style: TextStyle(
  402. fontSize: AppFontSizes.body,
  403. fontWeight: FontWeight.w500,
  404. color: colors.textPrimary,
  405. ),
  406. ),
  407. ),
  408. Column(
  409. crossAxisAlignment: CrossAxisAlignment.end,
  410. children: [
  411. Text(
  412. '¥${d.totalAmount.toStringAsFixed(2)}',
  413. style: TextStyle(
  414. fontSize: AppFontSizes.body,
  415. fontWeight: FontWeight.w600,
  416. color: colors.amountPrimary,
  417. ),
  418. ),
  419. if (d.approvedAmount > 0)
  420. Text(
  421. '¥${d.approvedAmount.toStringAsFixed(2)}',
  422. style: TextStyle(
  423. fontSize: AppFontSizes.body,
  424. fontWeight: FontWeight.w600,
  425. color: colors.success,
  426. ),
  427. ),
  428. ],
  429. ),
  430. ],
  431. ),
  432. const SizedBox(height: 2),
  433. Text(
  434. '${l10n.get('amountExcludingTax')}: ¥${d.amount.toStringAsFixed(2)}',
  435. style: TextStyle(
  436. fontSize: AppFontSizes.caption,
  437. color: colors.textSecondary,
  438. ),
  439. ),
  440. if (d.taxAmount > 0)
  441. Text(
  442. '${l10n.get('taxAmount')}: ¥${d.taxAmount.toStringAsFixed(2)}',
  443. style: TextStyle(
  444. fontSize: AppFontSizes.caption,
  445. color: colors.textSecondary,
  446. ),
  447. ),
  448. if (d.taxRate > 0)
  449. Text(
  450. '${l10n.get('taxRate')}: ${d.taxRate.toStringAsFixed(0)}%',
  451. style: TextStyle(
  452. fontSize: AppFontSizes.caption,
  453. color: colors.textSecondary,
  454. ),
  455. ),
  456. if (d.acctSubjectId.isNotEmpty)
  457. Text(
  458. '${l10n.get('acctSubject')}: ${d.acctSubjectId}${d.acctSubjectName.isNotEmpty ? '/${d.acctSubjectName}' : ''}',
  459. maxLines: 1,
  460. overflow: TextOverflow.ellipsis,
  461. style: TextStyle(
  462. fontSize: AppFontSizes.caption,
  463. color: colors.textSecondary,
  464. ),
  465. ),
  466. if (d.aeNo.isNotEmpty)
  467. Text(
  468. '${l10n.get('expenseApplyNo')}: ${d.aeNo}',
  469. maxLines: 1,
  470. overflow: TextOverflow.ellipsis,
  471. style: TextStyle(
  472. fontSize: AppFontSizes.caption,
  473. color: colors.textSecondary,
  474. ),
  475. ),
  476. if (d.aeDd.isNotEmpty)
  477. Text(
  478. '${l10n.get('applyDate')}: ${d.aeDd.length >= 10 ? d.aeDd.substring(0, 10) : d.aeDd}',
  479. style: TextStyle(
  480. fontSize: AppFontSizes.caption,
  481. color: colors.textSecondary,
  482. ),
  483. ),
  484. if (d.projectId.isNotEmpty)
  485. Text(
  486. '${l10n.get('project')}: ${d.projectId}${d.projectName.isNotEmpty ? '/${d.projectName}' : ''}',
  487. maxLines: 1,
  488. overflow: TextOverflow.ellipsis,
  489. style: TextStyle(
  490. fontSize: AppFontSizes.caption,
  491. color: colors.textSecondary,
  492. ),
  493. ),
  494. if (d.costDeptId.isNotEmpty)
  495. Text(
  496. '${l10n.get('costDept')}: ${d.costDeptId}${d.costDeptName.isNotEmpty ? '/${d.costDeptName}' : ''}',
  497. maxLines: 1,
  498. overflow: TextOverflow.ellipsis,
  499. style: TextStyle(
  500. fontSize: AppFontSizes.caption,
  501. color: colors.textSecondary,
  502. ),
  503. ),
  504. if (d.customerVendorId.isNotEmpty)
  505. Text(
  506. '${l10n.get('customerVendor')}: ${d.customerVendorId}${d.customerVendorName.isNotEmpty ? '/${d.customerVendorName}' : ''}',
  507. maxLines: 1,
  508. overflow: TextOverflow.ellipsis,
  509. style: TextStyle(
  510. fontSize: AppFontSizes.caption,
  511. color: colors.textSecondary,
  512. ),
  513. ),
  514. if (d.sqMan.isNotEmpty)
  515. Text(
  516. '${l10n.get('applicant')}: ${d.sqMan}${d.sqManName.isNotEmpty ? '/${d.sqManName}' : ''}',
  517. style: TextStyle(
  518. fontSize: AppFontSizes.caption,
  519. color: colors.textSecondary,
  520. ),
  521. ),
  522. if (d.bankAccountName.isNotEmpty)
  523. Text(
  524. '${l10n.get('bankAccountName')}: ${d.bankAccountName}',
  525. maxLines: 1,
  526. overflow: TextOverflow.ellipsis,
  527. style: TextStyle(
  528. fontSize: AppFontSizes.caption,
  529. color: colors.textSecondary,
  530. ),
  531. ),
  532. if (d.bankName.isNotEmpty)
  533. Text(
  534. '${l10n.get('bankName')}: ${d.bankName}',
  535. maxLines: 1,
  536. overflow: TextOverflow.ellipsis,
  537. style: TextStyle(
  538. fontSize: AppFontSizes.caption,
  539. color: colors.textSecondary,
  540. ),
  541. ),
  542. if (d.bankAccount.isNotEmpty)
  543. Text(
  544. '${l10n.get('bankAccount')}: ${d.bankAccount}',
  545. maxLines: 1,
  546. overflow: TextOverflow.ellipsis,
  547. style: TextStyle(
  548. fontSize: AppFontSizes.caption,
  549. color: colors.textSecondary,
  550. ),
  551. ),
  552. if (d.remark.isNotEmpty)
  553. Text(
  554. '${l10n.get('remark')}: ${d.remark}',
  555. maxLines: 2,
  556. overflow: TextOverflow.ellipsis,
  557. style: TextStyle(
  558. fontSize: AppFontSizes.caption,
  559. color: colors.textSecondary,
  560. ),
  561. ),
  562. ],
  563. ),
  564. ),
  565. ],
  566. ),
  567. ),
  568. );
  569. }),
  570. if (expense.details.isNotEmpty) ...[
  571. const SizedBox(height: 8),
  572. Row(
  573. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  574. children: [
  575. Text(
  576. l10n.get('totalExpense'),
  577. style: TextStyle(
  578. fontSize: AppFontSizes.body,
  579. fontWeight: FontWeight.w600,
  580. color: colors.textPrimary,
  581. ),
  582. ),
  583. Text(
  584. '¥${totalAmount.toStringAsFixed(2)}',
  585. style: TextStyle(
  586. fontSize: AppFontSizes.subtitle,
  587. fontWeight: FontWeight.w700,
  588. color: colors.amountPrimary,
  589. ),
  590. ),
  591. ],
  592. ),
  593. const SizedBox(height: 4),
  594. Row(
  595. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  596. children: [
  597. Text(
  598. l10n.get('approvedTotal'),
  599. style: TextStyle(
  600. fontSize: AppFontSizes.body,
  601. fontWeight: FontWeight.w600,
  602. color: colors.textPrimary,
  603. ),
  604. ),
  605. Text(
  606. '¥${totalApproved.toStringAsFixed(2)}',
  607. style: TextStyle(
  608. fontSize: AppFontSizes.subtitle,
  609. fontWeight: FontWeight.w700,
  610. color: totalApproved > 0 ? colors.success : colors.textPrimary,
  611. ),
  612. ),
  613. ],
  614. ),
  615. ],
  616. ],
  617. );
  618. }
  619. void _showExpenseDetailDialog(BuildContext context, ExpenseDetailModel d) {
  620. ExpenseDetailViewDialog.show(context, d);
  621. }
  622. // ═══ 附件 ═══
  623. Widget _buildAttachmentSection(
  624. AppLocalizations l10n,
  625. AppColorsExtension colors,
  626. ) {
  627. if (!_attachAvailable) {
  628. return FormSection(
  629. title: l10n.get('attachments'),
  630. leadingIcon: Icons.attach_file_outlined,
  631. children: [
  632. Text(
  633. l10n.get('attachServiceUnavailable'),
  634. style: TextStyle(
  635. fontSize: AppFontSizes.body,
  636. color: colors.textPlaceholder,
  637. ),
  638. ),
  639. ],
  640. );
  641. }
  642. if (!_billFileRights.canBrowseAttachments) {
  643. return FormSection(
  644. title: l10n.get('attachments'),
  645. leadingIcon: Icons.attach_file_outlined,
  646. children: [
  647. Text(
  648. l10n.get('noAttachmentPermission'),
  649. style: TextStyle(
  650. fontSize: AppFontSizes.body,
  651. color: colors.textPlaceholder,
  652. ),
  653. ),
  654. ],
  655. );
  656. }
  657. final headerAtts = _attachments.where((a) => a.isHeader).toList();
  658. final bodyGroups = <int, List<BillAttachment>>{};
  659. for (final a in _attachments.where((a) => a.isBody)) {
  660. bodyGroups.putIfAbsent(a.srcItm, () => []).add(a);
  661. }
  662. final children = <Widget>[];
  663. if (_attachments.isEmpty) {
  664. children.add(
  665. Text(
  666. l10n.get('noAttachment'),
  667. style: TextStyle(
  668. fontSize: AppFontSizes.body,
  669. color: colors.textPlaceholder,
  670. ),
  671. ),
  672. );
  673. } else {
  674. // 表头附件
  675. if (headerAtts.isNotEmpty) {
  676. children.add(
  677. Padding(
  678. padding: const EdgeInsets.only(bottom: 8),
  679. child: Text(
  680. l10n.get('headerAttachments'),
  681. style: TextStyle(
  682. fontSize: AppFontSizes.caption,
  683. fontWeight: FontWeight.w600,
  684. color: colors.textSecondary,
  685. ),
  686. ),
  687. ),
  688. );
  689. for (final a in headerAtts) {
  690. children.add(_buildAttachmentRow(a, colors));
  691. }
  692. }
  693. // 表身附件(按明细行分组)
  694. for (final entry in bodyGroups.entries) {
  695. children.add(const SizedBox(height: 8));
  696. children.add(
  697. Padding(
  698. padding: const EdgeInsets.only(bottom: 8),
  699. child: Text(
  700. '${l10n.get('detailLine')} ${entry.key}',
  701. style: TextStyle(
  702. fontSize: AppFontSizes.caption,
  703. fontWeight: FontWeight.w600,
  704. color: colors.textSecondary,
  705. ),
  706. ),
  707. ),
  708. );
  709. for (final a in entry.value) {
  710. children.add(_buildAttachmentRow(a, colors));
  711. }
  712. }
  713. }
  714. return FormSection(
  715. title: l10n.get('attachments'),
  716. leadingIcon: Icons.attach_file_outlined,
  717. children: children,
  718. );
  719. }
  720. Widget _buildAttachmentRow(BillAttachment a, AppColorsExtension colors) {
  721. final isImage = [
  722. 'jpg',
  723. 'jpeg',
  724. 'png',
  725. 'gif',
  726. 'bmp',
  727. 'webp',
  728. ].contains(a.ext.toLowerCase());
  729. return GestureDetector(
  730. onTap: () => _openAttachment(a),
  731. child: Container(
  732. margin: const EdgeInsets.symmetric(vertical: 4),
  733. padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
  734. decoration: BoxDecoration(
  735. color: colors.bgPage,
  736. borderRadius: BorderRadius.circular(8),
  737. ),
  738. child: Row(
  739. children: [
  740. if (isImage)
  741. _ExpAttachmentThumbnail(
  742. api: ref.read(expenseApiProvider),
  743. attachment: a,
  744. size: 40,
  745. )
  746. else
  747. Icon(_fileTypeIcon(a.ext), size: 40, color: colors.primary),
  748. const SizedBox(width: 10),
  749. Expanded(
  750. child: Text(
  751. a.fileName,
  752. maxLines: 1,
  753. overflow: TextOverflow.ellipsis,
  754. style: TextStyle(
  755. fontSize: AppFontSizes.body,
  756. color: colors.textPrimary,
  757. ),
  758. ),
  759. ),
  760. const SizedBox(width: 8),
  761. GestureDetector(
  762. onTap: () => AttachmentDownloadHelper.downloadAndSave(
  763. context,
  764. a,
  765. ref.read(expenseApiProvider).downloadAttachment,
  766. ),
  767. child: Icon(
  768. Icons.download_outlined,
  769. size: 22,
  770. color: colors.primary,
  771. ),
  772. ),
  773. ],
  774. ),
  775. ),
  776. );
  777. }
  778. Future<void> _openAttachment(BillAttachment a) async {
  779. final l10n = AppLocalizations.of(context);
  780. final ext = a.ext.toLowerCase();
  781. final isImage = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].contains(ext);
  782. if (isImage) {
  783. // 图片 → 弹窗预览,内部自动下载并显示 loading
  784. final api = ref.read(expenseApiProvider);
  785. AttachmentPreview.show(
  786. context,
  787. loader: api.downloadAttachment(a.id),
  788. fileName: a.fileName,
  789. loadingText: l10n.get('loading'),
  790. );
  791. return;
  792. }
  793. // 非图片 → 下载后调用系统工具打开
  794. try {
  795. LoadingDialog.show(context, text: l10n.get('downloading'));
  796. final api = ref.read(expenseApiProvider);
  797. final bytes = await api.downloadAttachment(a.id);
  798. if (!mounted) return;
  799. LoadingDialog.hide(context);
  800. if (bytes == null) {
  801. TDToast.showText(l10n.get('downloadFailed'), context: context);
  802. return;
  803. }
  804. final dir = await getTemporaryDirectory();
  805. final file = File('${dir.path}/${a.fileName}');
  806. await file.writeAsBytes(bytes);
  807. await OpenFilex.open(file.path);
  808. } catch (_) {
  809. if (mounted) LoadingDialog.hide(context);
  810. if (mounted) TDToast.showText(l10n.get('openFailed'), context: context);
  811. }
  812. }
  813. IconData _fileTypeIcon(String ext) {
  814. switch (ext.toLowerCase()) {
  815. case 'pdf':
  816. return Icons.picture_as_pdf;
  817. case 'doc':
  818. case 'docx':
  819. return Icons.description;
  820. case 'xls':
  821. case 'xlsx':
  822. return Icons.table_chart;
  823. case 'jpg':
  824. case 'jpeg':
  825. case 'png':
  826. case 'gif':
  827. case 'bmp':
  828. return Icons.image_outlined;
  829. default:
  830. return Icons.insert_drive_file;
  831. }
  832. }
  833. Widget _buildPageFooter(AppColorsExtension colors) {
  834. final l10n = AppLocalizations.of(context);
  835. return Center(
  836. child: Padding(
  837. padding: const EdgeInsets.only(bottom: 16),
  838. child: Row(
  839. mainAxisSize: MainAxisSize.min,
  840. children: [
  841. Icon(
  842. Icons.rocket_launch_outlined,
  843. size: 16,
  844. color: colors.textPlaceholder,
  845. ),
  846. const SizedBox(width: 6),
  847. Text(
  848. l10n.get('pageFooter'),
  849. style: TextStyle(
  850. fontSize: AppFontSizes.caption,
  851. color: colors.textPlaceholder,
  852. ),
  853. ),
  854. ],
  855. ),
  856. ),
  857. );
  858. }
  859. }
  860. /// 附件缩略图 — 自动调用 DownloadAttachment 加载图片
  861. class _ExpAttachmentThumbnail extends StatefulWidget {
  862. final ExpenseApi api;
  863. final BillAttachment attachment;
  864. final double size;
  865. const _ExpAttachmentThumbnail({
  866. required this.api,
  867. required this.attachment,
  868. required this.size,
  869. });
  870. @override
  871. State<_ExpAttachmentThumbnail> createState() =>
  872. _ExpAttachmentThumbnailState();
  873. }
  874. class _ExpAttachmentThumbnailState extends State<_ExpAttachmentThumbnail> {
  875. Uint8List? _bytes;
  876. bool _loading = true;
  877. @override
  878. void initState() {
  879. super.initState();
  880. _load();
  881. }
  882. Future<void> _load() async {
  883. try {
  884. final bytes = await widget.api.downloadAttachment(widget.attachment.id);
  885. if (mounted) {
  886. setState(() {
  887. _bytes = bytes;
  888. _loading = false;
  889. });
  890. }
  891. } catch (_) {
  892. if (mounted) setState(() => _loading = false);
  893. }
  894. }
  895. @override
  896. Widget build(BuildContext context) {
  897. if (_loading) {
  898. return SizedBox(
  899. width: widget.size,
  900. height: widget.size,
  901. child: const Center(
  902. child: SizedBox(
  903. width: 16,
  904. height: 16,
  905. child: CircularProgressIndicator(strokeWidth: 2),
  906. ),
  907. ),
  908. );
  909. }
  910. if (_bytes != null) {
  911. return ClipRRect(
  912. borderRadius: BorderRadius.circular(4),
  913. child: Image.memory(
  914. _bytes!,
  915. width: widget.size,
  916. height: widget.size,
  917. fit: BoxFit.cover,
  918. ),
  919. );
  920. }
  921. return Icon(
  922. Icons.broken_image,
  923. size: widget.size * 0.6,
  924. color: Colors.grey,
  925. );
  926. }
  927. }