diff --git a/lib/core/formatting/relative_date_time.dart b/lib/core/formatting/relative_date_time.dart new file mode 100644 index 0000000..1040824 --- /dev/null +++ b/lib/core/formatting/relative_date_time.dart @@ -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'); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 6affe0c..7fc3372 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -100,6 +100,13 @@ "count": {"type": "int"} } }, + "justNow": "Just now", + "hoursAgo": "{count} hours ago", + "@hoursAgo": { + "placeholders": { + "count": {"type": "int"} + } + }, "artworkOnly": "Artwork", "artworkOnlyNotice": "This is a visual catalog item. No video is available yet.", "videoPending": "Video pending", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 68f8893..24ab80c 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -79,6 +79,13 @@ "count": {"type": "int"} } }, + "justNow": "Baru saja", + "hoursAgo": "{count} jam lalu", + "@hoursAgo": { + "placeholders": { + "count": {"type": "int"} + } + }, "artworkOnly": "Gambar", "artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.", "videoPending": "Video belum siap", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 650b5bb..0e0842b 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -508,6 +508,18 @@ abstract class AppLocalizations { /// **'{count} min ago'** 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. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6437937..b043759 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -227,6 +227,14 @@ class AppLocalizationsEn extends AppLocalizations { return '$count min ago'; } + @override + String get justNow => 'Just now'; + + @override + String hoursAgo(int count) { + return '$count hours ago'; + } + @override String get artworkOnly => 'Artwork'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 5b3e823..ae57fa3 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -227,6 +227,14 @@ class AppLocalizationsId extends AppLocalizations { return '$count mnt lalu'; } + @override + String get justNow => 'Baru saja'; + + @override + String hoursAgo(int count) { + return '$count jam lalu'; + } + @override String get artworkOnly => 'Gambar'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 30037c8..b449f10 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -224,6 +224,14 @@ class AppLocalizationsZh extends AppLocalizations { return '$count 分钟前'; } + @override + String get justNow => '刚刚'; + + @override + String hoursAgo(int count) { + return '$count 小时前'; + } + @override String get artworkOnly => '海报展示'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 8ceaa1d..5b4b709 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -79,6 +79,13 @@ "count": {"type": "int"} } }, + "justNow": "刚刚", + "hoursAgo": "{count} 小时前", + "@hoursAgo": { + "placeholders": { + "count": {"type": "int"} + } + }, "artworkOnly": "海报展示", "artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。", "videoPending": "视频待提供", diff --git a/lib/pages/player/widgets/comment_tile.dart b/lib/pages/player/widgets/comment_tile.dart index 3b17b3c..d4016cb 100644 --- a/lib/pages/player/widgets/comment_tile.dart +++ b/lib/pages/player/widgets/comment_tile.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.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_dimens.dart'; import '../../../core/widgets/remote_image.dart'; @@ -164,7 +164,7 @@ class CommentTile extends StatelessWidget { children: [ if (createdAt != null) Text( - formatLocalizedDateTime(context, createdAt), + formatRelativeDateTime(createdAt, l10n), style: const TextStyle(color: AppColors.subtle, fontSize: 12), ), if (createdAt != null && !isReply) ...[ diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart index 51e6ab1..70d6c0c 100644 --- a/lib/pages/player/widgets/drama_comments_sheet.dart +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -43,18 +43,23 @@ class DramaCommentsSheet extends StatefulWidget { class _DramaCommentsSheetState extends State { final _input = TextEditingController(); final _inputFocus = FocusNode(); + late final CommentRepository _comments; late final CommentController _controller; DramaComment? _replyTo; + int? _serverCount; + int _countRequest = 0; @override void initState() { super.initState(); + _comments = context.read(); _controller = CommentController( - comments: context.read(), + comments: _comments, interactions: context.read(), dramaId: widget.drama.id, ); unawaited(_controller.load()); + unawaited(_refreshCount()); } @override @@ -76,6 +81,7 @@ class _DramaCommentsSheetState extends State { _input.clear(); setState(() => _replyTo = null); FocusScope.of(context).unfocus(); + await _refreshCount(); return; } _showMessage( @@ -122,11 +128,26 @@ class _DramaCommentsSheetState extends State { ); if (confirmed != true || !mounted) return; final result = await _controller.delete(comment); - if (!result.isSuccess && mounted) { + if (!result.isSuccess) { + if (!mounted) return; _showMessage( result.errorMessage ?? AppLocalizations.of(context)!.commentDeleteFailed, ); + return; + } + await _refreshCount(); + } + + Future _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 { Widget _content(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final count = _controller.exactCount; + final count = _serverCount ?? _controller.exactCount; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/test/comment/drama_comments_sheet_test.dart b/test/comment/drama_comments_sheet_test.dart index 4a256dc..fc8bf32 100644 --- a/test/comment/drama_comments_sheet_test.dart +++ b/test/comment/drama_comments_sheet_test.dart @@ -142,6 +142,45 @@ void main() { 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', ( tester, ) async { @@ -185,6 +224,7 @@ void main() { final repository = FakeCommentRepository( pages: [const CommentPage()], publishResult: commentFixture(id: '11', content: 'Nice demo', mine: true), + countResult: 1, ); await _pumpSheet(tester, repository: repository); @@ -197,6 +237,7 @@ void main() { await tester.pumpAndSettle(); expect(tester.widget(input).focusNode?.hasFocus, isFalse); + expect(repository.countCalls, ['5', '5']); }); testWidgets('should_use_polished_comment_sizes', (tester) async { diff --git a/test/formatting/relative_date_time_test.dart b/test/formatting/relative_date_time_test.dart new file mode 100644 index 0000000..6bcc9a5 --- /dev/null +++ b/test/formatting/relative_date_time_test.dart @@ -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', + ); + }); +} diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 4c809b0..9ee28af 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -198,11 +198,12 @@ void main() { await tester.tap(find.byKey(const Key('player-rail-comments'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-comments-sheet')), findsOneWidget); + expect(find.text('97 comments'), findsOneWidget); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); - expect(comments.countCalls, ['city-lights-demo']); + expect(comments.countCalls, ['city-lights-demo', 'city-lights-demo']); expect( tester .widget(find.byKey(const Key('player-rail-comments-count'))) diff --git a/test/support/fake_interaction_repositories.dart b/test/support/fake_interaction_repositories.dart index b96cc52..071a33b 100644 --- a/test/support/fake_interaction_repositories.dart +++ b/test/support/fake_interaction_repositories.dart @@ -42,7 +42,7 @@ class FakeCommentRepository implements CommentRepository { this.publishError, this.deleteError, this.pageError, - this.countResult = 0, + this.countResult, this.countError, }) : pages = pages ?? const []; @@ -51,7 +51,7 @@ class FakeCommentRepository implements CommentRepository { final Object? publishError; final Object? deleteError; final Object? pageError; - final int countResult; + final int? countResult; final Object? countError; final List pageCalls = []; final List publishCalls = []; @@ -85,7 +85,9 @@ class FakeCommentRepository implements CommentRepository { countCalls.add(dramaId); final error = countError; if (error != null) throw error; - return countResult; + final result = countResult; + if (result == null) throw StateError('comment count unavailable'); + return result; } @override