import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:go_router/go_router.dart'; import 'package:new_learn/core/api/class/api.dart'; import 'package:new_learn/core/api/class/model.dart'; import 'package:new_learn/core/components/confirm_box.dart'; import 'package:new_learn/core/components/user_circle_avatar.dart'; import 'package:new_learn/core/providers/user_provider.dart'; import 'package:new_learn/core/utils/img_url.dart'; import 'package:new_learn/features/class/widgets/class_update_widgets.dart'; /// ============================================================ /// 【MyUpdateDetailPage】「动态详情」页 —— 对标 Vue pages/classPackage/myInteract/myUpdateDetail/index.vue /// /// 一、数据:从列表卡片带入初始 UserPostDto,进入后若无 id 停止; /// 有 id 则 getUserPostDetail 增量更新评论/点赞头像/点赞状态/计数。 /// 二、展示:复用 PostCard(showCommentComposer=false,隐藏内嵌输入/预览), /// 下方接 CommentDetail:点赞人头像 + 「全部留言」列表。 /// 三、交互: /// - 点赞 → addUserLike(乐观更新,失败回滚) /// - 长按留言 → 仅本人或发帖人可删 → confirm → deleteUserCommon(乐观移除) /// ============================================================ class MyUpdateDetailPage extends ConsumerStatefulWidget { const MyUpdateDetailPage({super.key, required this.initialPost}); final UserPostDto initialPost; @override ConsumerState createState() => _MyUpdateDetailPageState(); } class _MyUpdateDetailPageState extends ConsumerState { late UserPostDto _post; @override void initState() { super.initState(); _post = widget.initialPost; if (_post.id != 0) _fetchDetail(); } /// 用详情接口增量更新评论相关字段(对标 Vue fetchDetail) /// /// 简化逻辑:接口成功返回就直接用详情数据覆盖评论相关字段, /// 和 Vue 版一致(Vue 里 res.commons !== undefined 就直接覆盖)。 Future _fetchDetail() async { try { final res = await ClassApi.instance.getUserPostDetail(_post.id); if (!mounted) return; setState(() { _post = _post.copyWith( commons: res.commons, userLikeAvatars: res.userLikeAvatars, userLikeStatus: res.userLikeStatus, replyCount: res.replyCount, likeCount: res.likeCount, ); }); } catch (_) { // 详情拉取失败:用列表带入的数据兜底展示 } } Future _toggleLike() async { final liked = !_post.userLikeStatus; final newCount = _post.likeCount + (liked ? 1 : -1); final old = _post; setState(() { _post = _post.copyWith( userLikeStatus: liked, likeCount: newCount < 0 ? 0 : newCount, ); }); try { await ClassApi.instance.addUserLike( userPostId: _post.id, likeStatus: liked, ); } catch (_) { if (!mounted) return; setState(() => _post = old); } } Future _deleteComment(UserPostCommonDto c) async { final old = _post; // 本地新增无 id 的评论直接删,不发接口 if (c.id == 0) { setState(() { _post = _post.copyWith( commons: _post.commons.where((x) => x != c).toList(), replyCount: _post.replyCount > 0 ? _post.replyCount - 1 : 0, ); }); return; } setState(() { _post = _post.copyWith( commons: _post.commons.where((x) => x.id != c.id).toList(), replyCount: _post.replyCount > 0 ? _post.replyCount - 1 : 0, ); }); try { await ClassApi.instance.deleteUserCommon(c.id); } catch (_) { if (!mounted) return; setState(() => _post = old); } } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFF5F8FA), appBar: AppBar( backgroundColor: const Color(0xFFF5F8FA), elevation: 0, title: const Text( '动态详情', style: TextStyle( fontSize: 18, fontWeight: FontWeight.w400, color: Color(0xFF1D2B47), ), ), centerTitle: true, leading: IconButton( icon: const Icon(Icons.arrow_back, color: Color(0xFF1D2B47)), onPressed: () => context.canPop() ? context.pop() : context.go('/class'), ), ), body: ListView( padding: EdgeInsets.fromLTRB(14.w, 12.h, 14.w, 24.h), children: [ Container( clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(15.r), ), child: Column( children: [ PostCard( post: _post, showCommentComposer: false, onLike: _toggleLike, onComment: (_) {}, hasCardBackground: false, showLikeCommentBar: false, ), _DetailComments(post: _post, onDelete: _deleteComment), ], ), ), ], ), ); } } /// 详情版留言区:点赞人头像 + 全部留言(对标 LikeAndCommentDetail) class _DetailComments extends ConsumerStatefulWidget { const _DetailComments({required this.post, required this.onDelete}); final UserPostDto post; final Future Function(UserPostCommonDto) onDelete; @override ConsumerState<_DetailComments> createState() => _DetailCommentsState(); } class _DetailCommentsState extends ConsumerState<_DetailComments> { /// 当前长按中(背景高亮 + 显示「删除」浮层)的评论索引 int? _active; /// 二次确认弹窗期间保持背景高亮(对标 Vue pendingDeleteIndex) int? _pending; /// 每条评论的定位锚点(懒加载,始终创建) final Map _links = {}; LayerLink _linkFor(int index) => _links.putIfAbsent(index, () => LayerLink()); UserPostDto get _post => widget.post; /// 点浮层「删除」→ 关浮层 + 弹确认框 Future _handleDelete(int index, UserPostCommonDto c) async { setState(() { _active = null; _pending = index; }); final ok = await showConfirmBox( context, title: '是否删除评论', content: '删除评论后将不可恢复,请慎重选择!', ); if (!mounted) return; setState(() => _pending = null); if (ok != true) return; await widget.onDelete(c); } @override Widget build(BuildContext context) { final me = ref.watch(currentUserProvider).value; final myId = me?.userId ?? 0; // 构建评论项时顺手收集所有 LayerLink(确保全部创建好) final items = []; if (_post.userLikeAvatars.isNotEmpty) { items.add( Padding( padding: EdgeInsets.symmetric(horizontal: 16.w), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: EdgeInsets.only(top: 5.h), child: Image.asset( 'assets/img/class/like-icon.png', width: 16.w, height: 16.w, errorBuilder: (_, _, _) => const Icon( Icons.favorite, size: 16, color: Color(0xFF3D7DF5), ), ), ), SizedBox(width: 10.w), Expanded( child: Wrap( spacing: 9.w, runSpacing: 9.h, children: [ for (final u in _post.userLikeAvatars) UserCircleAvatar(url: _avatar(u), radius: 13.w), ], ), ), ], ), ), ); items.add(SizedBox(height: 16.h)); } items.add( Padding( padding: EdgeInsets.symmetric(horizontal: 16.w), child: const Text( '全部留言', style: TextStyle(fontSize: 15, color: Color(0xFF1D2B47)), ), ), ); items.add(SizedBox(height: 8.h)); if (_post.commons.isEmpty) { items.add( Padding( padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 12.h), child: const Text( '暂无留言', style: TextStyle(fontSize: 13, color: Color(0xFFB4C2E0)), ), ), ); } else { for (int i = 0; i < _post.commons.length; i++) { items.add( Padding( padding: EdgeInsets.symmetric(horizontal: 16.w), child: _commentItem(_post.commons[i], i, myId), ), ); } } items.add(SizedBox(height: 16.h)); return Container( width: double.infinity, padding: EdgeInsets.only(top: 18.h), clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(15.r), ), // Stack 包裹 Column + 悬浮 Follower // Follower 放在最后绘制 → 浮层在最顶层,既能显示也能接收到点击 child: Stack( clipBehavior: Clip.none, // 允许浮层溢出 Stack 边界(向上悬浮在评论上方) children: [ // 主内容 GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { if (_active != null) setState(() => _active = null); }, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: items, ), ), // Follower 悬浮层:active 评论的「删除」浮层(最后绘制 → 顶层) _buildPopoverFollower(), ], ), ); } /// 悬浮层:CompositedTransformFollower 定位到 active 评论文本上方 /// /// 关键:Follower 直接放在 Stack 里(不是 OverlayPortal), /// 和 Target 在同一 widget 树 → link 一定能连上,坐标计算无跨 Overlay 问题。 Widget _buildPopoverFollower() { final index = _active; if (index == null || index >= _post.commons.length) { return const SizedBox.shrink(); } return CompositedTransformFollower( link: _linkFor(index), targetAnchor: Alignment.topCenter, followerAnchor: Alignment.bottomCenter, showWhenUnlinked: false, child: _deletePopover(index, _post.commons[index]), ); } Widget _commentItem(UserPostCommonDto c, int index, int myId) { final active = _active == index; final pending = _pending == index; final canDelete = (myId == c.userId) || (myId == _post.userId); return GestureDetector( behavior: HitTestBehavior.opaque, onLongPress: canDelete ? () { // 确保 link 已创建 _linkFor(index); setState(() => _active = index); } : null, // active 时吸收点击 → 不冒泡到外层关闭浮层 onTap: active ? () {} : null, child: Container( width: double.infinity, padding: EdgeInsets.symmetric(vertical: 10.h), color: (active || pending) ? const Color(0xFFF5F8FA) : Colors.transparent, // 包整个 Row 作为锚点 → 浮层对准整条评论 item 的水平中心 child: CompositedTransformTarget( link: _linkFor(index), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ UserCircleAvatar(url: _avatar(c.avatar), radius: 20.w), SizedBox(width: 15.w), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( c.userName, style: const TextStyle( fontSize: 15, fontWeight: FontWeight.bold, color: Color(0xFF1D2B47), ), ), Text( _time(c.createTime), style: const TextStyle( fontSize: 12, color: Color(0xFF687FAD), ), ), ], ), SizedBox(height: 6.h), _commentText(c.content), ], ), ), ], ), ), ), ); } Widget _commentText(String content) => Text( content, style: const TextStyle(fontSize: 14, color: Color(0xFF495A7A), height: 1.5), ); /// 「删除」黑色浮层:黑框在上 + 三角在下,三角紧贴黑框底部指向下方文本 Widget _deletePopover(int index, UserPostCommonDto c) { const bg = Color(0xBF000000); // rgba(0,0,0,0.75) return GestureDetector( onTap: () => _handleDelete(index, c), child: Column( mainAxisSize: MainAxisSize.min, children: [ // 黑框在上 Container( width: 60.w, padding: EdgeInsets.symmetric(vertical: 8.h), decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular(8.r), ), child: const Text( '删除', textAlign: TextAlign.center, style: TextStyle(fontSize: 14, color: Colors.white), ), ), // 三角:往上拉 4px 紧贴黑框(Icons.arrow_drop_down 有内置留白) Transform.translate( offset: const Offset(0, -9.5), child: Icon(Icons.arrow_drop_down, color: bg, size: 22), ), ], ), ); } String _time(String t) { if (t.isEmpty) return ''; final s = t.replaceFirst('T', ' ').replaceFirst('Z', ''); return s.length > 16 ? s.substring(0, 16) : s; } static String _avatar(String? raw) { final url = ImgUrl.build(raw); return url.isEmpty ? ImgUrl.build('/StaticResource/2026-04-03/fake-avatar.png') : url; } }