新增:首页 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,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),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user