outing_log_detail_page.dart 20 KB

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