diff --git a/bin/server.dart b/bin/server.dart index 79ac7b1..3497938 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -431,6 +431,10 @@ Handler _createStreamHandler() { '-reconnect_on_http_error', '4xx,5xx', '-rw_timeout', '15000000', // 15s timeout + // Probing settings to ensure stream detection + '-analyzeduration', '20000000', // 20s + '-probesize', '10000000', // 10MB + '-i', iptvUrl, // Output format: MPEG-TS for streaming (compatible with mpegts.js) diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 911e77f..edde650 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -14,8 +14,8 @@ class AppColors { /// Pure black for that infinite depth look (OLED friendly) static const Color background = Color(0xFF000000); - /// Very deep grey for surfaces that need to be distinct but subtle - static const Color surface = Color(0xFF121212); + /// Very deep grey for surfaces that need to be distinct but subtle (Apple Dark Grey) + static const Color surface = Color(0xFF1C1C1E); /// Focused element background (often white in tvOS for text, or bright accent) static const Color focusColor = Color(0xFFFFFFFF); diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 4df83bc..860498f 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -17,16 +17,17 @@ class AppTheme { static const double spacing32 = 32.0; static const double spacing48 = 48.0; // Larger spacing for TV feel + // ============ RADIUS ============ // ============ 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; + static const double radiusLg = 20.0; + static const double radiusXl = 32.0; // ============ ANIMATION ============ static const Duration durationFast = Duration(milliseconds: 200); static const Duration durationNormal = Duration(milliseconds: 300); - static const Curve curveDefault = Curves.easeOutCubic; + static const Curve curveDefault = Curves.fastOutSlowIn; // Apple-like snappy curve // ============ DARK THEME (TV Main) ============ static ThemeData get darkTheme { @@ -36,114 +37,95 @@ class AppTheme { colorScheme: AppColors.darkColorScheme, scaffoldBackgroundColor: AppColors.background, - // AppBar: Totally transparent, content floats below + // Typography: San Francisco style (using Inter as proxy) + textTheme: TextTheme( + displayLarge: GoogleFonts.inter(fontSize: 56, fontWeight: FontWeight.w700, letterSpacing: -1.0, color: AppColors.textPrimary), + displayMedium: GoogleFonts.inter(fontSize: 48, fontWeight: FontWeight.w700, letterSpacing: -0.5, color: AppColors.textPrimary), + displaySmall: GoogleFonts.inter(fontSize: 32, fontWeight: FontWeight.w600, color: AppColors.textPrimary), + headlineMedium: GoogleFonts.inter(fontSize: 24, fontWeight: FontWeight.w600, color: AppColors.textPrimary), + titleLarge: GoogleFonts.inter(fontSize: 20, fontWeight: FontWeight.w600, color: AppColors.textPrimary), + titleMedium: GoogleFonts.inter(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary), + bodyLarge: GoogleFonts.inter(fontSize: 16, fontWeight: FontWeight.w400, color: AppColors.textSecondary), + bodyMedium: GoogleFonts.inter(fontSize: 14, fontWeight: FontWeight.w400, color: AppColors.textSecondary), + labelLarge: GoogleFonts.inter(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary), + ), + + // AppBar: Transparent / Glass appBarTheme: AppBarTheme( - backgroundColor: Colors.transparent, - surfaceTintColor: Colors.transparent, + backgroundColor: Colors.transparent, + foregroundColor: AppColors.textPrimary, elevation: 0, centerTitle: true, - iconTheme: const IconThemeData(color: AppColors.textSecondary), + scrolledUnderElevation: 0, titleTextStyle: GoogleFonts.inter( - fontSize: 16, + fontSize: 17, fontWeight: FontWeight.w600, - color: AppColors.textSecondary, - letterSpacing: 0.5, + color: AppColors.textPrimary, ), ), - // NavigationBar: Minimal, often just icons or text - navigationBarTheme: NavigationBarThemeData( - 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.focusColor : AppColors.textSecondary, - ); - }), - ), - - // Cards: No background by default, they pop on focus - cardTheme: CardThemeData( - color: Colors.transparent, // Background comes from container in widget - elevation: 0, - margin: EdgeInsets.zero, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(radiusMd), - ), - ), - - // Inputs: Minimal, dark pills + // Inputs: Dark, minimal, no borders unless focused inputDecorationTheme: InputDecorationTheme( filled: true, fillColor: AppColors.surface, contentPadding: const EdgeInsets.symmetric(horizontal: spacing24, vertical: spacing16), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(radiusFull), // Pill shape + borderRadius: BorderRadius.circular(radiusMd), borderSide: BorderSide.none, ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(radiusFull), + borderRadius: BorderRadius.circular(radiusMd), borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(radiusFull), + borderRadius: BorderRadius.circular(radiusMd), borderSide: const BorderSide(color: AppColors.focusColor, width: 2), ), hintStyle: GoogleFonts.inter(color: AppColors.textTertiary), ), - // Buttons: White pill buttons usually + // Buttons: White pill or minimal text filledButtonTheme: FilledButtonThemeData( style: FilledButton.styleFrom( backgroundColor: AppColors.focusColor, // White buttons foregroundColor: Colors.black, // Black text elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(radiusFull), - ), - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 20), - textStyle: GoogleFonts.inter( - fontWeight: FontWeight.w700, - fontSize: 16, - ), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusMd)), + textStyle: GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 16), ).copyWith( - overlayColor: WidgetStateProperty.all(Colors.black.withOpacity(0.1)), + overlayColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.hovered)) return Colors.black.withOpacity(0.1); + if (states.contains(WidgetState.pressed)) return Colors.black.withOpacity(0.2); + return null; + }), ), ), + + // Cards: Transparent by default (content defines look) + cardTheme: const CardTheme( + color: AppColors.surface, + elevation: 0, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(radiusMd))), + ), - textTheme: TextTheme( - displayLarge: GoogleFonts.inter(color: AppColors.textPrimary, fontWeight: FontWeight.bold), - titleLarge: GoogleFonts.inter(color: AppColors.textPrimary, fontWeight: FontWeight.w600), - bodyLarge: GoogleFonts.inter(color: AppColors.textSecondary, fontSize: 16), - bodyMedium: GoogleFonts.inter(color: AppColors.textSecondary, fontSize: 14), + // Dialogs: Glass/Dark + dialogTheme: DialogTheme( + backgroundColor: const Color(0xFF1C1C1E), // Apple Dark Grey + elevation: 24, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusLg)), + titleTextStyle: GoogleFonts.inter(fontSize: 20, fontWeight: FontWeight.bold, color: AppColors.textPrimary), + contentTextStyle: GoogleFonts.inter(fontSize: 16, color: AppColors.textSecondary), ), ); } static const double radiusFull = 999.0; - // Light theme stub (TV interfaces rarely use light mode, but good for completeness) + // Light theme stub 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)), + textTheme: GoogleFonts.interTextTheme(ThemeData.light().textTheme), ); } diff --git a/lib/core/widgets/components/hero_carousel.dart b/lib/core/widgets/components/hero_carousel.dart new file mode 100644 index 0000000..49e4994 --- /dev/null +++ b/lib/core/widgets/components/hero_carousel.dart @@ -0,0 +1,273 @@ + +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../theme/app_colors.dart'; +import '../../theme/app_theme.dart'; + +class HeroCarousel extends StatefulWidget { + final List items; + final Function(HeroItem) onTap; + + const HeroCarousel({ + super.key, + required this.items, + required this.onTap, + }); + + @override + State createState() => _HeroCarouselState(); +} + +class _HeroCarouselState extends State { + late PageController _pageController; + int _currentPage = 0; + Timer? _timer; + + @override + void initState() { + super.initState(); + _pageController = PageController(viewportFraction: 0.9); + _startAutoPlay(); + } + + @override + void dispose() { + _pageController.dispose(); + _stopAutoPlay(); + super.dispose(); + } + + void _startAutoPlay() { + _timer = Timer.periodic(const Duration(seconds: 8), (timer) { + if (_currentPage < widget.items.length - 1) { + _currentPage++; + } else { + _currentPage = 0; + } + + if (_pageController.hasClients) { + _pageController.animateToPage( + _currentPage, + duration: const Duration(milliseconds: 800), + curve: Curves.fastOutSlowIn, + ); + } + }); + } + + void _stopAutoPlay() { + _timer?.cancel(); + } + + @override + Widget build(BuildContext context) { + if (widget.items.isEmpty) return const SizedBox.shrink(); + + return SizedBox( + height: 400, + child: PageView.builder( + controller: _pageController, + onPageChanged: (index) { + setState(() => _currentPage = index); + }, + itemCount: widget.items.length, + itemBuilder: (context, index) { + final item = widget.items[index]; + final isActive = index == _currentPage; + + return AnimatedScale( + duration: const Duration(milliseconds: 500), + scale: isActive ? 1.0 : 0.9, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 500), + opacity: isActive ? 1.0 : 0.5, + child: GestureDetector( + onTap: () => widget.onTap(item), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + boxShadow: [ + if (isActive) + BoxShadow( + color: Colors.black.withOpacity(0.5), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + child: Stack( + fit: StackFit.expand, + children: [ + // Background Image + CachedNetworkImage( + imageUrl: item.imageUrl, + fit: BoxFit.cover, + placeholder: (context, url) => Container(color: AppColors.surfaceVariant), + errorWidget: (context, url, error) => Container( + color: AppColors.surfaceVariant, + child: const Icon(Icons.movie, size: 64, color: AppColors.textTertiary), + ), + ), + + // Gradient Overlay + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withOpacity(0.2), + Colors.black.withOpacity(0.9), + ], + stops: const [0.0, 0.5, 1.0], + ), + ), + ), + + // Content + Positioned( + bottom: 32, + left: 32, + right: 32, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + style: GoogleFonts.outfit( + fontSize: 32, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (item.subtitle != null) ...[ + const SizedBox(height: 8), + Text( + item.subtitle!, + style: GoogleFonts.inter( + fontSize: 16, + color: Colors.white.withOpacity(0.8), + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + const SizedBox(height: 16), + Row( + children: [ + _HeroButton( + icon: Icons.play_arrow, + label: 'Play', + onPressed: () => widget.onTap(item), + isPrimary: true, + ), + const SizedBox(width: 12), + if (item.onMoreInfo != null) + _HeroButton( + icon: Icons.info_outline, + label: 'More Info', + onPressed: item.onMoreInfo!, + isPrimary: false, + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + }, + ), + ); + } +} + +class _HeroButton extends StatefulWidget { + final IconData icon; + final String label; + final VoidCallback onPressed; + final bool isPrimary; + + const _HeroButton({ + required this.icon, + required this.label, + required this.onPressed, + required this.isPrimary, + }); + + @override + State<_HeroButton> createState() => _HeroButtonState(); +} + +class _HeroButtonState extends State<_HeroButton> { + bool _isHovered = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: widget.onPressed, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: BoxDecoration( + color: widget.isPrimary + ? (_isHovered ? Colors.white : Colors.white.withOpacity(0.9)) + : (_isHovered ? Colors.white.withOpacity(0.2) : Colors.white.withOpacity(0.1)), + borderRadius: BorderRadius.circular(AppTheme.radiusFull), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + widget.icon, + color: widget.isPrimary ? Colors.black : Colors.white, + size: 20, + ), + const SizedBox(width: 8), + Text( + widget.label, + style: GoogleFonts.inter( + color: widget.isPrimary ? Colors.black : Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ); + } +} + +class HeroItem { + final String id; + final String title; + final String imageUrl; + final String? subtitle; + final VoidCallback? onMoreInfo; + + HeroItem({ + required this.id, + required this.title, + required this.imageUrl, + this.subtitle, + this.onMoreInfo, + }); +} diff --git a/lib/core/widgets/components/ui_components.dart b/lib/core/widgets/components/ui_components.dart index 929ac48..b69401a 100644 --- a/lib/core/widgets/components/ui_components.dart +++ b/lib/core/widgets/components/ui_components.dart @@ -306,3 +306,194 @@ class CategoryChip extends StatelessWidget { ); } } + +/// Apple TV style Media Card (Vertical Poster) +class MediaCard extends StatefulWidget { + final String title; + final String? imageUrl; + final String? subtitle; + final String? rating; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + final bool isWatched; + final IconData placeholderIcon; + + const MediaCard({ + super.key, + required this.title, + this.imageUrl, + this.subtitle, + this.rating, + this.onTap, + this.onLongPress, + this.isWatched = false, + this.placeholderIcon = Icons.movie, + }); + + @override + State createState() => _MediaCardState(); +} + +class _MediaCardState extends State + with SingleTickerProviderStateMixin { + bool _isHovered = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: widget.onTap, + onLongPress: widget.onLongPress, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Poster Image + Expanded( + child: AnimatedScale( + scale: _isHovered ? 1.05 : 1.0, + duration: AppTheme.durationFast, + curve: AppTheme.curveDefault, + child: AnimatedContainer( + duration: AppTheme.durationFast, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + color: AppColors.surface, + border: Border.all( + color: _isHovered ? AppColors.focusColor : Colors.transparent, + width: _isHovered ? 3 : 0, + ), + boxShadow: _isHovered + ? [ + BoxShadow( + color: Colors.black.withOpacity(0.5), + blurRadius: 20, + offset: const Offset(0, 10), + ) + ] + : [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 4, + offset: const Offset(0, 2), + ) + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(AppTheme.radiusMd - (_isHovered ? 2 : 0)), + child: Stack( + fit: StackFit.expand, + children: [ + if (widget.imageUrl != null) + CachedNetworkImage( + imageUrl: widget.imageUrl!, + fit: BoxFit.cover, + placeholder: (context, url) => Container( + color: AppColors.surface, + child: Center( + child: Icon(widget.placeholderIcon, color: AppColors.textTertiary), + ), + ), + errorWidget: (context, url, error) => Container( + color: AppColors.surface, + child: Center( + child: Icon(widget.placeholderIcon, color: AppColors.textTertiary), + ), + ), + ) + else + Container( + color: AppColors.surface, + child: Center( + child: Icon(widget.placeholderIcon, color: AppColors.textTertiary), + ), + ), + + // Rating Badge (Top Left) + if (widget.rating != null && widget.rating!.isNotEmpty) + Positioned( + top: 8, + left: 8, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.7), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.star, size: 10, color: Color(0xFFFFD700)), + const SizedBox(width: 4), + Text( + widget.rating!, + style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold), + ), + ], + ), + ), + ), + + // Watched Indicator (Top Right) + if (widget.isWatched) + Positioned( + top: 8, + right: 8, + child: Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: AppColors.success, + shape: BoxShape.circle, + ), + child: const Icon(Icons.check, size: 12, color: Colors.white), + ), + ), + ], + ), + ), + ), + ), + ), + + // Title & Subtitle (Below) + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: Column( + children: [ + Text( + widget.title, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: _isHovered ? AppColors.focusColor : AppColors.textSecondary, + fontWeight: _isHovered ? FontWeight.w600 : FontWeight.w500, + fontSize: 13, + ), + ), + if (widget.subtitle != null && _isHovered) ...[ + const SizedBox(height: 2), + Text( + widget.subtitle!, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: 11, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/admin/screens/admin_panel.dart b/lib/features/admin/screens/admin_panel.dart index 3088f92..8cf5788 100644 --- a/lib/features/admin/screens/admin_panel.dart +++ b/lib/features/admin/screens/admin_panel.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; import 'package:go_router/go_router.dart'; import '../../../core/services/playlist_api_service.dart'; import '../../../core/services/user_api_service.dart'; @@ -42,40 +43,100 @@ class _AdminPanelState extends ConsumerState @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: Row( + backgroundColor: AppColors.background, + body: SafeArea( + child: Column( children: [ + // Custom Glass Header Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: AppColors.primary.withOpacity(0.2), - borderRadius: BorderRadius.circular(8), + padding: const EdgeInsets.all(24), + child: Row( + children: [ + GlassCard( + borderRadius: 12, + padding: const EdgeInsets.all(8), + showBorder: false, + onTap: () => context.go('/playlists'), + child: const Icon(Icons.arrow_back, color: AppColors.textSecondary), + ), + const SizedBox(width: 16), + Text( + 'Administration', + style: Theme.of(context).textTheme.headlineMedium, + ), + const Spacer(), + // Custom Tab Bar Indicator + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(50), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildTabButton(0, 'Playlists', Icons.playlist_play), + const SizedBox(width: 4), + _buildTabButton(1, 'Users', Icons.people), + ], + ), + ), + ], + ), + ), + + // Content + Expanded( + child: TabBarView( + controller: _tabController, + physics: const NeverScrollableScrollPhysics(), // Custom tabs handle ref + children: const [ + _PlaylistsTab(), + _UsersTab(), + ], ), - child: const Icon(Icons.admin_panel_settings, size: 20), ), - const SizedBox(width: 12), - const Text('Administration'), - ], - ), - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => context.go('/playlists'), - ), - bottom: TabBar( - controller: _tabController, - tabs: const [ - Tab(icon: Icon(Icons.playlist_play), text: 'Playlists'), - Tab(icon: Icon(Icons.people), text: 'Utilisateurs'), ], ), ), - body: TabBarView( - controller: _tabController, - children: const [ - _PlaylistsTab(), - _UsersTab(), - ], - ), + ); + } + + Widget _buildTabButton(int index, String label, IconData icon) { + return AnimatedBuilder( + animation: _tabController.animation!, + builder: (context, child) { + final isSelected = _tabController.index == index; + return GestureDetector( + onTap: () => _tabController.animateTo(index), + child: AnimatedContainer( + duration: AppTheme.durationFast, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + decoration: BoxDecoration( + color: isSelected ? AppColors.focusColor : Colors.transparent, + borderRadius: BorderRadius.circular(50), + ), + child: Row( + children: [ + Icon( + icon, + size: 18, + color: isSelected ? Colors.black : AppColors.textSecondary, + ), + const SizedBox(width: 8), + Text( + label, + style: GoogleFonts.inter( + fontWeight: FontWeight.w600, + fontSize: 14, + color: isSelected ? Colors.black : AppColors.textSecondary, + ), + ), + ], + ), + ), + ); + }, ); } } @@ -91,80 +152,89 @@ class _PlaylistsTab extends ConsumerWidget { return Column( children: [ Padding( - padding: const EdgeInsets.all(16.0), - child: GradientButton( - label: 'Ajouter une playlist', - icon: Icons.add, - onPressed: () => _showPlaylistDialog(context, ref), + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('${playlistsAsync.asData?.value.length ?? 0} Playlists', style: Theme.of(context).textTheme.bodyLarge), + GradientButton( + label: 'Add Playlist', + icon: Icons.add, + onPressed: () => _showPlaylistDialog(context, ref), + ), + ], ), ), Expanded( child: playlistsAsync.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (error, stack) => Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.error_outline, size: 48, color: AppColors.error), - const SizedBox(height: 16), - Text('Erreur: $error'), - const SizedBox(height: 8), - OutlinedButton( - onPressed: () => ref.refresh(playlistsProvider), - child: const Text('Réessayer'), - ), - ], - ), - ), + error: (error, stack) => Center(child: Text('Error: $error', style: const TextStyle(color: AppColors.error))), data: (playlists) { if (playlists.isEmpty) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.playlist_remove, size: 64, color: AppColors.textSecondary), + Icon(Icons.playlist_remove, size: 64, color: AppColors.textSecondary.withOpacity(0.5)), const SizedBox(height: 16), - Text( - 'Aucune playlist', - style: TextStyle(fontSize: 18, color: AppColors.textSecondary), + Text('No playlists configured', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + TextButton( + onPressed: () => _showPlaylistDialog(context, ref), + child: const Text('Add your first playlist'), ), ], ), ); } - return ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 16), + return ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), itemCount: playlists.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), itemBuilder: (context, index) { final playlist = playlists[index]; - return Card( - margin: const EdgeInsets.only(bottom: 8), - child: ListTile( - leading: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - gradient: AppColors.primaryGradient, - borderRadius: BorderRadius.circular(10), + return GlassCard( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.playlist_play, color: AppColors.textPrimary), ), - child: const Icon(Icons.playlist_play, color: Colors.black, size: 20), - ), - title: Text(playlist.name), - subtitle: Text(playlist.dns, style: const TextStyle(fontSize: 11)), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.edit_outlined), - onPressed: () => _showPlaylistDialog(context, ref, playlist), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(playlist.name, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 4), + Text(playlist.dns, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: AppColors.textTertiary)), + ], ), - IconButton( - icon: Icon(Icons.delete_outline, color: AppColors.error), - onPressed: () => _deletePlaylist(context, ref, playlist), - ), - ], - ), + ), + Row( + children: [ + IconButton( + icon: const Icon(Icons.edit_outlined, size: 20), + color: AppColors.textSecondary, + onPressed: () => _showPlaylistDialog(context, ref, playlist), + tooltip: 'Edit', + ), + IconButton( + icon: const Icon(Icons.delete_outline, size: 20), + color: AppColors.error, + onPressed: () => _deletePlaylist(context, ref, playlist), + tooltip: 'Delete', + ), + ], + ), + ], ), ); }, @@ -263,82 +333,102 @@ class _UsersTab extends ConsumerWidget { return Column( children: [ Padding( - padding: const EdgeInsets.all(16.0), - child: GradientButton( - label: 'Créer un utilisateur', - icon: Icons.person_add, - onPressed: () => _showCreateUserDialog(context, ref), + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('${usersAsync.asData?.value.length ?? 0} Users', style: Theme.of(context).textTheme.bodyLarge), + GradientButton( + label: 'Create User', + icon: Icons.person_add, + onPressed: () => _showCreateUserDialog(context, ref), + ), + ], ), ), Expanded( child: usersAsync.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (error, stack) => Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.error_outline, size: 48, color: AppColors.error), - const SizedBox(height: 16), - Text('Erreur: $error'), - OutlinedButton( - onPressed: () => ref.refresh(usersProvider), - child: const Text('Réessayer'), - ), - ], - ), - ), + error: (error, stack) => Center(child: Text('Error: $error', style: const TextStyle(color: AppColors.error))), data: (users) { if (users.isEmpty) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.people_outline, size: 64, color: AppColors.textSecondary), + Icon(Icons.people_outline, size: 64, color: AppColors.textSecondary.withOpacity(0.5)), const SizedBox(height: 16), - Text('Aucun utilisateur', style: TextStyle(fontSize: 18, color: AppColors.textSecondary)), + Text('No users found', style: Theme.of(context).textTheme.titleMedium), ], ), ); } - return ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 16), + return ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), itemCount: users.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), itemBuilder: (context, index) { final user = users[index]; - return Card( - margin: const EdgeInsets.only(bottom: 8), - child: ListTile( - leading: CircleAvatar( - backgroundColor: user.isAdmin ? AppColors.primary.withOpacity(0.2) : AppColors.surface.withOpacity(0.5), - child: Icon(user.isAdmin ? Icons.admin_panel_settings : Icons.person, color: user.isAdmin ? AppColors.primary : AppColors.textSecondary), - ), - title: Row( - children: [ - Text(user.username), - if (user.isAdmin) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration(color: AppColors.primary.withOpacity(0.2), borderRadius: BorderRadius.circular(12)), - child: Text('Admin', style: TextStyle(fontSize: 10, color: AppColors.primary, fontWeight: FontWeight.w600)), - ), + return GlassCard( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: user.isAdmin ? AppColors.focusColor : AppColors.surface, + shape: BoxShape.circle, + ), + child: Icon( + user.isAdmin ? Icons.admin_panel_settings : Icons.person, + color: user.isAdmin ? Colors.black : AppColors.textPrimary, + size: 24 + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(user.username, style: Theme.of(context).textTheme.titleMedium), + if (user.isAdmin) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: AppColors.focusColor.withOpacity(0.2), + borderRadius: BorderRadius.circular(50), + ), + child: const Text('ADMIN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: AppColors.focusColor)), + ), + ], + ], + ), + const SizedBox(height: 4), + Text('ID: ${user.id}', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: AppColors.textTertiary)), + ], + ), + ), + PopupMenuButton( + icon: const Icon(Icons.more_vert, color: AppColors.textSecondary), + color: AppColors.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + onSelected: (value) { + switch (value) { + case 'password': _showChangePasswordDialog(context, ref, user); break; + case 'delete': _deleteUser(context, ref, user); break; + } + }, + itemBuilder: (context) => [ + const PopupMenuItem(value: 'password', child: Row(children: [Icon(Icons.lock_reset, size: 18), SizedBox(width: 8), Text('Change Password')])), + const PopupMenuItem(value: 'delete', child: Row(children: [Icon(Icons.delete, color: AppColors.error, size: 18), SizedBox(width: 8), Text('Delete', style: TextStyle(color: AppColors.error))])), ], - ], - ), - subtitle: Text('ID: ${user.id}', style: const TextStyle(fontSize: 11)), - trailing: PopupMenuButton( - onSelected: (value) { - switch (value) { - case 'password': _showChangePasswordDialog(context, ref, user); break; - case 'delete': _deleteUser(context, ref, user); break; - } - }, - itemBuilder: (context) => [ - const PopupMenuItem(value: 'password', child: ListTile(leading: Icon(Icons.lock_reset), title: Text('Changer mot de passe'), dense: true)), - PopupMenuItem(value: 'delete', child: ListTile(leading: Icon(Icons.delete, color: AppColors.error), title: Text('Supprimer', style: TextStyle(color: AppColors.error)), dense: true)), - ], - ), + ), + ], ), ); }, diff --git a/lib/features/iptv/widgets/live_tv_tab.dart b/lib/features/iptv/widgets/live_tv_tab.dart index 8147541..0b56c2c 100644 --- a/lib/features/iptv/widgets/live_tv_tab.dart +++ b/lib/features/iptv/widgets/live_tv_tab.dart @@ -10,9 +10,10 @@ 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 '../../../core/widgets/components/ui_components.dart'; import 'channel_card.dart'; -/// Live TV tab - Enhanced with Search, Favorites and Grid/List View +/// Live TV tab - Apple TV Style with Horizontal Category Filter class LiveTVTab extends ConsumerStatefulWidget { final PlaylistConfig playlist; @@ -27,14 +28,15 @@ class _LiveTVTabState extends ConsumerState // State String? _selectedCategory; - int _currentPage = 0; - static const int _itemsPerPage = 100; // Increased for grid view // UI State bool _isGridView = true; final TextEditingController _searchController = TextEditingController(); bool _showFavoritesOnly = false; String _searchQuery = ''; + + // Scroll Controllers + final ScrollController _mainScrollController = ScrollController(); @override void initState() { @@ -42,7 +44,6 @@ class _LiveTVTabState extends ConsumerState _searchController.addListener(() { setState(() { _searchQuery = _searchController.text.toLowerCase(); - _currentPage = 0; // Reset page on search }); }); } @@ -50,6 +51,7 @@ class _LiveTVTabState extends ConsumerState @override void dispose() { _searchController.dispose(); + _mainScrollController.dispose(); super.dispose(); } @@ -62,316 +64,170 @@ class _LiveTVTabState extends ConsumerState final channelsAsync = ref.watch(liveChannelsByPlaylistProvider(widget.playlist)); final favorites = ref.watch(favoritesProvider); - - return Scaffold( // Use scaffold for toolbar body structure - backgroundColor: Colors.transparent, - body: Column( - children: [ - // Toolbar - _buildToolbar(), - - // Content - Expanded( - child: channelsAsync.when( - data: (groupedChannels) { - // 1. Flatten for global search/filter if needed - final allChannels = groupedChannels.values.expand((element) => element).toList(); - - List displayedChannels = []; - bool isGlobalMode = _searchQuery.isNotEmpty || _showFavoritesOnly; - - if (isGlobalMode) { - // Global Filter Mode - displayedChannels = allChannels.where((channel) { - final matchesSearch = _searchQuery.isEmpty || - channel.name.toLowerCase().contains(_searchQuery); - final matchesFavorite = !_showFavoritesOnly || - favorites.contains(channel.streamId); - return matchesSearch && matchesFavorite; - }).toList(); - } else if (_selectedCategory != null) { - // Category Mode - displayedChannels = groupedChannels[_selectedCategory] ?? []; - } - - // If no global mode and no category selected, show Category Grid - if (!isGlobalMode && _selectedCategory == null) { - if (groupedChannels.isEmpty) { - return const Center(child: Text('No channels found')); - } - return _buildCategoryGrid(groupedChannels); - } - - // Show Channels (Grid or List) - if (displayedChannels.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.tv_off, size: 64, color: AppColors.textTertiary), - const SizedBox(height: 16), - Text( - _showFavoritesOnly ? 'No favorites found' : 'No channels found', - style: Theme.of(context).textTheme.bodyLarge, - ), - ], - ), - ); - } - - return _buildChannelsView(displayedChannels, isGlobalMode); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, s) => Center(child: Text('Error: $e')), - ), - ), - ], - ), - ); - } - - Widget _buildToolbar() { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: AppColors.surface, - border: Border(bottom: BorderSide(color: AppColors.border)), - ), - child: Row( - children: [ - // Search Bar - Expanded( - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: 'Search channels...', - prefixIcon: const Icon(Icons.search), - suffixIcon: _searchQuery.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear), - onPressed: () => _searchController.clear(), - ) - : null, - isDense: true, - filled: true, - fillColor: AppColors.surface.withOpacity(0.5), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide.none, - ), - ), - ), - ), - const SizedBox(width: 12), - - // Favorites Toggle - IconButton( - tooltip: 'Favorites Only', - icon: Icon( - _showFavoritesOnly ? Icons.favorite : Icons.favorite_border, - color: _showFavoritesOnly ? AppColors.primary : AppColors.textSecondary, - ), - onPressed: () { - setState(() { - _showFavoritesOnly = !_showFavoritesOnly; - _selectedCategory = null; // Reset category when toggling favorites - _currentPage = 0; - }); - }, - ), - - // View Toggle - IconButton( - tooltip: _isGridView ? 'List View' : 'Grid View', - icon: Icon( - _isGridView ? Icons.view_list : Icons.grid_view, - color: AppColors.textSecondary, - ), - onPressed: () => setState(() => _isGridView = !_isGridView), - ), - ], - ), - ); - } - - Widget _buildCategoryGrid(Map> groupedChannels) { final settings = ref.watch(iptvSettingsProvider); - var categories = groupedChannels.keys.toList(); - - // Apply Settings Filter (Hidden categories) - if (settings.liveTvKeywords.isNotEmpty) { - categories = categories.where((cat) => settings.matchesLiveTvFilter(cat)).toList(); - } - categories.sort(); - final crossAxisCount = ResponsiveLayout.value( - context, - mobile: 2, - tablet: 3, - desktop: 5, - ); + return Scaffold( + backgroundColor: Colors.transparent, + body: channelsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, s) => Center(child: Text('Error: $e')), + data: (groupedChannels) { + // Groups + var categories = groupedChannels.keys.toList(); + if (settings.liveTvKeywords.isNotEmpty) { + categories = categories.where((cat) => settings.matchesLiveTvFilter(cat)).toList(); + } + categories.sort(); + + // Filter Logic + List displayedChannels = []; + + if (_searchQuery.isNotEmpty) { + // Global Search + displayedChannels = groupedChannels.values.expand((l) => l) + .where((c) => c.name.toLowerCase().contains(_searchQuery)) + .toList(); + } else if (_showFavoritesOnly) { + // Favorites + displayedChannels = groupedChannels.values.expand((l) => l) + .where((c) => favorites.contains(c.streamId)) + .toList(); + } else if (_selectedCategory != null) { + // Selected Category + displayedChannels = groupedChannels[_selectedCategory] ?? []; + } else { + // Default: Show first category or All if practical, but "All" is too big. + // Apple TV pattern: Auto-select first category if nothing selected. + if (categories.isNotEmpty) { + // We don't want to trigger setState during build, so we just use the first category functionality + // But visualized as "All" might be confusing with map keys. + // Let's default to the first category in the list. + _selectedCategory ??= categories.first; + displayedChannels = groupedChannels[_selectedCategory] ?? []; + } + } - return GridView.builder( - padding: const EdgeInsets.all(16), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: crossAxisCount, - mainAxisSpacing: 16, - crossAxisSpacing: 16, - childAspectRatio: 1.5, - ), - itemCount: categories.length, - itemBuilder: (context, index) { - final category = categories[index]; - final count = groupedChannels[category]!.length; - - return InkWell( - onTap: () => setState(() { - _selectedCategory = category; - _currentPage = 0; - }), - borderRadius: BorderRadius.circular(AppTheme.radiusMd), - child: Container( - decoration: BoxDecoration( - color: AppColors.surface, - borderRadius: BorderRadius.circular(AppTheme.radiusMd), - border: Border.all(color: AppColors.border), - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - AppColors.surface.withOpacity(0.8), - AppColors.surface, - ], - ), - ), - child: Stack( - children: [ - Positioned( - right: -10, - bottom: -10, - child: Icon( - Icons.tv, - size: 80, - color: Colors.white.withOpacity(0.03), - ), - ), - Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - category, - style: GoogleFonts.inter( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, + return Column( + children: [ + // Minimalist Header + Container( + padding: const EdgeInsets.only(left: 24, right: 24, top: 16, bottom: 8), + child: Row( + children: [ + Text( + 'Live TV', + style: Theme.of(context).textTheme.headlineMedium, + ), + const Spacer(), + + // Search Pill + Container( + width: 250, + height: 36, + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppTheme.radiusFull), + border: Border.all(color: AppColors.border), ), - const SizedBox(height: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: AppColors.primary.withOpacity(0.2), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - '$count channels', - style: GoogleFonts.inter( - fontSize: 12, - color: AppColors.primary, - fontWeight: FontWeight.bold, + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + const Icon(Icons.search, size: 18, color: AppColors.textSecondary), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: _searchController, + style: const TextStyle(fontSize: 13, color: AppColors.textPrimary), + decoration: const InputDecoration( + hintText: 'Search channels', + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.only(bottom: 12), + ), + ), ), - ), + if (_searchQuery.isNotEmpty) + GestureDetector( + onTap: () => _searchController.clear(), + child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary) + ), + ], ), - ], + ), + const SizedBox(width: 16), + + // Favorites Toggle + IconButton( + icon: Icon( + _showFavoritesOnly ? Icons.favorite : Icons.favorite_border, + color: _showFavoritesOnly ? AppColors.error : AppColors.textSecondary, + ), + onPressed: () { + setState(() { + _showFavoritesOnly = !_showFavoritesOnly; + if (_showFavoritesOnly) _searchController.clear(); + }); + }, + tooltip: 'Favorites Only', + ), + IconButton( + icon: Icon( + _isGridView ? Icons.view_list : Icons.grid_view, + color: AppColors.textSecondary, + ), + onPressed: () => setState(() => _isGridView = !_isGridView), + tooltip: 'Toggle View', + ), + ], + ), + ), + + // Categories Horizontal List (Only if not searching/favorites) + if (_searchQuery.isEmpty && !_showFavoritesOnly) + Container( + height: 48, + padding: const EdgeInsets.symmetric(vertical: 4), + child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 24), + scrollDirection: Axis.horizontal, + itemCount: categories.length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final category = categories[index]; + final isSelected = category == _selectedCategory; + return CategoryChip( + label: category, + isSelected: isSelected, + onTap: () { + setState(() { + _selectedCategory = category; + _mainScrollController.jumpTo(0); // Reset scroll + }); + }, + ); + }, ), ), - ], - ), - ), - ); - }, - ); - } - Widget _buildChannelsView(List channels, bool isGlobalMode) { - // Pagination - final totalPages = (channels.length / _itemsPerPage).ceil(); - final startIndex = _currentPage * _itemsPerPage; - final endIndex = (startIndex + _itemsPerPage > channels.length) - ? channels.length - : startIndex + _itemsPerPage; - final pageChannels = channels.sublist(startIndex, endIndex); + const SizedBox(height: 8), - return Column( - children: [ - // Breadcrumb / Back Navigation - if (!isGlobalMode && _selectedCategory != null) - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - color: AppColors.surface.withOpacity(0.5), - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.arrow_back_ios, size: 16), - onPressed: () => setState(() => _selectedCategory = null), - tooltip: 'Back to Categories', - ), - Text( - _selectedCategory!, - style: GoogleFonts.inter( - fontWeight: FontWeight.bold, - fontSize: 18, - ), - ), - const Spacer(), - Text( - '${channels.length} items', - style: GoogleFonts.inter(color: AppColors.textSecondary), - ), - ], - ), - ), - - // Grid/List Content - Expanded( - child: _isGridView - ? _buildGridView(pageChannels) - : _buildListView(pageChannels), - ), - - // Pagination Controls - if (totalPages > 1) - Container( - height: 50, - decoration: BoxDecoration( - color: AppColors.surface, - border: Border(top: BorderSide(color: AppColors.border)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - IconButton( - icon: const Icon(Icons.chevron_left), - onPressed: _currentPage > 0 ? () => setState(() => _currentPage--) : null, - ), - Text( - 'Page ${_currentPage + 1} of $totalPages', - style: GoogleFonts.inter(fontWeight: FontWeight.w500), - ), - IconButton( - icon: const Icon(Icons.chevron_right), - onPressed: _currentPage < totalPages - 1 ? () => setState(() => _currentPage++) : null, - ), - ], - ), - ), - ], + // Channels Content + Expanded( + child: displayedChannels.isEmpty + ? Center( + child: Text( + _showFavoritesOnly ? 'No favorites' : 'No channels found', + style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: AppColors.textSecondary), + ), + ) + : _isGridView + ? _buildGridView(displayedChannels) + : _buildListView(displayedChannels), + ), + ], + ); + }, + ), ); } @@ -384,21 +240,22 @@ class _LiveTVTabState extends ConsumerState ); return GridView.builder( - padding: const EdgeInsets.all(16), + controller: _mainScrollController, + padding: const EdgeInsets.fromLTRB(24, 8, 24, 24), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: columns, mainAxisSpacing: 16, crossAxisSpacing: 16, - childAspectRatio: 1.4, // Card ratio + childAspectRatio: 1.3, // Card ratio 4:3ish ), itemCount: channels.length, itemBuilder: (context, index) { final channel = channels[index]; return ChannelCard( - streamId: channel.streamId, // Now required + streamId: channel.streamId, name: channel.name, iconUrl: _getProxiedIconUrl(channel.streamIcon), - currentProgram: null, // ShortEPG requires async fetch + currentProgram: null, onTap: () => _playChannel(channel), ); }, @@ -407,19 +264,20 @@ class _LiveTVTabState extends ConsumerState Widget _buildListView(List channels) { return ListView.separated( - padding: const EdgeInsets.all(16), + controller: _mainScrollController, + padding: const EdgeInsets.fromLTRB(24, 8, 24, 24), itemCount: channels.length, separatorBuilder: (_, __) => const SizedBox(height: 8), itemBuilder: (context, index) { final channel = channels[index]; return SizedBox( - height: 80, // Fixed height for list item + height: 70, child: ChannelCard( streamId: channel.streamId, name: channel.name, iconUrl: _getProxiedIconUrl(channel.streamIcon), - height: 80, - width: double.infinity, // Full width + height: 70, + width: double.infinity, onTap: () => _playChannel(channel), ), ); diff --git a/lib/features/iptv/widgets/movies_tab.dart b/lib/features/iptv/widgets/movies_tab.dart index 392bcef..4d11e1d 100644 --- a/lib/features/iptv/widgets/movies_tab.dart +++ b/lib/features/iptv/widgets/movies_tab.dart @@ -1,13 +1,17 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:cached_network_image/cached_network_image.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 '../../../core/widgets/components/hero_carousel.dart'; +import '../../../core/widgets/components/ui_components.dart'; +import '../../favorites/providers/watch_history_provider.dart'; import '../models/xtream_models.dart'; import '../providers/xtream_provider.dart'; import '../providers/settings_provider.dart'; -import '../providers/watch_history_provider.dart'; import '../screens/player_screen.dart'; class MoviesTab extends ConsumerStatefulWidget { @@ -23,7 +27,7 @@ class _MoviesTabState extends ConsumerState { final ScrollController _scrollController = ScrollController(); final TextEditingController _searchController = TextEditingController(); final List _movies = []; - List? _searchResults; // Results from full catalog search + List? _searchResults; String _searchQuery = ''; bool _isLoading = false; bool _isSearching = false; @@ -103,7 +107,7 @@ class _MoviesTabState extends ConsumerState { try { final service = ref.read(xtreamServiceProvider(widget.playlist)); - final newMovies = await service.getMovies( + final newMovies = await service.getMoviesPaginated( offset: _currentOffset, limit: _pageSize, ); @@ -126,252 +130,177 @@ class _MoviesTabState extends ConsumerState { } } + void _playMovie(Movie movie) { + ref.read(watchHistoryProvider.notifier).markMovieWatched(movie.streamId); + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PlayerScreen( + streamId: movie.streamId, + title: movie.name, + playlist: widget.playlist, + streamType: StreamType.vod, + containerExtension: movie.containerExtension ?? 'mp4', + ), + ), + ); + } + @override Widget build(BuildContext context) { final settings = ref.watch(iptvSettingsProvider); + final watchHistory = ref.watch(watchHistoryProvider); - // Use search results if searching, otherwise use loaded movies with category filter List displayMovies; if (_searchQuery.isNotEmpty && _searchResults != null) { displayMovies = _searchResults!; } else { displayMovies = settings.moviesKeywords.isEmpty ? _movies - : _movies.where((m) => settings.matchesMoviesFilter(m.categoryName)).toList(); + : _movies.where((m) => settings.matchesMovieFilter(m.categoryName)).toList(); } + // Hero Items (Take 5 random or first 5) + final heroItems = _movies.take(5).map((m) => HeroItem( + id: m.streamId, + title: m.name, + imageUrl: m.streamIcon ?? '', + subtitle: m.rating != null ? '${m.rating} ★' : null, + onMoreInfo: () { + _playMovie(m); + }, + )).toList(); + if (_movies.isEmpty && !_isLoading) { return const Center(child: Text('No movies available')); } - return Column( - children: [ - // Search bar - Padding( - padding: const EdgeInsets.all(8.0), - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: 'Search all movies...', - hintStyle: GoogleFonts.roboto(color: Colors.grey.shade500), - prefixIcon: _isSearching - ? const Padding( - padding: EdgeInsets.all(12), - child: SizedBox( - width: 20, height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ) - : const Icon(Icons.search, color: Colors.grey), - suffixIcon: _searchQuery.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear, color: Colors.grey), - onPressed: () { - _searchController.clear(); - _onSearchChanged(''); - }, - ) - : null, - filled: true, - fillColor: Colors.grey.shade800, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - ), - style: GoogleFonts.roboto(color: Colors.white), - onChanged: _onSearchChanged, - ), - ), - - // Results count - if (_searchQuery.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - '${displayMovies.length} result${displayMovies.length != 1 ? 's' : ''} found', - style: GoogleFonts.roboto(fontSize: 12, color: Colors.grey.shade600), - ), - ), - ), - - // Movie grid - Expanded( - child: displayMovies.isEmpty && _movies.isNotEmpty - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - _searchQuery.isNotEmpty ? Icons.search_off : Icons.filter_list_off, - size: 48, - color: Colors.grey, - ), - const SizedBox(height: 16), - Text( - _searchQuery.isNotEmpty - ? 'No movies match "$_searchQuery"' - : 'No movies match the filter', - style: GoogleFonts.roboto(color: Colors.grey), - ), - ], - ), - ) - : GridView.builder( - controller: _scrollController, - padding: const EdgeInsets.all(8), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 7, - crossAxisSpacing: 6, - mainAxisSpacing: 6, - childAspectRatio: 0.65, - ), - itemCount: displayMovies.length + (_hasMore && _searchQuery.isEmpty ? 1 : 0), - itemBuilder: (context, index) { - if (index >= displayMovies.length) { - return const Center(child: CircularProgressIndicator()); - } - - final movie = displayMovies[index]; - return _MovieCard( - movie: movie, - playlist: widget.playlist, - ); - }, - ), - ), - ], + final double gridItemRatio = 0.65; + final int crossAxisCount = ResponsiveLayout.value( + context, + mobile: 3, + tablet: 5, + desktop: 7, ); - } -} -class _MovieCard extends ConsumerWidget { - final Movie movie; - final PlaylistConfig playlist; - - const _MovieCard({ - required this.movie, - required this.playlist, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final watchHistory = ref.watch(watchHistoryProvider); - final isWatched = watchHistory.isMovieWatched(movie.streamId); - - return Card( - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () { - // Mark as watched when opening - ref.read(watchHistoryProvider.notifier).markMovieWatched(movie.streamId); - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => PlayerScreen( - streamId: movie.streamId, - title: movie.name, - playlist: playlist, - streamType: StreamType.vod, - containerExtension: movie.containerExtension ?? 'mp4', - ), - ), - ); - }, - onLongPress: () { - // Toggle watched status on long press - ref.read(watchHistoryProvider.notifier).toggleMovieWatched(movie.streamId); - }, - child: Stack( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + return CustomScrollView( + controller: _scrollController, + slivers: [ + // Header + SliverToBoxAdapter( + child: Container( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), + child: Row( children: [ - Expanded( - child: movie.streamIcon != null - ? CachedNetworkImage( - imageUrl: movie.streamIcon!, - fit: BoxFit.cover, - placeholder: (context, url) => Container( - color: Colors.grey.shade800, - child: const Center( - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), - errorWidget: (context, url, error) => Container( - color: Colors.grey.shade800, - child: const Icon(Icons.movie, size: 48), - ), - ) - : Container( - color: Colors.grey.shade800, - child: const Icon(Icons.movie, size: 48), - ), + Text( + 'Movies', + style: Theme.of(context).textTheme.headlineMedium, ), + const Spacer(), Container( - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + width: 300, + height: 40, + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppTheme.radiusFull), + border: Border.all(color: AppColors.border), + ), + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( children: [ - Text( - movie.name, - style: GoogleFonts.roboto( - fontSize: 11, - fontWeight: FontWeight.w500, + const Icon(Icons.search, size: 20, color: AppColors.textSecondary), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: _searchController, + style: const TextStyle(fontSize: 14, color: AppColors.textPrimary), + decoration: const InputDecoration( + hintText: 'Search', + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: EdgeInsets.only(bottom: 12), + isDense: true, + ), + onChanged: _onSearchChanged, ), - maxLines: 2, - overflow: TextOverflow.ellipsis, ), - if (movie.rating != null) ...[ - const SizedBox(height: 2), - Row( - children: [ - Icon( - Icons.star, - size: 12, - color: Colors.amber.shade700, - ), - const SizedBox(width: 2), - Text( - movie.rating!, - style: GoogleFonts.roboto( - fontSize: 10, - color: Colors.grey.shade600, - ), - ), - ], + if (_isSearching) + const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)), + if (_searchQuery.isNotEmpty) + GestureDetector( + onTap: () { + _searchController.clear(); + _onSearchChanged(''); + }, + child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary), ), - ], ], ), ), ], ), - // Watched indicator - if (isWatched) - Positioned( - top: 4, - right: 4, - child: Container( - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - color: Colors.green, - borderRadius: BorderRadius.circular(10), - ), - child: const Icon( - Icons.check, - size: 14, - color: Colors.white, - ), - ), - ), - ], + ), ), - ), + + // Hero Carousel + if (_searchQuery.isEmpty && heroItems.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(bottom: 24), + child: HeroCarousel( + items: heroItems, + onTap: (item) { + final movie = _movies.firstWhere((element) => element.streamId == item.id); + _playMovie(movie); + }, + ), + ), + ), + + // Grid + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + crossAxisSpacing: 24, + mainAxisSpacing: 32, + childAspectRatio: gridItemRatio, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index >= displayMovies.length) return null; + final movie = displayMovies[index]; + final isWatched = watchHistory.isMovieWatched(movie.streamId); + + return MediaCard( + title: movie.name, + imageUrl: movie.streamIcon, + subtitle: movie.rating != null ? '${movie.rating} ★' : null, + rating: movie.rating, + isWatched: isWatched, + onTap: () => _playMovie(movie), + onLongPress: () { + ref.read(watchHistoryProvider.notifier).toggleMovieWatched(movie.streamId); + }, + ); + }, + childCount: displayMovies.length, + ), + ), + ), + + // Loader + if (_isLoading) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator()), + ), + ), + ], ); } } diff --git a/lib/features/iptv/widgets/series_tab.dart b/lib/features/iptv/widgets/series_tab.dart index db6da38..1c9551d 100644 --- a/lib/features/iptv/widgets/series_tab.dart +++ b/lib/features/iptv/widgets/series_tab.dart @@ -1,9 +1,13 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:cached_network_image/cached_network_image.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 '../../../core/widgets/components/hero_carousel.dart'; +import '../../../core/widgets/components/ui_components.dart'; import '../models/xtream_models.dart'; import '../providers/xtream_provider.dart'; import '../providers/settings_provider.dart'; @@ -125,11 +129,22 @@ class _SeriesTabState extends ConsumerState { } } + void _openSeries(Series series) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SeriesDetailScreen( + series: series, + playlist: widget.playlist, + ), + ), + ); + } + @override Widget build(BuildContext context) { final settings = ref.watch(iptvSettingsProvider); - // Use search results if searching, otherwise use loaded series with category filter List displaySeries; if (_searchQuery.isNotEmpty && _searchResults != null) { displaySeries = _searchResults!; @@ -143,194 +158,135 @@ class _SeriesTabState extends ConsumerState { return const Center(child: Text('No series available')); } - return Column( - children: [ - // Search bar - Padding( - padding: const EdgeInsets.all(8.0), - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: 'Search all series...', - hintStyle: GoogleFonts.roboto(color: Colors.grey.shade500), - prefixIcon: _isSearching - ? const Padding( - padding: EdgeInsets.all(12), - child: SizedBox( - width: 20, height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ) - : const Icon(Icons.search, color: Colors.grey), - suffixIcon: _searchQuery.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear, color: Colors.grey), - onPressed: () { - _searchController.clear(); - _onSearchChanged(''); - }, - ) - : null, - filled: true, - fillColor: Colors.grey.shade800, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - ), - style: GoogleFonts.roboto(color: Colors.white), - onChanged: _onSearchChanged, - ), - ), - - // Results count - if (_searchQuery.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - '${displaySeries.length} result${displaySeries.length != 1 ? 's' : ''} found', - style: GoogleFonts.roboto(fontSize: 12, color: Colors.grey.shade600), - ), - ), - ), - - // Series grid - Expanded( - child: displaySeries.isEmpty && _series.isNotEmpty - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + // Hero Items + final heroItems = _series.take(5).map((s) => HeroItem( + id: s.seriesId.toString(), // ID logic might differ for Series + title: s.name, + imageUrl: s.cover ?? '', + subtitle: s.rating != null ? '${s.rating} ★' : null, + onMoreInfo: () => _openSeries(s), + )).toList(); + + final double gridItemRatio = 0.65; + final int crossAxisCount = ResponsiveLayout.value( + context, + mobile: 3, + tablet: 5, + desktop: 7, + ); + + return CustomScrollView( + controller: _scrollController, + slivers: [ + // Header + SliverToBoxAdapter( + child: Container( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), + child: Row( + children: [ + Text( + 'TV Series', + style: Theme.of(context).textTheme.headlineMedium, + ), + const Spacer(), + Container( + width: 300, + height: 40, + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppTheme.radiusFull), + border: Border.all(color: AppColors.border), + ), + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( children: [ - Icon( - _searchQuery.isNotEmpty ? Icons.search_off : Icons.filter_list_off, - size: 48, - color: Colors.grey, - ), - const SizedBox(height: 16), - Text( - _searchQuery.isNotEmpty - ? 'No series match "$_searchQuery"' - : 'No series match the filter', - style: GoogleFonts.roboto(color: Colors.grey), + const Icon(Icons.search, size: 20, color: AppColors.textSecondary), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: _searchController, + style: const TextStyle(fontSize: 14, color: AppColors.textPrimary), + decoration: const InputDecoration( + hintText: 'Search', + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: EdgeInsets.only(bottom: 12), + isDense: true, + ), + onChanged: _onSearchChanged, + ), ), + if (_isSearching) + const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)), + if (_searchQuery.isNotEmpty) + GestureDetector( + onTap: () { + _searchController.clear(); + _onSearchChanged(''); + }, + child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary), + ), ], ), - ) - : GridView.builder( - controller: _scrollController, - padding: const EdgeInsets.all(8), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 7, - crossAxisSpacing: 6, - mainAxisSpacing: 6, - childAspectRatio: 0.65, - ), - itemCount: displaySeries.length + (_hasMore && _searchQuery.isEmpty ? 1 : 0), - itemBuilder: (context, index) { - if (index >= displaySeries.length) { - return const Center(child: CircularProgressIndicator()); - } - - final serie = displaySeries[index]; - return _SeriesCard(series: serie, playlist: widget.playlist); - }, ), + ], + ), + ), ), + + // Hero Carousel + if (_searchQuery.isEmpty && heroItems.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(bottom: 24), + child: HeroCarousel( + items: heroItems, + onTap: (item) { + final series = _series.firstWhere((s) => s.name == item.title); // Fallback by title if ID mismatch + _openSeries(series); + }, + ), + ), + ), + + // Grid + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + crossAxisSpacing: 24, + mainAxisSpacing: 32, + childAspectRatio: gridItemRatio, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index >= displaySeries.length) return null; + final serie = displaySeries[index]; + return MediaCard( + title: serie.name, + imageUrl: serie.cover, + subtitle: serie.rating != null ? '${serie.rating} ★' : null, + rating: serie.rating, + placeholderIcon: Icons.tv, + onTap: () => _openSeries(serie), + ); + }, + childCount: displaySeries.length, + ), + ), + ), + + // Loader + if (_isLoading) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator()), + ), + ), ], ); } } - -class _SeriesCard extends StatelessWidget { - final Series series; - final PlaylistConfig playlist; - - const _SeriesCard({required this.series, required this.playlist}); - - @override - Widget build(BuildContext context) { - return Card( - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () { - // Navigate to series detail screen for episode selection - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => SeriesDetailScreen( - series: series, - playlist: playlist, - ), - ), - ); - }, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: series.cover != null - ? CachedNetworkImage( - imageUrl: series.cover!, - fit: BoxFit.cover, - placeholder: (context, url) => Container( - color: Colors.grey.shade800, - child: const Center( - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), - errorWidget: (context, url, error) => Container( - color: Colors.grey.shade800, - child: const Icon(Icons.tv, size: 48), - ), - ) - : Container( - color: Colors.grey.shade800, - child: const Icon(Icons.tv, size: 48), - ), - ), - Container( - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - series.name, - style: GoogleFonts.roboto( - fontSize: 11, - fontWeight: FontWeight.w500, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - if (series.rating != null) ...[ - const SizedBox(height: 2), - Row( - children: [ - Icon( - Icons.star, - size: 12, - color: Colors.amber.shade700, - ), - const SizedBox(width: 2), - Text( - series.rating!, - style: GoogleFonts.roboto( - fontSize: 10, - color: Colors.grey.shade600, - ), - ), - ], - ), - ], - ], - ), - ), - ], - ), - ), - ); - } -}