feat(flutter): 接入互动评论点赞举报
This commit is contained in:
1 parent
45f5f25640
commit
d361034226
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/catalog_api.dart';
|
||||
import 'core/data/remote/category_api.dart';
|
||||
import 'core/data/remote/comment_api.dart';
|
||||
import 'core/data/remote/download_api.dart';
|
||||
import 'core/data/remote/interaction_api.dart';
|
||||
import 'core/data/remote/library_api.dart';
|
||||
import 'core/data/remote/playback_api.dart';
|
||||
import 'core/data/remote/watch_api.dart';
|
||||
import 'core/data/repositories/catalog_repository.dart';
|
||||
import 'core/data/repositories/category_repository.dart';
|
||||
import 'core/data/repositories/comment_repository.dart';
|
||||
import 'core/data/repositories/download_repository.dart';
|
||||
import 'core/data/repositories/engagement_repository.dart';
|
||||
import 'core/data/repositories/favorite_repository.dart';
|
||||
import 'core/data/repositories/interaction_repository.dart';
|
||||
import 'core/data/repositories/playback_repository.dart';
|
||||
import 'core/data/repositories/watch_history_repository.dart';
|
||||
import 'core/data/services/auth_controller.dart';
|
||||
@@ -26,8 +29,8 @@ import 'core/data/services/auth_session_store.dart';
|
||||
import 'core/data/services/catalog_controller.dart';
|
||||
import 'core/data/services/category_controller.dart';
|
||||
import 'core/data/services/download_controller.dart';
|
||||
import 'core/data/services/engagement_controller.dart';
|
||||
import 'core/data/services/favorite_controller.dart';
|
||||
import 'core/data/services/interaction_controller.dart';
|
||||
import 'core/data/services/playback_session.dart';
|
||||
import 'core/data/services/video_player_port.dart';
|
||||
import 'l10n/app_localizations.dart';
|
||||
@@ -43,6 +46,8 @@ class LumaDramaApp extends StatefulWidget {
|
||||
this.watchHistoryRepository,
|
||||
this.favoriteRepository,
|
||||
this.playbackRepository,
|
||||
this.commentRepository,
|
||||
this.interactionRepository,
|
||||
});
|
||||
|
||||
final PlayerFactory playerFactory;
|
||||
@@ -54,6 +59,8 @@ class LumaDramaApp extends StatefulWidget {
|
||||
final WatchHistoryRepository? watchHistoryRepository;
|
||||
final FavoriteRepository? favoriteRepository;
|
||||
final PlaybackRepository? playbackRepository;
|
||||
final CommentRepository? commentRepository;
|
||||
final InteractionRepository? interactionRepository;
|
||||
|
||||
@override
|
||||
State<LumaDramaApp> createState() => _LumaDramaAppState();
|
||||
@@ -113,6 +120,16 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
||||
_downloadRepository,
|
||||
);
|
||||
|
||||
late final CommentRepository _commentRepository =
|
||||
widget.commentRepository ?? HttpCommentRepository(CommentApi(_apiClient));
|
||||
|
||||
late final InteractionRepository _interactionRepository =
|
||||
widget.interactionRepository ??
|
||||
HttpInteractionRepository(InteractionApi(_apiClient));
|
||||
|
||||
late final InteractionController _interactionController =
|
||||
InteractionController(_interactionRepository);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -128,6 +145,7 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
||||
void _onAuthChanged() {
|
||||
if (_auth.isSignedIn == _wasSignedIn) return;
|
||||
_wasSignedIn = _auth.isSignedIn;
|
||||
_interactionController.clear();
|
||||
if (_auth.isSignedIn) {
|
||||
unawaited(_favoriteController.load());
|
||||
} else {
|
||||
@@ -141,6 +159,7 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
||||
_auth.removeListener(_onAuthChanged);
|
||||
_favoriteController.dispose();
|
||||
_downloadController.dispose();
|
||||
_interactionController.dispose();
|
||||
_apiClient.close();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -154,6 +173,8 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
||||
Provider<PlaybackRepository>.value(value: _playback),
|
||||
Provider<WatchHistoryRepository>.value(value: _watchHistory),
|
||||
Provider<FavoriteRepository>.value(value: _favorites),
|
||||
Provider<CommentRepository>.value(value: _commentRepository),
|
||||
Provider<InteractionRepository>.value(value: _interactionRepository),
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => CatalogController(_catalogRepository)..load(),
|
||||
),
|
||||
@@ -163,11 +184,7 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
||||
ChangeNotifierProvider(create: (_) => LocaleController()..load()),
|
||||
ChangeNotifierProvider.value(value: _favoriteController),
|
||||
ChangeNotifierProvider.value(value: _downloadController),
|
||||
ChangeNotifierProvider(
|
||||
create: (_) =>
|
||||
EngagementController(SharedPreferencesEngagementRepository())
|
||||
..load(),
|
||||
),
|
||||
ChangeNotifierProvider.value(value: _interactionController),
|
||||
ChangeNotifierProvider.value(value: _auth),
|
||||
],
|
||||
child: Consumer<LocaleController>(
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'json_numbers.dart';
|
||||
|
||||
/// 评论游标分页大小,契约 §1.7 使用游标而非页码。
|
||||
const int commentPageSize = 20;
|
||||
|
||||
/// 评论列表页。
|
||||
class CommentPage {
|
||||
const CommentPage({
|
||||
this.comments = const [],
|
||||
this.nextCursor,
|
||||
this.hasMore = false,
|
||||
});
|
||||
|
||||
final List<DramaComment> comments;
|
||||
final String? nextCursor;
|
||||
final bool hasMore;
|
||||
|
||||
/// 解析后端 `CursorResult{list,nextCursor,hasMore}`。
|
||||
factory CommentPage.fromJson(Map<String, dynamic> json) {
|
||||
final rawList = json['list'];
|
||||
return CommentPage(
|
||||
comments: [
|
||||
if (rawList is List)
|
||||
for (final item in rawList)
|
||||
if (item is Map<String, dynamic>) DramaComment.fromJson(item),
|
||||
],
|
||||
nextCursor: json['nextCursor'] == null
|
||||
? null
|
||||
: jsonToId(json['nextCursor']),
|
||||
hasMore: json['hasMore'] == true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 服务端评论视图,字段对应 `CommentRespVO`。
|
||||
class DramaComment {
|
||||
const DramaComment({
|
||||
required this.id,
|
||||
required this.dramaId,
|
||||
required this.episodeId,
|
||||
required this.parentId,
|
||||
required this.rootId,
|
||||
required this.userId,
|
||||
required this.uid,
|
||||
required this.nickname,
|
||||
required this.avatarUrl,
|
||||
required this.content,
|
||||
required this.status,
|
||||
required this.auditReason,
|
||||
required this.likeCount,
|
||||
required this.replyCount,
|
||||
required this.reportCount,
|
||||
required this.createdAt,
|
||||
required this.mine,
|
||||
required this.liked,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String dramaId;
|
||||
final String episodeId;
|
||||
final String parentId;
|
||||
final String rootId;
|
||||
final String userId;
|
||||
final String uid;
|
||||
final String nickname;
|
||||
final String avatarUrl;
|
||||
final String content;
|
||||
final String status;
|
||||
final String auditReason;
|
||||
final int likeCount;
|
||||
final int replyCount;
|
||||
final int reportCount;
|
||||
final DateTime? createdAt;
|
||||
final bool mine;
|
||||
final bool liked;
|
||||
|
||||
/// 待人工审核的评论仅本人可见。
|
||||
bool get isPending => status == 'pending';
|
||||
|
||||
/// 顶层评论没有父评论。
|
||||
bool get isTopLevel => parentId.isEmpty || parentId == '0';
|
||||
|
||||
/// 解析 `CommentRespVO`,所有 ID 字段只走字符串解析。
|
||||
factory DramaComment.fromJson(Map<String, dynamic> json) => DramaComment(
|
||||
id: jsonToId(json['id']),
|
||||
dramaId: jsonToId(json['dramaId']),
|
||||
episodeId: jsonToId(json['episodeId'], fallback: '0'),
|
||||
parentId: jsonToId(json['parentId'], fallback: '0'),
|
||||
rootId: jsonToId(json['rootId'], fallback: '0'),
|
||||
userId: jsonToId(json['userId']),
|
||||
uid: json['uid']?.toString() ?? '',
|
||||
nickname: json['nickname']?.toString() ?? '',
|
||||
avatarUrl: json['avatarUrl']?.toString() ?? '',
|
||||
content: json['content']?.toString() ?? '',
|
||||
status: json['status']?.toString() ?? '',
|
||||
auditReason: json['auditReason']?.toString() ?? '',
|
||||
likeCount: jsonToInt(json['likeCount']),
|
||||
replyCount: jsonToInt(json['replyCount']),
|
||||
reportCount: jsonToInt(json['reportCount']),
|
||||
createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? ''),
|
||||
mine: json['mine'] == true,
|
||||
liked: json['liked'] == true,
|
||||
);
|
||||
|
||||
/// 局部更新点赞或回复数,列表中的其他字段保持不变。
|
||||
DramaComment copyWith({
|
||||
int? likeCount,
|
||||
int? replyCount,
|
||||
bool? liked,
|
||||
}) => DramaComment(
|
||||
id: id,
|
||||
dramaId: dramaId,
|
||||
episodeId: episodeId,
|
||||
parentId: parentId,
|
||||
rootId: rootId,
|
||||
userId: userId,
|
||||
uid: uid,
|
||||
nickname: nickname,
|
||||
avatarUrl: avatarUrl,
|
||||
content: content,
|
||||
status: status,
|
||||
auditReason: auditReason,
|
||||
likeCount: likeCount ?? this.likeCount,
|
||||
replyCount: replyCount ?? this.replyCount,
|
||||
reportCount: reportCount,
|
||||
createdAt: createdAt,
|
||||
mine: mine,
|
||||
liked: liked ?? this.liked,
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
class EngagementSnapshot {
|
||||
const EngagementSnapshot({
|
||||
this.savedDramaIds = const {},
|
||||
this.likedDramaIds = const {},
|
||||
this.commentsByDrama = const {},
|
||||
});
|
||||
|
||||
final Set<String> savedDramaIds;
|
||||
final Set<String> likedDramaIds;
|
||||
final Map<String, List<String>> commentsByDrama;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'json_numbers.dart';
|
||||
|
||||
/// 互动目标类型,契约只允许短剧与评论。
|
||||
enum InteractionTargetType {
|
||||
drama('DRAMA'),
|
||||
comment('COMMENT');
|
||||
|
||||
const InteractionTargetType(this.apiValue);
|
||||
|
||||
final String apiValue;
|
||||
}
|
||||
|
||||
/// 客户端固定举报原因,后端当前未限制枚举值。
|
||||
enum ReportReason {
|
||||
spam('SPAM'),
|
||||
abuse('ABUSE'),
|
||||
porn('PORN'),
|
||||
ad('AD'),
|
||||
other('OTHER');
|
||||
|
||||
const ReportReason(this.apiValue);
|
||||
|
||||
final String apiValue;
|
||||
}
|
||||
|
||||
/// 一次性互动请求,供仓库和测试断言使用。
|
||||
class ReportRequest {
|
||||
const ReportRequest({
|
||||
required this.targetType,
|
||||
required this.targetId,
|
||||
required this.reasonCode,
|
||||
this.description = '',
|
||||
});
|
||||
|
||||
final InteractionTargetType targetType;
|
||||
final String targetId;
|
||||
final String reasonCode;
|
||||
final String description;
|
||||
}
|
||||
|
||||
/// 点赞切换后的服务端状态。
|
||||
class LikeToggleResult {
|
||||
const LikeToggleResult({required this.liked, required this.count});
|
||||
|
||||
final bool liked;
|
||||
final int count;
|
||||
|
||||
/// 计数按 Long 字符串兼容解析。
|
||||
factory LikeToggleResult.fromJson(Map<String, dynamic> json) =>
|
||||
LikeToggleResult(
|
||||
liked: json['liked'] == true,
|
||||
count: jsonToInt(json['count']),
|
||||
);
|
||||
}
|
||||
|
||||
/// 控制器统一返回结果;失败消息可直接展示后端 `message`。
|
||||
class OperationResult<T> {
|
||||
const OperationResult._(this.data, this.errorMessage);
|
||||
|
||||
const OperationResult.success([T? data]) : this._(data, null);
|
||||
|
||||
const OperationResult.failure(this.errorMessage) : data = null;
|
||||
|
||||
final T? data;
|
||||
final String? errorMessage;
|
||||
|
||||
bool get isSuccess => errorMessage == null;
|
||||
}
|
||||
@@ -11,3 +11,10 @@ int jsonToInt(dynamic value, {int fallback = 0}) {
|
||||
if (value is String) return int.tryParse(value.trim()) ?? fallback;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/// ID 字段统一转字符串;后端按契约下发字符串,同时兼容测试或旧数据的数字形式。
|
||||
String jsonToId(dynamic value, {String fallback = ''}) {
|
||||
if (value is String) return value;
|
||||
if (value is num) return value.toString();
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../models/comment.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
/// 评论接口,对应契约 §1.7 的 `/app/comments*`。
|
||||
class CommentApi {
|
||||
const CommentApi(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
/// 拉取游标分页;`rootId` 用于加载某一楼层的回复。
|
||||
Future<CommentPage> page({
|
||||
required String dramaId,
|
||||
String? rootId,
|
||||
String? cursor,
|
||||
int size = commentPageSize,
|
||||
}) async {
|
||||
final data = await _client.get(
|
||||
'/app/comments',
|
||||
query: {
|
||||
'dramaId': dramaId,
|
||||
'rootId': ?rootId,
|
||||
'cursor': ?cursor,
|
||||
'size': '$size',
|
||||
},
|
||||
);
|
||||
if (data is! Map<String, dynamic>) return const CommentPage();
|
||||
return CommentPage.fromJson(data);
|
||||
}
|
||||
|
||||
/// 发表评论或回复;回复只提交 `parentId`,不自行拼层级。
|
||||
Future<DramaComment> publish({
|
||||
required String dramaId,
|
||||
String? episodeId,
|
||||
required String content,
|
||||
String? parentId,
|
||||
}) async {
|
||||
final data = await _client.post(
|
||||
'/app/comments',
|
||||
body: {
|
||||
'dramaId': dramaId,
|
||||
if (episodeId != null && episodeId.isNotEmpty)
|
||||
'episodeId': episodeId,
|
||||
'content': content,
|
||||
if (parentId != null && parentId.isNotEmpty) 'parentId': parentId,
|
||||
},
|
||||
);
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw const ApiException(500, '评论响应格式错误');
|
||||
}
|
||||
return DramaComment.fromJson(data);
|
||||
}
|
||||
|
||||
/// 删除自己的评论。
|
||||
Future<void> delete(String commentId) async {
|
||||
await _client.delete('/app/comments/$commentId');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../models/interaction.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
/// 点赞与举报接口,对应契约 §1.7。
|
||||
class InteractionApi {
|
||||
const InteractionApi(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
/// 切换点赞,服务端返回的状态与计数为最终真相。
|
||||
Future<LikeToggleResult> toggleLike({
|
||||
required InteractionTargetType targetType,
|
||||
required String targetId,
|
||||
}) async {
|
||||
final data = await _client.post(
|
||||
'/app/likes/toggle',
|
||||
body: {
|
||||
'targetType': targetType.apiValue,
|
||||
'targetId': targetId,
|
||||
},
|
||||
);
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw const ApiException(500, '点赞响应格式错误');
|
||||
}
|
||||
return LikeToggleResult.fromJson(data);
|
||||
}
|
||||
|
||||
/// 提交举报,返回工单 ID。
|
||||
Future<String> report(ReportRequest request) async {
|
||||
final data = await _client.post(
|
||||
'/app/reports',
|
||||
body: {
|
||||
'targetType': request.targetType.apiValue,
|
||||
'targetId': request.targetId,
|
||||
'reasonCode': request.reasonCode,
|
||||
if (request.description.isNotEmpty)
|
||||
'description': request.description,
|
||||
},
|
||||
);
|
||||
return data?.toString() ?? '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../models/comment.dart';
|
||||
import '../remote/comment_api.dart';
|
||||
|
||||
/// 评论数据来源抽象,页面与控制器不直接依赖 HTTP。
|
||||
abstract interface class CommentRepository {
|
||||
/// 拉取评论或回复的分页。
|
||||
Future<CommentPage> page({
|
||||
required String dramaId,
|
||||
String? rootId,
|
||||
String? cursor,
|
||||
int size = commentPageSize,
|
||||
});
|
||||
|
||||
/// 发表评论或回复。
|
||||
Future<DramaComment> publish({
|
||||
required String dramaId,
|
||||
String? episodeId,
|
||||
required String content,
|
||||
String? parentId,
|
||||
});
|
||||
|
||||
/// 删除指定评论。
|
||||
Future<void> delete(String commentId);
|
||||
}
|
||||
|
||||
/// HTTP 评论仓库。
|
||||
class HttpCommentRepository implements CommentRepository {
|
||||
const HttpCommentRepository(this._api);
|
||||
|
||||
final CommentApi _api;
|
||||
|
||||
@override
|
||||
Future<CommentPage> page({
|
||||
required String dramaId,
|
||||
String? rootId,
|
||||
String? cursor,
|
||||
int size = commentPageSize,
|
||||
}) => _api.page(
|
||||
dramaId: dramaId,
|
||||
rootId: rootId,
|
||||
cursor: cursor,
|
||||
size: size,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<DramaComment> publish({
|
||||
required String dramaId,
|
||||
String? episodeId,
|
||||
required String content,
|
||||
String? parentId,
|
||||
}) => _api.publish(
|
||||
dramaId: dramaId,
|
||||
episodeId: episodeId,
|
||||
content: content,
|
||||
parentId: parentId,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> delete(String commentId) => _api.delete(commentId);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/engagement_snapshot.dart';
|
||||
|
||||
abstract interface class EngagementRepository {
|
||||
Future<EngagementSnapshot> load();
|
||||
Future<void> save(EngagementSnapshot snapshot);
|
||||
}
|
||||
|
||||
class SharedPreferencesEngagementRepository implements EngagementRepository {
|
||||
SharedPreferencesEngagementRepository({SharedPreferencesAsync? preferences})
|
||||
: _preferences = preferences ?? SharedPreferencesAsync();
|
||||
|
||||
static const _key = 'engagement.v1.demo-local-v1';
|
||||
final SharedPreferencesAsync _preferences;
|
||||
|
||||
@override
|
||||
Future<EngagementSnapshot> load() async {
|
||||
final raw = await _preferences.getString(_key);
|
||||
if (raw == null) return const EngagementSnapshot();
|
||||
try {
|
||||
final json = jsonDecode(raw);
|
||||
if (json is! Map<String, dynamic>) return const EngagementSnapshot();
|
||||
Set<String> ids(Object? value) =>
|
||||
value is List ? value.whereType<String>().toSet() : <String>{};
|
||||
final comments = <String, List<String>>{};
|
||||
final rawComments = json['comments'];
|
||||
if (rawComments is Map<String, dynamic>) {
|
||||
for (final entry in rawComments.entries) {
|
||||
if (entry.value is List) {
|
||||
comments[entry.key] = (entry.value as List)
|
||||
.whereType<String>()
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
return EngagementSnapshot(
|
||||
savedDramaIds: ids(json['saved']),
|
||||
likedDramaIds: ids(json['liked']),
|
||||
commentsByDrama: comments,
|
||||
);
|
||||
} catch (_) {
|
||||
return const EngagementSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> save(EngagementSnapshot snapshot) => _preferences.setString(
|
||||
_key,
|
||||
jsonEncode({
|
||||
'saved': snapshot.savedDramaIds.toList(),
|
||||
'liked': snapshot.likedDramaIds.toList(),
|
||||
'comments': snapshot.commentsByDrama,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
class InMemoryEngagementRepository implements EngagementRepository {
|
||||
EngagementSnapshot snapshot = const EngagementSnapshot();
|
||||
|
||||
@override
|
||||
Future<EngagementSnapshot> load() async => snapshot;
|
||||
|
||||
@override
|
||||
Future<void> save(EngagementSnapshot value) async => snapshot = value;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../models/interaction.dart';
|
||||
import '../remote/interaction_api.dart';
|
||||
|
||||
/// 点赞与举报数据来源抽象。
|
||||
abstract interface class InteractionRepository {
|
||||
/// 切换目标点赞状态。
|
||||
Future<LikeToggleResult> toggleLike({
|
||||
required InteractionTargetType targetType,
|
||||
required String targetId,
|
||||
});
|
||||
|
||||
/// 提交举报并返回工单 ID。
|
||||
Future<String> report(ReportRequest request);
|
||||
}
|
||||
|
||||
/// HTTP 互动仓库。
|
||||
class HttpInteractionRepository implements InteractionRepository {
|
||||
const HttpInteractionRepository(this._api);
|
||||
|
||||
final InteractionApi _api;
|
||||
|
||||
@override
|
||||
Future<LikeToggleResult> toggleLike({
|
||||
required InteractionTargetType targetType,
|
||||
required String targetId,
|
||||
}) => _api.toggleLike(targetType: targetType, targetId: targetId);
|
||||
|
||||
@override
|
||||
Future<String> report(ReportRequest request) => _api.report(request);
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/comment.dart';
|
||||
import '../models/interaction.dart';
|
||||
import '../remote/api_client.dart';
|
||||
import '../repositories/comment_repository.dart';
|
||||
import '../repositories/interaction_repository.dart';
|
||||
|
||||
/// 评论列表、发布、回复、删除与评论点赞的状态控制器。
|
||||
class CommentController extends ChangeNotifier {
|
||||
CommentController({
|
||||
required CommentRepository comments,
|
||||
required InteractionRepository interactions,
|
||||
required this.dramaId,
|
||||
this.episodeId,
|
||||
}) : _commentRepository = comments,
|
||||
_interactionRepository = interactions;
|
||||
|
||||
final CommentRepository _commentRepository;
|
||||
final InteractionRepository _interactionRepository;
|
||||
final String dramaId;
|
||||
final String? episodeId;
|
||||
final List<DramaComment> _comments = [];
|
||||
final Map<String, List<DramaComment>> _replies = {};
|
||||
final Map<String, String?> _replyCursors = {};
|
||||
final Map<String, bool> _replyHasMore = {};
|
||||
final Set<String> _expandedRoots = {};
|
||||
final Set<String> _loadingReplies = {};
|
||||
final Set<String> _loadingMoreReplies = {};
|
||||
final Set<String> _likingCommentIds = {};
|
||||
final Set<String> _deletingCommentIds = {};
|
||||
bool _disposed = false;
|
||||
bool loading = false;
|
||||
bool loadingMore = false;
|
||||
bool submitting = false;
|
||||
bool hasMore = false;
|
||||
String? nextCursor;
|
||||
String? error;
|
||||
|
||||
/// 当前已加载的顶层评论。
|
||||
List<DramaComment> get comments => List.unmodifiable(_comments);
|
||||
|
||||
/// 某一楼层已加载的回复。
|
||||
List<DramaComment> repliesFor(String rootId) =>
|
||||
List.unmodifiable(_replies[rootId] ?? const []);
|
||||
|
||||
/// 楼层回复是否已展开。
|
||||
bool repliesExpanded(String rootId) => _expandedRoots.contains(rootId);
|
||||
|
||||
/// 楼层回复是否正在首屏加载。
|
||||
bool repliesLoading(String rootId) => _loadingReplies.contains(rootId);
|
||||
|
||||
/// 楼层回复是否还有下一页。
|
||||
bool repliesHasMore(String rootId) => _replyHasMore[rootId] ?? false;
|
||||
|
||||
/// 评论是否正在提交点赞,用于防连点。
|
||||
bool likingComment(String id) => _likingCommentIds.contains(id);
|
||||
|
||||
void _notify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
/// 加载首屏评论,成功后替换当前顶层列表。
|
||||
Future<void> load() async {
|
||||
loading = true;
|
||||
error = null;
|
||||
_notify();
|
||||
try {
|
||||
final page = await _commentRepository.page(
|
||||
dramaId: dramaId,
|
||||
size: commentPageSize,
|
||||
);
|
||||
_comments
|
||||
..clear()
|
||||
..addAll(page.comments);
|
||||
nextCursor = page.nextCursor;
|
||||
hasMore = page.hasMore;
|
||||
} catch (failure) {
|
||||
error = _errorMessage(failure) ?? 'commentsLoadFailed';
|
||||
} finally {
|
||||
loading = false;
|
||||
_notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用 `nextCursor` 续拉顶层评论。
|
||||
Future<void> loadMore() async {
|
||||
final cursor = nextCursor;
|
||||
if (loadingMore || !hasMore || cursor == null) return;
|
||||
loadingMore = true;
|
||||
error = null;
|
||||
_notify();
|
||||
try {
|
||||
final page = await _commentRepository.page(
|
||||
dramaId: dramaId,
|
||||
cursor: cursor,
|
||||
size: commentPageSize,
|
||||
);
|
||||
_comments.addAll(page.comments);
|
||||
nextCursor = page.nextCursor;
|
||||
hasMore = page.hasMore;
|
||||
} catch (failure) {
|
||||
error = _errorMessage(failure) ?? 'commentsLoadFailed';
|
||||
} finally {
|
||||
loadingMore = false;
|
||||
_notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// 展开并用 `rootId` 加载一层楼层回复。
|
||||
Future<void> loadReplies(String rootId) async {
|
||||
_expandedRoots.add(rootId);
|
||||
if (_replies.containsKey(rootId) || _loadingReplies.contains(rootId)) {
|
||||
_notify();
|
||||
return;
|
||||
}
|
||||
_loadingReplies.add(rootId);
|
||||
error = null;
|
||||
_notify();
|
||||
try {
|
||||
final page = await _commentRepository.page(
|
||||
dramaId: dramaId,
|
||||
rootId: rootId,
|
||||
size: commentPageSize,
|
||||
);
|
||||
_replies[rootId] = List.of(page.comments);
|
||||
_replyCursors[rootId] = page.nextCursor;
|
||||
_replyHasMore[rootId] = page.hasMore;
|
||||
} catch (failure) {
|
||||
error = _errorMessage(failure) ?? 'commentsLoadFailed';
|
||||
} finally {
|
||||
_loadingReplies.remove(rootId);
|
||||
_notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// 收起楼层回复,不丢弃已加载数据。
|
||||
void hideReplies(String rootId) {
|
||||
if (_expandedRoots.remove(rootId)) _notify();
|
||||
}
|
||||
|
||||
/// 续拉楼层回复。
|
||||
Future<void> loadMoreReplies(String rootId) async {
|
||||
final cursor = _replyCursors[rootId];
|
||||
if (_loadingMoreReplies.contains(rootId) || cursor == null) return;
|
||||
if (!(_replyHasMore[rootId] ?? false)) return;
|
||||
_loadingMoreReplies.add(rootId);
|
||||
_notify();
|
||||
try {
|
||||
final page = await _commentRepository.page(
|
||||
dramaId: dramaId,
|
||||
rootId: rootId,
|
||||
cursor: cursor,
|
||||
size: commentPageSize,
|
||||
);
|
||||
(_replies[rootId] ??= []).addAll(page.comments);
|
||||
_replyCursors[rootId] = page.nextCursor;
|
||||
_replyHasMore[rootId] = page.hasMore;
|
||||
} catch (failure) {
|
||||
error = _errorMessage(failure) ?? 'commentsLoadFailed';
|
||||
} finally {
|
||||
_loadingMoreReplies.remove(rootId);
|
||||
_notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// 发布评论或回复,输入框是否清空由调用方根据成功结果决定。
|
||||
Future<OperationResult<DramaComment>> publish(
|
||||
String rawContent, {
|
||||
DramaComment? replyTo,
|
||||
}) async {
|
||||
final content = rawContent.trim();
|
||||
if (content.isEmpty || submitting) {
|
||||
return const OperationResult.failure(null);
|
||||
}
|
||||
submitting = true;
|
||||
_notify();
|
||||
try {
|
||||
final comment = await _commentRepository.publish(
|
||||
dramaId: dramaId,
|
||||
episodeId: episodeId,
|
||||
content: content,
|
||||
parentId: replyTo?.id,
|
||||
);
|
||||
_insertPublished(comment, replyTo);
|
||||
return OperationResult.success(comment);
|
||||
} catch (failure) {
|
||||
return OperationResult.failure(_errorMessage(failure));
|
||||
} finally {
|
||||
submitting = false;
|
||||
_notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// 乐观删除自己的评论,失败时恢复到原位置。
|
||||
Future<OperationResult<void>> delete(DramaComment comment) async {
|
||||
if (!comment.mine || _deletingCommentIds.contains(comment.id)) {
|
||||
return const OperationResult.failure(null);
|
||||
}
|
||||
_deletingCommentIds.add(comment.id);
|
||||
final location = _removeComment(comment.id);
|
||||
_notify();
|
||||
try {
|
||||
await _commentRepository.delete(comment.id);
|
||||
return const OperationResult.success();
|
||||
} catch (failure) {
|
||||
_restoreComment(comment, location);
|
||||
return OperationResult.failure(_errorMessage(failure));
|
||||
} finally {
|
||||
_deletingCommentIds.remove(comment.id);
|
||||
_notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// 评论点赞先乐观更新,再用服务端 `{liked,count}` 校准。
|
||||
Future<OperationResult<LikeToggleResult>> toggleLike(
|
||||
DramaComment comment,
|
||||
) async {
|
||||
if (_likingCommentIds.contains(comment.id)) {
|
||||
return const OperationResult.failure(null);
|
||||
}
|
||||
final current = _findComment(comment.id) ?? comment;
|
||||
_likingCommentIds.add(comment.id);
|
||||
_replaceComment(
|
||||
current.copyWith(
|
||||
liked: !current.liked,
|
||||
likeCount: current.liked
|
||||
? (current.likeCount > 0 ? current.likeCount - 1 : 0)
|
||||
: current.likeCount + 1,
|
||||
),
|
||||
);
|
||||
_notify();
|
||||
try {
|
||||
final result = await _interactionRepository.toggleLike(
|
||||
targetType: InteractionTargetType.comment,
|
||||
targetId: comment.id,
|
||||
);
|
||||
_replaceComment(
|
||||
current.copyWith(liked: result.liked, likeCount: result.count),
|
||||
);
|
||||
return OperationResult.success(result);
|
||||
} catch (failure) {
|
||||
_replaceComment(current);
|
||||
return OperationResult.failure(_errorMessage(failure));
|
||||
} finally {
|
||||
_likingCommentIds.remove(comment.id);
|
||||
_notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// 举报评论,失败消息原样返回给界面展示。
|
||||
Future<OperationResult<String>> report(
|
||||
DramaComment comment, {
|
||||
required ReportReason reason,
|
||||
String description = '',
|
||||
}) async {
|
||||
try {
|
||||
final reportId = await _interactionRepository.report(
|
||||
ReportRequest(
|
||||
targetType: InteractionTargetType.comment,
|
||||
targetId: comment.id,
|
||||
reasonCode: reason.apiValue,
|
||||
description: description.trim(),
|
||||
),
|
||||
);
|
||||
return OperationResult.success(reportId);
|
||||
} catch (failure) {
|
||||
return OperationResult.failure(_errorMessage(failure));
|
||||
}
|
||||
}
|
||||
|
||||
void _insertPublished(DramaComment comment, DramaComment? replyTo) {
|
||||
final rootId = comment.rootId != '0'
|
||||
? comment.rootId
|
||||
: replyTo?.isTopLevel == true
|
||||
? replyTo!.id
|
||||
: replyTo?.rootId;
|
||||
if (replyTo == null || rootId == null || rootId == '0') {
|
||||
if (!_comments.any((item) => item.id == comment.id)) {
|
||||
_comments.insert(0, comment);
|
||||
}
|
||||
return;
|
||||
}
|
||||
final replies = _replies.putIfAbsent(rootId, () => []);
|
||||
if (!replies.any((item) => item.id == comment.id)) {
|
||||
replies.insert(0, comment);
|
||||
}
|
||||
_expandedRoots.add(rootId);
|
||||
final rootIndex = _comments.indexWhere((item) => item.id == rootId);
|
||||
if (rootIndex >= 0) {
|
||||
final root = _comments[rootIndex];
|
||||
_comments[rootIndex] = root.copyWith(replyCount: root.replyCount + 1);
|
||||
}
|
||||
}
|
||||
|
||||
_CommentLocation? _removeComment(String id) {
|
||||
final topIndex = _comments.indexWhere((item) => item.id == id);
|
||||
if (topIndex >= 0) {
|
||||
final removed = _comments.removeAt(topIndex);
|
||||
return _CommentLocation(comment: removed, topIndex: topIndex);
|
||||
}
|
||||
for (final entry in _replies.entries) {
|
||||
final index = entry.value.indexWhere((item) => item.id == id);
|
||||
if (index < 0) continue;
|
||||
final removed = entry.value.removeAt(index);
|
||||
return _CommentLocation(
|
||||
comment: removed,
|
||||
rootId: entry.key,
|
||||
replyIndex: index,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _restoreComment(DramaComment comment, _CommentLocation? location) {
|
||||
if (location == null) return;
|
||||
if (location.topIndex != null) {
|
||||
final index = location.topIndex!.clamp(0, _comments.length);
|
||||
_comments.insert(index, comment);
|
||||
return;
|
||||
}
|
||||
final replies = _replies.putIfAbsent(location.rootId!, () => []);
|
||||
final index = location.replyIndex!.clamp(0, replies.length);
|
||||
replies.insert(index, comment);
|
||||
}
|
||||
|
||||
DramaComment? _findComment(String id) {
|
||||
for (final comment in _comments) {
|
||||
if (comment.id == id) return comment;
|
||||
}
|
||||
for (final replies in _replies.values) {
|
||||
for (final comment in replies) {
|
||||
if (comment.id == id) return comment;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _replaceComment(DramaComment replacement) {
|
||||
final topIndex = _comments.indexWhere((item) => item.id == replacement.id);
|
||||
if (topIndex >= 0) {
|
||||
_comments[topIndex] = replacement;
|
||||
return;
|
||||
}
|
||||
for (final replies in _replies.values) {
|
||||
final index = replies.indexWhere((item) => item.id == replacement.id);
|
||||
if (index >= 0) {
|
||||
replies[index] = replacement;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String? _errorMessage(Object failure) =>
|
||||
failure is ApiException ? failure.message : null;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _CommentLocation {
|
||||
const _CommentLocation({
|
||||
required this.comment,
|
||||
this.topIndex,
|
||||
this.rootId,
|
||||
this.replyIndex,
|
||||
});
|
||||
|
||||
final DramaComment comment;
|
||||
final int? topIndex;
|
||||
final String? rootId;
|
||||
final int? replyIndex;
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/engagement_snapshot.dart';
|
||||
import '../repositories/engagement_repository.dart';
|
||||
|
||||
/// Local demo interactions, indexed by drama ID. No public/social backend.
|
||||
class EngagementController extends ChangeNotifier {
|
||||
EngagementController(this._repository);
|
||||
|
||||
final EngagementRepository _repository;
|
||||
final Set<String> _saved = {};
|
||||
final Set<String> _liked = {};
|
||||
final Map<String, List<String>> _comments = {};
|
||||
Future<void> _loadFuture = Future<void>.value();
|
||||
Future<void> _pendingSave = Future<void>.value();
|
||||
|
||||
bool isSaved(String dramaId) => _saved.contains(dramaId);
|
||||
bool isLiked(String dramaId) => _liked.contains(dramaId);
|
||||
List<String> commentsFor(String dramaId) =>
|
||||
List.unmodifiable(_comments[dramaId] ?? const []);
|
||||
|
||||
Future<void> load() => _loadFuture = _loadFromRepository();
|
||||
|
||||
Future<void> _loadFromRepository() async {
|
||||
try {
|
||||
final snapshot = await _repository.load();
|
||||
_saved.addAll(snapshot.savedDramaIds);
|
||||
_liked.addAll(snapshot.likedDramaIds);
|
||||
for (final entry in snapshot.commentsByDrama.entries) {
|
||||
_comments[entry.key] = List.of(entry.value);
|
||||
}
|
||||
notifyListeners();
|
||||
} catch (_) {
|
||||
// Local preferences are best effort for this demo.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> toggleSaved(String dramaId) async {
|
||||
await _loadFuture;
|
||||
if (!_saved.add(dramaId)) _saved.remove(dramaId);
|
||||
notifyListeners();
|
||||
await _persist();
|
||||
}
|
||||
|
||||
Future<void> toggleLiked(String dramaId) async {
|
||||
await _loadFuture;
|
||||
if (!_liked.add(dramaId)) _liked.remove(dramaId);
|
||||
notifyListeners();
|
||||
await _persist();
|
||||
}
|
||||
|
||||
Future<void> addComment(String dramaId, String text) async {
|
||||
await _loadFuture;
|
||||
final clean = text.trim();
|
||||
if (clean.isEmpty) return;
|
||||
(_comments[dramaId] ??= []).add(
|
||||
clean.length > 300 ? clean.substring(0, 300) : clean,
|
||||
);
|
||||
notifyListeners();
|
||||
await _persist();
|
||||
}
|
||||
|
||||
Future<void> _persist() {
|
||||
final snapshot = EngagementSnapshot(
|
||||
savedDramaIds: Set.of(_saved),
|
||||
likedDramaIds: Set.of(_liked),
|
||||
commentsByDrama: {
|
||||
for (final entry in _comments.entries) entry.key: List.of(entry.value),
|
||||
},
|
||||
);
|
||||
_pendingSave = _pendingSave
|
||||
.then((_) => _repository.save(snapshot))
|
||||
.catchError((Object _) {
|
||||
// Keep the current in-memory state if local persistence fails.
|
||||
});
|
||||
return _pendingSave;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/interaction.dart';
|
||||
import '../remote/api_client.dart';
|
||||
import '../repositories/interaction_repository.dart';
|
||||
|
||||
/// 剧集点赞与举报的会话状态控制器。
|
||||
///
|
||||
/// 剧集契约暂未下发初始点赞状态与计数,本批只维护当前会话内的 liked 状态,
|
||||
/// 不展示数字,也不伪造初始值。
|
||||
class InteractionController extends ChangeNotifier {
|
||||
InteractionController(this._repository);
|
||||
|
||||
final InteractionRepository _repository;
|
||||
final Set<String> _likedDramaIds = {};
|
||||
final Set<String> _busyDramaIds = {};
|
||||
bool _disposed = false;
|
||||
|
||||
/// 当前会话内该剧是否已点赞。
|
||||
bool isLiked(String dramaId) => _likedDramaIds.contains(dramaId);
|
||||
|
||||
/// 点赞请求是否进行中,用于防连点。
|
||||
bool isBusy(String dramaId) => _busyDramaIds.contains(dramaId);
|
||||
|
||||
/// 乐观切换剧集点赞,失败时回滚并以服务端返回状态校准。
|
||||
Future<OperationResult<LikeToggleResult>> toggleDramaLike(
|
||||
String dramaId,
|
||||
) async {
|
||||
if (_busyDramaIds.contains(dramaId)) {
|
||||
return const OperationResult.failure(null);
|
||||
}
|
||||
final wasLiked = _likedDramaIds.contains(dramaId);
|
||||
_busyDramaIds.add(dramaId);
|
||||
if (wasLiked) {
|
||||
_likedDramaIds.remove(dramaId);
|
||||
} else {
|
||||
_likedDramaIds.add(dramaId);
|
||||
}
|
||||
_notify();
|
||||
try {
|
||||
final result = await _repository.toggleLike(
|
||||
targetType: InteractionTargetType.drama,
|
||||
targetId: dramaId,
|
||||
);
|
||||
if (result.liked) {
|
||||
_likedDramaIds.add(dramaId);
|
||||
} else {
|
||||
_likedDramaIds.remove(dramaId);
|
||||
}
|
||||
return OperationResult.success(result);
|
||||
} catch (failure) {
|
||||
if (wasLiked) {
|
||||
_likedDramaIds.add(dramaId);
|
||||
} else {
|
||||
_likedDramaIds.remove(dramaId);
|
||||
}
|
||||
return OperationResult.failure(_errorMessage(failure));
|
||||
} finally {
|
||||
_busyDramaIds.remove(dramaId);
|
||||
_notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// 举报剧集,失败消息原样返回给界面。
|
||||
Future<OperationResult<String>> reportDrama(
|
||||
String dramaId, {
|
||||
required ReportReason reason,
|
||||
String description = '',
|
||||
}) async {
|
||||
try {
|
||||
final reportId = await _repository.report(
|
||||
ReportRequest(
|
||||
targetType: InteractionTargetType.drama,
|
||||
targetId: dramaId,
|
||||
reasonCode: reason.apiValue,
|
||||
description: description.trim(),
|
||||
),
|
||||
);
|
||||
return OperationResult.success(reportId);
|
||||
} catch (failure) {
|
||||
return OperationResult.failure(_errorMessage(failure));
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录账号变化时清理会话内状态,避免跨账号串联。
|
||||
void clear() {
|
||||
if (_likedDramaIds.isEmpty && _busyDramaIds.isEmpty) return;
|
||||
_likedDramaIds.clear();
|
||||
_busyDramaIds.clear();
|
||||
_notify();
|
||||
}
|
||||
|
||||
String? _errorMessage(Object failure) =>
|
||||
failure is ApiException ? failure.message : null;
|
||||
|
||||
void _notify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ String errorText(AppLocalizations l10n, String? code, String fallback) {
|
||||
'progressError' => l10n.progressError,
|
||||
'favoritesError' => l10n.favoritesError,
|
||||
'ticketError' => l10n.playbackError,
|
||||
'commentsLoadFailed' => l10n.commentsLoadFailed,
|
||||
final value => value,
|
||||
};
|
||||
}
|
||||
+41
-1
@@ -65,10 +65,50 @@
|
||||
"save": "Save",
|
||||
"comments": "Comments",
|
||||
"share": "Share",
|
||||
"localOnlyComments": "Only visible on this device",
|
||||
"noComments": "No comments yet.",
|
||||
"writeComment": "Write a comment",
|
||||
"postComment": "Post",
|
||||
"loadMore": "Load more",
|
||||
"commentsLoadFailed": "Comments could not be loaded.",
|
||||
"commentPostFailed": "Could not post your comment.",
|
||||
"delete": "Delete",
|
||||
"deleteComment": "Delete comment",
|
||||
"deleteCommentTitle": "Delete this comment?",
|
||||
"deleteCommentMessage": "This removes your comment from the conversation.",
|
||||
"commentDeleteFailed": "Could not delete this comment.",
|
||||
"underReview": "Under review",
|
||||
"reply": "Reply",
|
||||
"replyingTo": "Replying to {name}",
|
||||
"@replyingTo": {
|
||||
"placeholders": {
|
||||
"name": {}
|
||||
}
|
||||
},
|
||||
"cancelReply": "Cancel reply",
|
||||
"viewReplies": "View replies ({count})",
|
||||
"@viewReplies": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hideReplies": "Hide replies",
|
||||
"anonymousUser": "Viewer",
|
||||
"more": "More",
|
||||
"likeFailed": "Could not update like. Please retry.",
|
||||
"reportComment": "Report comment",
|
||||
"reportDrama": "Report drama",
|
||||
"reportReason": "Reason",
|
||||
"reportReasonSpam": "Spam",
|
||||
"reportReasonAbuse": "Harassment",
|
||||
"reportReasonPorn": "Sexual content",
|
||||
"reportReasonAd": "Advertising",
|
||||
"reportReasonOther": "Other",
|
||||
"reportDescriptionHint": "Describe the issue (optional)",
|
||||
"reportSubmit": "Submit report",
|
||||
"reportSubmitted": "Report submitted. Thank you.",
|
||||
"reportFailed": "Could not submit report.",
|
||||
"shareFailed": "Could not open the share sheet.",
|
||||
"nowPlaying": "Playing now",
|
||||
"watchFullscreen": "Watch in landscape",
|
||||
|
||||
+41
-1
@@ -65,10 +65,50 @@
|
||||
"save": "Simpan",
|
||||
"comments": "Komentar",
|
||||
"share": "Bagikan",
|
||||
"localOnlyComments": "Hanya terlihat di perangkat ini",
|
||||
"noComments": "Belum ada komentar.",
|
||||
"writeComment": "Tulis komentar",
|
||||
"postComment": "Kirim",
|
||||
"loadMore": "Muat lebih banyak",
|
||||
"commentsLoadFailed": "Komentar tidak dapat dimuat.",
|
||||
"commentPostFailed": "Komentar tidak dapat dikirim.",
|
||||
"delete": "Hapus",
|
||||
"deleteComment": "Hapus komentar",
|
||||
"deleteCommentTitle": "Hapus komentar ini?",
|
||||
"deleteCommentMessage": "Komentar ini akan dihapus dari percakapan.",
|
||||
"commentDeleteFailed": "Komentar tidak dapat dihapus.",
|
||||
"underReview": "Sedang ditinjau",
|
||||
"reply": "Balas",
|
||||
"replyingTo": "Membalas {name}",
|
||||
"@replyingTo": {
|
||||
"placeholders": {
|
||||
"name": {}
|
||||
}
|
||||
},
|
||||
"cancelReply": "Batal membalas",
|
||||
"viewReplies": "Lihat balasan ({count})",
|
||||
"@viewReplies": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hideReplies": "Sembunyikan balasan",
|
||||
"anonymousUser": "Penonton",
|
||||
"more": "Lainnya",
|
||||
"likeFailed": "Status suka tidak dapat diperbarui.",
|
||||
"reportComment": "Laporkan komentar",
|
||||
"reportDrama": "Laporkan drama",
|
||||
"reportReason": "Alasan",
|
||||
"reportReasonSpam": "Spam",
|
||||
"reportReasonAbuse": "Pelecehan",
|
||||
"reportReasonPorn": "Konten seksual",
|
||||
"reportReasonAd": "Iklan",
|
||||
"reportReasonOther": "Lainnya",
|
||||
"reportDescriptionHint": "Jelaskan masalah (opsional)",
|
||||
"reportSubmit": "Kirim laporan",
|
||||
"reportSubmitted": "Laporan terkirim. Terima kasih.",
|
||||
"reportFailed": "Laporan tidak dapat dikirim.",
|
||||
"shareFailed": "Tidak bisa membuka menu berbagi.",
|
||||
"nowPlaying": "Sedang diputar",
|
||||
"watchFullscreen": "Tonton mode lanskap",
|
||||
|
||||
@@ -448,12 +448,6 @@ abstract class AppLocalizations {
|
||||
/// **'Share'**
|
||||
String get share;
|
||||
|
||||
/// No description provided for @localOnlyComments.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Only visible on this device'**
|
||||
String get localOnlyComments;
|
||||
|
||||
/// No description provided for @noComments.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -472,6 +466,180 @@ abstract class AppLocalizations {
|
||||
/// **'Post'**
|
||||
String get postComment;
|
||||
|
||||
/// No description provided for @loadMore.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Load more'**
|
||||
String get loadMore;
|
||||
|
||||
/// No description provided for @commentsLoadFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Comments could not be loaded.'**
|
||||
String get commentsLoadFailed;
|
||||
|
||||
/// No description provided for @commentPostFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Could not post your comment.'**
|
||||
String get commentPostFailed;
|
||||
|
||||
/// No description provided for @delete.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete'**
|
||||
String get delete;
|
||||
|
||||
/// No description provided for @deleteComment.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete comment'**
|
||||
String get deleteComment;
|
||||
|
||||
/// No description provided for @deleteCommentTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete this comment?'**
|
||||
String get deleteCommentTitle;
|
||||
|
||||
/// No description provided for @deleteCommentMessage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This removes your comment from the conversation.'**
|
||||
String get deleteCommentMessage;
|
||||
|
||||
/// No description provided for @commentDeleteFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Could not delete this comment.'**
|
||||
String get commentDeleteFailed;
|
||||
|
||||
/// No description provided for @underReview.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Under review'**
|
||||
String get underReview;
|
||||
|
||||
/// No description provided for @reply.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reply'**
|
||||
String get reply;
|
||||
|
||||
/// No description provided for @replyingTo.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Replying to {name}'**
|
||||
String replyingTo(Object name);
|
||||
|
||||
/// No description provided for @cancelReply.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cancel reply'**
|
||||
String get cancelReply;
|
||||
|
||||
/// No description provided for @viewReplies.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'View replies ({count})'**
|
||||
String viewReplies(int count);
|
||||
|
||||
/// No description provided for @hideReplies.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hide replies'**
|
||||
String get hideReplies;
|
||||
|
||||
/// No description provided for @anonymousUser.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Viewer'**
|
||||
String get anonymousUser;
|
||||
|
||||
/// No description provided for @more.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'More'**
|
||||
String get more;
|
||||
|
||||
/// No description provided for @likeFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Could not update like. Please retry.'**
|
||||
String get likeFailed;
|
||||
|
||||
/// No description provided for @reportComment.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Report comment'**
|
||||
String get reportComment;
|
||||
|
||||
/// No description provided for @reportDrama.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Report drama'**
|
||||
String get reportDrama;
|
||||
|
||||
/// No description provided for @reportReason.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reason'**
|
||||
String get reportReason;
|
||||
|
||||
/// No description provided for @reportReasonSpam.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Spam'**
|
||||
String get reportReasonSpam;
|
||||
|
||||
/// No description provided for @reportReasonAbuse.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Harassment'**
|
||||
String get reportReasonAbuse;
|
||||
|
||||
/// No description provided for @reportReasonPorn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sexual content'**
|
||||
String get reportReasonPorn;
|
||||
|
||||
/// No description provided for @reportReasonAd.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Advertising'**
|
||||
String get reportReasonAd;
|
||||
|
||||
/// No description provided for @reportReasonOther.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Other'**
|
||||
String get reportReasonOther;
|
||||
|
||||
/// No description provided for @reportDescriptionHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Describe the issue (optional)'**
|
||||
String get reportDescriptionHint;
|
||||
|
||||
/// No description provided for @reportSubmit.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Submit report'**
|
||||
String get reportSubmit;
|
||||
|
||||
/// No description provided for @reportSubmitted.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Report submitted. Thank you.'**
|
||||
String get reportSubmitted;
|
||||
|
||||
/// No description provided for @reportFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Could not submit report.'**
|
||||
String get reportFailed;
|
||||
|
||||
/// No description provided for @shareFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -187,9 +187,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get share => 'Share';
|
||||
|
||||
@override
|
||||
String get localOnlyComments => 'Only visible on this device';
|
||||
|
||||
@override
|
||||
String get noComments => 'No comments yet.';
|
||||
|
||||
@@ -199,6 +196,98 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get postComment => 'Post';
|
||||
|
||||
@override
|
||||
String get loadMore => 'Load more';
|
||||
|
||||
@override
|
||||
String get commentsLoadFailed => 'Comments could not be loaded.';
|
||||
|
||||
@override
|
||||
String get commentPostFailed => 'Could not post your comment.';
|
||||
|
||||
@override
|
||||
String get delete => 'Delete';
|
||||
|
||||
@override
|
||||
String get deleteComment => 'Delete comment';
|
||||
|
||||
@override
|
||||
String get deleteCommentTitle => 'Delete this comment?';
|
||||
|
||||
@override
|
||||
String get deleteCommentMessage =>
|
||||
'This removes your comment from the conversation.';
|
||||
|
||||
@override
|
||||
String get commentDeleteFailed => 'Could not delete this comment.';
|
||||
|
||||
@override
|
||||
String get underReview => 'Under review';
|
||||
|
||||
@override
|
||||
String get reply => 'Reply';
|
||||
|
||||
@override
|
||||
String replyingTo(Object name) {
|
||||
return 'Replying to $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cancelReply => 'Cancel reply';
|
||||
|
||||
@override
|
||||
String viewReplies(int count) {
|
||||
return 'View replies ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get hideReplies => 'Hide replies';
|
||||
|
||||
@override
|
||||
String get anonymousUser => 'Viewer';
|
||||
|
||||
@override
|
||||
String get more => 'More';
|
||||
|
||||
@override
|
||||
String get likeFailed => 'Could not update like. Please retry.';
|
||||
|
||||
@override
|
||||
String get reportComment => 'Report comment';
|
||||
|
||||
@override
|
||||
String get reportDrama => 'Report drama';
|
||||
|
||||
@override
|
||||
String get reportReason => 'Reason';
|
||||
|
||||
@override
|
||||
String get reportReasonSpam => 'Spam';
|
||||
|
||||
@override
|
||||
String get reportReasonAbuse => 'Harassment';
|
||||
|
||||
@override
|
||||
String get reportReasonPorn => 'Sexual content';
|
||||
|
||||
@override
|
||||
String get reportReasonAd => 'Advertising';
|
||||
|
||||
@override
|
||||
String get reportReasonOther => 'Other';
|
||||
|
||||
@override
|
||||
String get reportDescriptionHint => 'Describe the issue (optional)';
|
||||
|
||||
@override
|
||||
String get reportSubmit => 'Submit report';
|
||||
|
||||
@override
|
||||
String get reportSubmitted => 'Report submitted. Thank you.';
|
||||
|
||||
@override
|
||||
String get reportFailed => 'Could not submit report.';
|
||||
|
||||
@override
|
||||
String get shareFailed => 'Could not open the share sheet.';
|
||||
|
||||
|
||||
@@ -187,9 +187,6 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get share => 'Bagikan';
|
||||
|
||||
@override
|
||||
String get localOnlyComments => 'Hanya terlihat di perangkat ini';
|
||||
|
||||
@override
|
||||
String get noComments => 'Belum ada komentar.';
|
||||
|
||||
@@ -199,6 +196,98 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get postComment => 'Kirim';
|
||||
|
||||
@override
|
||||
String get loadMore => 'Muat lebih banyak';
|
||||
|
||||
@override
|
||||
String get commentsLoadFailed => 'Komentar tidak dapat dimuat.';
|
||||
|
||||
@override
|
||||
String get commentPostFailed => 'Komentar tidak dapat dikirim.';
|
||||
|
||||
@override
|
||||
String get delete => 'Hapus';
|
||||
|
||||
@override
|
||||
String get deleteComment => 'Hapus komentar';
|
||||
|
||||
@override
|
||||
String get deleteCommentTitle => 'Hapus komentar ini?';
|
||||
|
||||
@override
|
||||
String get deleteCommentMessage =>
|
||||
'Komentar ini akan dihapus dari percakapan.';
|
||||
|
||||
@override
|
||||
String get commentDeleteFailed => 'Komentar tidak dapat dihapus.';
|
||||
|
||||
@override
|
||||
String get underReview => 'Sedang ditinjau';
|
||||
|
||||
@override
|
||||
String get reply => 'Balas';
|
||||
|
||||
@override
|
||||
String replyingTo(Object name) {
|
||||
return 'Membalas $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cancelReply => 'Batal membalas';
|
||||
|
||||
@override
|
||||
String viewReplies(int count) {
|
||||
return 'Lihat balasan ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get hideReplies => 'Sembunyikan balasan';
|
||||
|
||||
@override
|
||||
String get anonymousUser => 'Penonton';
|
||||
|
||||
@override
|
||||
String get more => 'Lainnya';
|
||||
|
||||
@override
|
||||
String get likeFailed => 'Status suka tidak dapat diperbarui.';
|
||||
|
||||
@override
|
||||
String get reportComment => 'Laporkan komentar';
|
||||
|
||||
@override
|
||||
String get reportDrama => 'Laporkan drama';
|
||||
|
||||
@override
|
||||
String get reportReason => 'Alasan';
|
||||
|
||||
@override
|
||||
String get reportReasonSpam => 'Spam';
|
||||
|
||||
@override
|
||||
String get reportReasonAbuse => 'Pelecehan';
|
||||
|
||||
@override
|
||||
String get reportReasonPorn => 'Konten seksual';
|
||||
|
||||
@override
|
||||
String get reportReasonAd => 'Iklan';
|
||||
|
||||
@override
|
||||
String get reportReasonOther => 'Lainnya';
|
||||
|
||||
@override
|
||||
String get reportDescriptionHint => 'Jelaskan masalah (opsional)';
|
||||
|
||||
@override
|
||||
String get reportSubmit => 'Kirim laporan';
|
||||
|
||||
@override
|
||||
String get reportSubmitted => 'Laporan terkirim. Terima kasih.';
|
||||
|
||||
@override
|
||||
String get reportFailed => 'Laporan tidak dapat dikirim.';
|
||||
|
||||
@override
|
||||
String get shareFailed => 'Tidak bisa membuka menu berbagi.';
|
||||
|
||||
|
||||
@@ -184,9 +184,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get share => '分享';
|
||||
|
||||
@override
|
||||
String get localOnlyComments => '仅在本机可见';
|
||||
|
||||
@override
|
||||
String get noComments => '还没有评论。';
|
||||
|
||||
@@ -196,6 +193,97 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get postComment => '发布';
|
||||
|
||||
@override
|
||||
String get loadMore => '加载更多';
|
||||
|
||||
@override
|
||||
String get commentsLoadFailed => '评论加载失败,请重试。';
|
||||
|
||||
@override
|
||||
String get commentPostFailed => '评论发布失败,请重试。';
|
||||
|
||||
@override
|
||||
String get delete => '删除';
|
||||
|
||||
@override
|
||||
String get deleteComment => '删除评论';
|
||||
|
||||
@override
|
||||
String get deleteCommentTitle => '删除这条评论?';
|
||||
|
||||
@override
|
||||
String get deleteCommentMessage => '删除后,这条评论将从评论列表中移除。';
|
||||
|
||||
@override
|
||||
String get commentDeleteFailed => '评论删除失败,请重试。';
|
||||
|
||||
@override
|
||||
String get underReview => '审核中';
|
||||
|
||||
@override
|
||||
String get reply => '回复';
|
||||
|
||||
@override
|
||||
String replyingTo(Object name) {
|
||||
return '回复 $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cancelReply => '取消回复';
|
||||
|
||||
@override
|
||||
String viewReplies(int count) {
|
||||
return '查看回复($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get hideReplies => '收起回复';
|
||||
|
||||
@override
|
||||
String get anonymousUser => '观众';
|
||||
|
||||
@override
|
||||
String get more => '更多';
|
||||
|
||||
@override
|
||||
String get likeFailed => '点赞状态更新失败,请重试。';
|
||||
|
||||
@override
|
||||
String get reportComment => '举报评论';
|
||||
|
||||
@override
|
||||
String get reportDrama => '举报短剧';
|
||||
|
||||
@override
|
||||
String get reportReason => '举报原因';
|
||||
|
||||
@override
|
||||
String get reportReasonSpam => '垃圾广告';
|
||||
|
||||
@override
|
||||
String get reportReasonAbuse => '辱骂骚扰';
|
||||
|
||||
@override
|
||||
String get reportReasonPorn => '色情低俗';
|
||||
|
||||
@override
|
||||
String get reportReasonAd => '引流广告';
|
||||
|
||||
@override
|
||||
String get reportReasonOther => '其他';
|
||||
|
||||
@override
|
||||
String get reportDescriptionHint => '补充说明(选填)';
|
||||
|
||||
@override
|
||||
String get reportSubmit => '提交举报';
|
||||
|
||||
@override
|
||||
String get reportSubmitted => '举报已提交,感谢反馈。';
|
||||
|
||||
@override
|
||||
String get reportFailed => '举报提交失败,请重试。';
|
||||
|
||||
@override
|
||||
String get shareFailed => '无法打开系统分享面板。';
|
||||
|
||||
|
||||
+41
-1
@@ -65,10 +65,50 @@
|
||||
"save": "收藏",
|
||||
"comments": "评论",
|
||||
"share": "分享",
|
||||
"localOnlyComments": "仅在本机可见",
|
||||
"noComments": "还没有评论。",
|
||||
"writeComment": "写评论",
|
||||
"postComment": "发布",
|
||||
"loadMore": "加载更多",
|
||||
"commentsLoadFailed": "评论加载失败,请重试。",
|
||||
"commentPostFailed": "评论发布失败,请重试。",
|
||||
"delete": "删除",
|
||||
"deleteComment": "删除评论",
|
||||
"deleteCommentTitle": "删除这条评论?",
|
||||
"deleteCommentMessage": "删除后,这条评论将从评论列表中移除。",
|
||||
"commentDeleteFailed": "评论删除失败,请重试。",
|
||||
"underReview": "审核中",
|
||||
"reply": "回复",
|
||||
"replyingTo": "回复 {name}",
|
||||
"@replyingTo": {
|
||||
"placeholders": {
|
||||
"name": {}
|
||||
}
|
||||
},
|
||||
"cancelReply": "取消回复",
|
||||
"viewReplies": "查看回复({count})",
|
||||
"@viewReplies": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hideReplies": "收起回复",
|
||||
"anonymousUser": "观众",
|
||||
"more": "更多",
|
||||
"likeFailed": "点赞状态更新失败,请重试。",
|
||||
"reportComment": "举报评论",
|
||||
"reportDrama": "举报短剧",
|
||||
"reportReason": "举报原因",
|
||||
"reportReasonSpam": "垃圾广告",
|
||||
"reportReasonAbuse": "辱骂骚扰",
|
||||
"reportReasonPorn": "色情低俗",
|
||||
"reportReasonAd": "引流广告",
|
||||
"reportReasonOther": "其他",
|
||||
"reportDescriptionHint": "补充说明(选填)",
|
||||
"reportSubmit": "提交举报",
|
||||
"reportSubmitted": "举报已提交,感谢反馈。",
|
||||
"reportFailed": "举报提交失败,请重试。",
|
||||
"shareFailed": "无法打开系统分享面板。",
|
||||
"nowPlaying": "正在播放",
|
||||
"watchFullscreen": "横屏全屏观看",
|
||||
|
||||
@@ -8,17 +8,21 @@ import 'package:provider/provider.dart';
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
import '../../core/data/services/favorite_controller.dart';
|
||||
import '../../core/data/services/interaction_controller.dart';
|
||||
import '../../core/formatting/error_text.dart';
|
||||
import '../../core/formatting/localized_content.dart';
|
||||
import '../../core/widgets/remote_image.dart';
|
||||
import '../../core/widgets/auth_gate.dart';
|
||||
import '../../core/widgets/download_action.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../interaction/widgets/report_dialog.dart';
|
||||
import 'drama_detail_binding.dart';
|
||||
import 'drama_detail_logic.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/repositories/playback_repository.dart';
|
||||
|
||||
const String _reportMenuValue = 'report';
|
||||
|
||||
class DramaDetailPage extends StatefulWidget {
|
||||
const DramaDetailPage({super.key, required this.drama});
|
||||
final Drama drama;
|
||||
@@ -138,6 +142,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
backgroundColor: const Color(0xFF111111),
|
||||
foregroundColor: Colors.white,
|
||||
actions: [
|
||||
_reportAction(context, l10n),
|
||||
_downloadAction(context, l10n),
|
||||
_favoriteAction(context, l10n),
|
||||
],
|
||||
@@ -291,6 +296,42 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 详情页更多菜单:本批只提供举报短剧。
|
||||
Widget _reportAction(BuildContext context, AppLocalizations l10n) {
|
||||
return PopupMenuButton<String>(
|
||||
key: const Key('detail-more'),
|
||||
tooltip: l10n.more,
|
||||
onSelected: (_) => unawaited(_reportDrama(l10n)),
|
||||
itemBuilder: (_) => [
|
||||
PopupMenuItem(
|
||||
value: _reportMenuValue,
|
||||
child: Text(l10n.reportDrama),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _reportDrama(AppLocalizations l10n) async {
|
||||
if (!await ensureSignedIn(context) || !mounted) return;
|
||||
final draft = await showReportDialog(context, title: l10n.reportDrama);
|
||||
if (draft == null || !mounted) return;
|
||||
final result = await context.read<InteractionController>().reportDrama(
|
||||
logic.drama.id,
|
||||
reason: draft.reason,
|
||||
description: draft.description,
|
||||
);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
result.isSuccess
|
||||
? l10n.reportSubmitted
|
||||
: result.errorMessage ?? l10n.reportFailed,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 收藏按钮:状态取自服务端(乐观更新,失败回滚并提示)。
|
||||
Widget _favoriteAction(BuildContext context, AppLocalizations l10n) {
|
||||
final favorites = context.watch<FavoriteController>();
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/data/models/interaction.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
const int _maxDescriptionLength = 300;
|
||||
|
||||
/// 举报弹窗收集的结果。
|
||||
class ReportDialogResult {
|
||||
const ReportDialogResult({
|
||||
required this.reason,
|
||||
required this.description,
|
||||
});
|
||||
|
||||
final ReportReason reason;
|
||||
final String description;
|
||||
}
|
||||
|
||||
/// 打开举报原因单选弹窗;取消时返回 null。
|
||||
Future<ReportDialogResult?> showReportDialog(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
}) => showDialog<ReportDialogResult>(
|
||||
context: context,
|
||||
builder: (_) => _ReportDialog(title: title),
|
||||
);
|
||||
|
||||
class _ReportDialog extends StatefulWidget {
|
||||
const _ReportDialog({required this.title});
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
State<_ReportDialog> createState() => _ReportDialogState();
|
||||
}
|
||||
|
||||
class _ReportDialogState extends State<_ReportDialog> {
|
||||
final _description = TextEditingController();
|
||||
ReportReason _reason = ReportReason.spam;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_description.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
Navigator.of(context).pop(
|
||||
ReportDialogResult(
|
||||
reason: _reason,
|
||||
description: _description.text.trim(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _reasonLabel(AppLocalizations l10n, ReportReason reason) =>
|
||||
switch (reason) {
|
||||
ReportReason.spam => l10n.reportReasonSpam,
|
||||
ReportReason.abuse => l10n.reportReasonAbuse,
|
||||
ReportReason.porn => l10n.reportReasonPorn,
|
||||
ReportReason.ad => l10n.reportReasonAd,
|
||||
ReportReason.other => l10n.reportReasonOther,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return AlertDialog(
|
||||
title: Text(widget.title),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.reportReason),
|
||||
RadioGroup<ReportReason>(
|
||||
groupValue: _reason,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
setState(() => _reason = value);
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
for (final reason in ReportReason.values)
|
||||
RadioListTile<ReportReason>(
|
||||
key: Key('report-reason-${reason.apiValue}'),
|
||||
value: reason,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
title: Text(_reasonLabel(l10n, reason)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
key: const Key('report-description'),
|
||||
controller: _description,
|
||||
maxLength: _maxDescriptionLength,
|
||||
maxLines: 3,
|
||||
minLines: 2,
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.reportDescriptionHint,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
key: const Key('report-submit'),
|
||||
onPressed: _submit,
|
||||
child: Text(l10n.reportSubmit),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import '../../core/formatting/localized_content.dart';
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/services/auth_controller.dart';
|
||||
import '../../core/data/services/engagement_controller.dart';
|
||||
import '../../core/data/services/interaction_controller.dart';
|
||||
import '../../core/data/services/favorite_controller.dart';
|
||||
import '../../core/formatting/error_text.dart';
|
||||
import '../../core/widgets/remote_image.dart';
|
||||
@@ -474,14 +474,14 @@ class _MyPageState extends State<MyPage> {
|
||||
logic.filter == 0 ||
|
||||
(logic.filter == 1 && fraction < 0.99) ||
|
||||
(logic.filter == 2 && fraction >= 0.99);
|
||||
final engagement = context.watch<EngagementController>();
|
||||
// 收藏以服务端为准(收藏列表接口返回卡片结构);点赞仍是本地状态(K3 接互动)。
|
||||
final interactions = context.watch<InteractionController>();
|
||||
// 收藏以服务端为准;剧集点赞暂按当前会话状态过滤 feed。
|
||||
final favorites = context.watch<FavoriteController>();
|
||||
final isWatchlist = logic.section == 1;
|
||||
final markedDramas = isWatchlist
|
||||
? favorites.cards
|
||||
: (widget.dramas ?? [widget.drama])
|
||||
.where((drama) => engagement.isLiked(drama.id))
|
||||
.where((drama) => interactions.isLiked(drama.id))
|
||||
.toList();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
@@ -9,8 +9,8 @@ import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/models/play_ticket.dart';
|
||||
import '../../core/data/services/engagement_controller.dart';
|
||||
import '../../core/data/services/favorite_controller.dart';
|
||||
import '../../core/data/services/interaction_controller.dart';
|
||||
import '../../core/data/services/playback_session.dart';
|
||||
import '../../core/data/services/video_player_port.dart';
|
||||
import '../../core/formatting/error_text.dart';
|
||||
@@ -108,7 +108,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
Widget _buildPlayer(BuildContext context) {
|
||||
final engagement = context.watch<EngagementController>();
|
||||
final interactions = context.watch<InteractionController>();
|
||||
final indices = logic.visibleIndices;
|
||||
final pages = logic.pages;
|
||||
final content = AnimatedBuilder(
|
||||
@@ -124,7 +124,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
unawaited(logic.selectEpisode(index));
|
||||
},
|
||||
itemBuilder: (context, position) =>
|
||||
_buildEpisode(context, indices[position], engagement),
|
||||
_buildEpisode(context, indices[position], interactions),
|
||||
),
|
||||
);
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
@@ -141,7 +141,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
Widget _buildEpisode(
|
||||
BuildContext context,
|
||||
int index,
|
||||
EngagementController engagement,
|
||||
InteractionController interactions,
|
||||
) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final entry = logic.queue.entryAt(index);
|
||||
@@ -336,7 +336,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
drama: drama,
|
||||
episode: episode,
|
||||
controller: ready ? videoPort!.controller : null,
|
||||
liked: engagement.isLiked(drama.id),
|
||||
liked: interactions.isLiked(drama.id),
|
||||
// 收藏以服务端为准(FavoriteController 由 app 注入,登录后自动加载)
|
||||
saved: context.watch<FavoriteController>().isSaved(drama.id),
|
||||
onBack: widget.embedded
|
||||
@@ -348,7 +348,17 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
// 收藏/点赞要对到具体账号:未登录先引导登录(首页/详情仍可匿名浏览)
|
||||
onLike: () async {
|
||||
if (await ensureSignedIn(context)) {
|
||||
unawaited(engagement.toggleLiked(drama.id));
|
||||
final result = await interactions.toggleDramaLike(drama.id);
|
||||
if (!result.isSuccess && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
result.errorMessage ??
|
||||
AppLocalizations.of(context)!.likeFailed,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
onSave: () async {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/data/models/comment.dart';
|
||||
import '../../../core/data/services/comment_controller.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
const String _deleteAction = 'delete';
|
||||
const String _reportAction = 'report';
|
||||
|
||||
/// 评论条目的交互回调集合。
|
||||
class CommentInteractionHandlers {
|
||||
const CommentInteractionHandlers({
|
||||
required this.onReply,
|
||||
required this.onLike,
|
||||
required this.onDelete,
|
||||
required this.onReport,
|
||||
});
|
||||
|
||||
final ValueChanged<DramaComment> onReply;
|
||||
final Future<void> Function(DramaComment) onLike;
|
||||
final Future<void> Function(DramaComment) onDelete;
|
||||
final Future<void> Function(DramaComment) onReport;
|
||||
}
|
||||
|
||||
/// 单条评论或回复的展示与操作行。
|
||||
class CommentTile extends StatelessWidget {
|
||||
const CommentTile({
|
||||
super.key,
|
||||
required this.comment,
|
||||
required this.controller,
|
||||
required this.handlers,
|
||||
this.isReply = false,
|
||||
});
|
||||
|
||||
final DramaComment comment;
|
||||
final CommentController controller;
|
||||
final CommentInteractionHandlers handlers;
|
||||
final bool isReply;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final name = comment.nickname.isEmpty ? l10n.anonymousUser : comment.nickname;
|
||||
return Padding(
|
||||
key: Key('comment-item-${comment.id}'),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: isReply ? 12 : 16,
|
||||
backgroundColor: AppColors.blue.withValues(alpha: 0.2),
|
||||
child: Icon(
|
||||
Icons.person_rounded,
|
||||
size: isReply ? 14 : 18,
|
||||
color: AppColors.blueSoft,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
name,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
key: Key('comment-menu-${comment.id}'),
|
||||
tooltip: l10n.more,
|
||||
onSelected: (value) {
|
||||
if (value == _deleteAction) {
|
||||
handlers.onDelete(comment);
|
||||
} else if (value == _reportAction) {
|
||||
handlers.onReport(comment);
|
||||
}
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
if (comment.mine)
|
||||
PopupMenuItem(
|
||||
value: _deleteAction,
|
||||
child: Text(l10n.deleteComment),
|
||||
)
|
||||
else
|
||||
PopupMenuItem(
|
||||
value: _reportAction,
|
||||
child: Text(l10n.reportComment),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
comment.content,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14),
|
||||
),
|
||||
if (comment.isPending)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
l10n.underReview,
|
||||
key: Key('comment-pending-${comment.id}'),
|
||||
style: const TextStyle(
|
||||
color: AppColors.blueSoft,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
TextButton.icon(
|
||||
key: Key('comment-like-${comment.id}'),
|
||||
onPressed: controller.likingComment(comment.id)
|
||||
? null
|
||||
: () => handlers.onLike(comment),
|
||||
icon: Icon(
|
||||
comment.liked
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded,
|
||||
size: 16,
|
||||
),
|
||||
label: Text('${comment.likeCount}'),
|
||||
),
|
||||
if (!isReply)
|
||||
TextButton(
|
||||
key: Key('comment-reply-${comment.id}'),
|
||||
onPressed: () => handlers.onReply(comment),
|
||||
child: Text(l10n.reply),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,27 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../core/data/models/comment.dart';
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/data/services/engagement_controller.dart';
|
||||
import '../../../core/data/repositories/comment_repository.dart';
|
||||
import '../../../core/data/repositories/interaction_repository.dart';
|
||||
import '../../../core/data/services/comment_controller.dart';
|
||||
import '../../../core/formatting/error_text.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/widgets/auth_gate.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../interaction/widgets/report_dialog.dart';
|
||||
import 'comment_tile.dart';
|
||||
|
||||
const double _minSheetHeight = 240;
|
||||
const double _maxSheetHeight = 460;
|
||||
const int _maxCommentLength = 300;
|
||||
|
||||
/// 服务端评论底部弹窗:匿名列表、游标翻页、回复、点赞和本人删除。
|
||||
class DramaCommentsSheet extends StatefulWidget {
|
||||
const DramaCommentsSheet({super.key, required this.drama});
|
||||
|
||||
@@ -18,26 +33,120 @@ class DramaCommentsSheet extends StatefulWidget {
|
||||
|
||||
class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
|
||||
final _input = TextEditingController();
|
||||
final _inputFocus = FocusNode();
|
||||
late final CommentController _controller;
|
||||
DramaComment? _replyTo;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = CommentController(
|
||||
comments: context.read<CommentRepository>(),
|
||||
interactions: context.read<InteractionRepository>(),
|
||||
dramaId: widget.drama.id,
|
||||
);
|
||||
unawaited(_controller.load());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_input.dispose();
|
||||
_inputFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _post() {
|
||||
final text = _input.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
context.read<EngagementController>().addComment(widget.drama.id, text);
|
||||
_input.clear();
|
||||
Future<void> _post() async {
|
||||
if (_controller.submitting) return;
|
||||
if (_input.text.trim().isEmpty) return;
|
||||
if (!await ensureSignedIn(context) || !mounted) return;
|
||||
final result = await _controller.publish(_input.text, replyTo: _replyTo);
|
||||
if (!mounted) return;
|
||||
if (result.isSuccess) {
|
||||
_input.clear();
|
||||
setState(() => _replyTo = null);
|
||||
return;
|
||||
}
|
||||
_showMessage(
|
||||
result.errorMessage ?? AppLocalizations.of(context)!.commentPostFailed,
|
||||
);
|
||||
}
|
||||
|
||||
void _beginReply(DramaComment comment) {
|
||||
setState(() => _replyTo = comment);
|
||||
_inputFocus.requestFocus();
|
||||
}
|
||||
|
||||
void _cancelReply() {
|
||||
if (_replyTo == null) return;
|
||||
setState(() => _replyTo = null);
|
||||
}
|
||||
|
||||
Future<void> _like(DramaComment comment) async {
|
||||
if (!await ensureSignedIn(context) || !mounted) return;
|
||||
final result = await _controller.toggleLike(comment);
|
||||
if (!result.isSuccess && mounted) {
|
||||
_showMessage(
|
||||
result.errorMessage ?? AppLocalizations.of(context)!.commentPostFailed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(DramaComment comment) async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(l10n.deleteCommentTitle),
|
||||
content: Text(l10n.deleteCommentMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
key: const Key('comment-delete-confirm'),
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: Text(l10n.delete),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
final result = await _controller.delete(comment);
|
||||
if (!result.isSuccess && mounted) {
|
||||
_showMessage(
|
||||
result.errorMessage ?? AppLocalizations.of(context)!.commentDeleteFailed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _report(DramaComment comment) async {
|
||||
if (!await ensureSignedIn(context) || !mounted) return;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final draft = await showReportDialog(context, title: l10n.reportComment);
|
||||
if (draft == null || !mounted) return;
|
||||
final result = await _controller.report(
|
||||
comment,
|
||||
reason: draft.reason,
|
||||
description: draft.description,
|
||||
);
|
||||
if (!mounted) return;
|
||||
_showMessage(
|
||||
result.isSuccess
|
||||
? l10n.reportSubmitted
|
||||
: result.errorMessage ?? l10n.reportFailed,
|
||||
);
|
||||
}
|
||||
|
||||
void _showMessage(String message) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final comments = context.watch<EngagementController>().commentsFor(
|
||||
widget.drama.id,
|
||||
);
|
||||
final available =
|
||||
MediaQuery.sizeOf(context).height -
|
||||
MediaQuery.viewInsetsOf(context).bottom -
|
||||
@@ -45,83 +154,244 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
|
||||
child: SizedBox(
|
||||
height: available.clamp(240.0, 460.0),
|
||||
height: available.clamp(_minSheetHeight, _maxSheetHeight),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.comments,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
l10n.localOnlyComments,
|
||||
style: const TextStyle(color: Color(0xFFE6E1E3), fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Expanded(
|
||||
child: comments.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
l10n.noComments,
|
||||
style: const TextStyle(color: Color(0xFFE6E1E3)),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
itemCount: comments.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) => ListTile(
|
||||
leading: const CircleAvatar(
|
||||
radius: 16,
|
||||
child: Icon(Icons.person_rounded, size: 18),
|
||||
),
|
||||
title: Text(comments[index]),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
key: const Key('comment-input'),
|
||||
controller: _input,
|
||||
maxLength: 300,
|
||||
maxLines: 1,
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.writeComment,
|
||||
counterText: '',
|
||||
filled: true,
|
||||
fillColor: const Color(0xFF35353A),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _post(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filled(
|
||||
key: const Key('comment-post'),
|
||||
tooltip: l10n.postComment,
|
||||
onPressed: _post,
|
||||
icon: const Icon(Icons.send_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
child: ListenableBuilder(
|
||||
listenable: _controller,
|
||||
builder: (context, _) => _content(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _content(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.comments,
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(child: _list(l10n)),
|
||||
if (_replyTo != null) _replyBanner(l10n, _replyTo!),
|
||||
_composer(l10n),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _list(AppLocalizations l10n) {
|
||||
if (_controller.loading && _controller.comments.isEmpty) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(key: Key('comment-list-loading')),
|
||||
);
|
||||
}
|
||||
if (_controller.error != null && _controller.comments.isEmpty) {
|
||||
return Center(
|
||||
key: const Key('comment-list-error'),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
errorText(l10n, _controller.error, l10n.commentsLoadFailed),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppColors.muted),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
key: const Key('comment-list-retry'),
|
||||
onPressed: _controller.load,
|
||||
child: Text(l10n.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_controller.comments.isEmpty) {
|
||||
return Center(
|
||||
key: const Key('comment-list-empty'),
|
||||
child: Text(
|
||||
l10n.noComments,
|
||||
style: const TextStyle(color: AppColors.muted),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
key: const Key('comment-list'),
|
||||
itemCount: _controller.comments.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == _controller.comments.length) return _footer(l10n);
|
||||
return _CommentThread(
|
||||
comment: _controller.comments[index],
|
||||
controller: _controller,
|
||||
handlers: CommentInteractionHandlers(
|
||||
onReply: _beginReply,
|
||||
onLike: _like,
|
||||
onDelete: _confirmDelete,
|
||||
onReport: _report,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _footer(AppLocalizations l10n) {
|
||||
if (_controller.loadingMore) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (_controller.hasMore) {
|
||||
return TextButton(
|
||||
key: const Key('comment-list-load-more'),
|
||||
onPressed: _controller.loadMore,
|
||||
child: Text(l10n.loadMore),
|
||||
);
|
||||
}
|
||||
return const SizedBox(height: 8);
|
||||
}
|
||||
|
||||
Widget _replyBanner(AppLocalizations l10n, DramaComment replyTo) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.replyingTo(
|
||||
replyTo.nickname.isEmpty ? l10n.anonymousUser : replyTo.nickname,
|
||||
),
|
||||
style: const TextStyle(color: AppColors.blueSoft, fontSize: 12),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
key: const Key('comment-cancel-reply'),
|
||||
tooltip: l10n.cancelReply,
|
||||
onPressed: _cancelReply,
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.close_rounded, size: 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _composer(AppLocalizations l10n) => Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
key: const Key('comment-input'),
|
||||
controller: _input,
|
||||
focusNode: _inputFocus,
|
||||
maxLength: _maxCommentLength,
|
||||
maxLines: 1,
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.writeComment,
|
||||
counterText: '',
|
||||
filled: true,
|
||||
fillColor: const Color(0xFF35353A),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _post(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filled(
|
||||
key: const Key('comment-post'),
|
||||
tooltip: l10n.postComment,
|
||||
onPressed: _controller.submitting ? null : _post,
|
||||
icon: _controller.submitting
|
||||
? const SizedBox.square(
|
||||
dimension: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.send_rounded),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _CommentThread extends StatelessWidget {
|
||||
const _CommentThread({
|
||||
required this.comment,
|
||||
required this.controller,
|
||||
required this.handlers,
|
||||
});
|
||||
|
||||
final DramaComment comment;
|
||||
final CommentController controller;
|
||||
final CommentInteractionHandlers handlers;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final replies = controller.repliesFor(comment.id);
|
||||
final expanded = controller.repliesExpanded(comment.id);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CommentTile(
|
||||
comment: comment,
|
||||
controller: controller,
|
||||
handlers: handlers,
|
||||
),
|
||||
if (comment.replyCount > 0 || replies.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 44, bottom: 8),
|
||||
child: TextButton(
|
||||
key: Key('comment-replies-${comment.id}'),
|
||||
onPressed: () => _toggleReplies(context),
|
||||
child: Text(
|
||||
expanded
|
||||
? AppLocalizations.of(context)!.hideReplies
|
||||
: AppLocalizations.of(
|
||||
context,
|
||||
)!.viewReplies(comment.replyCount),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (expanded)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 28),
|
||||
child: Column(
|
||||
children: [
|
||||
if (controller.repliesLoading(comment.id))
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
for (final reply in replies)
|
||||
CommentTile(
|
||||
comment: reply,
|
||||
controller: controller,
|
||||
handlers: handlers,
|
||||
isReply: true,
|
||||
),
|
||||
if (controller.repliesHasMore(comment.id))
|
||||
TextButton(
|
||||
onPressed: () => controller.loadMoreReplies(comment.id),
|
||||
child: Text(AppLocalizations.of(context)!.loadMore),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _toggleReplies(BuildContext context) async {
|
||||
if (controller.repliesExpanded(comment.id)) {
|
||||
controller.hideReplies(comment.id);
|
||||
return;
|
||||
}
|
||||
await controller.loadReplies(comment.id);
|
||||
}
|
||||
}
|
||||
@@ -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/formatting/localized_content.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations_en.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations_id.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations_zh.dart';
|
||||
|
||||
Drama _drama({required String id, String title = 'raw', String synopsis = 'raw'}) =>
|
||||
@@ -16,6 +17,7 @@ Drama _drama({required String id, String title = 'raw', String synopsis = 'raw'}
|
||||
void main() {
|
||||
final en = AppLocalizationsEn();
|
||||
final zh = AppLocalizationsZh();
|
||||
final id = AppLocalizationsId();
|
||||
|
||||
test('should_map_each_seeded_drama_to_its_own_localized_copy_when_looked_up', () {
|
||||
final signal = _drama(id: 'the-signal-demo');
|
||||
@@ -93,4 +95,21 @@ void main() {
|
||||
}
|
||||
expect(localizedEpisodeTitle(zh, 12), zh.episodeLabel(12));
|
||||
});
|
||||
|
||||
test('should_build_share_copy_from_localized_title_synopsis_and_footer', () {
|
||||
final signal = _drama(id: 'the-signal-demo');
|
||||
final copies = {
|
||||
for (final l10n in [en, zh, id])
|
||||
'${localizedDramaTitle(l10n, signal)}\n'
|
||||
'${localizedDramaSynopsis(l10n, signal)}\n'
|
||||
'${l10n.shareFooter}',
|
||||
};
|
||||
|
||||
expect(copies, hasLength(3));
|
||||
for (final copy in copies) {
|
||||
expect(copy, contains('FruitShorts'));
|
||||
expect(copy.split('\n'), hasLength(3));
|
||||
expect(copy.split('\n').every((line) => line.isNotEmpty), isTrue);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/interaction.dart';
|
||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||
import 'package:fruitshorts/core/data/remote/interaction_api.dart';
|
||||
|
||||
import '../support/fake_http_adapter.dart';
|
||||
|
||||
void main() {
|
||||
String envelope(Object? data) =>
|
||||
jsonEncode({'code': 0, 'message': 'ok', 'data': data});
|
||||
|
||||
test('should_send_like_target_and_parse_string_count_when_toggled', () async {
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(
|
||||
envelope({'liked': true, 'count': '2111012753262120962'}),
|
||||
),
|
||||
);
|
||||
final api = InteractionApi(
|
||||
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
|
||||
);
|
||||
|
||||
final result = await api.toggleLike(
|
||||
targetType: InteractionTargetType.drama,
|
||||
targetId: '5',
|
||||
);
|
||||
|
||||
expect(adapter.requests.single.method, 'POST');
|
||||
expect(adapter.requests.single.uri.path, '/app/likes/toggle');
|
||||
expect(adapter.requests.single.data, {
|
||||
'targetType': 'DRAMA',
|
||||
'targetId': '5',
|
||||
});
|
||||
expect(result.liked, isTrue);
|
||||
expect(result.count, 2111012753262120962);
|
||||
});
|
||||
|
||||
test('should_submit_fixed_reason_code_and_optional_description_when_reporting', () async {
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(envelope('2111012753262120999')),
|
||||
);
|
||||
final api = InteractionApi(
|
||||
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
|
||||
);
|
||||
|
||||
final reportId = await api.report(
|
||||
const ReportRequest(
|
||||
targetType: InteractionTargetType.comment,
|
||||
targetId: '7',
|
||||
reasonCode: 'SPAM',
|
||||
description: 'Repeated advertising',
|
||||
),
|
||||
);
|
||||
|
||||
expect(adapter.requests.single.uri.path, '/app/reports');
|
||||
expect(adapter.requests.single.data, {
|
||||
'targetType': 'COMMENT',
|
||||
'targetId': '7',
|
||||
'reasonCode': 'SPAM',
|
||||
'description': 'Repeated advertising',
|
||||
});
|
||||
expect(reportId, '2111012753262120999');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/interaction.dart';
|
||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||
import 'package:fruitshorts/core/data/services/interaction_controller.dart';
|
||||
|
||||
import '../support/fake_interaction_repositories.dart';
|
||||
|
||||
void main() {
|
||||
test('should_update_drama_like_optimistically_and_use_server_state_when_toggle_succeeds', () async {
|
||||
final repository = FakeInteractionRepository();
|
||||
final gate = Completer<void>();
|
||||
repository.likeGate = gate;
|
||||
final controller = InteractionController(repository);
|
||||
|
||||
final future = controller.toggleDramaLike('5');
|
||||
expect(controller.isLiked('5'), isTrue);
|
||||
expect(repository.likeTargets, ['DRAMA:5']);
|
||||
|
||||
repository.nextLikeResult = const LikeToggleResult(liked: false, count: 0);
|
||||
gate.complete();
|
||||
final result = await future;
|
||||
|
||||
expect(result.isSuccess, isTrue);
|
||||
expect(controller.isLiked('5'), isFalse);
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
test('should_rollback_drama_like_when_toggle_fails', () async {
|
||||
final repository = FakeInteractionRepository(
|
||||
nextLikeResult: const LikeToggleResult(liked: true, count: 3),
|
||||
);
|
||||
final controller = InteractionController(repository);
|
||||
await controller.toggleDramaLike('5');
|
||||
repository.likeError = const ApiException(500, '点赞失败');
|
||||
|
||||
final result = await controller.toggleDramaLike('5');
|
||||
|
||||
expect(result.isSuccess, isFalse);
|
||||
expect(result.errorMessage, '点赞失败');
|
||||
expect(controller.isLiked('5'), isTrue);
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
test('should_clear_account_scoped_drama_likes_when_session_changes', () async {
|
||||
final repository = FakeInteractionRepository(
|
||||
nextLikeResult: const LikeToggleResult(liked: true, count: 1),
|
||||
);
|
||||
final controller = InteractionController(repository);
|
||||
await controller.toggleDramaLike('5');
|
||||
|
||||
controller.clear();
|
||||
|
||||
expect(controller.isLiked('5'), isFalse);
|
||||
controller.dispose();
|
||||
});
|
||||
}
|
||||
@@ -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_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/app.dart';
|
||||
import 'package:fruitshorts/core/data/models/comment.dart';
|
||||
import 'package:fruitshorts/core/data/models/interaction.dart';
|
||||
import 'package:fruitshorts/pages/player/player_view.dart';
|
||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||
|
||||
import 'support/fake_catalog.dart';
|
||||
import 'support/fake_category.dart';
|
||||
import 'support/fake_interaction_repositories.dart';
|
||||
import 'support/widget_player.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/comment_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/interaction_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||
import 'package:fruitshorts/core/data/services/playback_session.dart';
|
||||
@@ -31,6 +36,11 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
void seedSignedOutSession() {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
seedSignedInSession();
|
||||
});
|
||||
@@ -40,6 +50,8 @@ void main() {
|
||||
Widget appUnderTest({
|
||||
PlayerFactory? playerFactory,
|
||||
CatalogRepository? catalogRepository,
|
||||
CommentRepository? commentRepository,
|
||||
InteractionRepository? interactionRepository,
|
||||
}) {
|
||||
final catalog = catalogRepository ?? FakeCatalogRepository();
|
||||
return LumaDramaApp(
|
||||
@@ -51,6 +63,9 @@ void main() {
|
||||
cards: FakeCatalogRepository().dramas,
|
||||
),
|
||||
playbackRepository: FakePlaybackRepository(),
|
||||
commentRepository: commentRepository ?? FakeCommentRepository(),
|
||||
interactionRepository:
|
||||
interactionRepository ?? FakeInteractionRepository(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -415,6 +430,10 @@ void main() {
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
String? sharedText;
|
||||
final interactions = FakeInteractionRepository();
|
||||
final comments = FakeCommentRepository(
|
||||
pages: [const CommentPage()],
|
||||
);
|
||||
const channel = MethodChannel('com.stormg.lumadrama/share');
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (
|
||||
call,
|
||||
@@ -430,7 +449,10 @@ void main() {
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
appUnderTest(),
|
||||
appUnderTest(
|
||||
commentRepository: comments,
|
||||
interactionRepository: interactions,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('tab-home')));
|
||||
@@ -450,7 +472,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('rail-comments')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Only visible on this device'), findsOneWidget);
|
||||
expect(find.text('No comments yet.'), findsOneWidget);
|
||||
await tester.enterText(find.byKey(const Key('comment-input')), 'Nice demo');
|
||||
await tester.tap(find.byKey(const Key('comment-post')));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -459,12 +481,67 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('rail-like')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(interactions.likeTargets, contains('DRAMA:the-signal-demo'));
|
||||
await tester.tap(find.byKey(const Key('rail-share')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sharedText, contains('The Signal'));
|
||||
expect(sharedText, contains('A demo series for vertical playback'));
|
||||
expect(sharedText, contains('FruitShorts'));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('should_open_auth_gate_without_liking_when_signed_out', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
seedSignedOutSession();
|
||||
final interactions = FakeInteractionRepository();
|
||||
|
||||
await tester.pumpWidget(
|
||||
appUnderTest(interactionRepository: interactions),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('tab-home')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('rail-like')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Sign in to continue'), findsOneWidget);
|
||||
expect(interactions.likeTargets, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('should_report_drama_from_detail_more_menu_when_submitted', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final interactions = FakeInteractionRepository();
|
||||
|
||||
await tester.pumpWidget(
|
||||
appUnderTest(interactionRepository: interactions),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('drawer-open-series')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('detail-more')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Report drama'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('report-submit')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(interactions.reports.single.targetType, InteractionTargetType.drama);
|
||||
expect(find.text('Report submitted. Thank you.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should_open_drawer_and_stay_on_home_when_system_back_is_pressed', (tester) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
|
||||
Reference in new issue
Block a user