新增:首页 Feed 接入真实数据——四态/尾部三态/聚合曝光埋点,社区 demo 消亡第一页(T3-14)
CI / flutter-gates (push) Successful in 2m41s

- home_page Feed segment 改接 CommunityController:骨架屏首载、空态 CTA、
  错误横幅重试、下拉刷新(失败保留旧列表 + SnackBar)、触底游标翻页
  (失败态只走显式重试,不随滚动风暴自动重打)
- PostCard 三形态(单图通栏 4:3 / 多图折叠封面 +N 角标 / 纯文字 6 行)+
  PostMediaGrid(列数规则 + ink 80% scrim 精算)+ LikeButton(error 族修订
  Colors.red,T3-14 纯展示禁用态,ToggleSync 接线留 T3-15/16)+ FeedSkeleton
  (呼吸动效尊重减弱动态设置);降级作者统一「宠友」占位
- SignedNetworkImage:预签名 URL 缓存 key 剥离 X-Amz-* 签名参数,
  RemoteImage 全仓换用(同图不同签名命中同一缓存条目)
- feed 域埋点:feed_viewed 聚合曝光(浏览段开/结算于切 Tab、切分段、
  退后台、销毁;≥50% 可见 ≥500ms 段内按帖去重,postId 不上报)+
  feed_load_failed(refresh/load_more 双路,会话失效不报)
- integration_test/feed_live_test.dart:compose 六容器 Linux 桌面真链路
  实测入口(PATBOND_FEED_LIVE=1 门控,默认跳过)
- 测试 379 → 421(+42):四态/尾部三态/翻页不丢不重/降级作者/曝光结算
  时机/缓存 key/事件属性全覆盖;analyze 0,format 无 diff

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 13:01:43 +08:00
parent 1441f0148f
commit 8aac8c52cc
22 changed files with 2401 additions and 212 deletions
+112
View File
@@ -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<String, String> _values = {};
@override
Future<String?> read(String key) async => _values[key];
@override
Future<void> write(String key, String value) async => _values[key] = value;
@override
Future<void> 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<void> 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);
}
+6 -1
View File
@@ -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<App> {
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<App> {
petsController = PetsController(
repository: widget.petsRepository ?? _buildPetsRepository(),
);
// T3-12 只装配数据层;Feed segment 的 UI 接线在 T3-14 挂入主壳
// T3-14Feed 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<App> {
key: const ValueKey('shell'),
appState: appState,
petsController: petsController,
communityController: communityController,
petAnalytics: petAnalytics,
healthRecordAnalytics: healthRecordAnalytics,
feedAnalytics: feedAnalytics,
pageViewTracker: _pageViewTracker,
onLogout: authRepository.logout,
);
@@ -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 = <String, String>{};
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> {
SignedNetworkImage(this.url, {this.scale = 1.0})
: cacheKey = presignedImageCacheKey(url);
final String url;
final double scale;
/// 剥离签名参数后的稳定缓存 key。
final String cacheKey;
@override
Future<SignedNetworkImage> obtainKey(ImageConfiguration configuration) =>
SynchronousFuture<SignedNetworkImage>(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")';
}
+125
View File
@@ -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<FeedSkeleton> createState() => _FeedSkeletonState();
}
class _FeedSkeletonState extends State<FeedSkeleton>
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),
),
);
}
}
+93
View File
@@ -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,
),
),
],
),
),
),
),
);
}
}
+240
View File
@@ -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,
),
),
],
),
),
),
);
}
}
+164
View File
@@ -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:160% 档
/// 3.88:1 不达标弃用,§5.1 精算)。
/// - 单图折叠形态:Feed 卡片契约只带封面 + mediaCountFeedCard 裁剪),
/// [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<String> urls;
/// 实际总张数(缺省 = urls.length);大于可展示数时渲染「+N」。
final int? totalCount;
/// 格子点按(全屏浏览入口,T3-15 详情页接线)。
final ValueChanged<int>? 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;
}
}
@@ -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 _ => '请求过于频繁,请稍后再试',
_ => '动态加载失败,请稍后重试',
};
+114
View File
@@ -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 仅接 hometopic / 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_errorserver_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,
);
}
}
+110
View File
@@ -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<String> _impressed = <String>{};
final Map<String, Timer> _dwellTimers = <String, Timer>{};
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,
);
}
}
+440 -208
View File
@@ -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 }
/// 首页 TabFeed 段自 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<PostModel> onOpenPost;
/// Feed 数据源(Tab 级单例,app.dart 装配注入)。
final CommunityController communityController;
final ValueChanged<bool> onOpenServices;
final VoidCallback onOpenCreate;
/// feed 域埋点(feed_viewed 聚合曝光 + feed_load_failed)。
final FeedAnalytics? feedAnalytics;
/// 首页 Tab 是否为当前可见 TabIndexedStack 各 Tab 常驻构建,
/// 由主壳传入以驱动浏览段开/结算)。
final bool isActive;
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
HomeSegment segment = HomeSegment.feed;
String query = '';
String sort = '综合';
List<PostModel> get filteredPosts {
/// 当前 Feed 浏览段(不在 Feed 面上时为 null)。
FeedViewSegment? _viewSegment;
/// 曝光扫描用:postId → 卡片 GlobalKey。
final Map<String, GlobalKey> _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<void> _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<void> _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<FeedCard> 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<HomePage> {
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: () async =>
Future<void>.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<HomeSegment>(
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<ScrollNotification>(
onNotification: _onScrollNotification,
child: RefreshIndicator(
onRefresh: () => segment == HomeSegment.feed
? _refreshFeed(userInitiated: true)
: Future<void>.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<HomeSegment>(
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<Widget> _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<FeedCard> 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});
+13 -1
View File
@@ -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<MainShellPage> {
final pages = [
HomePage(
appState: widget.appState,
onOpenPost: openPost,
communityController: widget.communityController,
feedAnalytics: widget.feedAnalytics,
isActive: currentIndex == 0,
onOpenServices: openServices,
onOpenCreate: () => selectTab(1),
),
+5 -2
View File
@@ -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,
+39
View File
@@ -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:
+3
View File
@@ -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
@@ -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));
});
});
}
+102
View File
@@ -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<String, dynamic> 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<Text>(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<Text>(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<Icon>(find.byIcon(Icons.favorite));
expect(likeIcon.color, AppColors.error);
// 收藏未激活:描边 + inkSoft。
final bookmarkIcon = tester.widget<Icon>(
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);
});
}
+100
View File
@@ -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<String> 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<DecoratedBox>(find.byType(DecoratedBox))
.map((w) => w.decoration)
.whereType<BoxDecoration>()
.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('FeedSkeleton05 §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<FadeTransition>(
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<FadeTransition>(
find
.descendant(
of: find.byType(FeedSkeleton),
matching: find.byType(FadeTransition),
)
.first,
);
expect(fade.opacity.value, 1.0);
});
});
}
@@ -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<String, dynamic>?)> 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);
});
}
@@ -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,
);
});
});
}
+393
View File
@@ -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<String, dynamic>?)> 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<Map<String, dynamic>?> eventsNamed(String name) =>
events.where((e) => e.$1 == name).map((e) => e.$2).toList();
Future<void> 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<CursorPage<FeedCard>>();
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:横幅 + 重试恢复 readyfeed_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<CursorPage<FeedCard>>();
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);
});
}
+3
View File
@@ -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();