expense_detail_page.dart 31 KB

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