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

+24 -7
View File
@@ -10,15 +10,18 @@ import 'core/data/remote/api_client.dart';
import 'core/data/remote/auth_api.dart';
import 'core/data/remote/catalog_api.dart';
import 'core/data/remote/category_api.dart';
import 'core/data/remote/comment_api.dart';
import 'core/data/remote/download_api.dart';
import 'core/data/remote/interaction_api.dart';
import 'core/data/remote/library_api.dart';
import 'core/data/remote/playback_api.dart';
import 'core/data/remote/watch_api.dart';
import 'core/data/repositories/catalog_repository.dart';
import 'core/data/repositories/category_repository.dart';
import 'core/data/repositories/comment_repository.dart';
import 'core/data/repositories/download_repository.dart';
import 'core/data/repositories/engagement_repository.dart';
import 'core/data/repositories/favorite_repository.dart';
import 'core/data/repositories/interaction_repository.dart';
import 'core/data/repositories/playback_repository.dart';
import 'core/data/repositories/watch_history_repository.dart';
import 'core/data/services/auth_controller.dart';
@@ -26,8 +29,8 @@ import 'core/data/services/auth_session_store.dart';
import 'core/data/services/catalog_controller.dart';
import 'core/data/services/category_controller.dart';
import 'core/data/services/download_controller.dart';
import 'core/data/services/engagement_controller.dart';
import 'core/data/services/favorite_controller.dart';
import 'core/data/services/interaction_controller.dart';
import 'core/data/services/playback_session.dart';
import 'core/data/services/video_player_port.dart';
import 'l10n/app_localizations.dart';
@@ -43,6 +46,8 @@ class LumaDramaApp extends StatefulWidget {
this.watchHistoryRepository,
this.favoriteRepository,
this.playbackRepository,
this.commentRepository,
this.interactionRepository,
});
final PlayerFactory playerFactory;
@@ -54,6 +59,8 @@ class LumaDramaApp extends StatefulWidget {
final WatchHistoryRepository? watchHistoryRepository;
final FavoriteRepository? favoriteRepository;
final PlaybackRepository? playbackRepository;
final CommentRepository? commentRepository;
final InteractionRepository? interactionRepository;
@override
State<LumaDramaApp> createState() => _LumaDramaAppState();
@@ -113,6 +120,16 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
_downloadRepository,
);
late final CommentRepository _commentRepository =
widget.commentRepository ?? HttpCommentRepository(CommentApi(_apiClient));
late final InteractionRepository _interactionRepository =
widget.interactionRepository ??
HttpInteractionRepository(InteractionApi(_apiClient));
late final InteractionController _interactionController =
InteractionController(_interactionRepository);
@override
void initState() {
super.initState();
@@ -128,6 +145,7 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
void _onAuthChanged() {
if (_auth.isSignedIn == _wasSignedIn) return;
_wasSignedIn = _auth.isSignedIn;
_interactionController.clear();
if (_auth.isSignedIn) {
unawaited(_favoriteController.load());
} else {
@@ -141,6 +159,7 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
_auth.removeListener(_onAuthChanged);
_favoriteController.dispose();
_downloadController.dispose();
_interactionController.dispose();
_apiClient.close();
super.dispose();
}
@@ -154,6 +173,8 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
Provider<PlaybackRepository>.value(value: _playback),
Provider<WatchHistoryRepository>.value(value: _watchHistory),
Provider<FavoriteRepository>.value(value: _favorites),
Provider<CommentRepository>.value(value: _commentRepository),
Provider<InteractionRepository>.value(value: _interactionRepository),
ChangeNotifierProvider(
create: (_) => CatalogController(_catalogRepository)..load(),
),
@@ -163,11 +184,7 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
ChangeNotifierProvider(create: (_) => LocaleController()..load()),
ChangeNotifierProvider.value(value: _favoriteController),
ChangeNotifierProvider.value(value: _downloadController),
ChangeNotifierProvider(
create: (_) =>
EngagementController(SharedPreferencesEngagementRepository())
..load(),
),
ChangeNotifierProvider.value(value: _interactionController),
ChangeNotifierProvider.value(value: _auth),
],
child: Consumer<LocaleController>(
+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();
}
}
+1
View File
@@ -11,6 +11,7 @@ String errorText(AppLocalizations l10n, String? code, String fallback) {
'progressError' => l10n.progressError,
'favoritesError' => l10n.favoritesError,
'ticketError' => l10n.playbackError,
'commentsLoadFailed' => l10n.commentsLoadFailed,
final value => value,
};
}
+41 -1
View File
@@ -65,10 +65,50 @@
"save": "Save",
"comments": "Comments",
"share": "Share",
"localOnlyComments": "Only visible on this device",
"noComments": "No comments yet.",
"writeComment": "Write a comment",
"postComment": "Post",
"loadMore": "Load more",
"commentsLoadFailed": "Comments could not be loaded.",
"commentPostFailed": "Could not post your comment.",
"delete": "Delete",
"deleteComment": "Delete comment",
"deleteCommentTitle": "Delete this comment?",
"deleteCommentMessage": "This removes your comment from the conversation.",
"commentDeleteFailed": "Could not delete this comment.",
"underReview": "Under review",
"reply": "Reply",
"replyingTo": "Replying to {name}",
"@replyingTo": {
"placeholders": {
"name": {}
}
},
"cancelReply": "Cancel reply",
"viewReplies": "View replies ({count})",
"@viewReplies": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"hideReplies": "Hide replies",
"anonymousUser": "Viewer",
"more": "More",
"likeFailed": "Could not update like. Please retry.",
"reportComment": "Report comment",
"reportDrama": "Report drama",
"reportReason": "Reason",
"reportReasonSpam": "Spam",
"reportReasonAbuse": "Harassment",
"reportReasonPorn": "Sexual content",
"reportReasonAd": "Advertising",
"reportReasonOther": "Other",
"reportDescriptionHint": "Describe the issue (optional)",
"reportSubmit": "Submit report",
"reportSubmitted": "Report submitted. Thank you.",
"reportFailed": "Could not submit report.",
"shareFailed": "Could not open the share sheet.",
"nowPlaying": "Playing now",
"watchFullscreen": "Watch in landscape",
+41 -1
View File
@@ -65,10 +65,50 @@
"save": "Simpan",
"comments": "Komentar",
"share": "Bagikan",
"localOnlyComments": "Hanya terlihat di perangkat ini",
"noComments": "Belum ada komentar.",
"writeComment": "Tulis komentar",
"postComment": "Kirim",
"loadMore": "Muat lebih banyak",
"commentsLoadFailed": "Komentar tidak dapat dimuat.",
"commentPostFailed": "Komentar tidak dapat dikirim.",
"delete": "Hapus",
"deleteComment": "Hapus komentar",
"deleteCommentTitle": "Hapus komentar ini?",
"deleteCommentMessage": "Komentar ini akan dihapus dari percakapan.",
"commentDeleteFailed": "Komentar tidak dapat dihapus.",
"underReview": "Sedang ditinjau",
"reply": "Balas",
"replyingTo": "Membalas {name}",
"@replyingTo": {
"placeholders": {
"name": {}
}
},
"cancelReply": "Batal membalas",
"viewReplies": "Lihat balasan ({count})",
"@viewReplies": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"hideReplies": "Sembunyikan balasan",
"anonymousUser": "Penonton",
"more": "Lainnya",
"likeFailed": "Status suka tidak dapat diperbarui.",
"reportComment": "Laporkan komentar",
"reportDrama": "Laporkan drama",
"reportReason": "Alasan",
"reportReasonSpam": "Spam",
"reportReasonAbuse": "Pelecehan",
"reportReasonPorn": "Konten seksual",
"reportReasonAd": "Iklan",
"reportReasonOther": "Lainnya",
"reportDescriptionHint": "Jelaskan masalah (opsional)",
"reportSubmit": "Kirim laporan",
"reportSubmitted": "Laporan terkirim. Terima kasih.",
"reportFailed": "Laporan tidak dapat dikirim.",
"shareFailed": "Tidak bisa membuka menu berbagi.",
"nowPlaying": "Sedang diputar",
"watchFullscreen": "Tonton mode lanskap",
+174 -6
View File
@@ -448,12 +448,6 @@ abstract class AppLocalizations {
/// **'Share'**
String get share;
/// No description provided for @localOnlyComments.
///
/// In en, this message translates to:
/// **'Only visible on this device'**
String get localOnlyComments;
/// No description provided for @noComments.
///
/// In en, this message translates to:
@@ -472,6 +466,180 @@ abstract class AppLocalizations {
/// **'Post'**
String get postComment;
/// No description provided for @loadMore.
///
/// In en, this message translates to:
/// **'Load more'**
String get loadMore;
/// No description provided for @commentsLoadFailed.
///
/// In en, this message translates to:
/// **'Comments could not be loaded.'**
String get commentsLoadFailed;
/// No description provided for @commentPostFailed.
///
/// In en, this message translates to:
/// **'Could not post your comment.'**
String get commentPostFailed;
/// No description provided for @delete.
///
/// In en, this message translates to:
/// **'Delete'**
String get delete;
/// No description provided for @deleteComment.
///
/// In en, this message translates to:
/// **'Delete comment'**
String get deleteComment;
/// No description provided for @deleteCommentTitle.
///
/// In en, this message translates to:
/// **'Delete this comment?'**
String get deleteCommentTitle;
/// No description provided for @deleteCommentMessage.
///
/// In en, this message translates to:
/// **'This removes your comment from the conversation.'**
String get deleteCommentMessage;
/// No description provided for @commentDeleteFailed.
///
/// In en, this message translates to:
/// **'Could not delete this comment.'**
String get commentDeleteFailed;
/// No description provided for @underReview.
///
/// In en, this message translates to:
/// **'Under review'**
String get underReview;
/// No description provided for @reply.
///
/// In en, this message translates to:
/// **'Reply'**
String get reply;
/// No description provided for @replyingTo.
///
/// In en, this message translates to:
/// **'Replying to {name}'**
String replyingTo(Object name);
/// No description provided for @cancelReply.
///
/// In en, this message translates to:
/// **'Cancel reply'**
String get cancelReply;
/// No description provided for @viewReplies.
///
/// In en, this message translates to:
/// **'View replies ({count})'**
String viewReplies(int count);
/// No description provided for @hideReplies.
///
/// In en, this message translates to:
/// **'Hide replies'**
String get hideReplies;
/// No description provided for @anonymousUser.
///
/// In en, this message translates to:
/// **'Viewer'**
String get anonymousUser;
/// No description provided for @more.
///
/// In en, this message translates to:
/// **'More'**
String get more;
/// No description provided for @likeFailed.
///
/// In en, this message translates to:
/// **'Could not update like. Please retry.'**
String get likeFailed;
/// No description provided for @reportComment.
///
/// In en, this message translates to:
/// **'Report comment'**
String get reportComment;
/// No description provided for @reportDrama.
///
/// In en, this message translates to:
/// **'Report drama'**
String get reportDrama;
/// No description provided for @reportReason.
///
/// In en, this message translates to:
/// **'Reason'**
String get reportReason;
/// No description provided for @reportReasonSpam.
///
/// In en, this message translates to:
/// **'Spam'**
String get reportReasonSpam;
/// No description provided for @reportReasonAbuse.
///
/// In en, this message translates to:
/// **'Harassment'**
String get reportReasonAbuse;
/// No description provided for @reportReasonPorn.
///
/// In en, this message translates to:
/// **'Sexual content'**
String get reportReasonPorn;
/// No description provided for @reportReasonAd.
///
/// In en, this message translates to:
/// **'Advertising'**
String get reportReasonAd;
/// No description provided for @reportReasonOther.
///
/// In en, this message translates to:
/// **'Other'**
String get reportReasonOther;
/// No description provided for @reportDescriptionHint.
///
/// In en, this message translates to:
/// **'Describe the issue (optional)'**
String get reportDescriptionHint;
/// No description provided for @reportSubmit.
///
/// In en, this message translates to:
/// **'Submit report'**
String get reportSubmit;
/// No description provided for @reportSubmitted.
///
/// In en, this message translates to:
/// **'Report submitted. Thank you.'**
String get reportSubmitted;
/// No description provided for @reportFailed.
///
/// In en, this message translates to:
/// **'Could not submit report.'**
String get reportFailed;
/// No description provided for @shareFailed.
///
/// In en, this message translates to:
+92 -3
View File
@@ -187,9 +187,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get share => 'Share';
@override
String get localOnlyComments => 'Only visible on this device';
@override
String get noComments => 'No comments yet.';
@@ -199,6 +196,98 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get postComment => 'Post';
@override
String get loadMore => 'Load more';
@override
String get commentsLoadFailed => 'Comments could not be loaded.';
@override
String get commentPostFailed => 'Could not post your comment.';
@override
String get delete => 'Delete';
@override
String get deleteComment => 'Delete comment';
@override
String get deleteCommentTitle => 'Delete this comment?';
@override
String get deleteCommentMessage =>
'This removes your comment from the conversation.';
@override
String get commentDeleteFailed => 'Could not delete this comment.';
@override
String get underReview => 'Under review';
@override
String get reply => 'Reply';
@override
String replyingTo(Object name) {
return 'Replying to $name';
}
@override
String get cancelReply => 'Cancel reply';
@override
String viewReplies(int count) {
return 'View replies ($count)';
}
@override
String get hideReplies => 'Hide replies';
@override
String get anonymousUser => 'Viewer';
@override
String get more => 'More';
@override
String get likeFailed => 'Could not update like. Please retry.';
@override
String get reportComment => 'Report comment';
@override
String get reportDrama => 'Report drama';
@override
String get reportReason => 'Reason';
@override
String get reportReasonSpam => 'Spam';
@override
String get reportReasonAbuse => 'Harassment';
@override
String get reportReasonPorn => 'Sexual content';
@override
String get reportReasonAd => 'Advertising';
@override
String get reportReasonOther => 'Other';
@override
String get reportDescriptionHint => 'Describe the issue (optional)';
@override
String get reportSubmit => 'Submit report';
@override
String get reportSubmitted => 'Report submitted. Thank you.';
@override
String get reportFailed => 'Could not submit report.';
@override
String get shareFailed => 'Could not open the share sheet.';
+92 -3
View File
@@ -187,9 +187,6 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get share => 'Bagikan';
@override
String get localOnlyComments => 'Hanya terlihat di perangkat ini';
@override
String get noComments => 'Belum ada komentar.';
@@ -199,6 +196,98 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get postComment => 'Kirim';
@override
String get loadMore => 'Muat lebih banyak';
@override
String get commentsLoadFailed => 'Komentar tidak dapat dimuat.';
@override
String get commentPostFailed => 'Komentar tidak dapat dikirim.';
@override
String get delete => 'Hapus';
@override
String get deleteComment => 'Hapus komentar';
@override
String get deleteCommentTitle => 'Hapus komentar ini?';
@override
String get deleteCommentMessage =>
'Komentar ini akan dihapus dari percakapan.';
@override
String get commentDeleteFailed => 'Komentar tidak dapat dihapus.';
@override
String get underReview => 'Sedang ditinjau';
@override
String get reply => 'Balas';
@override
String replyingTo(Object name) {
return 'Membalas $name';
}
@override
String get cancelReply => 'Batal membalas';
@override
String viewReplies(int count) {
return 'Lihat balasan ($count)';
}
@override
String get hideReplies => 'Sembunyikan balasan';
@override
String get anonymousUser => 'Penonton';
@override
String get more => 'Lainnya';
@override
String get likeFailed => 'Status suka tidak dapat diperbarui.';
@override
String get reportComment => 'Laporkan komentar';
@override
String get reportDrama => 'Laporkan drama';
@override
String get reportReason => 'Alasan';
@override
String get reportReasonSpam => 'Spam';
@override
String get reportReasonAbuse => 'Pelecehan';
@override
String get reportReasonPorn => 'Konten seksual';
@override
String get reportReasonAd => 'Iklan';
@override
String get reportReasonOther => 'Lainnya';
@override
String get reportDescriptionHint => 'Jelaskan masalah (opsional)';
@override
String get reportSubmit => 'Kirim laporan';
@override
String get reportSubmitted => 'Laporan terkirim. Terima kasih.';
@override
String get reportFailed => 'Laporan tidak dapat dikirim.';
@override
String get shareFailed => 'Tidak bisa membuka menu berbagi.';
+91 -3
View File
@@ -184,9 +184,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get share => '分享';
@override
String get localOnlyComments => '仅在本机可见';
@override
String get noComments => '还没有评论。';
@@ -196,6 +193,97 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get postComment => '发布';
@override
String get loadMore => '加载更多';
@override
String get commentsLoadFailed => '评论加载失败,请重试。';
@override
String get commentPostFailed => '评论发布失败,请重试。';
@override
String get delete => '删除';
@override
String get deleteComment => '删除评论';
@override
String get deleteCommentTitle => '删除这条评论?';
@override
String get deleteCommentMessage => '删除后,这条评论将从评论列表中移除。';
@override
String get commentDeleteFailed => '评论删除失败,请重试。';
@override
String get underReview => '审核中';
@override
String get reply => '回复';
@override
String replyingTo(Object name) {
return '回复 $name';
}
@override
String get cancelReply => '取消回复';
@override
String viewReplies(int count) {
return '查看回复($count)';
}
@override
String get hideReplies => '收起回复';
@override
String get anonymousUser => '观众';
@override
String get more => '更多';
@override
String get likeFailed => '点赞状态更新失败,请重试。';
@override
String get reportComment => '举报评论';
@override
String get reportDrama => '举报短剧';
@override
String get reportReason => '举报原因';
@override
String get reportReasonSpam => '垃圾广告';
@override
String get reportReasonAbuse => '辱骂骚扰';
@override
String get reportReasonPorn => '色情低俗';
@override
String get reportReasonAd => '引流广告';
@override
String get reportReasonOther => '其他';
@override
String get reportDescriptionHint => '补充说明(选填)';
@override
String get reportSubmit => '提交举报';
@override
String get reportSubmitted => '举报已提交,感谢反馈。';
@override
String get reportFailed => '举报提交失败,请重试。';
@override
String get shareFailed => '无法打开系统分享面板。';
+41 -1
View File
@@ -65,10 +65,50 @@
"save": "收藏",
"comments": "评论",
"share": "分享",
"localOnlyComments": "仅在本机可见",
"noComments": "还没有评论。",
"writeComment": "写评论",
"postComment": "发布",
"loadMore": "加载更多",
"commentsLoadFailed": "评论加载失败,请重试。",
"commentPostFailed": "评论发布失败,请重试。",
"delete": "删除",
"deleteComment": "删除评论",
"deleteCommentTitle": "删除这条评论?",
"deleteCommentMessage": "删除后,这条评论将从评论列表中移除。",
"commentDeleteFailed": "评论删除失败,请重试。",
"underReview": "审核中",
"reply": "回复",
"replyingTo": "回复 {name}",
"@replyingTo": {
"placeholders": {
"name": {}
}
},
"cancelReply": "取消回复",
"viewReplies": "查看回复({count})",
"@viewReplies": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"hideReplies": "收起回复",
"anonymousUser": "观众",
"more": "更多",
"likeFailed": "点赞状态更新失败,请重试。",
"reportComment": "举报评论",
"reportDrama": "举报短剧",
"reportReason": "举报原因",
"reportReasonSpam": "垃圾广告",
"reportReasonAbuse": "辱骂骚扰",
"reportReasonPorn": "色情低俗",
"reportReasonAd": "引流广告",
"reportReasonOther": "其他",
"reportDescriptionHint": "补充说明(选填)",
"reportSubmit": "提交举报",
"reportSubmitted": "举报已提交,感谢反馈。",
"reportFailed": "举报提交失败,请重试。",
"shareFailed": "无法打开系统分享面板。",
"nowPlaying": "正在播放",
"watchFullscreen": "横屏全屏观看",
@@ -8,17 +8,21 @@ import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/data/services/favorite_controller.dart';
import '../../core/data/services/interaction_controller.dart';
import '../../core/formatting/error_text.dart';
import '../../core/formatting/localized_content.dart';
import '../../core/widgets/remote_image.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/download_action.dart';
import '../../l10n/app_localizations.dart';
import '../interaction/widgets/report_dialog.dart';
import 'drama_detail_binding.dart';
import 'drama_detail_logic.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/repositories/playback_repository.dart';
const String _reportMenuValue = 'report';
class DramaDetailPage extends StatefulWidget {
const DramaDetailPage({super.key, required this.drama});
final Drama drama;
@@ -138,6 +142,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
backgroundColor: const Color(0xFF111111),
foregroundColor: Colors.white,
actions: [
_reportAction(context, l10n),
_downloadAction(context, l10n),
_favoriteAction(context, l10n),
],
@@ -291,6 +296,42 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
);
}
/// 详情页更多菜单:本批只提供举报短剧。
Widget _reportAction(BuildContext context, AppLocalizations l10n) {
return PopupMenuButton<String>(
key: const Key('detail-more'),
tooltip: l10n.more,
onSelected: (_) => unawaited(_reportDrama(l10n)),
itemBuilder: (_) => [
PopupMenuItem(
value: _reportMenuValue,
child: Text(l10n.reportDrama),
),
],
);
}
Future<void> _reportDrama(AppLocalizations l10n) async {
if (!await ensureSignedIn(context) || !mounted) return;
final draft = await showReportDialog(context, title: l10n.reportDrama);
if (draft == null || !mounted) return;
final result = await context.read<InteractionController>().reportDrama(
logic.drama.id,
reason: draft.reason,
description: draft.description,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
result.isSuccess
? l10n.reportSubmitted
: result.errorMessage ?? l10n.reportFailed,
),
),
);
}
/// 收藏按钮:状态取自服务端(乐观更新,失败回滚并提示)。
Widget _favoriteAction(BuildContext context, AppLocalizations l10n) {
final favorites = context.watch<FavoriteController>();
@@ -0,0 +1,125 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/data/models/interaction.dart';
import '../../../l10n/app_localizations.dart';
const int _maxDescriptionLength = 300;
/// 举报弹窗收集的结果。
class ReportDialogResult {
const ReportDialogResult({
required this.reason,
required this.description,
});
final ReportReason reason;
final String description;
}
/// 打开举报原因单选弹窗;取消时返回 null。
Future<ReportDialogResult?> showReportDialog(
BuildContext context, {
required String title,
}) => showDialog<ReportDialogResult>(
context: context,
builder: (_) => _ReportDialog(title: title),
);
class _ReportDialog extends StatefulWidget {
const _ReportDialog({required this.title});
final String title;
@override
State<_ReportDialog> createState() => _ReportDialogState();
}
class _ReportDialogState extends State<_ReportDialog> {
final _description = TextEditingController();
ReportReason _reason = ReportReason.spam;
@override
void dispose() {
_description.dispose();
super.dispose();
}
void _submit() {
Navigator.of(context).pop(
ReportDialogResult(
reason: _reason,
description: _description.text.trim(),
),
);
}
String _reasonLabel(AppLocalizations l10n, ReportReason reason) =>
switch (reason) {
ReportReason.spam => l10n.reportReasonSpam,
ReportReason.abuse => l10n.reportReasonAbuse,
ReportReason.porn => l10n.reportReasonPorn,
ReportReason.ad => l10n.reportReasonAd,
ReportReason.other => l10n.reportReasonOther,
};
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return AlertDialog(
title: Text(widget.title),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.reportReason),
RadioGroup<ReportReason>(
groupValue: _reason,
onChanged: (value) {
if (value == null) return;
setState(() => _reason = value);
},
child: Column(
children: [
for (final reason in ReportReason.values)
RadioListTile<ReportReason>(
key: Key('report-reason-${reason.apiValue}'),
value: reason,
contentPadding: EdgeInsets.zero,
dense: true,
title: Text(_reasonLabel(l10n, reason)),
),
],
),
),
const SizedBox(height: 8),
TextField(
key: const Key('report-description'),
controller: _description,
maxLength: _maxDescriptionLength,
maxLines: 3,
minLines: 2,
decoration: InputDecoration(
hintText: l10n.reportDescriptionHint,
border: const OutlineInputBorder(),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.cancel),
),
FilledButton(
key: const Key('report-submit'),
onPressed: _submit,
child: Text(l10n.reportSubmit),
),
],
);
}
}
+4 -4
View File
@@ -9,7 +9,7 @@ import '../../core/formatting/localized_content.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/auth_controller.dart';
import '../../core/data/services/engagement_controller.dart';
import '../../core/data/services/interaction_controller.dart';
import '../../core/data/services/favorite_controller.dart';
import '../../core/formatting/error_text.dart';
import '../../core/widgets/remote_image.dart';
@@ -474,14 +474,14 @@ class _MyPageState extends State<MyPage> {
logic.filter == 0 ||
(logic.filter == 1 && fraction < 0.99) ||
(logic.filter == 2 && fraction >= 0.99);
final engagement = context.watch<EngagementController>();
// 收藏以服务端为准(收藏列表接口返回卡片结构);点赞仍是本地状态(K3 接互动)。
final interactions = context.watch<InteractionController>();
// 收藏以服务端为准;剧集点赞暂按当前会话状态过滤 feed。
final favorites = context.watch<FavoriteController>();
final isWatchlist = logic.section == 1;
final markedDramas = isWatchlist
? favorites.cards
: (widget.dramas ?? [widget.drama])
.where((drama) => engagement.isLiked(drama.id))
.where((drama) => interactions.isLiked(drama.id))
.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
+16 -6
View File
@@ -9,8 +9,8 @@ import 'package:video_player/video_player.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/play_ticket.dart';
import '../../core/data/services/engagement_controller.dart';
import '../../core/data/services/favorite_controller.dart';
import '../../core/data/services/interaction_controller.dart';
import '../../core/data/services/playback_session.dart';
import '../../core/data/services/video_player_port.dart';
import '../../core/formatting/error_text.dart';
@@ -108,7 +108,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
}
Widget _buildPlayer(BuildContext context) {
final engagement = context.watch<EngagementController>();
final interactions = context.watch<InteractionController>();
final indices = logic.visibleIndices;
final pages = logic.pages;
final content = AnimatedBuilder(
@@ -124,7 +124,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
unawaited(logic.selectEpisode(index));
},
itemBuilder: (context, position) =>
_buildEpisode(context, indices[position], engagement),
_buildEpisode(context, indices[position], interactions),
),
);
return AnnotatedRegion<SystemUiOverlayStyle>(
@@ -141,7 +141,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
Widget _buildEpisode(
BuildContext context,
int index,
EngagementController engagement,
InteractionController interactions,
) {
final l10n = AppLocalizations.of(context)!;
final entry = logic.queue.entryAt(index);
@@ -336,7 +336,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
drama: drama,
episode: episode,
controller: ready ? videoPort!.controller : null,
liked: engagement.isLiked(drama.id),
liked: interactions.isLiked(drama.id),
// 收藏以服务端为准(FavoriteController 由 app 注入,登录后自动加载)
saved: context.watch<FavoriteController>().isSaved(drama.id),
onBack: widget.embedded
@@ -348,7 +348,17 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
// 收藏/点赞要对到具体账号:未登录先引导登录(首页/详情仍可匿名浏览)
onLike: () async {
if (await ensureSignedIn(context)) {
unawaited(engagement.toggleLiked(drama.id));
final result = await interactions.toggleDramaLike(drama.id);
if (!result.isSuccess && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
result.errorMessage ??
AppLocalizations.of(context)!.likeFailed,
),
),
);
}
}
},
onSave: () async {
+149
View File
@@ -0,0 +1,149 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/data/models/comment.dart';
import '../../../core/data/services/comment_controller.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
const String _deleteAction = 'delete';
const String _reportAction = 'report';
/// 评论条目的交互回调集合。
class CommentInteractionHandlers {
const CommentInteractionHandlers({
required this.onReply,
required this.onLike,
required this.onDelete,
required this.onReport,
});
final ValueChanged<DramaComment> onReply;
final Future<void> Function(DramaComment) onLike;
final Future<void> Function(DramaComment) onDelete;
final Future<void> Function(DramaComment) onReport;
}
/// 单条评论或回复的展示与操作行。
class CommentTile extends StatelessWidget {
const CommentTile({
super.key,
required this.comment,
required this.controller,
required this.handlers,
this.isReply = false,
});
final DramaComment comment;
final CommentController controller;
final CommentInteractionHandlers handlers;
final bool isReply;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final name = comment.nickname.isEmpty ? l10n.anonymousUser : comment.nickname;
return Padding(
key: Key('comment-item-${comment.id}'),
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: isReply ? 12 : 16,
backgroundColor: AppColors.blue.withValues(alpha: 0.2),
child: Icon(
Icons.person_rounded,
size: isReply ? 14 : 18,
color: AppColors.blueSoft,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
name,
style: const TextStyle(
color: AppColors.muted,
fontSize: 12,
),
),
),
PopupMenuButton<String>(
key: Key('comment-menu-${comment.id}'),
tooltip: l10n.more,
onSelected: (value) {
if (value == _deleteAction) {
handlers.onDelete(comment);
} else if (value == _reportAction) {
handlers.onReport(comment);
}
},
itemBuilder: (_) => [
if (comment.mine)
PopupMenuItem(
value: _deleteAction,
child: Text(l10n.deleteComment),
)
else
PopupMenuItem(
value: _reportAction,
child: Text(l10n.reportComment),
),
],
),
],
),
Text(
comment.content,
style: const TextStyle(color: Colors.white, fontSize: 14),
),
if (comment.isPending)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
l10n.underReview,
key: Key('comment-pending-${comment.id}'),
style: const TextStyle(
color: AppColors.blueSoft,
fontSize: 11,
),
),
),
Row(
children: [
TextButton.icon(
key: Key('comment-like-${comment.id}'),
onPressed: controller.likingComment(comment.id)
? null
: () => handlers.onLike(comment),
icon: Icon(
comment.liked
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
size: 16,
),
label: Text('${comment.likeCount}'),
),
if (!isReply)
TextButton(
key: Key('comment-reply-${comment.id}'),
onPressed: () => handlers.onReply(comment),
child: Text(l10n.reply),
),
],
),
],
),
),
],
),
);
}
}
@@ -1,12 +1,27 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../core/data/models/comment.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/data/services/engagement_controller.dart';
import '../../../core/data/repositories/comment_repository.dart';
import '../../../core/data/repositories/interaction_repository.dart';
import '../../../core/data/services/comment_controller.dart';
import '../../../core/formatting/error_text.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/auth_gate.dart';
import '../../../l10n/app_localizations.dart';
import '../../interaction/widgets/report_dialog.dart';
import 'comment_tile.dart';
const double _minSheetHeight = 240;
const double _maxSheetHeight = 460;
const int _maxCommentLength = 300;
/// 服务端评论底部弹窗:匿名列表、游标翻页、回复、点赞和本人删除。
class DramaCommentsSheet extends StatefulWidget {
const DramaCommentsSheet({super.key, required this.drama});
@@ -18,26 +33,120 @@ class DramaCommentsSheet extends StatefulWidget {
class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
final _input = TextEditingController();
final _inputFocus = FocusNode();
late final CommentController _controller;
DramaComment? _replyTo;
@override
void initState() {
super.initState();
_controller = CommentController(
comments: context.read<CommentRepository>(),
interactions: context.read<InteractionRepository>(),
dramaId: widget.drama.id,
);
unawaited(_controller.load());
}
@override
void dispose() {
_controller.dispose();
_input.dispose();
_inputFocus.dispose();
super.dispose();
}
void _post() {
final text = _input.text.trim();
if (text.isEmpty) return;
context.read<EngagementController>().addComment(widget.drama.id, text);
_input.clear();
Future<void> _post() async {
if (_controller.submitting) return;
if (_input.text.trim().isEmpty) return;
if (!await ensureSignedIn(context) || !mounted) return;
final result = await _controller.publish(_input.text, replyTo: _replyTo);
if (!mounted) return;
if (result.isSuccess) {
_input.clear();
setState(() => _replyTo = null);
return;
}
_showMessage(
result.errorMessage ?? AppLocalizations.of(context)!.commentPostFailed,
);
}
void _beginReply(DramaComment comment) {
setState(() => _replyTo = comment);
_inputFocus.requestFocus();
}
void _cancelReply() {
if (_replyTo == null) return;
setState(() => _replyTo = null);
}
Future<void> _like(DramaComment comment) async {
if (!await ensureSignedIn(context) || !mounted) return;
final result = await _controller.toggleLike(comment);
if (!result.isSuccess && mounted) {
_showMessage(
result.errorMessage ?? AppLocalizations.of(context)!.commentPostFailed,
);
}
}
Future<void> _confirmDelete(DramaComment comment) async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(l10n.deleteCommentTitle),
content: Text(l10n.deleteCommentMessage),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(l10n.cancel),
),
FilledButton(
key: const Key('comment-delete-confirm'),
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(l10n.delete),
),
],
),
);
if (confirmed != true || !mounted) return;
final result = await _controller.delete(comment);
if (!result.isSuccess && mounted) {
_showMessage(
result.errorMessage ?? AppLocalizations.of(context)!.commentDeleteFailed,
);
}
}
Future<void> _report(DramaComment comment) async {
if (!await ensureSignedIn(context) || !mounted) return;
final l10n = AppLocalizations.of(context)!;
final draft = await showReportDialog(context, title: l10n.reportComment);
if (draft == null || !mounted) return;
final result = await _controller.report(
comment,
reason: draft.reason,
description: draft.description,
);
if (!mounted) return;
_showMessage(
result.isSuccess
? l10n.reportSubmitted
: result.errorMessage ?? l10n.reportFailed,
);
}
void _showMessage(String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final comments = context.watch<EngagementController>().commentsFor(
widget.drama.id,
);
final available =
MediaQuery.sizeOf(context).height -
MediaQuery.viewInsetsOf(context).bottom -
@@ -45,83 +154,244 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
child: SizedBox(
height: available.clamp(240.0, 460.0),
height: available.clamp(_minSheetHeight, _maxSheetHeight),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.comments,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 3),
Text(
l10n.localOnlyComments,
style: const TextStyle(color: Color(0xFFE6E1E3), fontSize: 13),
),
const SizedBox(height: 14),
Expanded(
child: comments.isEmpty
? Center(
child: Text(
l10n.noComments,
style: const TextStyle(color: Color(0xFFE6E1E3)),
),
)
: ListView.separated(
itemCount: comments.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) => ListTile(
leading: const CircleAvatar(
radius: 16,
child: Icon(Icons.person_rounded, size: 18),
),
title: Text(comments[index]),
contentPadding: EdgeInsets.zero,
),
),
),
Row(
children: [
Expanded(
child: TextField(
key: const Key('comment-input'),
controller: _input,
maxLength: 300,
maxLines: 1,
decoration: InputDecoration(
hintText: l10n.writeComment,
counterText: '',
filled: true,
fillColor: const Color(0xFF35353A),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
),
onSubmitted: (_) => _post(),
),
),
const SizedBox(width: 8),
IconButton.filled(
key: const Key('comment-post'),
tooltip: l10n.postComment,
onPressed: _post,
icon: const Icon(Icons.send_rounded),
),
],
),
],
child: ListenableBuilder(
listenable: _controller,
builder: (context, _) => _content(context),
),
),
),
),
);
}
Widget _content(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.comments,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
Expanded(child: _list(l10n)),
if (_replyTo != null) _replyBanner(l10n, _replyTo!),
_composer(l10n),
],
);
}
Widget _list(AppLocalizations l10n) {
if (_controller.loading && _controller.comments.isEmpty) {
return const Center(
child: CircularProgressIndicator(key: Key('comment-list-loading')),
);
}
if (_controller.error != null && _controller.comments.isEmpty) {
return Center(
key: const Key('comment-list-error'),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
errorText(l10n, _controller.error, l10n.commentsLoadFailed),
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.muted),
),
const SizedBox(height: 8),
TextButton(
key: const Key('comment-list-retry'),
onPressed: _controller.load,
child: Text(l10n.retry),
),
],
),
);
}
if (_controller.comments.isEmpty) {
return Center(
key: const Key('comment-list-empty'),
child: Text(
l10n.noComments,
style: const TextStyle(color: AppColors.muted),
),
);
}
return ListView.builder(
key: const Key('comment-list'),
itemCount: _controller.comments.length + 1,
itemBuilder: (context, index) {
if (index == _controller.comments.length) return _footer(l10n);
return _CommentThread(
comment: _controller.comments[index],
controller: _controller,
handlers: CommentInteractionHandlers(
onReply: _beginReply,
onLike: _like,
onDelete: _confirmDelete,
onReport: _report,
),
);
},
);
}
Widget _footer(AppLocalizations l10n) {
if (_controller.loadingMore) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Center(child: CircularProgressIndicator()),
);
}
if (_controller.hasMore) {
return TextButton(
key: const Key('comment-list-load-more'),
onPressed: _controller.loadMore,
child: Text(l10n.loadMore),
);
}
return const SizedBox(height: 8);
}
Widget _replyBanner(AppLocalizations l10n, DramaComment replyTo) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
children: [
Expanded(
child: Text(
l10n.replyingTo(
replyTo.nickname.isEmpty ? l10n.anonymousUser : replyTo.nickname,
),
style: const TextStyle(color: AppColors.blueSoft, fontSize: 12),
),
),
IconButton(
key: const Key('comment-cancel-reply'),
tooltip: l10n.cancelReply,
onPressed: _cancelReply,
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.close_rounded, size: 18),
),
],
),
);
Widget _composer(AppLocalizations l10n) => Row(
children: [
Expanded(
child: TextField(
key: const Key('comment-input'),
controller: _input,
focusNode: _inputFocus,
maxLength: _maxCommentLength,
maxLines: 1,
decoration: InputDecoration(
hintText: l10n.writeComment,
counterText: '',
filled: true,
fillColor: const Color(0xFF35353A),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
),
onSubmitted: (_) => _post(),
),
),
const SizedBox(width: 8),
IconButton.filled(
key: const Key('comment-post'),
tooltip: l10n.postComment,
onPressed: _controller.submitting ? null : _post,
icon: _controller.submitting
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send_rounded),
),
],
);
}
class _CommentThread extends StatelessWidget {
const _CommentThread({
required this.comment,
required this.controller,
required this.handlers,
});
final DramaComment comment;
final CommentController controller;
final CommentInteractionHandlers handlers;
@override
Widget build(BuildContext context) {
final replies = controller.repliesFor(comment.id);
final expanded = controller.repliesExpanded(comment.id);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CommentTile(
comment: comment,
controller: controller,
handlers: handlers,
),
if (comment.replyCount > 0 || replies.isNotEmpty)
Padding(
padding: const EdgeInsets.only(left: 44, bottom: 8),
child: TextButton(
key: Key('comment-replies-${comment.id}'),
onPressed: () => _toggleReplies(context),
child: Text(
expanded
? AppLocalizations.of(context)!.hideReplies
: AppLocalizations.of(
context,
)!.viewReplies(comment.replyCount),
),
),
),
if (expanded)
Padding(
padding: const EdgeInsets.only(left: 28),
child: Column(
children: [
if (controller.repliesLoading(comment.id))
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: CircularProgressIndicator(),
),
for (final reply in replies)
CommentTile(
comment: reply,
controller: controller,
handlers: handlers,
isReply: true,
),
if (controller.repliesHasMore(comment.id))
TextButton(
onPressed: () => controller.loadMoreReplies(comment.id),
child: Text(AppLocalizations.of(context)!.loadMore),
),
],
),
),
const Divider(height: 1),
],
);
}
Future<void> _toggleReplies(BuildContext context) async {
if (controller.repliesExpanded(comment.id)) {
controller.hideReplies(comment.id);
return;
}
await controller.loadReplies(comment.id);
}
}
+129
View File
@@ -0,0 +1,129 @@
// Author: fruit.com.cn
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/remote/comment_api.dart';
import '../support/fake_http_adapter.dart';
void main() {
String envelope(Object? data) =>
jsonEncode({'code': 0, 'message': 'ok', 'data': data});
Map<String, dynamic> commentJson({
String id = '2111012753262120962',
String content = 'Great episode',
String status = 'published',
bool mine = false,
bool liked = true,
String parentId = '0',
String rootId = '0',
}) => {
'id': id,
'dramaId': '5',
'episodeId': '7',
'parentId': parentId,
'rootId': rootId,
'userId': '2111012753262120001',
'uid': 'viewer-1',
'nickname': 'Viewer',
'avatarUrl': '',
'content': content,
'status': status,
'auditReason': '',
'likeCount': '12',
'replyCount': 2,
'reportCount': '0',
'createdAt': '2026-10-17T12:30:00',
'mine': mine,
'liked': liked,
};
test('should_parse_cursor_page_and_string_ids_when_comment_list_is_loaded', () async {
final adapter = FakeHttpAdapter((options) async {
return FakeHttpResponse(
envelope({
'list': [commentJson()],
'nextCursor': '2111012753262120962',
'hasMore': true,
}),
);
});
final api = CommentApi(
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
);
final page = await api.page(dramaId: '5', cursor: '99', size: 20);
expect(adapter.requests.single.method, 'GET');
expect(adapter.requests.single.uri.path, '/app/comments');
expect(adapter.requests.single.queryParameters, {
'dramaId': '5',
'cursor': '99',
'size': '20',
});
expect(page.comments.single.id, '2111012753262120962');
expect(page.comments.single.dramaId, '5');
expect(page.comments.single.episodeId, '7');
expect(page.comments.single.likeCount, 12);
expect(page.nextCursor, '2111012753262120962');
expect(page.hasMore, isTrue);
});
test('should_send_parent_id_only_for_reply_and_parse_replaced_content_when_published', () async {
final adapter = FakeHttpAdapter((options) async {
return FakeHttpResponse(
envelope(
commentJson(
id: '2111012753262120999',
content: 'great ***',
status: 'pending',
mine: true,
parentId: '2111012753262120962',
rootId: '2111012753262120962',
),
),
);
});
final api = CommentApi(
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
);
final reply = await api.publish(
dramaId: '5',
content: 'great badword',
parentId: '2111012753262120962',
);
expect(adapter.requests.single.method, 'POST');
expect(adapter.requests.single.uri.path, '/app/comments');
expect(adapter.requests.single.data, {
'dramaId': '5',
'content': 'great badword',
'parentId': '2111012753262120962',
});
expect(reply.content, 'great ***');
expect(reply.status, 'pending');
expect(reply.parentId, '2111012753262120962');
expect(reply.rootId, '2111012753262120962');
});
test('should_delete_comment_by_string_id_when_requested', () async {
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(envelope(null)),
);
final api = CommentApi(
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
);
await api.delete('2111012753262120962');
expect(adapter.requests.single.method, 'DELETE');
expect(
adapter.requests.single.uri.path,
'/app/comments/2111012753262120962',
);
});
}
+192
View File
@@ -0,0 +1,192 @@
// Author: fruit.com.cn
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/comment.dart';
import 'package:fruitshorts/core/data/models/interaction.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/services/comment_controller.dart';
import '../support/fake_interaction_repositories.dart';
void main() {
CommentController controller(
FakeCommentRepository comments, {
FakeInteractionRepository? interactions,
}) => CommentController(
comments: comments,
interactions: interactions ?? FakeInteractionRepository(),
dramaId: '5',
);
test('should_append_next_page_with_cursor_when_has_more_is_true', () async {
final repository = FakeCommentRepository(
pages: [
CommentPage(
comments: [commentFixture(id: '2')],
nextCursor: '2',
hasMore: true,
),
CommentPage(comments: [commentFixture(id: '1')]),
],
);
final model = controller(repository);
await model.load();
await model.loadMore();
expect(model.comments.map((item) => item.id), ['2', '1']);
expect(model.hasMore, isFalse);
expect(repository.pageCalls[0].cursor, isNull);
expect(repository.pageCalls[1].cursor, '2');
expect(repository.pageCalls.every((call) => call.size == 20), isTrue);
model.dispose();
});
test('should_use_server_content_and_pending_status_when_review_is_returned', () async {
final repository = FakeCommentRepository(
publishResult: commentFixture(
id: '3',
content: 'replaced ***',
status: 'pending',
mine: true,
),
);
final model = controller(repository);
final result = await model.publish(' replaced badword ');
expect(result.isSuccess, isTrue);
expect(repository.publishCalls.single.content, 'replaced badword');
expect(model.comments.single.content, 'replaced ***');
expect(model.comments.single.isPending, isTrue);
model.dispose();
});
test('should_expose_backend_message_and_keep_existing_list_when_publish_is_blocked', () async {
final repository = FakeCommentRepository(
publishError: const ApiException(400, '评论包含敏感词'),
);
final model = controller(repository);
await model.load();
final result = await model.publish('blocked');
expect(result.isSuccess, isFalse);
expect(result.errorMessage, '评论包含敏感词');
expect(model.comments, isEmpty);
model.dispose();
});
test('should_insert_reply_under_root_and_request_replies_with_root_id', () async {
final root = commentFixture(id: '10', replyCount: 1);
final reply = commentFixture(
id: '11',
parentId: '10',
rootId: '10',
content: 'Reply',
mine: true,
);
final repository = FakeCommentRepository(
pages: [
CommentPage(comments: [root]),
CommentPage(comments: [reply]),
],
publishResult: reply,
);
final model = controller(repository);
await model.load();
await model.loadReplies('10');
final result = await model.publish('Reply', replyTo: root);
expect(result.isSuccess, isTrue);
expect(repository.publishCalls.single.parentId, '10');
expect(repository.pageCalls.last.rootId, '10');
expect(model.repliesFor('10').map((item) => item.id), ['11']);
model.dispose();
});
test('should_restore_comment_at_original_position_when_delete_fails', () async {
final first = commentFixture(id: '2', mine: true);
final second = commentFixture(id: '1');
final repository = FakeCommentRepository(
pages: [
CommentPage(comments: [first, second]),
],
deleteError: const ApiException(500, '删除失败'),
);
final model = controller(repository);
await model.load();
final result = await model.delete(first);
expect(result.isSuccess, isFalse);
expect(result.errorMessage, '删除失败');
expect(model.comments.map((item) => item.id), ['2', '1']);
expect(repository.deleteCalls, ['2']);
model.dispose();
});
test('should_remove_own_comment_when_delete_succeeds', () async {
final own = commentFixture(id: '2', mine: true);
final repository = FakeCommentRepository(
pages: [
CommentPage(comments: [own]),
],
);
final model = controller(repository);
await model.load();
final result = await model.delete(own);
expect(result.isSuccess, isTrue);
expect(model.comments, isEmpty);
model.dispose();
});
test('should_optimistically_update_comment_like_and_apply_server_count', () async {
final comment = commentFixture(id: '2', likeCount: 3, liked: false);
final interactions = FakeInteractionRepository(
nextLikeResult: const LikeToggleResult(liked: true, count: 8),
);
final repository = FakeCommentRepository(
pages: [
CommentPage(comments: [comment]),
],
);
final model = controller(repository, interactions: interactions);
await model.load();
final pending = model.toggleLike(comment);
expect(model.comments.single.liked, isTrue);
expect(model.comments.single.likeCount, 4);
final result = await pending;
expect(result.isSuccess, isTrue);
expect(model.comments.single.liked, isTrue);
expect(model.comments.single.likeCount, 8);
expect(interactions.likeTargets, ['COMMENT:2']);
model.dispose();
});
test('should_rollback_comment_like_when_request_fails', () async {
final comment = commentFixture(id: '2', likeCount: 3, liked: true);
final interactions = FakeInteractionRepository()
..likeError = const ApiException(500, '点赞失败');
final repository = FakeCommentRepository(
pages: [
CommentPage(comments: [comment]),
],
);
final model = controller(repository, interactions: interactions);
await model.load();
final result = await model.toggleLike(comment);
expect(result.isSuccess, isFalse);
expect(result.errorMessage, '点赞失败');
expect(model.comments.single.liked, isTrue);
expect(model.comments.single.likeCount, 3);
model.dispose();
});
}
+317
View File
@@ -0,0 +1,317 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/comment.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/interaction.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/remote/auth_api.dart';
import 'package:fruitshorts/core/data/repositories/comment_repository.dart';
import 'package:fruitshorts/core/data/repositories/interaction_repository.dart';
import 'package:fruitshorts/core/data/services/auth_controller.dart';
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/player/widgets/drama_comments_sheet.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import '../support/fake_http_adapter.dart';
import '../support/fake_interaction_repositories.dart';
const _drama = Drama(
id: '5',
title: 'The Signal',
synopsis: 'synopsis',
posterUrl: '/files/5.jpg',
);
void main() {
setUp(() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.withData({
'auth.accessToken': 'test-access',
'auth.refreshToken': 'test-refresh',
'auth.user': '{"uid":"9","nickname":"Me","avatarUrl":""}',
});
});
testWidgets('should_show_server_comments_and_append_next_cursor_page', (
tester,
) async {
final repository = FakeCommentRepository(
pages: [
CommentPage(
comments: [
commentFixture(id: '10', content: 'Great episode', replyCount: 1),
],
nextCursor: '10',
hasMore: true,
),
CommentPage(
comments: [commentFixture(id: '9', content: 'Second page')],
),
],
);
await _pumpSheet(tester, repository: repository);
expect(find.text('Great episode'), findsOneWidget);
expect(find.byKey(const Key('comment-list-load-more')), findsOneWidget);
await tester.tap(find.byKey(const Key('comment-list-load-more')));
await tester.pumpAndSettle();
expect(find.text('Second page'), findsOneWidget);
expect(repository.pageCalls.last.cursor, '10');
});
testWidgets('should_keep_input_and_show_backend_message_when_comment_is_blocked', (
tester,
) async {
final repository = FakeCommentRepository(
pages: [const CommentPage()],
publishError: const ApiException(400, '评论包含敏感词'),
);
await _pumpSheet(tester, repository: repository);
await tester.enterText(
find.byKey(const Key('comment-input')),
'blocked text',
);
await tester.tap(find.byKey(const Key('comment-post')));
await tester.pumpAndSettle();
expect(find.text('评论包含敏感词'), findsOneWidget);
final field = tester.widget<TextField>(
find.byKey(const Key('comment-input')),
);
expect(field.controller?.text, 'blocked text');
});
testWidgets('should_show_review_badge_and_server_replaced_content', (
tester,
) async {
final repository = FakeCommentRepository(
pages: [const CommentPage()],
publishResult: commentFixture(
id: '11',
content: 'hello ***',
status: 'pending',
mine: true,
),
);
await _pumpSheet(tester, repository: repository);
await tester.enterText(
find.byKey(const Key('comment-input')),
'hello badword',
);
await tester.tap(find.byKey(const Key('comment-post')));
await tester.pumpAndSettle();
expect(find.text('hello ***'), findsOneWidget);
expect(find.text('Under review'), findsOneWidget);
final field = tester.widget<TextField>(
find.byKey(const Key('comment-input')),
);
expect(field.controller?.text, isEmpty);
});
testWidgets('should_send_parent_id_when_replying_to_top_level_comment', (
tester,
) async {
final root = commentFixture(id: '10', content: 'Root', replyCount: 0);
final repository = FakeCommentRepository(
pages: [
CommentPage(comments: [root]),
],
publishResult: commentFixture(
id: '11',
parentId: '10',
rootId: '10',
content: 'My reply',
mine: true,
),
);
await _pumpSheet(tester, repository: repository);
await tester.tap(find.byKey(const Key('comment-reply-10')));
await tester.pump();
expect(find.textContaining('Replying to'), findsOneWidget);
await tester.enterText(
find.byKey(const Key('comment-input')),
'My reply',
);
await tester.tap(find.byKey(const Key('comment-post')));
await tester.pumpAndSettle();
expect(repository.publishCalls.single.parentId, '10');
expect(find.text('My reply'), findsOneWidget);
});
testWidgets('should_show_error_and_retry_when_comment_page_fails', (
tester,
) async {
final repository = FakeCommentRepository(
pageError: StateError('offline'),
);
await _pumpSheet(tester, repository: repository);
expect(find.byKey(const Key('comment-list-error')), findsOneWidget);
expect(find.byKey(const Key('comment-list-retry')), findsOneWidget);
});
testWidgets('should_update_comment_like_count_when_server_returns_count', (
tester,
) async {
final repository = FakeCommentRepository(
pages: [
CommentPage(
comments: [commentFixture(id: '10', content: 'Like me')],
),
],
);
final interactions = FakeInteractionRepository()
..nextLikeResult = const LikeToggleResult(liked: true, count: 7);
await _pumpSheet(
tester,
repository: repository,
interactions: interactions,
);
await tester.tap(find.byKey(const Key('comment-like-10')));
await tester.pumpAndSettle();
expect(interactions.likeTargets, ['COMMENT:10']);
expect(find.text('7'), findsOneWidget);
});
testWidgets('should_report_comment_and_show_success_when_submitted', (
tester,
) async {
final repository = FakeCommentRepository(
pages: [
CommentPage(
comments: [commentFixture(id: '10', content: 'Report me')],
),
],
);
final interactions = FakeInteractionRepository();
await _pumpSheet(
tester,
repository: repository,
interactions: interactions,
);
await tester.tap(find.byKey(const Key('comment-menu-10')));
await tester.pumpAndSettle();
await tester.tap(find.text('Report comment'));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('report-submit')));
await tester.pumpAndSettle();
expect(interactions.reports.single.targetType, InteractionTargetType.comment);
expect(interactions.reports.single.targetId, '10');
expect(interactions.reports.single.reasonCode, 'SPAM');
expect(find.text('Report submitted. Thank you.'), findsOneWidget);
});
testWidgets('should_show_backend_message_when_comment_report_is_duplicated', (
tester,
) async {
final repository = FakeCommentRepository(
pages: [
CommentPage(
comments: [commentFixture(id: '10', content: 'Report me')],
),
],
);
final interactions = FakeInteractionRepository()
..reportError = const ApiException(200003, '你已举报过该内容,我们正在处理');
await _pumpSheet(
tester,
repository: repository,
interactions: interactions,
);
await tester.tap(find.byKey(const Key('comment-menu-10')));
await tester.pumpAndSettle();
await tester.tap(find.text('Report comment'));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('report-submit')));
await tester.pumpAndSettle();
expect(find.text('你已举报过该内容,我们正在处理'), findsOneWidget);
});
testWidgets('should_open_auth_gate_without_reporting_when_signed_out', (
tester,
) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
final repository = FakeCommentRepository(
pages: [
CommentPage(
comments: [commentFixture(id: '10', content: 'Report me')],
),
],
);
final interactions = FakeInteractionRepository();
await _pumpSheet(
tester,
repository: repository,
interactions: interactions,
);
await tester.tap(find.byKey(const Key('comment-menu-10')));
await tester.pumpAndSettle();
await tester.tap(find.text('Report comment'));
await tester.pumpAndSettle();
expect(find.text('Sign in to continue'), findsOneWidget);
expect(interactions.reports, isEmpty);
});
}
Future<void> _pumpSheet(
WidgetTester tester, {
required FakeCommentRepository repository,
FakeInteractionRepository? interactions,
}) async {
final auth = await _auth();
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<CommentRepository>.value(value: repository),
Provider<InteractionRepository>.value(
value: interactions ?? FakeInteractionRepository(),
),
ChangeNotifierProvider<AuthController>.value(value: auth),
],
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(
backgroundColor: Color(0xFF232328),
body: DramaCommentsSheet(drama: _drama),
),
),
),
);
await tester.pumpAndSettle();
}
Future<AuthController> _auth() async {
final client = ApiClient(
baseUrl: 'http://127.0.0.1:8081',
adapter: FakeHttpAdapter(
(_) async => FakeHttpResponse('{"code":0,"message":"ok","data":null}'),
),
);
final auth = AuthController(AuthApi(client), AuthSessionStore());
await auth.load();
return auth;
}
@@ -0,0 +1,31 @@
// Author: fruit.com.cn
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/services/platform_share_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('should_keep_title_synopsis_footer_order_when_share_text_is_sent', () async {
String? sharedText;
const channel = MethodChannel('com.stormg.lumadrama/share');
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
sharedText = (call.arguments as Map)['text'] as String;
return null;
});
addTearDown(
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null),
);
await const PlatformShareService().shareDrama(
title: 'The Signal',
synopsis: 'A short drama',
footer: 'FruitShorts',
);
expect(sharedText, 'The Signal\nA short drama\nFruitShorts');
});
}
@@ -1,48 +0,0 @@
// Author: fruit.com.cn
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/services/engagement_controller.dart';
import 'package:fruitshorts/core/data/repositories/engagement_repository.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
void main() {
test('should_keep_saved_liked_and_comments_by_drama_id_when_toggled', () async {
final repository = InMemoryEngagementRepository();
final first = EngagementController(repository);
await first.load();
await first.toggleSaved('signal');
await first.toggleLiked('signal');
await first.addComment('signal', ' Great clip ');
expect(first.isSaved('signal'), isTrue);
expect(first.isSaved('city'), isFalse);
expect(first.isLiked('city'), isFalse);
expect(first.commentsFor('signal'), ['Great clip']);
expect(first.commentsFor('city'), isEmpty);
final reopened = EngagementController(repository);
await reopened.load();
expect(reopened.isSaved('signal'), isTrue);
expect(reopened.isLiked('signal'), isTrue);
expect(reopened.commentsFor('signal'), ['Great clip']);
await reopened.toggleSaved('signal');
expect(reopened.isSaved('signal'), isFalse);
});
test('should_survive_new_repository_instance_when_engagement_is_local', () async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
final first = EngagementController(SharedPreferencesEngagementRepository());
await first.load();
await first.toggleSaved('city');
await first.addComment('city', 'Local note');
final reopened = EngagementController(
SharedPreferencesEngagementRepository(),
);
await reopened.load();
expect(reopened.isSaved('city'), isTrue);
expect(reopened.commentsFor('city'), ['Local note']);
});
}
@@ -1,135 +0,0 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/engagement_snapshot.dart';
import 'package:fruitshorts/core/data/repositories/engagement_repository.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
/// Mirrors the private key used by [SharedPreferencesEngagementRepository].
const _key = 'engagement.v1.demo-local-v1';
void main() {
late SharedPreferencesAsync preferences;
setUp(() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
preferences = SharedPreferencesAsync();
});
SharedPreferencesEngagementRepository repository() =>
SharedPreferencesEngagementRepository(preferences: preferences);
Future<EngagementSnapshot> loadRaw(String raw) async {
await preferences.setString(_key, raw);
return repository().load();
}
test('should_read_empty_snapshot_when_first_launch', () async {
final snapshot = await repository().load();
expect(snapshot.savedDramaIds, isEmpty);
expect(snapshot.likedDramaIds, isEmpty);
expect(snapshot.commentsByDrama, isEmpty);
});
test('should_survive_save_and_reload_round_trip_when_snapshot_is_stored', () async {
await repository().save(
const EngagementSnapshot(
savedDramaIds: {'signal', 'city'},
likedDramaIds: {'city'},
commentsByDrama: {
'signal': ['first note', 'second note'],
},
),
);
final loaded = await repository().load();
expect(loaded.savedDramaIds, {'signal', 'city'});
expect(loaded.likedDramaIds, {'city'});
expect(loaded.commentsByDrama, {
'signal': ['first note', 'second note'],
});
});
test('should_round_trip_as_empty_collections_when_snapshot_is_empty', () async {
await repository().save(const EngagementSnapshot());
final loaded = await repository().load();
expect(loaded.savedDramaIds, isEmpty);
expect(loaded.likedDramaIds, isEmpty);
expect(loaded.commentsByDrama, isEmpty);
});
test('should_fall_back_to_empty_snapshot_when_payload_is_corrupt_or_not_an_object', () async {
for (final raw in <String>[
'',
'not json at all',
'[]',
'"a bare string"',
'42',
'null',
]) {
final snapshot = await loadRaw(raw);
expect(snapshot.savedDramaIds, isEmpty, reason: raw);
expect(snapshot.likedDramaIds, isEmpty, reason: raw);
expect(snapshot.commentsByDrama, isEmpty, reason: raw);
}
});
test('should_drop_mistyped_fields_when_payload_has_wrong_types', () async {
final snapshot = await loadRaw(
jsonEncode({
'saved': 'signal',
'liked': {'city': true},
'comments': {
'signal': 'a plain string',
'city': ['kept', 7, null, true],
'town': <Object>[],
},
}),
);
expect(snapshot.savedDramaIds, isEmpty);
expect(snapshot.likedDramaIds, isEmpty);
expect(snapshot.commentsByDrama, {
'city': ['kept'],
'town': <String>[],
});
});
test('should_fall_back_per_field_when_payload_keys_are_missing', () async {
final snapshot = await loadRaw(jsonEncode({'saved': ['signal']}));
expect(snapshot.savedDramaIds, {'signal'});
expect(snapshot.likedDramaIds, isEmpty);
expect(snapshot.commentsByDrama, isEmpty);
});
test('should_write_only_json_primitives_when_saving', () async {
await repository().save(
const EngagementSnapshot(
savedDramaIds: {'signal'},
likedDramaIds: {'city'},
commentsByDrama: {
'signal': ['note'],
},
),
);
final raw = await preferences.getString(_key);
expect(raw, isNotNull);
final decoded = jsonDecode(raw!) as Map<String, dynamic>;
expect(decoded['saved'], ['signal']);
expect(decoded['liked'], ['city']);
expect(decoded['comments'], {
'signal': ['note'],
});
});
}
@@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/formatting/localized_content.dart';
import 'package:fruitshorts/l10n/app_localizations_en.dart';
import 'package:fruitshorts/l10n/app_localizations_id.dart';
import 'package:fruitshorts/l10n/app_localizations_zh.dart';
Drama _drama({required String id, String title = 'raw', String synopsis = 'raw'}) =>
@@ -16,6 +17,7 @@ Drama _drama({required String id, String title = 'raw', String synopsis = 'raw'}
void main() {
final en = AppLocalizationsEn();
final zh = AppLocalizationsZh();
final id = AppLocalizationsId();
test('should_map_each_seeded_drama_to_its_own_localized_copy_when_looked_up', () {
final signal = _drama(id: 'the-signal-demo');
@@ -93,4 +95,21 @@ void main() {
}
expect(localizedEpisodeTitle(zh, 12), zh.episodeLabel(12));
});
test('should_build_share_copy_from_localized_title_synopsis_and_footer', () {
final signal = _drama(id: 'the-signal-demo');
final copies = {
for (final l10n in [en, zh, id])
'${localizedDramaTitle(l10n, signal)}\n'
'${localizedDramaSynopsis(l10n, signal)}\n'
'${l10n.shareFooter}',
};
expect(copies, hasLength(3));
for (final copy in copies) {
expect(copy, contains('FruitShorts'));
expect(copy.split('\n'), hasLength(3));
expect(copy.split('\n').every((line) => line.isNotEmpty), isTrue);
}
});
}
@@ -0,0 +1,67 @@
// Author: fruit.com.cn
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/interaction.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/remote/interaction_api.dart';
import '../support/fake_http_adapter.dart';
void main() {
String envelope(Object? data) =>
jsonEncode({'code': 0, 'message': 'ok', 'data': data});
test('should_send_like_target_and_parse_string_count_when_toggled', () async {
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(
envelope({'liked': true, 'count': '2111012753262120962'}),
),
);
final api = InteractionApi(
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
);
final result = await api.toggleLike(
targetType: InteractionTargetType.drama,
targetId: '5',
);
expect(adapter.requests.single.method, 'POST');
expect(adapter.requests.single.uri.path, '/app/likes/toggle');
expect(adapter.requests.single.data, {
'targetType': 'DRAMA',
'targetId': '5',
});
expect(result.liked, isTrue);
expect(result.count, 2111012753262120962);
});
test('should_submit_fixed_reason_code_and_optional_description_when_reporting', () async {
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(envelope('2111012753262120999')),
);
final api = InteractionApi(
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
);
final reportId = await api.report(
const ReportRequest(
targetType: InteractionTargetType.comment,
targetId: '7',
reasonCode: 'SPAM',
description: 'Repeated advertising',
),
);
expect(adapter.requests.single.uri.path, '/app/reports');
expect(adapter.requests.single.data, {
'targetType': 'COMMENT',
'targetId': '7',
'reasonCode': 'SPAM',
'description': 'Repeated advertising',
});
expect(reportId, '2111012753262120999');
});
}
@@ -0,0 +1,60 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/interaction.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/services/interaction_controller.dart';
import '../support/fake_interaction_repositories.dart';
void main() {
test('should_update_drama_like_optimistically_and_use_server_state_when_toggle_succeeds', () async {
final repository = FakeInteractionRepository();
final gate = Completer<void>();
repository.likeGate = gate;
final controller = InteractionController(repository);
final future = controller.toggleDramaLike('5');
expect(controller.isLiked('5'), isTrue);
expect(repository.likeTargets, ['DRAMA:5']);
repository.nextLikeResult = const LikeToggleResult(liked: false, count: 0);
gate.complete();
final result = await future;
expect(result.isSuccess, isTrue);
expect(controller.isLiked('5'), isFalse);
controller.dispose();
});
test('should_rollback_drama_like_when_toggle_fails', () async {
final repository = FakeInteractionRepository(
nextLikeResult: const LikeToggleResult(liked: true, count: 3),
);
final controller = InteractionController(repository);
await controller.toggleDramaLike('5');
repository.likeError = const ApiException(500, '点赞失败');
final result = await controller.toggleDramaLike('5');
expect(result.isSuccess, isFalse);
expect(result.errorMessage, '点赞失败');
expect(controller.isLiked('5'), isTrue);
controller.dispose();
});
test('should_clear_account_scoped_drama_likes_when_session_changes', () async {
final repository = FakeInteractionRepository(
nextLikeResult: const LikeToggleResult(liked: true, count: 1),
);
final controller = InteractionController(repository);
await controller.toggleDramaLike('5');
controller.clear();
expect(controller.isLiked('5'), isFalse);
controller.dispose();
});
}
+55
View File
@@ -0,0 +1,55 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/interaction.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/interaction/widgets/report_dialog.dart';
void main() {
testWidgets('should_return_selected_reason_and_description_when_submitted', (
tester,
) async {
ReportDialogResult? result;
await tester.pumpWidget(
MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => Scaffold(
body: TextButton(
key: const Key('open-report-dialog'),
onPressed: () async {
result = await showReportDialog(
context,
title: 'Report comment',
);
},
child: const Text('Open'),
),
),
),
),
);
await tester.tap(find.byKey(const Key('open-report-dialog')));
await tester.pumpAndSettle();
expect(find.text('Spam'), findsOneWidget);
expect(find.text('Harassment'), findsOneWidget);
expect(find.text('Sexual content'), findsOneWidget);
expect(find.text('Advertising'), findsOneWidget);
expect(find.text('Other'), findsOneWidget);
await tester.tap(find.byKey(const Key('report-reason-AD')));
await tester.enterText(
find.byKey(const Key('report-description')),
'Repeat promotion',
);
await tester.tap(find.byKey(const Key('report-submit')));
await tester.pumpAndSettle();
expect(result?.reason, ReportReason.ad);
expect(result?.description, 'Repeat promotion');
});
}
@@ -0,0 +1,177 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:fruitshorts/core/data/models/comment.dart';
import 'package:fruitshorts/core/data/models/interaction.dart';
import 'package:fruitshorts/core/data/repositories/comment_repository.dart';
import 'package:fruitshorts/core/data/repositories/interaction_repository.dart';
class CommentPageCall {
const CommentPageCall({
required this.dramaId,
required this.rootId,
required this.cursor,
required this.size,
});
final String dramaId;
final String? rootId;
final String? cursor;
final int size;
}
class PublishCall {
const PublishCall({
required this.dramaId,
required this.episodeId,
required this.content,
required this.parentId,
});
final String dramaId;
final String? episodeId;
final String content;
final String? parentId;
}
class FakeCommentRepository implements CommentRepository {
FakeCommentRepository({
List<CommentPage>? pages,
this.publishResult,
this.publishError,
this.deleteError,
this.pageError,
}) : pages = pages ?? const [];
final List<CommentPage> pages;
final DramaComment? publishResult;
final Object? publishError;
final Object? deleteError;
final Object? pageError;
final List<CommentPageCall> pageCalls = [];
final List<PublishCall> publishCalls = [];
final List<String> deleteCalls = [];
int _pageIndex = 0;
@override
Future<CommentPage> page({
required String dramaId,
String? rootId,
String? cursor,
int size = commentPageSize,
}) async {
pageCalls.add(
CommentPageCall(
dramaId: dramaId,
rootId: rootId,
cursor: cursor,
size: size,
),
);
final error = pageError;
if (error != null) throw error;
if (_pageIndex >= pages.length) return const CommentPage();
return pages[_pageIndex++];
}
@override
Future<DramaComment> publish({
required String dramaId,
String? episodeId,
required String content,
String? parentId,
}) async {
publishCalls.add(
PublishCall(
dramaId: dramaId,
episodeId: episodeId,
content: content,
parentId: parentId,
),
);
final error = publishError;
if (error != null) throw error;
return publishResult ??
commentFixture(
id: 'new-comment',
dramaId: dramaId,
content: content,
mine: true,
);
}
@override
Future<void> delete(String commentId) async {
deleteCalls.add(commentId);
final error = deleteError;
if (error != null) throw error;
}
}
class FakeInteractionRepository implements InteractionRepository {
FakeInteractionRepository({
this.nextLikeResult = const LikeToggleResult(liked: true, count: 1),
});
LikeToggleResult nextLikeResult;
Object? likeError;
Object? reportError;
Completer<void>? likeGate;
String reportId = 'report-1';
final List<String> likeTargets = [];
final List<ReportRequest> reports = [];
@override
Future<LikeToggleResult> toggleLike({
required InteractionTargetType targetType,
required String targetId,
}) async {
likeTargets.add('${targetType.apiValue}:$targetId');
final gate = likeGate;
if (gate != null) await gate.future;
final error = likeError;
if (error != null) throw error;
return nextLikeResult;
}
@override
Future<String> report(ReportRequest request) async {
reports.add(request);
final error = reportError;
if (error != null) throw error;
return reportId;
}
}
DramaComment commentFixture({
String id = '1001',
String dramaId = '5',
String? parentId,
String? rootId,
String content = 'A comment',
String status = 'published',
int likeCount = 0,
int replyCount = 0,
bool liked = false,
bool mine = false,
}) => DramaComment(
id: id,
dramaId: dramaId,
episodeId: '0',
parentId: parentId ?? '0',
rootId: rootId ?? '0',
userId: mine ? '9' : '8',
uid: mine ? 'me' : 'viewer',
nickname: mine ? 'Me' : 'Viewer',
avatarUrl: '',
content: content,
status: status,
auditReason: '',
likeCount: likeCount,
replyCount: replyCount,
reportCount: 0,
createdAt: DateTime(2026, 10, 17, 12),
mine: mine,
liked: liked,
);
+79 -2
View File
@@ -4,15 +4,20 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/app.dart';
import 'package:fruitshorts/core/data/models/comment.dart';
import 'package:fruitshorts/core/data/models/interaction.dart';
import 'package:fruitshorts/pages/player/player_view.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'support/fake_catalog.dart';
import 'support/fake_category.dart';
import 'support/fake_interaction_repositories.dart';
import 'support/widget_player.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
import 'package:fruitshorts/core/data/repositories/comment_repository.dart';
import 'package:fruitshorts/core/data/repositories/interaction_repository.dart';
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/core/data/services/playback_session.dart';
@@ -31,6 +36,11 @@ void main() {
});
}
void seedSignedOutSession() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
}
setUp(() {
seedSignedInSession();
});
@@ -40,6 +50,8 @@ void main() {
Widget appUnderTest({
PlayerFactory? playerFactory,
CatalogRepository? catalogRepository,
CommentRepository? commentRepository,
InteractionRepository? interactionRepository,
}) {
final catalog = catalogRepository ?? FakeCatalogRepository();
return LumaDramaApp(
@@ -51,6 +63,9 @@ void main() {
cards: FakeCatalogRepository().dramas,
),
playbackRepository: FakePlaybackRepository(),
commentRepository: commentRepository ?? FakeCommentRepository(),
interactionRepository:
interactionRepository ?? FakeInteractionRepository(),
);
}
@@ -415,6 +430,10 @@ void main() {
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
String? sharedText;
final interactions = FakeInteractionRepository();
final comments = FakeCommentRepository(
pages: [const CommentPage()],
);
const channel = MethodChannel('com.stormg.lumadrama/share');
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (
call,
@@ -430,7 +449,10 @@ void main() {
);
await tester.pumpWidget(
appUnderTest(),
appUnderTest(
commentRepository: comments,
interactionRepository: interactions,
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-home')));
@@ -450,7 +472,7 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('rail-comments')));
await tester.pumpAndSettle();
expect(find.text('Only visible on this device'), findsOneWidget);
expect(find.text('No comments yet.'), findsOneWidget);
await tester.enterText(find.byKey(const Key('comment-input')), 'Nice demo');
await tester.tap(find.byKey(const Key('comment-post')));
await tester.pumpAndSettle();
@@ -459,12 +481,67 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('rail-like')));
await tester.pumpAndSettle();
expect(interactions.likeTargets, contains('DRAMA:the-signal-demo'));
await tester.tap(find.byKey(const Key('rail-share')));
await tester.pumpAndSettle();
expect(sharedText, contains('The Signal'));
expect(sharedText, contains('A demo series for vertical playback'));
expect(sharedText, contains('FruitShorts'));
expect(tester.takeException(), isNull);
});
testWidgets('should_open_auth_gate_without_liking_when_signed_out', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
seedSignedOutSession();
final interactions = FakeInteractionRepository();
await tester.pumpWidget(
appUnderTest(interactionRepository: interactions),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('rail-like')));
await tester.pumpAndSettle();
expect(find.text('Sign in to continue'), findsOneWidget);
expect(interactions.likeTargets, isEmpty);
});
testWidgets('should_report_drama_from_detail_more_menu_when_submitted', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final interactions = FakeInteractionRepository();
await tester.pumpWidget(
appUnderTest(interactionRepository: interactions),
);
await tester.pumpAndSettle();
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('drawer-open-series')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('detail-more')));
await tester.pumpAndSettle();
await tester.tap(find.text('Report drama'));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('report-submit')));
await tester.pumpAndSettle();
expect(interactions.reports.single.targetType, InteractionTargetType.drama);
expect(find.text('Report submitted. Thank you.'), findsOneWidget);
});
testWidgets('should_open_drawer_and_stay_on_home_when_system_back_is_pressed', (tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;