diff --git a/integration_test/feed_live_test.dart b/integration_test/feed_live_test.dart new file mode 100644 index 0000000..6c7e643 --- /dev/null +++ b/integration_test/feed_live_test.dart @@ -0,0 +1,112 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:patbond_flutter/app/app.dart'; +import 'package:patbond_flutter/core/widgets/post_card.dart'; +import 'package:patbond_flutter/features/auth/session_manager.dart'; + +/// 内存 token 存储(桌面实测环境无 keyring;不落任何持久化)。 +class _InMemoryTokenStore implements TokenStore { + final Map _values = {}; + + @override + Future read(String key) async => _values[key]; + + @override + Future write(String key, String value) async => _values[key] = value; + + @override + Future delete(String key) async => _values.remove(key); +} + +/// T3-14 compose 真链路桌面实测(默认跳过,不计入常规测试套件): +/// +/// ```bash +/// # 先起后端六容器(patbond-api 仓库根)并用 scratch 种子脚本发帖,再: +/// PATBOND_FEED_LIVE=1 flutter test integration_test/feed_live_test.dart -d linux +/// ``` +/// +/// 驱动**真实 App**(Linux 桌面渲染管线 + 真实 HTTP + MinIO 预签名图片): +/// 注册账号 → UI 登录 → Feed 首屏真数据 → 滚动触底游标翻页到底 → +/// 下拉刷新。会话存储注入内存实现(桌面环境无 keyring,不动生产配置)。 +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive; + + final enabled = Platform.environment['PATBOND_FEED_LIVE'] == '1'; + const authBase = 'http://127.0.0.1:8081'; + + Future pumpUntil( + WidgetTester tester, + Finder finder, { + Duration timeout = const Duration(seconds: 20), + }) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + await tester.pump(const Duration(milliseconds: 250)); + if (finder.evaluate().isNotEmpty) return; + } + fail('等待超时:$finder'); + } + + testWidgets('Feed 桌面真链路:登录 → 首屏 → 游标翻页到底 → 下拉刷新', (tester) async { + // ---- 注册一次性账号(随机凭据,不落持久化)---- + final seed = DateTime.now().millisecondsSinceEpoch; + final username = 'feedlive$seed'; + final password = 'Live1234!$seed'; + final client = HttpClient(); + final request = await client.postUrl( + Uri.parse('$authBase/api/v1/auth/register'), + ); + request.headers.contentType = ContentType.json; + request.add( + utf8.encode( + jsonEncode({ + 'username': username, + 'phone': '+86137${(seed % 100000000).toString().padLeft(8, '0')}', + 'password': password, + }), + ), + ); + final response = await request.close(); + expect(response.statusCode, 200, reason: '注册测试账号失败'); + client.close(); + + // ---- 启动真实 App(仅注入内存会话存储,其余全为生产实现)---- + await tester.pumpWidget( + App(sessionManager: SessionManager(store: _InMemoryTokenStore())), + ); + await pumpUntil(tester, find.text('登录')); + + await tester.enterText(find.byType(TextField).at(0), username); + await tester.enterText(find.byType(TextField).at(1), password); + await tester.tap(find.text('登录')); + + // ---- Feed 首屏:真实 getFeed + 卡片渲染 ---- + await pumpUntil(tester, find.byType(PostCard)); + expect(find.textContaining('compose 实测'), findsWidgets); + + // ---- 触底翻页到「没有更多了」(26 帖 / 服务端页长 20 → 两页取齐)---- + final list = find.byType(ListView).first; + for (var i = 0; i < 12; i++) { + await tester.fling(list, const Offset(0, -700), 1500); + await tester.pump(const Duration(milliseconds: 400)); + if (find.text('没有更多了').evaluate().isNotEmpty) break; + } + await pumpUntil(tester, find.text('没有更多了')); + // 最早一帖(#1)在第二页尾部——游标翻页取齐的直接证据。 + expect(find.textContaining('#1:'), findsOneWidget); + + // ---- 回顶下拉刷新:整体替换后首屏仍在 ---- + for (var i = 0; i < 12; i++) { + await tester.fling(list, const Offset(0, 700), 1500); + await tester.pump(const Duration(milliseconds: 200)); + } + await tester.fling(list, const Offset(0, 400), 1000); + await pumpUntil(tester, find.byType(PostCard)); + expect(find.textContaining('compose 实测'), findsWidgets); + }, skip: !enabled); +} diff --git a/lib/app/app.dart b/lib/app/app.dart index 35e905f..3e2a6ce 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -14,6 +14,7 @@ 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_repository.dart'; +import 'package:patbond_flutter/features/community/feed_analytics.dart'; import 'package:patbond_flutter/features/main/main_shell_page.dart'; import 'package:patbond_flutter/features/pets/health_record_analytics.dart'; import 'package:patbond_flutter/features/pets/pet_analytics.dart'; @@ -48,6 +49,7 @@ class _AppState extends State { late final CommunityController communityController; late final PetAnalytics petAnalytics; late final HealthRecordAnalytics healthRecordAnalytics; + late final FeedAnalytics feedAnalytics; late final SessionTracker _sessionTracker; late final AnalyticsService _analytics; late final PageViewTracker _pageViewTracker; @@ -93,12 +95,13 @@ class _AppState extends State { petsController = PetsController( repository: widget.petsRepository ?? _buildPetsRepository(), ); - // T3-12 只装配数据层;Feed segment 的 UI 接线在 T3-14 挂入主壳。 + // T3-14:Feed segment 接线主壳(数据层 T3-12 就位)。 communityController = CommunityController( repository: widget.communityRepository ?? _buildCommunityRepository(), ); petAnalytics = PetAnalytics(_analytics.trackEvent); healthRecordAnalytics = HealthRecordAnalytics(_analytics.trackEvent); + feedAnalytics = FeedAnalytics(_analytics.trackEvent); // 认证状态切换补点(根路由 AnimatedSwitcher 无路由事件) sessionManager.addListener(_reportAuthStateChange); @@ -231,8 +234,10 @@ class _AppState extends State { key: const ValueKey('shell'), appState: appState, petsController: petsController, + communityController: communityController, petAnalytics: petAnalytics, healthRecordAnalytics: healthRecordAnalytics, + feedAnalytics: feedAnalytics, pageViewTracker: _pageViewTracker, onLogout: authRepository.logout, ); diff --git a/lib/core/network/signed_network_image.dart b/lib/core/network/signed_network_image.dart new file mode 100644 index 0000000..d3907c4 --- /dev/null +++ b/lib/core/network/signed_network_image.dart @@ -0,0 +1,67 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; + +/// 预签名 URL 的图片内存缓存 key:剥离 SigV4 签名参数(`X-Amz-*`, +/// 大小写不敏感),保留其余 query。 +/// +/// 媒体 URL 每次响应现签(TTL 1 小时),同一对象两次响应的完整 URL +/// 必然不同;若按完整 URL 作缓存 key,同图会反复未命中、重复下载。 +/// 对象路径唯一标识 MinIO 对象,剥签名后即稳定 key。 +String presignedImageCacheKey(String url) { + final uri = Uri.tryParse(url); + if (uri == null || uri.queryParameters.isEmpty) return url; + final kept = {}; + var stripped = false; + uri.queryParameters.forEach((key, value) { + if (key.toLowerCase().startsWith('x-amz-')) { + stripped = true; + } else { + kept[key] = value; + } + }); + if (!stripped) return url; + // Uri.replace 的 null 语义是「保留原值」,全剥空时手工截断 query。 + if (kept.isEmpty) return url.substring(0, url.indexOf('?')); + return uri.replace(queryParameters: kept).toString(); +} + +/// 以剥签名 key 判等的网络图 provider:同对象的不同签名 URL 命中同一 +/// [ImageCache] 条目;未命中时仍以完整签名 URL 发起请求(委托 +/// [NetworkImage] 加载,其为 factory-only 接口,无法直接继承)。 +class SignedNetworkImage extends ImageProvider { + SignedNetworkImage(this.url, {this.scale = 1.0}) + : cacheKey = presignedImageCacheKey(url); + + final String url; + final double scale; + + /// 剥离签名参数后的稳定缓存 key。 + final String cacheKey; + + @override + Future obtainKey(ImageConfiguration configuration) => + SynchronousFuture(this); + + @override + ImageStreamCompleter loadImage( + SignedNetworkImage key, + ImageDecoderCallback decode, + ) { + final delegate = NetworkImage(key.url, scale: key.scale); + return delegate.loadImage(delegate, decode); + } + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) return false; + return other is SignedNetworkImage && + other.cacheKey == cacheKey && + other.scale == scale; + } + + @override + int get hashCode => Object.hash(cacheKey, scale); + + @override + String toString() => 'SignedNetworkImage("$url", cacheKey: "$cacheKey")'; +} diff --git a/lib/core/widgets/feed_skeleton.dart b/lib/core/widgets/feed_skeleton.dart new file mode 100644 index 0000000..ed52bdd --- /dev/null +++ b/lib/core/widgets/feed_skeleton.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; + +/// Feed 骨架屏单元(05 号规范 §3.7):模拟单图卡——头部行(32 圆 + +/// 两条横条)→ 4:3 通栏块 → 两条正文横条。块色 `surfaceTint` +/// (1.18:1,装饰性占位不受对比度约束)。 +/// +/// 动效:整体不透明度 0.6 ↔ 1.0 呼吸循环 1200ms;系统「减弱动态效果」 +/// 开启时静止在 1.0。首载用法为连排 3 张(P1/P4)。 +class FeedSkeleton extends StatefulWidget { + const FeedSkeleton({super.key}); + + @override + State createState() => _FeedSkeletonState(); +} + +class _FeedSkeletonState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + lowerBound: 0.6, + value: 1, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final reduceMotion = MediaQuery.of(context).disableAnimations; + if (reduceMotion) { + _controller.stop(); + _controller.value = 1; + } else if (!_controller.isAnimating) { + _controller.repeat(reverse: true); + } + return FadeTransition( + opacity: _controller, + child: Card( + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + const _SkeletonBlock(width: 32, height: 32, circle: true), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SkeletonBlock( + width: _relative(context, 0.40), + height: 12, + ), + const SizedBox(height: 6), + _SkeletonBlock( + width: _relative(context, 0.24), + height: 8, + ), + ], + ), + ], + ), + ), + const AspectRatio( + aspectRatio: 4 / 3, + child: ColoredBox(color: AppColors.surfaceTint), + ), + Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SkeletonBlock(width: _relative(context, 0.90), height: 12), + const SizedBox(height: 8), + _SkeletonBlock(width: _relative(context, 0.60), height: 12), + ], + ), + ), + ], + ), + ), + ); + } + + /// 横条宽度按屏宽比例取(规范 40%/24%/90%/60%),免依赖父约束测量。 + double _relative(BuildContext context, double fraction) => + MediaQuery.of(context).size.width * fraction; +} + +class _SkeletonBlock extends StatelessWidget { + const _SkeletonBlock({ + required this.width, + required this.height, + this.circle = false, + }); + + final double width; + final double height; + final bool circle; + + @override + Widget build(BuildContext context) { + return Container( + width: width, + height: height, + decoration: BoxDecoration( + color: AppColors.surfaceTint, + borderRadius: BorderRadius.circular(circle ? width / 2 : 6), + ), + ); + } +} diff --git a/lib/core/widgets/like_button.dart b/lib/core/widgets/like_button.dart new file mode 100644 index 0000000..a159e8e --- /dev/null +++ b/lib/core/widgets/like_button.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; + +/// 点赞 / 收藏按钮语义变体(05 号规范 §3.5:同一组件,图标与语义色 +/// 参数化)。点赞激活 = `error` 图标 + `errorDark` 计数(demo 的 +/// `Colors.red` 3.13:1 修订弃用,D6);收藏激活 = `accentDark` 同色。 +enum LikeButtonVariant { + like( + inactiveIcon: Icons.favorite_border, + activeIcon: Icons.favorite, + activeIconColor: AppColors.error, + activeCountColor: AppColors.errorDark, + ), + bookmark( + inactiveIcon: Icons.bookmark_border, + activeIcon: Icons.bookmark, + activeIconColor: AppColors.accentDark, + activeCountColor: AppColors.accentDark, + ); + + const LikeButtonVariant({ + required this.inactiveIcon, + required this.activeIcon, + required this.activeIconColor, + required this.activeCountColor, + }); + + final IconData inactiveIcon; + final IconData activeIcon; + final Color activeIconColor; + final Color activeCountColor; +} + +/// 点赞/收藏交互钮(05 号规范 §3.5 静态规格):图标 20 + 计数 13/w600, +/// 未激活一律 `inkSoft`(6.59:1)。触控 44×44 由 padding 撑足。 +/// +/// T3-14 只做展示([onPressed] 传 null 即禁用态,仍按正常色渲染计数与 +/// 状态);乐观更新动画与 ToggleSync 接线属 T3-15/16。 +class LikeButton extends StatelessWidget { + const LikeButton({ + required this.variant, + required this.active, + required this.count, + super.key, + this.onPressed, + this.semanticLabel, + }); + + final LikeButtonVariant variant; + final bool active; + final int count; + final VoidCallback? onPressed; + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + final iconColor = active ? variant.activeIconColor : AppColors.inkSoft; + final countColor = active ? variant.activeCountColor : AppColors.inkSoft; + return Semantics( + label: semanticLabel, + button: onPressed != null, + child: InkWell( + onTap: onPressed, + 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: [ + Icon( + active ? variant.activeIcon : variant.inactiveIcon, + size: 20, + color: iconColor, + ), + const SizedBox(width: 4), + Text( + '$count', + style: TextStyle( + color: countColor, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/core/widgets/post_card.dart b/lib/core/widgets/post_card.dart new file mode 100644 index 0000000..4a4730c --- /dev/null +++ b/lib/core/widgets/post_card.dart @@ -0,0 +1,240 @@ +import 'package:flutter/material.dart'; +import 'package:patbond_flutter/core/theme/app_theme.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_display.dart'; +import 'package:patbond_flutter/features/community/community_models.dart'; +import 'package:patbond_flutter/widgets/common.dart'; + +/// 帖子卡三形态(05 号规范 §2.1/§3.1):home 私有 `_PostCard` 的升级 +/// 迁移,按媒体形态分支—— +/// +/// | 形态 | 媒体区 | +/// | --- | --- | +/// | 单图(mediaCount ≤ 1 且有封面) | 通栏出血 4:3 | +/// | 多图(mediaCount > 1) | [PostMediaGrid] 折叠封面(FeedCard 契约只带封面 + 计数),水平 padding 14 | +/// | 纯文字(无封面) | 无媒体区,正文放宽 ≤6 行、15/1.6 | +/// +/// 头部行:作者头像 32 + 名字 14/w700 + 时间 12 `inkSoft`;求助帖 +/// 元信息尾追加 `TagPill(accent)`。降级作者([AuthorSummary.isDegraded]) +/// 渲染占位头像 + 「宠友」。操作行:[LikeButton](点赞 / 收藏)+ 评论 +/// 计数 + 分享,各钮触控 44;互动回调传 null 即纯展示(T3-14 形态, +/// ToggleSync 接线属 T3-15/16)。 +class PostCard extends StatelessWidget { + const PostCard({ + required this.card, + super.key, + this.onTap, + this.onLikeTap, + this.onCommentTap, + this.onBookmarkTap, + this.onShareTap, + this.now, + }); + + final FeedCard card; + + /// 整卡点按(帖子详情入口)。 + final VoidCallback? onTap; + + final VoidCallback? onLikeTap; + final VoidCallback? onCommentTap; + final VoidCallback? onBookmarkTap; + final VoidCallback? onShareTap; + + /// 相对时间的参考时钟(测试注入;缺省取当前时间)。 + final DateTime? now; + + bool get _isTextOnly => card.coverImage == null; + + @override + Widget build(BuildContext context) { + return Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _header(context), + if (card.coverImage != null) + card.mediaCount > 1 + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: PostMediaGrid( + urls: [card.coverImage!.url], + totalCount: card.mediaCount, + ), + ) + : AspectRatio( + aspectRatio: 4 / 3, + child: RemoteImage(url: card.coverImage!.url), + ), + _body(context), + _actionRow(), + ], + ), + ), + ); + } + + Widget _header(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + PetAvatar(size: PetAvatarSize.sm, url: card.author.avatarUrl), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + authorDisplayName(card.author), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Row( + children: [ + Text( + feedRelativeTime(card.publishedAt, now: now), + style: const TextStyle( + color: AppColors.inkSoft, + fontSize: 12, + ), + ), + if (card.category == PostCategory.help) ...[ + const SizedBox(width: 6), + const TagPill('求助', color: AppColors.accent), + ], + ], + ), + ], + ), + ), + ], + ), + ); + } + + Widget _body(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (card.title != null) ...[ + Text( + card.title!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 6), + ], + Text( + card.contentPreview, + // 纯文字帖放宽至 6 行并升字号补偿视觉重量(§2.1)。 + maxLines: _isTextOnly ? 6 : 2, + overflow: TextOverflow.ellipsis, + style: _isTextOnly + ? const TextStyle( + color: AppColors.ink, + fontSize: 15, + height: 1.6, + ) + : Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ); + } + + Widget _actionRow() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2), + child: Row( + children: [ + LikeButton( + variant: LikeButtonVariant.like, + active: card.likedByMe, + count: card.likeCount, + onPressed: onLikeTap, + semanticLabel: '点赞', + ), + const SizedBox(width: 4), + _CommentCount(count: card.commentCount, onPressed: onCommentTap), + const SizedBox(width: 4), + LikeButton( + variant: LikeButtonVariant.bookmark, + active: card.bookmarkedByMe, + count: card.bookmarkCount, + onPressed: onBookmarkTap, + semanticLabel: '收藏', + ), + const Spacer(), + InkWell( + onTap: onShareTap, + borderRadius: BorderRadius.circular(AppRadius.pill), + child: const SizedBox( + width: 44, + height: 44, + child: Icon( + Icons.ios_share_outlined, + size: 20, + color: AppColors.inkSoft, + ), + ), + ), + ], + ), + ); + } +} + +/// 评论计数钮(与 LikeButton 同视觉规格,无激活态)。 +class _CommentCount extends StatelessWidget { + const _CommentCount({required this.count, this.onPressed}); + + final int count; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onPressed, + 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: [ + const Icon( + Icons.chat_bubble_outline, + size: 20, + color: AppColors.inkSoft, + ), + const SizedBox(width: 4), + Text( + '$count', + style: const TextStyle( + color: AppColors.inkSoft, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/core/widgets/post_media_grid.dart b/lib/core/widgets/post_media_grid.dart new file mode 100644 index 0000000..1943b4a --- /dev/null +++ b/lib/core/widgets/post_media_grid.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/widgets/common.dart'; + +/// 图片九宫格展示态(05 号规范 §3.2)。 +/// +/// - 列数规则:2、4 图 → 2 列;3、5–9 图 → 3 列;全部 1:1 cover、 +/// 格间距 4、单格圆角 `sm`(12),`RemoteImage` 复用(loading tint 块 / +/// 失败图标兜底,缓存 key 已剥签名)。 +/// - 超出折叠:最多显 9 格,[totalCount] 超过显示数时末格叠 +/// `ink` 80% scrim + 白字「+N」20/w800(合成最亮白图 7.10:1;60% 档 +/// 3.88:1 不达标弃用,§5.1 精算)。 +/// - 单图折叠形态:Feed 卡片契约只带封面 + mediaCount(FeedCard 裁剪), +/// [urls] 单元素而 [totalCount] > 1 时渲染 4:3 单格 + 右下「+N」角标 +/// 胶囊(同 80% scrim 精算)。 +/// +/// 编辑态(「+」格 / 删除角标)随发布页工单(T3-17)扩展。 +class PostMediaGrid extends StatelessWidget { + const PostMediaGrid({ + required this.urls, + super.key, + this.totalCount, + this.onCellTap, + }) : assert(urls.length > 0, 'PostMediaGrid 至少一张图'); + + /// 可展示的图片 URL(Feed 卡片仅封面一张;详情页全量)。 + final List urls; + + /// 实际总张数(缺省 = urls.length);大于可展示数时渲染「+N」。 + final int? totalCount; + + /// 格子点按(全屏浏览入口,T3-15 详情页接线)。 + final ValueChanged? onCellTap; + + static const _maxCells = 9; + static const _spacing = 4.0; + + int get _effectiveTotal => totalCount ?? urls.length; + + /// 列数规则(§3.2):2、4 → 2 列;其余 → 3 列(1 图不走网格)。 + static int columnsFor(int cellCount) => + (cellCount == 2 || cellCount == 4) ? 2 : 3; + + @override + Widget build(BuildContext context) { + if (urls.length == 1) { + return _CollapsedCover( + url: urls.first, + hiddenCount: _effectiveTotal - 1, + onTap: onCellTap == null ? null : () => onCellTap!(0), + ); + } + + final cellCount = urls.length > _maxCells ? _maxCells : urls.length; + final overflow = _effectiveTotal - cellCount; + final columns = columnsFor(cellCount); + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + mainAxisSpacing: _spacing, + crossAxisSpacing: _spacing, + ), + itemCount: cellCount, + itemBuilder: (context, index) { + final isOverflowCell = overflow > 0 && index == cellCount - 1; + Widget cell = RemoteImage( + url: urls[index], + borderRadius: BorderRadius.circular(AppRadius.sm), + ); + if (isOverflowCell) { + cell = Stack( + fit: StackFit.expand, + children: [ + cell, + DecoratedBox( + decoration: BoxDecoration( + color: AppColors.ink.withAlpha(204), + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: Center( + child: Text( + '+$overflow', + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + ], + ); + } + if (onCellTap != null) { + cell = InkWell( + onTap: () => onCellTap!(index), + borderRadius: BorderRadius.circular(AppRadius.sm), + child: cell, + ); + } + return cell; + }, + ); + } +} + +/// 单图折叠形态:4:3 圆角封面 + 右下「+N」胶囊角标。 +class _CollapsedCover extends StatelessWidget { + const _CollapsedCover({ + required this.url, + required this.hiddenCount, + this.onTap, + }); + + final String url; + final int hiddenCount; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + Widget cover = AspectRatio( + aspectRatio: 4 / 3, + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + url: url, + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + if (hiddenCount > 0) + Positioned( + right: 8, + bottom: 8, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppColors.ink.withAlpha(204), + borderRadius: BorderRadius.circular(AppRadius.pill), + ), + child: Text( + '+$hiddenCount', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ); + if (onTap != null) { + cover = InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.sm), + child: cover, + ); + } + return cover; + } +} diff --git a/lib/features/community/community_display.dart b/lib/features/community/community_display.dart new file mode 100644 index 0000000..d44eb75 --- /dev/null +++ b/lib/features/community/community_display.dart @@ -0,0 +1,33 @@ +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/features/community/community_models.dart'; + +/// 降级作者([AuthorSummary.isDegraded],资料暂不可得或已注销)的 +/// 统一占位名(05 号规范:占位头像 + 默认名,客户端不做昵称回退拼装)。 +const degradedAuthorName = '宠友'; + +/// 作者展示名:正常路径 nickname 恒非空(服务端已回退 username); +/// 降级形态统一 [degradedAuthorName]。 +String authorDisplayName(AuthorSummary author) => + author.nickname ?? degradedAuthorName; + +/// Feed 卡片元信息的相对时间(正典「2 小时前」语言)。 +/// [now] 注入口供测试锁定时钟。 +String feedRelativeTime(DateTime time, {DateTime? now}) { + final reference = now ?? DateTime.now(); + final difference = reference.difference(time.toLocal()); + if (difference.inMinutes < 1) return '刚刚'; + if (difference.inHours < 1) return '${difference.inMinutes} 分钟前'; + if (difference.inDays < 1) return '${difference.inHours} 小时前'; + if (difference.inDays < 7) return '${difference.inDays} 天前'; + final local = time.toLocal(); + if (local.year == reference.year) return '${local.month}月${local.day}日'; + return '${local.year}年${local.month}月${local.day}日'; +} + +/// Feed 加载失败的用户话术(pets 域 petLoadErrorMessage 同构; +/// 服务端原始 message 不上屏)。 +String feedLoadErrorMessage(ApiException? error) => switch (error) { + ApiNetworkException _ => '网络异常,请检查网络后重试', + ApiRateLimitException _ => '请求过于频繁,请稍后再试', + _ => '动态加载失败,请稍后重试', +}; diff --git a/lib/features/community/feed_analytics.dart b/lib/features/community/feed_analytics.dart new file mode 100644 index 0000000..d4e3dbe --- /dev/null +++ b/lib/features/community/feed_analytics.dart @@ -0,0 +1,114 @@ +import 'package:patbond_flutter/analytics/page_view_tracker.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; + +/// feed 域埋点强类型封装(06 号规划 §1.4 字典 v3;后端白名单已随 +/// api dev@8089c06 就绪)。沿 pet_analytics 惯例:枚举编译期锁死, +/// 业务代码禁止手拼事件名与属性;只记行为不记内容(隐私红线:postId +/// 等内容 ID 一律不进 props,曝光去重键只存活于客户端内存)。 + +/// feed_viewed / feed_load_failed 的 feedTab 枚举(06 §1.4)。 +/// M3 T3-14 仅接 home;topic / user_posts / favorites 随后续页面启用。 +enum FeedTab { + home('home'), + topic('topic'), + userPosts('user_posts'), + favorites('favorites'); + + const FeedTab(this.value); + + final String value; +} + +/// feed_load_failed 的 loadType 枚举。 +enum FeedLoadType { + refresh('refresh'), + loadMore('load_more'); + + const FeedLoadType(this.value); + + final String value; +} + +/// feed 加载失败原因(06 v3 失败枚举基底)。网络归并口径同 pet 域: +/// 断网/超时/5xx 均并入 network_error,server_error 保留兜底。 +enum FeedLoadFailureReason { + rateLimited('rate_limited'), + networkError('network_error'), + serverError('server_error'); + + const FeedLoadFailureReason(this.value); + + final String value; +} + +/// 类型化异常 → 失败原因;会话失效返回 null(应用即将回登录页, +/// 不作为 Feed 加载失败上报)。 +FeedLoadFailureReason? feedLoadFailureReasonOf(ApiException error) => + switch (error) { + ApiNetworkException _ => FeedLoadFailureReason.networkError, + ApiRateLimitException _ => FeedLoadFailureReason.rateLimited, + SessionExpiredException _ => null, + _ => FeedLoadFailureReason.serverError, + }; + +class FeedAnalytics { + FeedAnalytics(this._track); + + /// 生产传 `AnalyticsService.trackEvent`,测试传录制桩。 + final TrackEventFn _track; + + /// 一个 Feed 浏览段的聚合曝光(06 §1.2 裁定):离开 Feed(路由跳走 / + /// 退后台)时发一条,携带段内曝光卡片数(≥50% 可见 ≥500ms、按帖去重 + /// 后的**计数**)、翻页数、刷新数与前台停留时长。 + void feedViewed({ + required FeedTab feedTab, + required int durationMs, + required int impressionCount, + required int loadMoreCount, + required int refreshCount, + }) { + _track('feed_viewed', { + 'feedTab': feedTab.value, + 'durationMs': durationMs, + 'impressionCount': impressionCount, + 'loadMoreCount': loadMoreCount, + 'refreshCount': refreshCount, + }); + } + + /// 刷新或翻页请求失败(M3 验收「分页不丢失不重复」的客户端观测点)。 + /// + /// [errorCode] 为业务错误码(网络错误时缺席);[httpStatus] 由五位 + /// 业务码推导(`code ~/ 100`,pet 域同款口径)。 + void feedLoadFailed({ + required FeedTab feedTab, + required FeedLoadType loadType, + required FeedLoadFailureReason reason, + int? errorCode, + }) { + _track('feed_load_failed', { + 'feedTab': feedTab.value, + 'loadType': loadType.value, + 'failureReason': reason.value, + 'errorCode': ?errorCode, + if (errorCode != null && errorCode >= 10000) + 'httpStatus': errorCode ~/ 100, + }); + } + + /// [feedLoadFailed] 的异常直通口:会话失效不上报。 + void feedLoadFailedFrom( + ApiException error, { + required FeedTab feedTab, + required FeedLoadType loadType, + }) { + final reason = feedLoadFailureReasonOf(error); + if (reason == null) return; + feedLoadFailed( + feedTab: feedTab, + loadType: loadType, + reason: reason, + errorCode: error is ApiBusinessException ? error.code : null, + ); + } +} diff --git a/lib/features/community/feed_exposure.dart b/lib/features/community/feed_exposure.dart new file mode 100644 index 0000000..47f0bc7 --- /dev/null +++ b/lib/features/community/feed_exposure.dart @@ -0,0 +1,110 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:patbond_flutter/features/community/feed_analytics.dart'; + +/// 一个「Feed 浏览段」的聚合器(06 号规划 §1.2 裁定的客户端实现)。 +/// +/// 段生命周期由页面驱动:进入 Feed(Tab 激活且分段在 Feed)开段; +/// 离开(切 Tab / 切分段 / 退后台 / 页面销毁)时 [settle] 结算并经 +/// [FeedAnalytics.feedViewed] 发**一条**聚合事件。 +/// +/// - 曝光判定:卡片可见面积 ≥ [visibleThreshold] 且持续 ≥ [dwell], +/// 段内按 postId 去重;postId 只作内存去重键、绝不上报(隐私红线 2), +/// 段结束即弃。 +/// - durationMs 为前台时长(退后台即结算,段天然前台连续),上限 +/// 截断 30 分钟防挂机污染(06 §1.4 实现注意)。 +class FeedViewSegment { + FeedViewSegment({DateTime Function()? now}) + : _now = now ?? DateTime.now, + _settled = false { + _startedAt = _now(); + } + + static const visibleThreshold = 0.5; + static const dwell = Duration(milliseconds: 500); + static const maxDuration = Duration(minutes: 30); + + final DateTime Function() _now; + late final DateTime _startedAt; + + final Set _impressed = {}; + final Map _dwellTimers = {}; + int _loadMoreCount = 0; + int _refreshCount = 0; + bool _settled; + + /// 段内曝光卡片数(已去重;仅测试与结算读取)。 + int get impressionCount => _impressed.length; + + /// 可见性回报:≥50% 起 500ms 驻留计时,跌破或滚出视口即取消; + /// 驻留满即记曝光(去重后不再计时)。 + void updateVisibility(String postId, double visibleFraction) { + if (_settled) return; + if (visibleFraction >= visibleThreshold) { + if (_impressed.contains(postId) || _dwellTimers.containsKey(postId)) { + return; + } + _dwellTimers[postId] = Timer(dwell, () { + _dwellTimers.remove(postId); + _impressed.add(postId); + }); + } else { + _dwellTimers.remove(postId)?.cancel(); + } + } + + /// 用户触发的下拉刷新 / 失败重试(首屏自动预取不计)。 + void recordRefresh() { + if (!_settled) _refreshCount += 1; + } + + /// 触底翻页请求(含尾部失败重试)。 + void recordLoadMore() { + if (!_settled) _loadMoreCount += 1; + } + + /// 结算:取消在途驻留计时并冻结计数;幂等(重复调用返回 null, + /// 保证一段恰好一条 feed_viewed)。 + FeedViewSummary? settle() { + if (_settled) return null; + _settled = true; + for (final timer in _dwellTimers.values) { + timer.cancel(); + } + _dwellTimers.clear(); + final elapsed = _now().difference(_startedAt); + return FeedViewSummary( + durationMs: min(elapsed.inMilliseconds, maxDuration.inMilliseconds), + impressionCount: _impressed.length, + loadMoreCount: _loadMoreCount, + refreshCount: _refreshCount, + ); + } +} + +/// [FeedViewSegment.settle] 的结算结果(feed_viewed 专有属性)。 +class FeedViewSummary { + const FeedViewSummary({ + required this.durationMs, + required this.impressionCount, + required this.loadMoreCount, + required this.refreshCount, + }); + + final int durationMs; + final int impressionCount; + final int loadMoreCount; + final int refreshCount; + + /// 结算即上报的便捷口。 + void report(FeedAnalytics analytics, {FeedTab feedTab = FeedTab.home}) { + analytics.feedViewed( + feedTab: feedTab, + durationMs: durationMs, + impressionCount: impressionCount, + loadMoreCount: loadMoreCount, + refreshCount: refreshCount, + ); + } +} diff --git a/lib/features/home/home_page.dart b/lib/features/home/home_page.dart index 5ad80ba..fba66f8 100644 --- a/lib/features/home/home_page.dart +++ b/lib/features/home/home_page.dart @@ -1,42 +1,253 @@ import 'package:flutter/material.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart'; +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/data/demo_data.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_models.dart'; +import 'package:patbond_flutter/features/community/feed_analytics.dart'; +import 'package:patbond_flutter/features/community/feed_exposure.dart'; import 'package:patbond_flutter/models/models.dart'; import 'package:patbond_flutter/state/app_state.dart'; import 'package:patbond_flutter/widgets/common.dart'; enum HomeSegment { feed, services } +/// 首页 Tab:Feed 段自 T3-14 起消费 [CommunityController] 真实数据 +/// (四态 + 游标翻页 + 聚合曝光埋点);天气条/问候卡/搜索/服务段与 +/// `_StoryRow` 家具保留 demo 形态(03 号评估 §1.1 判定)。 class HomePage extends StatefulWidget { const HomePage({ required this.appState, - required this.onOpenPost, + required this.communityController, required this.onOpenServices, required this.onOpenCreate, super.key, + this.feedAnalytics, + this.isActive = true, }); final AppState appState; - final ValueChanged onOpenPost; + + /// Feed 数据源(Tab 级单例,app.dart 装配注入)。 + final CommunityController communityController; + final ValueChanged onOpenServices; final VoidCallback onOpenCreate; + /// feed 域埋点(feed_viewed 聚合曝光 + feed_load_failed)。 + final FeedAnalytics? feedAnalytics; + + /// 首页 Tab 是否为当前可见 Tab(IndexedStack 各 Tab 常驻构建, + /// 由主壳传入以驱动浏览段开/结算)。 + final bool isActive; + @override State createState() => _HomePageState(); } -class _HomePageState extends State { +class _HomePageState extends State with WidgetsBindingObserver { HomeSegment segment = HomeSegment.feed; String query = ''; String sort = '综合'; - List get filteredPosts { + /// 当前 Feed 浏览段(不在 Feed 面上时为 null)。 + FeedViewSegment? _viewSegment; + + /// 曝光扫描用:postId → 卡片 GlobalKey。 + final Map _cardKeys = {}; + final GlobalKey _listKey = GlobalKey(); + + AppLifecycleState _lastLifecycle = AppLifecycleState.resumed; + + CommunityController get _feed => widget.communityController; + + bool get _feedSurfaceVisible => + widget.isActive && segment == HomeSegment.feed; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + // 主壳挂载即预取(pets 先例);重登后控制器已 reset 回 initial。 + // 首屏自动预取不计入浏览段 refreshCount(非用户动作)。 + if (_feed.phase == FeedPhase.initial) { + _refreshFeed(userInitiated: false); + } + if (_feedSurfaceVisible) _startSegment(); + } + + @override + void didUpdateWidget(HomePage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.isActive != widget.isActive) { + widget.isActive ? _maybeStartSegment() : _settleSegment(); + } + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // 退后台即结算浏览段(06 §1.2:feed_viewed 触发点之一);只在离开 + // resumed 的第一次变更结算(inactive/hidden/paused 级联不重复)。 + // 回前台若仍在 Feed 面上则开新段。 + if (state == AppLifecycleState.resumed) { + _lastLifecycle = state; + _maybeStartSegment(); + return; + } + if (_lastLifecycle == AppLifecycleState.resumed) { + _settleSegment(); + } + _lastLifecycle = state; + } + + @override + void dispose() { + _settleSegment(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + // ---- 浏览段开/结算 ---- + + void _startSegment() { + _viewSegment ??= FeedViewSegment(); + _scheduleVisibilityScan(); + } + + void _maybeStartSegment() { + if (_feedSurfaceVisible && _lastLifecycle == AppLifecycleState.resumed) { + _startSegment(); + } + } + + void _settleSegment() { + final summary = _viewSegment?.settle(); + _viewSegment = null; + if (summary != null && widget.feedAnalytics != null) { + summary.report(widget.feedAnalytics!); + } + } + + void _onSegmentChanged(HomeSegment value) { + if (value == segment) return; + setState(() => segment = value); + value == HomeSegment.feed ? _maybeStartSegment() : _settleSegment(); + } + + // ---- 曝光可见性扫描 ---- + + bool _scanScheduled = false; + + /// 帧末扫描(滚动通知发生在本帧布局前,同步读 RenderBox 是旧位置; + /// 统一调度到 post-frame,一帧至多一次)。 + void _scheduleVisibilityScan() { + if (_scanScheduled) return; + _scanScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _scanScheduled = false; + _scanVisibility(); + }); + } + + /// 以列表视口与各卡片 RenderBox 的纵向交叠比例回报可见性 + /// (≥50% 且驻留 ≥500ms 才计曝光,判定在 [FeedViewSegment] 内)。 + void _scanVisibility() { + final viewSegment = _viewSegment; + if (viewSegment == null || !mounted) return; + final listBox = _listKey.currentContext?.findRenderObject() as RenderBox?; + if (listBox == null || !listBox.attached) return; + final viewportTop = listBox.localToGlobal(Offset.zero).dy; + final viewportBottom = viewportTop + listBox.size.height; + for (final entry in _cardKeys.entries) { + final box = entry.value.currentContext?.findRenderObject() as RenderBox?; + var fraction = 0.0; + if (box != null && box.attached && box.hasSize && box.size.height > 0) { + final top = box.localToGlobal(Offset.zero).dy; + final bottom = top + box.size.height; + final visible = + bottom.clamp(viewportTop, viewportBottom) - + top.clamp(viewportTop, viewportBottom); + fraction = visible / box.size.height; + } + viewSegment.updateVisibility(entry.key, fraction); + } + } + + bool _onScrollNotification(ScrollNotification notification) { + if (segment != HomeSegment.feed) return false; + if (notification is ScrollUpdateNotification || + notification is ScrollEndNotification) { + _scheduleVisibilityScan(); + // 滚动近底触发翻页(余量 400 提前预取);失败态不自动重试, + // 只走尾部重试条显式点按(避免滚动风暴反复打失败端点)。 + if (notification.metrics.extentAfter < 400 && + _feed.loadMorePhase == LoadMorePhase.idle) { + _loadMoreFeed(); + } + } + return false; + } + + // ---- 数据加载与失败埋点 ---- + + Future _refreshFeed({required bool userInitiated}) async { + if (userInitiated) _viewSegment?.recordRefresh(); + await _feed.refresh(); + final error = _feed.refreshError ?? _feed.lastError; + if (error != null) { + widget.feedAnalytics?.feedLoadFailedFrom( + error, + feedTab: FeedTab.home, + loadType: FeedLoadType.refresh, + ); + } + // 刷新失败但旧列表被保留:SnackBar 轻提示(首屏失败走 error 态横幅)。 + if (_feed.refreshError != null && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(feedLoadErrorMessage(_feed.refreshError))), + ); + } + } + + Future _loadMoreFeed() async { + if (_feed.phase != FeedPhase.ready || + !_feed.hasMore || + _feed.loadMorePhase == LoadMorePhase.loading) { + return; + } + _viewSegment?.recordLoadMore(); + await _feed.loadMore(); + final error = _feed.loadMoreError; + if (error != null) { + widget.feedAnalytics?.feedLoadFailedFrom( + error, + feedTab: FeedTab.home, + loadType: FeedLoadType.loadMore, + ); + } + } + + /// T3-14 取舍:详情页数据层重写属 T3-15,demo 详情页无法按服务端 + /// postId 渲染真实帖,故整卡点按先提示、互动按钮为纯展示禁用态。 + void _showDetailPending() { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(const SnackBar(content: Text('帖子详情正在接入真实数据,敬请期待'))); + } + + /// 搜索词过滤(demo 交互保留:只过滤已加载的多页缓存,不发检索请求; + /// 契约 v1.3.0 无搜索端点)。 + List get _visibleCards { final keyword = query.trim().toLowerCase(); - if (keyword.isEmpty) return widget.appState.posts; - return widget.appState.posts.where((post) { - return post.content.toLowerCase().contains(keyword) || - post.authorName.toLowerCase().contains(keyword) || - post.tags.any((tag) => tag.toLowerCase().contains(keyword)); + if (keyword.isEmpty) return _feed.feed; + return _feed.feed.where((card) { + return card.contentPreview.toLowerCase().contains(keyword) || + (card.title?.toLowerCase().contains(keyword) ?? false) || + authorDisplayName(card.author).toLowerCase().contains(keyword); }).toList(); } @@ -95,114 +306,232 @@ class _HomePageState extends State { @override Widget build(BuildContext context) { - return RefreshIndicator( - onRefresh: () async => - Future.delayed(const Duration(milliseconds: 500)), - child: ListView( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 28), - children: [ - _WeatherStatusBar( - weather: widget.appState.locationWeather, - onAreaTap: selectArea, - onWeatherTap: showWeatherDetails, - ), - const SizedBox(height: 12), - _PetGreetingCard( - greeting: greeting, - petName: widget.appState.pet.name, - petAvatar: widget.appState.pet.avatarUrl, - advice: widget.appState.locationWeather.petAdvice, - ), - const SizedBox(height: 14), - TextField( - onChanged: (value) => setState(() => query = value), - decoration: const InputDecoration( - hintText: '搜索动态、医院、美容或遛狗服务…', - prefixIcon: Icon(Icons.search), - ), - ), - const SizedBox(height: 14), - SegmentedButton( - segments: const [ - ButtonSegment( - value: HomeSegment.feed, - icon: Icon(Icons.forum_outlined), - label: Text('社区动态'), - ), - ButtonSegment( - value: HomeSegment.services, - icon: Icon(Icons.storefront_outlined), - label: Text('本地服务'), - ), - ], - selected: {segment}, - showSelectedIcon: false, - onSelectionChanged: (value) { - setState(() => segment = value.first); - }, - ), - const SizedBox(height: 18), - if (segment == HomeSegment.feed) ...[ - _StoryRow(onCreate: widget.onOpenCreate), - const SizedBox(height: 18), - _PromoCard(onTap: () => widget.onOpenServices(true)), - const SizedBox(height: 18), - if (filteredPosts.isEmpty) - const EmptyState(message: '没有找到相关动态') - else - ...filteredPosts.map( - (post) => Padding( - padding: const EdgeInsets.only(bottom: 16), - child: _PostCard( - post: post, - onTap: () => widget.onOpenPost(post), - onLike: () => widget.appState.updatePost( - post.copyWith( - hasLiked: !post.hasLiked, - likes: post.hasLiked ? post.likes - 1 : post.likes + 1, + return ListenableBuilder( + listenable: _feed, + builder: (context, _) { + // ready 后(含翻页追加)补一次可见性扫描:无滚动也能记首屏曝光。 + if (_viewSegment != null && _feed.phase == FeedPhase.ready) { + _scheduleVisibilityScan(); + } + return NotificationListener( + onNotification: _onScrollNotification, + child: RefreshIndicator( + onRefresh: () => segment == HomeSegment.feed + ? _refreshFeed(userInitiated: true) + : Future.delayed(const Duration(milliseconds: 500)), + child: ListView( + key: _listKey, + padding: const EdgeInsets.fromLTRB(16, 12, 16, 28), + children: [ + _WeatherStatusBar( + weather: widget.appState.locationWeather, + onAreaTap: selectArea, + onWeatherTap: showWeatherDetails, + ), + const SizedBox(height: 12), + _PetGreetingCard( + greeting: greeting, + petName: widget.appState.pet.name, + petAvatar: widget.appState.pet.avatarUrl, + advice: widget.appState.locationWeather.petAdvice, + ), + const SizedBox(height: 14), + TextField( + onChanged: (value) => setState(() => query = value), + decoration: const InputDecoration( + hintText: '搜索动态、医院、美容或遛狗服务…', + prefixIcon: Icon(Icons.search), + ), + ), + const SizedBox(height: 14), + SegmentedButton( + segments: const [ + ButtonSegment( + value: HomeSegment.feed, + icon: Icon(Icons.forum_outlined), + label: Text('社区动态'), + ), + ButtonSegment( + value: HomeSegment.services, + icon: Icon(Icons.storefront_outlined), + label: Text('本地服务'), + ), + ], + selected: {segment}, + showSelectedIcon: false, + onSelectionChanged: (value) => _onSegmentChanged(value.first), + ), + const SizedBox(height: 18), + if (segment == HomeSegment.feed) ...[ + _StoryRow(onCreate: widget.onOpenCreate), + const SizedBox(height: 18), + _PromoCard(onTap: () => widget.onOpenServices(true)), + const SizedBox(height: 18), + ..._feedSection(), + ] else ...[ + _CategoryGrid(onTap: (_) => widget.onOpenServices(false)), + const SizedBox(height: 18), + Row( + children: [ + Text( + '附近推荐', + style: Theme.of(context).textTheme.titleLarge, + ), + const Spacer(), + for (final item in ['综合', '距离', '评分']) + Padding( + padding: const EdgeInsets.only(left: 6), + child: ChoiceChip( + label: Text(item), + selected: sort == item, + onSelected: (_) => setState(() => sort = item), + ), + ), + ], + ), + const SizedBox(height: 12), + if (filteredProviders.isEmpty) + const EmptyState(message: '没有找到相关服务') + else + ...filteredProviders.map( + (provider) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _ProviderCompactCard( + provider: provider, + onTap: () => widget.onOpenServices( + provider.kind == ProviderKind.personal, + ), + ), ), ), - ), - ), - ), - ] else ...[ - _CategoryGrid(onTap: (_) => widget.onOpenServices(false)), - const SizedBox(height: 18), - Row( - children: [ - Text('附近推荐', style: Theme.of(context).textTheme.titleLarge), - const Spacer(), - for (final item in ['综合', '距离', '评分']) - Padding( - padding: const EdgeInsets.only(left: 6), - child: ChoiceChip( - label: Text(item), - selected: sort == item, - onSelected: (_) => setState(() => sort = item), - ), - ), + ], ], ), - const SizedBox(height: 12), - if (filteredProviders.isEmpty) - const EmptyState(message: '没有找到相关服务') - else - ...filteredProviders.map( - (provider) => Padding( - padding: const EdgeInsets.only(bottom: 12), - child: _ProviderCompactCard( - provider: provider, - onTap: () => widget.onOpenServices( - provider.kind == ProviderKind.personal, - ), - ), - ), - ), - ], - ], - ), + ), + ); + }, ); } + + // ---- Feed 段四态渲染 ---- + + List _feedSection() { + switch (_feed.phase) { + case FeedPhase.initial: + case FeedPhase.loading: + return const [ + FeedSkeleton(), + SizedBox(height: 16), + FeedSkeleton(), + SizedBox(height: 16), + FeedSkeleton(), + ]; + case FeedPhase.error: + return [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Column( + children: [ + InlineErrorBanner( + message: feedLoadErrorMessage(_feed.lastError), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () => _refreshFeed(userInitiated: true), + child: const Text('重试'), + ), + ], + ), + ), + ]; + case FeedPhase.ready: + if (_feed.feed.isEmpty) { + return [ + EmptyStateIllustration( + icon: Icons.forum_outlined, + title: '还没有动态', + description: '关注的毛孩子们还没发帖,去逛逛话题吧', + ctaLabel: '发布第一条', + onCtaPressed: widget.onOpenCreate, + ), + ]; + } + final cards = _visibleCards; + _pruneCardKeys(cards); + if (cards.isEmpty) { + return const [EmptyState(message: '没有找到相关动态')]; + } + return [ + for (final card in cards) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: KeyedSubtree( + key: _cardKeys.putIfAbsent(card.id, GlobalKey.new), + child: PostCard(card: card, onTap: _showDetailPending), + ), + ), + // 搜索过滤中不渲染尾部(过滤只作用于已加载页,翻页语义混淆)。 + if (query.trim().isEmpty) _feedTail(), + ]; + } + } + + /// 卡片 key 表随当前列表收敛(刷新整体替换后旧帖的驻留计时作废)。 + void _pruneCardKeys(List cards) { + final alive = {for (final card in cards) card.id}; + _cardKeys.removeWhere((id, _) { + if (alive.contains(id)) return false; + _viewSegment?.updateVisibility(id, 0); + return true; + }); + } + + /// 尾部三态:加载中转圈 / 失败重试 / 到底「没有更多了」。 + Widget _feedTail() { + switch (_feed.loadMorePhase) { + case LoadMorePhase.loading: + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: AppColors.primary, + ), + ), + ), + ); + case LoadMorePhase.error: + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + children: [ + Text( + feedLoadErrorMessage(_feed.loadMoreError), + style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), + ), + TextButton( + onPressed: _loadMoreFeed, + child: const Text('加载失败,点此重试'), + ), + ], + ), + ); + case LoadMorePhase.idle: + if (_feed.hasMore) return const SizedBox(height: 24); + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center( + child: Text( + '没有更多了', + style: TextStyle(color: AppColors.inkSoft, fontSize: 12), + ), + ), + ); + } + } } class _WeatherStatusBar extends StatelessWidget { @@ -661,103 +990,6 @@ class _PromoCard extends StatelessWidget { } } -class _PostCard extends StatelessWidget { - const _PostCard({ - required this.post, - required this.onTap, - required this.onLike, - }); - - final PostModel post; - final VoidCallback onTap; - final VoidCallback onLike; - - @override - Widget build(BuildContext context) { - return Card( - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onTap, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(14), - child: Row( - children: [ - RemoteImage( - url: post.authorAvatar, - width: 38, - height: 38, - borderRadius: BorderRadius.circular(19), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - post.authorName, - style: const TextStyle(fontWeight: FontWeight.w700), - ), - Text( - '${post.time} · ${post.breedTag}', - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - ), - const Icon(Icons.more_horiz, color: AppColors.muted), - ], - ), - ), - AspectRatio( - aspectRatio: 4 / 3, - child: RemoteImage(url: post.mainImage), - ), - Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - post.content, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 12), - Row( - children: [ - ActionChip( - avatar: Icon( - post.hasLiked - ? Icons.favorite - : Icons.favorite_border, - size: 17, - color: post.hasLiked ? Colors.red : AppColors.primary, - ), - label: Text('${post.likes}'), - onPressed: onLike, - ), - const SizedBox(width: 8), - Chip( - avatar: const Icon(Icons.chat_bubble_outline, size: 16), - label: Text('${post.comments.length}'), - ), - const Spacer(), - const Icon(Icons.share_outlined, color: AppColors.muted), - ], - ), - ], - ), - ), - ], - ), - ), - ); - } -} - class _CategoryGrid extends StatelessWidget { const _CategoryGrid({required this.onTap}); diff --git a/lib/features/main/main_shell_page.dart b/lib/features/main/main_shell_page.dart index 0485b08..ac8d766 100644 --- a/lib/features/main/main_shell_page.dart +++ b/lib/features/main/main_shell_page.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; 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/feed_analytics.dart'; import 'package:patbond_flutter/features/create/create_page.dart'; import 'package:patbond_flutter/features/home/home_page.dart'; import 'package:patbond_flutter/features/pets/health_record_analytics.dart'; @@ -19,9 +21,11 @@ class MainShellPage extends StatefulWidget { const MainShellPage({ required this.appState, required this.petsController, + required this.communityController, super.key, this.petAnalytics, this.healthRecordAnalytics, + this.feedAnalytics, this.pageViewTracker, this.onLogout, }); @@ -31,12 +35,18 @@ class MainShellPage extends StatefulWidget { /// 宠物档案状态(T2-11 拆出的独立 pets feature;档案 Tab 数据源)。 final PetsController petsController; + /// 社区状态(T3-12 数据层;首页 Feed segment 数据源,T3-14 接线)。 + final CommunityController communityController; + /// pet 域埋点强类型封装(建宠漏斗三事件)。 final PetAnalytics? petAnalytics; /// health_record 域埋点强类型封装(T2-13 创建漏斗三事件 + viewed)。 final HealthRecordAnalytics? healthRecordAnalytics; + /// feed 域埋点(T3-14 聚合曝光 + 加载失败)。 + final FeedAnalytics? feedAnalytics; + /// Tab 曝光补点(IndexedStack 切换不产生路由事件,03 号评估 §3.2)。 final PageViewTracker? pageViewTracker; @@ -107,7 +117,9 @@ class _MainShellPageState extends State { final pages = [ HomePage( appState: widget.appState, - onOpenPost: openPost, + communityController: widget.communityController, + feedAnalytics: widget.feedAnalytics, + isActive: currentIndex == 0, onOpenServices: openServices, onOpenCreate: () => selectTab(1), ), diff --git a/lib/widgets/common.dart b/lib/widgets/common.dart index 45ee16e..6df598b 100644 --- a/lib/widgets/common.dart +++ b/lib/widgets/common.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:patbond_flutter/core/network/signed_network_image.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; class RemoteImage extends StatelessWidget { @@ -21,8 +22,10 @@ class RemoteImage extends StatelessWidget { Widget build(BuildContext context) { return ClipRRect( borderRadius: borderRadius, - child: Image.network( - url, + // 缓存 key 剥离预签名参数(T3-14):媒体 URL 每次响应现签, + // 按完整 URL 缓存会同图重复下载。 + child: Image( + image: SignedNetworkImage(url), width: width, height: height, fit: fit, diff --git a/pubspec.lock b/pubspec.lock index 5fde8e9..f082fbd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -174,6 +174,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_image_compress: dependency: "direct main" description: @@ -296,6 +301,11 @@ packages: description: flutter source: sdk version: "0.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" hooks: dependency: transitive description: @@ -384,6 +394,11 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" jni: dependency: transitive description: @@ -584,6 +599,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + process: + dependency: transitive + description: + name: process + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" + url: "https://pub.dev" + source: hosted + version: "5.0.6" pub_semver: dependency: transitive description: @@ -693,6 +716,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" term_glyph: dependency: transitive description: @@ -749,6 +780,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7b4f8b2..7d96c66 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -47,6 +47,9 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + # T3-14 compose 真链路桌面实测入口(integration_test/,环境变量门控)。 + integration_test: + sdk: flutter # 定时冲刷/退避测试的假时钟驱动(flutter_test 传递依赖显式声明)。 fake_async: ^1.3.3 diff --git a/test/core/network/signed_network_image_test.dart b/test/core/network/signed_network_image_test.dart new file mode 100644 index 0000000..aeb441d --- /dev/null +++ b/test/core/network/signed_network_image_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/network/signed_network_image.dart'; + +void main() { + group('presignedImageCacheKey', () { + test('剥离全部 X-Amz-* 签名参数(大小写不敏感)', () { + const url = + 'http://127.0.0.1:9000/patbond-media/post_image/a-1.jpg' + '?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=cred' + '&X-Amz-Date=20260909T000000Z&X-Amz-Expires=3600' + '&X-Amz-SignedHeaders=host&x-amz-signature=deadbeef'; + expect( + presignedImageCacheKey(url), + 'http://127.0.0.1:9000/patbond-media/post_image/a-1.jpg', + ); + }); + + test('保留非签名 query 参数', () { + const url = 'https://cdn.example.com/p.jpg?w=300&X-Amz-Signature=sig'; + expect( + presignedImageCacheKey(url), + 'https://cdn.example.com/p.jpg?w=300', + ); + }); + + test('无签名参数原样返回', () { + expect( + presignedImageCacheKey('https://cdn.example.com/p.jpg?w=300&q=80'), + 'https://cdn.example.com/p.jpg?w=300&q=80', + ); + expect( + presignedImageCacheKey('https://cdn.example.com/p.jpg'), + 'https://cdn.example.com/p.jpg', + ); + }); + + test('非法 URL 不炸,退回原串', () { + expect(presignedImageCacheKey('::not a url::'), '::not a url::'); + }); + }); + + group('SignedNetworkImage', () { + test('同对象不同签名 → 判等(命中同一 ImageCache 条目)', () { + final first = SignedNetworkImage( + 'http://127.0.0.1:9000/m/a.jpg?X-Amz-Signature=sig1&X-Amz-Date=d1', + ); + final second = SignedNetworkImage( + 'http://127.0.0.1:9000/m/a.jpg?X-Amz-Signature=sig2&X-Amz-Date=d2', + ); + expect(first, second); + expect(first.hashCode, second.hashCode); + }); + + test('不同对象 → 不判等', () { + final first = SignedNetworkImage( + 'http://127.0.0.1:9000/m/a.jpg?X-Amz-Signature=sig', + ); + final second = SignedNetworkImage( + 'http://127.0.0.1:9000/m/b.jpg?X-Amz-Signature=sig', + ); + expect(first, isNot(second)); + }); + + test('scale 参与判等', () { + final first = SignedNetworkImage('http://h/m/a.jpg'); + final second = SignedNetworkImage('http://h/m/a.jpg', scale: 2); + expect(first, isNot(second)); + }); + }); +} diff --git a/test/core/widgets/post_card_test.dart b/test/core/widgets/post_card_test.dart new file mode 100644 index 0000000..9133bb8 --- /dev/null +++ b/test/core/widgets/post_card_test.dart @@ -0,0 +1,102 @@ +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/like_button.dart'; +import 'package:patbond_flutter/core/widgets/post_card.dart'; +import 'package:patbond_flutter/core/widgets/post_media_grid.dart'; +import 'package:patbond_flutter/features/community/community_models.dart'; +import 'package:patbond_flutter/widgets/common.dart'; + +import '../../helpers/community_test_helpers.dart'; + +FeedCard cardFrom(Map overrides) => + FeedCard.fromJson({...sampleFeedCardJson(), ...overrides}); + +Widget wrap(Widget child) => MaterialApp( + theme: buildAppTheme(), + home: Scaffold(body: SingleChildScrollView(child: child)), +); + +void main() { + testWidgets('单图形态:通栏 4:3 出血,不走九宫格,预览 2 行', (tester) async { + final card = cardFrom({'mediaCount': 1}); + await tester.pumpWidget(wrap(PostCard(card: card))); + + expect(find.byType(AspectRatio), findsOneWidget); + expect(find.byType(PostMediaGrid), findsNothing); + final preview = tester.widget(find.text('晒了一下午太阳。')); + expect(preview.maxLines, 2); + }); + + testWidgets('多图形态:九宫格折叠封面 + 「+N」角标(契约只带封面与计数)', (tester) async { + final card = cardFrom({'mediaCount': 5}); + await tester.pumpWidget(wrap(PostCard(card: card))); + + expect(find.byType(PostMediaGrid), findsOneWidget); + expect(find.text('+4'), findsOneWidget); + }); + + testWidgets('纯文字形态:无媒体区,正文放宽 6 行、15/1.6', (tester) async { + final card = cardFrom({'coverImage': null, 'mediaCount': 0}); + await tester.pumpWidget(wrap(PostCard(card: card))); + + expect(find.byType(PostMediaGrid), findsNothing); + expect(find.byType(AspectRatio), findsNothing); + final preview = tester.widget(find.text('晒了一下午太阳。')); + expect(preview.maxLines, 6); + expect(preview.style?.fontSize, 15); + }); + + testWidgets('头部行:作者名 + 相对时间;求助帖追加 accent 标', (tester) async { + final card = cardFrom({ + 'category': 'help', + 'publishedAt': DateTime.now() + .subtract(const Duration(hours: 2)) + .toUtc() + .toIso8601String(), + }); + await tester.pumpWidget(wrap(PostCard(card: card))); + + expect(find.text('毛毛的铲屎官'), findsOneWidget); + expect(find.text('2 小时前'), findsOneWidget); + expect(find.widgetWithText(TagPill, '求助'), findsOneWidget); + }); + + testWidgets('降级作者:占位头像 + 「宠友」默认名', (tester) async { + final card = cardFrom({ + 'author': sampleAuthorJson(nickname: null, avatarUrl: null), + }); + await tester.pumpWidget(wrap(PostCard(card: card))); + + expect(find.text('宠友'), findsOneWidget); + expect(find.text('毛毛的铲屎官'), findsNothing); + }); + + testWidgets('操作行:计数与激活态展示;T3-14 纯展示点按无副作用', (tester) async { + final card = cardFrom({ + 'likedByMe': true, + 'likeCount': 6, + 'bookmarkedByMe': false, + 'bookmarkCount': 2, + 'commentCount': 3, + }); + await tester.pumpWidget(wrap(PostCard(card: card))); + + expect(find.text('6'), findsOneWidget); + expect(find.text('2'), findsOneWidget); + expect(find.text('3'), findsOneWidget); + // 点赞激活:实心 favorite + error 色(Colors.red 修订 D6)。 + final likeIcon = tester.widget(find.byIcon(Icons.favorite)); + expect(likeIcon.color, AppColors.error); + // 收藏未激活:描边 + inkSoft。 + final bookmarkIcon = tester.widget( + find.byIcon(Icons.bookmark_border), + ); + expect(bookmarkIcon.color, AppColors.inkSoft); + + // 回调未接(null):点按不抛错、无状态变化。 + await tester.tap(find.byType(LikeButton).first, warnIfMissed: false); + await tester.pump(); + expect(find.byIcon(Icons.favorite), findsOneWidget); + }); +} diff --git a/test/core/widgets/post_media_grid_test.dart b/test/core/widgets/post_media_grid_test.dart new file mode 100644 index 0000000..d034439 --- /dev/null +++ b/test/core/widgets/post_media_grid_test.dart @@ -0,0 +1,100 @@ +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/feed_skeleton.dart'; +import 'package:patbond_flutter/core/widgets/post_media_grid.dart'; +import 'package:patbond_flutter/widgets/common.dart'; + +Widget wrap(Widget child, {bool disableAnimations = false}) => MaterialApp( + theme: buildAppTheme(), + home: MediaQuery( + data: MediaQueryData(disableAnimations: disableAnimations), + child: Scaffold(body: SingleChildScrollView(child: child)), + ), +); + +List urls(int count) => List.generate( + count, + (i) => 'https://minio.local/p$i.jpg?X-Amz-Signature=s', +); + +void main() { + group('PostMediaGrid 列数规则(05 §3.2)', () { + test('2、4 图 → 2 列;3、5–9 图 → 3 列', () { + expect(PostMediaGrid.columnsFor(2), 2); + expect(PostMediaGrid.columnsFor(4), 2); + expect(PostMediaGrid.columnsFor(3), 3); + for (var n = 5; n <= 9; n++) { + expect(PostMediaGrid.columnsFor(n), 3); + } + }); + + testWidgets('4 图渲染 4 格网格', (tester) async { + await tester.pumpWidget(wrap(PostMediaGrid(urls: urls(4)))); + expect(find.byType(RemoteImage), findsNWidgets(4)); + expect(find.byType(GridView), findsOneWidget); + }); + + testWidgets('超 9 图折叠:显 9 格,末格 ink 80% scrim + 「+N」', (tester) async { + await tester.pumpWidget( + wrap(PostMediaGrid(urls: urls(12), totalCount: 12)), + ); + expect(find.byType(RemoteImage), findsNWidgets(9)); + expect(find.text('+3'), findsOneWidget); + final scrim = tester + .widgetList(find.byType(DecoratedBox)) + .map((w) => w.decoration) + .whereType() + .where((d) => d.color == AppColors.ink.withAlpha(204)); + expect(scrim, isNotEmpty); + }); + + testWidgets('单图折叠形态(Feed 封面 + 总数):4:3 单格 + 角标胶囊', (tester) async { + await tester.pumpWidget( + wrap(PostMediaGrid(urls: urls(1), totalCount: 3)), + ); + expect(find.byType(GridView), findsNothing); + expect(find.byType(AspectRatio), findsOneWidget); + expect(find.text('+2'), findsOneWidget); + }); + + testWidgets('单图无折余不出角标', (tester) async { + await tester.pumpWidget(wrap(PostMediaGrid(urls: urls(1)))); + expect(find.textContaining('+'), findsNothing); + }); + }); + + group('FeedSkeleton(05 §3.7)', () { + testWidgets('骨架单元:头部圆 + 4:3 块 + 正文横条,呼吸动效运行', (tester) async { + await tester.pumpWidget(wrap(const FeedSkeleton())); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byType(AspectRatio), findsOneWidget); + final fade = tester.widget( + find + .descendant( + of: find.byType(FeedSkeleton), + matching: find.byType(FadeTransition), + ) + .first, + ); + final controller = fade.opacity as AnimationController; + expect(controller.isAnimating, isTrue); + }); + + testWidgets('系统减弱动态效果:动画静止在 1.0(pumpAndSettle 可收敛)', (tester) async { + await tester.pumpWidget( + wrap(const FeedSkeleton(), disableAnimations: true), + ); + await tester.pumpAndSettle(); + final fade = tester.widget( + find + .descendant( + of: find.byType(FeedSkeleton), + matching: find.byType(FadeTransition), + ) + .first, + ); + expect(fade.opacity.value, 1.0); + }); + }); +} diff --git a/test/features/community/feed_analytics_test.dart b/test/features/community/feed_analytics_test.dart new file mode 100644 index 0000000..bacc5a3 --- /dev/null +++ b/test/features/community/feed_analytics_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/features/community/feed_analytics.dart'; + +void main() { + late List<(String, Map?)> events; + late FeedAnalytics analytics; + + setUp(() { + events = []; + analytics = FeedAnalytics( + (name, [props]) async => events.add((name, props)), + ); + }); + + test('feed_viewed:五个专有属性逐一上报(06 §1.4 白名单)', () { + analytics.feedViewed( + feedTab: FeedTab.home, + durationMs: 12345, + impressionCount: 7, + loadMoreCount: 2, + refreshCount: 1, + ); + + expect(events, hasLength(1)); + expect(events.single.$1, 'feed_viewed'); + expect(events.single.$2, { + 'feedTab': 'home', + 'durationMs': 12345, + 'impressionCount': 7, + 'loadMoreCount': 2, + 'refreshCount': 1, + }); + }); + + test('feed_load_failed:业务码带 errorCode 并推导 httpStatus', () { + analytics.feedLoadFailed( + feedTab: FeedTab.home, + loadType: FeedLoadType.loadMore, + reason: FeedLoadFailureReason.serverError, + errorCode: 40403, + ); + + expect(events.single.$1, 'feed_load_failed'); + expect(events.single.$2, { + 'feedTab': 'home', + 'loadType': 'load_more', + 'failureReason': 'server_error', + 'errorCode': 40403, + 'httpStatus': 404, + }); + }); + + test('feed_load_failed:网络错误缺席 errorCode/httpStatus', () { + analytics.feedLoadFailedFrom( + const ApiNetworkException(), + feedTab: FeedTab.home, + loadType: FeedLoadType.refresh, + ); + + expect(events.single.$2, { + 'feedTab': 'home', + 'loadType': 'refresh', + 'failureReason': 'network_error', + }); + }); + + test('异常映射:限流/网络/业务兜底;会话失效不上报', () { + expect( + feedLoadFailureReasonOf(const ApiRateLimitException()), + FeedLoadFailureReason.rateLimited, + ); + expect( + feedLoadFailureReasonOf(const ApiNetworkException()), + FeedLoadFailureReason.networkError, + ); + expect( + feedLoadFailureReasonOf( + const ApiBusinessException(code: 40000, message: 'x'), + ), + FeedLoadFailureReason.serverError, + ); + expect(feedLoadFailureReasonOf(const SessionExpiredException()), isNull); + + analytics.feedLoadFailedFrom( + const SessionExpiredException(), + feedTab: FeedTab.home, + loadType: FeedLoadType.refresh, + ); + expect(events, isEmpty); + }); +} diff --git a/test/features/community/feed_exposure_test.dart b/test/features/community/feed_exposure_test.dart new file mode 100644 index 0000000..158d6bf --- /dev/null +++ b/test/features/community/feed_exposure_test.dart @@ -0,0 +1,77 @@ +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/features/community/feed_exposure.dart'; + +void main() { + test('曝光判定:≥50% 驻留满 500ms 记一次,段内按帖去重', () { + fakeAsync((async) { + final segment = FeedViewSegment(now: async.getClock(DateTime(2026)).now); + segment.updateVisibility('p-1', 0.8); + async.elapse(const Duration(milliseconds: 600)); + expect(segment.impressionCount, 1); + + // 再次可见不重复计。 + segment.updateVisibility('p-1', 0.9); + async.elapse(const Duration(milliseconds: 600)); + expect(segment.impressionCount, 1); + }); + }); + + test('快速滑过(<500ms 跌破阈值)不计曝光', () { + fakeAsync((async) { + final segment = FeedViewSegment(now: async.getClock(DateTime(2026)).now); + segment.updateVisibility('p-1', 0.8); + async.elapse(const Duration(milliseconds: 300)); + segment.updateVisibility('p-1', 0.2); // 滚出视口 + async.elapse(const Duration(seconds: 1)); + expect(segment.impressionCount, 0); + }); + }); + + test('可见面积不足 50% 不起计时', () { + fakeAsync((async) { + final segment = FeedViewSegment(now: async.getClock(DateTime(2026)).now); + segment.updateVisibility('p-1', 0.49); + async.elapse(const Duration(seconds: 1)); + expect(segment.impressionCount, 0); + }); + }); + + test('结算:冻结计数、取消在途驻留、幂等只出一份', () { + fakeAsync((async) { + final clock = async.getClock(DateTime(2026)); + final segment = FeedViewSegment(now: clock.now); + segment.updateVisibility('p-1', 1); + async.elapse(const Duration(milliseconds: 600)); + segment.updateVisibility('p-2', 1); // 在途驻留,结算时未满 500ms + async.elapse(const Duration(milliseconds: 100)); + segment.recordRefresh(); + segment.recordLoadMore(); + segment.recordLoadMore(); + + final summary = segment.settle(); + expect(summary, isNotNull); + expect(summary!.impressionCount, 1); + expect(summary.refreshCount, 1); + expect(summary.loadMoreCount, 2); + expect(summary.durationMs, 700); + + // 幂等:二次结算无第二条;结算后计数与曝光全部作废。 + expect(segment.settle(), isNull); + segment.updateVisibility('p-3', 1); + async.elapse(const Duration(seconds: 1)); + expect(segment.impressionCount, 1); + }); + }); + + test('durationMs 上限截断 30 分钟(防挂机污染 H8)', () { + fakeAsync((async) { + final segment = FeedViewSegment(now: async.getClock(DateTime(2026)).now); + async.elapse(const Duration(minutes: 45)); + expect( + segment.settle()!.durationMs, + const Duration(minutes: 30).inMilliseconds, + ); + }); + }); +} diff --git a/test/features/home/home_page_test.dart b/test/features/home/home_page_test.dart new file mode 100644 index 0000000..aac242e --- /dev/null +++ b/test/features/home/home_page_test.dart @@ -0,0 +1,393 @@ +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/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_models.dart'; +import 'package:patbond_flutter/features/community/feed_analytics.dart'; +import 'package:patbond_flutter/features/home/home_page.dart'; +import 'package:patbond_flutter/state/app_state.dart'; + +import '../../helpers/community_test_helpers.dart'; + +/// 短卡(纯文字)便于视口内多卡曝光断言;内容带 id 便于查重。 +FeedCard textCard(String id) => FeedCard.fromJson({ + ...sampleFeedCardJson(id: id), + 'coverImage': null, + 'mediaCount': 0, + 'title': null, + 'contentPreview': '动态内容 $id', +}); + +FeedCard degradedCard(String id) => FeedCard.fromJson({ + ...sampleFeedCardJson(id: id), + 'coverImage': null, + 'mediaCount': 0, + 'title': null, + 'contentPreview': '动态内容 $id', + 'author': sampleAuthorJson(nickname: null, avatarUrl: null), +}); + +void main() { + late FakeCommunityRepository repository; + late CommunityController controller; + late List<(String, Map?)> events; + late FeedAnalytics analytics; + late int createTaps; + + setUp(() { + repository = FakeCommunityRepository(); + controller = CommunityController(repository: repository); + events = []; + analytics = FeedAnalytics( + (name, [props]) async => events.add((name, props)), + ); + createTaps = 0; + }); + + List?> eventsNamed(String name) => + events.where((e) => e.$1 == name).map((e) => e.$2).toList(); + + Future pumpHome(WidgetTester tester, {bool isActive = true}) { + return tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: Scaffold( + body: HomePage( + appState: AppState(), + communityController: controller, + feedAnalytics: analytics, + isActive: isActive, + onOpenServices: (_) {}, + onOpenCreate: () => createTaps++, + ), + ), + ), + ); + } + + // 页面纵向主列表(story 环是嵌套的水平 ListView,取 first 即外层)。 + Finder list() => find.byType(ListView).first; + + testWidgets('四态 · loading:首载渲染 3 张骨架屏', (tester) async { + final completer = Completer>(); + repository.onFeed = (_, _) => completer.future; + + await pumpHome(tester); + await tester.pump(); + + // 首载骨架连排 3 张(列表懒加载,视口内至少 1 张被实例化)。 + expect(find.byType(FeedSkeleton), findsAtLeastNWidgets(1)); + + completer.complete(feedPage(const [])); + await tester.pumpAndSettle(); + expect(find.byType(FeedSkeleton), findsNothing); + }); + + testWidgets('四态 · empty:空态插画 + 「发布第一条」CTA 去创作', (tester) async { + repository.onFeed = (_, _) async => feedPage(const []); + + await pumpHome(tester); + await tester.pumpAndSettle(); + + expect(find.text('还没有动态'), findsOneWidget); + await tester.drag(list(), const Offset(0, -300)); + await tester.pump(); + await tester.tap(find.text('发布第一条')); + expect(createTaps, 1); + }); + + testWidgets('四态 · error:横幅 + 重试恢复 ready,feed_load_failed 上报', (tester) async { + var attempts = 0; + repository.onFeed = (_, _) async { + attempts += 1; + if (attempts == 1) throw const ApiNetworkException(); + return feedPage([textCard('p-1')]); + }; + + await pumpHome(tester); + await tester.pumpAndSettle(); + + expect(find.byType(InlineErrorBanner), findsOneWidget); + expect(find.text('网络异常,请检查网络后重试'), findsOneWidget); + expect(eventsNamed('feed_load_failed'), [ + { + 'feedTab': 'home', + 'loadType': 'refresh', + 'failureReason': 'network_error', + }, + ]); + + await tester.drag(list(), const Offset(0, -200)); + await tester.pump(); + await tester.tap(find.text('重试')); + await tester.pumpAndSettle(); + + expect(find.byType(InlineErrorBanner), findsNothing); + expect(find.text('动态内容 p-1'), findsOneWidget); + }); + + testWidgets('四态 · ready:卡片列表 + 降级作者「宠友」占位', (tester) async { + repository.onFeed = (_, _) async => + feedPage([textCard('p-1'), degradedCard('p-2')]); + + await pumpHome(tester); + await tester.pumpAndSettle(); + await tester.drag(list(), const Offset(0, -400)); + await tester.pump(); + + expect(find.byType(PostCard), findsNWidgets(2)); + expect(find.text('毛毛的铲屎官'), findsOneWidget); + expect(find.text('宠友'), findsOneWidget); + }); + + testWidgets('翻页:触底携游标加载下一页,不丢不重,到底显「没有更多了」', (tester) async { + final page2 = Completer>(); + repository.onFeed = (_, cursor) async { + if (cursor == null) { + return feedPage( + [textCard('p-1'), textCard('p-2')], + nextCursor: 'c1', + hasMore: true, + ); + } + return page2.future; + }; + + await pumpHome(tester); + await tester.pumpAndSettle(); + + // 触底:尾部 loading 转圈。 + await tester.drag(list(), const Offset(0, -1000)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + page2.complete(feedPage([textCard('p-3')])); + await tester.pumpAndSettle(); + await tester.drag(list(), const Offset(0, -600)); + await tester.pump(); + + // keyset 游标不丢不重:三帖各恰一张,游标原样带出。 + expect(repository.calls.where((c) => c.startsWith('feed:')).toList(), [ + 'feed:cursor=null', + 'feed:cursor=c1', + ]); + expect(find.text('动态内容 p-1'), findsOneWidget); + expect(find.text('动态内容 p-2'), findsOneWidget); + expect(find.text('动态内容 p-3'), findsOneWidget); + expect(find.text('没有更多了'), findsOneWidget); + }); + + testWidgets('尾部失败:重试条 + feed_load_failed(load_more),点按重试补页', (tester) async { + var page2Attempts = 0; + repository.onFeed = (_, cursor) async { + if (cursor == null) { + return feedPage([textCard('p-1')], nextCursor: 'c1', hasMore: true); + } + page2Attempts += 1; + if (page2Attempts == 1) { + throw const ApiBusinessException(code: 40000, message: 'x'); + } + return feedPage([textCard('p-2')]); + }; + + await pumpHome(tester); + await tester.pumpAndSettle(); + await tester.drag(list(), const Offset(0, -1000)); + await tester.pumpAndSettle(); + + expect(find.text('加载失败,点此重试'), findsOneWidget); + expect(eventsNamed('feed_load_failed'), [ + { + 'feedTab': 'home', + 'loadType': 'load_more', + 'failureReason': 'server_error', + 'errorCode': 40000, + 'httpStatus': 400, + }, + ]); + + await tester.tap(find.text('加载失败,点此重试')); + await tester.pumpAndSettle(); + await tester.drag(list(), const Offset(0, -400)); + await tester.pump(); + expect(find.text('动态内容 p-2'), findsOneWidget); + expect(find.text('加载失败,点此重试'), findsNothing); + }); + + testWidgets('下拉刷新:整体替换列表;刷新失败保留旧列表 + SnackBar', (tester) async { + var attempts = 0; + repository.onFeed = (_, _) async { + attempts += 1; + if (attempts == 1) return feedPage([textCard('p-1')]); + if (attempts == 2) throw const ApiNetworkException(); + return feedPage([textCard('p-9')]); + }; + + await pumpHome(tester); + await tester.pumpAndSettle(); + expect(find.text('动态内容 p-1'), findsOneWidget); + + // 第一次下拉:失败——旧列表保留、SnackBar 轻提示、事件上报。 + await tester.fling(list(), const Offset(0, 400), 1000); + await tester.pumpAndSettle(); + expect(find.text('动态内容 p-1'), findsOneWidget); + expect(find.text('网络异常,请检查网络后重试'), findsOneWidget); + expect(eventsNamed('feed_load_failed').single?['loadType'], 'refresh'); + + // 第二次下拉:成功——整体替换不残留旧卡。 + await tester.fling(list(), const Offset(0, 400), 1000); + await tester.pumpAndSettle(); + expect(find.text('动态内容 p-9'), findsOneWidget); + expect(find.text('动态内容 p-1'), findsNothing); + }); + + testWidgets('曝光结算 · 切走 Tab:可见 ≥500ms 的卡片计入,一段恰一条 feed_viewed', ( + tester, + ) async { + repository.onFeed = (_, _) async => + feedPage([textCard('p-1'), textCard('p-2')]); + + await pumpHome(tester); + await tester.pumpAndSettle(); + await tester.drag(list(), const Offset(0, -1000)); + // 先渲染一帧(滚动后位置在帧末扫描),再驻留满 500ms(曝光判定), + // 随后切走 Tab 结算。 + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + + expect(eventsNamed('feed_viewed'), isEmpty); + await pumpHome(tester, isActive: false); + await tester.pump(); + + final viewed = eventsNamed('feed_viewed'); + expect(viewed, hasLength(1)); + expect(viewed.single?['feedTab'], 'home'); + expect(viewed.single?['impressionCount'], 2); + // 首屏自动预取不计刷新,本段无翻页。 + expect(viewed.single?['refreshCount'], 0); + expect(viewed.single?['loadMoreCount'], 0); + // durationMs 取真实时钟(生产语义),widget 测试的 pump 只推进假 + // 时钟,此处仅验证字段在场非负(精确口径见 feed_exposure_test)。 + expect(viewed.single?['durationMs'], greaterThanOrEqualTo(0)); + + // 切回 Tab 开新段:再切走仍恰一条新事件(幂等不翻倍)。 + await pumpHome(tester); + await tester.pump(); + await pumpHome(tester, isActive: false); + await tester.pump(); + expect(eventsNamed('feed_viewed'), hasLength(2)); + }); + + testWidgets('曝光结算 · 快速滑过不计;刷新/翻页计数入段', (tester) async { + final cards = List.generate(6, (i) => textCard('p-$i')); + repository.onFeed = (_, cursor) async { + if (cursor == null) { + return feedPage(cards, nextCursor: 'c1', hasMore: true); + } + return feedPage([textCard('p-6')]); + }; + + await pumpHome(tester); + await tester.pumpAndSettle(); + + // 快速滑到底(各卡驻留 <500ms)→ 触发一次翻页。 + await tester.drag(list(), const Offset(0, -2000)); + await tester.pumpAndSettle(); + // 用户下拉刷新一次。 + await tester.drag(list(), const Offset(0, 2000)); + await tester.pump(); + await tester.fling(list(), const Offset(0, 400), 1000); + await tester.pumpAndSettle(); + + await pumpHome(tester, isActive: false); + await tester.pump(); + + final viewed = eventsNamed('feed_viewed').single!; + expect(viewed['refreshCount'], 1); + expect(viewed['loadMoreCount'], 1); + // 快速滑过的中段卡片未驻留满 500ms,不应全量计曝光。 + expect(viewed['impressionCount'], lessThan(cards.length)); + }); + + testWidgets('曝光结算 · 退后台:结算一条,回前台开新段', (tester) async { + repository.onFeed = (_, _) async => feedPage([textCard('p-1')]); + + await pumpHome(tester); + await tester.pumpAndSettle(); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + await tester.pump(); + // 级联 inactive→hidden→paused 只结算一次。 + expect(eventsNamed('feed_viewed'), hasLength(1)); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + await tester.pump(); + expect(eventsNamed('feed_viewed'), hasLength(2)); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + }); + + testWidgets('曝光结算 · 切到服务分段:结算;切回开新段', (tester) async { + repository.onFeed = (_, _) async => feedPage([textCard('p-1')]); + + await pumpHome(tester); + await tester.pumpAndSettle(); + + await tester.tap(find.text('本地服务')); + await tester.pump(); + expect(eventsNamed('feed_viewed'), hasLength(1)); + + await tester.tap(find.text('社区动态')); + await tester.pump(); + await tester.tap(find.text('本地服务')); + await tester.pump(); + expect(eventsNamed('feed_viewed'), hasLength(2)); + }); + + testWidgets('T3-14 取舍:整卡点按提示详情接入中(不导航 demo 详情)', (tester) async { + repository.onFeed = (_, _) async => feedPage([textCard('p-1')]); + + await pumpHome(tester); + await tester.pumpAndSettle(); + await tester.drag(list(), const Offset(0, -600)); + await tester.pump(); + + await tester.tap(find.text('动态内容 p-1')); + await tester.pump(); + expect(find.text('帖子详情正在接入真实数据,敬请期待'), findsOneWidget); + }); + + testWidgets('搜索词过滤已加载卡片;无命中显搜索空态', (tester) async { + repository.onFeed = (_, _) async => + feedPage([textCard('p-1'), textCard('p-2')]); + + await pumpHome(tester); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'p-1'); + await tester.pump(); + expect(find.byType(PostCard), findsOneWidget); + + await tester.enterText(find.byType(TextField), '不存在的词'); + await tester.pump(); + expect(find.byType(PostCard), findsNothing); + expect(find.text('没有找到相关动态'), findsOneWidget); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 2c513b3..c156e48 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -4,6 +4,7 @@ import 'package:patbond_flutter/features/auth/session_manager.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'helpers/auth_test_helpers.dart'; +import 'helpers/community_test_helpers.dart'; import 'helpers/pet_test_helpers.dart'; void main() { @@ -17,6 +18,8 @@ void main() { sessionManager: session, authRepository: FakeAuthRepository(), petsRepository: FakePetsRepository(), + communityRepository: FakeCommunityRepository() + ..onFeed = (_, _) async => feedPage(const []), ), ); await tester.pumpAndSettle();