Files
patbond-flutter/lib/features/community/community_controller.dart
T
lixi 92524da8e2 新增:互动埋点与乐观更新视觉基建——字典 v3 互动八事件封装 + ToggleSync 成功上报与触点归因 + LikeButton 三层闪烁抑制动画(T3-16)
- community_interaction_analytics:post_liked/unliked、post_favorited/unfavorited、
  comment_create_succeeded/failed、user_followed/unfollowed 强类型封装(22 号
  白名单键集逐一对齐;textLengthBucket 分桶、httpStatus 由五位码推导、
  会话失效不上报);comment_create_started 锁死 unknown 不封装
- CommunityController:toggleLike/toggleBookmark 增 source 触点归因
 (feed / post_detail),成功响应后经注入的 interactionAnalytics 上报
 (乐观翻转与失败不报);adjustCommentCount 评论计数同源写入
 (详情副本与 Feed 卡片一并更新)
- LikeButton 升 Stateful:点按激活 240ms 弹性缩放 + 120ms 图标淡入、
  取消仅颜色渐出;回滚/对账零动画直接跳变、计数与状态成对更新、
  激活动画未播完等播完再跳(05 号 §3.5/§4);减弱动态设置降级瞬变

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-09 13:38:50 +08:00

337 lines
12 KiB
Dart

import 'package:flutter/foundation.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/features/community/community_interaction_analytics.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/features/community/community_repository.dart';
import 'package:patbond_flutter/features/community/toggle_sync.dart';
/// Feed 首屏四态(同 pets 先例)。
enum FeedPhase { initial, loading, ready, error }
/// 尾部加载更多三态(游标累积流新增,pets 无此并发点)。
enum LoadMorePhase { idle, loading, error }
/// community feature 状态控制器(03 号评估 §2 分层:
/// Page/Widget → CommunityController → CommunityRepository → ApiClient)。
///
/// Tab 级单例(app.dart 装配注入主壳):Feed 是游标累积流,且详情页与
/// 首页共享同一份帖子内存副本(点赞状态跨页一致),不做页面级 state。
/// 评论列表只属详情页,按「页面级状态按页自建」纪律经 [repository]
/// 自取,不膨胀本控制器。服务端是唯一事实来源,内存副本仅作展示缓存。
class CommunityController extends ChangeNotifier {
CommunityController({required this._repository, this._interactionAnalytics}) {
_likeSync = ToggleSync(
read: (id) {
final post = _postCache[id];
if (post != null) {
return ToggleReading(active: post.likedByMe, count: post.likeCount);
}
final card = _cardOrNull(id);
if (card == null) return null;
return ToggleReading(active: card.likedByMe, count: card.likeCount);
},
write: (id, active, count) =>
_writeInteraction(id, likedByMe: active, likeCount: count),
send: (id, target) async {
final state = target
? await _repository.likePost(id)
: await _repository.unlikePost(id);
// 成功响应后上报(06 §1.4 口径;乐观翻转与失败均不报)。
final source = _likeSources[id] ?? InteractionSource.feed;
target
? _interactionAnalytics?.postLiked(source: source)
: _interactionAnalytics?.postUnliked(source: source);
return ToggleOutcome(active: state.liked, count: state.likeCount);
},
generation: () => _generation,
onError: _onToggleError,
);
_bookmarkSync = ToggleSync(
read: (id) {
final post = _postCache[id];
if (post != null) {
return ToggleReading(
active: post.bookmarkedByMe,
count: post.bookmarkCount,
);
}
final card = _cardOrNull(id);
if (card == null) return null;
return ToggleReading(
active: card.bookmarkedByMe,
count: card.bookmarkCount,
);
},
write: (id, active, count) =>
_writeInteraction(id, bookmarkedByMe: active, bookmarkCount: count),
send: (id, target) async {
final state = target
? await _repository.bookmarkPost(id)
: await _repository.unbookmarkPost(id);
final source = _bookmarkSources[id] ?? InteractionSource.feed;
target
? _interactionAnalytics?.postFavorited(source: source)
: _interactionAnalytics?.postUnfavorited(source: source);
return ToggleOutcome(
active: state.bookmarked,
count: state.bookmarkCount,
);
},
generation: () => _generation,
onError: _onToggleError,
);
}
final CommunityRepository _repository;
final CommunityInteractionAnalytics? _interactionAnalytics;
/// 各帖最近一次 toggle 的触点来源(Feed 卡片 / 详情页共享同一实例,
/// 成功响应上报时按发起触点归因)。
final Map<String, InteractionSource> _likeSources = {};
final Map<String, InteractionSource> _bookmarkSources = {};
/// 页面级状态(评论列表、我的帖子、收藏页等)按页直接经仓库取数。
CommunityRepository get repository => _repository;
late final ToggleSync _likeSync;
late final ToggleSync _bookmarkSync;
FeedPhase _phase = FeedPhase.initial;
LoadMorePhase _loadMorePhase = LoadMorePhase.idle;
List<FeedCard> _feed = const [];
String? _nextCursor;
bool _hasMore = false;
/// 首屏加载失败(error 态时非 null)。
ApiException? _lastError;
/// 刷新失败但旧列表被保留时的错误(页面 SnackBar 轻提示后消费)。
ApiException? _refreshError;
/// 加载更多失败(尾部重试条渲染依据)。
ApiException? _loadMoreError;
/// 最近一次点赞/收藏对账失败(T3-15/16 SnackBar 消费)。
ApiException? _toggleError;
/// 刷新代次:整体替换列表后,在途旧代次响应(尾页 / 互动对账)一律丢弃。
int _generation = 0;
/// 详情内存副本(详情页与 Feed 卡片互动状态同源)。
final Map<String, Post> _postCache = {};
bool _disposed = false;
FeedPhase get phase => _phase;
LoadMorePhase get loadMorePhase => _loadMorePhase;
/// 累积的多页 Feed 缓存(服务端 published_at DESC, id DESC 原样保留)。
List<FeedCard> get feed => _feed;
bool get hasMore => _hasMore;
/// ready 且列表为空 → 空态。
bool get isEmpty => _phase == FeedPhase.ready && _feed.isEmpty;
ApiException? get lastError => _lastError;
ApiException? get refreshError => _refreshError;
ApiException? get loadMoreError => _loadMoreError;
ApiException? get toggleError => _toggleError;
/// 详情内存副本(进入详情页先渲染缓存,再 [getPost] 拉新)。
Post? cachedPost(String postId) => _postCache[postId];
/// 首屏加载 / 下拉刷新:丢弃游标从头拉第一页,成功后**整体替换**累积列表。
/// 刷新失败保留旧列表(不清空不闪空态),错误经 [refreshError] 轻提示;
/// 首屏(空列表)失败收敛为 error 态供页面渲染 + retry。
Future<void> refresh() async {
_generation += 1;
final generation = _generation;
_lastError = null;
_refreshError = null;
_loadMorePhase = LoadMorePhase.idle;
_loadMoreError = null;
if (_feed.isEmpty) _phase = FeedPhase.loading;
_notify();
try {
final page = await _repository.getFeed();
if (generation != _generation) return; // 期间又发生过刷新/登出。
_feed = page.items;
_nextCursor = page.nextCursor;
_hasMore = page.hasMore;
_phase = FeedPhase.ready;
} on ApiException catch (error) {
if (generation != _generation) return;
if (_feed.isEmpty) {
_lastError = error;
_phase = FeedPhase.error;
} else {
_refreshError = error;
}
}
_notify();
}
/// 滚动近底加载下一页:携带上一页 nextCursor(keyset 翻页不丢不重)。
/// 失败置 [LoadMorePhase.error] 渲染尾部重试条;期间发生过刷新的
/// 旧代次响应直接丢弃(避免「刷新后旧尾页追加」的重复/错位)。
Future<void> loadMore() async {
if (_phase != FeedPhase.ready ||
!_hasMore ||
_loadMorePhase == LoadMorePhase.loading) {
return;
}
final generation = _generation;
_loadMorePhase = LoadMorePhase.loading;
_loadMoreError = null;
_notify();
try {
final page = await _repository.getFeed(cursor: _nextCursor);
if (generation != _generation) return; // 旧代次尾页,丢弃。
_feed = [..._feed, ...page.items];
_nextCursor = page.nextCursor;
_hasMore = page.hasMore;
_loadMorePhase = LoadMorePhase.idle;
} on ApiException catch (error) {
if (generation != _generation) return;
_loadMoreError = error;
_loadMorePhase = LoadMorePhase.error;
}
_notify();
}
/// 拉取帖子详情并同步内存副本(Feed 卡片互动字段一并回写)。
Future<Post> getPost(String postId) async {
final post = await _repository.getPost(postId);
_postCache[postId] = post;
_syncCardFromPost(post);
_notify();
return post;
}
/// 点赞/取消点赞(乐观翻转,终态由 [ToggleSync] 对账收敛,不外抛)。
/// [source] 为触点来源(埋点归因),Feed 卡片缺省 feed、详情页传
/// post_detail。
void toggleLike(
String postId, {
InteractionSource source = InteractionSource.feed,
}) {
_likeSources[postId] = source;
_likeSync.toggle(postId);
}
/// 收藏/取消收藏(与点赞同构)。
void toggleBookmark(
String postId, {
InteractionSource source = InteractionSource.feed,
}) {
_bookmarkSources[postId] = source;
_bookmarkSync.toggle(postId);
}
/// 评论创建/删除后的计数调整(详情页与 Feed 卡片同源写入,
/// 服务端 comment_count 由写侧同事务维护,本地只做展示对齐)。
void adjustCommentCount(String postId, int delta) {
final current =
_postCache[postId]?.commentCount ?? _cardOrNull(postId)?.commentCount;
if (current == null) return;
final next = current + delta;
_writeInteraction(postId, commentCount: next < 0 ? 0 : next);
}
/// 消费一次性 toggle 错误(SnackBar 展示后清除)。
void clearToggleError() => _toggleError = null;
/// 登出清空:回 initial 态、清多页缓存与详情副本、丢弃全部在途链,
/// 避免上一账号数据跨会话泄漏;重登后主壳重建,Feed 页重新触发 [refresh]。
void reset() {
_generation += 1;
_phase = FeedPhase.initial;
_loadMorePhase = LoadMorePhase.idle;
_feed = const [];
_nextCursor = null;
_hasMore = false;
_lastError = null;
_refreshError = null;
_loadMoreError = null;
_toggleError = null;
_postCache.clear();
_likeSources.clear();
_bookmarkSources.clear();
_likeSync.reset();
_bookmarkSync.reset();
_notify();
}
FeedCard? _cardOrNull(String postId) {
for (final card in _feed) {
if (card.id == postId) return card;
}
return null;
}
/// 互动字段统一写入口:详情副本与 Feed 卡片同步更新后 notify(同帧反馈)。
void _writeInteraction(
String postId, {
bool? likedByMe,
int? likeCount,
bool? bookmarkedByMe,
int? bookmarkCount,
int? commentCount,
}) {
final post = _postCache[postId];
if (post != null) {
_postCache[postId] = post.copyWithInteraction(
likedByMe: likedByMe,
likeCount: likeCount,
bookmarkedByMe: bookmarkedByMe,
bookmarkCount: bookmarkCount,
commentCount: commentCount,
);
}
final index = _feed.indexWhere((card) => card.id == postId);
if (index != -1) {
_feed = [..._feed]
..[index] = _feed[index].copyWithInteraction(
likedByMe: likedByMe,
likeCount: likeCount,
bookmarkedByMe: bookmarkedByMe,
bookmarkCount: bookmarkCount,
commentCount: commentCount,
);
}
if (post != null || index != -1) _notify();
}
void _syncCardFromPost(Post post) {
final index = _feed.indexWhere((card) => card.id == post.id);
if (index == -1) return;
_feed = [..._feed]
..[index] = _feed[index].copyWithInteraction(
likedByMe: post.likedByMe,
likeCount: post.likeCount,
commentCount: post.commentCount,
bookmarkedByMe: post.bookmarkedByMe,
bookmarkCount: post.bookmarkCount,
);
}
void _onToggleError(String postId, Object error) {
if (error is ApiException) {
_toggleError = error;
} else {
_toggleError = ApiNetworkException('$error');
}
_notify();
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}