From 431b0f9b0cf7e5f03c3b3c06f3a36b28ed1e6489 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 12 Jun 2026 13:56:47 +0200 Subject: [PATCH] refactor(ui): shared watched indicator overlay --- lib/screens/libraries/folder_tree_item.dart | 65 +--------- lib/widgets/episode_card.dart | 46 ++----- lib/widgets/media_card.dart | 70 +--------- lib/widgets/watched_indicator.dart | 136 ++++++++++++++++++++ 4 files changed, 154 insertions(+), 163 deletions(-) create mode 100644 lib/widgets/watched_indicator.dart diff --git a/lib/screens/libraries/folder_tree_item.dart b/lib/screens/libraries/folder_tree_item.dart index 63eb3a32..d0a50493 100644 --- a/lib/screens/libraries/folder_tree_item.dart +++ b/lib/screens/libraries/folder_tree_item.dart @@ -18,10 +18,9 @@ import '../../utils/formatters.dart'; import '../../utils/provider_extensions.dart'; import '../../widgets/app_menu.dart'; 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 '../../widgets/watched_indicator.dart'; import '../../theme/mono_tokens.dart'; import '../../i18n/strings.g.dart'; import '../../widgets/loading_indicator_box.dart'; @@ -379,63 +378,11 @@ class _FolderTreeItemState extends State with ContextMenuTapMixi } Widget _buildWatchOverlay(BuildContext context, bool showUnwatchedCount) { - // Shadows the field with the session-fresh view; everything below reads it. - final item = _effectiveItem(context); - final hasActiveProgress = item.hasActiveProgress; - final unwatchedCount = item.unwatchedCount; - - return Stack( - children: [ - // Watched checkmark - if (item.isWatched && !hasActiveProgress) - Positioned( - top: 3, - right: 3, - child: Container( - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - color: tokens(context).text, - shape: BoxShape.circle, - boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 4)], - ), - child: AppIcon(Symbols.check_rounded, fill: 1, color: tokens(context).bg, size: 12), - ), - ), - // Unwatched count for shows/seasons - if (showUnwatchedCount && - !item.isWatched && - (item.kind == MediaKind.show || item.kind == MediaKind.season) && - unwatchedCount != null && - unwatchedCount > 0) - Positioned(top: 3, right: 3, child: UnwatchedCountBadge(count: unwatchedCount, size: 20, fontSize: 10)), - // Progress bar - if (hasActiveProgress) - Positioned( - bottom: 0, - left: 0, - right: 0, - child: ClipRRect( - borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(6), bottomRight: Radius.circular(6)), - child: MediaProgressBar(viewOffset: item.viewOffsetMs!, duration: item.durationMs!), - ), - ), - // Season progress - if (item.isSeason && item.isPartiallyWatched) - Positioned( - bottom: 0, - left: 0, - right: 0, - child: ClipRRect( - borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(6), bottomRight: Radius.circular(6)), - child: LinearProgressIndicator( - value: item.viewedLeafCount! / item.leafCount!, - backgroundColor: tokens(context).outline, - valueColor: AlwaysStoppedAnimation(Theme.of(context).colorScheme.primary), - minHeight: 3, - ), - ), - ), - ], + return WatchedIndicator( + // Session-fresh view of the item so the overlay reflects live patches. + item: _effectiveItem(context), + size: WatchedIndicatorSize.compact, + showUnwatchedCount: showUnwatchedCount, ); } diff --git a/lib/widgets/episode_card.dart b/lib/widgets/episode_card.dart index d0edddd8..1962bdde 100644 --- a/lib/widgets/episode_card.dart +++ b/lib/widgets/episode_card.dart @@ -17,6 +17,7 @@ import '../media/media_item.dart'; import '../media/media_item_types.dart'; import '../widgets/collapsible_text.dart'; import '../widgets/download_status_icon.dart'; +import '../widgets/watched_indicator.dart'; import '../widgets/optimized_media_image.dart'; import '../utils/platform_detector.dart'; import '../utils/formatters.dart'; @@ -126,13 +127,6 @@ class _EpisodeCardState extends State with ContextMenuTapMixin 0; - final progress = hasProgress ? episode.viewOffsetMs! / episode.durationMs! : 0.0; - - final hasActiveProgress = hasProgress && episode.viewOffsetMs! < episode.durationMs!; - return Padding( padding: const EdgeInsets.symmetric(vertical: 2), // MergeSemantics: one node per card instead of one per text/progress — @@ -220,38 +214,14 @@ class _EpisodeCardState extends State with ContextMenuTapMixin with ContextMenuTapMixin with ContextMenuTapMixin 0 && mi.viewOffsetMs! < mi.durationMs!; - - return Stack( - children: [ - // Watched indicator (checkmark) - if (mi.isWatched && !hasActiveProgress) - Positioned( - top: 4, - right: 4, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: tokens(context).text, - shape: BoxShape.circle, - boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 4)], - ), - child: AppIcon(Symbols.check_rounded, fill: 1, color: tokens(context).bg, size: 16), - ), - ), - if (showUnwatchedCount && - !mi.isWatched && - (mi.kind == MediaKind.show || mi.kind == MediaKind.season) && - unwatched != null && - unwatched > 0) - Positioned(top: 4, right: 4, child: UnwatchedCountBadge(count: unwatched)), - // Progress bar for partially watched content (episodes/movies) - if (hasActiveProgress) - Positioned( - bottom: 0, - left: 0, - right: 0, - child: ClipRRect( - borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(8), bottomRight: Radius.circular(8)), - child: MediaProgressBar(viewOffset: mi.viewOffsetMs!, duration: mi.durationMs!), - ), - ), - // Progress bar for seasons (viewedLeafCount / leafCount) - if (mi.isSeason && mi.isPartiallyWatched) - Positioned( - bottom: 0, - left: 0, - right: 0, - child: ClipRRect( - borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(8), bottomRight: Radius.circular(8)), - child: LinearProgressIndicator( - value: mi.viewedLeafCount! / mi.leafCount!, - backgroundColor: tokens(context).outline, - valueColor: AlwaysStoppedAnimation(Theme.of(context).colorScheme.primary), - minHeight: 4, - ), - ), - ), - ], - ); - } } /// Whether this media item has a clickable title that navigates somewhere. diff --git a/lib/widgets/watched_indicator.dart b/lib/widgets/watched_indicator.dart new file mode 100644 index 00000000..d1081e35 --- /dev/null +++ b/lib/widgets/watched_indicator.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../media/media_item.dart'; +import '../media/media_item_types.dart'; +import '../media/media_kind.dart'; +import '../services/settings_service.dart'; +import '../theme/mono_tokens.dart'; +import 'app_icon.dart'; +import 'media_progress_bar.dart'; +import 'unwatched_count_badge.dart'; + +/// Size preset for [WatchedIndicator]: [standard] for grid/poster cards, +/// [compact] for dense surfaces (folder tree rows, episode thumbnails). +/// Add a preset here instead of hand-rolling a new overlay variant. +enum WatchedIndicatorSize { + standard(checkInset: 4, checkPadding: 4, checkIconSize: 16, badgeSize: 24, badgeFontSize: 12, barRadius: 8, barMinHeight: 4), + compact(checkInset: 3, checkPadding: 2, checkIconSize: 12, badgeSize: 20, badgeFontSize: 10, barRadius: 6, barMinHeight: 3); + + const WatchedIndicatorSize({ + required this.checkInset, + required this.checkPadding, + required this.checkIconSize, + required this.badgeSize, + required this.badgeFontSize, + required this.barRadius, + required this.barMinHeight, + }); + + final double checkInset; + final double checkPadding; + final double checkIconSize; + final double badgeSize; + final double badgeFontSize; + final double barRadius; + final double barMinHeight; +} + +/// Watched/progress overlay for media artwork: watched checkmark, +/// unwatched-count pill (shows/seasons), active-progress bar, and season +/// completion bar. The single implementation behind every surface that +/// stamps watch state onto a poster/thumbnail. +class WatchedIndicator extends StatelessWidget { + final MediaItem item; + final WatchedIndicatorSize size; + + /// Overrides the settings read for the unwatched-count pill — pass it when + /// the caller already watches [SettingsService.showUnwatchedCount] so pill + /// visibility updates reactively with the caller's rebuilds. + final bool? showUnwatchedCount; + + /// When false, in-progress state is ignored (no bar; checkmark still shown + /// for watched items) — pass false where progress isn't tracked (offline). + final bool progressAvailable; + + const WatchedIndicator({ + super.key, + required this.item, + this.size = WatchedIndicatorSize.standard, + this.showUnwatchedCount, + this.progressAvailable = true, + }); + + @override + Widget build(BuildContext context) { + final bool showCount = showUnwatchedCount ?? SettingsService.instance.read(SettingsService.showUnwatchedCount); + final hasActiveProgress = progressAvailable && item.hasActiveProgress; + final unwatched = item.unwatchedCount; + final barRadius = BorderRadius.only( + bottomLeft: Radius.circular(size.barRadius), + bottomRight: Radius.circular(size.barRadius), + ); + + return Stack( + children: [ + // Watched checkmark + if (item.isWatched && !hasActiveProgress) + Positioned( + top: size.checkInset, + right: size.checkInset, + child: Container( + padding: EdgeInsets.all(size.checkPadding), + decoration: BoxDecoration( + color: tokens(context).text, + shape: BoxShape.circle, + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 4)], + ), + child: AppIcon(Symbols.check_rounded, fill: 1, color: tokens(context).bg, size: size.checkIconSize), + ), + ), + // Unwatched count for shows/seasons + if (showCount && + !item.isWatched && + (item.kind == MediaKind.show || item.kind == MediaKind.season) && + unwatched != null && + unwatched > 0) + Positioned( + top: size.checkInset, + right: size.checkInset, + child: UnwatchedCountBadge(count: unwatched, size: size.badgeSize, fontSize: size.badgeFontSize), + ), + // Progress bar for partially watched content (episodes/movies) + if (hasActiveProgress) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: ClipRRect( + borderRadius: barRadius, + child: MediaProgressBar( + viewOffset: item.viewOffsetMs!, + duration: item.durationMs!, + minHeight: size.barMinHeight, + ), + ), + ), + // Progress bar for seasons (viewedLeafCount / leafCount) + if (item.isSeason && item.isPartiallyWatched) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: ClipRRect( + borderRadius: barRadius, + child: LinearProgressIndicator( + value: item.viewedLeafCount! / item.leafCount!, + backgroundColor: tokens(context).outline, + valueColor: AlwaysStoppedAnimation(Theme.of(context).colorScheme.primary), + minHeight: size.barMinHeight, + ), + ), + ), + ], + ); + } +}