merge: 合并 K3 客户端互动能力
This commit is contained in:
commit
9db6935308
39 files changed
+3298
-462
No files matched your search
+24
-7
@@ -10,15 +10,18 @@ import 'core/data/remote/api_client.dart';
|
|||||||
import 'core/data/remote/auth_api.dart';
|
import 'core/data/remote/auth_api.dart';
|
||||||
import 'core/data/remote/catalog_api.dart';
|
import 'core/data/remote/catalog_api.dart';
|
||||||
import 'core/data/remote/category_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/download_api.dart';
|
||||||
|
import 'core/data/remote/interaction_api.dart';
|
||||||
import 'core/data/remote/library_api.dart';
|
import 'core/data/remote/library_api.dart';
|
||||||
import 'core/data/remote/playback_api.dart';
|
import 'core/data/remote/playback_api.dart';
|
||||||
import 'core/data/remote/watch_api.dart';
|
import 'core/data/remote/watch_api.dart';
|
||||||
import 'core/data/repositories/catalog_repository.dart';
|
import 'core/data/repositories/catalog_repository.dart';
|
||||||
import 'core/data/repositories/category_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/download_repository.dart';
|
||||||
import 'core/data/repositories/engagement_repository.dart';
|
|
||||||
import 'core/data/repositories/favorite_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/playback_repository.dart';
|
||||||
import 'core/data/repositories/watch_history_repository.dart';
|
import 'core/data/repositories/watch_history_repository.dart';
|
||||||
import 'core/data/services/auth_controller.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/catalog_controller.dart';
|
||||||
import 'core/data/services/category_controller.dart';
|
import 'core/data/services/category_controller.dart';
|
||||||
import 'core/data/services/download_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/favorite_controller.dart';
|
||||||
|
import 'core/data/services/interaction_controller.dart';
|
||||||
import 'core/data/services/playback_session.dart';
|
import 'core/data/services/playback_session.dart';
|
||||||
import 'core/data/services/video_player_port.dart';
|
import 'core/data/services/video_player_port.dart';
|
||||||
import 'l10n/app_localizations.dart';
|
import 'l10n/app_localizations.dart';
|
||||||
@@ -43,6 +46,8 @@ class LumaDramaApp extends StatefulWidget {
|
|||||||
this.watchHistoryRepository,
|
this.watchHistoryRepository,
|
||||||
this.favoriteRepository,
|
this.favoriteRepository,
|
||||||
this.playbackRepository,
|
this.playbackRepository,
|
||||||
|
this.commentRepository,
|
||||||
|
this.interactionRepository,
|
||||||
});
|
});
|
||||||
|
|
||||||
final PlayerFactory playerFactory;
|
final PlayerFactory playerFactory;
|
||||||
@@ -54,6 +59,8 @@ class LumaDramaApp extends StatefulWidget {
|
|||||||
final WatchHistoryRepository? watchHistoryRepository;
|
final WatchHistoryRepository? watchHistoryRepository;
|
||||||
final FavoriteRepository? favoriteRepository;
|
final FavoriteRepository? favoriteRepository;
|
||||||
final PlaybackRepository? playbackRepository;
|
final PlaybackRepository? playbackRepository;
|
||||||
|
final CommentRepository? commentRepository;
|
||||||
|
final InteractionRepository? interactionRepository;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<LumaDramaApp> createState() => _LumaDramaAppState();
|
State<LumaDramaApp> createState() => _LumaDramaAppState();
|
||||||
@@ -113,6 +120,16 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
|||||||
_downloadRepository,
|
_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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -128,6 +145,7 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
|||||||
void _onAuthChanged() {
|
void _onAuthChanged() {
|
||||||
if (_auth.isSignedIn == _wasSignedIn) return;
|
if (_auth.isSignedIn == _wasSignedIn) return;
|
||||||
_wasSignedIn = _auth.isSignedIn;
|
_wasSignedIn = _auth.isSignedIn;
|
||||||
|
_interactionController.clear();
|
||||||
if (_auth.isSignedIn) {
|
if (_auth.isSignedIn) {
|
||||||
unawaited(_favoriteController.load());
|
unawaited(_favoriteController.load());
|
||||||
} else {
|
} else {
|
||||||
@@ -141,6 +159,7 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
|||||||
_auth.removeListener(_onAuthChanged);
|
_auth.removeListener(_onAuthChanged);
|
||||||
_favoriteController.dispose();
|
_favoriteController.dispose();
|
||||||
_downloadController.dispose();
|
_downloadController.dispose();
|
||||||
|
_interactionController.dispose();
|
||||||
_apiClient.close();
|
_apiClient.close();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -154,6 +173,8 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
|||||||
Provider<PlaybackRepository>.value(value: _playback),
|
Provider<PlaybackRepository>.value(value: _playback),
|
||||||
Provider<WatchHistoryRepository>.value(value: _watchHistory),
|
Provider<WatchHistoryRepository>.value(value: _watchHistory),
|
||||||
Provider<FavoriteRepository>.value(value: _favorites),
|
Provider<FavoriteRepository>.value(value: _favorites),
|
||||||
|
Provider<CommentRepository>.value(value: _commentRepository),
|
||||||
|
Provider<InteractionRepository>.value(value: _interactionRepository),
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (_) => CatalogController(_catalogRepository)..load(),
|
create: (_) => CatalogController(_catalogRepository)..load(),
|
||||||
),
|
),
|
||||||
@@ -163,11 +184,7 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
|||||||
ChangeNotifierProvider(create: (_) => LocaleController()..load()),
|
ChangeNotifierProvider(create: (_) => LocaleController()..load()),
|
||||||
ChangeNotifierProvider.value(value: _favoriteController),
|
ChangeNotifierProvider.value(value: _favoriteController),
|
||||||
ChangeNotifierProvider.value(value: _downloadController),
|
ChangeNotifierProvider.value(value: _downloadController),
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider.value(value: _interactionController),
|
||||||
create: (_) =>
|
|
||||||
EngagementController(SharedPreferencesEngagementRepository())
|
|
||||||
..load(),
|
|
||||||
),
|
|
||||||
ChangeNotifierProvider.value(value: _auth),
|
ChangeNotifierProvider.value(value: _auth),
|
||||||
],
|
],
|
||||||
child: Consumer<LocaleController>(
|
child: Consumer<LocaleController>(
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'json_numbers.dart';
|
||||||
|
|
||||||
|
/// 评论游标分页大小,契约 §1.7 使用游标而非页码。
|
||||||
|
const int commentPageSize = 20;
|
||||||
|
|
||||||
|
/// 评论列表页。
|
||||||
|
class CommentPage {
|
||||||
|
const CommentPage({
|
||||||
|
this.comments = const [],
|
||||||
|
this.nextCursor,
|
||||||
|
this.hasMore = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<DramaComment> comments;
|
||||||
|
final String? nextCursor;
|
||||||
|
final bool hasMore;
|
||||||
|
|
||||||
|
/// 解析后端 `CursorResult{list,nextCursor,hasMore}`。
|
||||||
|
factory CommentPage.fromJson(Map<String, dynamic> json) {
|
||||||
|
final rawList = json['list'];
|
||||||
|
return CommentPage(
|
||||||
|
comments: [
|
||||||
|
if (rawList is List)
|
||||||
|
for (final item in rawList)
|
||||||
|
if (item is Map<String, dynamic>) DramaComment.fromJson(item),
|
||||||
|
],
|
||||||
|
nextCursor: json['nextCursor'] == null
|
||||||
|
? null
|
||||||
|
: jsonToId(json['nextCursor']),
|
||||||
|
hasMore: json['hasMore'] == true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 服务端评论视图,字段对应 `CommentRespVO`。
|
||||||
|
class DramaComment {
|
||||||
|
const DramaComment({
|
||||||
|
required this.id,
|
||||||
|
required this.dramaId,
|
||||||
|
required this.episodeId,
|
||||||
|
required this.parentId,
|
||||||
|
required this.rootId,
|
||||||
|
required this.userId,
|
||||||
|
required this.uid,
|
||||||
|
required this.nickname,
|
||||||
|
required this.avatarUrl,
|
||||||
|
required this.content,
|
||||||
|
required this.status,
|
||||||
|
required this.auditReason,
|
||||||
|
required this.likeCount,
|
||||||
|
required this.replyCount,
|
||||||
|
required this.reportCount,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.mine,
|
||||||
|
required this.liked,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String dramaId;
|
||||||
|
final String episodeId;
|
||||||
|
final String parentId;
|
||||||
|
final String rootId;
|
||||||
|
final String userId;
|
||||||
|
final String uid;
|
||||||
|
final String nickname;
|
||||||
|
final String avatarUrl;
|
||||||
|
final String content;
|
||||||
|
final String status;
|
||||||
|
final String auditReason;
|
||||||
|
final int likeCount;
|
||||||
|
final int replyCount;
|
||||||
|
final int reportCount;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
final bool mine;
|
||||||
|
final bool liked;
|
||||||
|
|
||||||
|
/// 待人工审核的评论仅本人可见。
|
||||||
|
bool get isPending => status == 'pending';
|
||||||
|
|
||||||
|
/// 顶层评论没有父评论。
|
||||||
|
bool get isTopLevel => parentId.isEmpty || parentId == '0';
|
||||||
|
|
||||||
|
/// 解析 `CommentRespVO`,所有 ID 字段只走字符串解析。
|
||||||
|
factory DramaComment.fromJson(Map<String, dynamic> json) => DramaComment(
|
||||||
|
id: jsonToId(json['id']),
|
||||||
|
dramaId: jsonToId(json['dramaId']),
|
||||||
|
episodeId: jsonToId(json['episodeId'], fallback: '0'),
|
||||||
|
parentId: jsonToId(json['parentId'], fallback: '0'),
|
||||||
|
rootId: jsonToId(json['rootId'], fallback: '0'),
|
||||||
|
userId: jsonToId(json['userId']),
|
||||||
|
uid: json['uid']?.toString() ?? '',
|
||||||
|
nickname: json['nickname']?.toString() ?? '',
|
||||||
|
avatarUrl: json['avatarUrl']?.toString() ?? '',
|
||||||
|
content: json['content']?.toString() ?? '',
|
||||||
|
status: json['status']?.toString() ?? '',
|
||||||
|
auditReason: json['auditReason']?.toString() ?? '',
|
||||||
|
likeCount: jsonToInt(json['likeCount']),
|
||||||
|
replyCount: jsonToInt(json['replyCount']),
|
||||||
|
reportCount: jsonToInt(json['reportCount']),
|
||||||
|
createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? ''),
|
||||||
|
mine: json['mine'] == true,
|
||||||
|
liked: json['liked'] == true,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 局部更新点赞或回复数,列表中的其他字段保持不变。
|
||||||
|
DramaComment copyWith({
|
||||||
|
int? likeCount,
|
||||||
|
int? replyCount,
|
||||||
|
bool? liked,
|
||||||
|
}) => DramaComment(
|
||||||
|
id: id,
|
||||||
|
dramaId: dramaId,
|
||||||
|
episodeId: episodeId,
|
||||||
|
parentId: parentId,
|
||||||
|
rootId: rootId,
|
||||||
|
userId: userId,
|
||||||
|
uid: uid,
|
||||||
|
nickname: nickname,
|
||||||
|
avatarUrl: avatarUrl,
|
||||||
|
content: content,
|
||||||
|
status: status,
|
||||||
|
auditReason: auditReason,
|
||||||
|
likeCount: likeCount ?? this.likeCount,
|
||||||
|
replyCount: replyCount ?? this.replyCount,
|
||||||
|
reportCount: reportCount,
|
||||||
|
createdAt: createdAt,
|
||||||
|
mine: mine,
|
||||||
|
liked: liked ?? this.liked,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
|
|
||||||
class EngagementSnapshot {
|
|
||||||
const EngagementSnapshot({
|
|
||||||
this.savedDramaIds = const {},
|
|
||||||
this.likedDramaIds = const {},
|
|
||||||
this.commentsByDrama = const {},
|
|
||||||
});
|
|
||||||
|
|
||||||
final Set<String> savedDramaIds;
|
|
||||||
final Set<String> likedDramaIds;
|
|
||||||
final Map<String, List<String>> commentsByDrama;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'json_numbers.dart';
|
||||||
|
|
||||||
|
/// 互动目标类型,契约只允许短剧与评论。
|
||||||
|
enum InteractionTargetType {
|
||||||
|
drama('DRAMA'),
|
||||||
|
comment('COMMENT');
|
||||||
|
|
||||||
|
const InteractionTargetType(this.apiValue);
|
||||||
|
|
||||||
|
final String apiValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 客户端固定举报原因,后端当前未限制枚举值。
|
||||||
|
enum ReportReason {
|
||||||
|
spam('SPAM'),
|
||||||
|
abuse('ABUSE'),
|
||||||
|
porn('PORN'),
|
||||||
|
ad('AD'),
|
||||||
|
other('OTHER');
|
||||||
|
|
||||||
|
const ReportReason(this.apiValue);
|
||||||
|
|
||||||
|
final String apiValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一次性互动请求,供仓库和测试断言使用。
|
||||||
|
class ReportRequest {
|
||||||
|
const ReportRequest({
|
||||||
|
required this.targetType,
|
||||||
|
required this.targetId,
|
||||||
|
required this.reasonCode,
|
||||||
|
this.description = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
final InteractionTargetType targetType;
|
||||||
|
final String targetId;
|
||||||
|
final String reasonCode;
|
||||||
|
final String description;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 点赞切换后的服务端状态。
|
||||||
|
class LikeToggleResult {
|
||||||
|
const LikeToggleResult({required this.liked, required this.count});
|
||||||
|
|
||||||
|
final bool liked;
|
||||||
|
final int count;
|
||||||
|
|
||||||
|
/// 计数按 Long 字符串兼容解析。
|
||||||
|
factory LikeToggleResult.fromJson(Map<String, dynamic> json) =>
|
||||||
|
LikeToggleResult(
|
||||||
|
liked: json['liked'] == true,
|
||||||
|
count: jsonToInt(json['count']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 控制器统一返回结果;失败消息可直接展示后端 `message`。
|
||||||
|
class OperationResult<T> {
|
||||||
|
const OperationResult._(this.data, this.errorMessage);
|
||||||
|
|
||||||
|
const OperationResult.success([T? data]) : this._(data, null);
|
||||||
|
|
||||||
|
const OperationResult.failure(this.errorMessage) : data = null;
|
||||||
|
|
||||||
|
final T? data;
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
bool get isSuccess => errorMessage == null;
|
||||||
|
}
|
||||||
@@ -11,3 +11,10 @@ int jsonToInt(dynamic value, {int fallback = 0}) {
|
|||||||
if (value is String) return int.tryParse(value.trim()) ?? fallback;
|
if (value is String) return int.tryParse(value.trim()) ?? fallback;
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ID 字段统一转字符串;后端按契约下发字符串,同时兼容测试或旧数据的数字形式。
|
||||||
|
String jsonToId(dynamic value, {String fallback = ''}) {
|
||||||
|
if (value is String) return value;
|
||||||
|
if (value is num) return value.toString();
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import '../models/comment.dart';
|
||||||
|
import 'api_client.dart';
|
||||||
|
|
||||||
|
/// 评论接口,对应契约 §1.7 的 `/app/comments*`。
|
||||||
|
class CommentApi {
|
||||||
|
const CommentApi(this._client);
|
||||||
|
|
||||||
|
final ApiClient _client;
|
||||||
|
|
||||||
|
/// 拉取游标分页;`rootId` 用于加载某一楼层的回复。
|
||||||
|
Future<CommentPage> page({
|
||||||
|
required String dramaId,
|
||||||
|
String? rootId,
|
||||||
|
String? cursor,
|
||||||
|
int size = commentPageSize,
|
||||||
|
}) async {
|
||||||
|
final data = await _client.get(
|
||||||
|
'/app/comments',
|
||||||
|
query: {
|
||||||
|
'dramaId': dramaId,
|
||||||
|
'rootId': ?rootId,
|
||||||
|
'cursor': ?cursor,
|
||||||
|
'size': '$size',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (data is! Map<String, dynamic>) return const CommentPage();
|
||||||
|
return CommentPage.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发表评论或回复;回复只提交 `parentId`,不自行拼层级。
|
||||||
|
Future<DramaComment> publish({
|
||||||
|
required String dramaId,
|
||||||
|
String? episodeId,
|
||||||
|
required String content,
|
||||||
|
String? parentId,
|
||||||
|
}) async {
|
||||||
|
final data = await _client.post(
|
||||||
|
'/app/comments',
|
||||||
|
body: {
|
||||||
|
'dramaId': dramaId,
|
||||||
|
if (episodeId != null && episodeId.isNotEmpty)
|
||||||
|
'episodeId': episodeId,
|
||||||
|
'content': content,
|
||||||
|
if (parentId != null && parentId.isNotEmpty) 'parentId': parentId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (data is! Map<String, dynamic>) {
|
||||||
|
throw const ApiException(500, '评论响应格式错误');
|
||||||
|
}
|
||||||
|
return DramaComment.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除自己的评论。
|
||||||
|
Future<void> delete(String commentId) async {
|
||||||
|
await _client.delete('/app/comments/$commentId');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import '../models/interaction.dart';
|
||||||
|
import 'api_client.dart';
|
||||||
|
|
||||||
|
/// 点赞与举报接口,对应契约 §1.7。
|
||||||
|
class InteractionApi {
|
||||||
|
const InteractionApi(this._client);
|
||||||
|
|
||||||
|
final ApiClient _client;
|
||||||
|
|
||||||
|
/// 切换点赞,服务端返回的状态与计数为最终真相。
|
||||||
|
Future<LikeToggleResult> toggleLike({
|
||||||
|
required InteractionTargetType targetType,
|
||||||
|
required String targetId,
|
||||||
|
}) async {
|
||||||
|
final data = await _client.post(
|
||||||
|
'/app/likes/toggle',
|
||||||
|
body: {
|
||||||
|
'targetType': targetType.apiValue,
|
||||||
|
'targetId': targetId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (data is! Map<String, dynamic>) {
|
||||||
|
throw const ApiException(500, '点赞响应格式错误');
|
||||||
|
}
|
||||||
|
return LikeToggleResult.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交举报,返回工单 ID。
|
||||||
|
Future<String> report(ReportRequest request) async {
|
||||||
|
final data = await _client.post(
|
||||||
|
'/app/reports',
|
||||||
|
body: {
|
||||||
|
'targetType': request.targetType.apiValue,
|
||||||
|
'targetId': request.targetId,
|
||||||
|
'reasonCode': request.reasonCode,
|
||||||
|
if (request.description.isNotEmpty)
|
||||||
|
'description': request.description,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return data?.toString() ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import '../models/comment.dart';
|
||||||
|
import '../remote/comment_api.dart';
|
||||||
|
|
||||||
|
/// 评论数据来源抽象,页面与控制器不直接依赖 HTTP。
|
||||||
|
abstract interface class CommentRepository {
|
||||||
|
/// 拉取评论或回复的分页。
|
||||||
|
Future<CommentPage> page({
|
||||||
|
required String dramaId,
|
||||||
|
String? rootId,
|
||||||
|
String? cursor,
|
||||||
|
int size = commentPageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 发表评论或回复。
|
||||||
|
Future<DramaComment> publish({
|
||||||
|
required String dramaId,
|
||||||
|
String? episodeId,
|
||||||
|
required String content,
|
||||||
|
String? parentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 删除指定评论。
|
||||||
|
Future<void> delete(String commentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP 评论仓库。
|
||||||
|
class HttpCommentRepository implements CommentRepository {
|
||||||
|
const HttpCommentRepository(this._api);
|
||||||
|
|
||||||
|
final CommentApi _api;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CommentPage> page({
|
||||||
|
required String dramaId,
|
||||||
|
String? rootId,
|
||||||
|
String? cursor,
|
||||||
|
int size = commentPageSize,
|
||||||
|
}) => _api.page(
|
||||||
|
dramaId: dramaId,
|
||||||
|
rootId: rootId,
|
||||||
|
cursor: cursor,
|
||||||
|
size: size,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<DramaComment> publish({
|
||||||
|
required String dramaId,
|
||||||
|
String? episodeId,
|
||||||
|
required String content,
|
||||||
|
String? parentId,
|
||||||
|
}) => _api.publish(
|
||||||
|
dramaId: dramaId,
|
||||||
|
episodeId: episodeId,
|
||||||
|
content: content,
|
||||||
|
parentId: parentId,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> delete(String commentId) => _api.delete(commentId);
|
||||||
|
}
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
|
|
||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
import '../models/engagement_snapshot.dart';
|
|
||||||
|
|
||||||
abstract interface class EngagementRepository {
|
|
||||||
Future<EngagementSnapshot> load();
|
|
||||||
Future<void> save(EngagementSnapshot snapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
class SharedPreferencesEngagementRepository implements EngagementRepository {
|
|
||||||
SharedPreferencesEngagementRepository({SharedPreferencesAsync? preferences})
|
|
||||||
: _preferences = preferences ?? SharedPreferencesAsync();
|
|
||||||
|
|
||||||
static const _key = 'engagement.v1.demo-local-v1';
|
|
||||||
final SharedPreferencesAsync _preferences;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<EngagementSnapshot> load() async {
|
|
||||||
final raw = await _preferences.getString(_key);
|
|
||||||
if (raw == null) return const EngagementSnapshot();
|
|
||||||
try {
|
|
||||||
final json = jsonDecode(raw);
|
|
||||||
if (json is! Map<String, dynamic>) return const EngagementSnapshot();
|
|
||||||
Set<String> ids(Object? value) =>
|
|
||||||
value is List ? value.whereType<String>().toSet() : <String>{};
|
|
||||||
final comments = <String, List<String>>{};
|
|
||||||
final rawComments = json['comments'];
|
|
||||||
if (rawComments is Map<String, dynamic>) {
|
|
||||||
for (final entry in rawComments.entries) {
|
|
||||||
if (entry.value is List) {
|
|
||||||
comments[entry.key] = (entry.value as List)
|
|
||||||
.whereType<String>()
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return EngagementSnapshot(
|
|
||||||
savedDramaIds: ids(json['saved']),
|
|
||||||
likedDramaIds: ids(json['liked']),
|
|
||||||
commentsByDrama: comments,
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
return const EngagementSnapshot();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> save(EngagementSnapshot snapshot) => _preferences.setString(
|
|
||||||
_key,
|
|
||||||
jsonEncode({
|
|
||||||
'saved': snapshot.savedDramaIds.toList(),
|
|
||||||
'liked': snapshot.likedDramaIds.toList(),
|
|
||||||
'comments': snapshot.commentsByDrama,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class InMemoryEngagementRepository implements EngagementRepository {
|
|
||||||
EngagementSnapshot snapshot = const EngagementSnapshot();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<EngagementSnapshot> load() async => snapshot;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> save(EngagementSnapshot value) async => snapshot = value;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import '../models/interaction.dart';
|
||||||
|
import '../remote/interaction_api.dart';
|
||||||
|
|
||||||
|
/// 点赞与举报数据来源抽象。
|
||||||
|
abstract interface class InteractionRepository {
|
||||||
|
/// 切换目标点赞状态。
|
||||||
|
Future<LikeToggleResult> toggleLike({
|
||||||
|
required InteractionTargetType targetType,
|
||||||
|
required String targetId,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 提交举报并返回工单 ID。
|
||||||
|
Future<String> report(ReportRequest request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP 互动仓库。
|
||||||
|
class HttpInteractionRepository implements InteractionRepository {
|
||||||
|
const HttpInteractionRepository(this._api);
|
||||||
|
|
||||||
|
final InteractionApi _api;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<LikeToggleResult> toggleLike({
|
||||||
|
required InteractionTargetType targetType,
|
||||||
|
required String targetId,
|
||||||
|
}) => _api.toggleLike(targetType: targetType, targetId: targetId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> report(ReportRequest request) => _api.report(request);
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../models/comment.dart';
|
||||||
|
import '../models/interaction.dart';
|
||||||
|
import '../remote/api_client.dart';
|
||||||
|
import '../repositories/comment_repository.dart';
|
||||||
|
import '../repositories/interaction_repository.dart';
|
||||||
|
|
||||||
|
/// 评论列表、发布、回复、删除与评论点赞的状态控制器。
|
||||||
|
class CommentController extends ChangeNotifier {
|
||||||
|
CommentController({
|
||||||
|
required CommentRepository comments,
|
||||||
|
required InteractionRepository interactions,
|
||||||
|
required this.dramaId,
|
||||||
|
this.episodeId,
|
||||||
|
}) : _commentRepository = comments,
|
||||||
|
_interactionRepository = interactions;
|
||||||
|
|
||||||
|
final CommentRepository _commentRepository;
|
||||||
|
final InteractionRepository _interactionRepository;
|
||||||
|
final String dramaId;
|
||||||
|
final String? episodeId;
|
||||||
|
final List<DramaComment> _comments = [];
|
||||||
|
final Map<String, List<DramaComment>> _replies = {};
|
||||||
|
final Map<String, String?> _replyCursors = {};
|
||||||
|
final Map<String, bool> _replyHasMore = {};
|
||||||
|
final Set<String> _expandedRoots = {};
|
||||||
|
final Set<String> _loadingReplies = {};
|
||||||
|
final Set<String> _loadingMoreReplies = {};
|
||||||
|
final Set<String> _likingCommentIds = {};
|
||||||
|
final Set<String> _deletingCommentIds = {};
|
||||||
|
bool _disposed = false;
|
||||||
|
bool loading = false;
|
||||||
|
bool loadingMore = false;
|
||||||
|
bool submitting = false;
|
||||||
|
bool hasMore = false;
|
||||||
|
String? nextCursor;
|
||||||
|
String? error;
|
||||||
|
|
||||||
|
/// 当前已加载的顶层评论。
|
||||||
|
List<DramaComment> get comments => List.unmodifiable(_comments);
|
||||||
|
|
||||||
|
/// 某一楼层已加载的回复。
|
||||||
|
List<DramaComment> repliesFor(String rootId) =>
|
||||||
|
List.unmodifiable(_replies[rootId] ?? const []);
|
||||||
|
|
||||||
|
/// 楼层回复是否已展开。
|
||||||
|
bool repliesExpanded(String rootId) => _expandedRoots.contains(rootId);
|
||||||
|
|
||||||
|
/// 楼层回复是否正在首屏加载。
|
||||||
|
bool repliesLoading(String rootId) => _loadingReplies.contains(rootId);
|
||||||
|
|
||||||
|
/// 楼层回复是否还有下一页。
|
||||||
|
bool repliesHasMore(String rootId) => _replyHasMore[rootId] ?? false;
|
||||||
|
|
||||||
|
/// 评论是否正在提交点赞,用于防连点。
|
||||||
|
bool likingComment(String id) => _likingCommentIds.contains(id);
|
||||||
|
|
||||||
|
void _notify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载首屏评论,成功后替换当前顶层列表。
|
||||||
|
Future<void> load() async {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
final page = await _commentRepository.page(
|
||||||
|
dramaId: dramaId,
|
||||||
|
size: commentPageSize,
|
||||||
|
);
|
||||||
|
_comments
|
||||||
|
..clear()
|
||||||
|
..addAll(page.comments);
|
||||||
|
nextCursor = page.nextCursor;
|
||||||
|
hasMore = page.hasMore;
|
||||||
|
} catch (failure) {
|
||||||
|
error = _errorMessage(failure) ?? 'commentsLoadFailed';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使用 `nextCursor` 续拉顶层评论。
|
||||||
|
Future<void> loadMore() async {
|
||||||
|
final cursor = nextCursor;
|
||||||
|
if (loadingMore || !hasMore || cursor == null) return;
|
||||||
|
loadingMore = true;
|
||||||
|
error = null;
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
final page = await _commentRepository.page(
|
||||||
|
dramaId: dramaId,
|
||||||
|
cursor: cursor,
|
||||||
|
size: commentPageSize,
|
||||||
|
);
|
||||||
|
_comments.addAll(page.comments);
|
||||||
|
nextCursor = page.nextCursor;
|
||||||
|
hasMore = page.hasMore;
|
||||||
|
} catch (failure) {
|
||||||
|
error = _errorMessage(failure) ?? 'commentsLoadFailed';
|
||||||
|
} finally {
|
||||||
|
loadingMore = false;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 展开并用 `rootId` 加载一层楼层回复。
|
||||||
|
Future<void> loadReplies(String rootId) async {
|
||||||
|
_expandedRoots.add(rootId);
|
||||||
|
if (_replies.containsKey(rootId) || _loadingReplies.contains(rootId)) {
|
||||||
|
_notify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_loadingReplies.add(rootId);
|
||||||
|
error = null;
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
final page = await _commentRepository.page(
|
||||||
|
dramaId: dramaId,
|
||||||
|
rootId: rootId,
|
||||||
|
size: commentPageSize,
|
||||||
|
);
|
||||||
|
_replies[rootId] = List.of(page.comments);
|
||||||
|
_replyCursors[rootId] = page.nextCursor;
|
||||||
|
_replyHasMore[rootId] = page.hasMore;
|
||||||
|
} catch (failure) {
|
||||||
|
error = _errorMessage(failure) ?? 'commentsLoadFailed';
|
||||||
|
} finally {
|
||||||
|
_loadingReplies.remove(rootId);
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收起楼层回复,不丢弃已加载数据。
|
||||||
|
void hideReplies(String rootId) {
|
||||||
|
if (_expandedRoots.remove(rootId)) _notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 续拉楼层回复。
|
||||||
|
Future<void> loadMoreReplies(String rootId) async {
|
||||||
|
final cursor = _replyCursors[rootId];
|
||||||
|
if (_loadingMoreReplies.contains(rootId) || cursor == null) return;
|
||||||
|
if (!(_replyHasMore[rootId] ?? false)) return;
|
||||||
|
_loadingMoreReplies.add(rootId);
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
final page = await _commentRepository.page(
|
||||||
|
dramaId: dramaId,
|
||||||
|
rootId: rootId,
|
||||||
|
cursor: cursor,
|
||||||
|
size: commentPageSize,
|
||||||
|
);
|
||||||
|
(_replies[rootId] ??= []).addAll(page.comments);
|
||||||
|
_replyCursors[rootId] = page.nextCursor;
|
||||||
|
_replyHasMore[rootId] = page.hasMore;
|
||||||
|
} catch (failure) {
|
||||||
|
error = _errorMessage(failure) ?? 'commentsLoadFailed';
|
||||||
|
} finally {
|
||||||
|
_loadingMoreReplies.remove(rootId);
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发布评论或回复,输入框是否清空由调用方根据成功结果决定。
|
||||||
|
Future<OperationResult<DramaComment>> publish(
|
||||||
|
String rawContent, {
|
||||||
|
DramaComment? replyTo,
|
||||||
|
}) async {
|
||||||
|
final content = rawContent.trim();
|
||||||
|
if (content.isEmpty || submitting) {
|
||||||
|
return const OperationResult.failure(null);
|
||||||
|
}
|
||||||
|
submitting = true;
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
final comment = await _commentRepository.publish(
|
||||||
|
dramaId: dramaId,
|
||||||
|
episodeId: episodeId,
|
||||||
|
content: content,
|
||||||
|
parentId: replyTo?.id,
|
||||||
|
);
|
||||||
|
_insertPublished(comment, replyTo);
|
||||||
|
return OperationResult.success(comment);
|
||||||
|
} catch (failure) {
|
||||||
|
return OperationResult.failure(_errorMessage(failure));
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 乐观删除自己的评论,失败时恢复到原位置。
|
||||||
|
Future<OperationResult<void>> delete(DramaComment comment) async {
|
||||||
|
if (!comment.mine || _deletingCommentIds.contains(comment.id)) {
|
||||||
|
return const OperationResult.failure(null);
|
||||||
|
}
|
||||||
|
_deletingCommentIds.add(comment.id);
|
||||||
|
final location = _removeComment(comment.id);
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
await _commentRepository.delete(comment.id);
|
||||||
|
return const OperationResult.success();
|
||||||
|
} catch (failure) {
|
||||||
|
_restoreComment(comment, location);
|
||||||
|
return OperationResult.failure(_errorMessage(failure));
|
||||||
|
} finally {
|
||||||
|
_deletingCommentIds.remove(comment.id);
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 评论点赞先乐观更新,再用服务端 `{liked,count}` 校准。
|
||||||
|
Future<OperationResult<LikeToggleResult>> toggleLike(
|
||||||
|
DramaComment comment,
|
||||||
|
) async {
|
||||||
|
if (_likingCommentIds.contains(comment.id)) {
|
||||||
|
return const OperationResult.failure(null);
|
||||||
|
}
|
||||||
|
final current = _findComment(comment.id) ?? comment;
|
||||||
|
_likingCommentIds.add(comment.id);
|
||||||
|
_replaceComment(
|
||||||
|
current.copyWith(
|
||||||
|
liked: !current.liked,
|
||||||
|
likeCount: current.liked
|
||||||
|
? (current.likeCount > 0 ? current.likeCount - 1 : 0)
|
||||||
|
: current.likeCount + 1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
final result = await _interactionRepository.toggleLike(
|
||||||
|
targetType: InteractionTargetType.comment,
|
||||||
|
targetId: comment.id,
|
||||||
|
);
|
||||||
|
_replaceComment(
|
||||||
|
current.copyWith(liked: result.liked, likeCount: result.count),
|
||||||
|
);
|
||||||
|
return OperationResult.success(result);
|
||||||
|
} catch (failure) {
|
||||||
|
_replaceComment(current);
|
||||||
|
return OperationResult.failure(_errorMessage(failure));
|
||||||
|
} finally {
|
||||||
|
_likingCommentIds.remove(comment.id);
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 举报评论,失败消息原样返回给界面展示。
|
||||||
|
Future<OperationResult<String>> report(
|
||||||
|
DramaComment comment, {
|
||||||
|
required ReportReason reason,
|
||||||
|
String description = '',
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final reportId = await _interactionRepository.report(
|
||||||
|
ReportRequest(
|
||||||
|
targetType: InteractionTargetType.comment,
|
||||||
|
targetId: comment.id,
|
||||||
|
reasonCode: reason.apiValue,
|
||||||
|
description: description.trim(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return OperationResult.success(reportId);
|
||||||
|
} catch (failure) {
|
||||||
|
return OperationResult.failure(_errorMessage(failure));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _insertPublished(DramaComment comment, DramaComment? replyTo) {
|
||||||
|
final rootId = comment.rootId != '0'
|
||||||
|
? comment.rootId
|
||||||
|
: replyTo?.isTopLevel == true
|
||||||
|
? replyTo!.id
|
||||||
|
: replyTo?.rootId;
|
||||||
|
if (replyTo == null || rootId == null || rootId == '0') {
|
||||||
|
if (!_comments.any((item) => item.id == comment.id)) {
|
||||||
|
_comments.insert(0, comment);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final replies = _replies.putIfAbsent(rootId, () => []);
|
||||||
|
if (!replies.any((item) => item.id == comment.id)) {
|
||||||
|
replies.insert(0, comment);
|
||||||
|
}
|
||||||
|
_expandedRoots.add(rootId);
|
||||||
|
final rootIndex = _comments.indexWhere((item) => item.id == rootId);
|
||||||
|
if (rootIndex >= 0) {
|
||||||
|
final root = _comments[rootIndex];
|
||||||
|
_comments[rootIndex] = root.copyWith(replyCount: root.replyCount + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_CommentLocation? _removeComment(String id) {
|
||||||
|
final topIndex = _comments.indexWhere((item) => item.id == id);
|
||||||
|
if (topIndex >= 0) {
|
||||||
|
final removed = _comments.removeAt(topIndex);
|
||||||
|
return _CommentLocation(comment: removed, topIndex: topIndex);
|
||||||
|
}
|
||||||
|
for (final entry in _replies.entries) {
|
||||||
|
final index = entry.value.indexWhere((item) => item.id == id);
|
||||||
|
if (index < 0) continue;
|
||||||
|
final removed = entry.value.removeAt(index);
|
||||||
|
return _CommentLocation(
|
||||||
|
comment: removed,
|
||||||
|
rootId: entry.key,
|
||||||
|
replyIndex: index,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _restoreComment(DramaComment comment, _CommentLocation? location) {
|
||||||
|
if (location == null) return;
|
||||||
|
if (location.topIndex != null) {
|
||||||
|
final index = location.topIndex!.clamp(0, _comments.length);
|
||||||
|
_comments.insert(index, comment);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final replies = _replies.putIfAbsent(location.rootId!, () => []);
|
||||||
|
final index = location.replyIndex!.clamp(0, replies.length);
|
||||||
|
replies.insert(index, comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
DramaComment? _findComment(String id) {
|
||||||
|
for (final comment in _comments) {
|
||||||
|
if (comment.id == id) return comment;
|
||||||
|
}
|
||||||
|
for (final replies in _replies.values) {
|
||||||
|
for (final comment in replies) {
|
||||||
|
if (comment.id == id) return comment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _replaceComment(DramaComment replacement) {
|
||||||
|
final topIndex = _comments.indexWhere((item) => item.id == replacement.id);
|
||||||
|
if (topIndex >= 0) {
|
||||||
|
_comments[topIndex] = replacement;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (final replies in _replies.values) {
|
||||||
|
final index = replies.indexWhere((item) => item.id == replacement.id);
|
||||||
|
if (index >= 0) {
|
||||||
|
replies[index] = replacement;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _errorMessage(Object failure) =>
|
||||||
|
failure is ApiException ? failure.message : null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CommentLocation {
|
||||||
|
const _CommentLocation({
|
||||||
|
required this.comment,
|
||||||
|
this.topIndex,
|
||||||
|
this.rootId,
|
||||||
|
this.replyIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
final DramaComment comment;
|
||||||
|
final int? topIndex;
|
||||||
|
final String? rootId;
|
||||||
|
final int? replyIndex;
|
||||||
|
}
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
|
|
||||||
import '../models/engagement_snapshot.dart';
|
|
||||||
import '../repositories/engagement_repository.dart';
|
|
||||||
|
|
||||||
/// Local demo interactions, indexed by drama ID. No public/social backend.
|
|
||||||
class EngagementController extends ChangeNotifier {
|
|
||||||
EngagementController(this._repository);
|
|
||||||
|
|
||||||
final EngagementRepository _repository;
|
|
||||||
final Set<String> _saved = {};
|
|
||||||
final Set<String> _liked = {};
|
|
||||||
final Map<String, List<String>> _comments = {};
|
|
||||||
Future<void> _loadFuture = Future<void>.value();
|
|
||||||
Future<void> _pendingSave = Future<void>.value();
|
|
||||||
|
|
||||||
bool isSaved(String dramaId) => _saved.contains(dramaId);
|
|
||||||
bool isLiked(String dramaId) => _liked.contains(dramaId);
|
|
||||||
List<String> commentsFor(String dramaId) =>
|
|
||||||
List.unmodifiable(_comments[dramaId] ?? const []);
|
|
||||||
|
|
||||||
Future<void> load() => _loadFuture = _loadFromRepository();
|
|
||||||
|
|
||||||
Future<void> _loadFromRepository() async {
|
|
||||||
try {
|
|
||||||
final snapshot = await _repository.load();
|
|
||||||
_saved.addAll(snapshot.savedDramaIds);
|
|
||||||
_liked.addAll(snapshot.likedDramaIds);
|
|
||||||
for (final entry in snapshot.commentsByDrama.entries) {
|
|
||||||
_comments[entry.key] = List.of(entry.value);
|
|
||||||
}
|
|
||||||
notifyListeners();
|
|
||||||
} catch (_) {
|
|
||||||
// Local preferences are best effort for this demo.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> toggleSaved(String dramaId) async {
|
|
||||||
await _loadFuture;
|
|
||||||
if (!_saved.add(dramaId)) _saved.remove(dramaId);
|
|
||||||
notifyListeners();
|
|
||||||
await _persist();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> toggleLiked(String dramaId) async {
|
|
||||||
await _loadFuture;
|
|
||||||
if (!_liked.add(dramaId)) _liked.remove(dramaId);
|
|
||||||
notifyListeners();
|
|
||||||
await _persist();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> addComment(String dramaId, String text) async {
|
|
||||||
await _loadFuture;
|
|
||||||
final clean = text.trim();
|
|
||||||
if (clean.isEmpty) return;
|
|
||||||
(_comments[dramaId] ??= []).add(
|
|
||||||
clean.length > 300 ? clean.substring(0, 300) : clean,
|
|
||||||
);
|
|
||||||
notifyListeners();
|
|
||||||
await _persist();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _persist() {
|
|
||||||
final snapshot = EngagementSnapshot(
|
|
||||||
savedDramaIds: Set.of(_saved),
|
|
||||||
likedDramaIds: Set.of(_liked),
|
|
||||||
commentsByDrama: {
|
|
||||||
for (final entry in _comments.entries) entry.key: List.of(entry.value),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
_pendingSave = _pendingSave
|
|
||||||
.then((_) => _repository.save(snapshot))
|
|
||||||
.catchError((Object _) {
|
|
||||||
// Keep the current in-memory state if local persistence fails.
|
|
||||||
});
|
|
||||||
return _pendingSave;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../models/interaction.dart';
|
||||||
|
import '../remote/api_client.dart';
|
||||||
|
import '../repositories/interaction_repository.dart';
|
||||||
|
|
||||||
|
/// 剧集点赞与举报的会话状态控制器。
|
||||||
|
///
|
||||||
|
/// 剧集契约暂未下发初始点赞状态与计数,本批只维护当前会话内的 liked 状态,
|
||||||
|
/// 不展示数字,也不伪造初始值。
|
||||||
|
class InteractionController extends ChangeNotifier {
|
||||||
|
InteractionController(this._repository);
|
||||||
|
|
||||||
|
final InteractionRepository _repository;
|
||||||
|
final Set<String> _likedDramaIds = {};
|
||||||
|
final Set<String> _busyDramaIds = {};
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
/// 当前会话内该剧是否已点赞。
|
||||||
|
bool isLiked(String dramaId) => _likedDramaIds.contains(dramaId);
|
||||||
|
|
||||||
|
/// 点赞请求是否进行中,用于防连点。
|
||||||
|
bool isBusy(String dramaId) => _busyDramaIds.contains(dramaId);
|
||||||
|
|
||||||
|
/// 乐观切换剧集点赞,失败时回滚并以服务端返回状态校准。
|
||||||
|
Future<OperationResult<LikeToggleResult>> toggleDramaLike(
|
||||||
|
String dramaId,
|
||||||
|
) async {
|
||||||
|
if (_busyDramaIds.contains(dramaId)) {
|
||||||
|
return const OperationResult.failure(null);
|
||||||
|
}
|
||||||
|
final wasLiked = _likedDramaIds.contains(dramaId);
|
||||||
|
_busyDramaIds.add(dramaId);
|
||||||
|
if (wasLiked) {
|
||||||
|
_likedDramaIds.remove(dramaId);
|
||||||
|
} else {
|
||||||
|
_likedDramaIds.add(dramaId);
|
||||||
|
}
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
final result = await _repository.toggleLike(
|
||||||
|
targetType: InteractionTargetType.drama,
|
||||||
|
targetId: dramaId,
|
||||||
|
);
|
||||||
|
if (result.liked) {
|
||||||
|
_likedDramaIds.add(dramaId);
|
||||||
|
} else {
|
||||||
|
_likedDramaIds.remove(dramaId);
|
||||||
|
}
|
||||||
|
return OperationResult.success(result);
|
||||||
|
} catch (failure) {
|
||||||
|
if (wasLiked) {
|
||||||
|
_likedDramaIds.add(dramaId);
|
||||||
|
} else {
|
||||||
|
_likedDramaIds.remove(dramaId);
|
||||||
|
}
|
||||||
|
return OperationResult.failure(_errorMessage(failure));
|
||||||
|
} finally {
|
||||||
|
_busyDramaIds.remove(dramaId);
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 举报剧集,失败消息原样返回给界面。
|
||||||
|
Future<OperationResult<String>> reportDrama(
|
||||||
|
String dramaId, {
|
||||||
|
required ReportReason reason,
|
||||||
|
String description = '',
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final reportId = await _repository.report(
|
||||||
|
ReportRequest(
|
||||||
|
targetType: InteractionTargetType.drama,
|
||||||
|
targetId: dramaId,
|
||||||
|
reasonCode: reason.apiValue,
|
||||||
|
description: description.trim(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return OperationResult.success(reportId);
|
||||||
|
} catch (failure) {
|
||||||
|
return OperationResult.failure(_errorMessage(failure));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登录账号变化时清理会话内状态,避免跨账号串联。
|
||||||
|
void clear() {
|
||||||
|
if (_likedDramaIds.isEmpty && _busyDramaIds.isEmpty) return;
|
||||||
|
_likedDramaIds.clear();
|
||||||
|
_busyDramaIds.clear();
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _errorMessage(Object failure) =>
|
||||||
|
failure is ApiException ? failure.message : null;
|
||||||
|
|
||||||
|
void _notify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ String errorText(AppLocalizations l10n, String? code, String fallback) {
|
|||||||
'progressError' => l10n.progressError,
|
'progressError' => l10n.progressError,
|
||||||
'favoritesError' => l10n.favoritesError,
|
'favoritesError' => l10n.favoritesError,
|
||||||
'ticketError' => l10n.playbackError,
|
'ticketError' => l10n.playbackError,
|
||||||
|
'commentsLoadFailed' => l10n.commentsLoadFailed,
|
||||||
final value => value,
|
final value => value,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+41
-1
@@ -65,10 +65,50 @@
|
|||||||
"save": "Save",
|
"save": "Save",
|
||||||
"comments": "Comments",
|
"comments": "Comments",
|
||||||
"share": "Share",
|
"share": "Share",
|
||||||
"localOnlyComments": "Only visible on this device",
|
|
||||||
"noComments": "No comments yet.",
|
"noComments": "No comments yet.",
|
||||||
"writeComment": "Write a comment",
|
"writeComment": "Write a comment",
|
||||||
"postComment": "Post",
|
"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.",
|
"shareFailed": "Could not open the share sheet.",
|
||||||
"nowPlaying": "Playing now",
|
"nowPlaying": "Playing now",
|
||||||
"watchFullscreen": "Watch in landscape",
|
"watchFullscreen": "Watch in landscape",
|
||||||
|
|||||||
+41
-1
@@ -65,10 +65,50 @@
|
|||||||
"save": "Simpan",
|
"save": "Simpan",
|
||||||
"comments": "Komentar",
|
"comments": "Komentar",
|
||||||
"share": "Bagikan",
|
"share": "Bagikan",
|
||||||
"localOnlyComments": "Hanya terlihat di perangkat ini",
|
|
||||||
"noComments": "Belum ada komentar.",
|
"noComments": "Belum ada komentar.",
|
||||||
"writeComment": "Tulis komentar",
|
"writeComment": "Tulis komentar",
|
||||||
"postComment": "Kirim",
|
"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.",
|
"shareFailed": "Tidak bisa membuka menu berbagi.",
|
||||||
"nowPlaying": "Sedang diputar",
|
"nowPlaying": "Sedang diputar",
|
||||||
"watchFullscreen": "Tonton mode lanskap",
|
"watchFullscreen": "Tonton mode lanskap",
|
||||||
|
|||||||
@@ -448,12 +448,6 @@ abstract class AppLocalizations {
|
|||||||
/// **'Share'**
|
/// **'Share'**
|
||||||
String get 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.
|
/// No description provided for @noComments.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -472,6 +466,180 @@ abstract class AppLocalizations {
|
|||||||
/// **'Post'**
|
/// **'Post'**
|
||||||
String get postComment;
|
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.
|
/// No description provided for @shareFailed.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -187,9 +187,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get share => 'Share';
|
String get share => 'Share';
|
||||||
|
|
||||||
@override
|
|
||||||
String get localOnlyComments => 'Only visible on this device';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noComments => 'No comments yet.';
|
String get noComments => 'No comments yet.';
|
||||||
|
|
||||||
@@ -199,6 +196,98 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get postComment => 'Post';
|
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
|
@override
|
||||||
String get shareFailed => 'Could not open the share sheet.';
|
String get shareFailed => 'Could not open the share sheet.';
|
||||||
|
|
||||||
|
|||||||
@@ -187,9 +187,6 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get share => 'Bagikan';
|
String get share => 'Bagikan';
|
||||||
|
|
||||||
@override
|
|
||||||
String get localOnlyComments => 'Hanya terlihat di perangkat ini';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noComments => 'Belum ada komentar.';
|
String get noComments => 'Belum ada komentar.';
|
||||||
|
|
||||||
@@ -199,6 +196,98 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get postComment => 'Kirim';
|
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
|
@override
|
||||||
String get shareFailed => 'Tidak bisa membuka menu berbagi.';
|
String get shareFailed => 'Tidak bisa membuka menu berbagi.';
|
||||||
|
|
||||||
|
|||||||
@@ -184,9 +184,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get share => '分享';
|
String get share => '分享';
|
||||||
|
|
||||||
@override
|
|
||||||
String get localOnlyComments => '仅在本机可见';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noComments => '还没有评论。';
|
String get noComments => '还没有评论。';
|
||||||
|
|
||||||
@@ -196,6 +193,97 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get postComment => '发布';
|
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
|
@override
|
||||||
String get shareFailed => '无法打开系统分享面板。';
|
String get shareFailed => '无法打开系统分享面板。';
|
||||||
|
|
||||||
|
|||||||
+41
-1
@@ -65,10 +65,50 @@
|
|||||||
"save": "收藏",
|
"save": "收藏",
|
||||||
"comments": "评论",
|
"comments": "评论",
|
||||||
"share": "分享",
|
"share": "分享",
|
||||||
"localOnlyComments": "仅在本机可见",
|
|
||||||
"noComments": "还没有评论。",
|
"noComments": "还没有评论。",
|
||||||
"writeComment": "写评论",
|
"writeComment": "写评论",
|
||||||
"postComment": "发布",
|
"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": "无法打开系统分享面板。",
|
"shareFailed": "无法打开系统分享面板。",
|
||||||
"nowPlaying": "正在播放",
|
"nowPlaying": "正在播放",
|
||||||
"watchFullscreen": "横屏全屏观看",
|
"watchFullscreen": "横屏全屏观看",
|
||||||
|
|||||||
@@ -8,17 +8,21 @@ import 'package:provider/provider.dart';
|
|||||||
import '../../core/data/models/drama.dart';
|
import '../../core/data/models/drama.dart';
|
||||||
import '../../core/data/services/catalog_controller.dart';
|
import '../../core/data/services/catalog_controller.dart';
|
||||||
import '../../core/data/services/favorite_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/error_text.dart';
|
||||||
import '../../core/formatting/localized_content.dart';
|
import '../../core/formatting/localized_content.dart';
|
||||||
import '../../core/widgets/remote_image.dart';
|
import '../../core/widgets/remote_image.dart';
|
||||||
import '../../core/widgets/auth_gate.dart';
|
import '../../core/widgets/auth_gate.dart';
|
||||||
import '../../core/widgets/download_action.dart';
|
import '../../core/widgets/download_action.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import '../interaction/widgets/report_dialog.dart';
|
||||||
import 'drama_detail_binding.dart';
|
import 'drama_detail_binding.dart';
|
||||||
import 'drama_detail_logic.dart';
|
import 'drama_detail_logic.dart';
|
||||||
import '../../core/data/repositories/watch_history_repository.dart';
|
import '../../core/data/repositories/watch_history_repository.dart';
|
||||||
import '../../core/data/repositories/playback_repository.dart';
|
import '../../core/data/repositories/playback_repository.dart';
|
||||||
|
|
||||||
|
const String _reportMenuValue = 'report';
|
||||||
|
|
||||||
class DramaDetailPage extends StatefulWidget {
|
class DramaDetailPage extends StatefulWidget {
|
||||||
const DramaDetailPage({super.key, required this.drama});
|
const DramaDetailPage({super.key, required this.drama});
|
||||||
final Drama drama;
|
final Drama drama;
|
||||||
@@ -138,6 +142,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
|||||||
backgroundColor: const Color(0xFF111111),
|
backgroundColor: const Color(0xFF111111),
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
actions: [
|
actions: [
|
||||||
|
_reportAction(context, l10n),
|
||||||
_downloadAction(context, l10n),
|
_downloadAction(context, l10n),
|
||||||
_favoriteAction(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) {
|
Widget _favoriteAction(BuildContext context, AppLocalizations l10n) {
|
||||||
final favorites = context.watch<FavoriteController>();
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import '../../core/formatting/localized_content.dart';
|
|||||||
import '../../core/data/models/drama.dart';
|
import '../../core/data/models/drama.dart';
|
||||||
import '../../core/data/repositories/watch_history_repository.dart';
|
import '../../core/data/repositories/watch_history_repository.dart';
|
||||||
import '../../core/data/services/auth_controller.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/data/services/favorite_controller.dart';
|
||||||
import '../../core/formatting/error_text.dart';
|
import '../../core/formatting/error_text.dart';
|
||||||
import '../../core/widgets/remote_image.dart';
|
import '../../core/widgets/remote_image.dart';
|
||||||
@@ -474,14 +474,14 @@ class _MyPageState extends State<MyPage> {
|
|||||||
logic.filter == 0 ||
|
logic.filter == 0 ||
|
||||||
(logic.filter == 1 && fraction < 0.99) ||
|
(logic.filter == 1 && fraction < 0.99) ||
|
||||||
(logic.filter == 2 && fraction >= 0.99);
|
(logic.filter == 2 && fraction >= 0.99);
|
||||||
final engagement = context.watch<EngagementController>();
|
final interactions = context.watch<InteractionController>();
|
||||||
// 收藏以服务端为准(收藏列表接口返回卡片结构);点赞仍是本地状态(K3 接互动)。
|
// 收藏以服务端为准;剧集点赞暂按当前会话状态过滤 feed。
|
||||||
final favorites = context.watch<FavoriteController>();
|
final favorites = context.watch<FavoriteController>();
|
||||||
final isWatchlist = logic.section == 1;
|
final isWatchlist = logic.section == 1;
|
||||||
final markedDramas = isWatchlist
|
final markedDramas = isWatchlist
|
||||||
? favorites.cards
|
? favorites.cards
|
||||||
: (widget.dramas ?? [widget.drama])
|
: (widget.dramas ?? [widget.drama])
|
||||||
.where((drama) => engagement.isLiked(drama.id))
|
.where((drama) => interactions.isLiked(drama.id))
|
||||||
.toList();
|
.toList();
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import 'package:video_player/video_player.dart';
|
|||||||
|
|
||||||
import '../../core/data/models/drama.dart';
|
import '../../core/data/models/drama.dart';
|
||||||
import '../../core/data/models/play_ticket.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/favorite_controller.dart';
|
||||||
|
import '../../core/data/services/interaction_controller.dart';
|
||||||
import '../../core/data/services/playback_session.dart';
|
import '../../core/data/services/playback_session.dart';
|
||||||
import '../../core/data/services/video_player_port.dart';
|
import '../../core/data/services/video_player_port.dart';
|
||||||
import '../../core/formatting/error_text.dart';
|
import '../../core/formatting/error_text.dart';
|
||||||
@@ -108,7 +108,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPlayer(BuildContext context) {
|
Widget _buildPlayer(BuildContext context) {
|
||||||
final engagement = context.watch<EngagementController>();
|
final interactions = context.watch<InteractionController>();
|
||||||
final indices = logic.visibleIndices;
|
final indices = logic.visibleIndices;
|
||||||
final pages = logic.pages;
|
final pages = logic.pages;
|
||||||
final content = AnimatedBuilder(
|
final content = AnimatedBuilder(
|
||||||
@@ -124,7 +124,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
unawaited(logic.selectEpisode(index));
|
unawaited(logic.selectEpisode(index));
|
||||||
},
|
},
|
||||||
itemBuilder: (context, position) =>
|
itemBuilder: (context, position) =>
|
||||||
_buildEpisode(context, indices[position], engagement),
|
_buildEpisode(context, indices[position], interactions),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||||
@@ -141,7 +141,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
Widget _buildEpisode(
|
Widget _buildEpisode(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
int index,
|
int index,
|
||||||
EngagementController engagement,
|
InteractionController interactions,
|
||||||
) {
|
) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final entry = logic.queue.entryAt(index);
|
final entry = logic.queue.entryAt(index);
|
||||||
@@ -336,7 +336,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
drama: drama,
|
drama: drama,
|
||||||
episode: episode,
|
episode: episode,
|
||||||
controller: ready ? videoPort!.controller : null,
|
controller: ready ? videoPort!.controller : null,
|
||||||
liked: engagement.isLiked(drama.id),
|
liked: interactions.isLiked(drama.id),
|
||||||
// 收藏以服务端为准(FavoriteController 由 app 注入,登录后自动加载)
|
// 收藏以服务端为准(FavoriteController 由 app 注入,登录后自动加载)
|
||||||
saved: context.watch<FavoriteController>().isSaved(drama.id),
|
saved: context.watch<FavoriteController>().isSaved(drama.id),
|
||||||
onBack: widget.embedded
|
onBack: widget.embedded
|
||||||
@@ -348,7 +348,17 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
// 收藏/点赞要对到具体账号:未登录先引导登录(首页/详情仍可匿名浏览)
|
// 收藏/点赞要对到具体账号:未登录先引导登录(首页/详情仍可匿名浏览)
|
||||||
onLike: () async {
|
onLike: () async {
|
||||||
if (await ensureSignedIn(context)) {
|
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 {
|
onSave: () async {
|
||||||
|
|||||||
@@ -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
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../../core/data/models/comment.dart';
|
||||||
import '../../../core/data/models/drama.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 '../../../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 {
|
class DramaCommentsSheet extends StatefulWidget {
|
||||||
const DramaCommentsSheet({super.key, required this.drama});
|
const DramaCommentsSheet({super.key, required this.drama});
|
||||||
|
|
||||||
@@ -18,26 +33,120 @@ class DramaCommentsSheet extends StatefulWidget {
|
|||||||
|
|
||||||
class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
|
class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
|
||||||
final _input = TextEditingController();
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
_input.dispose();
|
_input.dispose();
|
||||||
|
_inputFocus.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _post() {
|
Future<void> _post() async {
|
||||||
final text = _input.text.trim();
|
if (_controller.submitting) return;
|
||||||
if (text.isEmpty) return;
|
if (_input.text.trim().isEmpty) return;
|
||||||
context.read<EngagementController>().addComment(widget.drama.id, text);
|
if (!await ensureSignedIn(context) || !mounted) return;
|
||||||
_input.clear();
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
|
||||||
final comments = context.watch<EngagementController>().commentsFor(
|
|
||||||
widget.drama.id,
|
|
||||||
);
|
|
||||||
final available =
|
final available =
|
||||||
MediaQuery.sizeOf(context).height -
|
MediaQuery.sizeOf(context).height -
|
||||||
MediaQuery.viewInsetsOf(context).bottom -
|
MediaQuery.viewInsetsOf(context).bottom -
|
||||||
@@ -45,83 +154,244 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
|
|||||||
return Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
|
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: available.clamp(240.0, 460.0),
|
height: available.clamp(_minSheetHeight, _maxSheetHeight),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||||
child: Column(
|
child: ListenableBuilder(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
listenable: _controller,
|
||||||
children: [
|
builder: (context, _) => _content(context),
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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/data/models/drama.dart';
|
||||||
import 'package:fruitshorts/core/formatting/localized_content.dart';
|
import 'package:fruitshorts/core/formatting/localized_content.dart';
|
||||||
import 'package:fruitshorts/l10n/app_localizations_en.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';
|
import 'package:fruitshorts/l10n/app_localizations_zh.dart';
|
||||||
|
|
||||||
Drama _drama({required String id, String title = 'raw', String synopsis = 'raw'}) =>
|
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() {
|
void main() {
|
||||||
final en = AppLocalizationsEn();
|
final en = AppLocalizationsEn();
|
||||||
final zh = AppLocalizationsZh();
|
final zh = AppLocalizationsZh();
|
||||||
|
final id = AppLocalizationsId();
|
||||||
|
|
||||||
test('should_map_each_seeded_drama_to_its_own_localized_copy_when_looked_up', () {
|
test('should_map_each_seeded_drama_to_its_own_localized_copy_when_looked_up', () {
|
||||||
final signal = _drama(id: 'the-signal-demo');
|
final signal = _drama(id: 'the-signal-demo');
|
||||||
@@ -93,4 +95,21 @@ void main() {
|
|||||||
}
|
}
|
||||||
expect(localizedEpisodeTitle(zh, 12), zh.episodeLabel(12));
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
@@ -4,15 +4,20 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:fruitshorts/app.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:fruitshorts/pages/player/player_view.dart';
|
||||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.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 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||||
|
|
||||||
import 'support/fake_catalog.dart';
|
import 'support/fake_catalog.dart';
|
||||||
import 'support/fake_category.dart';
|
import 'support/fake_category.dart';
|
||||||
|
import 'support/fake_interaction_repositories.dart';
|
||||||
import 'support/widget_player.dart';
|
import 'support/widget_player.dart';
|
||||||
import 'package:fruitshorts/core/data/repositories/favorite_repository.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/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/playback_repository.dart';
|
||||||
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||||
import 'package:fruitshorts/core/data/services/playback_session.dart';
|
import 'package:fruitshorts/core/data/services/playback_session.dart';
|
||||||
@@ -31,6 +36,11 @@ void main() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void seedSignedOutSession() {
|
||||||
|
SharedPreferencesAsyncPlatform.instance =
|
||||||
|
InMemorySharedPreferencesAsync.empty();
|
||||||
|
}
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
seedSignedInSession();
|
seedSignedInSession();
|
||||||
});
|
});
|
||||||
@@ -40,6 +50,8 @@ void main() {
|
|||||||
Widget appUnderTest({
|
Widget appUnderTest({
|
||||||
PlayerFactory? playerFactory,
|
PlayerFactory? playerFactory,
|
||||||
CatalogRepository? catalogRepository,
|
CatalogRepository? catalogRepository,
|
||||||
|
CommentRepository? commentRepository,
|
||||||
|
InteractionRepository? interactionRepository,
|
||||||
}) {
|
}) {
|
||||||
final catalog = catalogRepository ?? FakeCatalogRepository();
|
final catalog = catalogRepository ?? FakeCatalogRepository();
|
||||||
return LumaDramaApp(
|
return LumaDramaApp(
|
||||||
@@ -51,6 +63,9 @@ void main() {
|
|||||||
cards: FakeCatalogRepository().dramas,
|
cards: FakeCatalogRepository().dramas,
|
||||||
),
|
),
|
||||||
playbackRepository: FakePlaybackRepository(),
|
playbackRepository: FakePlaybackRepository(),
|
||||||
|
commentRepository: commentRepository ?? FakeCommentRepository(),
|
||||||
|
interactionRepository:
|
||||||
|
interactionRepository ?? FakeInteractionRepository(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,6 +430,10 @@ void main() {
|
|||||||
addTearDown(tester.view.resetPhysicalSize);
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
addTearDown(tester.view.resetDevicePixelRatio);
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
String? sharedText;
|
String? sharedText;
|
||||||
|
final interactions = FakeInteractionRepository();
|
||||||
|
final comments = FakeCommentRepository(
|
||||||
|
pages: [const CommentPage()],
|
||||||
|
);
|
||||||
const channel = MethodChannel('com.stormg.lumadrama/share');
|
const channel = MethodChannel('com.stormg.lumadrama/share');
|
||||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (
|
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (
|
||||||
call,
|
call,
|
||||||
@@ -430,7 +449,10 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
appUnderTest(),
|
appUnderTest(
|
||||||
|
commentRepository: comments,
|
||||||
|
interactionRepository: interactions,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.byKey(const Key('tab-home')));
|
await tester.tap(find.byKey(const Key('tab-home')));
|
||||||
@@ -450,7 +472,7 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.byKey(const Key('rail-comments')));
|
await tester.tap(find.byKey(const Key('rail-comments')));
|
||||||
await tester.pumpAndSettle();
|
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.enterText(find.byKey(const Key('comment-input')), 'Nice demo');
|
||||||
await tester.tap(find.byKey(const Key('comment-post')));
|
await tester.tap(find.byKey(const Key('comment-post')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -459,12 +481,67 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('rail-like')));
|
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.tap(find.byKey(const Key('rail-share')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(sharedText, contains('The Signal'));
|
expect(sharedText, contains('The Signal'));
|
||||||
|
expect(sharedText, contains('A demo series for vertical playback'));
|
||||||
|
expect(sharedText, contains('FruitShorts'));
|
||||||
expect(tester.takeException(), isNull);
|
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 {
|
testWidgets('should_open_drawer_and_stay_on_home_when_system_back_is_pressed', (tester) async {
|
||||||
tester.view.physicalSize = const Size(390, 844);
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
tester.view.devicePixelRatio = 1;
|
tester.view.devicePixelRatio = 1;
|
||||||
|
|||||||
Reference in new issue
Block a user