expense_detail_page.dart 31 KB

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