outing_log_detail_page.dart 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561
  1. import 'package:flutter/material.dart';
  2. import 'package:go_router/go_router.dart';
  3. import 'package:flutter_riverpod/flutter_riverpod.dart';
  4. import '../../core/theme/app_colors.dart';
  5. import '../shell/nav_bar_config.dart';
  6. import '../../core/utils/date_utils.dart' as du;
  7. import '../../core/i18n/app_localizations.dart';
  8. import 'outing_log_list_controller.dart';
  9. import 'outing_log_comment.dart';
  10. import 'outing_log_model.dart';
  11. class OutingLogDetailPage extends ConsumerStatefulWidget {
  12. final String id;
  13. const OutingLogDetailPage({super.key, required this.id});
  14. @override
  15. ConsumerState<OutingLogDetailPage> createState() =>
  16. _OutingLogDetailPageState();
  17. }
  18. class _OutingLogDetailPageState extends ConsumerState<OutingLogDetailPage> {
  19. late OutingLogModel _log;
  20. final bool _isManager = true; // 模拟经理角色
  21. int _rating = 0;
  22. final _commentCtrl = TextEditingController();
  23. final List<OutingLogComment> _comments = [];
  24. @override
  25. void initState() {
  26. super.initState();
  27. _log = mockOutingLogs.firstWhere(
  28. (e) => e.id == widget.id,
  29. orElse: () => mockOutingLogs.first,
  30. );
  31. _comments.addAll(_log.comments);
  32. // 模拟:进入详情时更新 LastViewedTime
  33. Future.delayed(const Duration(milliseconds: 500), () {
  34. if (mounted) setState(() {});
  35. });
  36. }
  37. @override
  38. void didChangeDependencies() {
  39. super.didChangeDependencies();
  40. _log = mockOutingLogs.firstWhere(
  41. (e) => e.id == widget.id,
  42. orElse: () => mockOutingLogs.first,
  43. );
  44. }
  45. @override
  46. void dispose() {
  47. _commentCtrl.dispose();
  48. super.dispose();
  49. }
  50. void _sendComment() {
  51. if (_rating == 0) {
  52. ScaffoldMessenger.of(context).showSnackBar(
  53. const SnackBar(content: Text('请选择评分')),
  54. );
  55. return;
  56. }
  57. final content = _commentCtrl.text.trim();
  58. if (content.isEmpty) {
  59. ScaffoldMessenger.of(context).showSnackBar(
  60. const SnackBar(content: Text('请输入点评内容')),
  61. );
  62. return;
  63. }
  64. setState(() {
  65. _comments.add(OutingLogComment(
  66. id: 'cmt-new-${DateTime.now().millisecondsSinceEpoch}',
  67. logId: widget.id,
  68. commenterId: 'u-mgr',
  69. commenterName: '王经理',
  70. commenterPosition: '销售总监',
  71. ratingStars: _rating,
  72. commentText: content,
  73. createTime: DateTime.now(),
  74. ));
  75. _rating = 0;
  76. _commentCtrl.clear();
  77. });
  78. ScaffoldMessenger.of(context).showSnackBar(
  79. const SnackBar(content: Text('点评已发送')),
  80. );
  81. }
  82. @override
  83. Widget build(BuildContext context) {
  84. final l10n = AppLocalizations.of(context);
  85. ref
  86. .read(navBarConfigProvider.notifier)
  87. .update(
  88. NavBarConfig(
  89. title: l10n.get('outingLogDetail'),
  90. showBack: true,
  91. onBack: () => context.pop(),
  92. ),
  93. );
  94. return Column(
  95. children: [
  96. Expanded(
  97. child: SingleChildScrollView(
  98. child: Column(
  99. children: [
  100. _buildMapPlaceholder(),
  101. _buildInfoSection(),
  102. const SizedBox(height: 8),
  103. _buildSectionCard(l10n.get('workSummary'),
  104. _log.visitSummary.isNotEmpty ? _log.visitSummary : l10n.get('noWorkSummary')),
  105. const SizedBox(height: 8),
  106. _buildSectionCard(l10n.get('followUp'),
  107. _log.nextPlan.isNotEmpty ? _log.nextPlan : l10n.get('noPlan')),
  108. const SizedBox(height: 8),
  109. _buildPhotoSection(),
  110. const SizedBox(height: 8),
  111. _buildCommentSection(),
  112. if (_isManager) _buildManagerCommentInput(),
  113. const SizedBox(height: 24),
  114. ],
  115. ),
  116. ),
  117. ),
  118. ],
  119. );
  120. }
  121. Widget _buildMapPlaceholder() {
  122. return Container(
  123. width: double.infinity,
  124. height: 160,
  125. padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
  126. child: GestureDetector(
  127. onTap: () {
  128. ScaffoldMessenger.of(context).showSnackBar(
  129. const SnackBar(content: Text('模拟:打开原生导航')),
  130. );
  131. },
  132. child: Container(
  133. decoration: BoxDecoration(
  134. color: const Color(0xFFE8F4FD),
  135. borderRadius: BorderRadius.circular(8),
  136. ),
  137. child: Stack(
  138. children: [
  139. const Center(
  140. child: Column(
  141. mainAxisAlignment: MainAxisAlignment.center,
  142. children: [
  143. Icon(Icons.map_outlined,
  144. size: 40, color: AppColors.primary),
  145. SizedBox(height: 4),
  146. Text('点击查看导航',
  147. style: TextStyle(
  148. fontSize: 12, color: AppColors.primary)),
  149. ],
  150. ),
  151. ),
  152. Positioned(
  153. bottom: 8,
  154. left: 8,
  155. child: Container(
  156. padding:
  157. const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
  158. decoration: BoxDecoration(
  159. color: Colors.black54,
  160. borderRadius: BorderRadius.circular(4),
  161. ),
  162. child: Text(
  163. '${_log.checkInLatitude?.toStringAsFixed(4) ?? "0.0000"}, ${_log.checkInLongitude?.toStringAsFixed(4) ?? "0.0000"}',
  164. style: const TextStyle(fontSize: 10, color: Colors.white),
  165. ),
  166. ),
  167. ),
  168. ],
  169. ),
  170. ),
  171. ),
  172. );
  173. }
  174. Widget _buildInfoSection() {
  175. return Padding(
  176. padding: const EdgeInsets.all(16),
  177. child: Container(
  178. width: double.infinity,
  179. padding: const EdgeInsets.all(16),
  180. decoration: BoxDecoration(
  181. color: AppColors.bgCard,
  182. borderRadius: BorderRadius.circular(8),
  183. ),
  184. child: Column(
  185. crossAxisAlignment: CrossAxisAlignment.start,
  186. children: [
  187. Text(
  188. _log.customerName,
  189. style: const TextStyle(
  190. fontSize: 18,
  191. fontWeight: FontWeight.w700,
  192. color: AppColors.textPrimary,
  193. ),
  194. ),
  195. const SizedBox(height: 12),
  196. _buildInfoRow('业务员', _log.salespersonName),
  197. const Divider(height: 12, color: AppColors.border),
  198. _buildInfoRow('部门', _log.deptName),
  199. const Divider(height: 12, color: AppColors.border),
  200. _buildInfoRow('客户名', _log.customerName),
  201. const Divider(height: 12, color: AppColors.border),
  202. _buildInfoRow('签到地址', _log.checkInAddress),
  203. const Divider(height: 12, color: AppColors.border),
  204. _buildInfoRow('签到时间',
  205. du.DateUtils.formatDateTime(_log.createTime)),
  206. ],
  207. ),
  208. ),
  209. );
  210. }
  211. Widget _buildInfoRow(String label, String value) {
  212. return Padding(
  213. padding: const EdgeInsets.symmetric(vertical: 4),
  214. child: Row(
  215. crossAxisAlignment: CrossAxisAlignment.start,
  216. children: [
  217. SizedBox(
  218. width: 70,
  219. child: Text(label,
  220. style: const TextStyle(
  221. fontSize: 13, color: AppColors.textSecondary)),
  222. ),
  223. Expanded(
  224. child: Text(value,
  225. style: const TextStyle(
  226. fontSize: 13, color: AppColors.textPrimary)),
  227. ),
  228. ],
  229. ),
  230. );
  231. }
  232. Widget _buildSectionCard(String title, String content) {
  233. return Padding(
  234. padding: const EdgeInsets.symmetric(horizontal: 16),
  235. child: Container(
  236. width: double.infinity,
  237. padding: const EdgeInsets.all(16),
  238. decoration: BoxDecoration(
  239. color: AppColors.bgCard,
  240. borderRadius: BorderRadius.circular(8),
  241. ),
  242. child: Column(
  243. crossAxisAlignment: CrossAxisAlignment.start,
  244. children: [
  245. Text(title,
  246. style: const TextStyle(
  247. fontSize: 14,
  248. fontWeight: FontWeight.w600,
  249. color: AppColors.textPrimary)),
  250. const SizedBox(height: 8),
  251. Text(content,
  252. style: const TextStyle(
  253. fontSize: 14,
  254. color: AppColors.textSecondary,
  255. height: 1.5)),
  256. ],
  257. ),
  258. ),
  259. );
  260. }
  261. Widget _buildPhotoSection() {
  262. final l10n = AppLocalizations.of(context);
  263. final photos = _log.visitPhotos;
  264. return Padding(
  265. padding: const EdgeInsets.symmetric(horizontal: 16),
  266. child: Container(
  267. width: double.infinity,
  268. padding: const EdgeInsets.all(16),
  269. decoration: BoxDecoration(
  270. color: AppColors.bgCard,
  271. borderRadius: BorderRadius.circular(8),
  272. ),
  273. child: Column(
  274. crossAxisAlignment: CrossAxisAlignment.start,
  275. children: [
  276. Text(l10n.get('sitePhotos'),
  277. style: const TextStyle(
  278. fontSize: 14,
  279. fontWeight: FontWeight.w600,
  280. color: AppColors.textPrimary)),
  281. const SizedBox(height: 12),
  282. if (photos.isEmpty)
  283. Text(l10n.get('noPhotos'),
  284. style: TextStyle(
  285. fontSize: 13, color: AppColors.textPlaceholder))
  286. else
  287. Wrap(
  288. spacing: 8,
  289. runSpacing: 8,
  290. children: photos.map((photo) {
  291. return GestureDetector(
  292. onTap: () {
  293. ScaffoldMessenger.of(context).showSnackBar(
  294. const SnackBar(content: Text('模拟:全屏预览照片')),
  295. );
  296. },
  297. child: Stack(
  298. children: [
  299. Container(
  300. width: 100,
  301. height: 100,
  302. decoration: BoxDecoration(
  303. color: const Color(0xFFD0E8F8),
  304. borderRadius: BorderRadius.circular(4),
  305. ),
  306. child: const Center(
  307. child: Icon(Icons.image_outlined,
  308. size: 36, color: AppColors.primary),
  309. ),
  310. ),
  311. Positioned(
  312. bottom: 0,
  313. left: 0,
  314. right: 0,
  315. child: Container(
  316. padding: const EdgeInsets.symmetric(
  317. horizontal: 3, vertical: 2),
  318. color: Colors.black54,
  319. child: Text(
  320. '${du.DateUtils.formatDate(_log.createTime)} ${_log.checkInLatitude?.toStringAsFixed(2) ?? "0.00"},${_log.checkInLongitude?.toStringAsFixed(2) ?? "0.00"}',
  321. style: const TextStyle(
  322. fontSize: 8, color: Colors.white),
  323. maxLines: 1,
  324. overflow: TextOverflow.ellipsis,
  325. ),
  326. ),
  327. ),
  328. ],
  329. ),
  330. );
  331. }).toList(),
  332. ),
  333. ],
  334. ),
  335. ),
  336. );
  337. }
  338. Widget _buildCommentSection() {
  339. final l10n = AppLocalizations.of(context);
  340. return Padding(
  341. padding: const EdgeInsets.symmetric(horizontal: 16),
  342. child: Container(
  343. width: double.infinity,
  344. padding: const EdgeInsets.all(16),
  345. decoration: BoxDecoration(
  346. color: AppColors.bgCard,
  347. borderRadius: BorderRadius.circular(8),
  348. ),
  349. child: Column(
  350. crossAxisAlignment: CrossAxisAlignment.start,
  351. children: [
  352. Text(l10n.get('comments'),
  353. style: const TextStyle(
  354. fontSize: 14,
  355. fontWeight: FontWeight.w600,
  356. color: AppColors.textPrimary)),
  357. const SizedBox(height: 12),
  358. if (_comments.isEmpty)
  359. Center(
  360. child: Padding(
  361. padding: const EdgeInsets.symmetric(vertical: 16),
  362. child: Text(l10n.get('noComments'),
  363. style: TextStyle(
  364. fontSize: 13, color: AppColors.textPlaceholder)),
  365. ),
  366. )
  367. else
  368. ..._comments.map((comment) => _buildCommentBubble(comment)),
  369. ],
  370. ),
  371. ),
  372. );
  373. }
  374. Widget _buildCommentBubble(OutingLogComment comment) {
  375. return Container(
  376. width: double.infinity,
  377. margin: const EdgeInsets.only(bottom: 12),
  378. padding: const EdgeInsets.all(12),
  379. decoration: BoxDecoration(
  380. color: AppColors.primaryLight,
  381. borderRadius: const BorderRadius.only(
  382. topLeft: Radius.circular(8),
  383. topRight: Radius.circular(8),
  384. bottomRight: Radius.circular(8),
  385. ),
  386. ),
  387. child: Column(
  388. crossAxisAlignment: CrossAxisAlignment.start,
  389. children: [
  390. Row(
  391. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  392. children: [
  393. Row(
  394. children: [
  395. CircleAvatar(
  396. radius: 12,
  397. backgroundColor: AppColors.primaryLight,
  398. child: Text(
  399. comment.commenterName.substring(0, 1),
  400. style: const TextStyle(
  401. fontSize: 12,
  402. fontWeight: FontWeight.w600,
  403. color: AppColors.primary),
  404. ),
  405. ),
  406. const SizedBox(width: 6),
  407. Text(comment.commenterName,
  408. style: const TextStyle(
  409. fontSize: 13,
  410. fontWeight: FontWeight.w600,
  411. color: AppColors.textPrimary)),
  412. const SizedBox(width: 4),
  413. if (comment.commenterPosition.isNotEmpty)
  414. Text('· ${comment.commenterPosition}',
  415. style: const TextStyle(
  416. fontSize: 11, color: AppColors.textSecondary)),
  417. ],
  418. ),
  419. Row(
  420. mainAxisSize: MainAxisSize.min,
  421. children: List.generate(5, (i) {
  422. return Icon(
  423. i < comment.ratingStars
  424. ? Icons.star
  425. : Icons.star_border,
  426. size: 14,
  427. color: AppColors.warning,
  428. );
  429. }),
  430. ),
  431. ],
  432. ),
  433. const SizedBox(height: 8),
  434. Text(comment.commentText,
  435. style: const TextStyle(
  436. fontSize: 14,
  437. color: AppColors.textSecondary,
  438. height: 1.5)),
  439. const SizedBox(height: 6),
  440. Text(du.DateUtils.formatDateTime(comment.createTime),
  441. style: const TextStyle(
  442. fontSize: 11, color: AppColors.textPlaceholder)),
  443. ],
  444. ),
  445. );
  446. }
  447. Widget _buildManagerCommentInput() {
  448. final l10n = AppLocalizations.of(context);
  449. return Padding(
  450. padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
  451. child: Container(
  452. width: double.infinity,
  453. padding: const EdgeInsets.all(16),
  454. decoration: BoxDecoration(
  455. color: AppColors.bgCard,
  456. borderRadius: BorderRadius.circular(8),
  457. ),
  458. child: Column(
  459. crossAxisAlignment: CrossAxisAlignment.start,
  460. children: [
  461. Text(l10n.get('managerComment'),
  462. style: const TextStyle(
  463. fontSize: 14,
  464. fontWeight: FontWeight.w600,
  465. color: AppColors.textPrimary)),
  466. const SizedBox(height: 8),
  467. Row(
  468. children: [
  469. Text('${l10n.get('statAvgRating')}:',
  470. style: TextStyle(
  471. fontSize: 13, color: AppColors.textSecondary)),
  472. ...List.generate(5, (i) {
  473. final starIndex = i + 1;
  474. return GestureDetector(
  475. onTap: () => setState(() {
  476. _rating = _rating == starIndex ? 0 : starIndex;
  477. }),
  478. child: Padding(
  479. padding: const EdgeInsets.only(right: 4),
  480. child: Icon(
  481. starIndex <= _rating
  482. ? Icons.star
  483. : Icons.star_border,
  484. size: 28,
  485. color: AppColors.warning,
  486. ),
  487. ),
  488. );
  489. }),
  490. ],
  491. ),
  492. const SizedBox(height: 8),
  493. Container(
  494. padding:
  495. const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
  496. decoration: BoxDecoration(
  497. color: AppColors.bgPage,
  498. borderRadius: BorderRadius.circular(8),
  499. ),
  500. child: Row(
  501. children: [
  502. Expanded(
  503. child: TextField(
  504. controller: _commentCtrl,
  505. decoration: InputDecoration(
  506. hintText: l10n.get('inputComment'),
  507. hintStyle: TextStyle(
  508. fontSize: 14, color: AppColors.textPlaceholder),
  509. border: InputBorder.none,
  510. contentPadding: EdgeInsets.zero,
  511. isDense: true,
  512. ),
  513. style: const TextStyle(
  514. fontSize: 14, color: AppColors.textPrimary),
  515. ),
  516. ),
  517. const SizedBox(width: 8),
  518. GestureDetector(
  519. onTap: _sendComment,
  520. child: Container(
  521. padding: const EdgeInsets.symmetric(
  522. horizontal: 16, vertical: 8),
  523. decoration: BoxDecoration(
  524. color: AppColors.primary,
  525. borderRadius: BorderRadius.circular(18),
  526. ),
  527. child: Text(l10n.get('send'),
  528. style: TextStyle(
  529. fontSize: 14,
  530. fontWeight: FontWeight.w600,
  531. color: Colors.white)),
  532. ),
  533. ),
  534. ],
  535. ),
  536. ),
  537. ],
  538. ),
  539. ),
  540. );
  541. }
  542. }