outing_log_detail_page.dart 20 KB

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