新增:首页 Feed 接入真实数据——四态/尾部三态/聚合曝光埋点,社区 demo 消亡第一页(T3-14)
CI / flutter-gates (push) Successful in 2m41s
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:
@@ -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
@@ -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-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<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")';
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<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 _ => '请求过于频繁,请稍后再试',
|
||||
_ => '动态加载失败,请稍后重试',
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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<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 是否为当前可见 Tab(IndexedStack 各 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});
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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('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<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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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:横幅 + 重试恢复 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<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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user