Add search functionality to Live, Movies and Series tabs

This commit is contained in:
2026-02-25 13:19:43 -03:00
parent 1f5b53ea3c
commit f9098ba234

View File

@@ -108,7 +108,21 @@ class _HomeScreenState extends State<HomeScreen> {
}
}
class _LiveTab extends StatelessWidget {
class _LiveTab extends StatefulWidget {
@override
State<_LiveTab> createState() => _LiveTabState();
}
class _LiveTabState extends State<_LiveTab> {
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Consumer<IPTVProvider>(
@@ -117,18 +131,70 @@ class _LiveTab extends StatelessWidget {
return const Center(child: CircularProgressIndicator(color: Colors.red));
}
List<XtreamStream> filteredStreams = provider.liveStreams;
if (_searchQuery.isNotEmpty) {
filteredStreams = provider.liveStreams
.where((s) => s.name.toLowerCase().contains(_searchQuery.toLowerCase()))
.toList();
}
return Column(
children: [
_CategoryDropdown(
categories: provider.liveCategories,
selectedCategory: provider.selectedLiveCategory,
onCategorySelected: (categoryId) {
provider.loadLiveStreams(categoryId);
},
Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
Expanded(
child: _CategoryDropdown(
categories: provider.liveCategories,
selectedCategory: provider.selectedLiveCategory,
onCategorySelected: (categoryId) {
provider.loadLiveStreams(categoryId);
_searchController.clear();
setState(() => _searchQuery = '');
},
),
),
const SizedBox(width: 8),
SizedBox(
width: 250,
height: 48,
child: TextField(
controller: _searchController,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
hintText: 'Buscar canal...',
hintStyle: const TextStyle(color: Colors.grey),
prefixIcon: const Icon(Icons.search, color: Colors.grey),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, color: Colors.grey),
onPressed: () {
_searchController.clear();
setState(() => _searchQuery = '');
},
)
: null,
filled: true,
fillColor: Colors.grey[900],
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
),
onChanged: (value) {
setState(() => _searchQuery = value);
},
),
),
],
),
),
Expanded(
child: _StreamList(
streams: provider.liveStreams,
streams: filteredStreams,
searchQuery: _searchQuery,
onStreamSelected: (stream) {
Navigator.push(
context,
@@ -146,7 +212,21 @@ class _LiveTab extends StatelessWidget {
}
}
class _MoviesTab extends StatelessWidget {
class _MoviesTab extends StatefulWidget {
@override
State<_MoviesTab> createState() => _MoviesTabState();
}
class _MoviesTabState extends State<_MoviesTab> {
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Consumer<IPTVProvider>(
@@ -155,23 +235,75 @@ class _MoviesTab extends StatelessWidget {
return const Center(child: CircularProgressIndicator(color: Colors.red));
}
List<XtreamStream> filteredStreams = provider.vodStreams;
if (_searchQuery.isNotEmpty) {
filteredStreams = provider.vodStreams
.where((s) => s.name.toLowerCase().contains(_searchQuery.toLowerCase()))
.toList();
}
return Column(
children: [
_CategoryDropdown(
categories: provider.vodCategories,
selectedCategory: provider.selectedVodCategory,
onCategorySelected: (categoryId) {
provider.loadVodStreams(categoryId);
},
Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
Expanded(
child: _CategoryDropdown(
categories: provider.vodCategories,
selectedCategory: provider.selectedVodCategory,
onCategorySelected: (categoryId) {
provider.loadVodStreams(categoryId);
_searchController.clear();
setState(() => _searchQuery = '');
},
),
),
const SizedBox(width: 8),
SizedBox(
width: 250,
height: 48,
child: TextField(
controller: _searchController,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
hintText: 'Buscar película...',
hintStyle: const TextStyle(color: Colors.grey),
prefixIcon: const Icon(Icons.search, color: Colors.grey),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, color: Colors.grey),
onPressed: () {
_searchController.clear();
setState(() => _searchQuery = '');
},
)
: null,
filled: true,
fillColor: Colors.grey[900],
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
),
onChanged: (value) {
setState(() => _searchQuery = value);
},
),
),
],
),
),
Expanded(
child: _StreamList(
streams: provider.vodStreams,
streams: filteredStreams,
searchQuery: _searchQuery,
onStreamSelected: (stream) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PlayerScreen(stream: stream),
builder: (_) => PlayerScreen(stream: stream, isLive: false),
),
);
},
@@ -184,7 +316,21 @@ class _MoviesTab extends StatelessWidget {
}
}
class _SeriesTab extends StatelessWidget {
class _SeriesTab extends StatefulWidget {
@override
State<_SeriesTab> createState() => _SeriesTabState();
}
class _SeriesTabState extends State<_SeriesTab> {
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Consumer<IPTVProvider>(
@@ -197,21 +343,71 @@ class _SeriesTab extends StatelessWidget {
return _SeriesDetailScreen(series: provider.selectedSeries!);
}
return _StreamList(
streams: provider.seriesList.map((s) => XtreamStream(
streamId: s.seriesId,
name: s.name,
streamIcon: s.cover,
plot: s.plot,
rating: s.rating,
)).toList(),
isSeries: true,
onStreamSelected: (stream) {
final series = provider.seriesList.firstWhere(
(s) => s.seriesId == stream.streamId,
);
provider.loadSeriesEpisodes(series);
},
List<XtreamStream> filteredSeries = provider.seriesList.map((s) => XtreamStream(
streamId: s.seriesId,
name: s.name,
streamIcon: s.cover,
plot: s.plot,
rating: s.rating,
)).toList();
if (_searchQuery.isNotEmpty) {
filteredSeries = filteredSeries
.where((s) => s.name.toLowerCase().contains(_searchQuery.toLowerCase()))
.toList();
}
return Column(
children: [
Padding(
padding: const EdgeInsets.all(8),
child: SizedBox(
width: 300,
height: 48,
child: TextField(
controller: _searchController,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
hintText: 'Buscar serie...',
hintStyle: const TextStyle(color: Colors.grey),
prefixIcon: const Icon(Icons.search, color: Colors.grey),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, color: Colors.grey),
onPressed: () {
_searchController.clear();
setState(() => _searchQuery = '');
},
)
: null,
filled: true,
fillColor: Colors.grey[900],
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
),
onChanged: (value) {
setState(() => _searchQuery = value);
},
),
),
),
Expanded(
child: _StreamList(
streams: filteredSeries,
searchQuery: _searchQuery,
isSeries: true,
onStreamSelected: (stream) {
final series = provider.seriesList.firstWhere(
(s) => s.seriesId == stream.streamId,
);
provider.loadSeriesEpisodes(series);
},
),
),
],
);
},
);
@@ -335,20 +531,22 @@ class _StreamList extends StatelessWidget {
final List<XtreamStream> streams;
final Function(XtreamStream) onStreamSelected;
final bool isSeries;
final String searchQuery;
const _StreamList({
required this.streams,
required this.onStreamSelected,
this.isSeries = false,
this.searchQuery = '',
});
@override
Widget build(BuildContext context) {
if (streams.isEmpty) {
return const Center(
return Center(
child: Text(
'No content available',
style: TextStyle(color: Colors.grey),
searchQuery.isNotEmpty ? 'No se encontraron canales' : 'No content available',
style: const TextStyle(color: Colors.grey),
),
);
}
@@ -358,6 +556,7 @@ class _StreamList extends StatelessWidget {
itemBuilder: (context, index) {
final stream = streams[index];
return ListTile(
dense: true,
leading: isSeries && stream.streamIcon != null
? ClipRRect(
borderRadius: BorderRadius.circular(4),
@@ -373,17 +572,12 @@ class _StreamList extends StatelessWidget {
),
),
)
: Icon(
isSeries ? Icons.tv : Icons.play_circle_outline,
: const Icon(
Icons.play_circle_outline,
color: Colors.red,
size: 40,
),
title: Text(
stream.name,
style: const TextStyle(color: Colors.white),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
title: _buildTitle(stream.name),
subtitle: stream.rating != null
? Row(
children: [
@@ -400,4 +594,48 @@ class _StreamList extends StatelessWidget {
},
);
}
Widget _buildTitle(String name) {
if (searchQuery.isEmpty) {
return Text(
name,
style: const TextStyle(color: Colors.white),
maxLines: 2,
overflow: TextOverflow.ellipsis,
);
}
final lowerName = name.toLowerCase();
final lowerQuery = searchQuery.toLowerCase();
final startIndex = lowerName.indexOf(lowerQuery);
if (startIndex == -1) {
return Text(
name,
style: const TextStyle(color: Colors.white),
maxLines: 2,
overflow: TextOverflow.ellipsis,
);
}
return RichText(
maxLines: 2,
overflow: TextOverflow.ellipsis,
text: TextSpan(
style: const TextStyle(color: Colors.white),
children: [
TextSpan(text: name.substring(0, startIndex)),
TextSpan(
text: name.substring(startIndex, startIndex + searchQuery.length),
style: const TextStyle(
backgroundColor: Colors.yellow,
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
TextSpan(text: name.substring(startIndex + searchQuery.length)),
],
),
);
}
}