outing_log_detail_page.dart 20 KB

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