outing_log_create_page.dart 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651
  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. class OutingLogCreatePage extends ConsumerStatefulWidget {
  11. const OutingLogCreatePage({super.key});
  12. @override
  13. ConsumerState<OutingLogCreatePage> createState() =>
  14. _OutingLogCreatePageState();
  15. }
  16. class _OutingLogCreatePageState extends ConsumerState<OutingLogCreatePage> {
  17. final _customerCtrl = TextEditingController();
  18. final _summaryCtrl = TextEditingController();
  19. final _planCtrl = TextEditingController();
  20. // GPS 模拟
  21. String _gpsAddress = '深圳市南山区科技园南路88号';
  22. final double _gpsLat = 22.5431;
  23. final double _gpsLng = 113.9532;
  24. double _gpsAccuracy = 15.0;
  25. bool _gpsFailed = false;
  26. // 客户联想
  27. final List<String> _mockCustomers = [
  28. '华软科技',
  29. '云创数据',
  30. '数据引力',
  31. '天诚科技',
  32. '博思软件',
  33. '智云科技',
  34. '恒通信息',
  35. '创新无限',
  36. ];
  37. List<String> _customerSuggestions = [];
  38. bool _showCustomerSuggestions = false;
  39. String? _selectedCustomer;
  40. // 客户联系人
  41. final Map<String, List<Map<String, String>>> _mockContacts = {
  42. '华软科技': [
  43. {'name': '赵经理', 'phone': '13800138001', 'position': 'IT经理'},
  44. {'name': '李主管', 'phone': '13800138002', 'position': '采购主管'},
  45. ],
  46. '云创数据': [
  47. {'name': '陈经理', 'phone': '13800138003', 'position': '技术总监'},
  48. ],
  49. '数据引力': [
  50. {'name': '孙总', 'phone': '13800138004', 'position': '总经理'},
  51. ],
  52. '天诚科技': [
  53. {'name': '周主任', 'phone': '13800138005', 'position': '办公室主任'},
  54. ],
  55. };
  56. Map<String, String>? _selectedContact;
  57. // 照片
  58. final List<String> _photos = [];
  59. static const int _maxPhotos = 9;
  60. @override
  61. void dispose() {
  62. _customerCtrl.dispose();
  63. _summaryCtrl.dispose();
  64. _planCtrl.dispose();
  65. super.dispose();
  66. }
  67. void _onCustomerChanged(String value) {
  68. setState(() {
  69. _selectedCustomer = null;
  70. _selectedContact = null;
  71. if (value.isEmpty) {
  72. _customerSuggestions = [];
  73. _showCustomerSuggestions = false;
  74. } else {
  75. _customerSuggestions = _mockCustomers
  76. .where((c) => c.contains(value))
  77. .toList();
  78. _showCustomerSuggestions = _customerSuggestions.isNotEmpty;
  79. }
  80. });
  81. }
  82. void _selectCustomer(String customer) {
  83. setState(() {
  84. _selectedCustomer = customer;
  85. _customerCtrl.text = customer;
  86. _showCustomerSuggestions = false;
  87. _selectedContact = null;
  88. });
  89. }
  90. Future<void> _pickContact() async {
  91. if (_selectedCustomer == null) {
  92. ScaffoldMessenger.of(context).showSnackBar(
  93. const SnackBar(content: Text('请先选择客户名称')),
  94. );
  95. return;
  96. }
  97. final contacts = _mockContacts[_selectedCustomer];
  98. if (contacts == null || contacts.isEmpty) {
  99. ScaffoldMessenger.of(context).showSnackBar(
  100. const SnackBar(content: Text('该客户暂无联系人')),
  101. );
  102. return;
  103. }
  104. final result = await showDialog<Map<String, String>>(
  105. context: context,
  106. builder: (ctx) => SimpleDialog(
  107. title: const Text('选择联系人'),
  108. children: contacts
  109. .map(
  110. (c) => SimpleDialogOption(
  111. onPressed: () => Navigator.pop(ctx, c),
  112. child: Text('${c['name']} ${c['position']} ${c['phone']}'),
  113. ),
  114. )
  115. .toList(),
  116. ),
  117. );
  118. if (result != null) {
  119. setState(() => _selectedContact = result);
  120. }
  121. }
  122. Future<void> _takePhoto() async {
  123. if (_photos.length >= _maxPhotos) {
  124. ScaffoldMessenger.of(context).showSnackBar(
  125. const SnackBar(content: Text('最多拍摄9张照片')),
  126. );
  127. return;
  128. }
  129. final idx = _photos.length + 1;
  130. setState(() {
  131. _photos.add('photo_placeholder_$idx');
  132. });
  133. if (context.mounted) {
  134. ScaffoldMessenger.of(context).showSnackBar(
  135. SnackBar(
  136. content: Text(
  137. '模拟拍照:已拍摄第 $idx 张照片(含水印:${DateTime.now().toString().substring(0, 19)} | $_gpsLat°N, $_gpsLng°E)',
  138. ),
  139. ),
  140. );
  141. }
  142. }
  143. void _removePhoto(int index) {
  144. setState(() => _photos.removeAt(index));
  145. }
  146. Future<void> _simulateGps() async {
  147. setState(() {
  148. _gpsFailed = false;
  149. _gpsAddress = '深圳市南山区科技园南路88号';
  150. _gpsAccuracy = 15.0;
  151. });
  152. ScaffoldMessenger.of(context).showSnackBar(
  153. const SnackBar(content: Text('GPS定位成功')),
  154. );
  155. }
  156. Future<void> _saveDraft() async {
  157. if (context.mounted) {
  158. ScaffoldMessenger.of(context).showSnackBar(
  159. const SnackBar(content: Text('已保存为草稿')),
  160. );
  161. }
  162. }
  163. Future<void> _submit() async {
  164. if (_gpsFailed) {
  165. ScaffoldMessenger.of(context).showSnackBar(
  166. const SnackBar(content: Text('无法获取GPS定位,请检查位置权限')),
  167. );
  168. return;
  169. }
  170. if (_gpsAddress.isEmpty) {
  171. ScaffoldMessenger.of(context).showSnackBar(
  172. const SnackBar(content: Text('GPS定位中,请稍后')),
  173. );
  174. return;
  175. }
  176. if (_photos.isEmpty) {
  177. ScaffoldMessenger.of(context).showSnackBar(
  178. const SnackBar(content: Text('请至少拍摄一张现场照片')),
  179. );
  180. return;
  181. }
  182. if (_summaryCtrl.text.trim().isEmpty) {
  183. ScaffoldMessenger.of(context).showSnackBar(
  184. const SnackBar(content: Text('请填写工作总结')),
  185. );
  186. return;
  187. }
  188. if (context.mounted) {
  189. ScaffoldMessenger.of(context).showSnackBar(
  190. const SnackBar(content: Text('外勤日志提交成功')),
  191. );
  192. context.pop();
  193. }
  194. }
  195. @override
  196. Widget build(BuildContext context) {
  197. final r = ResponsiveHelper.of(context);
  198. final l10n = AppLocalizations.of(context);
  199. ref
  200. .read(navBarConfigProvider.notifier)
  201. .update(
  202. NavBarConfig(
  203. title: l10n.get('outingLogCreate'),
  204. showBack: true,
  205. onBack: () => context.pop(),
  206. ),
  207. );
  208. return Column(
  209. children: [
  210. Expanded(
  211. child: Align(
  212. alignment: Alignment.topCenter,
  213. child: ConstrainedBox(
  214. constraints: BoxConstraints(maxWidth: r.formMaxWidth),
  215. child: SingleChildScrollView(
  216. padding: const EdgeInsets.symmetric(vertical: 8),
  217. child: Column(
  218. children: [
  219. _buildGpsSection(),
  220. const SizedBox(height: 8),
  221. FormSection(
  222. title: l10n.get('customerInfo'),
  223. children: [
  224. Container(
  225. padding: const EdgeInsets.symmetric(
  226. horizontal: 10, vertical: 4),
  227. decoration: BoxDecoration(
  228. color: AppColors.bgPage,
  229. borderRadius: BorderRadius.circular(4),
  230. ),
  231. child: Column(
  232. crossAxisAlignment: CrossAxisAlignment.start,
  233. children: [
  234. TextField(
  235. controller: _customerCtrl,
  236. decoration: InputDecoration(
  237. hintText: l10n.get('searchCustomer'),
  238. hintStyle: TextStyle(
  239. fontSize: 14,
  240. color: AppColors.textPlaceholder,
  241. ),
  242. border: InputBorder.none,
  243. contentPadding:
  244. EdgeInsets.symmetric(vertical: 10),
  245. isDense: true,
  246. ),
  247. style: const TextStyle(
  248. fontSize: 14,
  249. color: AppColors.textPrimary,
  250. ),
  251. onChanged: _onCustomerChanged,
  252. ),
  253. if (_showCustomerSuggestions)
  254. ..._customerSuggestions.map(
  255. (s) => GestureDetector(
  256. onTap: () => _selectCustomer(s),
  257. child: Container(
  258. padding: const EdgeInsets.symmetric(
  259. vertical: 8, horizontal: 4),
  260. decoration: const BoxDecoration(
  261. border: Border(
  262. top: BorderSide(
  263. color: AppColors.border),
  264. ),
  265. ),
  266. child: Text(
  267. s,
  268. style: const TextStyle(
  269. fontSize: 14,
  270. color: AppColors.textPrimary,
  271. ),
  272. ),
  273. ),
  274. ),
  275. ),
  276. ],
  277. ),
  278. ),
  279. const SizedBox(height: 12),
  280. GestureDetector(
  281. onTap: _pickContact,
  282. child: Container(
  283. padding: const EdgeInsets.symmetric(
  284. horizontal: 10, vertical: 12),
  285. decoration: BoxDecoration(
  286. color: AppColors.bgPage,
  287. borderRadius: BorderRadius.circular(4),
  288. ),
  289. child: Row(
  290. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  291. children: [
  292. Text(
  293. _selectedContact != null
  294. ? '${_selectedContact!['name']} ${_selectedContact!['phone']}'
  295. : l10n.get('selectContactHint'),
  296. style: TextStyle(
  297. fontSize: 14,
  298. color: _selectedContact != null
  299. ? AppColors.textPrimary
  300. : AppColors.textPlaceholder,
  301. ),
  302. ),
  303. const Icon(Icons.chevron_right,
  304. size: 14,
  305. color: AppColors.textPlaceholder),
  306. ],
  307. ),
  308. ),
  309. ),
  310. ],
  311. ),
  312. const SizedBox(height: 8),
  313. FormSection(
  314. title: l10n.get('workSummary'),
  315. children: [
  316. Container(
  317. padding: const EdgeInsets.all(12),
  318. decoration: BoxDecoration(
  319. color: AppColors.bgPage,
  320. borderRadius: BorderRadius.circular(4),
  321. ),
  322. child: TextField(
  323. controller: _summaryCtrl,
  324. maxLines: 5,
  325. decoration: const InputDecoration(
  326. hintText: '请填写本次外勤工作总结(必填)',
  327. hintStyle: TextStyle(
  328. fontSize: 14,
  329. color: AppColors.textPlaceholder,
  330. ),
  331. border: InputBorder.none,
  332. contentPadding: EdgeInsets.zero,
  333. isDense: true,
  334. ),
  335. style: const TextStyle(
  336. fontSize: 14,
  337. color: AppColors.textPrimary,
  338. ),
  339. ),
  340. ),
  341. ],
  342. ),
  343. const SizedBox(height: 8),
  344. FormSection(
  345. title: l10n.get('followUp'),
  346. children: [
  347. Container(
  348. padding: const EdgeInsets.symmetric(
  349. horizontal: 10, vertical: 4),
  350. decoration: BoxDecoration(
  351. color: AppColors.bgPage,
  352. borderRadius: BorderRadius.circular(4),
  353. ),
  354. child: TextField(
  355. controller: _planCtrl,
  356. decoration: const InputDecoration(
  357. hintText: '后续推进计划(选填)',
  358. hintStyle: TextStyle(
  359. fontSize: 14,
  360. color: AppColors.textPlaceholder,
  361. ),
  362. border: InputBorder.none,
  363. contentPadding:
  364. EdgeInsets.symmetric(vertical: 10),
  365. isDense: true,
  366. ),
  367. style: const TextStyle(
  368. fontSize: 14,
  369. color: AppColors.textPrimary,
  370. ),
  371. ),
  372. ),
  373. ],
  374. ),
  375. const SizedBox(height: 8),
  376. FormSection(
  377. title: l10n.get('sitePhotos'),
  378. actionText:
  379. _photos.length >= _maxPhotos ? l10n.get('limitReached') : l10n.get('takePhoto'),
  380. showAction: _photos.length < _maxPhotos,
  381. actionIcon: Icons.camera_alt_outlined,
  382. onActionTap:
  383. _photos.length >= _maxPhotos ? null : _takePhoto,
  384. children: [
  385. if (_photos.isEmpty)
  386. GestureDetector(
  387. onTap: _takePhoto,
  388. child: Container(
  389. height: 100,
  390. decoration: BoxDecoration(
  391. color: AppColors.bgPage,
  392. borderRadius: BorderRadius.circular(4),
  393. border: Border.all(
  394. color: AppColors.border, width: 1),
  395. ),
  396. child: const Center(
  397. child: Column(
  398. mainAxisAlignment: MainAxisAlignment.center,
  399. children: [
  400. Icon(Icons.camera_alt_outlined,
  401. size: 32, color: AppColors.primary),
  402. SizedBox(height: 4),
  403. Text(
  404. '点击拍照(至少1张)',
  405. style: TextStyle(
  406. fontSize: 12,
  407. color: AppColors.textPlaceholder,
  408. ),
  409. ),
  410. ],
  411. ),
  412. ),
  413. ),
  414. )
  415. else
  416. Wrap(
  417. spacing: 8,
  418. runSpacing: 8,
  419. children: [
  420. ..._photos.asMap().entries.map(
  421. (entry) =>
  422. _buildPhotoThumbnail(
  423. entry.key, entry.value),
  424. ),
  425. if (_photos.length < _maxPhotos)
  426. GestureDetector(
  427. onTap: _takePhoto,
  428. child: Container(
  429. width: 80,
  430. height: 80,
  431. decoration: BoxDecoration(
  432. color: AppColors.bgPage,
  433. borderRadius: BorderRadius.circular(4),
  434. border: Border.all(
  435. color: AppColors.border),
  436. ),
  437. child: const Icon(Icons.add,
  438. size: 28, color: AppColors.primary),
  439. ),
  440. ),
  441. ],
  442. ),
  443. const SizedBox(height: 8),
  444. Container(
  445. padding: const EdgeInsets.all(8),
  446. decoration: BoxDecoration(
  447. color: AppColors.primaryLight,
  448. borderRadius: BorderRadius.circular(4),
  449. ),
  450. child: Row(
  451. children: [
  452. const Icon(Icons.info_outline,
  453. size: 14, color: AppColors.primary),
  454. const SizedBox(width: 6),
  455. Expanded(
  456. child: Text(
  457. '照片将自动添加水印:服务器授时 + GPS经纬度(${_gpsLat.toStringAsFixed(4)}°N, ${_gpsLng.toStringAsFixed(4)}°E)',
  458. style: const TextStyle(
  459. fontSize: 11,
  460. color: AppColors.textSecondary,
  461. ),
  462. ),
  463. ),
  464. ],
  465. ),
  466. ),
  467. ],
  468. ),
  469. const SizedBox(height: 80),
  470. ],
  471. ),
  472. ),
  473. ),
  474. ),
  475. ),
  476. ActionBar(
  477. centerLabel: l10n.get('saveDraft'),
  478. rightLabel: l10n.get('submit'),
  479. showLeft: false,
  480. onCenterTap: _saveDraft,
  481. onRightTap: _submit,
  482. ),
  483. ],
  484. );
  485. }
  486. Widget _buildGpsSection() {
  487. if (_gpsFailed) {
  488. return Container(
  489. margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
  490. padding: const EdgeInsets.all(12),
  491. decoration: BoxDecoration(
  492. color: AppColors.bgCard,
  493. borderRadius: BorderRadius.circular(8),
  494. ),
  495. child: Row(
  496. children: [
  497. const Icon(Icons.location_off,
  498. size: 22, color: AppColors.statusGray),
  499. const SizedBox(width: 10),
  500. const Expanded(
  501. child: Column(
  502. crossAxisAlignment: CrossAxisAlignment.start,
  503. children: [
  504. Text('无法获取当前位置',
  505. style: TextStyle(
  506. fontSize: 14, color: AppColors.textPrimary)),
  507. SizedBox(height: 4),
  508. Text('请检查位置权限设置',
  509. style: TextStyle(
  510. fontSize: 12, color: AppColors.textSecondary)),
  511. ],
  512. ),
  513. ),
  514. GestureDetector(
  515. onTap: _simulateGps,
  516. child: Container(
  517. padding:
  518. const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
  519. decoration: BoxDecoration(
  520. color: AppColors.primaryLight,
  521. borderRadius: BorderRadius.circular(4),
  522. ),
  523. child: const Text('重试',
  524. style:
  525. TextStyle(fontSize: 12, color: AppColors.primary)),
  526. ),
  527. ),
  528. ],
  529. ),
  530. );
  531. }
  532. final isWarning = _gpsAccuracy > 100;
  533. return Container(
  534. margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
  535. padding: const EdgeInsets.all(12),
  536. decoration: BoxDecoration(
  537. color: AppColors.bgCard,
  538. borderRadius: BorderRadius.circular(8),
  539. ),
  540. child: Row(
  541. crossAxisAlignment: CrossAxisAlignment.start,
  542. children: [
  543. Icon(
  544. Icons.shield_outlined,
  545. size: 22,
  546. color: isWarning ? AppColors.warning : AppColors.success,
  547. ),
  548. const SizedBox(width: 10),
  549. Expanded(
  550. child: Column(
  551. crossAxisAlignment: CrossAxisAlignment.start,
  552. children: [
  553. Text(_gpsAddress,
  554. style: const TextStyle(
  555. fontSize: 14, color: AppColors.textPrimary)),
  556. const SizedBox(height: 4),
  557. Row(
  558. children: [
  559. Text(
  560. '${_gpsLat.toStringAsFixed(4)}°N, ${_gpsLng.toStringAsFixed(4)}°E · 精度 ${_gpsAccuracy.toStringAsFixed(0)}m',
  561. style: TextStyle(
  562. fontSize: 12,
  563. color: isWarning
  564. ? AppColors.warning
  565. : AppColors.textSecondary,
  566. ),
  567. ),
  568. if (isWarning) ...[
  569. const SizedBox(width: 6),
  570. const Icon(Icons.warning_amber_rounded,
  571. size: 14, color: AppColors.warning),
  572. ],
  573. ],
  574. ),
  575. ],
  576. ),
  577. ),
  578. ],
  579. ),
  580. );
  581. }
  582. Widget _buildPhotoThumbnail(int index, String photo) {
  583. return Stack(
  584. children: [
  585. Container(
  586. width: 80,
  587. height: 80,
  588. decoration: BoxDecoration(
  589. color: const Color(0xFFD0E8F8),
  590. borderRadius: BorderRadius.circular(4),
  591. ),
  592. child: const Center(
  593. child: Icon(Icons.image_outlined,
  594. size: 32, color: AppColors.primary),
  595. ),
  596. ),
  597. Positioned(
  598. top: -4,
  599. right: -4,
  600. child: GestureDetector(
  601. onTap: () => _removePhoto(index),
  602. child: Container(
  603. padding: const EdgeInsets.all(2),
  604. decoration: const BoxDecoration(
  605. color: AppColors.danger,
  606. shape: BoxShape.circle,
  607. ),
  608. child:
  609. const Icon(Icons.close, size: 14, color: Colors.white),
  610. ),
  611. ),
  612. ),
  613. Positioned(
  614. bottom: 2,
  615. left: 2,
  616. right: 2,
  617. child: Container(
  618. padding: const EdgeInsets.symmetric(horizontal: 2),
  619. color: Colors.black54,
  620. child: Text(
  621. '${DateTime.now().hour.toString().padLeft(2, '0')}:${DateTime.now().minute.toString().padLeft(2, '0')} ${_gpsLat.toStringAsFixed(2)},${_gpsLng.toStringAsFixed(2)}',
  622. style: const TextStyle(fontSize: 8, color: Colors.white),
  623. maxLines: 1,
  624. overflow: TextOverflow.ellipsis,
  625. ),
  626. ),
  627. ),
  628. ],
  629. );
  630. }
  631. }