expense_apply_detail_page.dart 26 KB

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