outing_log_detail_page.dart 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630
  1. import 'package:flutter/material.dart';
  2. import 'package:go_router/go_router.dart';
  3. import 'package:flutter_riverpod/flutter_riverpod.dart';
  4. import '../../shared/widgets/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(context).showSnackBar(
  143. SnackBar(content: Text(l10n.get('mockOpenNavigation'))),
  144. );
  145. },
  146. child: Container(
  147. decoration: BoxDecoration(
  148. color: colors.infoBg,
  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(
  223. l10n.get('checkInTime'),
  224. du.DateUtils.formatDateTime(_log.createTime),
  225. ),
  226. ],
  227. ),
  228. ),
  229. );
  230. }
  231. Widget _buildInfoRow(String label, String value) {
  232. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  233. return Padding(
  234. padding: const EdgeInsets.symmetric(vertical: 4),
  235. child: Row(
  236. crossAxisAlignment: CrossAxisAlignment.start,
  237. children: [
  238. SizedBox(
  239. width: 70,
  240. child: Text(
  241. label,
  242. style: TextStyle(fontSize: 13, color: colors.textSecondary),
  243. ),
  244. ),
  245. Expanded(
  246. child: Text(
  247. value,
  248. style: TextStyle(fontSize: 13, color: colors.textPrimary),
  249. ),
  250. ),
  251. ],
  252. ),
  253. );
  254. }
  255. Widget _buildSectionCard(String title, String content) {
  256. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  257. return Padding(
  258. padding: const EdgeInsets.symmetric(horizontal: 16),
  259. child: Container(
  260. width: double.infinity,
  261. padding: const EdgeInsets.all(16),
  262. decoration: BoxDecoration(
  263. color: colors.bgCard,
  264. borderRadius: BorderRadius.circular(8),
  265. ),
  266. child: Column(
  267. crossAxisAlignment: CrossAxisAlignment.start,
  268. children: [
  269. Text(
  270. title,
  271. style: TextStyle(
  272. fontSize: 14,
  273. fontWeight: FontWeight.w600,
  274. color: colors.textPrimary,
  275. ),
  276. ),
  277. const SizedBox(height: 8),
  278. Text(
  279. content,
  280. style: TextStyle(
  281. fontSize: 14,
  282. color: colors.textSecondary,
  283. height: 1.5,
  284. ),
  285. ),
  286. ],
  287. ),
  288. ),
  289. );
  290. }
  291. Widget _buildPhotoSection() {
  292. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  293. final l10n = AppLocalizations.of(context);
  294. final photos = _log.visitPhotos;
  295. return Padding(
  296. padding: const EdgeInsets.symmetric(horizontal: 16),
  297. child: Container(
  298. width: double.infinity,
  299. padding: const EdgeInsets.all(16),
  300. decoration: BoxDecoration(
  301. color: colors.bgCard,
  302. borderRadius: BorderRadius.circular(8),
  303. ),
  304. child: Column(
  305. crossAxisAlignment: CrossAxisAlignment.start,
  306. children: [
  307. Text(
  308. l10n.get('sitePhotos'),
  309. style: TextStyle(
  310. fontSize: 14,
  311. fontWeight: FontWeight.w600,
  312. color: colors.textPrimary,
  313. ),
  314. ),
  315. const SizedBox(height: 12),
  316. if (photos.isEmpty)
  317. Text(
  318. l10n.get('noPhotos'),
  319. style: TextStyle(fontSize: 13, color: colors.textPlaceholder),
  320. )
  321. else
  322. Wrap(
  323. spacing: 8,
  324. runSpacing: 8,
  325. children: photos.map((photo) {
  326. return GestureDetector(
  327. onTap: () {
  328. ScaffoldMessenger.of(context).showSnackBar(
  329. const SnackBar(content: Text('模拟:全屏预览照片')),
  330. );
  331. },
  332. child: Stack(
  333. children: [
  334. Container(
  335. width: 100,
  336. height: 100,
  337. decoration: BoxDecoration(
  338. color: colors.primaryLight,
  339. borderRadius: BorderRadius.circular(4),
  340. ),
  341. child: Center(
  342. child: Icon(
  343. Icons.image_outlined,
  344. size: 36,
  345. color: colors.primary,
  346. ),
  347. ),
  348. ),
  349. Positioned(
  350. bottom: 0,
  351. left: 0,
  352. right: 0,
  353. child: Container(
  354. padding: const EdgeInsets.symmetric(
  355. horizontal: 3,
  356. vertical: 2,
  357. ),
  358. color: Colors.black54,
  359. child: Text(
  360. '${du.DateUtils.formatDate(_log.createTime)} ${_log.checkInLatitude?.toStringAsFixed(2) ?? "0.00"},${_log.checkInLongitude?.toStringAsFixed(2) ?? "0.00"}',
  361. style: const TextStyle(
  362. fontSize: 8,
  363. color: Colors.white,
  364. ),
  365. maxLines: 1,
  366. overflow: TextOverflow.ellipsis,
  367. ),
  368. ),
  369. ),
  370. ],
  371. ),
  372. );
  373. }).toList(),
  374. ),
  375. ],
  376. ),
  377. ),
  378. );
  379. }
  380. Widget _buildCommentSection() {
  381. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  382. final l10n = AppLocalizations.of(context);
  383. return Padding(
  384. padding: const EdgeInsets.symmetric(horizontal: 16),
  385. child: Container(
  386. width: double.infinity,
  387. padding: const EdgeInsets.all(16),
  388. decoration: BoxDecoration(
  389. color: colors.bgCard,
  390. borderRadius: BorderRadius.circular(8),
  391. ),
  392. child: Column(
  393. crossAxisAlignment: CrossAxisAlignment.start,
  394. children: [
  395. Text(
  396. l10n.get('comments'),
  397. style: TextStyle(
  398. fontSize: 14,
  399. fontWeight: FontWeight.w600,
  400. color: colors.textPrimary,
  401. ),
  402. ),
  403. const SizedBox(height: 12),
  404. if (_comments.isEmpty)
  405. Center(
  406. child: Padding(
  407. padding: const EdgeInsets.symmetric(vertical: 16),
  408. child: Text(
  409. l10n.get('noComments'),
  410. style: TextStyle(
  411. fontSize: 13,
  412. color: colors.textPlaceholder,
  413. ),
  414. ),
  415. ),
  416. )
  417. else
  418. ..._comments.map((comment) => _buildCommentBubble(comment)),
  419. ],
  420. ),
  421. ),
  422. );
  423. }
  424. Widget _buildCommentBubble(OutingLogComment comment) {
  425. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  426. return Container(
  427. width: double.infinity,
  428. margin: const EdgeInsets.only(bottom: 12),
  429. padding: const EdgeInsets.all(12),
  430. decoration: BoxDecoration(
  431. color: colors.primaryLight,
  432. borderRadius: const BorderRadius.only(
  433. topLeft: Radius.circular(8),
  434. topRight: Radius.circular(8),
  435. bottomRight: Radius.circular(8),
  436. ),
  437. ),
  438. child: Column(
  439. crossAxisAlignment: CrossAxisAlignment.start,
  440. children: [
  441. Row(
  442. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  443. children: [
  444. Row(
  445. children: [
  446. CircleAvatar(
  447. radius: 12,
  448. backgroundColor: colors.primaryLight,
  449. child: Text(
  450. comment.commenterName.substring(0, 1),
  451. style: TextStyle(
  452. fontSize: 12,
  453. fontWeight: FontWeight.w600,
  454. color: colors.primary,
  455. ),
  456. ),
  457. ),
  458. const SizedBox(width: 6),
  459. Text(
  460. comment.commenterName,
  461. style: TextStyle(
  462. fontSize: 13,
  463. fontWeight: FontWeight.w600,
  464. color: colors.textPrimary,
  465. ),
  466. ),
  467. const SizedBox(width: 4),
  468. if (comment.commenterPosition.isNotEmpty)
  469. Text(
  470. '· ${comment.commenterPosition}',
  471. style: TextStyle(
  472. fontSize: 11,
  473. color: colors.textSecondary,
  474. ),
  475. ),
  476. ],
  477. ),
  478. Row(
  479. mainAxisSize: MainAxisSize.min,
  480. children: List.generate(5, (i) {
  481. return Icon(
  482. i < comment.ratingStars ? Icons.star : Icons.star_border,
  483. size: 14,
  484. color: colors.warning,
  485. );
  486. }),
  487. ),
  488. ],
  489. ),
  490. const SizedBox(height: 8),
  491. Text(
  492. comment.commentText,
  493. style: TextStyle(
  494. fontSize: 14,
  495. color: colors.textSecondary,
  496. height: 1.5,
  497. ),
  498. ),
  499. const SizedBox(height: 6),
  500. Text(
  501. du.DateUtils.formatDateTime(comment.createTime),
  502. style: TextStyle(fontSize: 11, color: colors.textPlaceholder),
  503. ),
  504. ],
  505. ),
  506. );
  507. }
  508. Widget _buildManagerCommentInput() {
  509. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  510. final l10n = AppLocalizations.of(context);
  511. return Padding(
  512. padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
  513. child: Container(
  514. width: double.infinity,
  515. padding: const EdgeInsets.all(16),
  516. decoration: BoxDecoration(
  517. color: colors.bgCard,
  518. borderRadius: BorderRadius.circular(8),
  519. ),
  520. child: Column(
  521. crossAxisAlignment: CrossAxisAlignment.start,
  522. children: [
  523. Text(
  524. l10n.get('managerComment'),
  525. style: TextStyle(
  526. fontSize: 14,
  527. fontWeight: FontWeight.w600,
  528. color: colors.textPrimary,
  529. ),
  530. ),
  531. const SizedBox(height: 8),
  532. Row(
  533. children: [
  534. Text(
  535. '${l10n.get('statAvgRating')}:',
  536. style: TextStyle(fontSize: 13, color: colors.textSecondary),
  537. ),
  538. ...List.generate(5, (i) {
  539. final starIndex = i + 1;
  540. return GestureDetector(
  541. onTap: () => setState(() {
  542. _rating = _rating == starIndex ? 0 : starIndex;
  543. }),
  544. child: Padding(
  545. padding: const EdgeInsets.only(right: 4),
  546. child: Icon(
  547. starIndex <= _rating ? Icons.star : Icons.star_border,
  548. size: 28,
  549. color: colors.warning,
  550. ),
  551. ),
  552. );
  553. }),
  554. ],
  555. ),
  556. const SizedBox(height: 8),
  557. Container(
  558. padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
  559. decoration: BoxDecoration(
  560. color: colors.bgPage,
  561. borderRadius: BorderRadius.circular(8),
  562. ),
  563. child: Row(
  564. children: [
  565. Expanded(
  566. child: TextField(
  567. controller: _commentCtrl,
  568. decoration: InputDecoration(
  569. hintText: l10n.get('inputComment'),
  570. hintStyle: TextStyle(
  571. fontSize: 14,
  572. color: colors.textPlaceholder,
  573. ),
  574. border: InputBorder.none,
  575. contentPadding: EdgeInsets.zero,
  576. isDense: true,
  577. ),
  578. style: TextStyle(fontSize: 14, color: colors.textPrimary),
  579. ),
  580. ),
  581. const SizedBox(width: 8),
  582. GestureDetector(
  583. onTap: _sendComment,
  584. child: Container(
  585. padding: const EdgeInsets.symmetric(
  586. horizontal: 16,
  587. vertical: 8,
  588. ),
  589. decoration: BoxDecoration(
  590. color: colors.primary,
  591. borderRadius: BorderRadius.circular(18),
  592. ),
  593. child: Text(
  594. l10n.get('send'),
  595. style: TextStyle(
  596. fontSize: 14,
  597. fontWeight: FontWeight.w600,
  598. color: Colors.white,
  599. ),
  600. ),
  601. ),
  602. ),
  603. ],
  604. ),
  605. ),
  606. ],
  607. ),
  608. ),
  609. );
  610. }
  611. }