diff --git a/lib/features/iptv/models/xtream_models.dart b/lib/features/iptv/models/xtream_models.dart index 7f769e5..1f18733 100644 --- a/lib/features/iptv/models/xtream_models.dart +++ b/lib/features/iptv/models/xtream_models.dart @@ -98,6 +98,128 @@ class Series { } } +/// Season model for series +class Season { + final int seasonNumber; + final String name; + final int episodeCount; + final String? cover; + + const Season({ + required this.seasonNumber, + required this.name, + required this.episodeCount, + this.cover, + }); + + factory Season.fromJson(Map json) { + return Season( + seasonNumber: int.tryParse(json['season_number']?.toString() ?? '1') ?? 1, + name: json['name']?.toString() ?? 'Season ${json['season_number'] ?? 1}', + episodeCount: int.tryParse(json['episode_count']?.toString() ?? '0') ?? 0, + cover: json['cover']?.toString(), + ); + } +} + +/// Episode model for series +class Episode { + final String id; + final int episodeNum; + final String title; + final String? containerExtension; + final String? info; + final String? cover; + final int? durationSecs; + + const Episode({ + required this.id, + required this.episodeNum, + required this.title, + this.containerExtension, + this.info, + this.cover, + this.durationSecs, + }); + + factory Episode.fromJson(Map json) { + return Episode( + id: json['id']?.toString() ?? '', + episodeNum: int.tryParse(json['episode_num']?.toString() ?? '1') ?? 1, + title: json['title']?.toString() ?? 'Episode ${json['episode_num'] ?? 1}', + containerExtension: json['container_extension']?.toString() ?? 'mkv', + info: json['info']?.toString(), + cover: json['custom_cover']?.toString() ?? json['cover']?.toString(), + durationSecs: int.tryParse(json['duration_secs']?.toString() ?? '0'), + ); + } + + String getStreamUrl(String dns, String username, String password) { + return '$dns/series/$username/$password/$id.${containerExtension ?? 'mkv'}'; + } +} + +/// Series info with seasons and episodes +class SeriesInfo { + final String seriesId; + final String name; + final String? cover; + final String? plot; + final String? cast; + final String? director; + final String? genre; + final String? releaseDate; + final String? rating; + final List seasons; + final Map> episodes; + + const SeriesInfo({ + required this.seriesId, + required this.name, + this.cover, + this.plot, + this.cast, + this.director, + this.genre, + this.releaseDate, + this.rating, + required this.seasons, + required this.episodes, + }); + + factory SeriesInfo.fromJson(Map json) { + final seasonsData = json['seasons'] as List? ?? []; + final seasons = seasonsData.map((s) => Season.fromJson(s as Map)).toList(); + + final episodesData = json['episodes'] as Map? ?? {}; + final episodes = >{}; + + episodesData.forEach((seasonNum, episodesList) { + final seasonInt = int.tryParse(seasonNum) ?? 1; + final episodeList = (episodesList as List?) + ?.map((e) => Episode.fromJson(e as Map)) + .toList() ?? []; + episodes[seasonInt] = episodeList; + }); + + final info = json['info'] as Map? ?? {}; + + return SeriesInfo( + seriesId: json['series_id']?.toString() ?? info['series_id']?.toString() ?? '', + name: info['name']?.toString() ?? json['name']?.toString() ?? 'Unknown', + cover: info['cover']?.toString() ?? json['cover']?.toString(), + plot: info['plot']?.toString(), + cast: info['cast']?.toString(), + director: info['director']?.toString(), + genre: info['genre']?.toString(), + releaseDate: info['releaseDate']?.toString(), + rating: info['rating']?.toString(), + seasons: seasons, + episodes: episodes, + ); + } +} + class Category { final String categoryId; final String categoryName; diff --git a/lib/features/iptv/providers/settings_provider.dart b/lib/features/iptv/providers/settings_provider.dart new file mode 100644 index 0000000..741a2ca --- /dev/null +++ b/lib/features/iptv/providers/settings_provider.dart @@ -0,0 +1,59 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Settings state for IPTV preferences +class IptvSettings { + /// Category filter keywords (comma-separated) + /// Example: "FR,FRANCE,HD" will show only categories containing one of these words + final String categoryFilter; + + const IptvSettings({ + this.categoryFilter = '', + }); + + IptvSettings copyWith({ + String? categoryFilter, + }) { + return IptvSettings( + categoryFilter: categoryFilter ?? this.categoryFilter, + ); + } + + /// Get list of filter keywords (trimmed and non-empty) + List get filterKeywords { + if (categoryFilter.isEmpty) return []; + return categoryFilter + .split(',') + .map((s) => s.trim().toUpperCase()) + .where((s) => s.isNotEmpty) + .toList(); + } + + /// Check if a category name matches the filter + /// Returns true if no filter is set or if the category contains any keyword + bool matchesFilter(String categoryName) { + final keywords = filterKeywords; + if (keywords.isEmpty) return true; + + final upperName = categoryName.toUpperCase(); + return keywords.any((keyword) => upperName.contains(keyword)); + } +} + +/// IPTV Settings notifier for managing user preferences +class IptvSettingsNotifier extends StateNotifier { + IptvSettingsNotifier() : super(const IptvSettings()); + + void setCategoryFilter(String filter) { + state = state.copyWith(categoryFilter: filter); + } + + void clearCategoryFilter() { + state = state.copyWith(categoryFilter: ''); + } +} + +/// Provider for IPTV settings +final iptvSettingsProvider = + StateNotifierProvider((ref) { + return IptvSettingsNotifier(); +}); diff --git a/lib/features/iptv/screens/series_detail_screen.dart b/lib/features/iptv/screens/series_detail_screen.dart new file mode 100644 index 0000000..e79e1c3 --- /dev/null +++ b/lib/features/iptv/screens/series_detail_screen.dart @@ -0,0 +1,332 @@ +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 '../models/xtream_models.dart'; +import '../providers/xtream_provider.dart'; +import 'player_screen.dart'; + +/// Series detail screen showing seasons and episodes +class SeriesDetailScreen extends ConsumerStatefulWidget { + final Series series; + final PlaylistConfig playlist; + + const SeriesDetailScreen({ + super.key, + required this.series, + required this.playlist, + }); + + @override + ConsumerState createState() => _SeriesDetailScreenState(); +} + +class _SeriesDetailScreenState extends ConsumerState { + SeriesInfo? _seriesInfo; + bool _isLoading = true; + String? _error; + int _selectedSeason = 1; + + @override + void initState() { + super.initState(); + _loadSeriesInfo(); + } + + Future _loadSeriesInfo() async { + try { + setState(() { + _isLoading = true; + _error = null; + }); + + final service = ref.read(xtreamServiceProvider(widget.playlist)); + final info = await service.getSeriesInfo(widget.series.seriesId); + + setState(() { + _seriesInfo = info; + _isLoading = false; + // Select first available season + if (info.episodes.isNotEmpty) { + _selectedSeason = info.episodes.keys.first; + } + }); + } catch (e) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.series.name), + backgroundColor: Colors.black87, + ), + backgroundColor: Colors.grey.shade900, + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: Colors.red), + const SizedBox(height: 16), + Text('Error: $_error', style: const TextStyle(color: Colors.white70)), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadSeriesInfo, + child: const Text('Retry'), + ), + ], + ), + ) + : _buildContent(), + ); + } + + Widget _buildContent() { + if (_seriesInfo == null) return const SizedBox.shrink(); + + final currentEpisodes = _seriesInfo!.episodes[_selectedSeason] ?? []; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Left panel: Series info and season selector + SizedBox( + width: 280, + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Cover image + if (_seriesInfo!.cover != null) + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: _seriesInfo!.cover!, + width: 248, + height: 350, + fit: BoxFit.cover, + errorWidget: (_, __, ___) => Container( + width: 248, + height: 350, + color: Colors.grey.shade800, + child: const Icon(Icons.tv, size: 64, color: Colors.white54), + ), + ), + ), + const SizedBox(height: 16), + + // Title + Text( + _seriesInfo!.name, + style: GoogleFonts.roboto( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 8), + + // Rating + if (_seriesInfo!.rating != null) + Row( + children: [ + Icon(Icons.star, size: 16, color: Colors.amber.shade700), + const SizedBox(width: 4), + Text( + _seriesInfo!.rating!, + style: GoogleFonts.roboto(fontSize: 13, color: Colors.white70), + ), + ], + ), + const SizedBox(height: 12), + + // Plot + if (_seriesInfo!.plot != null && _seriesInfo!.plot!.isNotEmpty) + Text( + _seriesInfo!.plot!, + style: GoogleFonts.roboto(fontSize: 12, color: Colors.white60), + maxLines: 5, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 16), + + // Season selector + Text( + 'Seasons', + style: GoogleFonts.roboto( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: _seriesInfo!.episodes.keys.map((seasonNum) { + final isSelected = seasonNum == _selectedSeason; + return ChoiceChip( + label: Text('S$seasonNum'), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setState(() => _selectedSeason = seasonNum); + } + }, + selectedColor: Colors.blue.shade700, + backgroundColor: Colors.grey.shade800, + labelStyle: GoogleFonts.roboto( + color: isSelected ? Colors.white : Colors.white70, + fontSize: 12, + ), + ); + }).toList(), + ), + ], + ), + ), + ), + + // Divider + Container(width: 1, color: Colors.grey.shade700), + + // Right panel: Episodes list + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Season $_selectedSeason - ${currentEpisodes.length} Episodes', + style: GoogleFonts.roboto( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + Expanded( + child: currentEpisodes.isEmpty + ? Center( + child: Text( + 'No episodes found', + style: GoogleFonts.roboto(color: Colors.white54), + ), + ) + : ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: currentEpisodes.length, + itemBuilder: (context, index) { + final episode = currentEpisodes[index]; + return _buildEpisodeTile(episode); + }, + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildEpisodeTile(Episode episode) { + return Card( + color: Colors.grey.shade800, + margin: const EdgeInsets.only(bottom: 8), + child: InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PlayerScreen( + streamId: episode.id, + title: '${widget.series.name} - ${episode.title}', + playlist: widget.playlist, + streamType: StreamType.series, + containerExtension: episode.containerExtension ?? 'mkv', + ), + ), + ); + }, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + // Episode number badge + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.blue.shade700, + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Text( + '${episode.episodeNum}', + style: GoogleFonts.roboto( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + const SizedBox(width: 12), + + // Episode info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + episode.title, + style: GoogleFonts.roboto( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (episode.durationSecs != null && episode.durationSecs! > 0) + Text( + _formatDuration(episode.durationSecs!), + style: GoogleFonts.roboto( + fontSize: 12, + color: Colors.white54, + ), + ), + ], + ), + ), + + // Play icon + const Icon(Icons.play_circle_outline, color: Colors.white70, size: 28), + ], + ), + ), + ), + ); + } + + String _formatDuration(int seconds) { + final hours = seconds ~/ 3600; + final minutes = (seconds % 3600) ~/ 60; + if (hours > 0) { + return '${hours}h ${minutes}m'; + } + return '${minutes}m'; + } +} diff --git a/lib/features/iptv/services/xtream_service.dart b/lib/features/iptv/services/xtream_service.dart index 1a7f4b8..2f80f68 100644 --- a/lib/features/iptv/services/xtream_service.dart +++ b/lib/features/iptv/services/xtream_service.dart @@ -399,6 +399,28 @@ class XtreamService { } } + /// Get series info with seasons and episodes + Future getSeriesInfo(String seriesId) async { + if (_currentPlaylist == null) throw Exception('No playlist configured'); + + try { + final response = await _dio.get( + _wrapWithProxy(_currentPlaylist!.apiBaseUrl), + queryParameters: { + 'username': _currentPlaylist!.username, + 'password': _currentPlaylist!.password, + 'action': 'get_series_info', + 'series_id': seriesId, + }, + options: Options(extra: _cacheOptions.toExtra()), + ); + + return xm.SeriesInfo.fromJson(response.data as Map); + } catch (e) { + throw Exception('Failed to fetch series info: $e'); + } + } + /// Get short EPG for a specific stream /// /// Returns "Now" and "Next" program info diff --git a/lib/features/iptv/widgets/live_tv_tab.dart b/lib/features/iptv/widgets/live_tv_tab.dart index 3e9a84c..ffb0438 100644 --- a/lib/features/iptv/widgets/live_tv_tab.dart +++ b/lib/features/iptv/widgets/live_tv_tab.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:google_fonts/google_fonts.dart'; import '../providers/xtream_provider.dart'; +import '../providers/settings_provider.dart'; import '../screens/player_screen.dart'; import '../../../core/models/iptv_models.dart'; import '../../../core/models/playlist_config.dart'; @@ -64,16 +65,45 @@ class _LiveTVTabState extends ConsumerState /// Build the category grid view with box cards Widget _buildCategoryGrid(Map> groupedChannels) { - final categories = groupedChannels.keys.toList()..sort(); + // Get filter settings + final settings = ref.watch(iptvSettingsProvider); + + // Filter categories based on keywords + var categories = groupedChannels.keys.toList(); + if (settings.filterKeywords.isNotEmpty) { + categories = categories.where((cat) => settings.matchesFilter(cat)).toList(); + } + categories.sort(); + + if (categories.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.filter_list_off, size: 48, color: Colors.grey), + const SizedBox(height: 16), + Text( + 'No categories match the filter', + style: GoogleFonts.roboto(color: Colors.grey), + ), + const SizedBox(height: 8), + Text( + 'Filter: ${settings.categoryFilter}', + style: GoogleFonts.roboto(fontSize: 12, color: Colors.grey.shade600), + ), + ], + ), + ); + } return Padding( - padding: const EdgeInsets.all(12.0), + padding: const EdgeInsets.all(8.0), child: GridView.builder( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 4, - crossAxisSpacing: 12, - mainAxisSpacing: 12, - childAspectRatio: 1.5, + crossAxisCount: 6, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + childAspectRatio: 1.8, ), itemCount: categories.length, itemBuilder: (context, index) { @@ -283,32 +313,25 @@ class _CategoryBox extends StatelessWidget { color: Colors.transparent, child: InkWell( onTap: onTap, - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(10), child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ - Theme.of(context).colorScheme.primary.withOpacity(0.2), - Theme.of(context).colorScheme.secondary.withOpacity(0.1), + Theme.of(context).colorScheme.primary.withOpacity(0.15), + Theme.of(context).colorScheme.secondary.withOpacity(0.08), ], ), - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(10), border: Border.all( - color: Theme.of(context).colorScheme.primary.withOpacity(0.3), - width: 1.5, + color: Theme.of(context).colorScheme.primary.withOpacity(0.25), + width: 1, ), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 8, - offset: const Offset(0, 4), - ), - ], ), child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(8), child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, @@ -317,20 +340,20 @@ class _CategoryBox extends StatelessWidget { children: [ Icon( Icons.live_tv, - size: 24, + size: 16, color: Theme.of(context).colorScheme.primary, ), const Spacer(), Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: Theme.of(context).colorScheme.primary.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(8), ), child: Text( '$channelCount', style: GoogleFonts.roboto( - fontSize: 12, + fontSize: 10, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.primary, ), @@ -342,7 +365,7 @@ class _CategoryBox extends StatelessWidget { Text( categoryName, style: GoogleFonts.roboto( - fontSize: 14, + fontSize: 11, fontWeight: FontWeight.w600, ), maxLines: 2, diff --git a/lib/features/iptv/widgets/movies_tab.dart b/lib/features/iptv/widgets/movies_tab.dart index 367c751..3a26fbd 100644 --- a/lib/features/iptv/widgets/movies_tab.dart +++ b/lib/features/iptv/widgets/movies_tab.dart @@ -5,7 +5,7 @@ import 'package:google_fonts/google_fonts.dart'; import '../../../core/models/playlist_config.dart'; import '../models/xtream_models.dart'; import '../providers/xtream_provider.dart'; -import '../screens/video_player_screen.dart'; +import '../screens/player_screen.dart'; class MoviesTab extends ConsumerStatefulWidget { final PlaylistConfig playlist; @@ -86,10 +86,10 @@ class _MoviesTabState extends ConsumerState { controller: _scrollController, padding: const EdgeInsets.all(8), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 5, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - childAspectRatio: 0.7, + crossAxisCount: 7, + crossAxisSpacing: 6, + mainAxisSpacing: 6, + childAspectRatio: 0.65, ), itemCount: _movies.length + (_hasMore ? 1 : 0), itemBuilder: (context, index) { @@ -122,19 +122,16 @@ class _MovieCard extends StatelessWidget { clipBehavior: Clip.antiAlias, child: InkWell( onTap: () { - final streamUrl = movie.getStreamUrl( - playlist.dns, - playlist.username, - playlist.password, - ); - + // Use PlayerScreen for VOD (video on demand) Navigator.push( context, MaterialPageRoute( - builder: (context) => VideoPlayerScreen( - streamUrl: streamUrl, + builder: (context) => PlayerScreen( + streamId: movie.streamId, title: movie.name, - posterUrl: movie.streamIcon, + playlist: playlist, + streamType: StreamType.vod, + containerExtension: movie.containerExtension ?? 'mp4', ), ), ); diff --git a/lib/features/iptv/widgets/series_tab.dart b/lib/features/iptv/widgets/series_tab.dart index f39f139..89c89b3 100644 --- a/lib/features/iptv/widgets/series_tab.dart +++ b/lib/features/iptv/widgets/series_tab.dart @@ -3,9 +3,9 @@ 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 '../services/xtream_service.dart'; import '../models/xtream_models.dart'; import '../providers/xtream_provider.dart'; +import '../screens/series_detail_screen.dart'; class SeriesTab extends ConsumerStatefulWidget { final PlaylistConfig playlist; @@ -86,10 +86,10 @@ class _SeriesTabState extends ConsumerState { controller: _scrollController, padding: const EdgeInsets.all(8), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 5, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - childAspectRatio: 0.7, + crossAxisCount: 7, + crossAxisSpacing: 6, + mainAxisSpacing: 6, + childAspectRatio: 0.65, ), itemCount: _series.length + (_hasMore ? 1 : 0), itemBuilder: (context, index) { @@ -98,7 +98,7 @@ class _SeriesTabState extends ConsumerState { } final serie = _series[index]; - return _SeriesCard(series: serie); + return _SeriesCard(series: serie, playlist: widget.playlist); }, ); } @@ -106,8 +106,9 @@ class _SeriesTabState extends ConsumerState { class _SeriesCard extends StatelessWidget { final Series series; + final PlaylistConfig playlist; - const _SeriesCard({required this.series}); + const _SeriesCard({required this.series, required this.playlist}); @override Widget build(BuildContext context) { @@ -115,22 +116,14 @@ class _SeriesCard extends StatelessWidget { clipBehavior: Clip.antiAlias, child: InkWell( onTap: () { - // Series require episode selection screen (not implemented) - // For now, show info dialog - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(series.name), - content: const Text( - 'Series playback requires episode selection.\n\n' - 'Use Xtream API endpoint get_series_info to fetch episodes.', + // Navigate to series detail screen for episode selection + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SeriesDetailScreen( + series: series, + playlist: playlist, ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('OK'), - ), - ], ), ); }, diff --git a/lib/features/iptv/widgets/settings_tab.dart b/lib/features/iptv/widgets/settings_tab.dart index 6b9b39c..181e41f 100644 --- a/lib/features/iptv/widgets/settings_tab.dart +++ b/lib/features/iptv/widgets/settings_tab.dart @@ -3,17 +3,44 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import '../../auth/providers/auth_provider.dart'; +import '../providers/settings_provider.dart'; -class SettingsTab extends ConsumerWidget { +class SettingsTab extends ConsumerStatefulWidget { const SettingsTab({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _SettingsTabState(); +} + +class _SettingsTabState extends ConsumerState { + late TextEditingController _filterController; + + @override + void initState() { + super.initState(); + _filterController = TextEditingController(); + } + + @override + void dispose() { + _filterController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { final currentUser = ref.watch(authProvider).currentUser; + final settings = ref.watch(iptvSettingsProvider); + + // Sync controller with state + if (_filterController.text != settings.categoryFilter) { + _filterController.text = settings.categoryFilter; + } return ListView( padding: const EdgeInsets.all(16), children: [ + // User info card Card( child: ListTile( leading: const Icon(Icons.person), @@ -34,6 +61,88 @@ class SettingsTab extends ConsumerWidget { ), ), const SizedBox(height: 16), + + // Category filter section + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.filter_list, size: 20), + const SizedBox(width: 8), + Text( + 'Live TV Category Filter', + style: GoogleFonts.roboto( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Enter keywords separated by commas. Only categories containing one of these words will be shown.', + style: GoogleFonts.roboto( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 12), + TextField( + controller: _filterController, + decoration: InputDecoration( + hintText: 'Ex: FR,FRANCE,HD,SPORT', + hintStyle: GoogleFonts.roboto(fontSize: 13), + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + suffixIcon: _filterController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 18), + onPressed: () { + _filterController.clear(); + ref.read(iptvSettingsProvider.notifier) + .clearCategoryFilter(); + }, + ) + : null, + ), + style: GoogleFonts.roboto(fontSize: 13), + onChanged: (value) { + ref.read(iptvSettingsProvider.notifier) + .setCategoryFilter(value); + }, + ), + if (settings.filterKeywords.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 4, + runSpacing: 4, + children: settings.filterKeywords.map((keyword) { + return Chip( + label: Text( + keyword, + style: GoogleFonts.roboto(fontSize: 11), + ), + padding: EdgeInsets.zero, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ); + }).toList(), + ), + ], + ], + ), + ), + ), + const SizedBox(height: 16), + + // Admin panel if (currentUser?.isAdmin ?? false) ...[ Card( child: ListTile( @@ -45,6 +154,8 @@ class SettingsTab extends ConsumerWidget { ), const SizedBox(height: 8), ], + + // Change playlist Card( child: ListTile( leading: const Icon(Icons.playlist_play), @@ -54,6 +165,8 @@ class SettingsTab extends ConsumerWidget { ), ), const SizedBox(height: 8), + + // About Card( child: ListTile( leading: const Icon(Icons.info_outline), @@ -65,6 +178,8 @@ class SettingsTab extends ConsumerWidget { ), ), const SizedBox(height: 24), + + // Logout button FilledButton.icon( onPressed: () { ref.read(authProvider.notifier).logout();