feat(app): 展示真实点赞与评论计数
This commit is contained in:
1 parent
7b927c794e
commit
dfc650456a
9 files changed
+123
-8
No files matched your search
@@ -36,12 +36,17 @@ class CommentController extends ChangeNotifier {
|
|||||||
bool loadingMore = false;
|
bool loadingMore = false;
|
||||||
bool submitting = false;
|
bool submitting = false;
|
||||||
bool hasMore = false;
|
bool hasMore = false;
|
||||||
|
bool _loaded = false;
|
||||||
String? nextCursor;
|
String? nextCursor;
|
||||||
String? error;
|
String? error;
|
||||||
|
|
||||||
/// 当前已加载的顶层评论。
|
/// 当前已加载的顶层评论。
|
||||||
List<DramaComment> get comments => List.unmodifiable(_comments);
|
List<DramaComment> get comments => List.unmodifiable(_comments);
|
||||||
|
|
||||||
|
/// 全部分页加载完后返回精确的顶层评论数,否则返回 null。
|
||||||
|
int? get exactCount =>
|
||||||
|
_loaded && !loading && !loadingMore && !hasMore ? _comments.length : null;
|
||||||
|
|
||||||
/// 某一楼层已加载的回复。
|
/// 某一楼层已加载的回复。
|
||||||
List<DramaComment> repliesFor(String rootId) =>
|
List<DramaComment> repliesFor(String rootId) =>
|
||||||
List.unmodifiable(_replies[rootId] ?? const []);
|
List.unmodifiable(_replies[rootId] ?? const []);
|
||||||
@@ -77,6 +82,7 @@ class CommentController extends ChangeNotifier {
|
|||||||
..addAll(page.comments);
|
..addAll(page.comments);
|
||||||
nextCursor = page.nextCursor;
|
nextCursor = page.nextCursor;
|
||||||
hasMore = page.hasMore;
|
hasMore = page.hasMore;
|
||||||
|
_loaded = true;
|
||||||
} catch (failure) {
|
} catch (failure) {
|
||||||
error = _errorMessage(failure) ?? 'commentsLoadFailed';
|
error = _errorMessage(failure) ?? 'commentsLoadFailed';
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -8,14 +8,15 @@ import '../repositories/interaction_repository.dart';
|
|||||||
|
|
||||||
/// 剧集点赞与举报的会话状态控制器。
|
/// 剧集点赞与举报的会话状态控制器。
|
||||||
///
|
///
|
||||||
/// 剧集契约暂未下发初始点赞状态与计数,本批只维护当前会话内的 liked 状态,
|
/// 剧集契约暂未下发初始点赞状态与计数,因此仅在点赞接口成功返回后展示
|
||||||
/// 不展示数字,也不伪造初始值。
|
/// 服务端最新计数,不伪造初始值。
|
||||||
class InteractionController extends ChangeNotifier {
|
class InteractionController extends ChangeNotifier {
|
||||||
InteractionController(this._repository);
|
InteractionController(this._repository);
|
||||||
|
|
||||||
final InteractionRepository _repository;
|
final InteractionRepository _repository;
|
||||||
final Set<String> _likedDramaIds = {};
|
final Set<String> _likedDramaIds = {};
|
||||||
final Set<String> _busyDramaIds = {};
|
final Set<String> _busyDramaIds = {};
|
||||||
|
final Map<String, int> _likeCounts = {};
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
|
|
||||||
/// 当前会话内该剧是否已点赞。
|
/// 当前会话内该剧是否已点赞。
|
||||||
@@ -24,6 +25,9 @@ class InteractionController extends ChangeNotifier {
|
|||||||
/// 点赞请求是否进行中,用于防连点。
|
/// 点赞请求是否进行中,用于防连点。
|
||||||
bool isBusy(String dramaId) => _busyDramaIds.contains(dramaId);
|
bool isBusy(String dramaId) => _busyDramaIds.contains(dramaId);
|
||||||
|
|
||||||
|
/// 服务端最新点赞数,仅在点赞接口成功返回后可用。
|
||||||
|
int? likeCount(String dramaId) => _likeCounts[dramaId];
|
||||||
|
|
||||||
/// 乐观切换剧集点赞,失败时回滚并以服务端返回状态校准。
|
/// 乐观切换剧集点赞,失败时回滚并以服务端返回状态校准。
|
||||||
Future<OperationResult<LikeToggleResult>> toggleDramaLike(
|
Future<OperationResult<LikeToggleResult>> toggleDramaLike(
|
||||||
String dramaId,
|
String dramaId,
|
||||||
@@ -49,6 +53,7 @@ class InteractionController extends ChangeNotifier {
|
|||||||
} else {
|
} else {
|
||||||
_likedDramaIds.remove(dramaId);
|
_likedDramaIds.remove(dramaId);
|
||||||
}
|
}
|
||||||
|
_likeCounts[dramaId] = result.count;
|
||||||
return OperationResult.success(result);
|
return OperationResult.success(result);
|
||||||
} catch (failure) {
|
} catch (failure) {
|
||||||
if (wasLiked) {
|
if (wasLiked) {
|
||||||
@@ -86,9 +91,14 @@ class InteractionController extends ChangeNotifier {
|
|||||||
|
|
||||||
/// 登录账号变化时清理会话内状态,避免跨账号串联。
|
/// 登录账号变化时清理会话内状态,避免跨账号串联。
|
||||||
void clear() {
|
void clear() {
|
||||||
if (_likedDramaIds.isEmpty && _busyDramaIds.isEmpty) return;
|
if (_likedDramaIds.isEmpty &&
|
||||||
|
_busyDramaIds.isEmpty &&
|
||||||
|
_likeCounts.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
_likedDramaIds.clear();
|
_likedDramaIds.clear();
|
||||||
_busyDramaIds.clear();
|
_busyDramaIds.clear();
|
||||||
|
_likeCounts.clear();
|
||||||
_notify();
|
_notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -337,6 +337,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
drama: drama,
|
drama: drama,
|
||||||
episode: episode,
|
episode: episode,
|
||||||
liked: interactions.isLiked(drama.id),
|
liked: interactions.isLiked(drama.id),
|
||||||
|
likeCount: interactions.likeCount(drama.id),
|
||||||
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)) {
|
||||||
@@ -388,6 +389,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
episode: episode,
|
episode: episode,
|
||||||
controller: ready ? videoPort!.controller : null,
|
controller: ready ? videoPort!.controller : null,
|
||||||
liked: interactions.isLiked(drama.id),
|
liked: interactions.isLiked(drama.id),
|
||||||
|
likeCount: interactions.likeCount(drama.id),
|
||||||
// 收藏以服务端为准(FavoriteController 由 app 注入,登录后自动加载)
|
// 收藏以服务端为准(FavoriteController 由 app 注入,登录后自动加载)
|
||||||
saved: context.watch<FavoriteController>().isSaved(drama.id),
|
saved: context.watch<FavoriteController>().isSaved(drama.id),
|
||||||
onBack: widget.embedded
|
onBack: widget.embedded
|
||||||
@@ -506,9 +508,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(errorText(l10n, logic.unlockError, l10n.unlockFailed)),
|
||||||
errorText(l10n, logic.unlockError, l10n.unlockFailed),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -688,6 +688,7 @@ class _PlaybackOverlay extends StatefulWidget {
|
|||||||
required this.episode,
|
required this.episode,
|
||||||
required this.controller,
|
required this.controller,
|
||||||
required this.liked,
|
required this.liked,
|
||||||
|
this.likeCount,
|
||||||
required this.saved,
|
required this.saved,
|
||||||
required this.playing,
|
required this.playing,
|
||||||
required this.playbackSpeed,
|
required this.playbackSpeed,
|
||||||
@@ -714,6 +715,7 @@ class _PlaybackOverlay extends StatefulWidget {
|
|||||||
final Episode episode;
|
final Episode episode;
|
||||||
final VideoPlayerController? controller;
|
final VideoPlayerController? controller;
|
||||||
final bool liked;
|
final bool liked;
|
||||||
|
final int? likeCount;
|
||||||
final bool saved;
|
final bool saved;
|
||||||
final bool playing;
|
final bool playing;
|
||||||
final double playbackSpeed;
|
final double playbackSpeed;
|
||||||
@@ -985,6 +987,12 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> {
|
|||||||
color: widget.liked ? AppColors.blue : AppColors.muted,
|
color: widget.liked ? AppColors.blue : AppColors.muted,
|
||||||
),
|
),
|
||||||
title: Text(widget.l10n.like),
|
title: Text(widget.l10n.like),
|
||||||
|
trailing: widget.likeCount == null
|
||||||
|
? null
|
||||||
|
: Text(
|
||||||
|
'${widget.likeCount}',
|
||||||
|
key: const Key('player-more-like-count'),
|
||||||
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(sheetContext);
|
Navigator.pop(sheetContext);
|
||||||
widget.onLike();
|
widget.onLike();
|
||||||
|
|||||||
@@ -178,11 +178,13 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
|
|||||||
|
|
||||||
Widget _content(BuildContext context) {
|
Widget _content(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final count = _controller.exactCount;
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
l10n.comments,
|
count == null ? l10n.comments : '${l10n.comments} · $count',
|
||||||
|
key: count == null ? null : const Key('comment-count-label'),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: AppColors.cream,
|
color: AppColors.cream,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class HomeFeedOverlay extends StatelessWidget {
|
|||||||
required this.drama,
|
required this.drama,
|
||||||
required this.episode,
|
required this.episode,
|
||||||
required this.liked,
|
required this.liked,
|
||||||
|
this.likeCount,
|
||||||
required this.saved,
|
required this.saved,
|
||||||
required this.onLike,
|
required this.onLike,
|
||||||
required this.onSave,
|
required this.onSave,
|
||||||
@@ -35,6 +36,7 @@ class HomeFeedOverlay extends StatelessWidget {
|
|||||||
final Drama drama;
|
final Drama drama;
|
||||||
final Episode episode;
|
final Episode episode;
|
||||||
final bool liked;
|
final bool liked;
|
||||||
|
final int? likeCount;
|
||||||
final bool saved;
|
final bool saved;
|
||||||
final VoidCallback onLike;
|
final VoidCallback onLike;
|
||||||
final VoidCallback onSave;
|
final VoidCallback onSave;
|
||||||
@@ -123,7 +125,8 @@ class HomeFeedOverlay extends StatelessWidget {
|
|||||||
_RailItem(
|
_RailItem(
|
||||||
key: const Key('rail-like'),
|
key: const Key('rail-like'),
|
||||||
icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||||
label: l10n.like,
|
label: likeCount?.toString() ?? l10n.like,
|
||||||
|
countKey: likeCount == null ? null : const Key('rail-like-count'),
|
||||||
selected: liked,
|
selected: liked,
|
||||||
onTap: onLike,
|
onTap: onLike,
|
||||||
),
|
),
|
||||||
@@ -375,12 +378,14 @@ class _RailItem extends StatelessWidget {
|
|||||||
required this.label,
|
required this.label,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
this.selected = false,
|
this.selected = false,
|
||||||
|
this.countKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final bool selected;
|
final bool selected;
|
||||||
|
final Key? countKey;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -404,6 +409,7 @@ class _RailItem extends StatelessWidget {
|
|||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
|
key: countKey,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
|
|||||||
@@ -42,6 +42,27 @@ void main() {
|
|||||||
model.dispose();
|
model.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should_expose_exact_count_only_when_last_page_is_loaded', () 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();
|
||||||
|
expect(model.exactCount, isNull);
|
||||||
|
|
||||||
|
await model.loadMore();
|
||||||
|
expect(model.exactCount, 2);
|
||||||
|
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(
|
||||||
|
|||||||
@@ -62,11 +62,13 @@ void main() {
|
|||||||
await _pumpSheet(tester, repository: repository);
|
await _pumpSheet(tester, repository: repository);
|
||||||
expect(find.text('Great episode'), findsOneWidget);
|
expect(find.text('Great episode'), findsOneWidget);
|
||||||
expect(find.byKey(const Key('comment-list-load-more')), findsOneWidget);
|
expect(find.byKey(const Key('comment-list-load-more')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('comment-count-label')), findsNothing);
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('comment-list-load-more')));
|
await tester.tap(find.byKey(const Key('comment-list-load-more')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Second page'), findsOneWidget);
|
expect(find.text('Second page'), findsOneWidget);
|
||||||
|
expect(find.text('Comments · 2'), findsOneWidget);
|
||||||
expect(repository.pageCalls.last.cursor, '10');
|
expect(repository.pageCalls.last.cursor, '10');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,48 @@ void main() {
|
|||||||
'Fallback Drama',
|
'Fallback Drama',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('should_show_server_like_count_when_count_is_available', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: AppTheme.dark(),
|
||||||
|
locale: const Locale('en'),
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
home: Builder(
|
||||||
|
builder: (context) => Scaffold(
|
||||||
|
body: HomeFeedOverlay(
|
||||||
|
l10n: AppLocalizations.of(context)!,
|
||||||
|
drama: _drama,
|
||||||
|
episode: const Episode(
|
||||||
|
id: 'count-episode',
|
||||||
|
number: 1,
|
||||||
|
title: 'Count Episode',
|
||||||
|
isFree: true,
|
||||||
|
),
|
||||||
|
liked: true,
|
||||||
|
likeCount: 128,
|
||||||
|
saved: false,
|
||||||
|
onLike: () {},
|
||||||
|
onSave: () {},
|
||||||
|
onComments: () {},
|
||||||
|
onShare: () {},
|
||||||
|
onEpisodes: () {},
|
||||||
|
onDownload: () {},
|
||||||
|
onClear: () {},
|
||||||
|
onSearch: () {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('rail-like-count')), findsOneWidget);
|
||||||
|
expect(find.text('128'), findsOneWidget);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const _drama = Drama(
|
const _drama = Drama(
|
||||||
|
|||||||
@@ -26,6 +26,23 @@ void main() {
|
|||||||
|
|
||||||
expect(result.isSuccess, isTrue);
|
expect(result.isSuccess, isTrue);
|
||||||
expect(controller.isLiked('5'), isFalse);
|
expect(controller.isLiked('5'), isFalse);
|
||||||
|
expect(controller.likeCount('5'), 0);
|
||||||
|
controller.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should_expose_latest_server_count_when_two_toggles_succeed', () async {
|
||||||
|
final repository = FakeInteractionRepository(
|
||||||
|
nextLikeResult: const LikeToggleResult(liked: true, count: 12),
|
||||||
|
);
|
||||||
|
final controller = InteractionController(repository);
|
||||||
|
|
||||||
|
await controller.toggleDramaLike('5');
|
||||||
|
expect(controller.likeCount('5'), 12);
|
||||||
|
|
||||||
|
repository.nextLikeResult = const LikeToggleResult(liked: false, count: 11);
|
||||||
|
await controller.toggleDramaLike('5');
|
||||||
|
|
||||||
|
expect(controller.likeCount('5'), 11);
|
||||||
controller.dispose();
|
controller.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,6 +72,7 @@ void main() {
|
|||||||
controller.clear();
|
controller.clear();
|
||||||
|
|
||||||
expect(controller.isLiked('5'), isFalse);
|
expect(controller.isLiked('5'), isFalse);
|
||||||
|
expect(controller.likeCount('5'), isNull);
|
||||||
controller.dispose();
|
controller.dispose();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user