From 5b1602903825a8ea40ffcb4d33fd2411d6807543 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Thu, 26 Mar 2026 11:15:30 +0100 Subject: [PATCH] refactor(channel_card): simplify ChannelCard structure and enhance hover effects --- lib/core/widgets/hero_carousel.dart | 298 +-------- lib/features/iptv/widgets/channel_card.dart | 632 +++++--------------- 2 files changed, 160 insertions(+), 770 deletions(-) diff --git a/lib/core/widgets/hero_carousel.dart b/lib/core/widgets/hero_carousel.dart index e11ff38..4e402be 100644 --- a/lib/core/widgets/hero_carousel.dart +++ b/lib/core/widgets/hero_carousel.dart @@ -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 items; final bool autoPlay; @@ -130,7 +121,6 @@ class _HeroCarouselState extends State child: Stack( fit: StackFit.expand, children: [ - // ============ PAGE VIEW ============ PageView.builder( controller: _pageController, onPageChanged: (index) { @@ -142,8 +132,6 @@ class _HeroCarouselState extends State return _buildCarouselPage(widget.items[index]); }, ), - - // ============ GRADIENT OVERLAY (Bottom) ============ Positioned( bottom: 0, left: 0, @@ -163,8 +151,6 @@ class _HeroCarouselState extends State ), ), ), - - // ============ CONTENT OVERLAY ============ Positioned( bottom: 32, left: 32, @@ -174,8 +160,6 @@ class _HeroCarouselState extends State child: _buildContentOverlay(widget.items[_currentIndex]), ), ), - - // ============ INDICATORS ============ Positioned( bottom: 20, right: 32, @@ -187,12 +171,10 @@ class _HeroCarouselState extends State ); } - /// 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 }, ) else - Container( - color: AppColors.surface, - ), - - // Image overlay + Container(color: AppColors.surface), Container( decoration: BoxDecoration( gradient: LinearGradient( @@ -228,13 +206,11 @@ class _HeroCarouselState extends State ); } - /// 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 ), ), ), - const SizedBox(height: 12), - - // Title Text( item.title, maxLines: 2, @@ -268,7 +241,6 @@ class _HeroCarouselState extends State height: 1.1, ), ), - if (item.subtitle != null) ...[ const SizedBox(height: 12), Text( @@ -283,14 +255,10 @@ class _HeroCarouselState extends State ), ), ], - 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 ), ), ), - const SizedBox(width: 16), - - // More info button if (item.onTap != null) GestureDetector( onTap: item.onTap, @@ -376,7 +341,6 @@ class _HeroCarouselState extends State ); } - /// Build carousel indicators Widget _buildIndicators() { return Row( mainAxisSize: MainAxisSize.min, @@ -407,264 +371,4 @@ class _HeroCarouselState extends State ), ); } -} - } - } - - 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 - ], - ), - ), - ], - ); - } } diff --git a/lib/features/iptv/widgets/channel_card.dart b/lib/features/iptv/widgets/channel_card.dart index 488a1d6..0bd860c 100644 --- a/lib/features/iptv/widgets/channel_card.dart +++ b/lib/features/iptv/widgets/channel_card.dart @@ -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 createState() => _ChannelCardState(); + State createState() => _ChannelCardState(); } -class _ChannelCardState extends ConsumerState +class _ChannelCardState extends State with SingleTickerProviderStateMixin { - bool _isHovered = false; late AnimationController _pulseController; late Animation _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(begin: 1.0, end: 1.5) - .animate(CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut)); + _pulseAnimation = Tween(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 @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 ), ); } - - /// 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 _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), - ), - ); - } }