merge: 合并 K90 点赞与评论按集

This commit is contained in:
Zhou Weidong committed 2026-10-21 16:15:44 +08:00
commit 4d0be47ee3
25 files changed
+337 -69

No files matched your search

+18 -9
View File
@@ -15,6 +15,8 @@ class Episode {
this.videoUrl = '', this.videoUrl = '',
this.durationSec = 0, this.durationSec = 0,
this.locked = false, this.locked = false,
this.likeCount,
this.commentCount,
}); });
final String id; final String id;
@@ -28,16 +30,21 @@ class Episode {
/// 后端判定需要解锁;解锁能力(金币/VIP)在 M3 接入。 /// 后端判定需要解锁;解锁能力(金币/VIP)在 M3 接入。
final bool locked; final bool locked;
final int? likeCount;
final int? commentCount;
Episode withVideoUrl(String url) => Episode( Episode withVideoUrl(String url, {int? likeCount, int? commentCount}) =>
id: id, Episode(
number: number, id: id,
title: title, number: number,
isFree: isFree, title: title,
videoUrl: url, isFree: isFree,
durationSec: durationSec, videoUrl: url,
locked: locked, durationSec: durationSec,
); locked: locked,
likeCount: likeCount ?? this.likeCount,
commentCount: commentCount ?? this.commentCount,
);
factory Episode.fromJson(Map<String, dynamic> json) { factory Episode.fromJson(Map<String, dynamic> json) {
return Episode( return Episode(
@@ -48,6 +55,8 @@ class Episode {
durationSec: (json['durationSec'] as num?)?.toInt() ?? 0, durationSec: (json['durationSec'] as num?)?.toInt() ?? 0,
locked: json['locked'] == true, locked: json['locked'] == true,
videoUrl: json['playUrl'] as String? ?? '', videoUrl: json['playUrl'] as String? ?? '',
likeCount: _optionalInt(json['likeCount']),
commentCount: _optionalInt(json['commentCount']),
); );
} }
} }
+2 -1
View File
@@ -2,9 +2,10 @@
import 'json_numbers.dart'; import 'json_numbers.dart';
/// 互动目标类型,契约只允许短剧与评论。 /// 互动目标类型。
enum InteractionTargetType { enum InteractionTargetType {
drama('DRAMA'), drama('DRAMA'),
episode('EPISODE'),
comment('COMMENT'); comment('COMMENT');
const InteractionTargetType(this.apiValue); const InteractionTargetType(this.apiValue);
+10
View File
@@ -14,6 +14,8 @@ class PlayTicket {
this.unlockCoin = 0, this.unlockCoin = 0,
this.previewSeconds = 0, this.previewSeconds = 0,
this.qualities = const [], this.qualities = const [],
this.likeCount,
this.commentCount,
}); });
static const String free = 'FREE'; static const String free = 'FREE';
@@ -38,6 +40,8 @@ class PlayTicket {
/// 后端下发的清晰度档位(如 AUTO / 720P / 1080P),HLS 由播放器自动切换。 /// 后端下发的清晰度档位(如 AUTO / 720P / 1080P),HLS 由播放器自动切换。
final List<String> qualities; final List<String> qualities;
final int? likeCount;
final int? commentCount;
bool get needsUnlock => bool get needsUnlock =>
accessType == coinRequired || accessType == vipRequired; accessType == coinRequired || accessType == vipRequired;
@@ -58,6 +62,12 @@ class PlayTicket {
.whereType<String>() .whereType<String>()
.where((quality) => quality.isNotEmpty) .where((quality) => quality.isNotEmpty)
.toList(), .toList(),
likeCount: json['likeCount'] == null
? null
: jsonToInt(json['likeCount']),
commentCount: json['commentCount'] == null
? null
: jsonToInt(json['commentCount']),
); );
} }
} }
+4 -2
View File
@@ -13,6 +13,7 @@ class CommentApi {
/// 拉取游标分页;`rootId` 用于加载某一楼层的回复。 /// 拉取游标分页;`rootId` 用于加载某一楼层的回复。
Future<CommentPage> page({ Future<CommentPage> page({
required String dramaId, required String dramaId,
String? episodeId,
String? rootId, String? rootId,
String? cursor, String? cursor,
int size = commentPageSize, int size = commentPageSize,
@@ -21,6 +22,7 @@ class CommentApi {
'/app/comments', '/app/comments',
query: { query: {
'dramaId': dramaId, 'dramaId': dramaId,
'episodeId': ?episodeId,
'rootId': ?rootId, 'rootId': ?rootId,
'cursor': ?cursor, 'cursor': ?cursor,
'size': '$size', 'size': '$size',
@@ -31,10 +33,10 @@ class CommentApi {
} }
/// 查询单部剧的有效评论总数;后端 Long 兼容数字与字符串。 /// 查询单部剧的有效评论总数;后端 Long 兼容数字与字符串。
Future<int> count(String dramaId) async { Future<int> count(String dramaId, {String? episodeId}) async {
final data = await _client.get( final data = await _client.get(
'/app/comments/count', '/app/comments/count',
query: {'dramaId': dramaId}, query: {'dramaId': dramaId, 'episodeId': ?episodeId},
); );
return jsonToInt(data); return jsonToInt(data);
} }
@@ -8,13 +8,14 @@ abstract interface class CommentRepository {
/// 拉取评论或回复的分页。 /// 拉取评论或回复的分页。
Future<CommentPage> page({ Future<CommentPage> page({
required String dramaId, required String dramaId,
String? episodeId,
String? rootId, String? rootId,
String? cursor, String? cursor,
int size = commentPageSize, int size = commentPageSize,
}); });
/// 查询单部剧的有效评论总数。 /// 查询单部剧的有效评论总数。
Future<int> count(String dramaId); Future<int> count(String dramaId, {String? episodeId});
/// 发表评论或回复。 /// 发表评论或回复。
Future<DramaComment> publish({ Future<DramaComment> publish({
@@ -37,18 +38,21 @@ class HttpCommentRepository implements CommentRepository {
@override @override
Future<CommentPage> page({ Future<CommentPage> page({
required String dramaId, required String dramaId,
String? episodeId,
String? rootId, String? rootId,
String? cursor, String? cursor,
int size = commentPageSize, int size = commentPageSize,
}) => _api.page( }) => _api.page(
dramaId: dramaId, dramaId: dramaId,
episodeId: episodeId,
rootId: rootId, rootId: rootId,
cursor: cursor, cursor: cursor,
size: size, size: size,
); );
@override @override
Future<int> count(String dramaId) => _api.count(dramaId); Future<int> count(String dramaId, {String? episodeId}) =>
_api.count(dramaId, episodeId: episodeId);
@override @override
Future<DramaComment> publish({ Future<DramaComment> publish({
@@ -75,6 +75,7 @@ class CommentController extends ChangeNotifier {
try { try {
final page = await _commentRepository.page( final page = await _commentRepository.page(
dramaId: dramaId, dramaId: dramaId,
episodeId: episodeId,
size: commentPageSize, size: commentPageSize,
); );
_comments _comments
@@ -101,6 +102,7 @@ class CommentController extends ChangeNotifier {
try { try {
final page = await _commentRepository.page( final page = await _commentRepository.page(
dramaId: dramaId, dramaId: dramaId,
episodeId: episodeId,
cursor: cursor, cursor: cursor,
size: commentPageSize, size: commentPageSize,
); );
@@ -128,6 +130,7 @@ class CommentController extends ChangeNotifier {
try { try {
final page = await _commentRepository.page( final page = await _commentRepository.page(
dramaId: dramaId, dramaId: dramaId,
episodeId: episodeId,
rootId: rootId, rootId: rootId,
size: commentPageSize, size: commentPageSize,
); );
@@ -157,6 +160,7 @@ class CommentController extends ChangeNotifier {
try { try {
final page = await _commentRepository.page( final page = await _commentRepository.page(
dramaId: dramaId, dramaId: dramaId,
episodeId: episodeId,
rootId: rootId, rootId: rootId,
cursor: cursor, cursor: cursor,
size: commentPageSize, size: commentPageSize,
@@ -17,6 +17,9 @@ class InteractionController extends ChangeNotifier {
final Set<String> _likedDramaIds = {}; final Set<String> _likedDramaIds = {};
final Set<String> _busyDramaIds = {}; final Set<String> _busyDramaIds = {};
final Map<String, int> _likeCounts = {}; final Map<String, int> _likeCounts = {};
final Set<String> _likedEpisodeIds = {};
final Set<String> _busyEpisodeIds = {};
final Map<String, int> _episodeLikeCounts = {};
bool _disposed = false; bool _disposed = false;
/// 当前会话内该剧是否已点赞。 /// 当前会话内该剧是否已点赞。
@@ -28,42 +31,75 @@ class InteractionController extends ChangeNotifier {
/// 服务端最新点赞数,仅在点赞接口成功返回后可用。 /// 服务端最新点赞数,仅在点赞接口成功返回后可用。
int? likeCount(String dramaId) => _likeCounts[dramaId]; int? likeCount(String dramaId) => _likeCounts[dramaId];
/// 当前会话内该集是否已点赞。
bool isEpisodeLiked(String episodeId) => _likedEpisodeIds.contains(episodeId);
/// 该集点赞请求是否进行中。
bool isEpisodeBusy(String episodeId) => _busyEpisodeIds.contains(episodeId);
/// 该集服务端最新点赞数。
int? episodeLikeCount(String episodeId) => _episodeLikeCounts[episodeId];
/// 乐观切换剧集点赞,失败时回滚并以服务端返回状态校准。 /// 乐观切换剧集点赞,失败时回滚并以服务端返回状态校准。
Future<OperationResult<LikeToggleResult>> toggleDramaLike( Future<OperationResult<LikeToggleResult>> toggleDramaLike(String dramaId) =>
String dramaId, _toggleLike(
) async { id: dramaId,
if (_busyDramaIds.contains(dramaId)) { targetType: InteractionTargetType.drama,
likedIds: _likedDramaIds,
busyIds: _busyDramaIds,
counts: _likeCounts,
);
/// 乐观切换分集点赞,失败时回滚并以服务端返回状态校准。
Future<OperationResult<LikeToggleResult>> toggleEpisodeLike(
String episodeId,
) => _toggleLike(
id: episodeId,
targetType: InteractionTargetType.episode,
likedIds: _likedEpisodeIds,
busyIds: _busyEpisodeIds,
counts: _episodeLikeCounts,
);
Future<OperationResult<LikeToggleResult>> _toggleLike({
required String id,
required InteractionTargetType targetType,
required Set<String> likedIds,
required Set<String> busyIds,
required Map<String, int> counts,
}) async {
if (busyIds.contains(id)) {
return const OperationResult.failure(null); return const OperationResult.failure(null);
} }
final wasLiked = _likedDramaIds.contains(dramaId); final wasLiked = likedIds.contains(id);
_busyDramaIds.add(dramaId); busyIds.add(id);
if (wasLiked) { if (wasLiked) {
_likedDramaIds.remove(dramaId); likedIds.remove(id);
} else { } else {
_likedDramaIds.add(dramaId); likedIds.add(id);
} }
_notify(); _notify();
try { try {
final result = await _repository.toggleLike( final result = await _repository.toggleLike(
targetType: InteractionTargetType.drama, targetType: targetType,
targetId: dramaId, targetId: id,
); );
if (result.liked) { if (result.liked) {
_likedDramaIds.add(dramaId); likedIds.add(id);
} else { } else {
_likedDramaIds.remove(dramaId); likedIds.remove(id);
} }
_likeCounts[dramaId] = result.count; counts[id] = result.count;
return OperationResult.success(result); return OperationResult.success(result);
} catch (failure) { } catch (failure) {
if (wasLiked) { if (wasLiked) {
_likedDramaIds.add(dramaId); likedIds.add(id);
} else { } else {
_likedDramaIds.remove(dramaId); likedIds.remove(id);
} }
return OperationResult.failure(_errorMessage(failure)); return OperationResult.failure(_errorMessage(failure));
} finally { } finally {
_busyDramaIds.remove(dramaId); busyIds.remove(id);
_notify(); _notify();
} }
} }
@@ -93,12 +129,18 @@ class InteractionController extends ChangeNotifier {
void clear() { void clear() {
if (_likedDramaIds.isEmpty && if (_likedDramaIds.isEmpty &&
_busyDramaIds.isEmpty && _busyDramaIds.isEmpty &&
_likeCounts.isEmpty) { _likeCounts.isEmpty &&
_likedEpisodeIds.isEmpty &&
_busyEpisodeIds.isEmpty &&
_episodeLikeCounts.isEmpty) {
return; return;
} }
_likedDramaIds.clear(); _likedDramaIds.clear();
_busyDramaIds.clear(); _busyDramaIds.clear();
_likeCounts.clear(); _likeCounts.clear();
_likedEpisodeIds.clear();
_busyEpisodeIds.clear();
_episodeLikeCounts.clear();
_notify(); _notify();
} }
@@ -116,6 +116,12 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
final saved = context.watch<FavoriteController>().isSaved(drama.id); final saved = context.watch<FavoriteController>().isSaved(drama.id);
final continueIndex = logic.continueIndex; final continueIndex = logic.continueIndex;
final episodesReady = drama.detailLoaded && drama.episodes.isNotEmpty; final episodesReady = drama.detailLoaded && drama.episodes.isNotEmpty;
final statsEpisode = episodesReady
? drama.episodes[continueIndex != null &&
continueIndex < drama.episodes.length
? continueIndex
: 0]
: null;
return Scaffold( return Scaffold(
backgroundColor: AppColors.ink, backgroundColor: AppColors.ink,
bottomNavigationBar: DramaDetailBottomBar( bottomNavigationBar: DramaDetailBottomBar(
@@ -146,6 +152,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
l10n: l10n, l10n: l10n,
drama: drama, drama: drama,
favoriteCount: logic.adjustedFavoriteCount, favoriteCount: logic.adjustedFavoriteCount,
likeCount: statsEpisode?.likeCount,
), ),
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
@@ -34,11 +34,13 @@ class DramaStatsCard extends StatelessWidget {
required this.l10n, required this.l10n,
required this.drama, required this.drama,
this.favoriteCount, this.favoriteCount,
this.likeCount,
}); });
final AppLocalizations l10n; final AppLocalizations l10n;
final Drama drama; final Drama drama;
final int? favoriteCount; final int? favoriteCount;
final int? likeCount;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -46,9 +48,9 @@ class DramaStatsCard extends StatelessWidget {
final favorite = currentFavoriteCount == null final favorite = currentFavoriteCount == null
? l10n.episodeCountLabel(drama.episodeCount) ? l10n.episodeCountLabel(drama.episodeCount)
: '${formatInteractionCount(currentFavoriteCount)} ${l10n.favorite}'; : '${formatInteractionCount(currentFavoriteCount)} ${l10n.favorite}';
final like = drama.likeCount == null final like = likeCount == null
? '—' ? '—'
: '${formatInteractionCount(drama.likeCount!)} ${l10n.like}'; : '${formatInteractionCount(likeCount!)} ${l10n.like}';
return Container( return Container(
key: const Key('detail-stats'), key: const Key('detail-stats'),
margin: const EdgeInsets.fromLTRB( margin: const EdgeInsets.fromLTRB(
+11 -3
View File
@@ -230,7 +230,11 @@ class PlayerLogic extends ChangeNotifier {
qualities = ticket.qualities; qualities = ticket.qualities;
if (ticket.accessible && ticket.playUrl.isNotEmpty) { if (ticket.accessible && ticket.playUrl.isNotEmpty) {
// 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host) // 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host)
sessionEpisodes[index] = episode.withVideoUrl(ticket.playUrl); sessionEpisodes[index] = episode.withVideoUrl(
ticket.playUrl,
likeCount: ticket.likeCount,
commentCount: ticket.commentCount,
);
return true; return true;
} }
blockedTicket = ticket; blockedTicket = ticket;
@@ -797,7 +801,11 @@ class PlayerLogic extends ChangeNotifier {
notify(); notify();
} }
Future<void> showComments(BuildContext context, Drama drama) async { Future<void> showComments(
BuildContext context,
Drama drama, {
String? episodeId,
}) async {
session.setOverlayOpen(true); session.setOverlayOpen(true);
await session.playbackSettled; await session.playbackSettled;
if (!context.mounted) { if (!context.mounted) {
@@ -812,7 +820,7 @@ class PlayerLogic extends ChangeNotifier {
showDragHandle: false, showDragHandle: false,
isDismissible: false, isDismissible: false,
enableDrag: false, enableDrag: false,
builder: (_) => DramaCommentsSheet(drama: drama), builder: (_) => DramaCommentsSheet(drama: drama, episodeId: episodeId),
); );
} finally { } finally {
session.setOverlayOpen(false); session.setOverlayOpen(false);
+27 -14
View File
@@ -205,13 +205,22 @@ class _PlayerPageState extends State<PlayerPage>
}); });
} }
Future<void> _openComments(BuildContext context, Drama drama) async { Future<void> _openComments(
BuildContext context,
Drama drama,
Episode episode,
) async {
final comments = context.read<CommentRepository>(); final comments = context.read<CommentRepository>();
await openCommentsWithAuth(context, drama, show: logic.showComments); await openCommentsWithAuth(
context,
drama,
show: (context, drama) =>
logic.showComments(context, drama, episodeId: episode.id),
);
try { try {
final count = await comments.count(drama.id); final count = await comments.count(drama.id, episodeId: episode.id);
if (!mounted) return; if (!mounted) return;
setState(() => _commentCounts[drama.id] = count); setState(() => _commentCounts[episode.id] = count);
} catch (_) { } catch (_) {
// 评论面板已经关闭,计数刷新失败时保留旧值即可。 // 评论面板已经关闭,计数刷新失败时保留旧值即可。
} }
@@ -456,18 +465,19 @@ class _PlayerPageState extends State<PlayerPage>
onBoostEnd: _endBoost, onBoostEnd: _endBoost,
onTogglePlay: () => onTogglePlay: () =>
logic.session.setUserWantsPlay(!logic.session.userWantsPlay), logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
liked: interactions.isLiked(drama.id), liked: interactions.isEpisodeLiked(episode.id),
likeCount: interactions.likeCount(drama.id) ?? drama.likeCount, likeCount:
interactions.episodeLikeCount(episode.id) ?? episode.likeCount,
favoriteCount: drama.favoriteCount == null favoriteCount: drama.favoriteCount == null
? null ? null
: drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0), : drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0),
commentCount: _commentCounts[drama.id] ?? drama.commentCount, commentCount: _commentCounts[episode.id] ?? episode.commentCount,
saved: context.watch<FavoriteController>().isSaved(drama.id), saved: context.watch<FavoriteController>().isSaved(drama.id),
onLike: () async { onLike: () async {
if (await ensureSignedIn(context)) { if (await ensureSignedIn(context)) {
if (!context.mounted) return; if (!context.mounted) return;
unawaited(context.read<Haptics>().lightImpact()); unawaited(context.read<Haptics>().lightImpact());
final result = await interactions.toggleDramaLike(drama.id); final result = await interactions.toggleEpisodeLike(episode.id);
if (!result.isSuccess && context.mounted) { if (!result.isSuccess && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@@ -481,7 +491,7 @@ class _PlayerPageState extends State<PlayerPage>
} }
}, },
onSave: () => unawaited(_toggleSave(context, drama)), onSave: () => unawaited(_toggleSave(context, drama)),
onComments: () => unawaited(_openComments(context, drama)), onComments: () => unawaited(_openComments(context, drama, episode)),
onShare: () => logic.shareDrama(context, drama), onShare: () => logic.shareDrama(context, drama),
onDetail: () => unawaited(logic.openDetail(context)), onDetail: () => unawaited(logic.openDetail(context)),
onOpenFullPlayer: () async { onOpenFullPlayer: () async {
@@ -502,9 +512,11 @@ class _PlayerPageState extends State<PlayerPage>
drama: drama, drama: drama,
episode: episode, episode: episode,
controller: ready ? videoPort!.controller : null, controller: ready ? videoPort!.controller : null,
liked: interactions.isLiked(drama.id), liked: interactions.isEpisodeLiked(episode.id),
likeCount: interactions.likeCount(drama.id) ?? drama.likeCount, likeCount:
commentCount: _commentCounts[drama.id] ?? drama.commentCount, interactions.episodeLikeCount(episode.id) ??
episode.likeCount,
commentCount: _commentCounts[episode.id] ?? episode.commentCount,
favoriteCount: drama.favoriteCount == null favoriteCount: drama.favoriteCount == null
? null ? null
: drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0), : drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0),
@@ -525,9 +537,10 @@ class _PlayerPageState extends State<PlayerPage>
onDetail: () => unawaited(logic.openDetail(context)), onDetail: () => unawaited(logic.openDetail(context)),
onSpeed: () => unawaited(_openSpeedSheet(context)), onSpeed: () => unawaited(_openSpeedSheet(context)),
onEpisodes: () => logic.showEpisodes(context), onEpisodes: () => logic.showEpisodes(context),
onComments: () => unawaited(_openComments(context, drama)), onComments: () =>
unawaited(_openComments(context, drama, episode)),
onLike: () => onLike: () =>
unawaited(_toggleLike(context, interactions, drama)), unawaited(_toggleLike(context, interactions, episode)),
onSave: () => unawaited(_toggleSave(context, drama)), onSave: () => unawaited(_toggleSave(context, drama)),
onShare: () => logic.shareDrama(context, drama), onShare: () => logic.shareDrama(context, drama),
onMore: () => _openMore(context, episode), onMore: () => _openMore(context, episode),
+2 -2
View File
@@ -81,11 +81,11 @@ extension _PlayerViewActions on _PlayerPageState {
Future<void> _toggleLike( Future<void> _toggleLike(
BuildContext context, BuildContext context,
InteractionController interactions, InteractionController interactions,
Drama drama, Episode episode,
) async { ) async {
if (!await ensureSignedIn(context) || !context.mounted) return; if (!await ensureSignedIn(context) || !context.mounted) return;
unawaited(context.read<Haptics>().lightImpact()); unawaited(context.read<Haptics>().lightImpact());
final result = await interactions.toggleDramaLike(drama.id); final result = await interactions.toggleEpisodeLike(episode.id);
if (result.isSuccess || !context.mounted) return; if (result.isSuccess || !context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@@ -31,9 +31,10 @@ const double _composerTopGap = 8;
/// 服务端评论底部弹窗:匿名列表、游标翻页、回复与登录发布。 /// 服务端评论底部弹窗:匿名列表、游标翻页、回复与登录发布。
class DramaCommentsSheet extends StatefulWidget { class DramaCommentsSheet extends StatefulWidget {
const DramaCommentsSheet({super.key, required this.drama}); const DramaCommentsSheet({super.key, required this.drama, this.episodeId});
final Drama drama; final Drama drama;
final String? episodeId;
@override @override
State<DramaCommentsSheet> createState() => _DramaCommentsSheetState(); State<DramaCommentsSheet> createState() => _DramaCommentsSheetState();
@@ -56,6 +57,7 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
comments: _comments, comments: _comments,
interactions: context.read<InteractionRepository>(), interactions: context.read<InteractionRepository>(),
dramaId: widget.drama.id, dramaId: widget.drama.id,
episodeId: widget.episodeId,
); );
unawaited(_controller.load()); unawaited(_controller.load());
unawaited(_refreshCount()); unawaited(_refreshCount());
@@ -77,7 +79,10 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
Future<void> _refreshCount() async { Future<void> _refreshCount() async {
final request = ++_countRequest; final request = ++_countRequest;
try { try {
final count = await _comments.count(widget.drama.id); final count = await _comments.count(
widget.drama.id,
episodeId: widget.episodeId,
);
if (!mounted || request != _countRequest) return; if (!mounted || request != _countRequest) return;
setState(() => _serverCount = count); setState(() => _serverCount = count);
} catch (_) { } catch (_) {
@@ -464,8 +469,9 @@ class _CommentThread extends StatelessWidget {
Text( Text(
expanded expanded
? AppLocalizations.of(context)!.hideReplies ? AppLocalizations.of(context)!.hideReplies
: AppLocalizations.of(context)! : AppLocalizations.of(
.viewReplies(comment.replyCount), context,
)!.viewReplies(comment.replyCount),
style: const TextStyle( style: const TextStyle(
color: AppColors.muted, color: AppColors.muted,
fontSize: 12, fontSize: 12,
+14 -1
View File
@@ -20,12 +20,16 @@ void main() {
'title': '第 1 集', 'title': '第 1 集',
'isFree': true, 'isFree': true,
'playUrl': '/files/episode/9.mp4', 'playUrl': '/files/episode/9.mp4',
'likeCount': '18',
'commentCount': 4,
}, },
}); });
expect(drama.detailLoaded, isFalse); expect(drama.detailLoaded, isFalse);
expect(drama.episodes, hasLength(1)); expect(drama.episodes, hasLength(1));
expect(drama.episodes.single.videoUrl, '/files/episode/9.mp4'); expect(drama.episodes.single.videoUrl, '/files/episode/9.mp4');
expect(drama.episodes.single.likeCount, 18);
expect(drama.episodes.single.commentCount, 4);
expect(drama.episodeCount, 77); expect(drama.episodeCount, 77);
expect(drama.isHot, isTrue); expect(drama.isHot, isTrue);
expect(drama.language, 'zh-CN'); expect(drama.language, 'zh-CN');
@@ -50,7 +54,14 @@ void main() {
'cover': '/files/cover.jpg', 'cover': '/files/cover.jpg',
'totalEpisodes': 77, 'totalEpisodes': 77,
'episodes': [ 'episodes': [
{'id': 9, 'number': 1, 'title': '第 1 集', 'isFree': true}, {
'id': 9,
'number': 1,
'title': '第 1 集',
'isFree': true,
'likeCount': '21',
'commentCount': '6',
},
{'id': 10, 'number': 2, 'title': '第 2 集', 'isFree': false}, {'id': 10, 'number': 2, 'title': '第 2 集', 'isFree': false},
], ],
}); });
@@ -58,5 +69,7 @@ void main() {
expect(drama.detailLoaded, isTrue); expect(drama.detailLoaded, isTrue);
expect(drama.episodeCount, 77); expect(drama.episodeCount, 77);
expect(drama.episodes.first.id, '9'); expect(drama.episodes.first.id, '9');
expect(drama.episodes.first.likeCount, 21);
expect(drama.episodes.first.commentCount, 6);
}); });
} }
+14 -3
View File
@@ -55,12 +55,18 @@ void main() {
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
); );
final page = await api.page(dramaId: '5', cursor: '99', size: 20); final page = await api.page(
dramaId: '5',
episodeId: '7',
cursor: '99',
size: 20,
);
expect(adapter.requests.single.method, 'GET'); expect(adapter.requests.single.method, 'GET');
expect(adapter.requests.single.uri.path, '/app/comments'); expect(adapter.requests.single.uri.path, '/app/comments');
expect(adapter.requests.single.queryParameters, { expect(adapter.requests.single.queryParameters, {
'dramaId': '5', 'dramaId': '5',
'episodeId': '7',
'cursor': '99', 'cursor': '99',
'size': '20', 'size': '20',
}); });
@@ -93,6 +99,7 @@ void main() {
final reply = await api.publish( final reply = await api.publish(
dramaId: '5', dramaId: '5',
episodeId: '7',
content: 'great badword', content: 'great badword',
parentId: '2111012753262120962', parentId: '2111012753262120962',
); );
@@ -101,6 +108,7 @@ void main() {
expect(adapter.requests.single.uri.path, '/app/comments'); expect(adapter.requests.single.uri.path, '/app/comments');
expect(adapter.requests.single.data, { expect(adapter.requests.single.data, {
'dramaId': '5', 'dramaId': '5',
'episodeId': '7',
'content': 'great badword', 'content': 'great badword',
'parentId': '2111012753262120962', 'parentId': '2111012753262120962',
}); });
@@ -135,12 +143,15 @@ void main() {
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
); );
final count = await api.count('5'); final count = await api.count('5', episodeId: '7');
expect(count, 17); expect(count, 17);
expect(adapter.requests.single.method, 'GET'); expect(adapter.requests.single.method, 'GET');
expect(adapter.requests.single.uri.path, '/app/comments/count'); expect(adapter.requests.single.uri.path, '/app/comments/count');
expect(adapter.requests.single.queryParameters, {'dramaId': '5'}); expect(adapter.requests.single.queryParameters, {
'dramaId': '5',
'episodeId': '7',
});
}); });
test('should_parse_string_comment_count', () async { test('should_parse_string_comment_count', () async {
+38
View File
@@ -12,10 +12,12 @@ void main() {
CommentController controller( CommentController controller(
FakeCommentRepository comments, { FakeCommentRepository comments, {
FakeInteractionRepository? interactions, FakeInteractionRepository? interactions,
String? episodeId,
}) => CommentController( }) => CommentController(
comments: comments, comments: comments,
interactions: interactions ?? FakeInteractionRepository(), interactions: interactions ?? FakeInteractionRepository(),
dramaId: '5', dramaId: '5',
episodeId: episodeId,
); );
test('should_append_next_page_with_cursor_when_has_more_is_true', () async { test('should_append_next_page_with_cursor_when_has_more_is_true', () async {
@@ -63,6 +65,42 @@ void main() {
model.dispose(); model.dispose();
}); });
test('should_pass_episode_id_to_every_page_and_publish_request', () async {
final root = commentFixture(id: '10', replyCount: 1);
final repository = FakeCommentRepository(
pages: [
CommentPage(
comments: [root],
nextCursor: '10',
hasMore: true,
),
CommentPage(comments: [commentFixture(id: '9')]),
CommentPage(
comments: [commentFixture(id: '11', rootId: '10')],
nextCursor: '11',
hasMore: true,
),
CommentPage(comments: [commentFixture(id: '12', rootId: '10')]),
],
publishResult: commentFixture(id: '13', mine: true),
);
final model = controller(repository, episodeId: 'episode-7');
await model.load();
await model.loadMore();
await model.loadReplies('10');
await model.loadMoreReplies('10');
await model.publish('Nice episode');
expect(
repository.pageCalls.map((call) => call.episodeId),
everyElement('episode-7'),
);
expect(repository.pageCalls, hasLength(4));
expect(repository.publishCalls.single.episodeId, 'episode-7');
model.dispose();
});
test('should_use_server_content_and_pending_status_when_review_is_returned', () async { test('should_use_server_content_and_pending_status_when_review_is_returned', () async {
final repository = FakeCommentRepository( final repository = FakeCommentRepository(
publishResult: commentFixture( publishResult: commentFixture(
+33 -2
View File
@@ -239,6 +239,36 @@ void main() {
expect(repository.countCalls, ['5']); expect(repository.countCalls, ['5']);
}); });
testWidgets('should_scope_list_count_and_publish_to_episode', (tester) async {
final repository = FakeCommentRepository(
pages: [const CommentPage()],
countResult: 3,
publishResult: commentFixture(
id: '11',
content: 'Episode comment',
mine: true,
),
);
await _pumpSheet(tester, repository: repository, episodeId: 'episode-7');
expect(repository.pageCalls.single.episodeId, 'episode-7');
expect(repository.countEpisodeCalls, ['episode-7']);
await tester.tap(find.byKey(const Key('comment-input-placeholder')));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('comment-composer-input')),
'Episode comment',
);
await tester.pump();
await tester.tap(find.byKey(const Key('comment-composer-post')));
await tester.pumpAndSettle();
expect(repository.publishCalls.single.episodeId, 'episode-7');
expect(repository.countEpisodeCalls, ['episode-7', 'episode-7']);
});
testWidgets('should_fall_back_to_exact_count_when_server_total_fails', ( testWidgets('should_fall_back_to_exact_count_when_server_total_fails', (
tester, tester,
) async { ) async {
@@ -964,6 +994,7 @@ Future<AuthController> _pumpSheet(
required FakeCommentRepository repository, required FakeCommentRepository repository,
FakeHaptics? haptics, FakeHaptics? haptics,
Locale locale = const Locale('en'), Locale locale = const Locale('en'),
String? episodeId,
}) async { }) async {
final auth = await _auth(); final auth = await _auth();
await tester.pumpWidget( await tester.pumpWidget(
@@ -983,12 +1014,12 @@ Future<AuthController> _pumpSheet(
initialRoute: '/comments', initialRoute: '/comments',
routes: { routes: {
'/': (_) => const Scaffold(backgroundColor: AppColors.ink), '/': (_) => const Scaffold(backgroundColor: AppColors.ink),
'/comments': (_) => const Scaffold( '/comments': (_) => Scaffold(
backgroundColor: AppColors.ink, backgroundColor: AppColors.ink,
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
body: Align( body: Align(
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
child: DramaCommentsSheet(drama: _drama), child: DramaCommentsSheet(drama: _drama, episodeId: episodeId),
), ),
), ),
}, },
@@ -158,6 +158,8 @@ Drama _detailDrama(Drama card) => Drama(
isFree: number == 1, isFree: number == 1,
videoUrl: number == 1 ? '/files/${card.id}-1.mp4' : '', videoUrl: number == 1 ? '/files/${card.id}-1.mp4' : '',
locked: number > 1, locked: number > 1,
likeCount: card.likeCount,
commentCount: card.commentCount,
), ),
], ],
); );
@@ -463,6 +463,8 @@ Drama _detailDrama({
durationSec: 135, durationSec: 135,
locked: number > 1, locked: number > 1,
videoUrl: number == 1 ? '/files/detail-k38-1.mp4' : '', videoUrl: number == 1 ? '/files/detail-k38-1.mp4' : '',
likeCount: withCounts ? 12400 : null,
commentCount: withCounts ? 96 : null,
), ),
], ],
); );
@@ -62,6 +62,56 @@ void main() {
controller.dispose(); controller.dispose();
}); });
test('should_toggle_episode_like_with_plus_one_and_minus_one', () async {
final repository = FakeInteractionRepository(
nextLikeResult: const LikeToggleResult(liked: true, count: 8),
);
final controller = InteractionController(repository);
await controller.toggleEpisodeLike('episode-5');
expect(repository.likeTargets, ['EPISODE:episode-5']);
expect(controller.isEpisodeLiked('episode-5'), isTrue);
expect(controller.episodeLikeCount('episode-5'), 8);
expect(controller.isLiked('episode-5'), isFalse);
repository.nextLikeResult = const LikeToggleResult(liked: false, count: 7);
await controller.toggleEpisodeLike('episode-5');
expect(controller.isEpisodeLiked('episode-5'), isFalse);
expect(controller.episodeLikeCount('episode-5'), 7);
controller.dispose();
});
test('should_isolate_episode_like_cache_by_episode_and_drama', () async {
final repository = FakeInteractionRepository(
nextLikeResult: const LikeToggleResult(liked: true, count: 8),
);
final controller = InteractionController(repository);
await controller.toggleEpisodeLike('episode-1');
repository.nextLikeResult = const LikeToggleResult(liked: true, count: 3);
await controller.toggleEpisodeLike('episode-2');
repository.nextLikeResult = const LikeToggleResult(liked: false, count: 7);
await controller.toggleEpisodeLike('episode-1');
repository.nextLikeResult = const LikeToggleResult(liked: true, count: 12);
await controller.toggleDramaLike('episode-1');
expect(controller.isEpisodeLiked('episode-1'), isFalse);
expect(controller.episodeLikeCount('episode-1'), 7);
expect(controller.isEpisodeLiked('episode-2'), isTrue);
expect(controller.episodeLikeCount('episode-2'), 3);
expect(controller.isLiked('episode-1'), isTrue);
expect(controller.likeCount('episode-1'), 12);
expect(repository.likeTargets, [
'EPISODE:episode-1',
'EPISODE:episode-2',
'EPISODE:episode-1',
'DRAMA:episode-1',
]);
controller.dispose();
});
test('should_clear_account_scoped_drama_likes_when_session_changes', () async { test('should_clear_account_scoped_drama_likes_when_session_changes', () async {
final repository = FakeInteractionRepository( final repository = FakeInteractionRepository(
nextLikeResult: const LikeToggleResult(liked: true, count: 1), nextLikeResult: const LikeToggleResult(liked: true, count: 1),
@@ -11,9 +11,13 @@ void main() {
'accessType': 'FREE', 'accessType': 'FREE',
'playUrl': '/files/episode/12.m3u8?e=1&s=abc', 'playUrl': '/files/episode/12.m3u8?e=1&s=abc',
'qualities': ['AUTO', '720P', '1080P'], 'qualities': ['AUTO', '720P', '1080P'],
'likeCount': '17',
'commentCount': 5,
}); });
expect(ticket.qualities, ['AUTO', '720P', '1080P']); expect(ticket.qualities, ['AUTO', '720P', '1080P']);
expect(ticket.likeCount, 17);
expect(ticket.commentCount, 5);
}); });
test('should_default_to_empty_qualities_when_field_is_missing', () { test('should_default_to_empty_qualities_when_field_is_missing', () {
+4 -5
View File
@@ -637,12 +637,10 @@ void main() {
'auth.refreshToken': 'test-refresh', 'auth.refreshToken': 'test-refresh',
'auth.user': '{"uid":"9","nickname":"Me","avatarUrl":""}', 'auth.user': '{"uid":"9","nickname":"Me","avatarUrl":""}',
}); });
await _pumpApp( final interactions = FakeInteractionRepository(
tester, nextLikeResult: const LikeToggleResult(liked: true, count: 23000),
interactions: FakeInteractionRepository(
nextLikeResult: const LikeToggleResult(liked: true, count: 23000),
),
); );
await _pumpApp(tester, interactions: interactions);
await _openSeriesPlayer(tester); await _openSeriesPlayer(tester);
String railLabel(Key key) => tester String railLabel(Key key) => tester
@@ -659,6 +657,7 @@ void main() {
await tester.tap(find.byKey(const Key('player-rail-like'))); await tester.tap(find.byKey(const Key('player-rail-like')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(railLabel(const Key('player-rail-like')), '2.3万'); expect(railLabel(const Key('player-rail-like')), '2.3万');
expect(interactions.likeTargets, contains('EPISODE:city-lights-demo-1'));
await tester.tap(find.byKey(const Key('player-rail-save'))); await tester.tap(find.byKey(const Key('player-rail-save')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
+2
View File
@@ -95,6 +95,8 @@ Drama _drama(
title: 'Episode ${index + 1}', title: 'Episode ${index + 1}',
isFree: true, isFree: true,
videoUrl: 'https://example.test/$format-$index.mp4', videoUrl: 'https://example.test/$format-$index.mp4',
likeCount: likeCount,
commentCount: commentCount,
), ),
], ],
); );
@@ -10,12 +10,14 @@ import 'package:fruitshorts/core/data/repositories/interaction_repository.dart';
class CommentPageCall { class CommentPageCall {
const CommentPageCall({ const CommentPageCall({
required this.dramaId, required this.dramaId,
required this.episodeId,
required this.rootId, required this.rootId,
required this.cursor, required this.cursor,
required this.size, required this.size,
}); });
final String dramaId; final String dramaId;
final String? episodeId;
final String? rootId; final String? rootId;
final String? cursor; final String? cursor;
final int size; final int size;
@@ -57,11 +59,13 @@ class FakeCommentRepository implements CommentRepository {
final List<PublishCall> publishCalls = []; final List<PublishCall> publishCalls = [];
final List<String> deleteCalls = []; final List<String> deleteCalls = [];
final List<String> countCalls = []; final List<String> countCalls = [];
final List<String> countEpisodeCalls = [];
int _pageIndex = 0; int _pageIndex = 0;
@override @override
Future<CommentPage> page({ Future<CommentPage> page({
required String dramaId, required String dramaId,
String? episodeId,
String? rootId, String? rootId,
String? cursor, String? cursor,
int size = commentPageSize, int size = commentPageSize,
@@ -69,6 +73,7 @@ class FakeCommentRepository implements CommentRepository {
pageCalls.add( pageCalls.add(
CommentPageCall( CommentPageCall(
dramaId: dramaId, dramaId: dramaId,
episodeId: episodeId,
rootId: rootId, rootId: rootId,
cursor: cursor, cursor: cursor,
size: size, size: size,
@@ -81,8 +86,11 @@ class FakeCommentRepository implements CommentRepository {
} }
@override @override
Future<int> count(String dramaId) async { Future<int> count(String dramaId, {String? episodeId}) async {
countCalls.add(dramaId); countCalls.add(dramaId);
if (episodeId != null && episodeId.isNotEmpty) {
countEpisodeCalls.add(episodeId);
}
final error = countError; final error = countError;
if (error != null) throw error; if (error != null) throw error;
final result = countResult; final result = countResult;
+1 -1
View File
@@ -263,7 +263,7 @@ void main() {
await tester.tap(find.byKey(const Key('player-rail-like'))); await tester.tap(find.byKey(const Key('player-rail-like')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(interactions.likeTargets, contains('DRAMA:city-lights-demo')); expect(interactions.likeTargets, contains('EPISODE:city-lights-demo-1'));
await tester.tap(find.byKey(const Key('player-rail-save'))); await tester.tap(find.byKey(const Key('player-rail-save')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();