feat(flutter): 接入互动评论点赞举报
This commit is contained in:
1 parent
45f5f25640
commit
d361034226
39 files changed
+3298
-462
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user