outing_log_detail_page.dart 20 KB

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