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

+132
View File
@@ -0,0 +1,132 @@
// Author: fruit.com.cn
import 'json_numbers.dart';
/// 评论游标分页大小,契约 §1.7 使用游标而非页码。
const int commentPageSize = 20;
/// 评论列表页。
class CommentPage {
const CommentPage({
this.comments = const [],
this.nextCursor,
this.hasMore = false,
});
final List<DramaComment> comments;
final String? nextCursor;
final bool hasMore;
/// 解析后端 `CursorResult{list,nextCursor,hasMore}`。
factory CommentPage.fromJson(Map<String, dynamic> json) {
final rawList = json['list'];
return CommentPage(
comments: [
if (rawList is List)
for (final item in rawList)
if (item is Map<String, dynamic>) DramaComment.fromJson(item),
],
nextCursor: json['nextCursor'] == null
? null
: jsonToId(json['nextCursor']),
hasMore: json['hasMore'] == true,
);
}
}
/// 服务端评论视图,字段对应 `CommentRespVO`。
class DramaComment {
const DramaComment({
required this.id,
required this.dramaId,
required this.episodeId,
required this.parentId,
required this.rootId,
required this.userId,
required this.uid,
required this.nickname,
required this.avatarUrl,
required this.content,
required this.status,
required this.auditReason,
required this.likeCount,
required this.replyCount,
required this.reportCount,
required this.createdAt,
required this.mine,
required this.liked,
});
final String id;
final String dramaId;
final String episodeId;
final String parentId;
final String rootId;
final String userId;
final String uid;
final String nickname;
final String avatarUrl;
final String content;
final String status;
final String auditReason;
final int likeCount;
final int replyCount;
final int reportCount;
final DateTime? createdAt;
final bool mine;
final bool liked;
/// 待人工审核的评论仅本人可见。
bool get isPending => status == 'pending';
/// 顶层评论没有父评论。
bool get isTopLevel => parentId.isEmpty || parentId == '0';
/// 解析 `CommentRespVO`,所有 ID 字段只走字符串解析。
factory DramaComment.fromJson(Map<String, dynamic> json) => DramaComment(
id: jsonToId(json['id']),
dramaId: jsonToId(json['dramaId']),
episodeId: jsonToId(json['episodeId'], fallback: '0'),
parentId: jsonToId(json['parentId'], fallback: '0'),
rootId: jsonToId(json['rootId'], fallback: '0'),
userId: jsonToId(json['userId']),
uid: json['uid']?.toString() ?? '',
nickname: json['nickname']?.toString() ?? '',
avatarUrl: json['avatarUrl']?.toString() ?? '',
content: json['content']?.toString() ?? '',
status: json['status']?.toString() ?? '',
auditReason: json['auditReason']?.toString() ?? '',
likeCount: jsonToInt(json['likeCount']),
replyCount: jsonToInt(json['replyCount']),
reportCount: jsonToInt(json['reportCount']),
createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? ''),
mine: json['mine'] == true,
liked: json['liked'] == true,
);
/// 局部更新点赞或回复数,列表中的其他字段保持不变。
DramaComment copyWith({
int? likeCount,
int? replyCount,
bool? liked,
}) => DramaComment(
id: id,
dramaId: dramaId,
episodeId: episodeId,
parentId: parentId,
rootId: rootId,
userId: userId,
uid: uid,
nickname: nickname,
avatarUrl: avatarUrl,
content: content,
status: status,
auditReason: auditReason,
likeCount: likeCount ?? this.likeCount,
replyCount: replyCount ?? this.replyCount,
reportCount: reportCount,
createdAt: createdAt,
mine: mine,
liked: liked ?? this.liked,
);
}
@@ -1,13 +0,0 @@
// Author: fruit.com.cn
class EngagementSnapshot {
const EngagementSnapshot({
this.savedDramaIds = const {},
this.likedDramaIds = const {},
this.commentsByDrama = const {},
});
final Set<String> savedDramaIds;
final Set<String> likedDramaIds;
final Map<String, List<String>> commentsByDrama;
}
+70
View File
@@ -0,0 +1,70 @@
// Author: fruit.com.cn
import 'json_numbers.dart';
/// 互动目标类型,契约只允许短剧与评论。
enum InteractionTargetType {
drama('DRAMA'),
comment('COMMENT');
const InteractionTargetType(this.apiValue);
final String apiValue;
}
/// 客户端固定举报原因,后端当前未限制枚举值。
enum ReportReason {
spam('SPAM'),
abuse('ABUSE'),
porn('PORN'),
ad('AD'),
other('OTHER');
const ReportReason(this.apiValue);
final String apiValue;
}
/// 一次性互动请求,供仓库和测试断言使用。
class ReportRequest {
const ReportRequest({
required this.targetType,
required this.targetId,
required this.reasonCode,
this.description = '',
});
final InteractionTargetType targetType;
final String targetId;
final String reasonCode;
final String description;
}
/// 点赞切换后的服务端状态。
class LikeToggleResult {
const LikeToggleResult({required this.liked, required this.count});
final bool liked;
final int count;
/// 计数按 Long 字符串兼容解析。
factory LikeToggleResult.fromJson(Map<String, dynamic> json) =>
LikeToggleResult(
liked: json['liked'] == true,
count: jsonToInt(json['count']),
);
}
/// 控制器统一返回结果;失败消息可直接展示后端 `message`。
class OperationResult<T> {
const OperationResult._(this.data, this.errorMessage);
const OperationResult.success([T? data]) : this._(data, null);
const OperationResult.failure(this.errorMessage) : data = null;
final T? data;
final String? errorMessage;
bool get isSuccess => errorMessage == null;
}
+7
View File
@@ -11,3 +11,10 @@ int jsonToInt(dynamic value, {int fallback = 0}) {
if (value is String) return int.tryParse(value.trim()) ?? fallback;
return fallback;
}
/// ID 字段统一转字符串;后端按契约下发字符串,同时兼容测试或旧数据的数字形式。
String jsonToId(dynamic value, {String fallback = ''}) {
if (value is String) return value;
if (value is num) return value.toString();
return fallback;
}
+59
View File
@@ -0,0 +1,59 @@
// Author: fruit.com.cn
import '../models/comment.dart';
import 'api_client.dart';
/// 评论接口,对应契约 §1.7 的 `/app/comments*`。
class CommentApi {
const CommentApi(this._client);
final ApiClient _client;
/// 拉取游标分页;`rootId` 用于加载某一楼层的回复。
Future<CommentPage> page({
required String dramaId,
String? rootId,
String? cursor,
int size = commentPageSize,
}) async {
final data = await _client.get(
'/app/comments',
query: {
'dramaId': dramaId,
'rootId': ?rootId,
'cursor': ?cursor,
'size': '$size',
},
);
if (data is! Map<String, dynamic>) return const CommentPage();
return CommentPage.fromJson(data);
}
/// 发表评论或回复;回复只提交 `parentId`,不自行拼层级。
Future<DramaComment> publish({
required String dramaId,
String? episodeId,
required String content,
String? parentId,
}) async {
final data = await _client.post(
'/app/comments',
body: {
'dramaId': dramaId,
if (episodeId != null && episodeId.isNotEmpty)
'episodeId': episodeId,
'content': content,
if (parentId != null && parentId.isNotEmpty) 'parentId': parentId,
},
);
if (data is! Map<String, dynamic>) {
throw const ApiException(500, '评论响应格式错误');
}
return DramaComment.fromJson(data);
}
/// 删除自己的评论。
Future<void> delete(String commentId) async {
await _client.delete('/app/comments/$commentId');
}
}
+44
View File
@@ -0,0 +1,44 @@
// Author: fruit.com.cn
import '../models/interaction.dart';
import 'api_client.dart';
/// 点赞与举报接口,对应契约 §1.7。
class InteractionApi {
const InteractionApi(this._client);
final ApiClient _client;
/// 切换点赞,服务端返回的状态与计数为最终真相。
Future<LikeToggleResult> toggleLike({
required InteractionTargetType targetType,
required String targetId,
}) async {
final data = await _client.post(
'/app/likes/toggle',
body: {
'targetType': targetType.apiValue,
'targetId': targetId,
},
);
if (data is! Map<String, dynamic>) {
throw const ApiException(500, '点赞响应格式错误');
}
return LikeToggleResult.fromJson(data);
}
/// 提交举报,返回工单 ID。
Future<String> report(ReportRequest request) async {
final data = await _client.post(
'/app/reports',
body: {
'targetType': request.targetType.apiValue,
'targetId': request.targetId,
'reasonCode': request.reasonCode,
if (request.description.isNotEmpty)
'description': request.description,
},
);
return data?.toString() ?? '';
}
}
@@ -0,0 +1,62 @@
// Author: fruit.com.cn
import '../models/comment.dart';
import '../remote/comment_api.dart';
/// 评论数据来源抽象,页面与控制器不直接依赖 HTTP。
abstract interface class CommentRepository {
/// 拉取评论或回复的分页。
Future<CommentPage> page({
required String dramaId,
String? rootId,
String? cursor,
int size = commentPageSize,
});
/// 发表评论或回复。
Future<DramaComment> publish({
required String dramaId,
String? episodeId,
required String content,
String? parentId,
});
/// 删除指定评论。
Future<void> delete(String commentId);
}
/// HTTP 评论仓库。
class HttpCommentRepository implements CommentRepository {
const HttpCommentRepository(this._api);
final CommentApi _api;
@override
Future<CommentPage> page({
required String dramaId,
String? rootId,
String? cursor,
int size = commentPageSize,
}) => _api.page(
dramaId: dramaId,
rootId: rootId,
cursor: cursor,
size: size,
);
@override
Future<DramaComment> publish({
required String dramaId,
String? episodeId,
required String content,
String? parentId,
}) => _api.publish(
dramaId: dramaId,
episodeId: episodeId,
content: content,
parentId: parentId,
);
@override
Future<void> delete(String commentId) => _api.delete(commentId);
}
@@ -1,70 +0,0 @@
// Author: fruit.com.cn
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/engagement_snapshot.dart';
abstract interface class EngagementRepository {
Future<EngagementSnapshot> load();
Future<void> save(EngagementSnapshot snapshot);
}
class SharedPreferencesEngagementRepository implements EngagementRepository {
SharedPreferencesEngagementRepository({SharedPreferencesAsync? preferences})
: _preferences = preferences ?? SharedPreferencesAsync();
static const _key = 'engagement.v1.demo-local-v1';
final SharedPreferencesAsync _preferences;
@override
Future<EngagementSnapshot> load() async {
final raw = await _preferences.getString(_key);
if (raw == null) return const EngagementSnapshot();
try {
final json = jsonDecode(raw);
if (json is! Map<String, dynamic>) return const EngagementSnapshot();
Set<String> ids(Object? value) =>
value is List ? value.whereType<String>().toSet() : <String>{};
final comments = <String, List<String>>{};
final rawComments = json['comments'];
if (rawComments is Map<String, dynamic>) {
for (final entry in rawComments.entries) {
if (entry.value is List) {
comments[entry.key] = (entry.value as List)
.whereType<String>()
.toList();
}
}
}
return EngagementSnapshot(
savedDramaIds: ids(json['saved']),
likedDramaIds: ids(json['liked']),
commentsByDrama: comments,
);
} catch (_) {
return const EngagementSnapshot();
}
}
@override
Future<void> save(EngagementSnapshot snapshot) => _preferences.setString(
_key,
jsonEncode({
'saved': snapshot.savedDramaIds.toList(),
'liked': snapshot.likedDramaIds.toList(),
'comments': snapshot.commentsByDrama,
}),
);
}
class InMemoryEngagementRepository implements EngagementRepository {
EngagementSnapshot snapshot = const EngagementSnapshot();
@override
Future<EngagementSnapshot> load() async => snapshot;
@override
Future<void> save(EngagementSnapshot value) async => snapshot = value;
}
@@ -0,0 +1,32 @@
// Author: fruit.com.cn
import '../models/interaction.dart';
import '../remote/interaction_api.dart';
/// 点赞与举报数据来源抽象。
abstract interface class InteractionRepository {
/// 切换目标点赞状态。
Future<LikeToggleResult> toggleLike({
required InteractionTargetType targetType,
required String targetId,
});
/// 提交举报并返回工单 ID。
Future<String> report(ReportRequest request);
}
/// HTTP 互动仓库。
class HttpInteractionRepository implements InteractionRepository {
const HttpInteractionRepository(this._api);
final InteractionApi _api;
@override
Future<LikeToggleResult> toggleLike({
required InteractionTargetType targetType,
required String targetId,
}) => _api.toggleLike(targetType: targetType, targetId: targetId);
@override
Future<String> report(ReportRequest request) => _api.report(request);
}
@@ -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();
}
}