feat(flutter): 接入互动评论点赞举报

This commit is contained in:
Zhou Weidong committed 2026-10-17 15:26:42 +08:00
1 parent 45f5f25640
commit d361034226
39 files changed
+3298 -462

No files matched your search

@@ -0,0 +1,378 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/comment.dart';
import '../models/interaction.dart';
import '../remote/api_client.dart';
import '../repositories/comment_repository.dart';
import '../repositories/interaction_repository.dart';
/// 评论列表、发布、回复、删除与评论点赞的状态控制器。
class CommentController extends ChangeNotifier {
CommentController({
required CommentRepository comments,
required InteractionRepository interactions,
required this.dramaId,
this.episodeId,
}) : _commentRepository = comments,
_interactionRepository = interactions;
final CommentRepository _commentRepository;
final InteractionRepository _interactionRepository;
final String dramaId;
final String? episodeId;
final List<DramaComment> _comments = [];
final Map<String, List<DramaComment>> _replies = {};
final Map<String, String?> _replyCursors = {};
final Map<String, bool> _replyHasMore = {};
final Set<String> _expandedRoots = {};
final Set<String> _loadingReplies = {};
final Set<String> _loadingMoreReplies = {};
final Set<String> _likingCommentIds = {};
final Set<String> _deletingCommentIds = {};
bool _disposed = false;
bool loading = false;
bool loadingMore = false;
bool submitting = false;
bool hasMore = false;
String? nextCursor;
String? error;
/// 当前已加载的顶层评论。
List<DramaComment> get comments => List.unmodifiable(_comments);
/// 某一楼层已加载的回复。
List<DramaComment> repliesFor(String rootId) =>
List.unmodifiable(_replies[rootId] ?? const []);
/// 楼层回复是否已展开。
bool repliesExpanded(String rootId) => _expandedRoots.contains(rootId);
/// 楼层回复是否正在首屏加载。
bool repliesLoading(String rootId) => _loadingReplies.contains(rootId);
/// 楼层回复是否还有下一页。
bool repliesHasMore(String rootId) => _replyHasMore[rootId] ?? false;
/// 评论是否正在提交点赞,用于防连点。
bool likingComment(String id) => _likingCommentIds.contains(id);
void _notify() {
if (!_disposed) notifyListeners();
}
/// 加载首屏评论,成功后替换当前顶层列表。
Future<void> load() async {
loading = true;
error = null;
_notify();
try {
final page = await _commentRepository.page(
dramaId: dramaId,
size: commentPageSize,
);
_comments
..clear()
..addAll(page.comments);
nextCursor = page.nextCursor;
hasMore = page.hasMore;
} catch (failure) {
error = _errorMessage(failure) ?? 'commentsLoadFailed';
} finally {
loading = false;
_notify();
}
}
/// 使用 `nextCursor` 续拉顶层评论。
Future<void> loadMore() async {
final cursor = nextCursor;
if (loadingMore || !hasMore || cursor == null) return;
loadingMore = true;
error = null;
_notify();
try {
final page = await _commentRepository.page(
dramaId: dramaId,
cursor: cursor,
size: commentPageSize,
);
_comments.addAll(page.comments);
nextCursor = page.nextCursor;
hasMore = page.hasMore;
} catch (failure) {
error = _errorMessage(failure) ?? 'commentsLoadFailed';
} finally {
loadingMore = false;
_notify();
}
}
/// 展开并用 `rootId` 加载一层楼层回复。
Future<void> loadReplies(String rootId) async {
_expandedRoots.add(rootId);
if (_replies.containsKey(rootId) || _loadingReplies.contains(rootId)) {
_notify();
return;
}
_loadingReplies.add(rootId);
error = null;
_notify();
try {
final page = await _commentRepository.page(
dramaId: dramaId,
rootId: rootId,
size: commentPageSize,
);
_replies[rootId] = List.of(page.comments);
_replyCursors[rootId] = page.nextCursor;
_replyHasMore[rootId] = page.hasMore;
} catch (failure) {
error = _errorMessage(failure) ?? 'commentsLoadFailed';
} finally {
_loadingReplies.remove(rootId);
_notify();
}
}
/// 收起楼层回复,不丢弃已加载数据。
void hideReplies(String rootId) {
if (_expandedRoots.remove(rootId)) _notify();
}
/// 续拉楼层回复。
Future<void> loadMoreReplies(String rootId) async {
final cursor = _replyCursors[rootId];
if (_loadingMoreReplies.contains(rootId) || cursor == null) return;
if (!(_replyHasMore[rootId] ?? false)) return;
_loadingMoreReplies.add(rootId);
_notify();
try {
final page = await _commentRepository.page(
dramaId: dramaId,
rootId: rootId,
cursor: cursor,
size: commentPageSize,
);
(_replies[rootId] ??= []).addAll(page.comments);
_replyCursors[rootId] = page.nextCursor;
_replyHasMore[rootId] = page.hasMore;
} catch (failure) {
error = _errorMessage(failure) ?? 'commentsLoadFailed';
} finally {
_loadingMoreReplies.remove(rootId);
_notify();
}
}
/// 发布评论或回复,输入框是否清空由调用方根据成功结果决定。
Future<OperationResult<DramaComment>> publish(
String rawContent, {
DramaComment? replyTo,
}) async {
final content = rawContent.trim();
if (content.isEmpty || submitting) {
return const OperationResult.failure(null);
}
submitting = true;
_notify();
try {
final comment = await _commentRepository.publish(
dramaId: dramaId,
episodeId: episodeId,
content: content,
parentId: replyTo?.id,
);
_insertPublished(comment, replyTo);
return OperationResult.success(comment);
} catch (failure) {
return OperationResult.failure(_errorMessage(failure));
} finally {
submitting = false;
_notify();
}
}
/// 乐观删除自己的评论,失败时恢复到原位置。
Future<OperationResult<void>> delete(DramaComment comment) async {
if (!comment.mine || _deletingCommentIds.contains(comment.id)) {
return const OperationResult.failure(null);
}
_deletingCommentIds.add(comment.id);
final location = _removeComment(comment.id);
_notify();
try {
await _commentRepository.delete(comment.id);
return const OperationResult.success();
} catch (failure) {
_restoreComment(comment, location);
return OperationResult.failure(_errorMessage(failure));
} finally {
_deletingCommentIds.remove(comment.id);
_notify();
}
}
/// 评论点赞先乐观更新,再用服务端 `{liked,count}` 校准。
Future<OperationResult<LikeToggleResult>> toggleLike(
DramaComment comment,
) async {
if (_likingCommentIds.contains(comment.id)) {
return const OperationResult.failure(null);
}
final current = _findComment(comment.id) ?? comment;
_likingCommentIds.add(comment.id);
_replaceComment(
current.copyWith(
liked: !current.liked,
likeCount: current.liked
? (current.likeCount > 0 ? current.likeCount - 1 : 0)
: current.likeCount + 1,
),
);
_notify();
try {
final result = await _interactionRepository.toggleLike(
targetType: InteractionTargetType.comment,
targetId: comment.id,
);
_replaceComment(
current.copyWith(liked: result.liked, likeCount: result.count),
);
return OperationResult.success(result);
} catch (failure) {
_replaceComment(current);
return OperationResult.failure(_errorMessage(failure));
} finally {
_likingCommentIds.remove(comment.id);
_notify();
}
}
/// 举报评论,失败消息原样返回给界面展示。
Future<OperationResult<String>> report(
DramaComment comment, {
required ReportReason reason,
String description = '',
}) async {
try {
final reportId = await _interactionRepository.report(
ReportRequest(
targetType: InteractionTargetType.comment,
targetId: comment.id,
reasonCode: reason.apiValue,
description: description.trim(),
),
);
return OperationResult.success(reportId);
} catch (failure) {
return OperationResult.failure(_errorMessage(failure));
}
}
void _insertPublished(DramaComment comment, DramaComment? replyTo) {
final rootId = comment.rootId != '0'
? comment.rootId
: replyTo?.isTopLevel == true
? replyTo!.id
: replyTo?.rootId;
if (replyTo == null || rootId == null || rootId == '0') {
if (!_comments.any((item) => item.id == comment.id)) {
_comments.insert(0, comment);
}
return;
}
final replies = _replies.putIfAbsent(rootId, () => []);
if (!replies.any((item) => item.id == comment.id)) {
replies.insert(0, comment);
}
_expandedRoots.add(rootId);
final rootIndex = _comments.indexWhere((item) => item.id == rootId);
if (rootIndex >= 0) {
final root = _comments[rootIndex];
_comments[rootIndex] = root.copyWith(replyCount: root.replyCount + 1);
}
}
_CommentLocation? _removeComment(String id) {
final topIndex = _comments.indexWhere((item) => item.id == id);
if (topIndex >= 0) {
final removed = _comments.removeAt(topIndex);
return _CommentLocation(comment: removed, topIndex: topIndex);
}
for (final entry in _replies.entries) {
final index = entry.value.indexWhere((item) => item.id == id);
if (index < 0) continue;
final removed = entry.value.removeAt(index);
return _CommentLocation(
comment: removed,
rootId: entry.key,
replyIndex: index,
);
}
return null;
}
void _restoreComment(DramaComment comment, _CommentLocation? location) {
if (location == null) return;
if (location.topIndex != null) {
final index = location.topIndex!.clamp(0, _comments.length);
_comments.insert(index, comment);
return;
}
final replies = _replies.putIfAbsent(location.rootId!, () => []);
final index = location.replyIndex!.clamp(0, replies.length);
replies.insert(index, comment);
}
DramaComment? _findComment(String id) {
for (final comment in _comments) {
if (comment.id == id) return comment;
}
for (final replies in _replies.values) {
for (final comment in replies) {
if (comment.id == id) return comment;
}
}
return null;
}
void _replaceComment(DramaComment replacement) {
final topIndex = _comments.indexWhere((item) => item.id == replacement.id);
if (topIndex >= 0) {
_comments[topIndex] = replacement;
return;
}
for (final replies in _replies.values) {
final index = replies.indexWhere((item) => item.id == replacement.id);
if (index >= 0) {
replies[index] = replacement;
return;
}
}
}
String? _errorMessage(Object failure) =>
failure is ApiException ? failure.message : null;
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
class _CommentLocation {
const _CommentLocation({
required this.comment,
this.topIndex,
this.rootId,
this.replyIndex,
});
final DramaComment comment;
final int? topIndex;
final String? rootId;
final int? replyIndex;
}
@@ -1,80 +0,0 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/engagement_snapshot.dart';
import '../repositories/engagement_repository.dart';
/// Local demo interactions, indexed by drama ID. No public/social backend.
class EngagementController extends ChangeNotifier {
EngagementController(this._repository);
final EngagementRepository _repository;
final Set<String> _saved = {};
final Set<String> _liked = {};
final Map<String, List<String>> _comments = {};
Future<void> _loadFuture = Future<void>.value();
Future<void> _pendingSave = Future<void>.value();
bool isSaved(String dramaId) => _saved.contains(dramaId);
bool isLiked(String dramaId) => _liked.contains(dramaId);
List<String> commentsFor(String dramaId) =>
List.unmodifiable(_comments[dramaId] ?? const []);
Future<void> load() => _loadFuture = _loadFromRepository();
Future<void> _loadFromRepository() async {
try {
final snapshot = await _repository.load();
_saved.addAll(snapshot.savedDramaIds);
_liked.addAll(snapshot.likedDramaIds);
for (final entry in snapshot.commentsByDrama.entries) {
_comments[entry.key] = List.of(entry.value);
}
notifyListeners();
} catch (_) {
// Local preferences are best effort for this demo.
}
}
Future<void> toggleSaved(String dramaId) async {
await _loadFuture;
if (!_saved.add(dramaId)) _saved.remove(dramaId);
notifyListeners();
await _persist();
}
Future<void> toggleLiked(String dramaId) async {
await _loadFuture;
if (!_liked.add(dramaId)) _liked.remove(dramaId);
notifyListeners();
await _persist();
}
Future<void> addComment(String dramaId, String text) async {
await _loadFuture;
final clean = text.trim();
if (clean.isEmpty) return;
(_comments[dramaId] ??= []).add(
clean.length > 300 ? clean.substring(0, 300) : clean,
);
notifyListeners();
await _persist();
}
Future<void> _persist() {
final snapshot = EngagementSnapshot(
savedDramaIds: Set.of(_saved),
likedDramaIds: Set.of(_liked),
commentsByDrama: {
for (final entry in _comments.entries) entry.key: List.of(entry.value),
},
);
_pendingSave = _pendingSave
.then((_) => _repository.save(snapshot))
.catchError((Object _) {
// Keep the current in-memory state if local persistence fails.
});
return _pendingSave;
}
}
@@ -0,0 +1,107 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/interaction.dart';
import '../remote/api_client.dart';
import '../repositories/interaction_repository.dart';
/// 剧集点赞与举报的会话状态控制器。
///
/// 剧集契约暂未下发初始点赞状态与计数,本批只维护当前会话内的 liked 状态,
/// 不展示数字,也不伪造初始值。
class InteractionController extends ChangeNotifier {
InteractionController(this._repository);
final InteractionRepository _repository;
final Set<String> _likedDramaIds = {};
final Set<String> _busyDramaIds = {};
bool _disposed = false;
/// 当前会话内该剧是否已点赞。
bool isLiked(String dramaId) => _likedDramaIds.contains(dramaId);
/// 点赞请求是否进行中,用于防连点。
bool isBusy(String dramaId) => _busyDramaIds.contains(dramaId);
/// 乐观切换剧集点赞,失败时回滚并以服务端返回状态校准。
Future<OperationResult<LikeToggleResult>> toggleDramaLike(
String dramaId,
) async {
if (_busyDramaIds.contains(dramaId)) {
return const OperationResult.failure(null);
}
final wasLiked = _likedDramaIds.contains(dramaId);
_busyDramaIds.add(dramaId);
if (wasLiked) {
_likedDramaIds.remove(dramaId);
} else {
_likedDramaIds.add(dramaId);
}
_notify();
try {
final result = await _repository.toggleLike(
targetType: InteractionTargetType.drama,
targetId: dramaId,
);
if (result.liked) {
_likedDramaIds.add(dramaId);
} else {
_likedDramaIds.remove(dramaId);
}
return OperationResult.success(result);
} catch (failure) {
if (wasLiked) {
_likedDramaIds.add(dramaId);
} else {
_likedDramaIds.remove(dramaId);
}
return OperationResult.failure(_errorMessage(failure));
} finally {
_busyDramaIds.remove(dramaId);
_notify();
}
}
/// 举报剧集,失败消息原样返回给界面。
Future<OperationResult<String>> reportDrama(
String dramaId, {
required ReportReason reason,
String description = '',
}) async {
try {
final reportId = await _repository.report(
ReportRequest(
targetType: InteractionTargetType.drama,
targetId: dramaId,
reasonCode: reason.apiValue,
description: description.trim(),
),
);
return OperationResult.success(reportId);
} catch (failure) {
return OperationResult.failure(_errorMessage(failure));
}
}
/// 登录账号变化时清理会话内状态,避免跨账号串联。
void clear() {
if (_likedDramaIds.isEmpty && _busyDramaIds.isEmpty) return;
_likedDramaIds.clear();
_busyDramaIds.clear();
_notify();
}
String? _errorMessage(Object failure) =>
failure is ApiException ? failure.message : null;
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}