refactor(channel_card): simplify ChannelCard structure and enhance hover effects

This commit is contained in:
GitHub Copilot committed 2026-03-26 11:15:30 +01:00
1 parent 2ca0c8bf1a
commit 5b16029038
2 files changed
+160 -770

No files matched your search

+1 -297
View File
@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../theme/app_colors.dart';
import '../theme/app_theme.dart';
import 'glass_container.dart';
class HeroCarouselItem {
final String id;
@@ -27,14 +26,6 @@ class HeroCarouselItem {
});
}
/// Modern Apple TV Hero Carousel
///
/// Premium carousel showcasing featured content with:
/// - Large background images
/// - Smooth transitions
/// - Interactive play buttons
/// - Animated indicators
/// - Focus-aware scaling
class HeroCarousel extends StatefulWidget {
final List<HeroCarouselItem> items;
final bool autoPlay;
@@ -130,7 +121,6 @@ class _HeroCarouselState extends State<HeroCarousel>
child: Stack(
fit: StackFit.expand,
children: [
// ============ PAGE VIEW ============
PageView.builder(
controller: _pageController,
onPageChanged: (index) {
@@ -142,8 +132,6 @@ class _HeroCarouselState extends State<HeroCarousel>
return _buildCarouselPage(widget.items[index]);
},
),
// ============ GRADIENT OVERLAY (Bottom) ============
Positioned(
bottom: 0,
left: 0,
@@ -163,8 +151,6 @@ class _HeroCarouselState extends State<HeroCarousel>
),
),
),
// ============ CONTENT OVERLAY ============
Positioned(
bottom: 32,
left: 32,
@@ -174,8 +160,6 @@ class _HeroCarouselState extends State<HeroCarousel>
child: _buildContentOverlay(widget.items[_currentIndex]),
),
),
// ============ INDICATORS ============
Positioned(
bottom: 20,
right: 32,
@@ -187,12 +171,10 @@ class _HeroCarouselState extends State<HeroCarousel>
);
}
/// Build carousel page
Widget _buildCarouselPage(HeroCarouselItem item) {
return Stack(
fit: StackFit.expand,
children: [
// Background image
if (item.imageUrl != null && item.imageUrl!.isNotEmpty)
Image.network(
item.imageUrl!,
@@ -207,11 +189,7 @@ class _HeroCarouselState extends State<HeroCarousel>
},
)
else
Container(
color: AppColors.surface,
),
// Image overlay
Container(color: AppColors.surface),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
@@ -228,13 +206,11 @@ class _HeroCarouselState extends State<HeroCarousel>
);
}
/// Build content overlay
Widget _buildContentOverlay(HeroCarouselItem item) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Badge
if (item.badge != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
@@ -252,10 +228,7 @@ class _HeroCarouselState extends State<HeroCarousel>
),
),
),
const SizedBox(height: 12),
// Title
Text(
item.title,
maxLines: 2,
@@ -268,7 +241,6 @@ class _HeroCarouselState extends State<HeroCarousel>
height: 1.1,
),
),
if (item.subtitle != null) ...[
const SizedBox(height: 12),
Text(
@@ -283,14 +255,10 @@ class _HeroCarouselState extends State<HeroCarousel>
),
),
],
const SizedBox(height: 24),
// Action buttons
if (item.onPlay != null || item.onTap != null)
Row(
children: [
// Play button
if (item.onPlay != null)
AnimatedScale(
scale: _isHovered ? 1.05 : 1.0,
@@ -332,10 +300,7 @@ class _HeroCarouselState extends State<HeroCarousel>
),
),
),
const SizedBox(width: 16),
// More info button
if (item.onTap != null)
GestureDetector(
onTap: item.onTap,
@@ -376,7 +341,6 @@ class _HeroCarouselState extends State<HeroCarousel>
);
}
/// Build carousel indicators
Widget _buildIndicators() {
return Row(
mainAxisSize: MainAxisSize.min,
@@ -407,264 +371,4 @@ class _HeroCarouselState extends State<HeroCarousel>
),
);
}
}
}
}
void _startAutoPlay() {
_timer = Timer.periodic(const Duration(seconds: 8), (timer) {
if (_currentIndex < widget.items.length - 1) {
_currentIndex++;
} else {
_currentIndex = 0;
}
if (_pageController.hasClients) {
_pageController.animateToPage(
_currentIndex,
duration: const Duration(milliseconds: 800),
curve: Curves.fastOutSlowIn,
);
}
});
}
@override
void dispose() {
_timer?.cancel();
_pageController.dispose();
super.dispose();
}
String? _getProxiedUrl(String? url) {
if (url == null || url.isEmpty) return null;
if (url.startsWith('http')) {
return '/api/xtream/$url';
}
return url;
}
@override
Widget build(BuildContext context) {
if (widget.items.isEmpty) return const SizedBox.shrink();
return SizedBox(
height: 500, // Cinematic height
child: Stack(
children: [
// Carousel
PageView.builder(
controller: _pageController,
onPageChanged: (index) {
setState(() => _currentIndex = index);
},
itemCount: widget.items.length,
itemBuilder: (context, index) {
final item = widget.items[index];
return _buildHeroSlide(item);
},
),
// Indicators
Positioned(
bottom: 24,
right: 48,
child: Row(
children: List.generate(
widget.items.length,
(index) => AnimatedContainer(
duration: const Duration(milliseconds: 300),
margin: const EdgeInsets.symmetric(horizontal: 4),
width: _currentIndex == index ? 24 : 8,
height: 8,
decoration: BoxDecoration(
color: _currentIndex == index
? AppColors.primary
: Colors.white.withOpacity(0.3),
borderRadius: BorderRadius.circular(4),
),
),
),
),
),
],
),
);
}
Widget _buildHeroSlide(HeroCarouselItem item) {
return Stack(
fit: StackFit.expand,
children: [
// Background Image with Ken Burns Effect (Simulated via Scale in future, static for now)
if (item.imageUrl != null && item.imageUrl!.isNotEmpty)
Image.network(
_getProxiedUrl(item.imageUrl)!,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(color: AppColors.surface),
),
// Gradient Overlays
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
AppColors.background.withOpacity(0.2),
AppColors.background.withOpacity(0.9),
AppColors.background,
],
stops: const [0.0, 0.5, 0.8, 1.0],
),
),
),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerRight,
end: Alignment.centerLeft,
colors: [
Colors.transparent,
AppColors.background.withOpacity(0.8),
],
),
),
),
// Content Info
Padding(
padding: const EdgeInsets.all(48.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Category Tag
Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: AppColors.primary.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.primary.withOpacity(0.5)),
),
child: Text(
'FEATURED',
style: GoogleFonts.inter(
color: AppColors.primary,
fontWeight: FontWeight.bold,
fontSize: 12,
letterSpacing: 1.5,
),
),
),
const SizedBox(height: 16),
// Title
SizedBox(
width: 600,
child: Text(
item.title,
style: GoogleFonts.outfit(
fontSize: 48,
fontWeight: FontWeight.bold,
color: Colors.white,
height: 1.1,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
if (item.subtitle != null) ...[
const SizedBox(height: 8),
Text(
item.subtitle!,
style: GoogleFonts.inter(
color: AppColors.textSecondary,
fontSize: 18,
),
),
],
const SizedBox(height: 24),
// Actions
Row(
children: [
TvFocusableCard(
onTap: item.onPlay,
borderRadius: 50,
scaleFactor: 1.1,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 16,
),
decoration: BoxDecoration(
gradient: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(50),
boxShadow: [
BoxShadow(
color: AppColors.primary.withOpacity(0.4),
blurRadius: 20,
offset: const Offset(0, 5),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.play_arrow_rounded,
color: Colors.white,
),
const SizedBox(width: 8),
Text(
'WATCH NOW',
style: GoogleFonts.inter(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
],
),
),
),
const SizedBox(width: 20),
TvFocusableCard(
onTap: item.onTap, // Add to favorites or details
borderRadius: 50,
child: GlassContainer(
borderRadius: 50,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 16,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.info_outline, color: Colors.white),
const SizedBox(width: 8),
Text(
'DETAILS',
style: GoogleFonts.inter(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
],
),
),
),
],
),
const SizedBox(height: 48), // Bottom padding
],
),
),
],
);
}
}
+159 -473
View File
@@ -1,73 +1,67 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../providers/favorites_provider.dart';
import '../../../core/models/playlist_config.dart';
import '../../iptv/providers/xtream_provider.dart';
import '../theme/app_colors.dart';
import '../theme/app_theme.dart';
/// Modern Apple TV Channel Card
///
/// Features:
/// - Live indicator with pulse animation
/// - Favorite button with smooth interaction
/// - EPG information display
/// - Focus-aware scaling
/// - Smooth image loading with skeleton loading
class ChannelCard extends ConsumerStatefulWidget {
final String streamId;
final String name;
final String? iconUrl;
class ChannelCard extends StatefulWidget {
final String channelNumber;
final String channelName;
final String? channelLogo;
final String? currentProgram;
final bool isLive;
final String? programTime;
final bool isFavorite;
final VoidCallback? onTap;
final double? width;
final double? height;
final PlaylistConfig playlist;
final VoidCallback? onFavoriteTap;
final bool isActive;
const ChannelCard({
super.key,
required this.streamId,
required this.name,
required this.playlist,
this.iconUrl,
required this.channelNumber,
required this.channelName,
this.channelLogo,
this.currentProgram,
this.isLive = true,
this.programTime,
this.isFavorite = false,
this.onTap,
this.width = 200,
this.height = 120,
this.onFavoriteTap,
this.isActive = false,
});
@override
ConsumerState<ChannelCard> createState() => _ChannelCardState();
State<ChannelCard> createState() => _ChannelCardState();
}
class _ChannelCardState extends ConsumerState<ChannelCard>
class _ChannelCardState extends State<ChannelCard>
with SingleTickerProviderStateMixin {
bool _isHovered = false;
late AnimationController _pulseController;
late Animation<double> _pulseAnimation;
bool? _epgLoaded;
String? _epgNow;
bool _isHovered = false;
@override
void initState() {
super.initState();
// Pulse animation for live indicator
_pulseController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat();
duration: AppTheme.durationMd,
)..repeat(reverse: true);
_pulseAnimation = Tween<double>(begin: 1.0, end: 1.5)
.animate(CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut));
_pulseAnimation = Tween<double>(begin: 1.0, end: 1.05).animate(
CurvedAnimation(parent: _pulseController, curve: AppTheme.curveSmooth),
);
_epgLoaded = false;
if (widget.isActive) {
_pulseController.forward();
}
}
@override
void didUpdateWidget(ChannelCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isActive && !oldWidget.isActive) {
_pulseController.forward();
} else if (!widget.isActive && oldWidget.isActive) {
_pulseController.reverse();
}
}
@override
@@ -78,105 +72,144 @@ class _ChannelCardState extends ConsumerState<ChannelCard>
@override
Widget build(BuildContext context) {
final isFavorite = ref.watch(favoritesProvider).contains(widget.streamId);
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedScale(
scale: _isHovered ? 1.08 : 1.0,
duration: AppTheme.durationMd,
curve: AppTheme.curveDefault,
child: GlassCard(
borderRadius: AppTheme.radiusLg,
padding: EdgeInsets.zero,
interactive: true,
onTap: widget.onTap,
child: ScaleTransition(
scale: widget.isActive ? _pulseAnimation : AlwaysStoppedAnimation(1.0),
child: AnimatedContainer(
duration: AppTheme.durationMd,
curve: AppTheme.curveDefault,
decoration: BoxDecoration(
color: widget.isActive
? AppColors.primary.withOpacity(0.15)
: AppColors.surface,
border: Border.all(
color: widget.isActive
? AppColors.primary
: (_isHovered ? AppColors.border : AppColors.border.withOpacity(0.5)),
width: widget.isActive ? 2.5 : 1.5,
),
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
boxShadow: [
if (_isHovered || widget.isActive)
BoxShadow(
color: widget.isActive
? AppColors.primary.withOpacity(0.25)
: AppColors.primary.withOpacity(0.1),
blurRadius: 12,
offset: const Offset(0, 4),
spreadRadius: 1,
),
],
),
child: Stack(
fit: StackFit.expand,
children: [
// ============ BACKGROUND IMAGE ============
_buildChannelImage(),
// ============ GRADIENT OVERLAY ============
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withOpacity(_isHovered ? 0.7 : 0.5),
],
),
),
),
// ============ CONTENT ============
Positioned(
left: AppTheme.spacing12,
right: AppTheme.spacing12,
bottom: AppTheme.spacing12,
Padding(
padding: EdgeInsets.all(AppTheme.spacingMd),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Channel name
Text(
widget.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.outfit(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
if (widget.currentProgram != null) ...[
const SizedBox(height: 4),
Text(
widget.currentProgram!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w400,
color: AppColors.textSecondary,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.channelNumber,
style: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
letterSpacing: 0.5,
),
),
SizedBox(height: AppTheme.spacingXs),
Text(
widget.channelName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.outfit(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
],
),
),
GestureDetector(
onTap: widget.onFavoriteTap,
child: AnimatedScale(
scale: widget.isFavorite ? 1.1 : 1.0,
duration: AppTheme.durationXs,
child: Icon(
widget.isFavorite
? Icons.favorite
: Icons.favorite_border,
color: widget.isFavorite
? AppColors.secondary
: AppColors.textSecondary,
size: 20,
),
),
),
],
),
SizedBox(height: AppTheme.spacingSm),
if (widget.currentProgram != null)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.currentProgram!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
),
),
if (widget.programTime != null) ...[
SizedBox(height: AppTheme.spacingXs),
Text(
widget.programTime!,
style: GoogleFonts.inter(
fontSize: 11,
fontWeight: FontWeight.w400,
color: AppColors.textTertiary,
),
),
],
],
),
],
],
),
),
// ============ TOP INDICATORS ============
Positioned(
top: AppTheme.spacing12,
left: AppTheme.spacing12,
right: AppTheme.spacing12,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Live indicator
if (widget.isLive) _buildLiveIndicator(),
// Favorite button
_buildFavoriteButton(isFavorite),
],
),
),
// ============ FOCUS STATE ============
if (_isHovered)
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
border: Border.all(
if (widget.isActive)
Positioned(
top: AppTheme.spacingSm,
right: AppTheme.spacingSm,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: AppColors.primary,
width: 2,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: AppColors.primary.withOpacity(0.6),
blurRadius: 8,
spreadRadius: 2,
),
],
),
),
),
@@ -187,351 +220,4 @@ class _ChannelCardState extends ConsumerState<ChannelCard>
),
);
}
/// Build channel image with skeleton loading
Widget _buildChannelImage() {
if (widget.iconUrl == null || widget.iconUrl!.isEmpty) {
return Container(
color: AppColors.surface,
child: Center(
child: Icon(
Icons.tv,
color: AppColors.textSecondary,
size: 48,
),
),
);
}
return CachedNetworkImage(
imageUrl: widget.iconUrl!,
fit: BoxFit.cover,
placeholder: (context, url) => _buildImageSkeleton(),
errorWidget: (context, url, error) => Container(
color: AppColors.surface,
child: Center(
child: Icon(
Icons.broken_image_outlined,
color: AppColors.textSecondary,
size: 48,
),
),
),
);
}
/// Skeleton loader for images
Widget _buildImageSkeleton() {
return Container(
color: AppColors.shimmer,
child: const Center(
child: SizedBox(
width: 40,
height: 40,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(AppColors.primary),
),
),
),
);
}
/// Build animated live indicator
Widget _buildLiveIndicator() {
return ScaleTransition(
scale: _pulseAnimation,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColors.live,
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: AppColors.textPrimary,
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
Text(
'LIVE',
style: GoogleFonts.inter(
fontSize: 10,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
letterSpacing: 0.5,
),
),
],
),
),
);
}
/// Build favorite button with interaction
Widget _buildFavoriteButton(bool isFavorite) {
return GestureDetector(
onTap: () {
ref.read(favoritesProvider.notifier).toggle(widget.streamId);
},
child: AnimatedScale(
scale: isFavorite ? 1.0 : 0.9,
duration: AppTheme.durationSm,
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: AppColors.surface.withOpacity(0.8),
shape: BoxShape.circle,
border: Border.all(
color: isFavorite ? AppColors.secondary : AppColors.border,
width: 1.5,
),
),
child: Icon(
isFavorite ? Icons.favorite : Icons.favorite_border,
color: isFavorite ? AppColors.secondary : AppColors.textSecondary,
size: 18,
),
),
),
);
}
}
_pulseController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1000),
)..repeat(reverse: true);
// Fetch EPG immediately when card is built
WidgetsBinding.instance.addPostFrameCallback((_) {
_fetchEpg();
});
}
@override
void dispose() {
_pulseController.dispose();
super.dispose();
}
Future<void> _fetchEpg() async {
if (_epgLoaded) return;
try {
// Don't modify state if unmounted
if (!mounted) return;
final service = ref.read(xtreamServiceProvider(widget.playlist));
// We use the short EPG endpoint for quick access
final entries = await service.getShortEpg(widget.streamId);
if (mounted && entries.isNotEmpty) {
final now = DateTime.now();
// Find current program
final current = entries.firstWhere(
(e) {
try {
final start = DateTime.parse(e.start);
final end = DateTime.parse(e.end);
return now.isAfter(start) && now.isBefore(end);
} catch (_) {
return false;
}
},
orElse: () => entries.first,
);
setState(() {
_epgNow = current.title;
_epgLoaded = true;
});
}
} catch (_) {
// Fail silently
if (mounted) setState(() => _epgLoaded = true);
}
}
@override
Widget build(BuildContext context) {
final favorites = ref.watch(favoritesProvider);
final isFavorite = favorites.contains(widget.streamId);
// Dynamic sizing based on hover
return MouseRegion(
onEnter: (_) {
setState(() => _isHovered = true);
},
onExit: (_) => setState(() => _isHovered = false),
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: widget.onTap,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// The Image Card (Scales)
AnimatedScale(
scale: _isHovered ? 1.05 : 1.0,
duration: AppTheme.durationFast,
curve: AppTheme.curveDefault,
child: AnimatedContainer(
duration: AppTheme.durationFast,
width: widget.width ?? 180,
height: widget.height ?? 110,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
color: AppColors.surface, // Fallback color
// Thick white border on focus (tvOS style)
border: Border.all(
color: _isHovered ? AppColors.focusColor : Colors.transparent,
width: _isHovered ? 3 : 0,
),
// Deep shadow on focus
boxShadow: _isHovered
? [
BoxShadow(
color: Colors.black.withOpacity(0.5),
blurRadius: 30,
offset: const Offset(0, 15),
spreadRadius: 2,
),
]
: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(AppTheme.radiusMd - (_isHovered ? 2 : 0)), // Adjust inner radius
child: Stack(
fit: StackFit.expand,
children: [
// 1. Background Image / Logo - EDGE TO EDGE
_buildChannelImage(),
// 2. Gradient Overlay (Subtle)
if (_isHovered)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.transparent, Colors.black.withOpacity(0.4)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
),
),
// 3. Live Badge (Top Right)
if (widget.isLive)
Positioned(
top: 8,
right: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppColors.live,
borderRadius: BorderRadius.circular(4),
),
child: const Text('LIVE', style: TextStyle(color: Colors.white, fontSize: 9, fontWeight: FontWeight.bold)),
),
),
// 4. Favorite Icon (Top Left)
if (_isHovered || isFavorite)
Positioned(
top: 5,
left: 5,
child: GestureDetector(
onTap: () => ref.read(favoritesProvider.notifier).toggleFavorite(widget.streamId),
child: Icon(
isFavorite ? Icons.favorite : Icons.favorite_border,
color: isFavorite ? AppColors.live : Colors.white.withOpacity(0.7),
size: 20,
),
),
),
],
),
),
),
),
const SizedBox(height: 8),
// Text separates from card (Classic tvOS look)
SizedBox(
width: widget.width ?? 180,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
widget.name,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: _isHovered ? AppColors.focusColor : AppColors.textSecondary,
fontWeight: _isHovered ? FontWeight.bold : FontWeight.w500,
fontSize: 13,
),
),
if ((widget.currentProgram != null || _epgNow != null)) ...[
const SizedBox(height: 2),
Text(
_epgNow ?? widget.currentProgram ?? '',
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textTertiary,
fontSize: 11,
),
),
],
],
),
),
],
),
),
);
}
Widget _buildChannelImage() {
if (widget.iconUrl != null && widget.iconUrl!.isNotEmpty) {
return Container(
color: Colors.white, // Logos usually look best on white/light grey
child: CachedNetworkImage(
imageUrl: widget.iconUrl!,
fit: BoxFit.contain,
width: double.infinity,
height: double.infinity,
placeholder: (context, url) => _buildPlaceholder(),
errorWidget: (context, url, error) => _buildPlaceholder(),
),
);
}
return _buildPlaceholder();
}
Widget _buildPlaceholder() {
return Container(
color: AppColors.surface,
child: const Center(
child: Icon(Icons.tv, color: AppColors.textTertiary),
),
);
}
}