expense_application_apply_page.dart 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import 'package:go_router/go_router.dart';
  4. import '../../core/theme/app_colors.dart';
  5. import '../shell/nav_bar_config.dart';
  6. import '../../core/utils/responsive.dart';
  7. import '../../shared/widgets/form_section.dart';
  8. import '../../shared/widgets/form_field_row.dart';
  9. import '../../core/i18n/app_localizations.dart';
  10. import '../../shared/widgets/action_bar.dart';
  11. class ExpenseApplicationApplyPage extends ConsumerStatefulWidget {
  12. final String? id;
  13. const ExpenseApplicationApplyPage({super.key, this.id});
  14. @override
  15. ConsumerState<ExpenseApplicationApplyPage> createState() =>
  16. _ExpenseApplicationApplyPageState();
  17. }
  18. class _ExpenseApplicationApplyPageState
  19. extends ConsumerState<ExpenseApplicationApplyPage> {
  20. final _formKey = GlobalKey<FormState>();
  21. String _expenseType = '差旅费';
  22. String _urgency = 'normal';
  23. final _amountController = TextEditingController();
  24. final _purposeController = TextEditingController();
  25. final _remarkController = TextEditingController();
  26. static const _types = ['差旅费', '办公用品', '招待费', '交通费', '通讯费', '其他'];
  27. @override
  28. void dispose() {
  29. _amountController.dispose();
  30. _purposeController.dispose();
  31. _remarkController.dispose();
  32. super.dispose();
  33. }
  34. @override
  35. Widget build(BuildContext context) {
  36. final r = ResponsiveHelper.of(context);
  37. final l10n = AppLocalizations.of(context);
  38. ref
  39. .read(navBarConfigProvider.notifier)
  40. .update(
  41. NavBarConfig(
  42. title: l10n.get('expenseApplyRequest'),
  43. showBack: true,
  44. onBack: () => context.pop(),
  45. ),
  46. );
  47. return Column(
  48. children: [
  49. Expanded(
  50. child: Align(
  51. alignment: Alignment.topCenter,
  52. child: ConstrainedBox(
  53. constraints: BoxConstraints(maxWidth: r.formMaxWidth),
  54. child: SingleChildScrollView(
  55. padding: const EdgeInsets.all(16),
  56. child: Form(
  57. key: _formKey,
  58. child: Column(
  59. children: [
  60. _buildBasicInfoSection(),
  61. const SizedBox(height: 16),
  62. _buildControlSection(),
  63. const SizedBox(height: 16),
  64. _buildExpenseDetailSection(),
  65. const SizedBox(height: 16),
  66. _buildAttachmentSection(),
  67. ],
  68. ),
  69. ),
  70. ),
  71. ),
  72. ),
  73. ),
  74. ActionBar(
  75. leftLabel: '重置',
  76. centerLabel: '存为草稿',
  77. rightLabel: '提交审批',
  78. onLeftTap: () {
  79. setState(() {
  80. _expenseType = '差旅费';
  81. _urgency = 'normal';
  82. _amountController.clear();
  83. _purposeController.clear();
  84. _remarkController.clear();
  85. });
  86. },
  87. onCenterTap: _handleSaveDraft,
  88. onRightTap: _handleSubmit,
  89. showLeft: true,
  90. ),
  91. ],
  92. );
  93. }
  94. Widget _buildBasicInfoSection() {
  95. return FormSection(
  96. title: '基本信息',
  97. children: [
  98. FormFieldRow(
  99. label: '申请人',
  100. value: '张三',
  101. readOnly: true,
  102. showArrow: false,
  103. ),
  104. FormFieldRow(
  105. label: '所属部门',
  106. value: '技术部',
  107. readOnly: true,
  108. showArrow: false,
  109. ),
  110. FormFieldRow(
  111. label: '申请日期',
  112. value: '2026-06-01',
  113. readOnly: true,
  114. showArrow: false,
  115. ),
  116. const SizedBox(height: 8),
  117. Container(height: 1, color: AppColors.border),
  118. const SizedBox(height: 8),
  119. const Text(
  120. '紧急程度',
  121. style: TextStyle(
  122. fontSize: AppFontSizes.body,
  123. color: AppColors.textSecondary,
  124. ),
  125. ),
  126. const SizedBox(height: 4),
  127. Row(
  128. children: [
  129. _buildRadio('普通', 'normal'),
  130. const SizedBox(width: 24),
  131. _buildRadio('紧急', 'urgent'),
  132. ],
  133. ),
  134. FormFieldRow(
  135. label: '费用类型',
  136. value: _expenseType,
  137. onTap: _showTypePicker,
  138. ),
  139. const SizedBox(height: 8),
  140. const Text(
  141. '费用事由',
  142. style: TextStyle(
  143. fontSize: AppFontSizes.body,
  144. color: AppColors.textSecondary,
  145. ),
  146. ),
  147. const SizedBox(height: 4),
  148. Container(
  149. height: 88,
  150. padding: const EdgeInsets.all(12),
  151. decoration: BoxDecoration(
  152. color: AppColors.bgPage,
  153. borderRadius: BorderRadius.circular(4),
  154. ),
  155. child: TextField(
  156. controller: _purposeController,
  157. maxLines: 3,
  158. style: const TextStyle(
  159. fontSize: AppFontSizes.body,
  160. color: AppColors.textPrimary,
  161. ),
  162. decoration: const InputDecoration.collapsed(hintText: '请输入费用事由'),
  163. ),
  164. ),
  165. ],
  166. );
  167. }
  168. Widget _buildControlSection() {
  169. return FormSection(
  170. title: '关联管控',
  171. children: [
  172. FormFieldRow(label: '关联项目', hint: '请选择项目'),
  173. FormFieldRow(label: '预算科目', hint: '请选择科目'),
  174. _buildBudgetRow(),
  175. ],
  176. );
  177. }
  178. Widget _buildBudgetRow() {
  179. return Container(
  180. height: 44,
  181. padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
  182. child: Row(
  183. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  184. children: [
  185. const Text(
  186. '可用预算余额',
  187. style: TextStyle(
  188. fontSize: AppFontSizes.body,
  189. color: AppColors.textSecondary,
  190. ),
  191. ),
  192. const Text(
  193. '¥50,000.00',
  194. style: TextStyle(
  195. fontSize: AppFontSizes.subtitle,
  196. fontWeight: FontWeight.w700,
  197. color: AppColors.amountPrimary,
  198. ),
  199. ),
  200. ],
  201. ),
  202. );
  203. }
  204. Widget _buildExpenseDetailSection() {
  205. final amount = double.tryParse(_amountController.text) ?? 0.0;
  206. return FormSection(
  207. title: '费用明细',
  208. showAction: true,
  209. actionText: '添加',
  210. onActionTap: () {
  211. ScaffoldMessenger.of(
  212. context,
  213. ).showSnackBar(const SnackBar(content: Text('请在明细中添加费用项')));
  214. },
  215. children: [
  216. if (amount > 0)
  217. Container(
  218. padding: const EdgeInsets.symmetric(vertical: 8),
  219. child: Row(
  220. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  221. children: [
  222. Row(
  223. children: [
  224. Icon(
  225. Icons.receipt_long,
  226. size: 14,
  227. color: AppColors.textSecondary,
  228. ),
  229. const SizedBox(width: 8),
  230. const Text(
  231. '费用项',
  232. style: TextStyle(
  233. fontSize: AppFontSizes.body,
  234. color: AppColors.textPrimary,
  235. ),
  236. ),
  237. ],
  238. ),
  239. Text(
  240. '¥${amount.toStringAsFixed(2)}',
  241. style: const TextStyle(
  242. fontSize: AppFontSizes.body,
  243. fontWeight: FontWeight.w500,
  244. color: AppColors.amountPrimary,
  245. ),
  246. ),
  247. ],
  248. ),
  249. )
  250. else
  251. const Padding(
  252. padding: EdgeInsets.symmetric(vertical: 8),
  253. child: Text(
  254. '暂无明细,点击上方添加',
  255. style: TextStyle(
  256. fontSize: AppFontSizes.body,
  257. color: AppColors.textPlaceholder,
  258. ),
  259. ),
  260. ),
  261. Container(height: 1, color: AppColors.border),
  262. Container(
  263. height: 36,
  264. padding: const EdgeInsets.symmetric(vertical: 8),
  265. child: Row(
  266. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  267. children: [
  268. const Text(
  269. '合计',
  270. style: TextStyle(
  271. fontSize: AppFontSizes.body,
  272. fontWeight: FontWeight.w600,
  273. color: AppColors.textPrimary,
  274. ),
  275. ),
  276. Text(
  277. '¥${amount.toStringAsFixed(2)}',
  278. style: const TextStyle(
  279. fontSize: AppFontSizes.subtitle,
  280. fontWeight: FontWeight.w700,
  281. color: AppColors.amountPrimary,
  282. ),
  283. ),
  284. ],
  285. ),
  286. ),
  287. // Budget warning placeholder
  288. Row(
  289. children: [
  290. Icon(Icons.info_outline, size: 14, color: AppColors.warning),
  291. const SizedBox(width: 6),
  292. const Text(
  293. '超出预算¥0.00',
  294. style: TextStyle(
  295. fontSize: AppFontSizes.caption,
  296. color: AppColors.warning,
  297. ),
  298. ),
  299. ],
  300. ),
  301. ],
  302. );
  303. }
  304. Widget _buildAttachmentSection() {
  305. return FormSection(
  306. title: '附件上传',
  307. children: [
  308. const Text(
  309. '最多上传6张图片或PDF文件',
  310. style: TextStyle(
  311. fontSize: AppFontSizes.caption,
  312. color: AppColors.textPlaceholder,
  313. ),
  314. ),
  315. const SizedBox(height: 8),
  316. _buildUploadGrid(),
  317. ],
  318. );
  319. }
  320. Widget _buildUploadGrid() {
  321. return Wrap(
  322. spacing: 8,
  323. runSpacing: 8,
  324. children: List.generate(6, (i) {
  325. return Container(
  326. width: 80,
  327. height: 80,
  328. decoration: BoxDecoration(
  329. color: AppColors.bgPage,
  330. borderRadius: BorderRadius.circular(4),
  331. border: Border.all(
  332. color: AppColors.border,
  333. width: 1,
  334. strokeAlign: BorderSide.strokeAlignInside,
  335. ),
  336. ),
  337. child: i == 0
  338. ? const Center(
  339. child: Icon(
  340. Icons.add,
  341. size: 24,
  342. color: AppColors.textPlaceholder,
  343. ),
  344. )
  345. : const SizedBox.shrink(),
  346. );
  347. }),
  348. );
  349. }
  350. Widget _buildRadio(String label, String value) {
  351. final isSelected = _urgency == value;
  352. return GestureDetector(
  353. onTap: () {
  354. setState(() => _urgency = value);
  355. },
  356. child: Row(
  357. mainAxisSize: MainAxisSize.min,
  358. children: [
  359. Container(
  360. width: 18,
  361. height: 18,
  362. decoration: BoxDecoration(
  363. shape: BoxShape.circle,
  364. border: Border.all(
  365. color: isSelected
  366. ? AppColors.primary
  367. : AppColors.textPlaceholder,
  368. width: 2,
  369. ),
  370. ),
  371. child: isSelected
  372. ? Center(
  373. child: Container(
  374. width: 8,
  375. height: 8,
  376. decoration: const BoxDecoration(
  377. shape: BoxShape.circle,
  378. color: AppColors.primary,
  379. ),
  380. ),
  381. )
  382. : null,
  383. ),
  384. const SizedBox(width: 6),
  385. Text(
  386. label,
  387. style: TextStyle(
  388. fontSize: AppFontSizes.body,
  389. color: isSelected ? AppColors.primary : AppColors.textSecondary,
  390. ),
  391. ),
  392. ],
  393. ),
  394. );
  395. }
  396. void _showTypePicker() {
  397. showModalBottomSheet(
  398. context: context,
  399. builder: (_) => Column(
  400. mainAxisSize: MainAxisSize.min,
  401. children: _types
  402. .map(
  403. (t) => ListTile(
  404. title: Text(t),
  405. onTap: () {
  406. setState(() => _expenseType = t);
  407. Navigator.pop(context);
  408. },
  409. ),
  410. )
  411. .toList(),
  412. ),
  413. );
  414. }
  415. void _handleSaveDraft() {
  416. if (!_formKey.currentState!.validate()) return;
  417. ScaffoldMessenger.of(
  418. context,
  419. ).showSnackBar(const SnackBar(content: Text('草稿已保存')));
  420. context.pop();
  421. }
  422. void _handleSubmit() {
  423. if (!_formKey.currentState!.validate()) return;
  424. ScaffoldMessenger.of(
  425. context,
  426. ).showSnackBar(const SnackBar(content: Text('提交成功')));
  427. context.pop();
  428. }
  429. }