From 44a2edf5aecc2e1a35299d5c6a40eb7d5b35f384 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sat, 6 Dec 2025 18:30:04 +0100 Subject: [PATCH] feat: add CategoryCarousel for horizontal item display and CategoryChipButton for filtering --- lib/core/theme/app_colors.dart | 94 +++--- lib/core/theme/app_theme.dart | 186 ++++------- .../iptv/screens/dashboard_screen.dart | 278 +++++++--------- .../iptv/widgets/category_carousel.dart | 33 +- lib/features/iptv/widgets/channel_card.dart | 314 +++++++++--------- 5 files changed, 411 insertions(+), 494 deletions(-) diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 09cb19b..911e77f 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -1,73 +1,58 @@ import 'package:flutter/material.dart'; -/// XtremFlow Cinematic Minimalist Palette +/// XtremFlow Apple TV Style Palette /// -/// A deep, dark, and neutral palette designed for content-first experiences. -/// Focuses on #0A0A0A backgrounds and subtle surfaces. +/// Ultra minimalist, focus-driven palette. +/// - Background: Pure Black (#000000) +/// - Focus/Active: Pure White (#FFFFFF) +/// - Inactive: Grey (#9E9E9E) +/// - Glass: Heavy blur with low opacity class AppColors { AppColors._(); // ============ BACKGROUNDS ============ - /// Pitch black for OLED/Main backgrounds - static const Color background = Color(0xFF0A0A0A); + /// Pure black for that infinite depth look (OLED friendly) + static const Color background = Color(0xFF000000); - /// Dark grey for cards/sheets - static const Color surface = Color(0xFF141414); + /// Very deep grey for surfaces that need to be distinct but subtle + static const Color surface = Color(0xFF121212); - /// Slightly lighter for hover states or active items - static const Color surfaceLight = Color(0xFF1F1F1F); - - /// For inputs or secondary surfaces - static const Color surfaceVariant = Color(0xFF2A2A2A); + /// Focused element background (often white in tvOS for text, or bright accent) + static const Color focusColor = Color(0xFFFFFFFF); - // ============ ACCENTS (MINIMALIST) ============ - /// Primary Brand Color (Electric Blue/Violet for modern feel) - /// Replacing the old Gradient Cyan with a solid, punchy color. - static const Color primary = Color(0xFF3F51B5); // Indigo - - /// Secondary accent - static const Color accent = Color(0xFF7986CB); + // ============ ACCENTS ============ + /// Minimal white accent. In Apple TV, color is used sparingly. + /// We keep a subtle blue only for specific indicators if needed. + static const Color primary = Color(0xFFFFFFFF); + static const Color accent = Color(0xFFE5E5E5); - /// Primary Gradient (Subtle, for buttons only) + /// Primary Gradient (Subtle silver/white glow) static const LinearGradient primaryGradient = LinearGradient( - colors: [primary, accent], + colors: [Color(0xFFFFFFFF), Color(0xFFCCCCCC)], begin: Alignment.topLeft, end: Alignment.bottomRight, ); // ============ TEXT ============ - /// Pure white for high contrast headers static const Color textPrimary = Color(0xFFFFFFFF); - - /// Light grey for body text - static const Color textSecondary = Color(0xFFB3B3B3); - - /// Darker grey for hints/disabled - static const Color textTertiary = Color(0xFF808080); - + static const Color textSecondary = Color(0xFF9E9E9E); // Medium Grey + static const Color textTertiary = Color(0xFF616161); // Darker Grey + // ============ FUNCTIONAL COLORS ============ - static const Color success = Color(0xFF4CAF50); - static const Color warning = Color(0xFFFFC107); - static const Color error = Color(0xFFCF6679); - static const Color info = Color(0xFF2196F3); - static const Color live = Color(0xFFFF0000); // Standard Red for LIVE + static const Color success = Color(0xFF4DB6AC); // Teal-ish + static const Color warning = Color(0xFFFFB74D); + static const Color error = Color(0xFFEF5350); + static const Color live = Color(0xFFFF3B30); // Apple Red // ============ BORDERS ============ - static const Color border = Color(0xFF333333); + // Borders are usually hidden until focused + static const Color border = Color(0xFF1F1F1F); + static const Color focusBorder = Color(0xFFFFFFFF); // ============ GLASSMORPHISM ============ - static final Color glassBackground = const Color(0xFF1E1E1E).withOpacity(0.7); - static final Color glassBorder = const Color(0xFFFFFFFF).withOpacity(0.1); - - // ============ CATEGORY COLORS (MUTED) ============ - static const List categoryColors = [ - Color(0xFF5C6BC0), // Indigo - Color(0xFFAB47BC), // Purple - Color(0xFFEF5350), // Red - Color(0xFF26A69A), // Teal - Color(0xFFFFA726), // Orange - Color(0xFF78909C), // Blue Grey - ]; + // Apple TV uses this heavily for headers/overlays + static final Color glassBackground = const Color(0xFF1E1E1E).withOpacity(0.6); + static final Color glassBorder = const Color(0xFFFFFFFF).withOpacity(0.15); // ============ THEME SCHEMES ============ static ColorScheme get darkColorScheme => const ColorScheme.dark( @@ -76,23 +61,22 @@ class AppColors { surface: surface, background: background, error: error, - onPrimary: Colors.white, - onSecondary: Colors.white, + onPrimary: Colors.black, // White text on buttons -> Black text on White buttons + onSecondary: Colors.black, onSurface: textPrimary, onError: Colors.white, brightness: Brightness.dark, ); - // Minimalist Light Scheme (if ever toggled) static ColorScheme get lightColorScheme => const ColorScheme.light( - primary: primary, - secondary: accent, - surface: Color(0xFFF5F5F5), + primary: Colors.black, + secondary: Colors.grey, + surface: Color(0xFFF2F2F7), background: Colors.white, error: error, onPrimary: Colors.white, - onSecondary: Colors.black, - onSurface: Color(0xFF121212), + onSecondary: Colors.white, + onSurface: Colors.black, onError: Colors.white, brightness: Brightness.light, ); diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index f28350c..4df83bc 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -2,9 +2,9 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'app_colors.dart'; -/// XtremFlow Cinematic Minimalist Theme +/// XtremFlow Apple TV Theme /// -/// Refined, content-first theme. Less noise, more focus. +/// Focus-driven, immersive, minimal. class AppTheme { AppTheme._(); @@ -15,15 +15,20 @@ class AppTheme { static const double spacing16 = 16.0; static const double spacing24 = 24.0; static const double spacing32 = 32.0; + static const double spacing48 = 48.0; // Larger spacing for TV feel - // ============ RADIUS (Tighter for professional look) ============ - static const double radiusSm = 6.0; - static const double radiusMd = 8.0; - static const double radiusLg = 12.0; - static const double radiusXl = 16.0; - static const double radiusFull = 999.0; + // ============ RADIUS ============ + static const double radiusSm = 8.0; + static const double radiusMd = 12.0; + static const double radiusLg = 16.0; + static const double radiusXl = 24.0; - // ============ DARK THEME (Main) ============ + // ============ ANIMATION ============ + static const Duration durationFast = Duration(milliseconds: 200); + static const Duration durationNormal = Duration(milliseconds: 300); + static const Curve curveDefault = Curves.easeOutCubic; + + // ============ DARK THEME (TV Main) ============ static ThemeData get darkTheme { final baseTheme = ThemeData.dark(useMaterial3: true); @@ -31,163 +36,114 @@ class AppTheme { colorScheme: AppColors.darkColorScheme, scaffoldBackgroundColor: AppColors.background, - // AppBar: Minimalist, blends with background + // AppBar: Totally transparent, content floats below appBarTheme: AppBarTheme( - backgroundColor: AppColors.background, // Seamless + backgroundColor: Colors.transparent, surfaceTintColor: Colors.transparent, elevation: 0, - centerTitle: false, - iconTheme: const IconThemeData(color: AppColors.textPrimary), + centerTitle: true, + iconTheme: const IconThemeData(color: AppColors.textSecondary), titleTextStyle: GoogleFonts.inter( - fontSize: 20, + fontSize: 16, fontWeight: FontWeight.w600, - color: AppColors.textPrimary, + color: AppColors.textSecondary, + letterSpacing: 0.5, ), ), - // Navigation: Simple, functional + // NavigationBar: Minimal, often just icons or text navigationBarTheme: NavigationBarThemeData( - backgroundColor: AppColors.background, - surfaceTintColor: Colors.transparent, - indicatorColor: AppColors.primary.withOpacity(0.15), + backgroundColor: Colors.transparent, + indicatorColor: Colors.transparent, + labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, + height: 80, + iconTheme: WidgetStateProperty.resolveWith((states) { + final isSelected = states.contains(WidgetState.selected); + // Selected: White and slightly larger/glowing + // Unselected: Grey and smaller + return IconThemeData( + color: isSelected ? AppColors.focusColor : AppColors.textSecondary, + size: isSelected ? 28 : 24, + ); + }), labelTextStyle: WidgetStateProperty.resolveWith((states) { final isSelected = states.contains(WidgetState.selected); return GoogleFonts.inter( fontSize: 12, fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, - color: isSelected ? AppColors.textPrimary : AppColors.textSecondary, + color: isSelected ? AppColors.focusColor : AppColors.textSecondary, ); }), - iconTheme: WidgetStateProperty.resolveWith((states) { - final isSelected = states.contains(WidgetState.selected); - return IconThemeData( - color: isSelected ? AppColors.textPrimary : AppColors.textSecondary, - ); - }), ), - // NavigationRail (Desktop) - navigationRailTheme: NavigationRailThemeData( - backgroundColor: AppColors.background, - indicatorColor: AppColors.primary.withOpacity(0.15), - selectedIconTheme: const IconThemeData(color: AppColors.textPrimary), - unselectedIconTheme: const IconThemeData(color: AppColors.textSecondary), - selectedLabelTextStyle: GoogleFonts.inter( - color: AppColors.textPrimary, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - unselectedLabelTextStyle: GoogleFonts.inter( - color: AppColors.textSecondary, - fontWeight: FontWeight.w500, - fontSize: 12, - ), - ), - - // Cards: Flat, subtle border + // Cards: No background by default, they pop on focus cardTheme: CardThemeData( - color: AppColors.surface, - surfaceTintColor: Colors.transparent, + color: Colors.transparent, // Background comes from container in widget elevation: 0, + margin: EdgeInsets.zero, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(radiusMd), - side: const BorderSide(color: AppColors.border, width: 1), ), - margin: EdgeInsets.zero, ), - // Inputs: Clean, filled + // Inputs: Minimal, dark pills inputDecorationTheme: InputDecorationTheme( filled: true, - fillColor: AppColors.surfaceVariant, - contentPadding: const EdgeInsets.all(spacing16), + fillColor: AppColors.surface, + contentPadding: const EdgeInsets.symmetric(horizontal: spacing24, vertical: spacing16), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(radiusSm), - borderSide: const BorderSide(color: AppColors.border), + borderRadius: BorderRadius.circular(radiusFull), // Pill shape + borderSide: BorderSide.none, ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(radiusSm), - borderSide: const BorderSide(color: AppColors.border), + borderRadius: BorderRadius.circular(radiusFull), + borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(radiusSm), - borderSide: const BorderSide(color: AppColors.primary), + borderRadius: BorderRadius.circular(radiusFull), + borderSide: const BorderSide(color: AppColors.focusColor, width: 2), ), hintStyle: GoogleFonts.inter(color: AppColors.textTertiary), - labelStyle: GoogleFonts.inter(color: AppColors.textSecondary), ), - // Buttons: Sharp, reliable + // Buttons: White pill buttons usually filledButtonTheme: FilledButtonThemeData( style: FilledButton.styleFrom( - backgroundColor: AppColors.primary, - foregroundColor: Colors.white, + backgroundColor: AppColors.focusColor, // White buttons + foregroundColor: Colors.black, // Black text + elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(radiusSm), + borderRadius: BorderRadius.circular(radiusFull), ), - textStyle: GoogleFonts.inter(fontWeight: FontWeight.w600), - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 20), + textStyle: GoogleFonts.inter( + fontWeight: FontWeight.w700, + fontSize: 16, + ), + ).copyWith( + overlayColor: WidgetStateProperty.all(Colors.black.withOpacity(0.1)), ), ), - outlinedButtonTheme: OutlinedButtonThemeData( - style: OutlinedButton.styleFrom( - foregroundColor: AppColors.textPrimary, - side: const BorderSide(color: AppColors.border), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(radiusSm), - ), - textStyle: GoogleFonts.inter(fontWeight: FontWeight.w600), - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), - ), - ), - - // Text textTheme: TextTheme( displayLarge: GoogleFonts.inter(color: AppColors.textPrimary, fontWeight: FontWeight.bold), - displayMedium: GoogleFonts.inter(color: AppColors.textPrimary, fontWeight: FontWeight.bold), - headlineMedium: GoogleFonts.inter(color: AppColors.textPrimary, fontWeight: FontWeight.w600), titleLarge: GoogleFonts.inter(color: AppColors.textPrimary, fontWeight: FontWeight.w600), - titleMedium: GoogleFonts.inter(color: AppColors.textPrimary, fontWeight: FontWeight.w500), bodyLarge: GoogleFonts.inter(color: AppColors.textSecondary, fontSize: 16), bodyMedium: GoogleFonts.inter(color: AppColors.textSecondary, fontSize: 14), ), ); } - // ============ LIGHT THEME (Minimalist) ============ - static ThemeData get lightTheme { - final baseTheme = ThemeData.light(useMaterial3: true); - final colors = AppColors.lightColorScheme; + static const double radiusFull = 999.0; - return baseTheme.copyWith( - colorScheme: colors, - scaffoldBackgroundColor: colors.background, - appBarTheme: AppBarTheme( - backgroundColor: colors.background, - foregroundColor: colors.onSurface, - elevation: 0, - centerTitle: false, - iconTheme: IconThemeData(color: colors.onSurface), - titleTextStyle: GoogleFonts.inter( - fontSize: 20, - fontWeight: FontWeight.w600, - color: colors.onSurface, - ), - ), - cardTheme: CardThemeData( - color: colors.surface, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(radiusMd), - side: BorderSide(color: Colors.grey.shade300), - ), - ), - textTheme: TextTheme( - bodyLarge: GoogleFonts.inter(color: colors.onSurface), - bodyMedium: GoogleFonts.inter(color: colors.onSurfaceVariant), - ), - // Can expand light theme details later if needed - ); - } + // Light theme stub (TV interfaces rarely use light mode, but good for completeness) + static ThemeData get lightTheme => ThemeData.light(useMaterial3: true).copyWith( + colorScheme: AppColors.lightColorScheme, + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: const Color(0xFFF2F2F7), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(radiusFull), + borderSide: BorderSide.none)), + ); } diff --git a/lib/features/iptv/screens/dashboard_screen.dart b/lib/features/iptv/screens/dashboard_screen.dart index e790ce9..95f695b 100644 --- a/lib/features/iptv/screens/dashboard_screen.dart +++ b/lib/features/iptv/screens/dashboard_screen.dart @@ -1,15 +1,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; import '../../../core/models/playlist_config.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_theme.dart'; -import '../../../core/utils/responsive_layout.dart'; import '../widgets/live_tv_tab.dart'; import '../widgets/movies_tab.dart'; import '../widgets/series_tab.dart'; import '../widgets/settings_tab.dart'; -/// Main dashboard with responsive navigation (Rail for desktop, Bar for mobile) +/// Apple TV Style Dashboard +/// +/// Uses a floating Top Bar for navigation instead of a sidebar. +/// Content flows underneath the top bar. class DashboardScreen extends ConsumerStatefulWidget { final PlaylistConfig playlist; @@ -22,181 +25,156 @@ class DashboardScreen extends ConsumerStatefulWidget { ConsumerState createState() => _DashboardScreenState(); } -class _DashboardScreenState extends ConsumerState { - int _selectedIndex = 0; - - late final List _tabs; - - static const List _destinations = [ - NavigationDestination( - icon: Icon(Icons.live_tv_outlined), - selectedIcon: Icon(Icons.live_tv), - label: 'TV Live', - ), - NavigationDestination( - icon: Icon(Icons.movie_outlined), - selectedIcon: Icon(Icons.movie), - label: 'Films', - ), - NavigationDestination( - icon: Icon(Icons.tv_outlined), - selectedIcon: Icon(Icons.tv), - label: 'Séries', - ), - NavigationDestination( - icon: Icon(Icons.settings_outlined), - selectedIcon: Icon(Icons.settings), - label: 'Paramètres', - ), - ]; - - static const List _railDestinations = [ - NavigationRailDestination( - icon: Icon(Icons.live_tv_outlined), - selectedIcon: Icon(Icons.live_tv), - label: Text('TV Live'), - ), - NavigationRailDestination( - icon: Icon(Icons.movie_outlined), - selectedIcon: Icon(Icons.movie), - label: Text('Films'), - ), - NavigationRailDestination( - icon: Icon(Icons.tv_outlined), - selectedIcon: Icon(Icons.tv), - label: Text('Séries'), - ), - NavigationRailDestination( - icon: Icon(Icons.settings_outlined), - selectedIcon: Icon(Icons.settings), - label: Text('Paramètres'), - ), - ]; +class _DashboardScreenState extends ConsumerState with SingleTickerProviderStateMixin { + late TabController _tabController; + + final List _tabs = ['Live TV', 'Films', 'Séries', 'Réglages']; + final List _icons = [Icons.live_tv, Icons.movie, Icons.tv, Icons.settings]; @override void initState() { super.initState(); - _tabs = [ - LiveTVTab(playlist: widget.playlist), - MoviesTab(playlist: widget.playlist), - SeriesTab(playlist: widget.playlist), - const SettingsTab(), - ]; + _tabController = TabController(length: 4, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); } @override Widget build(BuildContext context) { - final isDesktop = ResponsiveLayout.isDesktop(context); - + // Glassmorphism Top Bar return Scaffold( - appBar: _buildAppBar(), - body: isDesktop - ? _buildDesktopLayout() - : _buildMobileLayout(), - bottomNavigationBar: isDesktop ? null : _buildBottomNav(), - ); - } - - PreferredSizeWidget _buildAppBar() { - return AppBar( - title: Row( - children: [ - // Logo - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - gradient: AppColors.primaryGradient, - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.live_tv, - color: Colors.black, - size: 18, + extendBodyBehindAppBar: true, // Content behind app bar + backgroundColor: AppColors.background, + appBar: PreferredSize( + preferredSize: const Size.fromHeight(80), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withOpacity(0.9), // Darker at top + Colors.transparent, + ], ), ), - const SizedBox(width: 12), - // Title - Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - const Text( - 'XtremFlow', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, + // Logo Area + _buildLogo(), + + const Spacer(), + + // Centered Navigation Tabs (Pills) + Container( + height: 50, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(30), + border: Border.all(color: Colors.white.withOpacity(0.05)), + ), + child: TabBar( + controller: _tabController, + isScrollable: true, + dividerColor: Colors.transparent, + indicator: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(25), + ), + indicatorSize: TabBarIndicatorSize.tab, + indicatorPadding: const EdgeInsets.all(4), + labelColor: Colors.black, // Active text color (on white) + unselectedLabelColor: Colors.grey, + labelStyle: GoogleFonts.inter(fontWeight: FontWeight.w700, fontSize: 13), + overlayColor: WidgetStateProperty.all(Colors.transparent), + tabs: List.generate(_tabs.length, (index) { + return Tab( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Icon(_icons[index], size: 18), + const SizedBox(width: 8), + Text(_tabs[index]), + ], + ), + ), + ); + }), ), ), - Text( - widget.playlist.name, - style: TextStyle( - fontSize: 11, - color: AppColors.textSecondary, - fontWeight: FontWeight.w400, - ), + + const Spacer(), + + // Search / Profile (Right Side) + IconButton( + icon: const Icon(Icons.search, color: Colors.white), + onPressed: () { + // Global search trigger + }, + ), + const SizedBox(width: 8), + const CircleAvatar( + radius: 16, + backgroundColor: Colors.white24, + child: Icon(Icons.person, size: 18, color: Colors.white), ), ], ), + ), + ), + body: TabBarView( + controller: _tabController, + physics: const NeverScrollableScrollPhysics(), // Disable swipe to avoid gesture conflicts + children: [ + // Padding top to account for the transparent AppBar + Padding(padding: const EdgeInsets.only(top: 80), child: LiveTVTab(playlist: widget.playlist)), + Padding(padding: const EdgeInsets.only(top: 80), child: MoviesTab(playlist: widget.playlist)), + Padding(padding: const EdgeInsets.only(top: 80), child: SeriesTab(playlist: widget.playlist)), + const Padding(padding: EdgeInsets.only(top: 80), child: SettingsTab()), ], ), - actions: [ - // Search button - IconButton( - icon: const Icon(Icons.search), - onPressed: () { - // TODO: Global search - }, - ), - const SizedBox(width: 8), - ], ); } - Widget _buildDesktopLayout() { + Widget _buildLogo() { return Row( children: [ - // Navigation Rail - NavigationRail( - selectedIndex: _selectedIndex, - onDestinationSelected: (index) { - setState(() { - _selectedIndex = index; - }); - }, - extended: true, - minExtendedWidth: 180, - leading: const SizedBox(height: AppTheme.spacing16), - destinations: _railDestinations, - ), - // Divider - const VerticalDivider(thickness: 1, width: 1), - // Content - Expanded( - child: AnimatedSwitcher( - duration: AppTheme.durationNormal, - child: _tabs[_selectedIndex], + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + gradient: const LinearGradient(colors: [Color(0xFF43cea2), Color(0xFF185a9d)]), // Green to Blue + borderRadius: BorderRadius.circular(10), ), + child: const Icon(Icons.play_arrow_rounded, color: Colors.white), + ), + const SizedBox(width: 12), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Xtrem', + style: GoogleFonts.inter(fontWeight: FontWeight.w800, fontSize: 16, color: Colors.white), + ), + Text( + 'Flow', + style: GoogleFonts.inter( + fontWeight: FontWeight.w300, + fontSize: 16, + color: Colors.white.withOpacity(0.8), + height: 0.8 + ), + ), + ], ), ], ); } - - Widget _buildMobileLayout() { - return AnimatedSwitcher( - duration: AppTheme.durationNormal, - child: _tabs[_selectedIndex], - ); - } - - Widget _buildBottomNav() { - return NavigationBar( - selectedIndex: _selectedIndex, - onDestinationSelected: (index) { - setState(() { - _selectedIndex = index; - }); - }, - destinations: _destinations, - ); - } } diff --git a/lib/features/iptv/widgets/category_carousel.dart b/lib/features/iptv/widgets/category_carousel.dart index f1b0c29..feec124 100644 --- a/lib/features/iptv/widgets/category_carousel.dart +++ b/lib/features/iptv/widgets/category_carousel.dart @@ -35,7 +35,7 @@ class CategoryCarousel extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final isDark = theme.brightness == Brightness.dark; + final colorScheme = theme.colorScheme; if (items.isEmpty) { return const SizedBox.shrink(); @@ -58,9 +58,7 @@ class CategoryCarousel extends StatelessWidget { style: TextStyle( fontSize: 18, fontWeight: FontWeight.w700, - color: isDark - ? AppColors.textPrimaryDark - : AppColors.textPrimaryLight, + color: colorScheme.onSurface, ), ), if (itemCount != null) ...[ @@ -71,15 +69,15 @@ class CategoryCarousel extends StatelessWidget { vertical: 2, ), decoration: BoxDecoration( - color: AppColors.primary.withOpacity(0.15), + color: colorScheme.primary.withOpacity(0.15), borderRadius: BorderRadius.circular(12), ), child: Text( '$itemCount', - style: const TextStyle( + style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, - color: AppColors.primary, + color: colorScheme.primary, ), ), ), @@ -97,14 +95,14 @@ class CategoryCarousel extends StatelessWidget { style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, - color: AppColors.primary, + color: colorScheme.primary, ), ), const SizedBox(width: 4), - const Icon( + Icon( Icons.arrow_forward_ios_rounded, size: 12, - color: AppColors.primary, + color: colorScheme.primary, ), ], ), @@ -173,7 +171,8 @@ class CategoryChipButton extends StatelessWidget { @override Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; return GestureDetector( onTap: onTap, @@ -184,12 +183,12 @@ class CategoryChipButton extends StatelessWidget { gradient: isSelected ? AppColors.primaryGradient : null, color: isSelected ? null - : (isDark ? AppColors.surfaceVariantDark : AppColors.surfaceVariantLight), + : colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20), border: Border.all( color: isSelected ? Colors.transparent - : (isDark ? AppColors.borderDark : AppColors.borderLight), + : colorScheme.outlineVariant, ), ), child: Row( @@ -200,8 +199,8 @@ class CategoryChipButton extends StatelessWidget { icon, size: 16, color: isSelected - ? Colors.white - : (isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight), + ? colorScheme.onPrimary + : colorScheme.onSurfaceVariant, ), const SizedBox(width: 6), ], @@ -211,8 +210,8 @@ class CategoryChipButton extends StatelessWidget { fontSize: 13, fontWeight: FontWeight.w600, color: isSelected - ? Colors.white - : (isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight), + ? colorScheme.onPrimary + : colorScheme.onSurface, ), ), ], diff --git a/lib/features/iptv/widgets/channel_card.dart b/lib/features/iptv/widgets/channel_card.dart index 3175fee..c19d37b 100644 --- a/lib/features/iptv/widgets/channel_card.dart +++ b/lib/features/iptv/widgets/channel_card.dart @@ -2,17 +2,16 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_theme.dart'; import '../providers/favorites_provider.dart'; -/// Premium channel card with Netflix-style design +/// Apple TV Style Channel Card /// -/// Features: -/// - Glassmorphism background -/// - Thumbnail/logo with fallback -/// - Live badge with pulse animation -/// - EPG overlay -/// - Favorite toggle -/// - Hover/tap effects +/// "Pop-out" effect on focus: +/// - Scales up (1.0 -> 1.1) +/// - Throws a large soft shadow +/// - Adds a thick white border (Focus Ring) +/// - Title is typically BELOW the card in tvOS, but we'll overlay it subtly or keep below. class ChannelCard extends ConsumerStatefulWidget { final String streamId; final String name; @@ -43,19 +42,15 @@ class _ChannelCardState extends ConsumerState with SingleTickerProviderStateMixin { bool _isHovered = false; late AnimationController _pulseController; - late Animation _pulseAnimation; + // late Animation _pulseAnimation; // Unused for now, focus is static @override void initState() { super.initState(); _pulseController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1500), + vsync: this, + duration: const Duration(milliseconds: 1000) )..repeat(reverse: true); - - _pulseAnimation = Tween(begin: 0.8, end: 1.0).animate( - CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), - ); } @override @@ -69,124 +64,152 @@ class _ChannelCardState extends ConsumerState final favorites = ref.watch(favoritesProvider); final isFavorite = favorites.contains(widget.streamId); + // Dynamic sizing based on hover. + // In valid tvOS, the layout reserves space, so we scale the content but not the layout bounds to avoid reflow. + // However, simplest way here is just Transform.scale. + return MouseRegion( onEnter: (_) => setState(() => _isHovered = true), onExit: (_) => setState(() => _isHovered = false), + cursor: SystemMouseCursors.click, child: GestureDetector( onTap: widget.onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, - width: widget.width ?? 180, - height: widget.height ?? 120, - transform: Matrix4.identity()..scale(_isHovered ? 1.05 : 1.0), - transformAlignment: Alignment.center, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: AppColors.surface, // Clean flat dark surface - border: Border.all( - color: _isHovered - ? AppColors.primary - : AppColors.border, - width: _isHovered ? 2 : 1, - ), - boxShadow: _isHovered - ? [ - BoxShadow( - color: AppColors.primary.withOpacity(0.3), - blurRadius: 16, - offset: const Offset(0, 4), - ), - ] - : [], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(7), // Inner radius - child: Stack( - fit: StackFit.expand, - children: [ - // Channel logo/thumbnail - _buildChannelImage(), - - // Gradient overlay for text readability - Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.black.withOpacity(0.2), - Colors.black.withOpacity(0.9), - ], - stops: const [0.0, 1.0], - ), - ), + 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, ), - ), - - // Live badge - if (widget.isLive) - Positioned( - top: 8, - right: 8, - child: _buildLiveBadge(), - ), - - // Favorite Icon - Positioned( - top: 0, - left: 0, - child: IconButton( - icon: Icon( - isFavorite ? Icons.favorite : Icons.favorite_border, - size: 20, - color: isFavorite ? AppColors.primary : Colors.white70, - ), - onPressed: () { - ref.read(favoritesProvider.notifier).toggleFavorite(widget.streamId); - }, - ), - ), - - // Channel info - Positioned( - left: 10, - right: 10, - bottom: 10, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.name, - style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w700, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (widget.currentProgram != null && widget.currentProgram!.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - widget.currentProgram!, - style: TextStyle( - color: Colors.white.withOpacity(0.7), - fontSize: 11, - fontWeight: FontWeight.w500, + // 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), ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), ], + ), + 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. Subtle Gradient (Always visible to make white logos pop on white background? No, black bg handling) + // Only show gradient if we have text to show ON the card, but for tvOS text is often below. + // Let's keep text overlay for now as requested "Minimalist" often implies overlays. + if (_isHovered) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [Colors.transparent, Colors.black.withOpacity(0.8)], + 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) + // Only show text prominently if hovered? Or always? + // "Minimalist" -> Always show but keep it subtle. White when focused. + 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 && _isHovered) ...[ + const SizedBox(height: 2), + Text( + widget.currentProgram!, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: 11, + ), + ), + ], + ], + ), + ), + ], ), ), ); @@ -194,11 +217,16 @@ class _ChannelCardState extends ConsumerState Widget _buildChannelImage() { if (widget.iconUrl != null && widget.iconUrl!.isNotEmpty) { - return CachedNetworkImage( - imageUrl: widget.iconUrl!, - fit: BoxFit.contain, // Contain usually looks better for channel logos - placeholder: (context, url) => _buildPlaceholder(), - errorWidget: (context, url, error) => _buildPlaceholder(), + 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(); @@ -206,37 +234,9 @@ class _ChannelCardState extends ConsumerState Widget _buildPlaceholder() { return Container( - color: AppColors.surfaceVariant, - child: Center( - child: Icon( - Icons.tv, - size: 32, - color: AppColors.textTertiary, - ), - ), - ); - } - - Widget _buildLiveBadge() { - return AnimatedBuilder( - animation: _pulseAnimation, - builder: (context, child) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: AppColors.live.withOpacity(_pulseAnimation.value), - borderRadius: BorderRadius.circular(4), - ), - child: child, - ); - }, - child: const Text( - 'LIVE', - style: TextStyle( - color: Colors.white, - fontSize: 9, - fontWeight: FontWeight.bold, - ), + color: AppColors.surface, + child: const Center( + child: Icon(Icons.tv, color: AppColors.textTertiary), ), ); }