outing_log_create_page.dart 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  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/action_bar.dart';
  9. import '../../core/i18n/app_localizations.dart';
  10. import 'outing_log_api.dart';
  11. import 'outing_log_model.dart';
  12. class OutingLogCreatePage extends ConsumerStatefulWidget {
  13. const OutingLogCreatePage({super.key});
  14. @override
  15. ConsumerState<OutingLogCreatePage> createState() =>
  16. _OutingLogCreatePageState();
  17. }
  18. class _OutingLogCreatePageState extends ConsumerState<OutingLogCreatePage> {
  19. final _contentCtrl = TextEditingController();
  20. final _clientCtrl = TextEditingController();
  21. final _addressCtrl = TextEditingController();
  22. final _planCtrl = TextEditingController();
  23. DateTime _date = DateTime.now();
  24. TimeOfDay _startTime = const TimeOfDay(hour: 9, minute: 0);
  25. TimeOfDay _endTime = const TimeOfDay(hour: 17, minute: 0);
  26. String _visitType = '客户拜访';
  27. final _visitTypes = ['客户拜访', '外出办事', '其他'];
  28. @override
  29. void dispose() {
  30. _contentCtrl.dispose();
  31. _clientCtrl.dispose();
  32. _addressCtrl.dispose();
  33. _planCtrl.dispose();
  34. super.dispose();
  35. }
  36. @override
  37. Widget build(BuildContext context) {
  38. final r = ResponsiveHelper.of(context);
  39. final l10n = AppLocalizations.of(context);
  40. ref
  41. .read(navBarConfigProvider.notifier)
  42. .update(
  43. NavBarConfig(
  44. title: l10n.get('outingLogCreate'),
  45. showBack: true,
  46. onBack: () => context.pop(),
  47. ),
  48. );
  49. return Column(
  50. children: [
  51. Expanded(
  52. child: Align(
  53. alignment: Alignment.topCenter,
  54. child: ConstrainedBox(
  55. constraints: BoxConstraints(maxWidth: r.formMaxWidth),
  56. child: SingleChildScrollView(
  57. padding: const EdgeInsets.symmetric(vertical: 8),
  58. child: Column(
  59. children: [
  60. // GPS 信息卡片
  61. Container(
  62. margin: const EdgeInsets.symmetric(
  63. horizontal: 16,
  64. vertical: 4,
  65. ),
  66. padding: const EdgeInsets.all(12),
  67. decoration: BoxDecoration(
  68. color: AppColors.bgCard,
  69. borderRadius: BorderRadius.circular(8),
  70. ),
  71. child: Row(
  72. children: [
  73. const Icon(
  74. Icons.shield_outlined,
  75. size: 22,
  76. color: AppColors.success,
  77. ),
  78. const SizedBox(width: 10),
  79. const Column(
  80. crossAxisAlignment: CrossAxisAlignment.start,
  81. children: [
  82. Text(
  83. '深圳市南山区科技园南路 88 号',
  84. style: TextStyle(
  85. fontSize: 14,
  86. color: AppColors.textPrimary,
  87. ),
  88. ),
  89. SizedBox(height: 4),
  90. Text(
  91. '22.5431°N, 113.9532°E · 精度 15m',
  92. style: TextStyle(
  93. fontSize: 12,
  94. color: AppColors.textSecondary,
  95. ),
  96. ),
  97. ],
  98. ),
  99. ],
  100. ),
  101. ),
  102. const SizedBox(height: 8),
  103. // 基本信息
  104. FormSection(
  105. title: '基本信息',
  106. children: [
  107. _buildFormField('创建人', '张三', readOnly: true),
  108. const Divider(height: 1, color: AppColors.border),
  109. _buildFormField('部门', '市场部', readOnly: true),
  110. const Divider(height: 1, color: AppColors.border),
  111. _buildFormField(
  112. '日期',
  113. '${_date.year}-${_date.month.toString().padLeft(2, '0')}-${_date.day.toString().padLeft(2, '0')}',
  114. onTap: _pickDate,
  115. ),
  116. ],
  117. ),
  118. const SizedBox(height: 8),
  119. // 外出详情
  120. FormSection(
  121. title: '外出详情',
  122. children: [
  123. _buildSelectField('外出类型', _visitType, _pickVisitType),
  124. const Divider(height: 1, color: AppColors.border),
  125. _buildFormField(
  126. '外出地点',
  127. _addressCtrl.text.isEmpty ? null : _addressCtrl.text,
  128. hint: '请输入外出地点',
  129. showArrow: false,
  130. onTap: () {},
  131. ),
  132. if (_addressCtrl.text.isEmpty)
  133. Padding(
  134. padding: const EdgeInsets.only(bottom: 4),
  135. child: TextField(
  136. controller: _addressCtrl,
  137. decoration: const InputDecoration(
  138. hintText: '请输入外出地点',
  139. hintStyle: TextStyle(
  140. fontSize: 14,
  141. color: AppColors.textPlaceholder,
  142. ),
  143. border: InputBorder.none,
  144. contentPadding: EdgeInsets.zero,
  145. isDense: true,
  146. ),
  147. style: const TextStyle(
  148. fontSize: 14,
  149. color: AppColors.textPrimary,
  150. ),
  151. ),
  152. ),
  153. const Divider(height: 1, color: AppColors.border),
  154. _buildFormField(
  155. '开始时间',
  156. _startTime.format(context),
  157. onTap: () => _pickTime(true),
  158. ),
  159. const Divider(height: 1, color: AppColors.border),
  160. _buildFormField(
  161. '结束时间',
  162. _endTime.format(context),
  163. onTap: () => _pickTime(false),
  164. ),
  165. const Divider(height: 1, color: AppColors.border),
  166. const SizedBox(height: 8),
  167. const Text(
  168. '外出事由',
  169. style: TextStyle(
  170. fontSize: 14,
  171. color: AppColors.textSecondary,
  172. ),
  173. ),
  174. const SizedBox(height: 8),
  175. Container(
  176. padding: const EdgeInsets.all(12),
  177. decoration: BoxDecoration(
  178. color: AppColors.bgPage,
  179. borderRadius: BorderRadius.circular(4),
  180. ),
  181. child: TextField(
  182. controller: _contentCtrl,
  183. maxLines: 5,
  184. decoration: const InputDecoration(
  185. hintText: '请填写外出事由及工作内容...',
  186. hintStyle: TextStyle(
  187. fontSize: 14,
  188. color: AppColors.textPlaceholder,
  189. ),
  190. border: InputBorder.none,
  191. contentPadding: EdgeInsets.zero,
  192. isDense: true,
  193. ),
  194. style: const TextStyle(
  195. fontSize: 14,
  196. color: AppColors.textPrimary,
  197. ),
  198. ),
  199. ),
  200. ],
  201. ),
  202. const SizedBox(height: 8),
  203. // 附件
  204. FormSection(
  205. title: '附件',
  206. children: [
  207. Container(
  208. height: 80,
  209. decoration: BoxDecoration(
  210. color: AppColors.bgPage,
  211. borderRadius: BorderRadius.circular(4),
  212. ),
  213. child: const Center(
  214. child: Column(
  215. mainAxisAlignment: MainAxisAlignment.center,
  216. children: [
  217. Icon(
  218. Icons.add_photo_alternate_outlined,
  219. size: 28,
  220. color: AppColors.primary,
  221. ),
  222. SizedBox(height: 4),
  223. Text(
  224. '添加附件',
  225. style: TextStyle(
  226. fontSize: 12,
  227. color: AppColors.primary,
  228. ),
  229. ),
  230. ],
  231. ),
  232. ),
  233. ),
  234. ],
  235. ),
  236. const SizedBox(height: 80),
  237. ],
  238. ),
  239. ),
  240. ),
  241. ),
  242. ),
  243. // ActionBar
  244. ActionBar(
  245. centerLabel: '存草稿',
  246. rightLabel: '提交',
  247. showLeft: false,
  248. onCenterTap: _saveDraft,
  249. onRightTap: _submit,
  250. ),
  251. ],
  252. );
  253. }
  254. Widget _buildFormField(
  255. String label,
  256. String? value, {
  257. String? hint,
  258. bool readOnly = false,
  259. bool showArrow = true,
  260. VoidCallback? onTap,
  261. }) {
  262. final hasValue = value != null && value.isNotEmpty;
  263. return GestureDetector(
  264. onTap: readOnly ? null : onTap,
  265. child: Container(
  266. height: 44,
  267. alignment: Alignment.centerLeft,
  268. child: Row(
  269. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  270. children: [
  271. Text(
  272. label,
  273. style: const TextStyle(
  274. fontSize: 14,
  275. color: AppColors.textSecondary,
  276. ),
  277. ),
  278. Row(
  279. mainAxisSize: MainAxisSize.min,
  280. children: [
  281. Text(
  282. hasValue ? value : (hint ?? '请选择或填写'),
  283. style: TextStyle(
  284. fontSize: 14,
  285. color: hasValue
  286. ? AppColors.textPrimary
  287. : AppColors.textPlaceholder,
  288. ),
  289. ),
  290. if (showArrow && !readOnly) ...[
  291. const SizedBox(width: 4),
  292. const Icon(
  293. Icons.chevron_right,
  294. size: 14,
  295. color: AppColors.textPlaceholder,
  296. ),
  297. ],
  298. ],
  299. ),
  300. ],
  301. ),
  302. ),
  303. );
  304. }
  305. Widget _buildSelectField(String label, String value, VoidCallback onTap) {
  306. return GestureDetector(
  307. onTap: onTap,
  308. child: Container(
  309. height: 44,
  310. alignment: Alignment.centerLeft,
  311. child: Row(
  312. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  313. children: [
  314. Text(
  315. label,
  316. style: const TextStyle(
  317. fontSize: 14,
  318. color: AppColors.textSecondary,
  319. ),
  320. ),
  321. Row(
  322. mainAxisSize: MainAxisSize.min,
  323. children: [
  324. Text(
  325. value,
  326. style: const TextStyle(
  327. fontSize: 14,
  328. color: AppColors.textPrimary,
  329. ),
  330. ),
  331. const SizedBox(width: 4),
  332. const Icon(
  333. Icons.chevron_right,
  334. size: 14,
  335. color: AppColors.textPlaceholder,
  336. ),
  337. ],
  338. ),
  339. ],
  340. ),
  341. ),
  342. );
  343. }
  344. Future<void> _pickDate() async {
  345. final picked = await showDatePicker(
  346. context: context,
  347. initialDate: _date,
  348. firstDate: DateTime(2020),
  349. lastDate: DateTime(2030),
  350. );
  351. if (picked != null) setState(() => _date = picked);
  352. }
  353. Future<void> _pickTime(bool isStart) async {
  354. final picked = await showTimePicker(
  355. context: context,
  356. initialTime: isStart ? _startTime : _endTime,
  357. );
  358. if (picked != null) {
  359. setState(() {
  360. if (isStart) {
  361. _startTime = picked;
  362. } else {
  363. _endTime = picked;
  364. }
  365. });
  366. }
  367. }
  368. Future<void> _pickVisitType() async {
  369. final result = await showDialog<String>(
  370. context: context,
  371. builder: (ctx) => SimpleDialog(
  372. title: const Text('选择外出类型'),
  373. children: _visitTypes
  374. .map(
  375. (t) => SimpleDialogOption(
  376. onPressed: () => Navigator.pop(ctx, t),
  377. child: Text(t),
  378. ),
  379. )
  380. .toList(),
  381. ),
  382. );
  383. if (result != null) setState(() => _visitType = result);
  384. }
  385. Future<void> _saveDraft() async {
  386. // TODO: 存草稿逻辑
  387. if (context.mounted) {
  388. ScaffoldMessenger.of(
  389. context,
  390. ).showSnackBar(const SnackBar(content: Text('已保存草稿')));
  391. }
  392. }
  393. Future<void> _submit() async {
  394. try {
  395. final visitDate = DateTime(_date.year, _date.month, _date.day);
  396. await ref
  397. .read(outingLogApiProvider)
  398. .create(
  399. OutingLogModel(
  400. id: '',
  401. visitNo: '',
  402. salespersonId: '',
  403. salespersonName: '张三',
  404. deptId: '',
  405. deptName: '市场部',
  406. customerName: _clientCtrl.text,
  407. visitDate: visitDate,
  408. visitStartTime: DateTime(
  409. visitDate.year,
  410. visitDate.month,
  411. visitDate.day,
  412. _startTime.hour,
  413. _startTime.minute,
  414. ),
  415. visitEndTime: DateTime(
  416. visitDate.year,
  417. visitDate.month,
  418. visitDate.day,
  419. _endTime.hour,
  420. _endTime.minute,
  421. ),
  422. visitType: _visitType,
  423. visitPurpose: '',
  424. visitLocation: _addressCtrl.text,
  425. visitSummary: _contentCtrl.text,
  426. nextVisitTime: visitDate,
  427. createTime: DateTime.now(),
  428. updateTime: DateTime.now(),
  429. ),
  430. );
  431. if (context.mounted) context.pop();
  432. } catch (_) {}
  433. }
  434. }