expense_detail_page.dart 32 KB

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