153 lines
5.0 KiB
Dart
153 lines
5.0 KiB
Dart
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 (viewed leaves / total leaves).
|
|
if (item.isSeason && item.isPartiallyWatched)
|
|
Positioned(
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
child: ClipRRect(
|
|
borderRadius: barRadius,
|
|
child: LinearProgressIndicator(
|
|
value: item.leafWatchFraction,
|
|
backgroundColor: tokens(context).outline,
|
|
valueColor: AlwaysStoppedAnimation<Color>(Theme.of(context).colorScheme.primary),
|
|
minHeight: size.barMinHeight,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|