refactor(ui): shared watched indicator overlay
This commit is contained in:
@@ -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<FolderTreeItem> 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<Color>(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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<EpisodeCard> with ContextMenuTapMixin<Epis
|
||||
final shouldBlur = hideSpoilers && episode.shouldHideSpoiler;
|
||||
final qualityLabels = buildMediaQualityLabels(episode);
|
||||
|
||||
// Hide progress when offline (not tracked)
|
||||
final hasProgress =
|
||||
!widget.isOffline && episode.viewOffsetMs != null && episode.durationMs != null && episode.viewOffsetMs! > 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<EpisodeCard> with ContextMenuTapMixin<Epis
|
||||
),
|
||||
),
|
||||
|
||||
if (hasActiveProgress)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(6),
|
||||
bottomRight: Radius.circular(6),
|
||||
),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
backgroundColor: tokens(context).outline,
|
||||
minHeight: 3,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (episode.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: 12),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: WatchedIndicator(
|
||||
item: episode,
|
||||
size: WatchedIndicatorSize.compact,
|
||||
// Progress isn't tracked offline.
|
||||
progressAvailable: !widget.isOffline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -17,6 +17,7 @@ import '../providers/watch_state_store.dart';
|
||||
import '../services/download_storage_service.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import 'settings_builder.dart';
|
||||
import 'watched_indicator.dart';
|
||||
import '../utils/content_utils.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/formatters.dart';
|
||||
@@ -25,10 +26,8 @@ import '../utils/snackbar_helper.dart';
|
||||
import '../theme/mono_tokens.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
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;
|
||||
@@ -318,7 +317,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
|
||||
knownWidth: width,
|
||||
knownHeight: height,
|
||||
),
|
||||
if (item is MediaItem) _MediaCardHelpers.buildWatchProgress(context, item),
|
||||
if (item is MediaItem) WatchedIndicator(item: item),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -351,7 +350,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
|
||||
knownHeight: posterHeight,
|
||||
),
|
||||
),
|
||||
if (item is MediaItem) _MediaCardHelpers.buildWatchProgress(context, item),
|
||||
if (item is MediaItem) WatchedIndicator(item: item),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -617,7 +616,7 @@ class _MediaCardList extends StatelessWidget {
|
||||
episodePosterModeOverride: episodePosterModeOverride,
|
||||
),
|
||||
),
|
||||
if (item is MediaItem) _MediaCardHelpers.buildWatchProgress(context, item as MediaItem),
|
||||
if (item is MediaItem) WatchedIndicator(item: item as MediaItem),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -902,67 +901,6 @@ class _MediaCardHelpers {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
/// 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!;
|
||||
|
||||
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<Color>(Theme.of(context).colorScheme.primary),
|
||||
minHeight: 4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this media item has a clickable title that navigates somewhere.
|
||||
|
||||
@@ -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<Color>(Theme.of(context).colorScheme.primary),
|
||||
minHeight: size.barMinHeight,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user