import 'package:flutter/foundation.dart'; import 'package:patbond_flutter/core/network/api_exception.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}) { _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); 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); return ToggleOutcome( active: state.bookmarked, count: state.bookmarkCount, ); }, generation: () => _generation, onError: _onToggleError, ); } final CommunityRepository _repository; /// 页面级状态(评论列表、我的帖子、收藏页等)按页直接经仓库取数。 CommunityRepository get repository => _repository; late final ToggleSync _likeSync; late final ToggleSync _bookmarkSync; FeedPhase _phase = FeedPhase.initial; LoadMorePhase _loadMorePhase = LoadMorePhase.idle; List _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 _postCache = {}; bool _disposed = false; FeedPhase get phase => _phase; LoadMorePhase get loadMorePhase => _loadMorePhase; /// 累积的多页 Feed 缓存(服务端 published_at DESC, id DESC 原样保留)。 List 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 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 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 getPost(String postId) async { final post = await _repository.getPost(postId); _postCache[postId] = post; _syncCardFromPost(post); _notify(); return post; } /// 点赞/取消点赞(乐观翻转,终态由 [ToggleSync] 对账收敛,不外抛)。 void toggleLike(String postId) => _likeSync.toggle(postId); /// 收藏/取消收藏(与点赞同构)。 void toggleBookmark(String postId) => _bookmarkSync.toggle(postId); /// 消费一次性 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(); _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, }) { final post = _postCache[postId]; if (post != null) { _postCache[postId] = post.copyWithInteraction( likedByMe: likedByMe, likeCount: likeCount, bookmarkedByMe: bookmarkedByMe, bookmarkCount: bookmarkCount, ); } 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, ); } 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(); } }