fix(player): 评论总数含回复并改用相对时间
This commit is contained in:
1 parent
22cb74ded4
commit
2659c29501
14 files changed
+212
-9
No files matched your search
@@ -0,0 +1,24 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
/// Formats a timestamp as a relative label, falling back to a compact local date.
|
||||||
|
String formatRelativeDateTime(
|
||||||
|
DateTime value,
|
||||||
|
AppLocalizations l10n, {
|
||||||
|
DateTime? now,
|
||||||
|
}) {
|
||||||
|
final localValue = value.toLocal();
|
||||||
|
final localNow = (now ?? DateTime.now()).toLocal();
|
||||||
|
final elapsed = localNow.difference(localValue);
|
||||||
|
if (elapsed.isNegative || elapsed.inMinutes < 1) return l10n.justNow;
|
||||||
|
if (elapsed.inMinutes < 60) return l10n.minutesAgo(elapsed.inMinutes);
|
||||||
|
if (elapsed.inHours < 24) return l10n.hoursAgo(elapsed.inHours);
|
||||||
|
if (localValue.year == localNow.year) {
|
||||||
|
return '${_twoDigits(localValue.month)}-${_twoDigits(localValue.day)}';
|
||||||
|
}
|
||||||
|
return '${localValue.year}-${_twoDigits(localValue.month)}-'
|
||||||
|
'${_twoDigits(localValue.day)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _twoDigits(int value) => value.toString().padLeft(2, '0');
|
||||||
@@ -100,6 +100,13 @@
|
|||||||
"count": {"type": "int"}
|
"count": {"type": "int"}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"justNow": "Just now",
|
||||||
|
"hoursAgo": "{count} hours ago",
|
||||||
|
"@hoursAgo": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
"artworkOnly": "Artwork",
|
"artworkOnly": "Artwork",
|
||||||
"artworkOnlyNotice": "This is a visual catalog item. No video is available yet.",
|
"artworkOnlyNotice": "This is a visual catalog item. No video is available yet.",
|
||||||
"videoPending": "Video pending",
|
"videoPending": "Video pending",
|
||||||
|
|||||||
@@ -79,6 +79,13 @@
|
|||||||
"count": {"type": "int"}
|
"count": {"type": "int"}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"justNow": "Baru saja",
|
||||||
|
"hoursAgo": "{count} jam lalu",
|
||||||
|
"@hoursAgo": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
"artworkOnly": "Gambar",
|
"artworkOnly": "Gambar",
|
||||||
"artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.",
|
"artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.",
|
||||||
"videoPending": "Video belum siap",
|
"videoPending": "Video belum siap",
|
||||||
|
|||||||
@@ -508,6 +508,18 @@ abstract class AppLocalizations {
|
|||||||
/// **'{count} min ago'**
|
/// **'{count} min ago'**
|
||||||
String minutesAgo(int count);
|
String minutesAgo(int count);
|
||||||
|
|
||||||
|
/// No description provided for @justNow.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Just now'**
|
||||||
|
String get justNow;
|
||||||
|
|
||||||
|
/// No description provided for @hoursAgo.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count} hours ago'**
|
||||||
|
String hoursAgo(int count);
|
||||||
|
|
||||||
/// No description provided for @artworkOnly.
|
/// No description provided for @artworkOnly.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -227,6 +227,14 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
return '$count min ago';
|
return '$count min ago';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get justNow => 'Just now';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String hoursAgo(int count) {
|
||||||
|
return '$count hours ago';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get artworkOnly => 'Artwork';
|
String get artworkOnly => 'Artwork';
|
||||||
|
|
||||||
|
|||||||
@@ -227,6 +227,14 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
return '$count mnt lalu';
|
return '$count mnt lalu';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get justNow => 'Baru saja';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String hoursAgo(int count) {
|
||||||
|
return '$count jam lalu';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get artworkOnly => 'Gambar';
|
String get artworkOnly => 'Gambar';
|
||||||
|
|
||||||
|
|||||||
@@ -224,6 +224,14 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
return '$count 分钟前';
|
return '$count 分钟前';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get justNow => '刚刚';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String hoursAgo(int count) {
|
||||||
|
return '$count 小时前';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get artworkOnly => '海报展示';
|
String get artworkOnly => '海报展示';
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,13 @@
|
|||||||
"count": {"type": "int"}
|
"count": {"type": "int"}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"justNow": "刚刚",
|
||||||
|
"hoursAgo": "{count} 小时前",
|
||||||
|
"@hoursAgo": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
"artworkOnly": "海报展示",
|
"artworkOnly": "海报展示",
|
||||||
"artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。",
|
"artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。",
|
||||||
"videoPending": "视频待提供",
|
"videoPending": "视频待提供",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../../core/data/models/comment.dart';
|
import '../../../core/data/models/comment.dart';
|
||||||
import '../../../core/formatting/localized_date_time.dart';
|
import '../../../core/formatting/relative_date_time.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';
|
||||||
import '../../../core/widgets/remote_image.dart';
|
import '../../../core/widgets/remote_image.dart';
|
||||||
@@ -164,7 +164,7 @@ class CommentTile extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
if (createdAt != null)
|
if (createdAt != null)
|
||||||
Text(
|
Text(
|
||||||
formatLocalizedDateTime(context, createdAt),
|
formatRelativeDateTime(createdAt, l10n),
|
||||||
style: const TextStyle(color: AppColors.subtle, fontSize: 12),
|
style: const TextStyle(color: AppColors.subtle, fontSize: 12),
|
||||||
),
|
),
|
||||||
if (createdAt != null && !isReply) ...[
|
if (createdAt != null && !isReply) ...[
|
||||||
|
|||||||
@@ -43,18 +43,23 @@ class DramaCommentsSheet extends StatefulWidget {
|
|||||||
class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
|
class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
|
||||||
final _input = TextEditingController();
|
final _input = TextEditingController();
|
||||||
final _inputFocus = FocusNode();
|
final _inputFocus = FocusNode();
|
||||||
|
late final CommentRepository _comments;
|
||||||
late final CommentController _controller;
|
late final CommentController _controller;
|
||||||
DramaComment? _replyTo;
|
DramaComment? _replyTo;
|
||||||
|
int? _serverCount;
|
||||||
|
int _countRequest = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_comments = context.read<CommentRepository>();
|
||||||
_controller = CommentController(
|
_controller = CommentController(
|
||||||
comments: context.read<CommentRepository>(),
|
comments: _comments,
|
||||||
interactions: context.read<InteractionRepository>(),
|
interactions: context.read<InteractionRepository>(),
|
||||||
dramaId: widget.drama.id,
|
dramaId: widget.drama.id,
|
||||||
);
|
);
|
||||||
unawaited(_controller.load());
|
unawaited(_controller.load());
|
||||||
|
unawaited(_refreshCount());
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -76,6 +81,7 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
|
|||||||
_input.clear();
|
_input.clear();
|
||||||
setState(() => _replyTo = null);
|
setState(() => _replyTo = null);
|
||||||
FocusScope.of(context).unfocus();
|
FocusScope.of(context).unfocus();
|
||||||
|
await _refreshCount();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_showMessage(
|
_showMessage(
|
||||||
@@ -122,11 +128,26 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
|
|||||||
);
|
);
|
||||||
if (confirmed != true || !mounted) return;
|
if (confirmed != true || !mounted) return;
|
||||||
final result = await _controller.delete(comment);
|
final result = await _controller.delete(comment);
|
||||||
if (!result.isSuccess && mounted) {
|
if (!result.isSuccess) {
|
||||||
|
if (!mounted) return;
|
||||||
_showMessage(
|
_showMessage(
|
||||||
result.errorMessage ??
|
result.errorMessage ??
|
||||||
AppLocalizations.of(context)!.commentDeleteFailed,
|
AppLocalizations.of(context)!.commentDeleteFailed,
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _refreshCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshCount() async {
|
||||||
|
final request = ++_countRequest;
|
||||||
|
try {
|
||||||
|
final count = await _comments.count(widget.drama.id);
|
||||||
|
if (!mounted || request != _countRequest) return;
|
||||||
|
setState(() => _serverCount = count);
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted || request != _countRequest) return;
|
||||||
|
setState(() => _serverCount = null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +211,7 @@ 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;
|
final count = _serverCount ?? _controller.exactCount;
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -142,6 +142,45 @@ void main() {
|
|||||||
expect(repository.pageCalls.last.cursor, '10');
|
expect(repository.pageCalls.last.cursor, '10');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('should_show_server_total_that_includes_replies', (tester) async {
|
||||||
|
final repository = FakeCommentRepository(
|
||||||
|
pages: [
|
||||||
|
CommentPage(
|
||||||
|
comments: [
|
||||||
|
commentFixture(id: '10', content: 'Root one'),
|
||||||
|
commentFixture(id: '11', content: 'Root two'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
countResult: 9,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _pumpSheet(tester, repository: repository);
|
||||||
|
|
||||||
|
expect(find.text('9 comments'), findsOneWidget);
|
||||||
|
expect(repository.countCalls, ['5']);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_fall_back_to_exact_count_when_server_total_fails', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final repository = FakeCommentRepository(
|
||||||
|
pages: [
|
||||||
|
CommentPage(
|
||||||
|
comments: [
|
||||||
|
commentFixture(id: '10', content: 'Root one'),
|
||||||
|
commentFixture(id: '11', content: 'Root two'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
countError: StateError('offline'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _pumpSheet(tester, repository: repository);
|
||||||
|
|
||||||
|
expect(find.text('2 comments'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('should_show_compact_empty_state_when_no_comments_exist', (
|
testWidgets('should_show_compact_empty_state_when_no_comments_exist', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@@ -185,6 +224,7 @@ void main() {
|
|||||||
final repository = FakeCommentRepository(
|
final repository = FakeCommentRepository(
|
||||||
pages: [const CommentPage()],
|
pages: [const CommentPage()],
|
||||||
publishResult: commentFixture(id: '11', content: 'Nice demo', mine: true),
|
publishResult: commentFixture(id: '11', content: 'Nice demo', mine: true),
|
||||||
|
countResult: 1,
|
||||||
);
|
);
|
||||||
|
|
||||||
await _pumpSheet(tester, repository: repository);
|
await _pumpSheet(tester, repository: repository);
|
||||||
@@ -197,6 +237,7 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(tester.widget<TextField>(input).focusNode?.hasFocus, isFalse);
|
expect(tester.widget<TextField>(input).focusNode?.hasFocus, isFalse);
|
||||||
|
expect(repository.countCalls, ['5', '5']);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should_use_polished_comment_sizes', (tester) async {
|
testWidgets('should_use_polished_comment_sizes', (tester) async {
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fruitshorts/core/formatting/relative_date_time.dart';
|
||||||
|
import 'package:fruitshorts/l10n/app_localizations_en.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final l10n = AppLocalizationsEn();
|
||||||
|
final now = DateTime(2026, 10, 21, 12);
|
||||||
|
|
||||||
|
test('should_show_just_now_under_one_minute', () {
|
||||||
|
expect(
|
||||||
|
formatRelativeDateTime(
|
||||||
|
now.subtract(const Duration(seconds: 59)),
|
||||||
|
l10n,
|
||||||
|
now: now,
|
||||||
|
),
|
||||||
|
'Just now',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should_use_minutes_below_sixty_minutes', () {
|
||||||
|
expect(
|
||||||
|
formatRelativeDateTime(
|
||||||
|
now.subtract(const Duration(minutes: 59)),
|
||||||
|
l10n,
|
||||||
|
now: now,
|
||||||
|
),
|
||||||
|
'59 min ago',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should_use_hours_below_twenty_four_hours', () {
|
||||||
|
expect(
|
||||||
|
formatRelativeDateTime(
|
||||||
|
now.subtract(const Duration(hours: 23)),
|
||||||
|
l10n,
|
||||||
|
now: now,
|
||||||
|
),
|
||||||
|
'23 hours ago',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should_use_month_and_day_for_same_year', () {
|
||||||
|
expect(
|
||||||
|
formatRelativeDateTime(DateTime(2026, 9, 3), l10n, now: now),
|
||||||
|
'09-03',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should_use_full_date_for_previous_year', () {
|
||||||
|
expect(
|
||||||
|
formatRelativeDateTime(DateTime(2025, 12, 31), l10n, now: now),
|
||||||
|
'2025-12-31',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -198,11 +198,12 @@ void main() {
|
|||||||
await tester.tap(find.byKey(const Key('player-rail-comments')));
|
await tester.tap(find.byKey(const Key('player-rail-comments')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(const Key('player-comments-sheet')), findsOneWidget);
|
expect(find.byKey(const Key('player-comments-sheet')), findsOneWidget);
|
||||||
|
expect(find.text('97 comments'), findsOneWidget);
|
||||||
|
|
||||||
await tester.binding.handlePopRoute();
|
await tester.binding.handlePopRoute();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(comments.countCalls, ['city-lights-demo']);
|
expect(comments.countCalls, ['city-lights-demo', 'city-lights-demo']);
|
||||||
expect(
|
expect(
|
||||||
tester
|
tester
|
||||||
.widget<Text>(find.byKey(const Key('player-rail-comments-count')))
|
.widget<Text>(find.byKey(const Key('player-rail-comments-count')))
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class FakeCommentRepository implements CommentRepository {
|
|||||||
this.publishError,
|
this.publishError,
|
||||||
this.deleteError,
|
this.deleteError,
|
||||||
this.pageError,
|
this.pageError,
|
||||||
this.countResult = 0,
|
this.countResult,
|
||||||
this.countError,
|
this.countError,
|
||||||
}) : pages = pages ?? const [];
|
}) : pages = pages ?? const [];
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ class FakeCommentRepository implements CommentRepository {
|
|||||||
final Object? publishError;
|
final Object? publishError;
|
||||||
final Object? deleteError;
|
final Object? deleteError;
|
||||||
final Object? pageError;
|
final Object? pageError;
|
||||||
final int countResult;
|
final int? countResult;
|
||||||
final Object? countError;
|
final Object? countError;
|
||||||
final List<CommentPageCall> pageCalls = [];
|
final List<CommentPageCall> pageCalls = [];
|
||||||
final List<PublishCall> publishCalls = [];
|
final List<PublishCall> publishCalls = [];
|
||||||
@@ -85,7 +85,9 @@ class FakeCommentRepository implements CommentRepository {
|
|||||||
countCalls.add(dramaId);
|
countCalls.add(dramaId);
|
||||||
final error = countError;
|
final error = countError;
|
||||||
if (error != null) throw error;
|
if (error != null) throw error;
|
||||||
return countResult;
|
final result = countResult;
|
||||||
|
if (result == null) throw StateError('comment count unavailable');
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
Reference in new issue
Block a user