feat(flutter): 播放页互动数量与评论面板收紧

This commit is contained in:
Zhou Weidong committed 2026-10-20 12:06:21 +08:00
1 parent e39aa766ac
commit bed6bbb75b
13 files changed
+265 -59

No files matched your search

+16
View File
@@ -65,6 +65,9 @@ class Drama {
this.freeEpisodes = 0, this.freeEpisodes = 0,
this.isHot = false, this.isHot = false,
this.viewCount = 0, this.viewCount = 0,
this.likeCount,
this.favoriteCount,
this.commentCount,
this.language = '', this.language = '',
this.detailLoaded = false, this.detailLoaded = false,
}); });
@@ -81,6 +84,11 @@ class Drama {
final bool isHot; final bool isHot;
final int viewCount; final int viewCount;
/// 详情下发的互动数量;卡片或旧接口没有这些字段时为 null(客户端不显示数字)。
final int? likeCount;
final int? favoriteCount;
final int? commentCount;
/// 卡片语种(如 zh-CN / id),用于剧场语言筛选。 /// 卡片语种(如 zh-CN / id),用于剧场语言筛选。
final String language; final String language;
@@ -108,6 +116,9 @@ class Drama {
isHot: json['isHot'] == true, isHot: json['isHot'] == true,
// viewCount 后端是 Long,按字符串下发(见 json_numbers.dart) // viewCount 后端是 Long,按字符串下发(见 json_numbers.dart)
viewCount: jsonToInt(json['viewCount']), viewCount: jsonToInt(json['viewCount']),
likeCount: _optionalInt(json['likeCount']),
favoriteCount: _optionalInt(json['favoriteCount']),
commentCount: _optionalInt(json['commentCount']),
language: json['language'] as String? ?? '', language: json['language'] as String? ?? '',
); );
} }
@@ -125,6 +136,9 @@ class Drama {
freeEpisodes: (json['freeEpisodes'] as num?)?.toInt() ?? 0, freeEpisodes: (json['freeEpisodes'] as num?)?.toInt() ?? 0,
isHot: json['isHot'] == true, isHot: json['isHot'] == true,
viewCount: jsonToInt(json['viewCount']), viewCount: jsonToInt(json['viewCount']),
likeCount: _optionalInt(json['likeCount']),
favoriteCount: _optionalInt(json['favoriteCount']),
commentCount: _optionalInt(json['commentCount']),
language: json['language'] as String? ?? '', language: json['language'] as String? ?? '',
detailLoaded: true, detailLoaded: true,
episodes: (json['episodes'] as List? ?? const []) episodes: (json['episodes'] as List? ?? const [])
@@ -134,3 +148,5 @@ class Drama {
); );
} }
} }
int? _optionalInt(dynamic value) => value == null ? null : jsonToInt(value);
+9
View File
@@ -0,0 +1,9 @@
// Author: fruit.com.cn
/// 互动数量展示:不足一万显示原数,一万及以上显示一位小数的「万」。
///
/// 例:`3200` → `3200`,`12400` → `1.2万`。
String formatInteractionCount(int count) {
if (count < 10000) return '$count';
return '${(count / 10000).toStringAsFixed(1)}万';
}
+16 -3
View File
@@ -83,6 +83,9 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
bool _chromeVisible = true; bool _chromeVisible = true;
double _playbackSpeed = 1.0; double _playbackSpeed = 1.0;
/// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。
final Map<String, int> _favoriteDelta = {};
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -127,6 +130,14 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
setState(() => _chromeVisible = false); setState(() => _chromeVisible = false);
} }
/// 收藏状态确认后同步右侧计数(总数没有独立计数接口,按 ±1 跟随)。
void _applyFavoriteDelta(String dramaId, {required bool nowSaved}) {
setState(() {
_favoriteDelta[dramaId] =
(_favoriteDelta[dramaId] ?? 0) + (nowSaved ? 1 : -1);
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListenableBuilder( return ListenableBuilder(
@@ -377,9 +388,11 @@ 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), likeCount: interactions.likeCount(drama.id) ?? drama.likeCount,
commentCount: null, commentCount: drama.commentCount,
favoriteCount: null, favoriteCount: drama.favoriteCount == null
? null
: drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0),
saved: context.watch<FavoriteController>().isSaved(drama.id), saved: context.watch<FavoriteController>().isSaved(drama.id),
playing: logic.session.userWantsPlay, playing: logic.session.userWantsPlay,
playbackSpeed: _playbackSpeed, playbackSpeed: _playbackSpeed,
+9 -1
View File
@@ -100,8 +100,16 @@ extension _PlayerViewActions on _PlayerPageState {
final favorites = context.read<FavoriteController>(); final favorites = context.read<FavoriteController>();
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 wasSaved = favorites.isSaved(drama.id);
final ok = await favorites.toggle(drama.id, card: drama); final ok = await favorites.toggle(drama.id, card: drama);
if (ok || !context.mounted) return; if (!context.mounted) return;
if (ok) {
final nowSaved = favorites.isSaved(drama.id);
if (nowSaved != wasSaved) {
_applyFavoriteDelta(drama.id, nowSaved: nowSaved);
}
return;
}
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(AppLocalizations.of(context)!.favoriteToggleFailed), content: Text(AppLocalizations.of(context)!.favoriteToggleFailed),
+7 -3
View File
@@ -107,7 +107,11 @@ class CommentTile extends StatelessWidget {
), ),
Text( Text(
comment.content, comment.content,
style: const TextStyle(color: AppColors.cream, fontSize: 14), style: const TextStyle(
color: AppColors.cream,
fontSize: 13,
height: 1.2,
),
), ),
if (comment.isPending) if (comment.isPending)
Padding( Padding(
@@ -153,14 +157,14 @@ class CommentTile extends StatelessWidget {
} }
Widget _avatar() { Widget _avatar() {
final size = isReply ? 24.0 : 32.0; final size = isReply ? 22.0 : 28.0;
if (comment.avatarUrl.isEmpty) { if (comment.avatarUrl.isEmpty) {
return CircleAvatar( return CircleAvatar(
radius: size / 2, radius: size / 2,
backgroundColor: AppColors.raised, backgroundColor: AppColors.raised,
child: Icon( child: Icon(
Icons.person_rounded, Icons.person_rounded,
size: isReply ? 14 : 18, size: isReply ? 13 : 16,
color: AppColors.cream, color: AppColors.cream,
), ),
); );
@@ -22,7 +22,9 @@ import 'comment_tile.dart';
const double _minSheetHeight = 280; const double _minSheetHeight = 280;
const double _maxSheetHeight = 520; const double _maxSheetHeight = 520;
const int _maxCommentLength = 300; const int _maxCommentLength = 300;
const double _composerRadius = 22; const double _composerRadius = 20;
const double _composerHeight = 40;
const double _sendButtonSize = 32;
const double _sheetRadius = 20; const double _sheetRadius = 20;
/// 服务端评论底部弹窗:匿名列表、游标翻页、回复、点赞和本人删除。 /// 服务端评论底部弹窗:匿名列表、游标翻页、回复、点赞和本人删除。
@@ -70,6 +72,7 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
if (result.isSuccess) { if (result.isSuccess) {
_input.clear(); _input.clear();
setState(() => _replyTo = null); setState(() => _replyTo = null);
FocusScope.of(context).unfocus();
return; return;
} }
_showMessage( _showMessage(
@@ -174,7 +177,7 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
child: SafeArea( child: SafeArea(
top: false, top: false,
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
child: ListenableBuilder( child: ListenableBuilder(
listenable: _controller, listenable: _controller,
builder: (context, _) => _content(context), builder: (context, _) => _content(context),
@@ -200,7 +203,7 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
key: count == null ? null : const Key('comment-count-label'), key: count == null ? null : const Key('comment-count-label'),
style: const TextStyle( style: const TextStyle(
color: AppColors.onSurface, color: AppColors.onSurface,
fontSize: 20, fontSize: 18,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
), ),
), ),
@@ -223,7 +226,7 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 8),
Expanded(child: _list(l10n)), Expanded(child: _list(l10n)),
if (_replyTo != null) _replyBanner(l10n, _replyTo!), if (_replyTo != null) _replyBanner(l10n, _replyTo!),
_composer(l10n), _composer(l10n),
@@ -323,11 +326,12 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
); );
return InputDecoration( return InputDecoration(
hintText: hint, hintText: hint,
hintStyle: const TextStyle(color: AppColors.muted, fontSize: 14), hintStyle: const TextStyle(color: AppColors.muted, fontSize: 13),
counterText: '', counterText: '',
filled: true, filled: true,
fillColor: AppColors.raised, fillColor: AppColors.raised,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), contentPadding: const EdgeInsets.symmetric(horizontal: 12),
isDense: true,
border: clear, border: clear,
enabledBorder: clear, enabledBorder: clear,
focusedBorder: clear, focusedBorder: clear,
@@ -337,29 +341,37 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
Widget _composer(AppLocalizations l10n) => Row( Widget _composer(AppLocalizations l10n) => Row(
children: [ children: [
Expanded( Expanded(
child: TextField( child: SizedBox(
key: const Key('comment-input'), height: _composerHeight,
controller: _input, child: TextField(
focusNode: _inputFocus, key: const Key('comment-input'),
maxLength: _maxCommentLength, controller: _input,
maxLines: 1, focusNode: _inputFocus,
style: const TextStyle(color: AppColors.cream, fontSize: 14), maxLength: _maxCommentLength,
cursorColor: AppColors.blue, maxLines: 1,
decoration: _composerDecoration(l10n.writeComment), style: const TextStyle(color: AppColors.cream, fontSize: 13),
onSubmitted: (_) => _post(), cursorColor: AppColors.blue,
decoration: _composerDecoration(l10n.writeComment),
onSubmitted: (_) => _post(),
),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
IconButton.filled( SizedBox.square(
key: const Key('comment-post'), dimension: _sendButtonSize,
tooltip: l10n.postComment, child: IconButton.filled(
onPressed: _controller.submitting ? null : _post, key: const Key('comment-post'),
icon: _controller.submitting tooltip: l10n.postComment,
? const SizedBox.square( padding: EdgeInsets.zero,
dimension: 18, iconSize: 18,
child: CircularProgressIndicator(strokeWidth: 2), onPressed: _controller.submitting ? null : _post,
) icon: _controller.submitting
: const Icon(Icons.send_rounded), ? const SizedBox.square(
dimension: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send_rounded),
),
), ),
], ],
); );
@@ -1,6 +1,7 @@
// Author: fruit.com.cn // Author: fruit.com.cn
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../core/formatting/count_format.dart';
import '../../../core/formatting/localized_content.dart'; import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_dimens.dart';
@@ -138,7 +138,9 @@ class _Rail extends StatelessWidget {
_RailButton( _RailButton(
key: const Key('player-rail-save'), key: const Key('player-rail-save'),
icon: saved ? Icons.star_rounded : Icons.star_border_rounded, icon: saved ? Icons.star_rounded : Icons.star_border_rounded,
label: favoriteCount?.toString() ?? l10n.favorite, label: favoriteCount == null
? l10n.favorite
: formatInteractionCount(favoriteCount!),
active: saved, active: saved,
onTap: onSave, onTap: onSave,
), ),
@@ -146,14 +148,18 @@ class _Rail extends StatelessWidget {
_RailButton( _RailButton(
key: const Key('player-rail-comments'), key: const Key('player-rail-comments'),
icon: Icons.chat_bubble_outline_rounded, icon: Icons.chat_bubble_outline_rounded,
label: commentCount?.toString() ?? l10n.comments, label: commentCount == null
? l10n.comments
: formatInteractionCount(commentCount!),
onTap: onComments, onTap: onComments,
), ),
const SizedBox(height: _railGap), const SizedBox(height: _railGap),
_RailButton( _RailButton(
key: const Key('player-rail-like'), key: const Key('player-rail-like'),
icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded, icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
label: likeCount?.toString() ?? l10n.like, label: likeCount == null
? l10n.like
: formatInteractionCount(likeCount!),
active: liked, active: liked,
onTap: onLike, onTap: onLike,
), ),
@@ -106,9 +106,57 @@ void main() {
find.byKey(const Key('comment-input')), find.byKey(const Key('comment-input')),
); );
expect(field.controller?.text, 'blocked text'); expect(field.controller?.text, 'blocked text');
// 发送失败时保留输入与焦点,用户可以直接改完再发。
expect(field.focusNode?.hasFocus, isTrue);
}, },
); );
testWidgets('should_unfocus_input_after_successful_post', (tester) async {
final repository = FakeCommentRepository(
pages: [const CommentPage()],
publishResult: commentFixture(id: '11', content: 'Nice demo', mine: true),
);
await _pumpSheet(tester, repository: repository);
final input = find.byKey(const Key('comment-input'));
await tester.tap(input);
await tester.pump();
expect(tester.widget<TextField>(input).focusNode?.hasFocus, isTrue);
await tester.enterText(input, 'Nice demo');
await tester.tap(find.byKey(const Key('comment-post')));
await tester.pumpAndSettle();
expect(tester.widget<TextField>(input).focusNode?.hasFocus, isFalse);
});
testWidgets('should_use_compact_comment_sizes', (tester) async {
final repository = FakeCommentRepository(
pages: [
CommentPage(
comments: [commentFixture(id: '10', content: 'Great episode')],
),
],
);
await _pumpSheet(tester, repository: repository);
expect(
tester.getSize(find.byKey(const Key('comment-post'))),
const Size(32, 32),
);
expect(tester.getSize(find.byKey(const Key('comment-input'))).height, 40);
final avatar = tester.widget<CircleAvatar>(
find.descendant(
of: find.byKey(const Key('comment-item-10')),
matching: find.byType(CircleAvatar),
),
);
expect(avatar.radius, 14);
final body = tester.widget<Text>(find.text('Great episode'));
expect(body.style?.fontSize, 13);
expect(body.style?.height, 1.2);
});
testWidgets('should_show_review_badge_and_server_replaced_content', ( testWidgets('should_show_review_badge_and_server_replaced_content', (
tester, tester,
) async { ) async {
+18
View File
@@ -0,0 +1,18 @@
// Author: fruit.com.cn
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/formatting/count_format.dart';
void main() {
test('should_keep_raw_number_below_ten_thousand', () {
expect(formatInteractionCount(0), '0');
expect(formatInteractionCount(96), '96');
expect(formatInteractionCount(9999), '9999');
});
test('should_use_one_decimal_wan_from_ten_thousand', () {
expect(formatInteractionCount(10000), '1.0万');
expect(formatInteractionCount(12400), '1.2万');
expect(formatInteractionCount(123456), '12.3万');
});
}
+21 -2
View File
@@ -46,10 +46,18 @@ void main() {
}); });
test('should_parse_view_count_when_backend_sends_number', () { test('should_parse_view_count_when_backend_sends_number', () {
final drama = Drama.fromCardJson({'id': 5, 'title': 'T', 'viewCount': 1200}); final drama = Drama.fromCardJson({
'id': 5,
'title': 'T',
'viewCount': 1200,
});
expect(drama.id, '5'); expect(drama.id, '5');
expect(drama.viewCount, 1200); expect(drama.viewCount, 1200);
// 卡片没有互动数量时保持未知,界面才回退到文字标签。
expect(drama.likeCount, isNull);
expect(drama.favoriteCount, isNull);
expect(drama.commentCount, isNull);
}); });
test('should_parse_detail_payload_when_ids_are_strings', () { test('should_parse_detail_payload_when_ids_are_strings', () {
@@ -59,14 +67,25 @@ void main() {
'totalEpisodes': 12, 'totalEpisodes': 12,
'freeEpisodes': 3, 'freeEpisodes': 3,
'viewCount': '99', 'viewCount': '99',
'likeCount': '12400',
'favoriteCount': '3200',
'commentCount': '96',
'episodes': [ 'episodes': [
{'id': '2111012753262120963', 'number': 1, 'title': 'E1', 'isFree': true}, {
'id': '2111012753262120963',
'number': 1,
'title': 'E1',
'isFree': true,
},
], ],
}); });
expect(drama.id, '2111012753262120962'); expect(drama.id, '2111012753262120962');
expect(drama.freeEpisodes, 3); expect(drama.freeEpisodes, 3);
expect(drama.viewCount, 99); expect(drama.viewCount, 99);
expect(drama.likeCount, 12400);
expect(drama.favoriteCount, 3200);
expect(drama.commentCount, 96);
expect(drama.episodes.first.id, '2111012753262120963'); expect(drama.episodes.first.id, '2111012753262120963');
expect(drama.episodes.first.number, 1); expect(drama.episodes.first.number, 1);
}); });
+40 -1
View File
@@ -7,6 +7,7 @@ import 'package:shared_preferences_platform_interface/shared_preferences_async_p
import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/app.dart';
import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/interaction.dart';
import 'package:fruitshorts/core/data/models/play_ticket.dart'; import 'package:fruitshorts/core/data/models/play_ticket.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
@@ -285,6 +286,43 @@ void main() {
expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget); expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget);
}); });
testWidgets('should_show_rail_counts_from_detail_and_follow_server_results', (
tester,
) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.withData({
'auth.accessToken': 'test-access',
'auth.refreshToken': 'test-refresh',
'auth.user': '{"uid":"9","nickname":"Me","avatarUrl":""}',
});
await _pumpApp(
tester,
interactions: FakeInteractionRepository(
nextLikeResult: const LikeToggleResult(liked: true, count: 23000),
),
);
await _openSeriesPlayer(tester);
String railLabel(Key key) => tester
.widget<Text>(
find.descendant(of: find.byKey(key), matching: find.byType(Text)),
)
.data!;
expect(railLabel(const Key('player-rail-like')), '1.2万');
expect(railLabel(const Key('player-rail-save')), '3200');
expect(railLabel(const Key('player-rail-comments')), '96');
// 分享没有数量,仍是文字标签。
expect(railLabel(const Key('player-rail-share')), 'Share');
await tester.tap(find.byKey(const Key('player-rail-like')));
await tester.pumpAndSettle();
expect(railLabel(const Key('player-rail-like')), '2.3万');
await tester.tap(find.byKey(const Key('player-rail-save')));
await tester.pumpAndSettle();
expect(railLabel(const Key('player-rail-save')), '3201');
});
testWidgets('should_toggle_synopsis_with_inline_text_button', (tester) async { testWidgets('should_toggle_synopsis_with_inline_text_button', (tester) async {
await _pumpApp(tester); await _pumpApp(tester);
await _openSeriesPlayer(tester); await _openSeriesPlayer(tester);
@@ -784,6 +822,7 @@ Future<void> _pumpApp(
Size size = const Size(390, 844), Size size = const Size(390, 844),
FakeCatalogRepository? catalog, FakeCatalogRepository? catalog,
FakePlaybackRepository? playback, FakePlaybackRepository? playback,
FakeInteractionRepository? interactions,
}) async { }) async {
tester.view.physicalSize = size; tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
@@ -799,7 +838,7 @@ Future<void> _pumpApp(
favoriteRepository: InMemoryFavoriteRepository(cards: repository.dramas), favoriteRepository: InMemoryFavoriteRepository(cards: repository.dramas),
playbackRepository: playback ?? FakePlaybackRepository(), playbackRepository: playback ?? FakePlaybackRepository(),
commentRepository: FakeCommentRepository(), commentRepository: FakeCommentRepository(),
interactionRepository: FakeInteractionRepository(), interactionRepository: interactions ?? FakeInteractionRepository(),
noticeRepository: FakeNoticeRepository(), noticeRepository: FakeNoticeRepository(),
haptics: FakeHaptics(), haptics: FakeHaptics(),
), ),
+33 -20
View File
@@ -50,27 +50,40 @@ final List<Drama> demoDramas = [
'City Lights & Velvet Nights', 'City Lights & Velvet Nights',
'Urban Romance', 'Urban Romance',
format: 8, format: 8,
likeCount: 12400,
favoriteCount: 3200,
commentCount: 96,
), ),
_drama('vengeance-demo', 'Vengeance Rising', 'Action · Thriller', format: 15), _drama('vengeance-demo', 'Vengeance Rising', 'Action · Thriller', format: 15),
]; ];
Drama _drama(String id, String title, String genre, {required int format}) => Drama _drama(
Drama( String id,
id: id, String title,
title: title, String genre, {
synopsis: '$title synopsis', required int format,
posterUrl: '/files/$id.png', int? likeCount,
genre: genre, int? favoriteCount,
language: 'id', int? commentCount,
detailLoaded: true, }) => Drama(
episodes: [ id: id,
for (var index = 0; index < 7; index++) title: title,
Episode( synopsis: '$title synopsis',
id: '$id-${index + 1}', posterUrl: '/files/$id.png',
number: index + 1, genre: genre,
title: 'Episode ${index + 1}', language: 'id',
isFree: true, detailLoaded: true,
videoUrl: 'https://example.test/$format-$index.mp4', likeCount: likeCount,
), favoriteCount: favoriteCount,
], commentCount: commentCount,
); episodes: [
for (var index = 0; index < 7; index++)
Episode(
id: '$id-${index + 1}',
number: index + 1,
title: 'Episode ${index + 1}',
isFree: true,
videoUrl: 'https://example.test/$format-$index.mp4',
),
],
);