From d361034226ec2bc431548d45068dc60046e5e473 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Sat, 17 Oct 2026 15:26:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=E6=8E=A5=E5=85=A5=E4=BA=92?= =?UTF-8?q?=E5=8A=A8=E8=AF=84=E8=AE=BA=E7=82=B9=E8=B5=9E=E4=B8=BE=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/app.dart | 31 +- lib/core/data/models/comment.dart | 132 ++++++ lib/core/data/models/engagement_snapshot.dart | 13 - lib/core/data/models/interaction.dart | 70 +++ lib/core/data/models/json_numbers.dart | 7 + lib/core/data/remote/comment_api.dart | 59 +++ lib/core/data/remote/interaction_api.dart | 44 ++ .../data/repositories/comment_repository.dart | 62 +++ .../repositories/engagement_repository.dart | 70 --- .../repositories/interaction_repository.dart | 32 ++ .../data/services/comment_controller.dart | 378 ++++++++++++++++ .../data/services/engagement_controller.dart | 80 ---- .../data/services/interaction_controller.dart | 107 +++++ lib/core/formatting/error_text.dart | 1 + lib/l10n/app_en.arb | 42 +- lib/l10n/app_id.arb | 42 +- lib/l10n/app_localizations.dart | 180 +++++++- lib/l10n/app_localizations_en.dart | 95 +++- lib/l10n/app_localizations_id.dart | 95 +++- lib/l10n/app_localizations_zh.dart | 94 +++- lib/l10n/app_zh.arb | 42 +- lib/pages/drama_detail/drama_detail_view.dart | 41 ++ .../interaction/widgets/report_dialog.dart | 125 +++++ lib/pages/mine/mine_view.dart | 8 +- lib/pages/player/player_view.dart | 22 +- lib/pages/player/widgets/comment_tile.dart | 149 ++++++ .../player/widgets/drama_comments_sheet.dart | 428 ++++++++++++++---- test/comment/comment_api_test.dart | 129 ++++++ test/comment/comment_controller_test.dart | 192 ++++++++ test/comment/drama_comments_sheet_test.dart | 317 +++++++++++++ test/core/platform_share_service_test.dart | 31 ++ .../engagement_controller_test.dart | 48 -- .../engagement_repository_test.dart | 135 ------ test/formatting/localized_content_test.dart | 19 + test/interaction/interaction_api_test.dart | 67 +++ .../interaction_controller_test.dart | 60 +++ test/interaction/report_dialog_test.dart | 55 +++ .../fake_interaction_repositories.dart | 177 ++++++++ test/widget_test.dart | 81 +++- 39 files changed, 3298 insertions(+), 462 deletions(-) create mode 100644 lib/core/data/models/comment.dart delete mode 100644 lib/core/data/models/engagement_snapshot.dart create mode 100644 lib/core/data/models/interaction.dart create mode 100644 lib/core/data/remote/comment_api.dart create mode 100644 lib/core/data/remote/interaction_api.dart create mode 100644 lib/core/data/repositories/comment_repository.dart delete mode 100644 lib/core/data/repositories/engagement_repository.dart create mode 100644 lib/core/data/repositories/interaction_repository.dart create mode 100644 lib/core/data/services/comment_controller.dart delete mode 100644 lib/core/data/services/engagement_controller.dart create mode 100644 lib/core/data/services/interaction_controller.dart create mode 100644 lib/pages/interaction/widgets/report_dialog.dart create mode 100644 lib/pages/player/widgets/comment_tile.dart create mode 100644 test/comment/comment_api_test.dart create mode 100644 test/comment/comment_controller_test.dart create mode 100644 test/comment/drama_comments_sheet_test.dart create mode 100644 test/core/platform_share_service_test.dart delete mode 100644 test/engagement/engagement_controller_test.dart delete mode 100644 test/engagement/engagement_repository_test.dart create mode 100644 test/interaction/interaction_api_test.dart create mode 100644 test/interaction/interaction_controller_test.dart create mode 100644 test/interaction/report_dialog_test.dart create mode 100644 test/support/fake_interaction_repositories.dart diff --git a/lib/app.dart b/lib/app.dart index f2aef94..9ff5a7e 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -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 createState() => _LumaDramaAppState(); @@ -113,6 +120,16 @@ class _LumaDramaAppState extends State { _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 { 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 { _auth.removeListener(_onAuthChanged); _favoriteController.dispose(); _downloadController.dispose(); + _interactionController.dispose(); _apiClient.close(); super.dispose(); } @@ -154,6 +173,8 @@ class _LumaDramaAppState extends State { Provider.value(value: _playback), Provider.value(value: _watchHistory), Provider.value(value: _favorites), + Provider.value(value: _commentRepository), + Provider.value(value: _interactionRepository), ChangeNotifierProvider( create: (_) => CatalogController(_catalogRepository)..load(), ), @@ -163,11 +184,7 @@ class _LumaDramaAppState extends State { 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( diff --git a/lib/core/data/models/comment.dart b/lib/core/data/models/comment.dart new file mode 100644 index 0000000..1e2a01b --- /dev/null +++ b/lib/core/data/models/comment.dart @@ -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 comments; + final String? nextCursor; + final bool hasMore; + + /// 解析后端 `CursorResult{list,nextCursor,hasMore}`。 + factory CommentPage.fromJson(Map json) { + final rawList = json['list']; + return CommentPage( + comments: [ + if (rawList is List) + for (final item in rawList) + if (item is Map) 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 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, + ); +} diff --git a/lib/core/data/models/engagement_snapshot.dart b/lib/core/data/models/engagement_snapshot.dart deleted file mode 100644 index 9fe6ff2..0000000 --- a/lib/core/data/models/engagement_snapshot.dart +++ /dev/null @@ -1,13 +0,0 @@ -// Author: fruit.com.cn - -class EngagementSnapshot { - const EngagementSnapshot({ - this.savedDramaIds = const {}, - this.likedDramaIds = const {}, - this.commentsByDrama = const {}, - }); - - final Set savedDramaIds; - final Set likedDramaIds; - final Map> commentsByDrama; -} diff --git a/lib/core/data/models/interaction.dart b/lib/core/data/models/interaction.dart new file mode 100644 index 0000000..d1413e5 --- /dev/null +++ b/lib/core/data/models/interaction.dart @@ -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 json) => + LikeToggleResult( + liked: json['liked'] == true, + count: jsonToInt(json['count']), + ); +} + +/// 控制器统一返回结果;失败消息可直接展示后端 `message`。 +class OperationResult { + 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; +} diff --git a/lib/core/data/models/json_numbers.dart b/lib/core/data/models/json_numbers.dart index 6fd1084..9aa91a9 100644 --- a/lib/core/data/models/json_numbers.dart +++ b/lib/core/data/models/json_numbers.dart @@ -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; +} diff --git a/lib/core/data/remote/comment_api.dart b/lib/core/data/remote/comment_api.dart new file mode 100644 index 0000000..f99438e --- /dev/null +++ b/lib/core/data/remote/comment_api.dart @@ -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 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) return const CommentPage(); + return CommentPage.fromJson(data); + } + + /// 发表评论或回复;回复只提交 `parentId`,不自行拼层级。 + Future 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) { + throw const ApiException(500, '评论响应格式错误'); + } + return DramaComment.fromJson(data); + } + + /// 删除自己的评论。 + Future delete(String commentId) async { + await _client.delete('/app/comments/$commentId'); + } +} diff --git a/lib/core/data/remote/interaction_api.dart b/lib/core/data/remote/interaction_api.dart new file mode 100644 index 0000000..6fd1a45 --- /dev/null +++ b/lib/core/data/remote/interaction_api.dart @@ -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 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) { + throw const ApiException(500, '点赞响应格式错误'); + } + return LikeToggleResult.fromJson(data); + } + + /// 提交举报,返回工单 ID。 + Future 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() ?? ''; + } +} diff --git a/lib/core/data/repositories/comment_repository.dart b/lib/core/data/repositories/comment_repository.dart new file mode 100644 index 0000000..92d93e2 --- /dev/null +++ b/lib/core/data/repositories/comment_repository.dart @@ -0,0 +1,62 @@ +// Author: fruit.com.cn + +import '../models/comment.dart'; +import '../remote/comment_api.dart'; + +/// 评论数据来源抽象,页面与控制器不直接依赖 HTTP。 +abstract interface class CommentRepository { + /// 拉取评论或回复的分页。 + Future page({ + required String dramaId, + String? rootId, + String? cursor, + int size = commentPageSize, + }); + + /// 发表评论或回复。 + Future publish({ + required String dramaId, + String? episodeId, + required String content, + String? parentId, + }); + + /// 删除指定评论。 + Future delete(String commentId); +} + +/// HTTP 评论仓库。 +class HttpCommentRepository implements CommentRepository { + const HttpCommentRepository(this._api); + + final CommentApi _api; + + @override + Future page({ + required String dramaId, + String? rootId, + String? cursor, + int size = commentPageSize, + }) => _api.page( + dramaId: dramaId, + rootId: rootId, + cursor: cursor, + size: size, + ); + + @override + Future publish({ + required String dramaId, + String? episodeId, + required String content, + String? parentId, + }) => _api.publish( + dramaId: dramaId, + episodeId: episodeId, + content: content, + parentId: parentId, + ); + + @override + Future delete(String commentId) => _api.delete(commentId); +} diff --git a/lib/core/data/repositories/engagement_repository.dart b/lib/core/data/repositories/engagement_repository.dart deleted file mode 100644 index c7067bf..0000000 --- a/lib/core/data/repositories/engagement_repository.dart +++ /dev/null @@ -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 load(); - Future 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 load() async { - final raw = await _preferences.getString(_key); - if (raw == null) return const EngagementSnapshot(); - try { - final json = jsonDecode(raw); - if (json is! Map) return const EngagementSnapshot(); - Set ids(Object? value) => - value is List ? value.whereType().toSet() : {}; - final comments = >{}; - final rawComments = json['comments']; - if (rawComments is Map) { - for (final entry in rawComments.entries) { - if (entry.value is List) { - comments[entry.key] = (entry.value as List) - .whereType() - .toList(); - } - } - } - return EngagementSnapshot( - savedDramaIds: ids(json['saved']), - likedDramaIds: ids(json['liked']), - commentsByDrama: comments, - ); - } catch (_) { - return const EngagementSnapshot(); - } - } - - @override - Future 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 load() async => snapshot; - - @override - Future save(EngagementSnapshot value) async => snapshot = value; -} diff --git a/lib/core/data/repositories/interaction_repository.dart b/lib/core/data/repositories/interaction_repository.dart new file mode 100644 index 0000000..d7a8b09 --- /dev/null +++ b/lib/core/data/repositories/interaction_repository.dart @@ -0,0 +1,32 @@ +// Author: fruit.com.cn + +import '../models/interaction.dart'; +import '../remote/interaction_api.dart'; + +/// 点赞与举报数据来源抽象。 +abstract interface class InteractionRepository { + /// 切换目标点赞状态。 + Future toggleLike({ + required InteractionTargetType targetType, + required String targetId, + }); + + /// 提交举报并返回工单 ID。 + Future report(ReportRequest request); +} + +/// HTTP 互动仓库。 +class HttpInteractionRepository implements InteractionRepository { + const HttpInteractionRepository(this._api); + + final InteractionApi _api; + + @override + Future toggleLike({ + required InteractionTargetType targetType, + required String targetId, + }) => _api.toggleLike(targetType: targetType, targetId: targetId); + + @override + Future report(ReportRequest request) => _api.report(request); +} diff --git a/lib/core/data/services/comment_controller.dart b/lib/core/data/services/comment_controller.dart new file mode 100644 index 0000000..55f6785 --- /dev/null +++ b/lib/core/data/services/comment_controller.dart @@ -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 _comments = []; + final Map> _replies = {}; + final Map _replyCursors = {}; + final Map _replyHasMore = {}; + final Set _expandedRoots = {}; + final Set _loadingReplies = {}; + final Set _loadingMoreReplies = {}; + final Set _likingCommentIds = {}; + final Set _deletingCommentIds = {}; + bool _disposed = false; + bool loading = false; + bool loadingMore = false; + bool submitting = false; + bool hasMore = false; + String? nextCursor; + String? error; + + /// 当前已加载的顶层评论。 + List get comments => List.unmodifiable(_comments); + + /// 某一楼层已加载的回复。 + List 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 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 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 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 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> 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> 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> 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> 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; +} diff --git a/lib/core/data/services/engagement_controller.dart b/lib/core/data/services/engagement_controller.dart deleted file mode 100644 index 440fe8e..0000000 --- a/lib/core/data/services/engagement_controller.dart +++ /dev/null @@ -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 _saved = {}; - final Set _liked = {}; - final Map> _comments = {}; - Future _loadFuture = Future.value(); - Future _pendingSave = Future.value(); - - bool isSaved(String dramaId) => _saved.contains(dramaId); - bool isLiked(String dramaId) => _liked.contains(dramaId); - List commentsFor(String dramaId) => - List.unmodifiable(_comments[dramaId] ?? const []); - - Future load() => _loadFuture = _loadFromRepository(); - - Future _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 toggleSaved(String dramaId) async { - await _loadFuture; - if (!_saved.add(dramaId)) _saved.remove(dramaId); - notifyListeners(); - await _persist(); - } - - Future toggleLiked(String dramaId) async { - await _loadFuture; - if (!_liked.add(dramaId)) _liked.remove(dramaId); - notifyListeners(); - await _persist(); - } - - Future 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 _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; - } -} diff --git a/lib/core/data/services/interaction_controller.dart b/lib/core/data/services/interaction_controller.dart new file mode 100644 index 0000000..9dd39c9 --- /dev/null +++ b/lib/core/data/services/interaction_controller.dart @@ -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 _likedDramaIds = {}; + final Set _busyDramaIds = {}; + bool _disposed = false; + + /// 当前会话内该剧是否已点赞。 + bool isLiked(String dramaId) => _likedDramaIds.contains(dramaId); + + /// 点赞请求是否进行中,用于防连点。 + bool isBusy(String dramaId) => _busyDramaIds.contains(dramaId); + + /// 乐观切换剧集点赞,失败时回滚并以服务端返回状态校准。 + Future> 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> 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(); + } +} diff --git a/lib/core/formatting/error_text.dart b/lib/core/formatting/error_text.dart index c538be9..bd6a5b7 100644 --- a/lib/core/formatting/error_text.dart +++ b/lib/core/formatting/error_text.dart @@ -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, }; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8b04bf3..e9e536f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 1c41600..39656dc 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -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", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 54911ab..d4ca405 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index b4e2e65..e73718c 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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.'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index fe736cc..b6ce3be 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -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.'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 8a24435..e6d1590 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -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 => '无法打开系统分享面板。'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 264e5de..a23e533 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -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": "横屏全屏观看", diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index 131298e..af04e8b 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -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 { 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 { ); } + /// 详情页更多菜单:本批只提供举报短剧。 + Widget _reportAction(BuildContext context, AppLocalizations l10n) { + return PopupMenuButton( + key: const Key('detail-more'), + tooltip: l10n.more, + onSelected: (_) => unawaited(_reportDrama(l10n)), + itemBuilder: (_) => [ + PopupMenuItem( + value: _reportMenuValue, + child: Text(l10n.reportDrama), + ), + ], + ); + } + + Future _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().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(); diff --git a/lib/pages/interaction/widgets/report_dialog.dart b/lib/pages/interaction/widgets/report_dialog.dart new file mode 100644 index 0000000..e6fae25 --- /dev/null +++ b/lib/pages/interaction/widgets/report_dialog.dart @@ -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 showReportDialog( + BuildContext context, { + required String title, +}) => showDialog( + 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( + groupValue: _reason, + onChanged: (value) { + if (value == null) return; + setState(() => _reason = value); + }, + child: Column( + children: [ + for (final reason in ReportReason.values) + RadioListTile( + 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), + ), + ], + ); + } +} diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 1546d2b..af0a003 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -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 { logic.filter == 0 || (logic.filter == 1 && fraction < 0.99) || (logic.filter == 2 && fraction >= 0.99); - final engagement = context.watch(); - // 收藏以服务端为准(收藏列表接口返回卡片结构);点赞仍是本地状态(K3 接互动)。 + final interactions = context.watch(); + // 收藏以服务端为准;剧集点赞暂按当前会话状态过滤 feed。 final favorites = context.watch(); 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, diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index c5521db..75941bf 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -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 with WidgetsBindingObserver { } Widget _buildPlayer(BuildContext context) { - final engagement = context.watch(); + final interactions = context.watch(); final indices = logic.visibleIndices; final pages = logic.pages; final content = AnimatedBuilder( @@ -124,7 +124,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { unawaited(logic.selectEpisode(index)); }, itemBuilder: (context, position) => - _buildEpisode(context, indices[position], engagement), + _buildEpisode(context, indices[position], interactions), ), ); return AnnotatedRegion( @@ -141,7 +141,7 @@ class _PlayerPageState extends State 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 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().isSaved(drama.id), onBack: widget.embedded @@ -348,7 +348,17 @@ class _PlayerPageState extends State 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 { diff --git a/lib/pages/player/widgets/comment_tile.dart b/lib/pages/player/widgets/comment_tile.dart new file mode 100644 index 0000000..f453682 --- /dev/null +++ b/lib/pages/player/widgets/comment_tile.dart @@ -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 onReply; + final Future Function(DramaComment) onLike; + final Future Function(DramaComment) onDelete; + final Future 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( + 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), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart index f819f5c..708bb4a 100644 --- a/lib/pages/player/widgets/drama_comments_sheet.dart +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -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 { final _input = TextEditingController(); + final _inputFocus = FocusNode(); + late final CommentController _controller; + DramaComment? _replyTo; + + @override + void initState() { + super.initState(); + _controller = CommentController( + comments: context.read(), + interactions: context.read(), + 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().addComment(widget.drama.id, text); - _input.clear(); + Future _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 _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 _confirmDelete(DramaComment comment) async { + final l10n = AppLocalizations.of(context)!; + final confirmed = await showDialog( + 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 _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().commentsFor( - widget.drama.id, - ); final available = MediaQuery.sizeOf(context).height - MediaQuery.viewInsetsOf(context).bottom - @@ -45,83 +154,244 @@ class _DramaCommentsSheetState extends State { 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 _toggleReplies(BuildContext context) async { + if (controller.repliesExpanded(comment.id)) { + controller.hideReplies(comment.id); + return; + } + await controller.loadReplies(comment.id); + } } diff --git a/test/comment/comment_api_test.dart b/test/comment/comment_api_test.dart new file mode 100644 index 0000000..a83dfce --- /dev/null +++ b/test/comment/comment_api_test.dart @@ -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 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', + ); + }); +} diff --git a/test/comment/comment_controller_test.dart b/test/comment/comment_controller_test.dart new file mode 100644 index 0000000..b47f97f --- /dev/null +++ b/test/comment/comment_controller_test.dart @@ -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(); + }); +} diff --git a/test/comment/drama_comments_sheet_test.dart b/test/comment/drama_comments_sheet_test.dart new file mode 100644 index 0000000..ad2a65b --- /dev/null +++ b/test/comment/drama_comments_sheet_test.dart @@ -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( + 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( + 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 _pumpSheet( + WidgetTester tester, { + required FakeCommentRepository repository, + FakeInteractionRepository? interactions, +}) async { + final auth = await _auth(); + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value(value: repository), + Provider.value( + value: interactions ?? FakeInteractionRepository(), + ), + ChangeNotifierProvider.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 _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; +} diff --git a/test/core/platform_share_service_test.dart b/test/core/platform_share_service_test.dart new file mode 100644 index 0000000..b8c39dc --- /dev/null +++ b/test/core/platform_share_service_test.dart @@ -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'); + }); +} diff --git a/test/engagement/engagement_controller_test.dart b/test/engagement/engagement_controller_test.dart deleted file mode 100644 index 46546e0..0000000 --- a/test/engagement/engagement_controller_test.dart +++ /dev/null @@ -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']); - }); -} diff --git a/test/engagement/engagement_repository_test.dart b/test/engagement/engagement_repository_test.dart deleted file mode 100644 index c877051..0000000 --- a/test/engagement/engagement_repository_test.dart +++ /dev/null @@ -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 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 [ - '', - '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': [], - }, - }), - ); - - expect(snapshot.savedDramaIds, isEmpty); - expect(snapshot.likedDramaIds, isEmpty); - expect(snapshot.commentsByDrama, { - 'city': ['kept'], - 'town': [], - }); - }); - - 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; - expect(decoded['saved'], ['signal']); - expect(decoded['liked'], ['city']); - expect(decoded['comments'], { - 'signal': ['note'], - }); - }); -} diff --git a/test/formatting/localized_content_test.dart b/test/formatting/localized_content_test.dart index 5f05e78..efe7bb7 100644 --- a/test/formatting/localized_content_test.dart +++ b/test/formatting/localized_content_test.dart @@ -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); + } + }); } diff --git a/test/interaction/interaction_api_test.dart b/test/interaction/interaction_api_test.dart new file mode 100644 index 0000000..47d9417 --- /dev/null +++ b/test/interaction/interaction_api_test.dart @@ -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'); + }); +} diff --git a/test/interaction/interaction_controller_test.dart b/test/interaction/interaction_controller_test.dart new file mode 100644 index 0000000..b2690b3 --- /dev/null +++ b/test/interaction/interaction_controller_test.dart @@ -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(); + 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(); + }); +} diff --git a/test/interaction/report_dialog_test.dart b/test/interaction/report_dialog_test.dart new file mode 100644 index 0000000..41262d7 --- /dev/null +++ b/test/interaction/report_dialog_test.dart @@ -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'); + }); +} diff --git a/test/support/fake_interaction_repositories.dart b/test/support/fake_interaction_repositories.dart new file mode 100644 index 0000000..51ebf04 --- /dev/null +++ b/test/support/fake_interaction_repositories.dart @@ -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? pages, + this.publishResult, + this.publishError, + this.deleteError, + this.pageError, + }) : pages = pages ?? const []; + + final List pages; + final DramaComment? publishResult; + final Object? publishError; + final Object? deleteError; + final Object? pageError; + final List pageCalls = []; + final List publishCalls = []; + final List deleteCalls = []; + int _pageIndex = 0; + + @override + Future 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 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 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? likeGate; + String reportId = 'report-1'; + final List likeTargets = []; + final List reports = []; + + @override + Future 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 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, +); diff --git a/test/widget_test.dart b/test/widget_test.dart index a6ede86..e9f6c41 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -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;