From a1b1363bc8074e626edc57f5b2a5af03f82dc6c0 Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Fri, 27 Mar 2026 15:15:44 +0100 Subject: [PATCH] feat: add mobile Xtream service, data providers, and UI widgets for Live TV and Movies. --- lib/core/theme/app_decorations.dart | 29 +- .../screens/mobile_series_detail_screen.dart | 9 +- .../iptv/services/xtream_service_mobile.dart | 80 +- .../iptv/widgets/mobile_live_tv_tab.dart | 727 +++++++++--------- .../iptv/widgets/mobile_movies_tab.dart | 213 +++-- .../iptv/widgets/mobile_series_tab.dart | 471 ++++++------ .../providers/mobile_xtream_providers.dart | 17 +- lib/mobile/widgets/mobile_category_card.dart | 76 ++ lib/mobile/widgets/mobile_poster_card.dart | 27 +- 9 files changed, 875 insertions(+), 774 deletions(-) create mode 100644 lib/mobile/widgets/mobile_category_card.dart diff --git a/lib/core/theme/app_decorations.dart b/lib/core/theme/app_decorations.dart index 007453c..3c2c84a 100644 --- a/lib/core/theme/app_decorations.dart +++ b/lib/core/theme/app_decorations.dart @@ -6,8 +6,8 @@ import 'package:flutter/material.dart'; class AppDecorations { AppDecorations._(); - static bool _isDark(BuildContext context) => - Theme.of(context).brightness == Brightness.dark; + // Force Dark/Glossy mode for IPTV experience regardless of system theme + static bool _isDark(BuildContext context) => true; // ─── Backgrounds ──────────────────────────────────────────────────────────── @@ -86,9 +86,9 @@ class AppDecorations { border: Border.all(color: const Color(0x28FFFFFF), width: 1), boxShadow: const [ BoxShadow( - color: Color(0xCC000000), // Increased opacity from 0xAA + color: Color(0xCC000000), blurRadius: 16, - spreadRadius: 2, // Added spread to make it visible all around + spreadRadius: 2, offset: Offset(0, 4), ), ], @@ -119,11 +119,10 @@ class AppDecorations { gradient: const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Color(0x25FFFFFF), Color(0x00FFFFFF)], // Reduced from 0x40 - stops: [0.0, 0.45], // Reduced stop + colors: [Color(0x45FFFFFF), Color(0x00FFFFFF)], + stops: [0.0, 0.5], ), - borderRadius: - BorderRadius.vertical(top: Radius.circular(radius)), + borderRadius: BorderRadius.vertical(top: Radius.circular(radius)), ); } return BoxDecoration( @@ -133,21 +132,23 @@ class AppDecorations { colors: [Color(0x0A000000), Color(0x00000000)], stops: [0.0, 0.55], ), - borderRadius: - BorderRadius.vertical(top: Radius.circular(radius)), + borderRadius: BorderRadius.vertical(top: Radius.circular(radius)), ); } /// Smaller channel card base (top area behind the logo). static BoxDecoration channelCardBase(BuildContext context) { if (_isDark(context)) { - return const BoxDecoration( - gradient: LinearGradient( + return BoxDecoration( + gradient: const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Color(0xFF121214), Color(0xFF1C1C1E)], // Top darkened from 3A3A3C + colors: [Color(0xFF2C2C2E), Color(0xFF1C1C1E)], + ), + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + border: Border( + top: BorderSide(color: Colors.white.withOpacity(0.12), width: 0.5), ), - borderRadius: BorderRadius.vertical(top: Radius.circular(12)), ); } return const BoxDecoration( diff --git a/lib/features/iptv/screens/mobile_series_detail_screen.dart b/lib/features/iptv/screens/mobile_series_detail_screen.dart index 27efc8a..fd498a2 100644 --- a/lib/features/iptv/screens/mobile_series_detail_screen.dart +++ b/lib/features/iptv/screens/mobile_series_detail_screen.dart @@ -30,7 +30,7 @@ class _MobileSeriesDetailScreenState model.SeriesInfo? _seriesInfo; bool _isLoading = true; String? _error; - int _selectedSeason = 1; + String _selectedSeason = "1"; @override void initState() { @@ -64,8 +64,7 @@ class _MobileSeriesDetailScreenState _seriesInfo = info; _isLoading = false; if (_seriesInfo!.episodes.isNotEmpty) { - final firstKey = _seriesInfo!.episodes.keys.first; - _selectedSeason = int.tryParse(firstKey.toString()) ?? 1; + _selectedSeason = _seriesInfo!.episodes.keys.first.toString(); } }); } @@ -258,7 +257,7 @@ class _MobileSeriesDetailScreenState selected: isSelected, onSelected: (selected) { if (selected) { - setState(() => _selectedSeason = int.tryParse(seasonNum.toString()) ?? 1); + setState(() => _selectedSeason = seasonNum.toString()); } }, backgroundColor: isDark @@ -310,7 +309,7 @@ class _MobileSeriesDetailScreenState final watchHistory = ref.watch(mobileWatchHistoryProvider); final episodeKey = MobileWatchHistory.episodeKey( widget.series.seriesId, - _selectedSeason, + int.tryParse(_selectedSeason) ?? 1, episode.episodeNum, ); final isWatched = watchHistory.isEpisodeWatched(episodeKey); diff --git a/lib/features/iptv/services/xtream_service_mobile.dart b/lib/features/iptv/services/xtream_service_mobile.dart index 98d9dca..5a2dbfc 100644 --- a/lib/features/iptv/services/xtream_service_mobile.dart +++ b/lib/features/iptv/services/xtream_service_mobile.dart @@ -396,29 +396,77 @@ class XtreamServiceMobile { 'password': _password, 'action': 'get_series', }, + options: Options(receiveTimeout: const Duration(seconds: 45)), ); if (response.statusCode == 200 && response.data is List) { return (response.data as List) .map((e) => model.Series.fromJson(e)) .toList(); } + if (kDebugMode) { + print('⚠️ get_series returned unexpected status or type: ${response.statusCode}'); + } return []; } catch (e) { return []; } } - /// Search series - Future> searchSeries(String query) async { - final all = await getSeries(); - return all - .where((s) => s.name.toLowerCase().contains(query.toLowerCase())) - .toList(); + + /// Get series paginated / filtered by category + Future> getSeriesPaginated({String? categoryId}) async { + try { + final queryParams = { + 'username': _username, + 'password': _password, + 'action': 'get_series', + }; + + if (categoryId != null && categoryId.isNotEmpty) { + queryParams['category_id'] = categoryId; + } + + final response = await _dio.get( + '$_baseUrl/player_api.php', + queryParameters: queryParams, + options: Options(receiveTimeout: const Duration(seconds: 30)), + ); + + if (response.statusCode == 200 && response.data is List) { + return (response.data as List) + .map((e) => model.Series.fromJson(e)) + .toList(); + } + return []; + } catch (e) { + if (kDebugMode) print('❌ Error loading series by category: $e'); + return []; + } } - /// Get series paginated - Future> getSeriesPaginated({int? categoryId}) async { - return getSeries(); // Simplified for now, can add filtering by categoryId later + /// Get series categories + Future> getSeriesCategories() async { + try { + final response = await _dio.get( + '$_baseUrl/player_api.php', + queryParameters: { + 'username': _username, + 'password': _password, + 'action': 'get_series_categories', + }, + options: Options(receiveTimeout: const Duration(seconds: 15)), + ); + + if (response.statusCode == 200 && response.data is List) { + return (response.data as List) + .map((e) => model.Category.fromJson(e)) + .toList(); + } + return []; + } catch (e) { + if (kDebugMode) print('❌ Error loading series categories: $e'); + return []; + } } /// Get series info @@ -482,6 +530,20 @@ class XtreamServiceMobile { return '$_baseUrl/series/$_username/$_password/$streamId.$extension'; } + /// Search series (local filter on full list) + Future> searchSeries(String query) async { + try { + final allSeries = await getSeries(); + if (query.isEmpty) return allSeries; + + final normalizedQuery = query.toLowerCase(); + return allSeries.where((s) => s.name.toLowerCase().contains(normalizedQuery)).toList(); + } catch (e) { + if (kDebugMode) print('❌ Error searching series: $e'); + return []; + } + } + /// Dispose service resources void dispose() { clearCache(); diff --git a/lib/features/iptv/widgets/mobile_live_tv_tab.dart b/lib/features/iptv/widgets/mobile_live_tv_tab.dart index c3d4ba5..8d9ef75 100644 --- a/lib/features/iptv/widgets/mobile_live_tv_tab.dart +++ b/lib/features/iptv/widgets/mobile_live_tv_tab.dart @@ -11,6 +11,7 @@ import 'package:xtremobile/core/models/iptv_models.dart'; import 'package:xtremobile/core/models/playlist_config.dart'; import 'package:xtremobile/core/theme/app_decorations.dart'; import 'package:xtremobile/mobile/widgets/tv_focusable.dart'; +import 'package:xtremobile/mobile/widgets/mobile_category_card.dart'; import 'package:xtremobile/features/iptv/screens/lite_player_screen.dart'; import 'package:flutter/services.dart'; import 'package:xtremobile/core/services/ip_service.dart'; @@ -85,7 +86,7 @@ class _MobileLiveTVTabState extends ConsumerState !_showFavoritesOnly; return Container( - decoration: AppDecorations.background(context), + color: const Color(0xFF000000), // Pure black background child: Scaffold( backgroundColor: Colors.transparent, body: PopScope( @@ -192,176 +193,186 @@ class _MobileLiveTVTabState extends ConsumerState return SafeArea( bottom: false, - child: Column( - children: [ - // Header (Search + Title/Back) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - child: Column( - children: [ - // Search Bar - Wrapped in TVFocusable for remote access - TVFocusable( - scale: 1.0, // Disable scaling to prevent overflow - focusColor: - Colors.white, // Solid white selection frame - onPressed: () { - setState(() => _isSearchEditing = true); - WidgetsBinding.instance.addPostFrameCallback((_) { - _searchFocusNode.requestFocus(); - SystemChannels.textInput - .invokeMethod('TextInput.show'); - }); - }, - borderRadius: BorderRadius.circular(12), - child: Container( - height: 48, - decoration: AppDecorations.searchBar(context), - padding: - const EdgeInsets.symmetric(horizontal: 12), - child: Row( - children: [ - Icon( - Icons.search, - color: AppDecorations.textSecondary(context), - ), - const SizedBox(width: 8), - Expanded( - child: ExcludeFocus( - excluding: !_isSearchEditing, - child: TextField( - cursorColor: Colors.white, - controller: _searchController, - focusNode: _searchFocusNode, - readOnly: !_isSearchEditing, - style: TextStyle( - color: AppDecorations.textPrimary(context), - ), - decoration: const InputDecoration( - hintText: 'Rechercher une chaîne...', - border: InputBorder.none, - focusedBorder: InputBorder.none, - enabledBorder: InputBorder.none, - isDense: true, - ), - onSubmitted: (_) => setState( - () => _isSearchEditing = false, - ), - ), - ), - ), - if (_searchQuery.isNotEmpty) - GestureDetector( - onTap: () => _searchController.clear(), - child: Icon( - Icons.close, + child: CustomScrollView( + slivers: [ + // Floating Header (Search + Navigation) + SliverAppBar( + floating: true, + pinned: false, + snap: true, + backgroundColor: Colors.transparent, // Minimal background + elevation: 0, + scrolledUnderElevation: 0, + automaticallyImplyLeading: false, + toolbarHeight: _searchQuery.isNotEmpty || _showFavoritesOnly || !uiState.isCategoryView + ? 120.0 + : 110.0, + flexibleSpace: FlexibleSpaceBar( + background: Container( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Search Bar + TVFocusable( + scale: 1.0, + focusColor: Colors.white, + onPressed: () { + setState(() => _isSearchEditing = true); + WidgetsBinding.instance + .addPostFrameCallback((_) { + _searchFocusNode.requestFocus(); + SystemChannels.textInput + .invokeMethod('TextInput.show'); + }); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + height: 48, + decoration: AppDecorations.searchBar(context), + padding: + const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + Icon( + Icons.search, color: AppDecorations.textSecondary(context), ), + const SizedBox(width: 8), + Expanded( + child: ExcludeFocus( + excluding: !_isSearchEditing, + child: TextField( + cursorColor: Colors.white, + controller: _searchController, + focusNode: _searchFocusNode, + readOnly: !_isSearchEditing, + style: TextStyle( + color: + AppDecorations.textPrimary(context), + ), + decoration: const InputDecoration( + hintText: 'Rechercher une chaîne...', + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + isDense: true, + ), + onSubmitted: (_) => setState( + () => _isSearchEditing = false, + ), + ), + ), + ), + if (_searchQuery.isNotEmpty) + GestureDetector( + onTap: () => _searchController.clear(), + child: Icon( + Icons.close, + color: + AppDecorations.textSecondary(context), + ), + ), + ], + ), + ), + ), + + const SizedBox(height: 12), + + // Title / Navigation Row + Row( + children: [ + if (!showGrid || + _showFavoritesOnly || + _searchQuery.isNotEmpty) ...[ + IconButton( + icon: Icon( + Icons.arrow_back, + color: AppDecorations.textPrimary(context), + ), + onPressed: () { + if (_searchQuery.isNotEmpty) { + _searchController.clear(); + } else if (_showFavoritesOnly) { + setState( + () => _showFavoritesOnly = false, + ); + } else { + uiNotifier.state = uiState.copyWith( + isCategoryView: true, + ); + } + }, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), ), + const SizedBox(width: 12), + ], + Expanded( + child: Text( + _searchQuery.isNotEmpty + ? 'Résultats de recherche' + : _showFavoritesOnly + ? 'Favoris' + : showGrid + ? 'Catégories' + : uiState.selectedCategory ?? + 'Chaînes', + style: TextStyle( + color: AppDecorations.textPrimary(context), + fontWeight: FontWeight.bold, + fontSize: 18, + ), + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: Icon( + _showFavoritesOnly + ? Icons.favorite + : Icons.favorite_border, + color: _showFavoritesOnly + ? const Color(0xFFFF453A) + : AppDecorations.textSecondary(context), + ), + onPressed: () => setState(() { + _showFavoritesOnly = !_showFavoritesOnly; + if (_showFavoritesOnly) { + uiNotifier.state = uiState.copyWith( + isCategoryView: false); + } + if (!_showFavoritesOnly) { + uiNotifier.state = + uiState.copyWith(isCategoryView: true); + } + }), + ), ], ), - ), - ), - - const SizedBox(height: 12), - - // Title / Navigation Row - Row( - children: [ - if (!showGrid || - _showFavoritesOnly || - _searchQuery.isNotEmpty) ...[ - IconButton( - icon: Icon( - Icons.arrow_back, - color: AppDecorations.textPrimary(context), - ), - onPressed: () { - if (_searchQuery.isNotEmpty) { - _searchController.clear(); - } else if (_showFavoritesOnly) { - setState( - () => _showFavoritesOnly = false, - ); - } else { - uiNotifier.state = uiState.copyWith( - isCategoryView: true, - ); - } - }, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - const SizedBox(width: 12), - ], - Expanded( - child: Text( - _searchQuery.isNotEmpty - ? 'Résultats de recherche' - : _showFavoritesOnly - ? 'Favoris' - : showGrid - ? 'Catégories' - : uiState.selectedCategory ?? - 'Chaînes', - style: TextStyle( - color: AppDecorations.textPrimary(context), - fontWeight: FontWeight.bold, - fontSize: 18, - ), - overflow: TextOverflow.ellipsis, - ), - ), - IconButton( - icon: Icon( - _showFavoritesOnly - ? Icons.favorite - : Icons.favorite_border, - color: _showFavoritesOnly - ? const Color(0xFFFF453A) - : AppDecorations.textSecondary(context), - ), - onPressed: () => setState(() { - _showFavoritesOnly = !_showFavoritesOnly; - // Reset category view if entering favorites - if (_showFavoritesOnly) { - uiNotifier.state = - uiState.copyWith(isCategoryView: false); - } - // If exiting favorites, default back to category grid - if (!_showFavoritesOnly) { - uiNotifier.state = - uiState.copyWith(isCategoryView: true); - } - }), - ), ], ), - ], + ), ), ), - // Main Content - Expanded( - child: showGrid - ? _buildCategoryGrid(categories, uiNotifier, uiState) - : _ChannelListView( - playlist: widget.playlist, - uiState: uiState, - searchQuery: _searchQuery, - showFavoritesOnly: _showFavoritesOnly, - onPlay: (channel, channels, index) => - _playChannel( - context, - channel, - channels, - widget.playlist, - index, - ), - ), - ), + // Main Content Sliver + if (showGrid) + _buildCategorySliver(categories, uiNotifier, uiState) + else + _ChannelListView( + playlist: widget.playlist, + uiState: uiState, + searchQuery: _searchQuery, + showFavoritesOnly: _showFavoritesOnly, + onPlay: (channel, channels, index) => _playChannel( + context, + channel, + channels, + widget.playlist, + index, + ), + ), ], ), ); @@ -372,88 +383,50 @@ class _MobileLiveTVTabState extends ConsumerState ); } - Widget _buildCategoryGrid( + Widget _buildCategorySliver( List categories, StateController uiNotifier, LiveTvUiState uiState, ) { if (categories.isEmpty) { - return Center( - child: Text( - 'Aucune catégorie trouvée', - style: GoogleFonts.inter(color: AppDecorations.textSecondary(context)), + return SliverFillRemaining( + child: Center( + child: Text( + 'Aucune catégorie trouvée', + style: + GoogleFonts.inter(color: AppDecorations.textSecondary(context)), + ), ), ); } - return GridView.builder( - padding: const EdgeInsets.all(16), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 6, // Adjusted to 6 columns as requested - childAspectRatio: 1.0, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - ), - itemCount: categories.length, - itemBuilder: (context, index) { - final category = categories[index]; - return TVFocusable( - onPressed: () { - uiNotifier.state = LiveTvUiState( - selectedCategory: category.categoryName, - selectedCategoryId: category.categoryId, - isCategoryView: false, + return SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 80), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 5, + childAspectRatio: 1.0, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + final category = categories[index]; + return MobileCategoryCard( + title: category.categoryName, + icon: Icons.live_tv_rounded, + onTap: () { + uiNotifier.state = LiveTvUiState( + selectedCategory: category.categoryName, + selectedCategoryId: category.categoryId, + isCategoryView: false, + ); + }, ); }, - borderRadius: BorderRadius.circular(16), - child: Stack( - children: [ - // Base gradient (theme-aware) - Container(decoration: AppDecorations.glossyCard(context)), - // Glossy highlight overlay (top shimmer) - Positioned( - top: 0, - left: 0, - right: 0, - height: 60, - child: Container( - decoration: AppDecorations.glossShimmer(context), - ), - ), - // Content (icon + text) — Positioned.fill ensures Column fills the card - Positioned.fill( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Icon( - Icons.live_tv_rounded, - color: Color(0xFF0A84FF), - size: 36, - ), - const SizedBox(height: 10), - Text( - category.categoryName, - style: TextStyle( - color: AppDecorations.textPrimary(context), - fontWeight: FontWeight.w600, - fontSize: 13, - letterSpacing: 0.2, - ), - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ), // closes Positioned.fill - ], - ), - ); - }, + childCount: categories.length, + ), + ), ); } @@ -532,11 +505,12 @@ class _ChannelListView extends ConsumerWidget { final categoryId = uiState.selectedCategoryId; if (categoryId == null && !showFavoritesOnly && searchQuery.isEmpty) { - // No category selected and no filter → go back to categories - return Center( - child: Text( - 'Aucune catégorie sélectionnée', - style: GoogleFonts.inter(color: AppDecorations.textSecondary(context)), + return const SliverFillRemaining( + child: Center( + child: Text( + 'Aucune catégorie sélectionnée', + style: TextStyle(color: Colors.white70), + ), ), ); } @@ -548,32 +522,35 @@ class _ChannelListView extends ConsumerWidget { : null; if (channelsAsync == null) { - // Favorites or search without category: need channels — show loading - // For favorites/search without a category, we can't load without a categoryId. - // Show a message directing user to select a category first. - return Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Text( - showFavoritesOnly - ? 'Sélectionnez une catégorie pour voir vos favoris' - : 'Sélectionnez une catégorie pour rechercher', - style: GoogleFonts.inter(color: AppDecorations.textSecondary(context)), - textAlign: TextAlign.center, + return SliverFillRemaining( + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + showFavoritesOnly + ? 'Sélectionnez une catégorie pour voir vos favoris' + : 'Sélectionnez une catégorie pour rechercher', + style: const TextStyle(color: Colors.white70), + textAlign: TextAlign.center, + ), ), ), ); } return channelsAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, s) => Center( - child: Padding( - padding: const EdgeInsets.all(16), - child: Text( - 'Erreur: $e', - style: const TextStyle(color: Colors.red), - textAlign: TextAlign.center, + loading: () => const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ), + error: (e, s) => SliverFillRemaining( + child: Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Erreur: $e', + style: const TextStyle(color: Colors.red), + textAlign: TextAlign.center, + ), ), ), ), @@ -593,31 +570,37 @@ class _ChannelListView extends ConsumerWidget { } if (displayedChannels.isEmpty) { - return Center( - child: Text( - 'Aucune chaîne trouvée', - style: GoogleFonts.inter(color: AppDecorations.textSecondary(context)), + return const SliverFillRemaining( + child: Center( + child: Text( + 'Aucune chaîne trouvée', + style: TextStyle(color: Colors.white70), + ), ), ); } - return GridView.builder( + return SliverPadding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 80), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 6, - childAspectRatio: 0.85, - crossAxisSpacing: 12, - mainAxisSpacing: 12, + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 5, + childAspectRatio: 1.0, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + final channel = displayedChannels[index]; + return _MobileChannelCard( + channel: channel, + playlist: playlist, + onTap: () => onPlay(channel, displayedChannels, index), + ); + }, + childCount: displayedChannels.length, + ), ), - itemCount: displayedChannels.length, - itemBuilder: (context, index) { - final channel = displayedChannels[index]; - return _MobileChannelCard( - channel: channel, - playlist: playlist, - onTap: () => onPlay(channel, displayedChannels, index), - ); - }, ); }, ); @@ -680,131 +663,125 @@ class _MobileChannelCardState extends ConsumerState<_MobileChannelCard> { .read(mobileFavoritesProvider.notifier) .toggle(widget.channel.streamId); }, - child: ClipRRect( - borderRadius: BorderRadius.circular(12), + child: Container( + decoration: AppDecorations.glossyCard(context, radius: 12), + clipBehavior: Clip.antiAlias, child: Stack( children: [ - // Base card (theme-aware) - Container( - decoration: AppDecorations.glossyCard(context, radius: 12), - ), // Glossy highlight overlay (top shimmer) Positioned( top: 0, left: 0, right: 0, - height: 50, + height: 40, child: Container( decoration: AppDecorations.glossShimmer(context, radius: 12), ), ), // Content - Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // Channel Logo - Expanded( - flex: 3, - child: Container( - width: double.infinity, - decoration: AppDecorations.channelCardBase(context), - child: Stack( - children: [ - // Logo - // [P1-2 FIX] Optimize channel icon cache (40x40 display) - Center( - child: iconUrl != null - ? CachedNetworkImage( - imageUrl: iconUrl, - fit: BoxFit.contain, - memCacheWidth: 50, - memCacheHeight: 50, - maxWidthDiskCache: 50, - maxHeightDiskCache: 50, - cacheManager: AppCacheManager.instance, - errorWidget: (_, __, ___) => Icon( - Icons.tv, - color: AppDecorations.iconMuted(context), - size: 40, - ), - placeholder: (_, __) => Icon( - Icons.tv, - color: AppDecorations.iconMuted(context), - size: 40, - ), - ) - : Icon( - Icons.tv, - color: AppDecorations.iconMuted(context), - size: 40, - ), - ), - // Favorite badge - if (isFavorite) - Positioned( - top: 4, - right: 4, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.red, - borderRadius: BorderRadius.circular(12), - ), - child: const Icon( - Icons.favorite, - color: Colors.white, - size: 12, - ), + Positioned.fill( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Channel Logo + Expanded( + flex: 3, + child: Container( + decoration: AppDecorations.channelCardBase(context), + child: Stack( + children: [ + // Logo + Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: iconUrl != null + ? CachedNetworkImage( + imageUrl: iconUrl, + fit: BoxFit.contain, + memCacheHeight: 120, + cacheManager: AppCacheManager.instance, + errorWidget: (_, __, ___) => Icon( + Icons.tv, + color: AppDecorations.iconMuted(context), + size: 30, + ), + placeholder: (_, __) => Icon( + Icons.tv, + color: AppDecorations.iconMuted(context), + size: 30, + ), + ) + : Icon( + Icons.tv, + color: AppDecorations.iconMuted(context), + size: 30, + ), ), ), - ], + // Favorite badge + if (isFavorite) + Positioned( + top: 4, + right: 4, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.favorite, + color: Colors.white, + size: 10, + ), + ), + ), + ], + ), ), ), - ), - // Channel Name - Expanded( - flex: 2, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 4, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - widget.channel.name, - style: TextStyle( - color: AppDecorations.textPrimary(context), - fontWeight: FontWeight.w500, - fontSize: 11, - ), - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - if (_epgTitle != null) ...[ - const SizedBox(height: 2), + // Channel Info + Expanded( + flex: 2, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ Text( - _epgTitle!, + widget.channel.name, style: const TextStyle( - color: Colors.yellow, - fontSize: 9, + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 10, ), textAlign: TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, ), + if (_epgTitle != null) ...[ + const SizedBox(height: 1), + Text( + _epgTitle!, + style: const TextStyle( + color: Colors.yellow, + fontSize: 8, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], ], - ], + ), ), ), - ), - ], - ), // end channel card content + ], + ), + ), ], - ), // end Stack - ), // end ClipRRect - ); // end TVFocusable + ), + ), + ); } } diff --git a/lib/features/iptv/widgets/mobile_movies_tab.dart b/lib/features/iptv/widgets/mobile_movies_tab.dart index 3181a9e..6839b98 100644 --- a/lib/features/iptv/widgets/mobile_movies_tab.dart +++ b/lib/features/iptv/widgets/mobile_movies_tab.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:xtremobile/mobile/widgets/tv_focusable.dart'; import 'package:xtremobile/mobile/widgets/mobile_poster_card.dart'; +import 'package:xtremobile/mobile/widgets/mobile_category_card.dart'; import 'package:xtremobile/core/models/playlist_config.dart'; import 'package:xtremobile/core/theme/app_decorations.dart'; import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart'; @@ -321,31 +322,97 @@ class _MobileMoviesTabState extends ConsumerState color: Theme.of(context).colorScheme.primary, backgroundColor: Theme.of(context).colorScheme.surface, child: CustomScrollView( - controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ - // Category Title (If inside a category) - if (showingMovies) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Row( + // Pinned Header + SliverAppBar( + pinned: true, + floating: true, + backgroundColor: AppDecorations.background(context).gradient is LinearGradient + ? (AppDecorations.background(context).gradient as LinearGradient).colors.first + : Theme.of(context).scaffoldBackgroundColor, + elevation: 4, + automaticallyImplyLeading: false, + expandedHeight: showingMovies ? 110 : 110, + collapsedHeight: showingMovies ? 110 : 110, + flexibleSpace: FlexibleSpaceBar( + background: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Column( children: [ - IconButton( - icon: const Icon( - Icons.arrow_back, - color: Colors.white, - ), - onPressed: _onBack, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - _selectedCategoryName ?? 'Films', - style: const TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold, + if (showingMovies) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: _onBack, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _selectedCategoryName ?? 'Films', + style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold), + ), + ), + ], + ), + ) + else + const SizedBox(height: 10), + + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: TVFocusable( + scale: 1.0, + focusColor: Colors.white, + onPressed: () { + setState(() => _isSearchEditing = true); + WidgetsBinding.instance.addPostFrameCallback((_) { + _searchFocusNode.requestFocus(); + }); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + height: 40, + decoration: AppDecorations.searchBar(context), + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + Icon(Icons.search, size: 20, color: AppDecorations.textSecondary(context)), + const SizedBox(width: 8), + Expanded( + child: ExcludeFocus( + excluding: !_isSearchEditing, + child: TextField( + cursorColor: AppDecorations.textPrimary(context), + controller: _searchController, + focusNode: _searchFocusNode, + readOnly: !_isSearchEditing, + style: TextStyle(fontSize: 14, color: AppDecorations.textPrimary(context)), + decoration: InputDecoration( + hintText: _selectedCategoryId != null ? 'Rechercher...' : 'Rechercher un film...', + border: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.only(bottom: 11), + ), + onChanged: _onSearchChanged, + onSubmitted: (_) => setState(() => _isSearchEditing = false), + ), + ), + ), + if (_isSearching) const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)), + if (_searchQuery.isNotEmpty) + GestureDetector( + onTap: () { + _searchController.clear(); + _onSearchChanged(''); + }, + child: Icon(Icons.close, size: 16, color: AppDecorations.textSecondary(context)), + ), + ], + ), ), ), ), @@ -353,88 +420,6 @@ class _MobileMoviesTabState extends ConsumerState ), ), ), - - // Search Bar - SliverToBoxAdapter( - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: TVFocusable( - scale: 1.0, - focusColor: Colors.white, - onPressed: () { - setState(() => _isSearchEditing = true); - WidgetsBinding.instance.addPostFrameCallback((_) { - _searchFocusNode.requestFocus(); - }); - }, - borderRadius: BorderRadius.circular(12), - child: Container( - height: 40, - decoration: AppDecorations.searchBar(context), - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - children: [ - Icon( - Icons.search, - size: 20, - color: AppDecorations.textSecondary(context), - ), - const SizedBox(width: 8), - Expanded( - child: ExcludeFocus( - excluding: !_isSearchEditing, - child: TextField( - cursorColor: AppDecorations.textPrimary(context), - controller: _searchController, - focusNode: _searchFocusNode, - readOnly: !_isSearchEditing, - style: TextStyle( - fontSize: 14, - color: AppDecorations.textPrimary(context), - ), - decoration: InputDecoration( - hintText: _selectedCategoryId != null - ? 'Recherche Globale (Tout le catalogue)' - : 'Rechercher un film...', - border: InputBorder.none, - focusedBorder: InputBorder.none, - enabledBorder: InputBorder.none, - isDense: true, - contentPadding: - const EdgeInsets.only(bottom: 11), - ), - onChanged: _onSearchChanged, - onSubmitted: (_) => - setState(() => _isSearchEditing = false), - ), - ), - ), - if (_isSearching) - const SizedBox( - width: 12, - height: 12, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ), - if (_searchQuery.isNotEmpty) - GestureDetector( - onTap: () { - _searchController.clear(); - _onSearchChanged(''); - }, - child: Icon( - Icons.close, - size: 16, - color: AppDecorations.textSecondary(context), - ), - ), - ], - ), - ), - ), - ), ), // Grid @@ -442,26 +427,20 @@ class _MobileMoviesTabState extends ConsumerState padding: const EdgeInsets.fromLTRB(16, 0, 16, 80), sliver: SliverGrid( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: showingCategories - ? 4 - : 8, // Bigger cards for categories - crossAxisSpacing: 8, - mainAxisSpacing: 8, - childAspectRatio: showingCategories - ? 2.5 - : 0.7, // Wide cards for categories + crossAxisCount: showingCategories ? 3 : 4, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + childAspectRatio: showingCategories ? 2.0 : 0.67, ), delegate: SliverChildBuilderDelegate( (context, index) { if (showingCategories) { if (index >= filteredCategories.length) return null; final cat = filteredCategories[index]; - return MobilePosterCard( - title: cat.value, // Name - imageUrl: null, // No image for category - placeholderIcon: Icons.folder, // Folder icon + return MobileCategoryCard( + title: cat.value, + icon: Icons.movie_filter_rounded, onTap: () => _selectCategory(cat.key, cat.value), - isWatched: false, ); } else if (showingMovies) { if (index >= _categoryMovies.length) return null; diff --git a/lib/features/iptv/widgets/mobile_series_tab.dart b/lib/features/iptv/widgets/mobile_series_tab.dart index 63cb55d..def8c6f 100644 --- a/lib/features/iptv/widgets/mobile_series_tab.dart +++ b/lib/features/iptv/widgets/mobile_series_tab.dart @@ -4,9 +4,9 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:xtremobile/mobile/widgets/tv_focusable.dart'; import 'package:xtremobile/mobile/widgets/mobile_poster_card.dart'; +import 'package:xtremobile/mobile/widgets/mobile_category_card.dart'; import 'package:xtremobile/core/models/playlist_config.dart'; import 'package:xtremobile/core/theme/app_decorations.dart'; -import 'package:xtremobile/core/widgets/components/hero_carousel.dart'; import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart'; import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart'; import 'package:xtremobile/core/models/iptv_models.dart' as model; @@ -25,20 +25,31 @@ class _MobileSeriesTabState extends ConsumerState final ScrollController _scrollController = ScrollController(); final TextEditingController _searchController = TextEditingController(); final FocusNode _searchFocusNode = FocusNode(); - final List _series = []; + + // Data + List> _categories = []; + List _categorySeries = []; + String? _selectedCategoryId; + String? _selectedCategoryName; + + // Search List? _searchResults; String _searchQuery = ''; bool _isSearchEditing = false; - bool _isLoading = false; bool _isSearching = false; - bool _hasMore = true; Timer? _searchDebounce; + // State + bool _isLoading = false; + @override void initState() { super.initState(); - _loadMoreSeries(); - _scrollController.addListener(_onScroll); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _categories.isEmpty) { + _loadCategories(); + } + }); } @override @@ -50,16 +61,6 @@ class _MobileSeriesTabState extends ConsumerState super.dispose(); } - void _onScroll() { - // Only trigger load when near bottom and not already loading - if (!_isLoading && - _hasMore && - _scrollController.position.pixels >= - _scrollController.position.maxScrollExtent * 0.8) { - _loadMoreSeries(); - } - } - void _onSearchChanged(String query) { _searchDebounce?.cancel(); setState(() { @@ -84,12 +85,11 @@ class _MobileSeriesTabState extends ConsumerState try { final service = await ref.read(mobileXtreamServiceProvider(widget.playlist).future); - final results = await service.getSeries(); // Fallback to full list if searchSeries not available - final filteredResults = results.where((s) => s.name.toLowerCase().contains(query.toLowerCase())).toList(); + final results = await service.searchSeries(query); if (mounted && _searchQuery == query) { setState(() { - _searchResults = filteredResults; + _searchResults = results; _isSearching = false; }); } @@ -98,41 +98,72 @@ class _MobileSeriesTabState extends ConsumerState } } - Future _loadMoreSeries() async { - // Prevent race conditions - if (_isLoading || !_hasMore || !mounted) return; - + Future _loadCategories() async { + if (_isLoading || !mounted) return; setState(() => _isLoading = true); try { final service = await ref.read(mobileXtreamServiceProvider(widget.playlist).future); - - // Add timeout to prevent infinite loading state - final newSeries = await service - .getSeriesPaginated() - .timeout(const Duration(seconds: 45)); + final categories = await service.getSeriesCategories(); if (mounted) { setState(() { - _series.addAll(newSeries); - _hasMore = false; // Disable pagination for now as it's not supported by simple getSeries + _categories = categories + .map((c) => MapEntry(c.categoryId, c.categoryName)) + .toList() + ..sort((a, b) => a.value.compareTo(b.value)); _isLoading = false; }); } } catch (e) { - debugPrint('Error loading series: $e'); + if (mounted) setState(() => _isLoading = false); + } + } + + Future _selectCategory(String id, String name) async { + setState(() { + _selectedCategoryId = id; + _selectedCategoryName = name; + _categorySeries = []; + _isLoading = true; + }); + + try { + final service = + await ref.read(mobileXtreamServiceProvider(widget.playlist).future); + final series = await service.getSeriesPaginated(categoryId: id); + if (mounted) { setState(() { + _categorySeries = series; _isLoading = false; - // Do NOT set _hasMore to false on error }); } + } catch (e) { + if (mounted) setState(() => _isLoading = false); + } + } + + void _onBack() { + if (_searchQuery.isNotEmpty) { + _onSearchChanged(''); + _searchController.clear(); + setState(() => _isSearchEditing = false); + return; + } + + if (_selectedCategoryId != null) { + setState(() { + _selectedCategoryId = null; + _selectedCategoryName = null; + _categorySeries = []; + }); + return; } } Future _refresh() async { - // Clear service cache to force fresh data try { final service = await ref.read(mobileXtreamServiceProvider(widget.playlist).future); @@ -140,14 +171,16 @@ class _MobileSeriesTabState extends ConsumerState } catch (_) {} setState(() { - _series.clear(); - _hasMore = true; + _categories.clear(); + _selectedCategoryId = null; + _selectedCategoryName = null; + _categorySeries.clear(); _searchResults = null; _searchQuery = ''; _searchController.clear(); _isLoading = false; }); - await _loadMoreSeries(); + await _loadCategories(); } String? _formatRating(String? rating) { @@ -159,7 +192,6 @@ class _MobileSeriesTabState extends ConsumerState return rating; } - /// On mobile, use direct URLs (no proxy needed) String _getImageUrl(String? originalUrl) { if (originalUrl == null || originalUrl.isEmpty) return ''; return originalUrl; @@ -182,37 +214,36 @@ class _MobileSeriesTabState extends ConsumerState @override Widget build(BuildContext context) { - super.build(context); // Required for AutomaticKeepAliveClientMixin + super.build(context); final settings = ref.watch(mobileSettingsProvider); - List displaySeries; - if (_searchQuery.isNotEmpty && _searchResults != null) { - displaySeries = _searchResults!; - } else { - displaySeries = settings.seriesKeywords.isEmpty - ? _series - : _series - .where((s) => settings.matchesSeriesFilter(s.categoryName)) - .toList(); + if (_categories.isEmpty && !_isLoading) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _categories.isEmpty) { + _loadCategories(); + } + }); } - // Hero Items (use filtered series) - final heroItems = displaySeries - .take(3) - .map( - (s) { - final serie = s; // model.Series handle is enough - return HeroItem( - id: serie.streamId, - title: serie.name, - imageUrl: _getImageUrl(serie.cover), - subtitle: serie.rating.isNotEmpty ? '${_formatRating(serie.rating)} ★' : null, - onMoreInfo: () => _openSeries(serie), - ); - }, - ) + final bool showingCategories = + _searchQuery.isEmpty && _selectedCategoryId == null; + final bool showingSeries = + _searchQuery.isEmpty && _selectedCategoryId != null; + final bool showingSearch = _searchQuery.isNotEmpty; + + final filteredCategories = _categories + .where((entry) => settings.matchesSeriesFilter(entry.value)) .toList(); + int itemCount = 0; + if (showingCategories) { + itemCount = filteredCategories.length; + } else if (showingSeries) { + itemCount = _categorySeries.length; + } else if (showingSearch) { + itemCount = _searchResults?.length ?? 0; + } + return Container( decoration: AppDecorations.background(context), child: PopScope( @@ -221,40 +252,32 @@ class _MobileSeriesTabState extends ConsumerState if (didPop) return; if (_searchQuery.isNotEmpty || _isSearching) { - _onSearchChanged(''); // clear search + _onSearchChanged(''); _searchController.clear(); setState(() => _isSearchEditing = false); return; } - // Root View -> Show Exit Dialog + if (_selectedCategoryId != null) { + _onBack(); + return; + } + final shouldExit = await showDialog( context: context, builder: (context) => AlertDialog( backgroundColor: Colors.grey[900], - title: const Text( - "Quitter l'application", - style: TextStyle(color: Colors.white), - ), - content: const Text( - "Voulez-vous vraiment quitter l'application ?", - style: TextStyle(color: Colors.white70), - ), + title: const Text("Quitter l'application", style: TextStyle(color: Colors.white)), + content: const Text("Voulez-vous vraiment quitter l'application ?", style: TextStyle(color: Colors.white70)), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), autofocus: true, - child: const Text( - 'Annuler', - style: TextStyle(color: Colors.blueAccent), - ), + child: const Text('Annuler', style: TextStyle(color: Colors.blueAccent)), ), TextButton( onPressed: () => Navigator.of(context).pop(true), - child: const Text( - 'Quitter', - style: TextStyle(color: Colors.redAccent), - ), + child: const Text('Quitter', style: TextStyle(color: Colors.redAccent)), ), ], ), @@ -268,142 +291,150 @@ class _MobileSeriesTabState extends ConsumerState bottom: false, child: RefreshIndicator( onRefresh: _refresh, - color: Theme.of(context).colorScheme.primary, - backgroundColor: Theme.of(context).colorScheme.surface, child: CustomScrollView( - controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ - // Hero Section (reduced height, moved above search) - if (_searchQuery.isEmpty && heroItems.isNotEmpty) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(top: 8, bottom: 8), - child: SizedBox( - height: 180, // Reduced from 250 - child: HeroCarousel( - items: heroItems, - onTap: (item) { - try { - final series = _series.firstWhere( - (s) => s.streamId.toString() == item.id, - ); - _openSeries(series); - } catch (e) { - // Fallback logic not critical - } - }, - ), - ), - ), - ), + // Pinned Header + SliverAppBar( + pinned: true, + floating: true, + backgroundColor: AppDecorations.background(context).gradient is LinearGradient + ? (AppDecorations.background(context).gradient as LinearGradient).colors.first + : Theme.of(context).scaffoldBackgroundColor, + elevation: 4, + automaticallyImplyLeading: false, + expandedHeight: showingSeries ? 110 : 110, + collapsedHeight: showingSeries ? 110 : 110, + flexibleSpace: FlexibleSpaceBar( + background: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Column( + children: [ + if (showingSeries) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: _onBack, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _selectedCategoryName ?? 'Séries', + style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold), + ), + ), + ], + ), + ) + else + const SizedBox(height: 10), - // Search Bar (moved below carousel) - SliverToBoxAdapter( - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: TVFocusable( - scale: 1.0, // Disable scaling to prevent overflow - focusColor: Colors.white, // Solid white selection frame - onPressed: () { - setState(() => _isSearchEditing = true); - WidgetsBinding.instance.addPostFrameCallback((_) { - _searchFocusNode.requestFocus(); - }); - }, - borderRadius: BorderRadius.circular(12), - child: Container( - height: 40, - decoration: AppDecorations.searchBar(context), - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - children: [ - Icon( - Icons.search, - size: 20, - color: AppDecorations.textSecondary(context), - ), - const SizedBox(width: 8), - Expanded( - child: ExcludeFocus( - excluding: !_isSearchEditing, - child: TextField( - cursorColor: AppDecorations.textPrimary(context), - controller: _searchController, - focusNode: _searchFocusNode, - readOnly: !_isSearchEditing, - style: TextStyle( - fontSize: 14, - color: AppDecorations.textPrimary(context), - ), - decoration: const InputDecoration( - hintText: 'Rechercher une série...', - border: InputBorder.none, - focusedBorder: InputBorder - .none, // Explicitly remove focus border - enabledBorder: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.only(bottom: 11), - ), - onChanged: _onSearchChanged, - onSubmitted: (_) => - setState(() => _isSearchEditing = false), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: TVFocusable( + scale: 1.0, + focusColor: Colors.white, + onPressed: () { + setState(() => _isSearchEditing = true); + WidgetsBinding.instance.addPostFrameCallback((_) { + _searchFocusNode.requestFocus(); + }); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + height: 40, + decoration: AppDecorations.searchBar(context), + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + Icon(Icons.search, size: 20, color: AppDecorations.textSecondary(context)), + const SizedBox(width: 8), + Expanded( + child: ExcludeFocus( + excluding: !_isSearchEditing, + child: TextField( + cursorColor: AppDecorations.textPrimary(context), + controller: _searchController, + focusNode: _searchFocusNode, + readOnly: !_isSearchEditing, + style: TextStyle(fontSize: 14, color: AppDecorations.textPrimary(context)), + decoration: InputDecoration( + hintText: _selectedCategoryId != null ? 'Rechercher...' : 'Rechercher une série...', + border: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.only(bottom: 11), + ), + onChanged: _onSearchChanged, + onSubmitted: (_) => setState(() => _isSearchEditing = false), + ), + ), + ), + if (_isSearching) const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)), + if (_searchQuery.isNotEmpty) + GestureDetector( + onTap: () { + _searchController.clear(); + _onSearchChanged(''); + }, + child: Icon(Icons.close, size: 16, color: AppDecorations.textSecondary(context)), + ), + ], ), ), ), - if (_isSearching) - const SizedBox( - width: 12, - height: 12, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ), - if (_searchQuery.isNotEmpty) - GestureDetector( - onTap: () { - _searchController.clear(); - _onSearchChanged(''); - }, - child: Icon( - Icons.close, - size: 16, - color: AppDecorations.textSecondary(context), - ), - ), - ], - ), + ), + ], ), ), ), ), - // Grid SliverPadding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 80), sliver: SliverGrid( - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 8, // Reduced size further (was 4) - crossAxisSpacing: 8, - mainAxisSpacing: 8, - childAspectRatio: 0.7, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: showingCategories ? 3 : 4, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + childAspectRatio: showingCategories ? 2.0 : 0.67, ), delegate: SliverChildBuilderDelegate( (context, index) { - if (index >= displaySeries.length) return null; - final series = displaySeries[index]; - - return MobilePosterCard( - title: series.name, - imageUrl: _getImageUrl(series.cover), - rating: _formatRating(series.rating), - placeholderIcon: Icons.tv, - onTap: () => _openSeries(series), - ); + if (showingCategories) { + if (index >= filteredCategories.length) return null; + final cat = filteredCategories[index]; + return MobileCategoryCard( + title: cat.value, + icon: Icons.video_library_rounded, + onTap: () => _selectCategory(cat.key, cat.value), + ); + } else if (showingSeries) { + if (index >= _categorySeries.length) return null; + final series = _categorySeries[index]; + return MobilePosterCard( + title: series.name, + imageUrl: _getImageUrl(series.cover), + rating: _formatRating(series.rating), + placeholderIcon: Icons.tv, + onTap: () => _openSeries(series), + ); + } else if (showingSearch) { + if (_searchResults == null || index >= _searchResults!.length) return null; + final series = _searchResults![index]; + return MobilePosterCard( + title: series.name, + imageUrl: _getImageUrl(series.cover), + rating: _formatRating(series.rating), + placeholderIcon: Icons.tv, + onTap: () => _openSeries(series), + ); + } + return null; }, - childCount: displaySeries.length, + childCount: itemCount, ), ), ), @@ -415,47 +446,13 @@ class _MobileSeriesTabState extends ConsumerState child: Center(child: CircularProgressIndicator()), ), ), - - if (!_isLoading && displaySeries.isEmpty) + + if (!_isLoading && itemCount == 0 && (_selectedCategoryId != null || _searchQuery.isNotEmpty)) SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(48), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.video_library_outlined, - size: 64, - color: AppDecorations.textSecondary(context), - ), - const SizedBox(height: 16), - Text( - 'Aucune série chargée', - style: TextStyle( - color: AppDecorations.textPrimary(context), - fontSize: 18, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - Text( - 'Le chargement a pris trop de temps ou une erreur est survenue.', - textAlign: TextAlign.center, - style: TextStyle( - color: AppDecorations.textSecondary(context), - fontSize: 14, - ), - ), - const SizedBox(height: 24), - FilledButton.icon( - onPressed: () { - setState(() => _hasMore = true); - _loadMoreSeries(); - }, - icon: const Icon(Icons.refresh), - label: const Text('Réessayer'), - ), - ], + child: Center( + child: Padding( + padding: const EdgeInsets.all(48), + child: Text('Aucune série trouvée', style: TextStyle(color: AppDecorations.textSecondary(context))), ), ), ), diff --git a/lib/mobile/providers/mobile_xtream_providers.dart b/lib/mobile/providers/mobile_xtream_providers.dart index ae47f98..b26c25a 100644 --- a/lib/mobile/providers/mobile_xtream_providers.dart +++ b/lib/mobile/providers/mobile_xtream_providers.dart @@ -107,11 +107,20 @@ final mobileMoviesProvider = return service.getMoviesByCategory(""); // Fetch all or default }); -/// Mobile-specific series pagination provider -final mobileSeriesProvider = - FutureProvider.family, PlaylistConfig>((ref, playlist) async { +/// Mobile-specific series categories provider +final mobileSeriesCategoriesProvider = + FutureProvider.family, PlaylistConfig>( + (ref, playlist) async { final service = await ref.watch(mobileXtreamServiceProvider(playlist).future); - return service.getSeriesPaginated(); + return service.getSeriesCategories(); +}); + +/// Mobile-specific series pagination provider +final mobileSeriesProvider = FutureProvider.family, + (PlaylistConfig, String?)>((ref, params) async { + final (playlist, categoryId) = params; + final service = await ref.watch(mobileXtreamServiceProvider(playlist).future); + return service.getSeriesPaginated(categoryId: categoryId); }); /// Series info with playlist context diff --git a/lib/mobile/widgets/mobile_category_card.dart b/lib/mobile/widgets/mobile_category_card.dart new file mode 100644 index 0000000..11b9bcc --- /dev/null +++ b/lib/mobile/widgets/mobile_category_card.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:xtremobile/core/theme/app_decorations.dart'; +import 'package:xtremobile/mobile/widgets/tv_focusable.dart'; + +class MobileCategoryCard extends StatelessWidget { + final String title; + final VoidCallback onTap; + final IconData icon; + final Color iconColor; + + const MobileCategoryCard({ + super.key, + required this.title, + required this.onTap, + this.icon = Icons.folder_rounded, + this.iconColor = const Color(0xFF0A84FF), + }); + + @override + Widget build(BuildContext context) { + return TVFocusable( + onPressed: onTap, + borderRadius: BorderRadius.circular(16), + child: Stack( + children: [ + // Base glossy background + Container( + decoration: AppDecorations.glossyCard(context, radius: 16), + ), + // Glossy highlight overlay (shimmer) + Positioned( + top: 0, + left: 0, + right: 0, + height: 60, + child: IgnorePointer( + child: Container( + decoration: AppDecorations.glossShimmer(context, radius: 16), + ), + ), + ), + // Content + Positioned.fill( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + icon, + color: iconColor, + size: 28, + ), + const SizedBox(height: 8), + Text( + title, + style: TextStyle( + color: AppDecorations.textPrimary(context), + fontWeight: FontWeight.w600, + fontSize: 12, + letterSpacing: 0.1, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/mobile/widgets/mobile_poster_card.dart b/lib/mobile/widgets/mobile_poster_card.dart index 2ec2c8f..f41850a 100644 --- a/lib/mobile/widgets/mobile_poster_card.dart +++ b/lib/mobile/widgets/mobile_poster_card.dart @@ -26,7 +26,6 @@ class MobilePosterCard extends StatelessWidget { @override Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, @@ -34,18 +33,7 @@ class MobilePosterCard extends StatelessWidget { // Shadow lives on this outer Container so ClipRRect doesn't swallow it Expanded( child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: isDark - ? const Color(0x99000000) - : const Color(0x28000000), - blurRadius: 12, - offset: const Offset(0, 6), - ), - ], - ), + decoration: AppDecorations.glossyCard(context, radius: 12), child: TVFocusable( onPressed: onTap, onLongPress: onLongPress, @@ -144,6 +132,19 @@ class MobilePosterCard extends StatelessWidget { ), ), ), + + // Glossy highlight overlay (top shimmer) + Positioned( + top: 0, + left: 0, + right: 0, + height: 80, // Taller for posters + child: IgnorePointer( + child: Container( + decoration: AppDecorations.glossShimmer(context, radius: 12), + ), + ), + ), ], ), ),