expense_apply_detail_page.dart 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815
  1. import 'dart:typed_data';
  2. import 'dart:io';
  3. import 'package:flutter/material.dart';
  4. import 'package:path_provider/path_provider.dart';
  5. import 'package:open_filex/open_filex.dart';
  6. import '../../shared/widgets/attachment_preview_page.dart';
  7. import 'package:tdesign_flutter/tdesign_flutter.dart';
  8. import 'package:flutter_riverpod/flutter_riverpod.dart';
  9. import '../../shared/widgets/loading_dialog.dart';
  10. import '../../core/utils/date_utils.dart' as du;
  11. import '../../shared/widgets/form_section.dart';
  12. import '../../shared/widgets/form_field_row.dart';
  13. import '../../shared/widgets/app_skeletons.dart';
  14. import '../../shared/models/bill_file_rights.dart';
  15. import '../../shared/widgets/attachment_download_helper.dart';
  16. import '../../core/navigation/host_app_channel.dart';
  17. import 'expense_apply_model.dart';
  18. import 'widgets/expense_apply_detail_view_dialog.dart';
  19. import '../../core/i18n/app_localizations.dart';
  20. import '../../shared/models/bill_attachment.dart';
  21. import 'expense_apply_api.dart';
  22. import '../../core/theme/app_colors.dart';
  23. import '../../core/theme/app_colors_extension.dart';
  24. import '../../core/utils/amount_utils.dart';
  25. import '../../shared/widgets/bill_status_bar.dart';
  26. import 'package:go_router/go_router.dart';
  27. class ExpenseApplyDetailPage extends ConsumerStatefulWidget {
  28. final String billNo;
  29. final int queryId;
  30. const ExpenseApplyDetailPage({
  31. super.key,
  32. required this.billNo,
  33. this.queryId = 0,
  34. });
  35. @override
  36. ConsumerState<ExpenseApplyDetailPage> createState() =>
  37. _ExpenseApplyDetailPageState();
  38. }
  39. class _ExpenseApplyDetailPageState
  40. extends ConsumerState<ExpenseApplyDetailPage> {
  41. bool _loading = true;
  42. String? _error;
  43. ExpenseApplyModel? _data;
  44. List<BillAttachment> _attachments = [];
  45. bool _attachAvailable = false;
  46. BillFileRights _billFileRights = BillFileRights.none;
  47. BillStatusBar? _billStatusBar;
  48. @override
  49. void initState() {
  50. super.initState();
  51. _loadData();
  52. }
  53. @override
  54. void dispose() {
  55. super.dispose();
  56. }
  57. Future<void> _loadData() async {
  58. setState(() {
  59. _loading = true;
  60. _error = null;
  61. });
  62. try {
  63. final api = ref.read(expenseApplyApiProvider);
  64. final detail = await api.fetchDetail(widget.billNo);
  65. // Load attachments (non-critical, best-effort)
  66. bool attachAvailable = false;
  67. try {
  68. attachAvailable = await api.checkAttachHealth();
  69. debugPrint('[Attach] checkAttachHealth result: $attachAvailable');
  70. } catch (e) {
  71. debugPrint('[Attach] checkAttachHealth error: $e');
  72. attachAvailable = false;
  73. }
  74. // 加载附件权限
  75. BillFileRights billFileRights = BillFileRights.none;
  76. try {
  77. billFileRights = await api.getBillFileRights('AE');
  78. } catch (_) {}
  79. List<BillAttachment> attachments = [];
  80. if (attachAvailable && billFileRights.canBrowseAttachments) {
  81. try {
  82. attachments = await api.getAttachments('AE', widget.billNo);
  83. debugPrint('[Attach] getAttachments count: ${attachments.length}');
  84. } catch (e) {
  85. debugPrint('[Attach] getAttachments error: $e');
  86. }
  87. }
  88. debugPrint(
  89. '[Attach] final state: attachAvailable=$attachAvailable, count=${attachments.length}',
  90. );
  91. // 单据状态 + 审核配置(非关键,尽力加载),与详情合并到一个 setState 避免骨架闪烁
  92. Map<String, dynamic>? status;
  93. Map<String, dynamic>? auditConfig;
  94. try {
  95. final results = await Future.wait([
  96. api.getBillStatus(widget.billNo),
  97. api.getBillAuditConfig('AE'),
  98. ]);
  99. final Map<String, dynamic> s = results[0];
  100. final Map<String, dynamic> c = results[1];
  101. status = s;
  102. auditConfig = c;
  103. } catch (_) {}
  104. final hasAuditFlow = auditConfig?['hasAuditFlow'] == true;
  105. final autoSubmit = auditConfig?['autoSubmit'] == true;
  106. final canManualSubmit = hasAuditFlow && !autoSubmit;
  107. if (mounted) {
  108. setState(() {
  109. _data = detail;
  110. _attachments = attachments;
  111. _attachAvailable = attachAvailable;
  112. _billFileRights = billFileRights;
  113. _billStatusBar = (status != null)
  114. ? BillStatusBar(
  115. billStatus: status,
  116. canManualSubmit: canManualSubmit,
  117. transferredText: AppLocalizations.of(
  118. context,
  119. ).get('statusConvertedToExpense'),
  120. onEdit: () async {
  121. final result = await GoRouter.of(
  122. context,
  123. ).push('/expense-apply/edit/${widget.billNo}');
  124. if (result == true && mounted) _loadData();
  125. },
  126. onSubmit: () async {
  127. final l10n = AppLocalizations.of(context);
  128. final a = ref.read(expenseApplyApiProvider);
  129. final applyDate = _data?.expenseApplyDate != null
  130. ? du.DateUtils.formatDate(_data!.expenseApplyDate!)
  131. : '';
  132. LoadingDialog.show(context, text: l10n.get('submitting'));
  133. try {
  134. await a.shSubmit(bilNo: widget.billNo, bilDd: applyDate);
  135. } finally {
  136. if (mounted) LoadingDialog.hide(context);
  137. }
  138. if (mounted) _loadData();
  139. },
  140. onCancelSubmit: () async {
  141. final l10n = AppLocalizations.of(context);
  142. final a = ref.read(expenseApplyApiProvider);
  143. final applyDate = _data?.expenseApplyDate != null
  144. ? du.DateUtils.formatDate(_data!.expenseApplyDate!)
  145. : '';
  146. LoadingDialog.show(context, text: l10n.get('submitting'));
  147. try {
  148. await a.shSubmit(
  149. bilNo: widget.billNo,
  150. bilDd: applyDate,
  151. isCancel: true,
  152. );
  153. } finally {
  154. if (mounted) LoadingDialog.hide(context);
  155. }
  156. if (mounted) _loadData();
  157. },
  158. onTapStatusTag: () => _showAuditTrail('AE'),
  159. )
  160. : null;
  161. _loading = false;
  162. });
  163. }
  164. } catch (e) {
  165. if (mounted) {
  166. setState(() {
  167. _error = e.toString();
  168. _loading = false;
  169. });
  170. }
  171. }
  172. }
  173. @override
  174. Widget build(BuildContext context) {
  175. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  176. final l10n = AppLocalizations.of(context);
  177. if (_loading) {
  178. return const SkeletonDetailPage();
  179. }
  180. if (_error != null) {
  181. return Center(
  182. child: Column(
  183. mainAxisSize: MainAxisSize.min,
  184. children: [
  185. Icon(Icons.error_outline, size: 48, color: colors.danger),
  186. const SizedBox(height: 16),
  187. Padding(
  188. padding: const EdgeInsets.symmetric(horizontal: 32),
  189. child: Text(
  190. _error!,
  191. textAlign: TextAlign.center,
  192. style: TextStyle(
  193. fontSize: AppFontSizes.body,
  194. color: colors.textSecondary,
  195. ),
  196. ),
  197. ),
  198. const SizedBox(height: 16),
  199. TDButton(
  200. text: l10n.get('retry'),
  201. size: TDButtonSize.medium,
  202. onTap: _loadData,
  203. ),
  204. ],
  205. ),
  206. );
  207. }
  208. final app = _data!;
  209. return Column(
  210. children: [
  211. Expanded(
  212. child: SingleChildScrollView(
  213. physics: const AlwaysScrollableScrollPhysics(),
  214. padding: const EdgeInsets.all(16),
  215. child: Column(
  216. children: [
  217. _buildBasicInfoSection(app, l10n, colors),
  218. const SizedBox(height: 16),
  219. _buildExpenseDetailSection(app, l10n, colors),
  220. const SizedBox(height: 16),
  221. _buildAttachmentSection(l10n, colors),
  222. const SizedBox(height: 24),
  223. _buildPageFooter(colors),
  224. ],
  225. ),
  226. ),
  227. ),
  228. _billStatusBar?.buildActions(context) ?? const SizedBox.shrink(),
  229. ],
  230. );
  231. }
  232. Future<void> _showAuditTrail(String billId) async {
  233. await HostAppChannel.showAuditTrail(billId, widget.billNo);
  234. }
  235. // ═══ 基本信息 ═══
  236. Widget _buildBasicInfoSection(
  237. ExpenseApplyModel app,
  238. AppLocalizations l10n,
  239. AppColorsExtension colors,
  240. ) {
  241. return FormSection(
  242. title: l10n.get('basicInfo'),
  243. leadingIcon: Icons.info_outline,
  244. trailing:
  245. _billStatusBar?.buildStatusTag(context) ?? const SizedBox.shrink(),
  246. children: [
  247. FormFieldRow(
  248. label: l10n.get('expenseApplyNo'),
  249. value: app.expenseApplyNo,
  250. readOnly: true,
  251. showArrow: false,
  252. ),
  253. const SizedBox(height: 16),
  254. FormFieldRow(
  255. label: l10n.get('date'),
  256. value: du.DateUtils.formatDate(app.createTime),
  257. readOnly: true,
  258. showArrow: false,
  259. ),
  260. const SizedBox(height: 16),
  261. FormFieldRow(
  262. label: l10n.get('applyDept'),
  263. value: app.deptId.isNotEmpty
  264. ? '${app.deptId}${app.deptName.isNotEmpty ? '/${app.deptName}' : ''}'
  265. : '-',
  266. readOnly: true,
  267. showArrow: false,
  268. ),
  269. const SizedBox(height: 16),
  270. Row(
  271. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  272. children: [
  273. Text(
  274. l10n.get('emergencyLevel'),
  275. style: TextStyle(
  276. fontSize: AppFontSizes.subtitle,
  277. color: colors.textSecondary,
  278. ),
  279. ),
  280. _buildUrgencyChip(app.urgency, l10n, colors),
  281. ],
  282. ),
  283. const SizedBox(height: 16),
  284. FormFieldRow(
  285. label: l10n.get('applyReason'),
  286. value: app.purpose.isNotEmpty ? app.purpose : '-',
  287. readOnly: true,
  288. showArrow: false,
  289. bold: true,
  290. showMoreOnOverflow: true,
  291. ),
  292. const SizedBox(height: 16),
  293. FormFieldRow(
  294. label: l10n.get('remark'),
  295. value: app.remark.isNotEmpty ? app.remark : '-',
  296. readOnly: true,
  297. showArrow: false,
  298. bold: false,
  299. showMoreOnOverflow: true,
  300. ),
  301. ],
  302. );
  303. }
  304. // ═══ 费用明细 ═══
  305. Widget _buildExpenseDetailSection(
  306. ExpenseApplyModel app,
  307. AppLocalizations l10n,
  308. AppColorsExtension colors,
  309. ) {
  310. return FormSection(
  311. title: l10n.get('expenseDetails'),
  312. leadingIcon: Icons.receipt_long_outlined,
  313. children: [
  314. if (app.details.isEmpty)
  315. Padding(
  316. padding: const EdgeInsets.symmetric(vertical: 8),
  317. child: Text(
  318. l10n.get('noDetailData'),
  319. style: TextStyle(
  320. fontSize: AppFontSizes.body,
  321. color: colors.textPlaceholder,
  322. ),
  323. ),
  324. )
  325. else
  326. ...app.details.asMap().entries.map((e) {
  327. final d = e.value;
  328. final catLabel = d.categoryName.isNotEmpty
  329. ? '${d.expenseCategory}/${d.categoryName}'
  330. : d.expenseCategory;
  331. return GestureDetector(
  332. onTap: () => _showExpenseDetailDialog(context, d),
  333. child: Container(
  334. margin: const EdgeInsets.symmetric(vertical: 8),
  335. padding: const EdgeInsets.all(12),
  336. decoration: BoxDecoration(
  337. color: colors.bgPage,
  338. borderRadius: BorderRadius.circular(8),
  339. ),
  340. child: Row(
  341. crossAxisAlignment: CrossAxisAlignment.center,
  342. children: [
  343. Expanded(
  344. child: Column(
  345. crossAxisAlignment: CrossAxisAlignment.start,
  346. children: [
  347. Row(
  348. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  349. children: [
  350. Expanded(
  351. child: Text(
  352. catLabel,
  353. maxLines: 1,
  354. overflow: TextOverflow.ellipsis,
  355. style: TextStyle(
  356. fontSize: AppFontSizes.subtitle,
  357. color: colors.textPrimary,
  358. ),
  359. ),
  360. ),
  361. const SizedBox(width: 12),
  362. Text(
  363. formatAmount(d.estimatedAmount),
  364. style: TextStyle(
  365. fontSize: AppFontSizes.caption,
  366. fontWeight: FontWeight.w600,
  367. color: colors.amountPrimary,
  368. ),
  369. ),
  370. ],
  371. ),
  372. if (d.acctSubjectId.isNotEmpty) ...[
  373. const SizedBox(height: 4),
  374. Text(
  375. '${l10n.get('acctSubject')}: ${d.acctSubjectId}${d.acctSubjectName.isNotEmpty ? '/${d.acctSubjectName}' : ''}',
  376. maxLines: 1,
  377. overflow: TextOverflow.ellipsis,
  378. style: TextStyle(
  379. fontSize: AppFontSizes.caption,
  380. color: colors.textSecondary,
  381. ),
  382. ),
  383. ],
  384. if (d.sqMan.isNotEmpty) ...[
  385. const SizedBox(height: 4),
  386. Text(
  387. '${l10n.get('applicant')}: ${d.sqMan}${d.sqName.isNotEmpty ? '/${d.sqName}' : ''}',
  388. maxLines: 1,
  389. overflow: TextOverflow.ellipsis,
  390. style: TextStyle(
  391. fontSize: AppFontSizes.caption,
  392. color: colors.textSecondary,
  393. ),
  394. ),
  395. ],
  396. if (d.projectId.isNotEmpty) ...[
  397. const SizedBox(height: 4),
  398. Text(
  399. '${l10n.get('project')}: ${d.projectId}${d.projectName.isNotEmpty ? '/${d.projectName}' : ''}',
  400. maxLines: 1,
  401. overflow: TextOverflow.ellipsis,
  402. style: TextStyle(
  403. fontSize: AppFontSizes.caption,
  404. color: colors.textSecondary,
  405. ),
  406. ),
  407. ],
  408. if (d.costDeptId.isNotEmpty) ...[
  409. const SizedBox(height: 4),
  410. Text(
  411. '${l10n.get('costDept')}: ${d.costDeptId}${d.costDeptName.isNotEmpty ? '/${d.costDeptName}' : ''}',
  412. maxLines: 1,
  413. overflow: TextOverflow.ellipsis,
  414. style: TextStyle(
  415. fontSize: AppFontSizes.caption,
  416. color: colors.textSecondary,
  417. ),
  418. ),
  419. ],
  420. if (d.estimatedStartDate != null) ...[
  421. const SizedBox(height: 4),
  422. Text(
  423. '${l10n.get('estimatedDate')}: ${du.DateUtils.formatDate(d.estimatedStartDate!)}${d.estimatedEndDate != null ? ' ~ ${du.DateUtils.formatDate(d.estimatedEndDate!)}' : ''}',
  424. maxLines: 1,
  425. overflow: TextOverflow.ellipsis,
  426. style: TextStyle(
  427. fontSize: AppFontSizes.caption,
  428. color: colors.textSecondary,
  429. ),
  430. ),
  431. ],
  432. if (d.bxNo.isNotEmpty) ...[
  433. const SizedBox(height: 4),
  434. Text(
  435. '${l10n.get('expenseNo')}: ${d.bxNo}',
  436. maxLines: 1,
  437. overflow: TextOverflow.ellipsis,
  438. style: TextStyle(
  439. fontSize: AppFontSizes.caption,
  440. color: colors.textSecondary,
  441. ),
  442. ),
  443. ],
  444. if (d.remark.isNotEmpty) ...[
  445. const SizedBox(height: 4),
  446. Text(
  447. d.remark,
  448. maxLines: 2,
  449. overflow: TextOverflow.ellipsis,
  450. style: TextStyle(
  451. fontSize: AppFontSizes.caption,
  452. color: colors.textSecondary,
  453. ),
  454. ),
  455. ],
  456. ],
  457. ),
  458. ),
  459. ],
  460. ),
  461. ),
  462. );
  463. }),
  464. if (app.details.isNotEmpty) ...[
  465. const SizedBox(height: 8),
  466. Row(
  467. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  468. children: [
  469. Text(
  470. l10n.get('total'),
  471. style: TextStyle(
  472. fontSize: AppFontSizes.body,
  473. fontWeight: FontWeight.w600,
  474. color: colors.textPrimary,
  475. ),
  476. ),
  477. Text(
  478. formatAmount(
  479. app.details.fold<double>(
  480. 0,
  481. (sum, d) => sum + d.estimatedAmount,
  482. ),
  483. ),
  484. style: TextStyle(
  485. fontSize: AppFontSizes.subtitle,
  486. fontWeight: FontWeight.w700,
  487. color: colors.amountPrimary,
  488. ),
  489. ),
  490. ],
  491. ),
  492. ],
  493. ],
  494. );
  495. }
  496. // ═══ 附件 ═══
  497. Widget _buildAttachmentSection(
  498. AppLocalizations l10n,
  499. AppColorsExtension colors,
  500. ) {
  501. final children = <Widget>[];
  502. if (!_attachAvailable) {
  503. children.add(
  504. Text(
  505. l10n.get('attachServiceUnavailable'),
  506. style: TextStyle(
  507. fontSize: AppFontSizes.body,
  508. color: colors.textPlaceholder,
  509. ),
  510. ),
  511. );
  512. } else if (!_billFileRights.canBrowseAttachments) {
  513. children.add(
  514. Text(
  515. l10n.get('noAttachmentPermission'),
  516. style: TextStyle(
  517. fontSize: AppFontSizes.body,
  518. color: colors.textPlaceholder,
  519. ),
  520. ),
  521. );
  522. } else if (_attachments.isEmpty) {
  523. children.add(
  524. Text(
  525. l10n.get('noAttachment'),
  526. style: TextStyle(
  527. fontSize: AppFontSizes.body,
  528. color: colors.textPlaceholder,
  529. ),
  530. ),
  531. );
  532. } else {
  533. children.addAll(_attachments.map((a) => _buildAttachmentRow(a, colors)));
  534. }
  535. return FormSection(
  536. title: l10n.get('attachments'),
  537. leadingIcon: Icons.attach_file_outlined,
  538. children: children,
  539. );
  540. }
  541. Widget _buildAttachmentRow(BillAttachment a, AppColorsExtension colors) {
  542. final isImage = [
  543. 'jpg',
  544. 'jpeg',
  545. 'png',
  546. 'gif',
  547. 'bmp',
  548. 'webp',
  549. ].contains(a.ext.toLowerCase());
  550. return GestureDetector(
  551. onTap: () => _openAttachment(a),
  552. child: Container(
  553. margin: const EdgeInsets.symmetric(vertical: 4),
  554. padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
  555. decoration: BoxDecoration(
  556. color: colors.bgPage,
  557. borderRadius: BorderRadius.circular(8),
  558. ),
  559. child: Row(
  560. children: [
  561. if (isImage)
  562. _AttachmentThumbnail(
  563. api: ref.read(expenseApplyApiProvider),
  564. attachment: a,
  565. size: 40,
  566. )
  567. else
  568. Icon(_fileTypeIcon(a.ext), size: 40, color: colors.primary),
  569. const SizedBox(width: 10),
  570. Expanded(
  571. child: Text(
  572. a.fileName,
  573. maxLines: 1,
  574. overflow: TextOverflow.ellipsis,
  575. style: TextStyle(
  576. fontSize: AppFontSizes.body,
  577. color: colors.textPrimary,
  578. ),
  579. ),
  580. ),
  581. const SizedBox(width: 8),
  582. GestureDetector(
  583. onTap: () => AttachmentDownloadHelper.downloadAndSave(
  584. context,
  585. a,
  586. ref.read(expenseApplyApiProvider).downloadAttachment,
  587. ),
  588. child: Icon(
  589. Icons.download_outlined,
  590. size: 22,
  591. color: colors.primary,
  592. ),
  593. ),
  594. ],
  595. ),
  596. ),
  597. );
  598. }
  599. Future<void> _openAttachment(BillAttachment a) async {
  600. final l10n = AppLocalizations.of(context);
  601. final ext = a.ext.toLowerCase();
  602. final isImage = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].contains(ext);
  603. if (isImage) {
  604. // 图片 → 弹窗预览,内部自动下载并显示 loading
  605. final api = ref.read(expenseApplyApiProvider);
  606. AttachmentPreview.show(
  607. context,
  608. loader: api.downloadAttachment(a.id),
  609. fileName: a.fileName,
  610. loadingText: l10n.get('loading'),
  611. );
  612. return;
  613. }
  614. // 非图片 → 下载后调用系统工具打开
  615. try {
  616. LoadingDialog.show(context, text: l10n.get('downloading'));
  617. final api = ref.read(expenseApplyApiProvider);
  618. final bytes = await api.downloadAttachment(a.id);
  619. if (!mounted) return;
  620. LoadingDialog.hide(context);
  621. if (bytes == null) {
  622. TDToast.showText(l10n.get('downloadFailed'), context: context);
  623. return;
  624. }
  625. final dir = await getTemporaryDirectory();
  626. final file = File('${dir.path}/${a.fileName}');
  627. await file.writeAsBytes(bytes);
  628. await OpenFilex.open(file.path);
  629. } catch (_) {
  630. if (mounted) LoadingDialog.hide(context);
  631. if (mounted) TDToast.showText(l10n.get('openFailed'), context: context);
  632. }
  633. }
  634. IconData _fileTypeIcon(String ext) {
  635. switch (ext.toLowerCase()) {
  636. case 'pdf':
  637. return Icons.picture_as_pdf;
  638. case 'doc':
  639. case 'docx':
  640. return Icons.description;
  641. case 'xls':
  642. case 'xlsx':
  643. return Icons.table_chart;
  644. case 'jpg':
  645. case 'jpeg':
  646. case 'png':
  647. case 'gif':
  648. case 'bmp':
  649. return Icons.image_outlined;
  650. default:
  651. return Icons.insert_drive_file;
  652. }
  653. }
  654. Widget _buildPageFooter(AppColorsExtension colors) {
  655. final l10n = AppLocalizations.of(context);
  656. return Center(
  657. child: Padding(
  658. padding: const EdgeInsets.only(bottom: 16),
  659. child: Row(
  660. mainAxisSize: MainAxisSize.min,
  661. children: [
  662. Icon(
  663. Icons.rocket_launch_outlined,
  664. size: 16,
  665. color: colors.textPlaceholder,
  666. ),
  667. const SizedBox(width: 6),
  668. Text(
  669. l10n.get('pageFooter'),
  670. style: TextStyle(
  671. fontSize: AppFontSizes.caption,
  672. color: colors.textPlaceholder,
  673. ),
  674. ),
  675. ],
  676. ),
  677. ),
  678. );
  679. }
  680. Widget _buildUrgencyChip(
  681. String urgency,
  682. AppLocalizations l10n,
  683. AppColorsExtension colors,
  684. ) {
  685. final (label, color) = switch (urgency) {
  686. '3' || 'critical' => (l10n.get('critical'), colors.danger),
  687. '2' || 'urgent' => (l10n.get('urgent'), colors.warning),
  688. '1' || 'normal' => (l10n.get('normal'), colors.primary),
  689. _ => (l10n.get('normal'), colors.primary),
  690. };
  691. return Container(
  692. padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
  693. decoration: BoxDecoration(
  694. color: color.withValues(alpha: 0.1),
  695. borderRadius: BorderRadius.circular(4),
  696. border: Border.all(color: color, width: 0.5),
  697. ),
  698. child: Text(
  699. label,
  700. style: TextStyle(
  701. fontSize: 11,
  702. fontWeight: FontWeight.w500,
  703. color: color,
  704. ),
  705. ),
  706. );
  707. }
  708. void _showExpenseDetailDialog(
  709. BuildContext context,
  710. ExpenseApplyDetailModel d,
  711. ) {
  712. ExpenseApplyDetailViewDialog.show(context, d);
  713. }
  714. }
  715. /// 附件缩略图 — 自动调用 DownloadAttachment 加载图片
  716. class _AttachmentThumbnail extends StatefulWidget {
  717. final ExpenseApplyApi api;
  718. final BillAttachment attachment;
  719. final double size;
  720. const _AttachmentThumbnail({
  721. required this.api,
  722. required this.attachment,
  723. required this.size,
  724. });
  725. @override
  726. State<_AttachmentThumbnail> createState() => _AttachmentThumbnailState();
  727. }
  728. class _AttachmentThumbnailState extends State<_AttachmentThumbnail> {
  729. Uint8List? _bytes;
  730. bool _loading = true;
  731. @override
  732. void initState() {
  733. super.initState();
  734. _load();
  735. }
  736. Future<void> _load() async {
  737. try {
  738. final bytes = await widget.api.downloadAttachment(widget.attachment.id);
  739. if (mounted) {
  740. setState(() {
  741. _bytes = bytes;
  742. _loading = false;
  743. });
  744. }
  745. } catch (_) {
  746. if (mounted) setState(() => _loading = false);
  747. }
  748. }
  749. @override
  750. Widget build(BuildContext context) {
  751. if (_loading) {
  752. return SizedBox(
  753. width: widget.size,
  754. height: widget.size,
  755. child: const Center(
  756. child: SizedBox(
  757. width: 16,
  758. height: 16,
  759. child: CircularProgressIndicator(strokeWidth: 2),
  760. ),
  761. ),
  762. );
  763. }
  764. if (_bytes != null) {
  765. return ClipRRect(
  766. borderRadius: BorderRadius.circular(4),
  767. child: Image.memory(
  768. _bytes!,
  769. width: widget.size,
  770. height: widget.size,
  771. fit: BoxFit.cover,
  772. ),
  773. );
  774. }
  775. return Icon(
  776. Icons.broken_image,
  777. size: widget.size * 0.6,
  778. color: Colors.grey,
  779. );
  780. }
  781. }