expense_application_detail_page.dart 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. import 'package:flutter/material.dart';
  2. import 'package:tdesign_flutter/tdesign_flutter.dart';
  3. import 'package:flutter_riverpod/flutter_riverpod.dart';
  4. import 'package:go_router/go_router.dart';
  5. import '../shell/nav_bar_config.dart';
  6. import '../../core/utils/date_utils.dart' as du;
  7. import '../../shared/widgets/form_section.dart';
  8. import '../../shared/widgets/form_field_row.dart';
  9. import '../../shared/widgets/status_banner.dart';
  10. import '../../shared/widgets/action_bar.dart';
  11. import '../../shared/widgets/approval_timeline.dart';
  12. import 'expense_application_model.dart';
  13. import '../../core/i18n/app_localizations.dart';
  14. import 'expense_application_list_controller.dart';
  15. import '../../core/theme/app_colors.dart';
  16. import '../../core/theme/app_colors_extension.dart';
  17. class ExpenseApplicationDetailPage extends ConsumerWidget {
  18. final String id;
  19. const ExpenseApplicationDetailPage({super.key, required this.id});
  20. @override
  21. Widget build(BuildContext context, WidgetRef ref) {
  22. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  23. final app = mockExpenseApplications.firstWhere(
  24. (e) => e.id == id,
  25. orElse: () => mockExpenseApplications.first,
  26. );
  27. final l10n = AppLocalizations.of(context);
  28. ref
  29. .read(navBarConfigProvider.notifier)
  30. .update(
  31. NavBarConfig(
  32. title: l10n.get('expenseApplyDetail'),
  33. showBack: true,
  34. onBack: () => context.pop(),
  35. ),
  36. );
  37. return Column(
  38. children: [
  39. Expanded(
  40. child: SingleChildScrollView(
  41. padding: const EdgeInsets.all(16),
  42. child: Column(
  43. children: [
  44. _buildStatusBanner(context, app, colors),
  45. const SizedBox(height: 4),
  46. _buildSubmitTime(context, app, colors),
  47. const SizedBox(height: 16),
  48. _buildBasicInfoSection(app, l10n, colors),
  49. const SizedBox(height: 16),
  50. _buildExpenseDetailSection(app, l10n, colors),
  51. const SizedBox(height: 16),
  52. _buildAttachmentSection(l10n, colors),
  53. const SizedBox(height: 16),
  54. if (app.approvalRecords.isNotEmpty ||
  55. app.approvalChain.isNotEmpty)
  56. _buildApprovalSection(l10n, app),
  57. const SizedBox(height: 16),
  58. ],
  59. ),
  60. ),
  61. ),
  62. _buildBottomBar(context, app),
  63. ],
  64. );
  65. }
  66. Widget _buildStatusBanner(
  67. BuildContext context,
  68. ExpenseApplicationModel app,
  69. AppColorsExtension colors,
  70. ) {
  71. final l10n = AppLocalizations.of(context);
  72. final (icon, color, label) = switch (app.status) {
  73. 'approved' => (Icons.check_circle, colors.success, l10n.get('approved')),
  74. 'rejected' => (Icons.cancel, colors.danger, l10n.get('rejected')),
  75. 'draft' => (Icons.edit, colors.statusGray, l10n.get('draft')),
  76. _ => (Icons.schedule, colors.warning, l10n.get('pending')),
  77. };
  78. final approverText = switch (app.status) {
  79. 'approved' when app.approvalRecords.isNotEmpty =>
  80. '${l10n.get('approver')}:${app.approvalRecords.last.approverName}',
  81. 'rejected' when app.approvalRecords.isNotEmpty =>
  82. '${l10n.get('rejecter')}:${app.approvalRecords.last.approverName}',
  83. 'pending' when app.currentApproverId.isNotEmpty =>
  84. '${l10n.get('currentApprover')}:${app.currentApproverId}',
  85. _ => '',
  86. };
  87. return StatusBanner(
  88. icon: icon,
  89. statusText: label,
  90. subText: approverText,
  91. color: color,
  92. );
  93. }
  94. Widget _buildSubmitTime(
  95. BuildContext context,
  96. ExpenseApplicationModel app,
  97. AppColorsExtension colors,
  98. ) {
  99. final l10n = AppLocalizations.of(context);
  100. return Padding(
  101. padding: const EdgeInsets.only(left: 4, top: 4),
  102. child: Align(
  103. alignment: Alignment.centerLeft,
  104. child: Text(
  105. '${l10n.get('submitTimeText')}:${du.DateUtils.formatDateTime(app.createTime)}',
  106. style: TextStyle(
  107. fontSize: AppFontSizes.caption,
  108. color: colors.textPlaceholder,
  109. ),
  110. ),
  111. ),
  112. );
  113. }
  114. Widget _buildBasicInfoSection(
  115. ExpenseApplicationModel app,
  116. AppLocalizations l10n,
  117. AppColorsExtension colors,
  118. ) {
  119. String urgencyLabel = switch (app.urgency) {
  120. 'urgent' => l10n.get('urgent'),
  121. 'normal' => l10n.get('normal'),
  122. _ => app.urgency,
  123. };
  124. return FormSection(
  125. title: l10n.get('basicInfo'),
  126. children: [
  127. FormFieldRow(
  128. label: l10n.get('applicant'),
  129. value: app.applicantName,
  130. readOnly: true,
  131. showArrow: false,
  132. ),
  133. FormFieldRow(
  134. label: l10n.get('department'),
  135. value: app.deptName,
  136. readOnly: true,
  137. showArrow: false,
  138. ),
  139. FormFieldRow(
  140. label: l10n.get('expenseType'),
  141. value: app.expenseType,
  142. readOnly: true,
  143. showArrow: false,
  144. ),
  145. Container(
  146. height: 44,
  147. padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
  148. child: Row(
  149. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  150. children: [
  151. Text(
  152. l10n.get('expenseAmount'),
  153. style: TextStyle(
  154. fontSize: AppFontSizes.body,
  155. color: colors.textSecondary,
  156. ),
  157. ),
  158. Text(
  159. '¥${app.estimatedAmount.toStringAsFixed(2)}',
  160. style: TextStyle(
  161. fontSize: AppFontSizes.subtitle,
  162. fontWeight: FontWeight.w700,
  163. color: colors.amountPrimary,
  164. ),
  165. ),
  166. ],
  167. ),
  168. ),
  169. FormFieldRow(
  170. label: l10n.get('relatedProject'),
  171. value: app.projectName.isNotEmpty ? app.projectName : null,
  172. hint: '-',
  173. readOnly: true,
  174. showArrow: false,
  175. ),
  176. FormFieldRow(
  177. label: l10n.get('budgetSubject'),
  178. value: app.budgetSubjectId.isNotEmpty ? app.budgetSubjectId : null,
  179. hint: '-',
  180. readOnly: true,
  181. showArrow: false,
  182. ),
  183. FormFieldRow(
  184. label: l10n.get('emergencyLevel'),
  185. value: urgencyLabel,
  186. readOnly: true,
  187. showArrow: false,
  188. ),
  189. ],
  190. );
  191. }
  192. Widget _buildExpenseDetailSection(
  193. ExpenseApplicationModel app,
  194. AppLocalizations l10n,
  195. AppColorsExtension colors,
  196. ) {
  197. return FormSection(
  198. title: l10n.get('expenseDetails'),
  199. children: [
  200. // Table header
  201. Container(
  202. height: 36,
  203. padding: const EdgeInsets.symmetric(horizontal: 8),
  204. decoration: BoxDecoration(
  205. color: colors.bgPage,
  206. borderRadius: BorderRadius.circular(4),
  207. ),
  208. child: Row(
  209. children: [
  210. Expanded(
  211. flex: 3,
  212. child: Text(
  213. l10n.get('expenseProject'),
  214. style: TextStyle(
  215. fontSize: AppFontSizes.caption,
  216. fontWeight: FontWeight.w500,
  217. color: colors.textSecondary,
  218. ),
  219. ),
  220. ),
  221. Expanded(
  222. flex: 2,
  223. child: Text(
  224. l10n.get('amount'),
  225. textAlign: TextAlign.right,
  226. style: TextStyle(
  227. fontSize: AppFontSizes.caption,
  228. fontWeight: FontWeight.w500,
  229. color: colors.textSecondary,
  230. ),
  231. ),
  232. ),
  233. ],
  234. ),
  235. ),
  236. if (app.details.isEmpty)
  237. Padding(
  238. padding: EdgeInsets.symmetric(vertical: 8),
  239. child: Text(
  240. l10n.get('noDetailData'),
  241. style: TextStyle(
  242. fontSize: AppFontSizes.body,
  243. color: colors.textPlaceholder,
  244. ),
  245. ),
  246. )
  247. else
  248. ...app.details.map(
  249. (d) => SizedBox(
  250. height: 28,
  251. child: Row(
  252. children: [
  253. Expanded(
  254. flex: 3,
  255. child: Text(
  256. d.itemName,
  257. style: TextStyle(
  258. fontSize: AppFontSizes.body,
  259. color: colors.textPrimary,
  260. ),
  261. ),
  262. ),
  263. Expanded(
  264. flex: 2,
  265. child: Text(
  266. '¥${d.estimatedAmount.toStringAsFixed(2)}',
  267. textAlign: TextAlign.right,
  268. style: TextStyle(
  269. fontSize: AppFontSizes.body,
  270. fontWeight: FontWeight.w500,
  271. color: colors.amountPrimary,
  272. ),
  273. ),
  274. ),
  275. ],
  276. ),
  277. ),
  278. ),
  279. ],
  280. );
  281. }
  282. Widget _buildAttachmentSection(
  283. AppLocalizations l10n,
  284. AppColorsExtension colors,
  285. ) {
  286. return FormSection(
  287. title: l10n.get('attachments'),
  288. children: [
  289. Wrap(
  290. spacing: 8,
  291. runSpacing: 8,
  292. children: List.generate(3, (i) {
  293. return Container(
  294. width: 80,
  295. height: 80,
  296. decoration: BoxDecoration(
  297. color: colors.bgPage,
  298. borderRadius: BorderRadius.circular(4),
  299. border: Border.all(
  300. color: colors.border,
  301. strokeAlign: BorderSide.strokeAlignInside,
  302. ),
  303. ),
  304. child: Center(
  305. child: Icon(
  306. Icons.image_outlined,
  307. size: 24,
  308. color: colors.textPlaceholder,
  309. ),
  310. ),
  311. );
  312. }),
  313. ),
  314. ],
  315. );
  316. }
  317. Widget _buildApprovalSection(
  318. AppLocalizations l10n,
  319. ExpenseApplicationModel app,
  320. ) {
  321. return FormSection(
  322. title: l10n.get('approvalFlow'),
  323. children: [
  324. ApprovalTimeline(
  325. records: app.approvalRecords,
  326. chain: app.approvalChain,
  327. currentApproverId: app.currentApproverId,
  328. ),
  329. ],
  330. );
  331. }
  332. Widget _buildBottomBar(BuildContext context, ExpenseApplicationModel app) {
  333. final l10n = AppLocalizations.of(context);
  334. final canWithdraw = app.status == 'pending' || app.status == 'draft';
  335. if (!canWithdraw) {
  336. return const SizedBox.shrink();
  337. }
  338. return ActionBar(
  339. showLeft: false,
  340. centerLabel: l10n.get('withdrawApplication'),
  341. rightLabel: l10n.get('submitApproval'),
  342. onCenterTap: () {
  343. TDToast.showText(l10n.get('withdrawn'), context: context);
  344. context.pop();
  345. },
  346. onRightTap: null,
  347. );
  348. }
  349. }