diff --git a/lib/screens/libraries/folder_tree_item.dart b/lib/screens/libraries/folder_tree_item.dart index 1180f04e..63eb3a32 100644 --- a/lib/screens/libraries/folder_tree_item.dart +++ b/lib/screens/libraries/folder_tree_item.dart @@ -21,6 +21,7 @@ import '../../widgets/media_context_menu.dart'; import '../../widgets/media_progress_bar.dart'; import '../../widgets/optimized_media_image.dart'; import '../../widgets/overlay_sheet.dart'; +import '../../widgets/unwatched_count_badge.dart'; import '../../theme/mono_tokens.dart'; import '../../i18n/strings.g.dart'; import '../../widgets/loading_indicator_box.dart'; @@ -406,24 +407,7 @@ class _FolderTreeItemState extends State with ContextMenuTapMixi (item.kind == MediaKind.show || item.kind == MediaKind.season) && unwatchedCount != null && unwatchedCount > 0) - Positioned( - top: 3, - right: 3, - child: Container( - width: 20, - height: 20, - decoration: BoxDecoration( - color: tokens(context).text, - shape: BoxShape.circle, - boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 4)], - ), - alignment: .center, - child: Text( - '$unwatchedCount', - style: TextStyle(color: tokens(context).bg, fontSize: 10, fontWeight: .bold), - ), - ), - ), + Positioned(top: 3, right: 3, child: UnwatchedCountBadge(count: unwatchedCount, size: 20, fontSize: 10)), // Progress bar if (hasActiveProgress) Positioned( diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index b94e3f67..928f779f 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -28,6 +28,7 @@ import 'media_context_menu.dart'; import 'media_progress_bar.dart'; import 'media_card_list_layout.dart'; import 'backend_badge.dart'; +import 'unwatched_count_badge.dart'; import 'optimized_media_image.dart'; const _failedPosterUrlCacheLimit = 512; @@ -904,6 +905,7 @@ class _MediaCardHelpers { /// Builds watch progress overlay (checkmark for watched, progress bar for in-progress) static Widget buildWatchProgress(BuildContext context, MediaItem mi) { final showUnwatchedCount = SettingsService.instance.read(SettingsService.showUnwatchedCount); + final unwatched = mi.unwatchedCount; final hasActiveProgress = mi.viewOffsetMs != null && mi.durationMs != null && mi.viewOffsetMs! > 0 && mi.viewOffsetMs! < mi.durationMs!; @@ -928,25 +930,9 @@ class _MediaCardHelpers { if (showUnwatchedCount && !mi.isWatched && (mi.kind == MediaKind.show || mi.kind == MediaKind.season) && - (mi.leafCount != null && mi.leafCount! > 0 && mi.viewedLeafCount != null)) - Positioned( - top: 4, - right: 4, - child: Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: tokens(context).text, - shape: BoxShape.circle, - boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 4)], - ), - alignment: .center, - child: Text( - '${mi.leafCount! - mi.viewedLeafCount!}', - style: TextStyle(color: tokens(context).bg, fontSize: 12, fontWeight: .bold), - ), - ), - ), + unwatched != null && + unwatched > 0) + Positioned(top: 4, right: 4, child: UnwatchedCountBadge(count: unwatched)), // Progress bar for partially watched content (episodes/movies) if (hasActiveProgress) Positioned( diff --git a/lib/widgets/unwatched_count_badge.dart b/lib/widgets/unwatched_count_badge.dart new file mode 100644 index 00000000..6c67f42a --- /dev/null +++ b/lib/widgets/unwatched_count_badge.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +import '../theme/mono_tokens.dart'; + +/// Unwatched-episode count chip shown in the top-right corner of poster +/// cards. Keeps a [size]-diameter circular footprint for 1–2 digit counts +/// and widens into a stadium pill beyond that; counts above 999 render as +/// "999+" so the label stays on one line (#1310). +class UnwatchedCountBadge extends StatelessWidget { + final int count; + final double size; + final double fontSize; + + const UnwatchedCountBadge({super.key, required this.count, this.size = 24, this.fontSize = 12}); + + @override + Widget build(BuildContext context) { + return Container( + height: size, + constraints: BoxConstraints(minWidth: size), + padding: EdgeInsets.symmetric(horizontal: size * 0.2), + decoration: BoxDecoration( + color: tokens(context).text, + borderRadius: BorderRadius.circular(size / 2), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 4)], + ), + // Center with widthFactor shrink-wraps the pill to the label (Container + // alignment would expand into the Stack's loose width instead). + child: Center( + widthFactor: 1, + child: Text( + count > 999 ? '999+' : '$count', + maxLines: 1, + softWrap: false, + style: TextStyle(color: tokens(context).bg, fontSize: fontSize, fontWeight: .bold), + ), + ), + ); + } +} diff --git a/test/widgets/unwatched_count_badge_test.dart b/test/widgets/unwatched_count_badge_test.dart new file mode 100644 index 00000000..3ac28bb0 --- /dev/null +++ b/test/widgets/unwatched_count_badge_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/widgets/unwatched_count_badge.dart'; + +void main() { + Future pumpBadge(WidgetTester tester, int count) { + return tester.pumpWidget( + MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold( + body: Center(child: UnwatchedCountBadge(count: count)), + ), + ), + ); + } + + testWidgets('single digit keeps circular footprint', (tester) async { + await pumpBadge(tester, 5); + expect(find.text('5'), findsOneWidget); + expect(tester.getSize(find.byType(UnwatchedCountBadge)), const Size(24, 24)); + }); + + testWidgets('counts above 999 cap at 999+ on a single line', (tester) async { + await pumpBadge(tester, 1200); + expect(find.text('999+'), findsOneWidget); + + final badge = tester.getSize(find.byType(UnwatchedCountBadge)); + expect(badge.height, 24); + expect(badge.width, greaterThan(24)); + // Wide labels widen the pill instead of wrapping (#1310): the text stays + // one line tall and inside the badge. + expect(tester.getSize(find.text('999+')).height, lessThanOrEqualTo(24)); + expect(tester.takeException(), isNull); + }); + + testWidgets('999 renders uncapped', (tester) async { + await pumpBadge(tester, 999); + expect(find.text('999'), findsOneWidget); + }); +}