新增:community feature 数据层——契约 v1.3.0 十九操作全覆盖 + ToggleSync 乐观更新状态机(T3-12)
CI / flutter-gates (push) Successful in 2m26s
CI / flutter-gates (push) Successful in 2m26s
- community_models:community/media 域 DTO 逐字段照冻结契约手写 JSON 映射 (Post/FeedCard/PostComment/AuthorSummary 降级形态/媒体两步上传凭据等), 未知枚举抛 FormatException 暴露契约漂移;CursorPage 上移 core 复用 - community_repository:13 路径 19 操作全覆盖;createPost/createComment 必带 Idempotency-Key(每次逻辑提交换新键、刷新重放同键);点赞/收藏/ 关注走 PUT/DELETE 语义幂等 - community_exceptions:v1.3.0 新增 9 码 + 40902 共码类型化异常映射 - toggle_sync:乐观翻转 + 快照回滚 + 单飞合并最终意图 + 代次守卫, 点赞/收藏共用一套参数化状态机,权威终态对账收敛 - community_controller:Feed 多页缓存 + 首屏四态 + 尾部加载三态 + 游标拼接 + 刷新代次丢弃旧尾页;详情副本与卡片互动状态同源;reset 清态 - app.dart 装配:community 服务分端口直连(:8084),共享 TokenRefresher, 登出同步 reset - 测试 286 → 347(模型映射 / 19 操作线路 / 错误映射 / 竞态序列全覆盖) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
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<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] 对账收敛,不外抛)。
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user