From f873acf9a3573adb221e80e874dc978ec1443817 Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Wed, 9 Sep 2026 13:39:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E5=B8=96=E5=AD=90?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E9=A1=B5=E7=9C=9F=E5=AE=9E=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=95=B4=E9=A1=B5=E6=9B=BF=E6=8D=A2=E2=80=94=E2=80=94=E5=9B=9B?= =?UTF-8?q?=E6=80=81/=E7=9C=9F=E4=B9=9D=E5=AE=AB=E6=A0=BC=E5=A4=A7?= =?UTF-8?q?=E5=9B=BE/=E8=AF=84=E8=AE=BA=E5=8C=BA/=E5=85=B3=E6=B3=A8?= =?UTF-8?q?=E5=8F=8C=E6=80=81=EF=BC=8CFeed=20=E5=AF=BC=E8=88=AA=E4=B8=8E?= =?UTF-8?q?=E4=BA=92=E5=8A=A8=E5=85=A8=E6=8E=A5=E7=BA=BF=EF=BC=8Cdemo=20?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E9=80=80=E5=BD=B9=EF=BC=88T3-15/16=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - post_detail_page 整页重写:内存副本先渲染 + getPost 拉新、四态 (loading/error+重试/ready/40403 提示后返回 Feed 并刷新);媒体全量 ——单图原比例(高度钳制 [0.75w,1.33w])、多图真九宫格(D11 轮播 弃用,本单裁定)+ InteractiveViewer 全屏大图(03 号拍板 E) - 评论区:游标列表触底补页 + 底部输入条创建(幂等键在数据层)+ 仅本人评论渲染删除入口(40301/40404 服务端兜底);成败埋点对 (durationMs 取输入会话起点、attemptSeq 会话内递增);CommentTile 升共享组件(@ 回复前缀呈现、降级作者「宠友」占位) - 关注双态钮(tonal +关注 / Outlined 已关注 + 取关确认)乐观翻转、 回滚零动画 + SnackBar,user_followed/unfollowed(post_detail) 挂接; 本人帖不渲染 - Feed 接通:整卡与评论钮导航详情(T3-14 占位提示移除)、点赞/收藏 接共享 ToggleSync(详情与卡片同一实例跨页一致)、toggleError 一次性 SnackBar 消费 - 主壳/装配:openPost 按 postId push;create demo 流不再导航已退役的 demo 详情(发布页真实化留 T3-17);AppState 在 post_detail/Feed 面 消费清零 - compose 冒烟(env 门控默认跳过):真链路发帖/点赞幂等/收藏/评论/ 删评一轮 + 断网点赞回滚(生产 ApiClient 异常链) Co-Authored-By: Claude Fable 5 --- lib/app/app.dart | 9 +- lib/core/widgets/comment_tile.dart | 141 ++ lib/features/home/home_page.dart | 33 +- lib/features/main/main_shell_page.dart | 32 +- lib/features/post/post_detail_page.dart | 1131 ++++++++++++++--- test/core/widgets/comment_tile_test.dart | 75 ++ test/features/home/home_page_test.dart | 80 +- test/features/post/post_detail_page_test.dart | 551 ++++++++ test/helpers/community_test_helpers.dart | 92 +- .../smoke/detail_interactions_smoke_test.dart | 265 ++++ 10 files changed, 2160 insertions(+), 249 deletions(-) create mode 100644 lib/core/widgets/comment_tile.dart create mode 100644 test/core/widgets/comment_tile_test.dart create mode 100644 test/features/post/post_detail_page_test.dart create mode 100644 test/smoke/detail_interactions_smoke_test.dart diff --git a/lib/app/app.dart b/lib/app/app.dart index 3e2a6ce..22b9575 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -13,6 +13,7 @@ import 'package:patbond_flutter/features/auth/login_page.dart'; import 'package:patbond_flutter/features/auth/session_manager.dart'; import 'package:patbond_flutter/features/auth/splash_page.dart'; import 'package:patbond_flutter/features/community/community_controller.dart'; +import 'package:patbond_flutter/features/community/community_interaction_analytics.dart'; import 'package:patbond_flutter/features/community/community_repository.dart'; import 'package:patbond_flutter/features/community/feed_analytics.dart'; import 'package:patbond_flutter/features/main/main_shell_page.dart'; @@ -50,6 +51,7 @@ class _AppState extends State { late final PetAnalytics petAnalytics; late final HealthRecordAnalytics healthRecordAnalytics; late final FeedAnalytics feedAnalytics; + late final CommunityInteractionAnalytics interactionAnalytics; late final SessionTracker _sessionTracker; late final AnalyticsService _analytics; late final PageViewTracker _pageViewTracker; @@ -95,9 +97,12 @@ class _AppState extends State { petsController = PetsController( repository: widget.petsRepository ?? _buildPetsRepository(), ); - // T3-14:Feed segment 接线主壳(数据层 T3-12 就位)。 + // T3-14:Feed segment 接线主壳(数据层 T3-12 就位);T3-16 起 + // 点赞/收藏成功埋点经 interactionAnalytics 在 controller 内上报。 + interactionAnalytics = CommunityInteractionAnalytics(_analytics.trackEvent); communityController = CommunityController( repository: widget.communityRepository ?? _buildCommunityRepository(), + interactionAnalytics: interactionAnalytics, ); petAnalytics = PetAnalytics(_analytics.trackEvent); healthRecordAnalytics = HealthRecordAnalytics(_analytics.trackEvent); @@ -235,9 +240,11 @@ class _AppState extends State { appState: appState, petsController: petsController, communityController: communityController, + currentUserId: sessionManager.userId, petAnalytics: petAnalytics, healthRecordAnalytics: healthRecordAnalytics, feedAnalytics: feedAnalytics, + interactionAnalytics: interactionAnalytics, pageViewTracker: _pageViewTracker, onLogout: authRepository.logout, ); diff --git a/lib/core/widgets/comment_tile.dart b/lib/core/widgets/comment_tile.dart new file mode 100644 index 0000000..39f3878 --- /dev/null +++ b/lib/core/widgets/comment_tile.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/pet_avatar.dart'; +import 'package:patbond_flutter/features/community/community_display.dart'; +import 'package:patbond_flutter/features/community/community_models.dart'; + +/// 评论条目(05 号规范 §3.4,demo 气泡形态升共享): +/// `PetAvatar sm32` + 10 + 气泡(`surface` 底、`border` 1px、圆角 16、 +/// padding 12)——作者名 13/w700 → 4 → 内容 bodyMedium → 6 → 底行 +/// (时间 11 `inkSoft` + 仅本人评论的「删除」入口)。 +/// +/// - @ 回复(单层平铺,契约 replyToUser)以「回复 @昵称:」前缀呈现; +/// 降级作者统一「宠友」占位(isDegraded 一个判定口)。 +/// - [onDelete] 非 null 才渲染删除入口——**权限判定在调用方**(17 号 +/// 后端语义:仅评论作者可删,他人可见评论 403/40301);[deleting] +/// 期间入口替换为 14 转圈防重复提交。 +/// - 评论点赞(§3.4 底行右端)无契约端点,M3 不渲染。 +class CommentTile extends StatelessWidget { + const CommentTile({ + required this.comment, + super.key, + this.onDelete, + this.deleting = false, + this.now, + }); + + final PostComment comment; + + /// 删除回调;null = 非本人评论,不渲染删除入口。 + final VoidCallback? onDelete; + + /// 删除请求在途(入口转圈锁定)。 + final bool deleting; + + /// 相对时间的参考时钟(测试注入;缺省取当前时间)。 + final DateTime? now; + + @override + Widget build(BuildContext context) { + final replyTo = comment.replyToUser; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PetAvatar(size: PetAvatarSize.sm, url: comment.author.avatarUrl), + const SizedBox(width: 10), + Expanded( + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surface, + border: Border.all(color: AppColors.border), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + authorDisplayName(comment.author), + style: const TextStyle( + color: AppColors.ink, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text.rich( + TextSpan( + children: [ + if (replyTo != null) + TextSpan( + text: '回复 @${authorDisplayName(replyTo)}:', + style: const TextStyle( + color: AppColors.inkSoft, + fontWeight: FontWeight.w600, + ), + ), + TextSpan(text: comment.content), + ], + ), + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 6), + Row( + children: [ + Text( + feedRelativeTime(comment.createdAt, now: now), + style: const TextStyle( + color: AppColors.inkSoft, + fontSize: 11, + ), + ), + const Spacer(), + if (onDelete != null) + deleting + ? const Padding( + padding: EdgeInsets.all(4), + child: SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.inkSoft, + ), + ), + ) + : Semantics( + label: '删除评论', + button: true, + child: InkWell( + onTap: onDelete, + borderRadius: BorderRadius.circular( + AppRadius.pill, + ), + // 视觉 11 字,触控由 padding 撑到 ≥32 + //(气泡内行高受限,不足 44 以热区扩展补偿)。 + child: const Padding( + padding: EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + child: Text( + '删除', + style: TextStyle( + color: AppColors.inkSoft, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/home/home_page.dart b/lib/features/home/home_page.dart index fba66f8..86a92af 100644 --- a/lib/features/home/home_page.dart +++ b/lib/features/home/home_page.dart @@ -26,6 +26,7 @@ class HomePage extends StatefulWidget { required this.onOpenServices, required this.onOpenCreate, super.key, + this.onOpenPost, this.feedAnalytics, this.isActive = true, }); @@ -38,6 +39,9 @@ class HomePage extends StatefulWidget { final ValueChanged onOpenServices; final VoidCallback onOpenCreate; + /// 帖子详情导航(T3-15 接通;主壳 push PostDetailPage)。 + final ValueChanged? onOpenPost; + /// feed 域埋点(feed_viewed 聚合曝光 + feed_load_failed)。 final FeedAnalytics? feedAnalytics; @@ -231,14 +235,6 @@ class _HomePageState extends State with WidgetsBindingObserver { } } - /// T3-14 取舍:详情页数据层重写属 T3-15,demo 详情页无法按服务端 - /// postId 渲染真实帖,故整卡点按先提示、互动按钮为纯展示禁用态。 - void _showDetailPending() { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar(const SnackBar(content: Text('帖子详情正在接入真实数据,敬请期待'))); - } - /// 搜索词过滤(demo 交互保留:只过滤已加载的多页缓存,不发检索请求; /// 契约 v1.3.0 无搜索端点)。 List get _visibleCards { @@ -309,6 +305,17 @@ class _HomePageState extends State with WidgetsBindingObserver { return ListenableBuilder( listenable: _feed, builder: (context, _) { + // 点赞/收藏对账失败的一次性 SnackBar(§3.5 回滚提示;与详情页 + // 共用 controller 的 toggleError 消费口,先消费者清空)。 + if (_feed.toggleError != null) { + _feed.clearToggleError(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('操作失败,请重试'))); + }); + } // ready 后(含翻页追加)补一次可见性扫描:无滚动也能记首屏曝光。 if (_viewSegment != null && _feed.phase == FeedPhase.ready) { _scheduleVisibilityScan(); @@ -467,7 +474,15 @@ class _HomePageState extends State with WidgetsBindingObserver { padding: const EdgeInsets.only(bottom: 16), child: KeyedSubtree( key: _cardKeys.putIfAbsent(card.id, GlobalKey.new), - child: PostCard(card: card, onTap: _showDetailPending), + // 点赞/收藏经共享 ToggleSync(source=feed 缺省);整卡与 + // 评论钮进详情(T3-15 导航接通,T3-14 的占位提示移除)。 + child: PostCard( + card: card, + onTap: () => widget.onOpenPost?.call(card.id), + onCommentTap: () => widget.onOpenPost?.call(card.id), + onLikeTap: () => _feed.toggleLike(card.id), + onBookmarkTap: () => _feed.toggleBookmark(card.id), + ), ), ), // 搜索过滤中不渲染尾部(过滤只作用于已加载页,翻页语义混淆)。 diff --git a/lib/features/main/main_shell_page.dart b/lib/features/main/main_shell_page.dart index ac8d766..bc4f974 100644 --- a/lib/features/main/main_shell_page.dart +++ b/lib/features/main/main_shell_page.dart @@ -3,6 +3,7 @@ import 'package:patbond_flutter/analytics/analytics_page_name.dart'; import 'package:patbond_flutter/analytics/page_view_tracker.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; import 'package:patbond_flutter/features/community/community_controller.dart'; +import 'package:patbond_flutter/features/community/community_interaction_analytics.dart'; import 'package:patbond_flutter/features/community/feed_analytics.dart'; import 'package:patbond_flutter/features/create/create_page.dart'; import 'package:patbond_flutter/features/home/home_page.dart'; @@ -13,7 +14,6 @@ import 'package:patbond_flutter/features/pets/pets_page.dart'; import 'package:patbond_flutter/features/post/post_detail_page.dart'; import 'package:patbond_flutter/features/profile/profile_page.dart'; import 'package:patbond_flutter/features/services/services_page.dart'; -import 'package:patbond_flutter/models/models.dart'; import 'package:patbond_flutter/state/app_state.dart'; import 'package:patbond_flutter/widgets/common.dart'; @@ -23,9 +23,11 @@ class MainShellPage extends StatefulWidget { required this.petsController, required this.communityController, super.key, + this.currentUserId, this.petAnalytics, this.healthRecordAnalytics, this.feedAnalytics, + this.interactionAnalytics, this.pageViewTracker, this.onLogout, }); @@ -38,6 +40,9 @@ class MainShellPage extends StatefulWidget { /// 社区状态(T3-12 数据层;首页 Feed segment 数据源,T3-14 接线)。 final CommunityController communityController; + /// 当前登录用户 id(详情页评论删除入口 / 关注钮自见性的 UI 判定)。 + final String? currentUserId; + /// pet 域埋点强类型封装(建宠漏斗三事件)。 final PetAnalytics? petAnalytics; @@ -47,6 +52,9 @@ class MainShellPage extends StatefulWidget { /// feed 域埋点(T3-14 聚合曝光 + 加载失败)。 final FeedAnalytics? feedAnalytics; + /// 互动域埋点(T3-16 评论成败对 + 关注对;详情页消费)。 + final CommunityInteractionAnalytics? interactionAnalytics; + /// Tab 曝光补点(IndexedStack 切换不产生路由事件,03 号评估 §3.2)。 final PageViewTracker? pageViewTracker; @@ -93,12 +101,18 @@ class _MainShellPageState extends State { widget.pageViewTracker?.reportTab(_tabPages[3]); } - void openPost(PostModel post) { + /// 帖子详情(T3-15:真实数据整页;Feed 卡片与详情共享 + /// communityController,互动状态跨页一致)。 + void openPost(String postId) { Navigator.of(context).push( MaterialPageRoute( settings: RouteSettings(name: AnalyticsPageName.postDetail.pageName), - builder: (context) => - PostDetailPage(appState: widget.appState, postId: post.id), + builder: (context) => PostDetailPage( + controller: widget.communityController, + postId: postId, + currentUserId: widget.currentUserId, + analytics: widget.interactionAnalytics, + ), ), ); } @@ -122,15 +136,13 @@ class _MainShellPageState extends State { isActive: currentIndex == 0, onOpenServices: openServices, onOpenCreate: () => selectTab(1), + onOpenPost: openPost, ), CreatePage( appState: widget.appState, - onPublished: (post) { - selectTab(0); - WidgetsBinding.instance.addPostFrameCallback( - (_) => openPost(post), - ); - }, + // demo 详情页已退役(T3-15):demo 发布流不再导航详情, + // 回首页 Feed;发布页真实化随 T3-17 收编。 + onPublished: (_) => selectTab(0), ), PetsPage( controller: widget.petsController, diff --git a/lib/features/post/post_detail_page.dart b/lib/features/post/post_detail_page.dart index c7180df..c7b3cae 100644 --- a/lib/features/post/post_detail_page.dart +++ b/lib/features/post/post_detail_page.dart @@ -1,71 +1,436 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; -import 'package:patbond_flutter/data/demo_data.dart'; -import 'package:patbond_flutter/models/models.dart'; -import 'package:patbond_flutter/state/app_state.dart'; +import 'package:patbond_flutter/core/widgets/comment_tile.dart'; +import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; +import 'package:patbond_flutter/core/widgets/like_button.dart'; +import 'package:patbond_flutter/core/widgets/pet_avatar.dart'; +import 'package:patbond_flutter/core/widgets/post_media_grid.dart'; +import 'package:patbond_flutter/features/community/community_controller.dart'; +import 'package:patbond_flutter/features/community/community_display.dart'; +import 'package:patbond_flutter/features/community/community_exceptions.dart'; +import 'package:patbond_flutter/features/community/community_interaction_analytics.dart'; +import 'package:patbond_flutter/features/community/community_models.dart'; import 'package:patbond_flutter/widgets/common.dart'; +/// 帖子详情页(T3-15 整页替换,demo 数据层退役):媒体全量渲染 +/// (真九宫格 + 大图预览)+ 作者卡(关注双态)+ 正文 + 操作行 +/// (ToggleSync 接线,Feed 卡片与详情共享同一 [CommunityController] +/// 实例,互动状态跨页一致)+ 评论区(游标列表 / 输入框创建 / +/// 仅本人评论可删)。 +/// +/// 形态取舍: +/// - 多图媒体区按本单裁定走**真九宫格**([PostMediaGrid] 全量形态, +/// 05 号 D11 的轮播方案弃用);点格进全屏大图浏览 +/// (03 号拍板 E:内置 InteractiveViewer,体验不达再升级 photo_view)。 +/// - 40403(不存在/已删/防枚举合并)→ SnackBar 提示后返回 Feed 并 +/// 触发刷新(列表剔除失效帖)。 +/// - 评论 @ 回复的**发起** UI 留待(数据层 replyToUserId 已支持), +/// 响应中的 replyToUser 照常呈现。 class PostDetailPage extends StatefulWidget { const PostDetailPage({ - required this.appState, + required this.controller, required this.postId, super.key, + this.currentUserId, + this.analytics, + this.now, }); - final AppState appState; + /// Tab 级单例(app.dart 装配);详情副本与 Feed 卡片互动字段同源。 + final CommunityController controller; + final String postId; + /// 当前登录用户 id(评论删除入口与关注钮自见性的 UI 判定; + /// 服务端仍是权限的唯一裁决者)。 + final String? currentUserId; + + /// 互动域埋点(评论成败对 + 关注对;点赞/收藏对经 controller 上报)。 + final CommunityInteractionAnalytics? analytics; + + /// 相对时间的参考时钟(测试注入;缺省取当前时间)。 + final DateTime? now; + @override State createState() => _PostDetailPageState(); } +enum _DetailPhase { loading, error, ready } + class _PostDetailPageState extends State { - final commentController = TextEditingController(); - bool isFollowing = false; + final _commentController = TextEditingController(); + final _commentFocus = FocusNode(); + + _DetailPhase _phase = _DetailPhase.loading; + ApiException? _loadError; + + /// 评论列表(页面级状态按页自建,03 号评估 §2 纪律;DESC 新评在前)。 + _DetailPhase _commentsPhase = _DetailPhase.loading; + ApiException? _commentsError; + List _comments = const []; + String? _commentsCursor; + bool _commentsHasMore = false; + LoadMorePhase _commentsLoadMore = LoadMorePhase.idle; + + bool _sending = false; + final Set _deletingIds = {}; + + /// 输入会话起点(首个字符输入时记;comment_create_succeeded 的 + /// durationMs 口径,评论不设 started 事件)。 + DateTime? _composeStartedAt; + + /// 本次输入会话内的提交序号(comment_create_failed.attemptSeq, + /// 从 1 起;成功或清空输入后重置)。 + int _attemptSeq = 0; + + /// 关注状态(页面级;作者为本人或加载失败时不渲染关注钮)。 + FollowStats? _followStats; + bool _following = false; + + /// 40403 返回 Feed 的单次守卫(详情与评论路径都可能触发)。 + bool _leaving = false; + + CommunityController get _controller => widget.controller; + + Post? get _post => _controller.cachedPost(widget.postId); + + @override + void initState() { + super.initState(); + _commentController.addListener(_onComposeChanged); + if (_post != null) { + _phase = _DetailPhase.ready; + _ensureFollowStats(_post!); + } + unawaited(_loadPost()); + unawaited(_loadComments()); + } @override void dispose() { - commentController.dispose(); + _commentController.dispose(); + _commentFocus.dispose(); super.dispose(); } - PostModel get post => - widget.appState.posts.firstWhere((item) => item.id == widget.postId); + // ---- 详情加载 ---- - void toggleLike() { - final current = post; - widget.appState.updatePost( - current.copyWith( - hasLiked: !current.hasLiked, - likes: current.hasLiked ? current.likes - 1 : current.likes + 1, + Future _loadPost() async { + if (_post == null && _phase != _DetailPhase.loading) { + setState(() { + _phase = _DetailPhase.loading; + _loadError = null; + }); + } + try { + final post = await _controller.getPost(widget.postId); + if (!mounted) return; + setState(() => _phase = _DetailPhase.ready); + _ensureFollowStats(post); + } on PostNotFoundException { + _handleGone(); + } on ApiException catch (error) { + if (!mounted) return; + // 有内存副本则保留展示(后台刷新失败不打断阅读);无副本收敛 error 态。 + if (_post == null) { + setState(() { + _phase = _DetailPhase.error; + _loadError = error; + }); + } + } + } + + /// 40403:帖子不存在/已删/不可见——提示后返回 Feed 并刷新 + /// (整体替换剔除失效帖)。 + void _handleGone() { + if (_leaving || !mounted) return; + _leaving = true; + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('帖子不存在或已被删除'))); + unawaited(_controller.refresh()); + Navigator.of(context).pop(); + } + + // ---- 评论列表 ---- + + Future _loadComments() async { + setState(() { + _commentsPhase = _DetailPhase.loading; + _commentsError = null; + }); + try { + final page = await _controller.repository.listComments(widget.postId); + if (!mounted) return; + setState(() { + _comments = page.items; + _commentsCursor = page.nextCursor; + _commentsHasMore = page.hasMore; + _commentsPhase = _DetailPhase.ready; + _commentsLoadMore = LoadMorePhase.idle; + }); + } on PostNotFoundException { + _handleGone(); + } on ApiException catch (error) { + if (!mounted) return; + setState(() { + _commentsPhase = _DetailPhase.error; + _commentsError = error; + }); + } + } + + Future _loadMoreComments() async { + if (_commentsPhase != _DetailPhase.ready || + !_commentsHasMore || + _commentsLoadMore == LoadMorePhase.loading) { + return; + } + setState(() => _commentsLoadMore = LoadMorePhase.loading); + try { + final page = await _controller.repository.listComments( + widget.postId, + cursor: _commentsCursor, + ); + if (!mounted) return; + setState(() { + _comments = [..._comments, ...page.items]; + _commentsCursor = page.nextCursor; + _commentsHasMore = page.hasMore; + _commentsLoadMore = LoadMorePhase.idle; + }); + } on ApiException { + if (!mounted) return; + setState(() => _commentsLoadMore = LoadMorePhase.error); + } + } + + bool _onScrollNotification(ScrollNotification notification) { + if (notification is ScrollUpdateNotification || + notification is ScrollEndNotification) { + // 触底翻页(余量 400 提前预取);失败态只走显式重试(Feed 同款守卫)。 + if (notification.metrics.extentAfter < 400 && + _commentsLoadMore == LoadMorePhase.idle) { + unawaited(_loadMoreComments()); + } + } + return false; + } + + // ---- 评论创建 / 删除 ---- + + void _onComposeChanged() { + final text = _commentController.text; + if (text.isEmpty) { + // 清空 = 输入会话结束(durationMs 起点与 attemptSeq 一并重置)。 + _composeStartedAt = null; + _attemptSeq = 0; + } else { + _composeStartedAt ??= DateTime.now(); + } + setState(() {}); // 发送钮可用态跟随文本。 + } + + Future _submitComment() async { + final text = _commentController.text.trim(); + if (text.isEmpty || _sending) return; + final startedAt = _composeStartedAt ?? DateTime.now(); + _attemptSeq += 1; + setState(() => _sending = true); + try { + final comment = await _controller.repository.createComment( + widget.postId, + CreateCommentRequest(content: text), + ); + if (!mounted) return; + widget.analytics?.commentCreateSucceeded( + durationMs: DateTime.now().difference(startedAt).inMilliseconds, + isReply: false, + textLength: text.length, + ); + setState(() { + _comments = [comment, ..._comments]; + _sending = false; + }); + _controller.adjustCommentCount(widget.postId, 1); + _commentController.clear(); + _commentFocus.unfocus(); + } on ApiException catch (error) { + if (!mounted) return; + final reason = commentCreateFailureReasonOf(error); + if (reason != null) { + widget.analytics?.commentCreateFailed( + reason: reason, + attemptSeq: _attemptSeq, + errorCode: error is ApiBusinessException ? error.code : null, + ); + } + setState(() => _sending = false); + if (error is PostNotFoundException) { + _handleGone(); + return; + } + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(_commentErrorMessage(error)))); + } + } + + String _commentErrorMessage(ApiException error) => switch (error) { + ApiNetworkException _ => '网络异常,请检查网络后重试', + ApiRateLimitException _ => '请求过于频繁,请稍后再试', + ApiBusinessException(code: ApiCodes.paramError) => '评论内容不合规,请修改后重试', + _ => '评论发送失败,请稍后重试', + }; + + Future _deleteComment(PostComment comment) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('删除这条评论?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('删除'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() => _deletingIds.add(comment.id)); + try { + await _controller.repository.deleteComment(comment.id); + if (!mounted) return; + _removeCommentLocally(comment.id); + } on CommentNotFoundException { + // 已在别处删除:本地同步剔除(终态一致,下次 getPost 对齐权威计数)。 + if (!mounted) return; + _removeCommentLocally(comment.id); + } on ApiException catch (error) { + if (!mounted) return; + setState(() => _deletingIds.remove(comment.id)); + final message = switch (error) { + PostAccessDeniedException _ => '没有权限删除这条评论', + ApiNetworkException _ => '网络异常,请检查网络后重试', + _ => '删除失败,请稍后重试', + }; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + } + + void _removeCommentLocally(String commentId) { + setState(() { + _deletingIds.remove(commentId); + _comments = _comments.where((c) => c.id != commentId).toList(); + }); + _controller.adjustCommentCount(widget.postId, -1); + } + + // ---- 关注 ---- + + void _ensureFollowStats(Post post) { + if (_followStats != null) return; + final authorId = post.author.userId; + if (authorId == widget.currentUserId) return; // 本人帖不渲染关注钮。 + unawaited(() async { + try { + final stats = await _controller.repository.getFollowStats(authorId); + if (!mounted) return; + setState(() { + _followStats = stats; + _following = stats.followedByMe; + }); + } on ApiException { + // 关注状态拉取失败:不渲染关注钮(不阻塞正文阅读)。 + } + }()); + } + + Future _toggleFollow(String authorId) async { + final target = !_following; + if (!target) { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('不再关注 TA?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('不再关注'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + } + setState(() => _following = target); // 乐观翻转(§4.5)。 + try { + final state = target + ? await _controller.repository.followUser(authorId) + : await _controller.repository.unfollowUser(authorId); + if (!mounted) return; + target + ? widget.analytics?.userFollowed(source: InteractionSource.postDetail) + : widget.analytics?.userUnfollowed( + source: InteractionSource.postDetail, + ); + setState(() => _following = state.following); // 权威终态对账。 + } on ApiException { + if (!mounted) return; + // 回滚直接跳变 + SnackBar(§4.5,无过渡动画)。 + setState(() => _following = !target); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('操作失败,请重试'))); + } + } + + // ---- 大图浏览 ---- + + void _openGallery(List media, int initialIndex) { + Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (context) => _MediaGalleryPage( + urls: [for (final item in media) item.url], + initialIndex: initialIndex, + ), ), ); } - void sendComment() { - final text = commentController.text.trim(); - if (text.isEmpty) return; - final current = post; - final comment = CommentModel( - id: 'comment_${DateTime.now().millisecondsSinceEpoch}', - authorName: '萌宠新手', - authorAvatar: userAvatar, - content: text, - time: '刚刚', - ); - widget.appState.updatePost( - current.copyWith(comments: [comment, ...current.comments]), - ); - commentController.clear(); - FocusScope.of(context).unfocus(); - } + // ---- 渲染 ---- @override Widget build(BuildContext context) { - return AnimatedBuilder( - animation: widget.appState, + return ListenableBuilder( + listenable: _controller, builder: (context, _) { - final current = post; + // 点赞/收藏对账失败的一次性 SnackBar(§3.5 回滚提示)。 + if (_controller.toggleError != null) { + _controller.clearToggleError(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('操作失败,请重试'))); + }); + } + final post = _post; return Scaffold( appBar: AppBar( title: const Text( @@ -78,200 +443,544 @@ class _PostDetailPageState extends State { tooltip: '分享', onPressed: () => ScaffoldMessenger.of( context, - ).showSnackBar(const SnackBar(content: Text('分享链接已准备好(演示)'))), + ).showSnackBar(const SnackBar(content: Text('分享功能即将上线'))), icon: const Icon(Icons.ios_share_outlined), ), ], ), - body: ListView( - padding: const EdgeInsets.only(bottom: 100), - children: [ - AspectRatio( - aspectRatio: 1, - child: RemoteImage(url: current.mainImage), - ), - Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + body: post != null ? _content(post) : _placeholder(), + bottomSheet: post != null ? _commentInputBar() : null, + ); + }, + ); + } + + /// 无内存副本时的 loading / error 态。 + Widget _placeholder() { + if (_phase == _DetailPhase.error) { + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + InlineErrorBanner(message: feedLoadErrorMessage(_loadError)), + const SizedBox(height: 16), + FilledButton(onPressed: _loadPost, child: const Text('重试')), + ], + ), + ); + } + return const Center(child: CircularProgressIndicator()); + } + + Widget _content(Post post) { + return NotificationListener( + onNotification: _onScrollNotification, + child: ListView( + padding: const EdgeInsets.only(bottom: 100), + children: [ + _mediaSection(post), + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _authorCard(post), + const SizedBox(height: 14), + _bodyCard(post), + const SizedBox(height: 14), + _actionRow(post), + const SizedBox(height: 22), + Text( + '评论 (${post.commentCount})', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + ..._commentsSection(), + ], + ), + ), + ], + ), + ); + } + + /// 媒体全量:单图原比例(高度钳制 [0.75w, 1.33w]);多图真九宫格 + ///(PostMediaGrid 全量形态,列数 2/4→2 列、3/5–9→3 列 + 超 9 折叠)。 + Widget _mediaSection(Post post) { + final media = [...post.media] + ..sort((a, b) => a.position.compareTo(b.position)); + if (media.isEmpty) return const SizedBox.shrink(); + if (media.length == 1) { + final item = media.first; + var ratio = 4 / 3; + final width = item.widthPx; + final height = item.heightPx; + if (width != null && height != null && width > 0 && height > 0) { + // 高度钳制 [宽×0.75, 宽×1.33](05 §2.2)→ 宽高比落 [1/1.33, 1/0.75]。 + ratio = (width / height).clamp(1 / 1.33, 1 / 0.75); + } + return GestureDetector( + onTap: () => _openGallery(media, 0), + child: AspectRatio( + aspectRatio: ratio, + child: RemoteImage(url: item.url), + ), + ); + } + return Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 0), + child: PostMediaGrid( + urls: [for (final item in media) item.url], + onCellTap: (index) => _openGallery(media, index), + ), + ); + } + + Widget _authorCard(Post post) { + final author = post.author; + final isSelf = author.userId == widget.currentUserId; + return SectionCard( + child: Row( + children: [ + PetAvatar(size: PetAvatarSize.md, url: author.avatarUrl), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + authorDisplayName(author), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 2), + Text( + feedRelativeTime( + post.publishedAt ?? post.createdAt, + now: widget.now, + ), + style: const TextStyle( + color: AppColors.inkSoft, + fontSize: 12, + ), + ), + ], + ), + ), + if (!isSelf && _followStats != null) _followButton(author.userId), + ], + ), + ); + } + + /// 关注双态(05 §2.2):未关注 tonal「+ 关注」;已关注 Outlined + /// 「已关注」+ 取关确认。 + Widget _followButton(String authorId) { + if (_following) { + return OutlinedButton( + onPressed: () => _toggleFollow(authorId), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.inkSoft, + side: const BorderSide(color: AppColors.border), + ), + child: const Text('已关注'), + ); + } + return FilledButton.tonal( + onPressed: () => _toggleFollow(authorId), + child: const Text('+ 关注'), + ); + } + + Widget _bodyCard(Post post) { + return SectionCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (post.title != null) ...[ + Text(post.title!, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + ], + if (post.category == PostCategory.help) ...[ + const TagPill('求助', color: AppColors.accent), + const SizedBox(height: 8), + ], + Text( + post.content, + style: const TextStyle( + color: AppColors.ink, + fontSize: 14, + height: 1.6, + ), + ), + const SizedBox(height: 12), + Text( + '发布于 ${feedRelativeTime(post.publishedAt ?? post.createdAt, now: widget.now)}', + style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), + ), + ], + ), + ); + } + + /// 操作行:与 Feed 卡片同一套组件、同一 ToggleSync 实例(卡片外裸排, + /// demo 的 FilledButton.tonalIcon 形态弃用)。 + Widget _actionRow(Post post) { + return Row( + children: [ + LikeButton( + variant: LikeButtonVariant.like, + active: post.likedByMe, + count: post.likeCount, + onPressed: () => _controller.toggleLike( + widget.postId, + source: InteractionSource.postDetail, + ), + semanticLabel: '点赞', + ), + const SizedBox(width: 4), + // 评论锚点:聚焦底部输入框(唤起键盘直接开写)。 + Semantics( + label: '评论', + button: true, + child: InkWell( + onTap: _commentFocus.requestFocus, + borderRadius: BorderRadius.circular(AppRadius.pill), + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 44, minHeight: 44), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + mainAxisSize: MainAxisSize.min, children: [ - SectionCard( - child: Row( - children: [ - RemoteImage( - url: current.authorAvatar, - width: 48, - height: 48, - borderRadius: BorderRadius.circular(24), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - current.authorName, - style: Theme.of( - context, - ).textTheme.titleMedium, - ), - Text( - current.breedTag, - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - ), - FilledButton.tonal( - onPressed: () => - setState(() => isFollowing = !isFollowing), - child: Text(isFollowing ? '已关注' : '关注'), - ), - ], - ), + const Icon( + Icons.chat_bubble_outline, + size: 20, + color: AppColors.inkSoft, ), - const SizedBox(height: 14), - SectionCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(current.content), - const SizedBox(height: 14), - Wrap( - spacing: 8, - runSpacing: 8, - children: current.tags - .map((tag) => TagPill('#$tag')) - .toList(), - ), - const SizedBox(height: 12), - Text( - '发布于 ${current.time}', - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - ), - const SizedBox(height: 14), - Row( - children: [ - FilledButton.tonalIcon( - onPressed: toggleLike, - icon: Icon( - current.hasLiked - ? Icons.favorite - : Icons.favorite_border, - color: current.hasLiked ? Colors.red : null, - ), - label: Text('${current.likes}'), - ), - const SizedBox(width: 10), - FilledButton.tonalIcon( - onPressed: () {}, - icon: const Icon(Icons.chat_bubble_outline), - label: Text('${current.comments.length}'), - ), - const Spacer(), - IconButton.filledTonal( - onPressed: () => widget.appState.updatePost( - current.copyWith( - hasBookmarked: !current.hasBookmarked, - ), - ), - icon: Icon( - current.hasBookmarked - ? Icons.bookmark - : Icons.bookmark_border, - color: current.hasBookmarked - ? AppColors.primary - : null, - ), - ), - ], - ), - const SizedBox(height: 22), + const SizedBox(width: 4), Text( - '评论 (${current.comments.length})', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 12), - ...current.comments.map( - (comment) => Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - RemoteImage( - url: comment.authorAvatar, - width: 36, - height: 36, - borderRadius: BorderRadius.circular(18), - ), - const SizedBox(width: 10), - Expanded( - child: SectionCard( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - comment.authorName, - style: const TextStyle( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 4), - Text(comment.content), - const SizedBox(height: 5), - Text( - comment.time, - style: Theme.of( - context, - ).textTheme.bodySmall, - ), - ], - ), - ), - ), - ], - ), + '${post.commentCount}', + style: const TextStyle( + color: AppColors.inkSoft, + fontSize: 13, + fontWeight: FontWeight.w600, ), ), ], ), ), - ], + ), ), - bottomSheet: SafeArea( - top: false, - child: Container( - color: Colors.white, - padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), - child: Row( - children: [ - Expanded( - child: TextField( - controller: commentController, - textInputAction: TextInputAction.send, - onSubmitted: (_) => sendComment(), - decoration: const InputDecoration( - hintText: '写下你的评论…', - isDense: true, - ), - ), + ), + const SizedBox(width: 4), + LikeButton( + variant: LikeButtonVariant.bookmark, + active: post.bookmarkedByMe, + count: post.bookmarkCount, + onPressed: () => _controller.toggleBookmark( + widget.postId, + source: InteractionSource.postDetail, + ), + semanticLabel: '收藏', + ), + ], + ); + } + + List _commentsSection() { + switch (_commentsPhase) { + case _DetailPhase.loading: + return const [ + _CommentSkeleton(), + SizedBox(height: 12), + _CommentSkeleton(), + ]; + case _DetailPhase.error: + return [ + Center( + child: Column( + children: [ + Text( + feedLoadErrorMessage(_commentsError), + style: const TextStyle( + color: AppColors.inkSoft, + fontSize: 12, ), - const SizedBox(width: 8), - IconButton.filled( - tooltip: '发送评论', - onPressed: sendComment, - icon: const Icon(Icons.send_rounded), + ), + TextButton( + onPressed: _loadComments, + child: const Text('加载失败,点此重试'), + ), + ], + ), + ), + ]; + case _DetailPhase.ready: + if (_comments.isEmpty) { + return const [ + Padding( + padding: EdgeInsets.symmetric(vertical: 32), + child: Column( + children: [ + // 纯装饰图标,muted 合法(DEBT-2 口径)。 + Icon( + Icons.chat_bubble_outline, + size: 36, + color: AppColors.muted, + ), + SizedBox(height: 8), + Text( + '还没有评论,来抢沙发', + style: TextStyle(color: AppColors.inkSoft, fontSize: 12), ), ], ), ), + ]; + } + return [ + for (final comment in _comments) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: CommentTile( + comment: comment, + now: widget.now, + deleting: _deletingIds.contains(comment.id), + // 仅本人评论渲染删除入口(服务端 40301/40404 仍兜底)。 + onDelete: + widget.currentUserId != null && + comment.author.userId == widget.currentUserId + ? () => _deleteComment(comment) + : null, + ), + ), + ..._commentsTail(), + ]; + } + } + + List _commentsTail() { + switch (_commentsLoadMore) { + case LoadMorePhase.loading: + return const [ + Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: AppColors.primary, + ), + ), + ), ), - ); - }, + ]; + case LoadMorePhase.error: + return [ + Center( + child: TextButton( + onPressed: _loadMoreComments, + child: const Text('加载失败,点此重试'), + ), + ), + ]; + case LoadMorePhase.idle: + return const []; + } + } + + /// 底部固定输入条(05 §2.2):surface 底 + 顶部 border 1px 分隔线 + + /// isDense 输入框 + filled 发送钮(空文本禁用;发送中 18 转圈锁尺寸)。 + Widget _commentInputBar() { + final canSend = _commentController.text.trim().isNotEmpty && !_sending; + return Container( + decoration: const BoxDecoration( + color: AppColors.surface, + border: Border(top: BorderSide(color: AppColors.border)), + ), + padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), + child: SafeArea( + top: false, + child: Row( + children: [ + Expanded( + child: TextField( + controller: _commentController, + focusNode: _commentFocus, + textInputAction: TextInputAction.send, + onSubmitted: (_) => _submitComment(), + decoration: const InputDecoration( + hintText: '写下你的评论…', + isDense: true, + ), + ), + ), + const SizedBox(width: 8), + IconButton.filled( + tooltip: '发送评论', + onPressed: canSend ? _submitComment : null, + icon: _sending + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.send_rounded), + ), + ], + ), + ), + ); + } +} + +/// 评论区首载骨架(05 §3.7:32 圆 + 圆角 16 矩形块高 72,装饰性占位)。 +class _CommentSkeleton extends StatelessWidget { + const _CommentSkeleton(); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 32, + height: 32, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: AppColors.surfaceTint, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Container( + height: 72, + decoration: BoxDecoration( + color: AppColors.surfaceTint, + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ], + ); + } +} + +/// 全屏大图浏览(05 §2.2:黑底、双击缩放;03 号拍板 E 选内置 +/// InteractiveViewer,零依赖)。横滑翻页 + 右上「n/N」ink 胶囊 + 关闭钮; +/// 下滑关闭与 InteractiveViewer 平移手势冲突,留待手势方案升级 +/// (photo_view 复评条件:体验不达标)。 +class _MediaGalleryPage extends StatefulWidget { + const _MediaGalleryPage({required this.urls, required this.initialIndex}); + + final List urls; + final int initialIndex; + + @override + State<_MediaGalleryPage> createState() => _MediaGalleryPageState(); +} + +class _MediaGalleryPageState extends State<_MediaGalleryPage> { + late final PageController _pageController = PageController( + initialPage: widget.initialIndex, + ); + late int _index = widget.initialIndex; + final _viewerController = TransformationController(); + + @override + void dispose() { + _pageController.dispose(); + _viewerController.dispose(); + super.dispose(); + } + + void _toggleZoom(TapDownDetails details) { + if (_viewerController.value != Matrix4.identity()) { + _viewerController.value = Matrix4.identity(); + return; + } + final position = details.localPosition; + // 双击点为中心放大 2.5 倍。 + _viewerController.value = Matrix4.identity() + ..translateByDouble(-position.dx * 1.5, -position.dy * 1.5, 0, 1) + ..scaleByDouble(2.5, 2.5, 1, 1); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + PageView.builder( + controller: _pageController, + itemCount: widget.urls.length, + onPageChanged: (index) { + _viewerController.value = Matrix4.identity(); + setState(() => _index = index); + }, + itemBuilder: (context, index) { + return GestureDetector( + onDoubleTapDown: index == _index ? _toggleZoom : null, + onDoubleTap: () {}, // 消费事件,保留 onDoubleTapDown 的坐标。 + child: InteractiveViewer( + transformationController: index == _index + ? _viewerController + : null, + maxScale: 4, + child: Center( + child: RemoteImage( + url: widget.urls[index], + fit: BoxFit.contain, + ), + ), + ), + ); + }, + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + IconButton( + tooltip: '关闭', + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, color: Colors.white), + ), + const Spacer(), + if (widget.urls.length > 1) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: AppColors.ink, + borderRadius: BorderRadius.circular(AppRadius.pill), + ), + child: Text( + '${_index + 1}/${widget.urls.length}', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ), + ], + ), ); } } diff --git a/test/core/widgets/comment_tile_test.dart b/test/core/widgets/comment_tile_test.dart new file mode 100644 index 0000000..f5af2c4 --- /dev/null +++ b/test/core/widgets/comment_tile_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/comment_tile.dart'; + +import '../../helpers/community_test_helpers.dart'; + +void main() { + final now = DateTime.parse('2026-09-08T13:00:00.000Z'); + + Widget wrap(Widget child) => MaterialApp( + theme: buildAppTheme(), + home: Scaffold(body: Center(child: child)), + ); + + testWidgets('渲染作者名 / 内容 / 相对时间;无 onDelete 不显删除', (tester) async { + await tester.pumpWidget( + wrap(CommentTile(comment: sampleComment(), now: now)), + ); + + expect(find.text('毛毛的铲屎官'), findsOneWidget); + expect(find.text('好可爱!', findRichText: true), findsOneWidget); + expect(find.text('2 小时前'), findsOneWidget); + expect(find.text('删除'), findsNothing); + }); + + testWidgets('@ 回复以「回复 @昵称:」前缀呈现;降级作者「宠友」占位', (tester) async { + await tester.pumpWidget( + wrap( + CommentTile( + comment: sampleComment( + author: sampleAuthorJson(nickname: null, avatarUrl: null), + replyToUser: sampleAuthorJson(userId: 'u-2', nickname: '豆豆麻麻'), + ), + now: now, + ), + ), + ); + + expect(find.text('宠友'), findsOneWidget); + expect( + find.textContaining('回复 @豆豆麻麻:', findRichText: true), + findsOneWidget, + ); + }); + + testWidgets('onDelete 提供时显删除入口并回调;deleting 期间转圈锁定', (tester) async { + var deleted = 0; + await tester.pumpWidget( + wrap( + CommentTile( + comment: sampleComment(), + now: now, + onDelete: () => deleted++, + ), + ), + ); + + await tester.tap(find.text('删除')); + expect(deleted, 1); + + await tester.pumpWidget( + wrap( + CommentTile( + comment: sampleComment(), + now: now, + deleting: true, + onDelete: () => deleted++, + ), + ), + ); + expect(find.text('删除'), findsNothing); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); +} diff --git a/test/features/home/home_page_test.dart b/test/features/home/home_page_test.dart index aac242e..729b454 100644 --- a/test/features/home/home_page_test.dart +++ b/test/features/home/home_page_test.dart @@ -8,6 +8,7 @@ import 'package:patbond_flutter/core/widgets/feed_skeleton.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; import 'package:patbond_flutter/core/widgets/post_card.dart'; import 'package:patbond_flutter/features/community/community_controller.dart'; +import 'package:patbond_flutter/features/community/community_interaction_analytics.dart'; import 'package:patbond_flutter/features/community/community_models.dart'; import 'package:patbond_flutter/features/community/feed_analytics.dart'; import 'package:patbond_flutter/features/home/home_page.dart'; @@ -39,15 +40,22 @@ void main() { late List<(String, Map?)> events; late FeedAnalytics analytics; late int createTaps; + late List openedPosts; setUp(() { repository = FakeCommunityRepository(); - controller = CommunityController(repository: repository); events = []; + controller = CommunityController( + repository: repository, + interactionAnalytics: CommunityInteractionAnalytics( + (name, [props]) async => events.add((name, props)), + ), + ); analytics = FeedAnalytics( (name, [props]) async => events.add((name, props)), ); createTaps = 0; + openedPosts = []; }); List?> eventsNamed(String name) => @@ -65,6 +73,7 @@ void main() { isActive: isActive, onOpenServices: (_) {}, onOpenCreate: () => createTaps++, + onOpenPost: openedPosts.add, ), ), ), @@ -361,7 +370,7 @@ void main() { expect(eventsNamed('feed_viewed'), hasLength(2)); }); - testWidgets('T3-14 取舍:整卡点按提示详情接入中(不导航 demo 详情)', (tester) async { + testWidgets('T3-15 导航接通:整卡与评论钮点按回调 onOpenPost(占位提示移除)', (tester) async { repository.onFeed = (_, _) async => feedPage([textCard('p-1')]); await pumpHome(tester); @@ -371,7 +380,72 @@ void main() { await tester.tap(find.text('动态内容 p-1')); await tester.pump(); - expect(find.text('帖子详情正在接入真实数据,敬请期待'), findsOneWidget); + expect(find.text('帖子详情正在接入真实数据,敬请期待'), findsNothing); + expect(openedPosts, ['p-1']); + + await tester.tap(find.byIcon(Icons.chat_bubble_outline)); + await tester.pump(); + expect(openedPosts, ['p-1', 'p-1']); + }); + + testWidgets('T3-16 互动接线:点赞乐观翻转即时显示,成功报 post_liked(source=feed)', ( + tester, + ) async { + repository.onFeed = (_, _) async => feedPage([ + FeedCard.fromJson({ + ...sampleFeedCardJson(likeCount: 6), + 'coverImage': null, + 'mediaCount': 0, + }), + ]); + final like = Completer(); + repository.onLikeToggle = (_, _) => like.future; + + await pumpHome(tester); + await tester.pumpAndSettle(); + await tester.drag(list(), const Offset(0, -800)); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.favorite_border)); + await tester.pump(); + // 乐观翻转:请求未回已 +1(同帧反馈)。 + expect(find.text('7'), findsOneWidget); + expect(repository.calls, contains('like:p-1')); + + like.complete(const LikeState(liked: true, likeCount: 7)); + await tester.pumpAndSettle(); + expect(eventsNamed('post_liked'), [ + {'source': 'feed'}, + ]); + expect(find.text('7'), findsOneWidget); + }); + + testWidgets('T3-16 互动接线:点赞失败回滚 + SnackBar「操作失败,请重试」', (tester) async { + repository.onFeed = (_, _) async => feedPage([ + FeedCard.fromJson({ + ...sampleFeedCardJson(likeCount: 6), + 'coverImage': null, + 'mediaCount': 0, + }), + ]); + final like = Completer(); + repository.onLikeToggle = (_, _) => like.future; + + await pumpHome(tester); + await tester.pumpAndSettle(); + await tester.drag(list(), const Offset(0, -800)); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.favorite_border)); + await tester.pump(); + expect(find.text('7'), findsOneWidget); + + like.completeError(const ApiNetworkException()); + await tester.pumpAndSettle(); + // 回滚成对恢复 + SnackBar;不报 post_liked。 + expect(find.text('6'), findsOneWidget); + expect(find.text('操作失败,请重试'), findsOneWidget); + expect(eventsNamed('post_liked'), isEmpty); }); testWidgets('搜索词过滤已加载卡片;无命中显搜索空态', (tester) async { diff --git a/test/features/post/post_detail_page_test.dart b/test/features/post/post_detail_page_test.dart new file mode 100644 index 0000000..2063fe1 --- /dev/null +++ b/test/features/post/post_detail_page_test.dart @@ -0,0 +1,551 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/comment_tile.dart'; +import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; +import 'package:patbond_flutter/core/widgets/post_media_grid.dart'; +import 'package:patbond_flutter/features/community/community_controller.dart'; +import 'package:patbond_flutter/features/community/community_exceptions.dart'; +import 'package:patbond_flutter/features/community/community_interaction_analytics.dart'; +import 'package:patbond_flutter/features/community/community_models.dart'; +import 'package:patbond_flutter/features/post/post_detail_page.dart'; + +import '../../helpers/community_test_helpers.dart'; + +void main() { + late FakeCommunityRepository repository; + late CommunityController controller; + late List<(String, Map?)> events; + late CommunityInteractionAnalytics analytics; + + /// 无媒体帖(避免测试环境网络图噪音;媒体形态单测另立)。 + Post textPost({ + String id = 'p-1', + bool likedByMe = false, + int likeCount = 6, + int commentCount = 3, + }) => Post.fromJson({ + ...samplePostJson(id: id, likedByMe: likedByMe, likeCount: likeCount), + 'media': [], + 'commentCount': commentCount, + }); + + setUp(() { + repository = FakeCommunityRepository(); + events = []; + analytics = CommunityInteractionAnalytics( + (name, [props]) async => events.add((name, props)), + ); + controller = CommunityController( + repository: repository, + interactionAnalytics: analytics, + ); + // 缺省行为:正常帖 + 空评论 + 未关注(各测试按需覆盖)。 + repository.onGetPost = (_) async => textPost(); + repository.onListComments = (_, _) async => commentPage(const []); + repository.onGetFollowStats = (_) async => const FollowStats( + followerCount: 1, + followingCount: 2, + followedByMe: false, + ); + }); + + List?> eventsNamed(String name) => + events.where((e) => e.$1 == name).map((e) => e.$2).toList(); + + Widget detailApp({String? currentUserId = 'me'}) => MaterialApp( + theme: buildAppTheme(), + home: PostDetailPage( + controller: controller, + postId: 'p-1', + currentUserId: currentUserId, + analytics: analytics, + ), + ); + + /// 经启动页 push 详情(40403 返回 Feed 的 pop 断言用)。 + Widget launcherApp({String? currentUserId = 'me'}) => MaterialApp( + theme: buildAppTheme(), + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PostDetailPage( + controller: controller, + postId: 'p-1', + currentUserId: currentUserId, + analytics: analytics, + ), + ), + ), + child: const Text('打开详情'), + ), + ), + ), + ), + ); + + group('详情四态', () { + testWidgets('loading:请求未回渲染转圈', (tester) async { + final post = Completer(); + repository.onGetPost = (_) => post.future; + + await tester.pumpWidget(detailApp()); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + post.complete(textPost()); + await tester.pumpAndSettle(); + expect(find.text('晒了一下午太阳。'), findsOneWidget); + }); + + testWidgets('ready:正文/作者/求助标/评论标题齐全', (tester) async { + await tester.pumpWidget(detailApp()); + await tester.pumpAndSettle(); + + expect(find.text('今天的豆豆'), findsOneWidget); + expect(find.text('晒了一下午太阳。'), findsOneWidget); + expect(find.text('毛毛的铲屎官'), findsOneWidget); + expect(find.text('评论 (3)'), findsOneWidget); + expect(find.text('还没有评论,来抢沙发'), findsOneWidget); + }); + + testWidgets('error:横幅 + 重试恢复 ready', (tester) async { + var attempts = 0; + repository.onGetPost = (_) async { + attempts += 1; + if (attempts == 1) throw const ApiNetworkException(); + return textPost(); + }; + + await tester.pumpWidget(detailApp()); + await tester.pumpAndSettle(); + expect(find.byType(InlineErrorBanner), findsOneWidget); + expect(find.text('网络异常,请检查网络后重试'), findsOneWidget); + + await tester.tap(find.text('重试')); + await tester.pumpAndSettle(); + expect(find.byType(InlineErrorBanner), findsNothing); + expect(find.text('晒了一下午太阳。'), findsOneWidget); + }); + + testWidgets('40403 不存在态:SnackBar + 返回 Feed 并触发刷新', (tester) async { + repository.onGetPost = (_) async => + throw const PostNotFoundException(message: 'gone'); + repository.onFeed = (_, _) async => feedPage(const []); + + await tester.pumpWidget(launcherApp()); + await tester.tap(find.text('打开详情')); + await tester.pumpAndSettle(); + + // 已弹回启动页 + 提示 + Feed 整体刷新(失效帖剔除)。 + expect(find.text('打开详情'), findsOneWidget); + expect(find.text('帖子不存在或已被删除'), findsOneWidget); + expect(repository.calls, contains('feed:cursor=null')); + }); + + testWidgets('内存副本先渲染:进入即展示缓存,后台拉新不阻塞', (tester) async { + await controller.getPost('p-1'); // 预热详情副本。 + final refresh = Completer(); + repository.onGetPost = (_) => refresh.future; + + await tester.pumpWidget(detailApp()); + await tester.pump(); + // 拉新未回已渲染缓存内容,无全页转圈。 + expect(find.text('晒了一下午太阳。'), findsOneWidget); + + refresh.complete(textPost(likeCount: 9)); + await tester.pumpAndSettle(); + expect(find.text('9'), findsOneWidget); + }); + }); + + group('媒体', () { + testWidgets('多图渲染真九宫格,点格进全屏大图(页码 + 关闭)', (tester) async { + repository.onGetPost = (_) async => Post.fromJson({ + ...samplePostJson(), + 'media': [ + for (var i = 0; i < 5; i++) + samplePostMediaItemJson( + assetId: 'a-$i', + position: i, + isCover: i == 0, + ), + ], + }); + + await tester.pumpWidget(detailApp()); + await tester.pumpAndSettle(); + expect(find.byType(PostMediaGrid), findsOneWidget); + + await tester.tap( + find + .descendant( + of: find.byType(PostMediaGrid), + matching: find.byType(InkWell), + ) + .first, + ); + await tester.pumpAndSettle(); + expect(find.text('1/5'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + expect(find.byType(PostMediaGrid), findsOneWidget); + }); + }); + + group('评论区', () { + testWidgets('游标列表:首页渲染 + 触底携游标补页', (tester) async { + repository.onListComments = (_, cursor) async { + if (cursor == null) { + return commentPage( + [ + sampleComment(id: 'c-1', content: '第一条'), + sampleComment(id: 'c-2', content: '第二条'), + ], + nextCursor: 'cc1', + hasMore: true, + ); + } + return commentPage([sampleComment(id: 'c-3', content: '第三条')]); + }; + + await tester.pumpWidget(detailApp()); + await tester.pumpAndSettle(); + expect(find.text('第一条'), findsOneWidget); + + await tester.drag(find.byType(ListView), const Offset(0, -800)); + await tester.pumpAndSettle(); + expect( + repository.calls.where((c) => c.startsWith('comments:')).toList(), + ['comments:p-1:cursor=null', 'comments:p-1:cursor=cc1'], + ); + expect(find.text('第三条'), findsOneWidget); + }); + + testWidgets('列表失败:话术 + 点按重试恢复', (tester) async { + var attempts = 0; + repository.onListComments = (_, _) async { + attempts += 1; + if (attempts == 1) throw const ApiNetworkException(); + return commentPage([sampleComment(content: '恢复后的评论')]); + }; + + await tester.pumpWidget(detailApp()); + await tester.pumpAndSettle(); + expect(find.text('加载失败,点此重试'), findsOneWidget); + + await tester.tap(find.text('加载失败,点此重试')); + await tester.pumpAndSettle(); + expect(find.text('恢复后的评论'), findsOneWidget); + }); + + testWidgets('创建成功:插入列表头 + 计数 +1 + comment_create_succeeded + 清空输入', ( + tester, + ) async { + repository.onCreateComment = (_, request) async => + sampleComment(id: 'c-new', content: request.content); + + await tester.pumpWidget(detailApp()); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), '沙发!'); + await tester.pump(); + await tester.tap(find.byIcon(Icons.send_rounded)); + await tester.pumpAndSettle(); + + expect(repository.calls, contains('createComment:p-1:沙发!')); + expect(find.text('沙发!'), findsOneWidget); // 列表中的新评论。 + expect(find.text('评论 (4)'), findsOneWidget); + expect( + tester.widget(find.byType(TextField)).controller?.text, + '', + ); + final succeeded = eventsNamed('comment_create_succeeded').single!; + expect(succeeded['isReply'], false); + expect(succeeded['textLengthBucket'], 'short'); + expect(succeeded['durationMs'], greaterThanOrEqualTo(0)); + // Feed 卡片同源计数(跨页一致的另一半在 like 测试)。 + expect(controller.cachedPost('p-1')!.commentCount, 4); + }); + + testWidgets('创建失败:SnackBar + comment_create_failed,attemptSeq 递增', ( + tester, + ) async { + repository.onCreateComment = (_, _) async => + throw const ApiBusinessException(code: 40000, message: 'bad'); + + await tester.pumpWidget(detailApp()); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), '不合规内容'); + await tester.pump(); + await tester.tap(find.byIcon(Icons.send_rounded)); + await tester.pumpAndSettle(); + expect(find.text('评论内容不合规,请修改后重试'), findsOneWidget); + + // 等 SnackBar 退场(遮挡底部发送钮)后重试第二次。 + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.send_rounded)); + await tester.pumpAndSettle(); + expect(eventsNamed('comment_create_failed'), [ + { + 'failureReason': 'validation_error', + 'attemptSeq': 1, + 'errorCode': 40000, + 'httpStatus': 400, + }, + { + 'failureReason': 'validation_error', + 'attemptSeq': 2, + 'errorCode': 40000, + 'httpStatus': 400, + }, + ]); + // 失败不清空输入、不入列表、不动计数。 + expect(find.text('评论 (3)'), findsOneWidget); + }); + + testWidgets('删除权限呈现:仅本人评论有「删除」;确认后删除 + 计数 -1', (tester) async { + repository.onListComments = (_, _) async => commentPage([ + sampleComment( + id: 'c-mine', + author: sampleAuthorJson(userId: 'me', nickname: '我自己'), + content: '我的评论', + ), + sampleComment(id: 'c-other', content: '别人的评论'), + ]); + repository.onDeleteComment = (_) async {}; + + await tester.pumpWidget(detailApp()); + await tester.pumpAndSettle(); + await tester.drag(find.byType(ListView), const Offset(0, -400)); + await tester.pump(); + + // 两条评论,删除入口恰一个(本人条目)。 + expect(find.byType(CommentTile), findsNWidgets(2)); + expect(find.text('删除'), findsOneWidget); + + await tester.tap(find.text('删除')); + await tester.pumpAndSettle(); + expect(find.text('删除这条评论?'), findsOneWidget); + await tester.tap(find.widgetWithText(FilledButton, '删除')); + await tester.pumpAndSettle(); + + expect(repository.calls, contains('deleteComment:c-mine')); + expect(find.text('我的评论'), findsNothing); + expect(find.text('别人的评论'), findsOneWidget); + expect(find.text('评论 (2)'), findsOneWidget); + }); + + testWidgets('删除失败:40301 提示无权限,条目保留', (tester) async { + repository.onListComments = (_, _) async => commentPage([ + sampleComment( + id: 'c-mine', + author: sampleAuthorJson(userId: 'me', nickname: '我自己'), + content: '我的评论', + ), + ]); + repository.onDeleteComment = (_) async => + throw const PostAccessDeniedException(message: 'denied'); + + await tester.pumpWidget(detailApp()); + await tester.pumpAndSettle(); + await tester.drag(find.byType(ListView), const Offset(0, -400)); + await tester.pump(); + + await tester.tap(find.text('删除')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, '删除')); + await tester.pumpAndSettle(); + + expect(find.text('没有权限删除这条评论'), findsOneWidget); + expect(find.text('我的评论'), findsOneWidget); + expect(find.text('评论 (3)'), findsOneWidget); + }); + }); + + group('互动(ToggleSync UI 层)', () { + testWidgets('点赞乐观翻转即时 +1,成功报 post_liked(source=post_detail)', ( + tester, + ) async { + final like = Completer(); + repository.onLikeToggle = (_, _) => like.future; + + await tester.pumpWidget(detailApp()); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.favorite_border)); + await tester.pump(); + expect(find.text('7'), findsOneWidget); + expect(find.byIcon(Icons.favorite), findsOneWidget); + + like.complete(const LikeState(liked: true, likeCount: 7)); + await tester.pumpAndSettle(); + expect(eventsNamed('post_liked'), [ + {'source': 'post_detail'}, + ]); + }); + + testWidgets('点赞失败:零动画回滚成对恢复 + SnackBar,不报事件', (tester) async { + final like = Completer(); + repository.onLikeToggle = (_, _) => like.future; + + await tester.pumpWidget(detailApp()); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.favorite_border)); + await tester.pump(); + expect(find.text('7'), findsOneWidget); + + like.completeError(const ApiNetworkException()); + await tester.pumpAndSettle(); + expect(find.text('6'), findsOneWidget); + expect(find.byIcon(Icons.favorite_border), findsOneWidget); + expect(find.text('操作失败,请重试'), findsOneWidget); + expect(eventsNamed('post_liked'), isEmpty); + }); + + testWidgets('收藏接线:成功报 post_favorited(source=post_detail)', (tester) async { + repository.onBookmarkToggle = (_, target) async => + BookmarkState(bookmarked: target, bookmarkCount: target ? 3 : 2); + + await tester.pumpWidget(detailApp()); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.bookmark_border)); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.bookmark), findsOneWidget); + expect(eventsNamed('post_favorited'), [ + {'source': 'post_detail'}, + ]); + }); + + testWidgets('跨页一致:详情页点赞,Feed 卡片同帖同帧更新(共享同一实例)', (tester) async { + repository.onFeed = (_, _) async => + feedPage([sampleFeedCard(likeCount: 6)]); + await controller.refresh(); + repository.onLikeToggle = (_, target) async => + LikeState(liked: target, likeCount: target ? 7 : 6); + + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: Column( + children: [ + // 模拟 Feed 侧对同一 controller 的消费。 + ListenableBuilder( + listenable: controller, + builder: (context, _) { + final card = controller.feed.single; + return Text('card:${card.likedByMe}:${card.likeCount}'); + }, + ), + Expanded( + child: PostDetailPage( + controller: controller, + postId: 'p-1', + currentUserId: 'me', + analytics: analytics, + ), + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('card:false:6'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.favorite_border)); + await tester.pump(); + // 乐观写入即同源:卡片副本同帧翻转。 + expect(find.text('card:true:7'), findsOneWidget); + await tester.pumpAndSettle(); + expect(find.text('card:true:7'), findsOneWidget); + }); + }); + + group('关注', () { + testWidgets('未关注 → 关注:乐观翻转 + user_followed(post_detail)', (tester) async { + repository.onFollowToggle = (_, target) async => + FollowState(following: target, followerCount: target ? 2 : 1); + + await tester.pumpWidget(detailApp()); + await tester.pumpAndSettle(); + expect(find.text('+ 关注'), findsOneWidget); + + await tester.tap(find.text('+ 关注')); + await tester.pump(); + expect(find.text('已关注'), findsOneWidget); + + await tester.pumpAndSettle(); + expect(repository.calls, contains('follow:u-1')); + expect(eventsNamed('user_followed'), [ + {'source': 'post_detail'}, + ]); + }); + + testWidgets('已关注 → 取关:确认弹窗 + user_unfollowed', (tester) async { + repository.onGetFollowStats = (_) async => const FollowStats( + followerCount: 2, + followingCount: 2, + followedByMe: true, + ); + repository.onFollowToggle = (_, target) async => + FollowState(following: target, followerCount: target ? 2 : 1); + + await tester.pumpWidget(detailApp()); + await tester.pumpAndSettle(); + expect(find.text('已关注'), findsOneWidget); + + await tester.tap(find.text('已关注')); + await tester.pumpAndSettle(); + expect(find.text('不再关注 TA?'), findsOneWidget); + await tester.tap(find.widgetWithText(FilledButton, '不再关注')); + await tester.pumpAndSettle(); + + expect(find.text('+ 关注'), findsOneWidget); + expect(repository.calls, contains('unfollow:u-1')); + expect(eventsNamed('user_unfollowed'), [ + {'source': 'post_detail'}, + ]); + }); + + testWidgets('关注失败:回滚直接跳变 + SnackBar', (tester) async { + final follow = Completer(); + repository.onFollowToggle = (_, _) => follow.future; + + await tester.pumpWidget(detailApp()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('+ 关注')); + await tester.pump(); + expect(find.text('已关注'), findsOneWidget); + + follow.completeError(const ApiNetworkException()); + await tester.pumpAndSettle(); + expect(find.text('+ 关注'), findsOneWidget); + expect(find.text('操作失败,请重试'), findsOneWidget); + expect(eventsNamed('user_followed'), isEmpty); + }); + + testWidgets('本人帖:不渲染关注钮、不拉 follow-stats', (tester) async { + await tester.pumpWidget(detailApp(currentUserId: 'u-1')); + await tester.pumpAndSettle(); + + expect(find.text('+ 关注'), findsNothing); + expect(find.text('已关注'), findsNothing); + expect( + repository.calls.where((c) => c.startsWith('followStats:')), + isEmpty, + ); + }); + }); +} diff --git a/test/helpers/community_test_helpers.dart b/test/helpers/community_test_helpers.dart index b75819b..713f353 100644 --- a/test/helpers/community_test_helpers.dart +++ b/test/helpers/community_test_helpers.dart @@ -76,13 +76,15 @@ Map sampleFeedCardJson({ Map sampleCommentJson({ String id = 'c-1', + Map? author, Map? replyToUser, + String content = '好可爱!', }) => { 'id': id, 'postId': 'p-1', - 'author': sampleAuthorJson(), + 'author': author ?? sampleAuthorJson(), 'replyToUser': replyToUser, - 'content': '好可爱!', + 'content': content, 'createdAt': '2026-09-08T11:00:00.000Z', }; @@ -131,6 +133,42 @@ CursorPage feedPage( bool hasMore = false, }) => CursorPage(items: items, nextCursor: nextCursor, hasMore: hasMore); +Post samplePost({ + String id = 'p-1', + bool likedByMe = false, + int likeCount = 6, + bool bookmarkedByMe = false, + int bookmarkCount = 2, +}) => Post.fromJson( + samplePostJson( + id: id, + likedByMe: likedByMe, + likeCount: likeCount, + bookmarkedByMe: bookmarkedByMe, + bookmarkCount: bookmarkCount, + ), +); + +PostComment sampleComment({ + String id = 'c-1', + Map? author, + Map? replyToUser, + String content = '好可爱!', +}) => PostComment.fromJson( + sampleCommentJson( + id: id, + author: author, + replyToUser: replyToUser, + content: content, + ), +); + +CursorPage commentPage( + List items, { + String? nextCursor, + bool hasMore = false, +}) => CursorPage(items: items, nextCursor: nextCursor, hasMore: hasMore); + /// 假仓库:controller 测试注入行为并记录调用(Completer 控时序)。 /// 未注入 handler 的方法一律 UnimplementedError(误触发即测试失败)。 class FakeCommunityRepository implements CommunityRepository { @@ -141,6 +179,13 @@ class FakeCommunityRepository implements CommunityRepository { Future Function(String postId, bool target)? onLikeToggle; Future Function(String postId, bool target)? onBookmarkToggle; Future Function(String postId)? onGetPost; + Future> Function(String postId, String? cursor)? + onListComments; + Future Function(String postId, CreateCommentRequest request)? + onCreateComment; + Future Function(String commentId)? onDeleteComment; + Future Function(String userId, bool target)? onFollowToggle; + Future Function(String userId)? onGetFollowStats; Future Function(CreateMediaUploadRequest request)? onCreateMediaUpload; Future Function(String assetId)? onCompleteMediaUpload; @@ -218,28 +263,45 @@ class FakeCommunityRepository implements CommunityRepository { String postId, { int? limit, String? cursor, - }) => throw UnimplementedError(); + }) { + calls.add('comments:$postId:cursor=$cursor'); + return onListComments!(postId, cursor); + } @override Future createComment( String postId, CreateCommentRequest request, - ) => throw UnimplementedError(); + ) { + calls.add('createComment:$postId:${request.content}'); + return onCreateComment!(postId, request); + } @override - Future deleteComment(String commentId) => throw UnimplementedError(); + Future deleteComment(String commentId) { + calls.add('deleteComment:$commentId'); + return onDeleteComment!(commentId); + } + + @override + Future followUser(String userId) { + calls.add('follow:$userId'); + return onFollowToggle!(userId, true); + } + + @override + Future unfollowUser(String userId) { + calls.add('unfollow:$userId'); + return onFollowToggle!(userId, false); + } + + @override + Future getFollowStats(String userId) { + calls.add('followStats:$userId'); + return onGetFollowStats!(userId); + } @override Future> listMyBookmarks({int? limit, String? cursor}) => throw UnimplementedError(); - - @override - Future followUser(String userId) => throw UnimplementedError(); - - @override - Future unfollowUser(String userId) => throw UnimplementedError(); - - @override - Future getFollowStats(String userId) => - throw UnimplementedError(); } diff --git a/test/smoke/detail_interactions_smoke_test.dart b/test/smoke/detail_interactions_smoke_test.dart new file mode 100644 index 0000000..aeba99e --- /dev/null +++ b/test/smoke/detail_interactions_smoke_test.dart @@ -0,0 +1,265 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/network/api_client.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/core/network/token_refresher.dart'; +import 'package:patbond_flutter/features/auth/auth_models.dart'; +import 'package:patbond_flutter/features/auth/session_manager.dart'; +import 'package:patbond_flutter/features/community/community_controller.dart'; +import 'package:patbond_flutter/features/community/community_models.dart'; +import 'package:patbond_flutter/features/community/community_repository.dart'; +import 'package:uuid/uuid.dart'; + +import '../helpers/auth_test_helpers.dart'; + +/// T3-15/16 compose 真链路冒烟(默认跳过,不计入常规测试套件): +/// +/// ```bash +/// # 先起后端六容器(patbond-api 仓库根): +/// # ./deploy/init-secrets.sh +/// # JAVA_HOME= ./mvnw -DskipTests package && docker compose up -d --build +/// PATBOND_DETAIL_SMOKE=1 flutter test test/smoke/detail_interactions_smoke_test.dart +/// ``` +/// +/// 覆盖:发帖 → 点赞(含重复施加幂等)→ 收藏/取消 → 评论创建 +/// (Idempotency-Key)→ 仅作者删除评论 → 计数权威对账;再走一轮 +/// **断网点赞回滚**——[CommunityController]/ToggleSync 生产实现 + +/// 生产 ApiClient 错误链,community 端点切至不可达端口模拟断网 +/// (连接拒绝走真实 ApiNetworkException 路径),断言乐观翻转即刻 +/// 可见、失败后快照回滚、网络恢复后权威终态收敛。 +void main() { + final enabled = Platform.environment['PATBOND_DETAIL_SMOKE'] == '1'; + final env = Platform.environment; + final authBase = env['PATBOND_SMOKE_AUTH_BASE'] ?? 'http://127.0.0.1:8081'; + final communityBase = + env['PATBOND_SMOKE_COMMUNITY_BASE'] ?? 'http://127.0.0.1:8084'; + + test( + '点赞/收藏/评论/删评真链路一轮 + 断网点赞回滚', + () async { + // ---- 注册一次性账号(随机凭据,不落任何持久化)---- + final dio = Dio(BaseOptions(validateStatus: (_) => true)); + final seed = DateTime.now().millisecondsSinceEpoch; + final register = await dio.post>( + '$authBase/api/v1/auth/register', + data: { + 'username': 'ismoke$seed', + 'phone': '+86138${(seed % 100000000).toString().padLeft(8, '0')}', + 'password': 'Smoke1234!$seed', + }, + options: Options( + headers: { + 'Idempotency-Key': const Uuid().v4(), + 'X-Device-Id': const Uuid().v4(), + }, + ), + ); + expect(register.data?['code'], 0, reason: '注册失败:${register.data}'); + + final session = SessionManager(store: InMemoryTokenStore()); + await session.updateTokens( + AuthTokens.fromJson(register.data!['data'] as Map), + ); + final refresher = TokenRefresher( + dio: buildPatbondDio(session: session, baseUrl: authBase), + session: session, + ); + final live = ApiCommunityRepository( + api: ApiClient( + dio: buildPatbondDio(session: session, baseUrl: communityBase), + session: session, + refresher: refresher, + ), + ); + // 「断网」形态:community 域切至不可达端口(连接拒绝 → 生产 + // ApiClient 映射 ApiNetworkException,与真实断网同一异常链)。 + final dead = ApiCommunityRepository( + api: ApiClient( + dio: buildPatbondDio(session: session, baseUrl: 'http://127.0.0.1:9'), + session: session, + refresher: refresher, + ), + ); + final switchable = _SwitchableRepository(live); + + // ---- 发帖(published,纯文字)---- + final post = await live.createPost( + CreatePostRequest( + content: 'T3-15/16 互动冒烟 $seed', + status: PostStatus.published, + ), + ); + expect(post.likeCount, 0); + expect(post.commentCount, 0); + + // ---- 点赞:施加 / 重复施加幂等,权威终态 ---- + var like = await live.likePost(post.id); + expect(like.liked, true); + expect(like.likeCount, 1); + like = await live.likePost(post.id); + expect(like.likeCount, 1, reason: '重复 PUT 不重复计数'); + + // ---- 收藏 / 取消 ---- + var bookmark = await live.bookmarkPost(post.id); + expect(bookmark.bookmarked, true); + expect(bookmark.bookmarkCount, 1); + bookmark = await live.unbookmarkPost(post.id); + expect(bookmark.bookmarked, false); + expect(bookmark.bookmarkCount, 0); + + // ---- 评论创建(Idempotency-Key 由仓库层携带)→ 计数 +1 ---- + final comment = await live.createComment( + post.id, + const CreateCommentRequest(content: '冒烟评论:真链路一轮'), + ); + expect(comment.content, '冒烟评论:真链路一轮'); + var fresh = await live.getPost(post.id); + expect(fresh.commentCount, 1); + + // ---- 仅作者删除评论 → 计数 -1、列表剔除 ---- + await live.deleteComment(comment.id); + fresh = await live.getPost(post.id); + expect(fresh.commentCount, 0); + final comments = await live.listComments(post.id); + expect(comments.items, isEmpty); + + // ---- 断网点赞回滚(生产 CommunityController + ToggleSync)---- + final controller = CommunityController(repository: switchable); + await controller.refresh(); + FeedCard card() => controller.feed.firstWhere((c) => c.id == post.id); + expect(card().likedByMe, true); + expect(card().likeCount, 1); + + switchable.target = dead; // 拔网线。 + final errored = Completer(); + controller.addListener(() { + if (controller.toggleError != null && !errored.isCompleted) { + errored.complete(); + } + }); + controller.toggleLike(post.id); + // 乐观翻转同帧可见。 + expect(card().likedByMe, false); + expect(card().likeCount, 0); + + await errored.future.timeout(const Duration(seconds: 30)); + // 快照回滚成对恢复 + 一次性错误可供 SnackBar 消费。 + expect(card().likedByMe, true); + expect(card().likeCount, 1); + expect(controller.toggleError, isA()); + controller.clearToggleError(); + + // ---- 网络恢复:取消点赞收敛到服务端权威终态 ---- + switchable.target = live; + controller.toggleLike(post.id); + expect(card().likedByMe, false); // 乐观翻转。 + // 轮询服务端权威终态(乐观值不作为收敛依据)。 + final deadline = DateTime.now().add(const Duration(seconds: 15)); + Post settled = await live.getPost(post.id); + while (settled.likedByMe) { + expect(DateTime.now().isBefore(deadline), true, reason: '收敛超时'); + await Future.delayed(const Duration(milliseconds: 300)); + settled = await live.getPost(post.id); + } + expect(settled.likedByMe, false); + expect(settled.likeCount, 0); + expect(controller.toggleError, isNull); + expect(card().likedByMe, false); + controller.dispose(); + }, + skip: enabled + ? false + : '设 PATBOND_DETAIL_SMOKE=1 且后端六容器在本机运行时才执行', + timeout: const Timeout(Duration(minutes: 3)), + ); +} + +/// 可切换目标的仓库代理(断网模拟:live ↔ dead 端点整体切换)。 +class _SwitchableRepository implements CommunityRepository { + _SwitchableRepository(this.target); + + CommunityRepository target; + + @override + Future createMediaUpload( + CreateMediaUploadRequest request, + ) => target.createMediaUpload(request); + + @override + Future completeMediaUpload(String assetId) => + target.completeMediaUpload(assetId); + + @override + Future createPost(CreatePostRequest request) => + target.createPost(request); + + @override + Future getPost(String postId) => target.getPost(postId); + + @override + Future updatePost(String postId, UpdatePostRequest request) => + target.updatePost(postId, request); + + @override + Future deletePost(String postId) => target.deletePost(postId); + + @override + Future> listMyPosts({ + int? limit, + String? cursor, + PostStatus? status, + }) => target.listMyPosts(limit: limit, cursor: cursor, status: status); + + @override + Future> getFeed({int? limit, String? cursor}) => + target.getFeed(limit: limit, cursor: cursor); + + @override + Future> listComments( + String postId, { + int? limit, + String? cursor, + }) => target.listComments(postId, limit: limit, cursor: cursor); + + @override + Future createComment( + String postId, + CreateCommentRequest request, + ) => target.createComment(postId, request); + + @override + Future deleteComment(String commentId) => + target.deleteComment(commentId); + + @override + Future likePost(String postId) => target.likePost(postId); + + @override + Future unlikePost(String postId) => target.unlikePost(postId); + + @override + Future bookmarkPost(String postId) => + target.bookmarkPost(postId); + + @override + Future unbookmarkPost(String postId) => + target.unbookmarkPost(postId); + + @override + Future> listMyBookmarks({int? limit, String? cursor}) => + target.listMyBookmarks(limit: limit, cursor: cursor); + + @override + Future followUser(String userId) => target.followUser(userId); + + @override + Future unfollowUser(String userId) => + target.unfollowUser(userId); + + @override + Future getFollowStats(String userId) => + target.getFollowStats(userId); +}