v1.0.1: Fix Android TV remote control navigation and focus indicators
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1,6 +1,9 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||||
|
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="XStream TV"
|
android:label="XStream TV"
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class XtreamStream {
|
|||||||
final String? plot;
|
final String? plot;
|
||||||
final String? rating;
|
final String? rating;
|
||||||
final String? containerExtension;
|
final String? containerExtension;
|
||||||
|
final String? categoryId;
|
||||||
String? url;
|
String? url;
|
||||||
|
|
||||||
XtreamStream({
|
XtreamStream({
|
||||||
@@ -35,6 +36,7 @@ class XtreamStream {
|
|||||||
this.plot,
|
this.plot,
|
||||||
this.rating,
|
this.rating,
|
||||||
this.containerExtension,
|
this.containerExtension,
|
||||||
|
this.categoryId,
|
||||||
this.url,
|
this.url,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,6 +48,7 @@ class XtreamStream {
|
|||||||
plot: json['plot']?.toString(),
|
plot: json['plot']?.toString(),
|
||||||
rating: json['rating']?.toString(),
|
rating: json['rating']?.toString(),
|
||||||
containerExtension: json['container_extension']?.toString(),
|
containerExtension: json['container_extension']?.toString(),
|
||||||
|
categoryId: json['category_id']?.toString(),
|
||||||
url: null,
|
url: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -58,6 +61,7 @@ class XtreamStream {
|
|||||||
'plot': plot,
|
'plot': plot,
|
||||||
'rating': rating,
|
'rating': rating,
|
||||||
'container_extension': containerExtension,
|
'container_extension': containerExtension,
|
||||||
|
'category_id': categoryId,
|
||||||
'url': url,
|
'url': url,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import '../services/iptv_provider.dart';
|
import '../services/iptv_provider.dart';
|
||||||
import '../models/xtream_models.dart';
|
import '../models/xtream_models.dart';
|
||||||
import 'player_screen.dart';
|
import 'player_screen.dart';
|
||||||
|
import '../widgets/simple_countries_sidebar.dart';
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
@@ -18,9 +20,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
// No automatic data loading on startup
|
||||||
_loadInitialData();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
double get _screenWidth => MediaQuery.of(context).size.width;
|
double get _screenWidth => MediaQuery.of(context).size.width;
|
||||||
@@ -37,13 +37,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
double get _iconSize => _isLargeScreen ? 80 : 60;
|
double get _iconSize => _isLargeScreen ? 80 : 60;
|
||||||
double get _headerPadding => _isLargeScreen ? 32 : 24;
|
double get _headerPadding => _isLargeScreen ? 32 : 24;
|
||||||
|
|
||||||
Future<void> _loadInitialData() async {
|
|
||||||
final provider = context.read<IPTVProvider>();
|
|
||||||
await provider.loadLiveStreams();
|
|
||||||
await provider.loadVodStreams();
|
|
||||||
await provider.loadSeries();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showLiveCategories() {
|
void _showLiveCategories() {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
@@ -51,14 +44,60 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showMovies() {
|
Future<void> _refreshChannels() async {
|
||||||
|
final provider = context.read<IPTVProvider>();
|
||||||
|
await provider.reloadM3UStreams();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _downloadPlaylistAsJson() async {
|
||||||
|
final provider = context.read<IPTVProvider>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final filePath = await provider.downloadAndSaveM3UAsJson();
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Playlist guardada en: $filePath'),
|
||||||
|
duration: const Duration(seconds: 5),
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: 'OK',
|
||||||
|
onPressed: () {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Error: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
duration: const Duration(seconds: 5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showMovies() async {
|
||||||
|
final provider = context.read<IPTVProvider>();
|
||||||
|
// Cargar películas bajo demanda
|
||||||
|
if (provider.vodStreams.isEmpty) {
|
||||||
|
await provider.loadVodStreams();
|
||||||
|
}
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(builder: (_) => const ContentListScreen(type: ContentType.movies)),
|
MaterialPageRoute(builder: (_) => const ContentListScreen(type: ContentType.movies)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showSeries() {
|
void _showSeries() async {
|
||||||
|
final provider = context.read<IPTVProvider>();
|
||||||
|
// Cargar series bajo demanda
|
||||||
|
if (provider.seriesList.isEmpty) {
|
||||||
|
await provider.loadSeries();
|
||||||
|
}
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(builder: (_) => const ContentListScreen(type: ContentType.series)),
|
MaterialPageRoute(builder: (_) => const ContentListScreen(type: ContentType.series)),
|
||||||
@@ -137,12 +176,105 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
const SizedBox(width: 24),
|
const SizedBox(width: 24),
|
||||||
Icon(Icons.person, color: Colors.white70, size: _isLargeScreen ? 32 : 24),
|
Icon(Icons.person, color: Colors.white70, size: _isLargeScreen ? 32 : 24),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
IconButton(
|
Consumer<IPTVProvider>(
|
||||||
icon: Icon(Icons.settings, color: Colors.white70, size: _isLargeScreen ? 32 : 24),
|
builder: (context, provider, _) {
|
||||||
onPressed: () {
|
if (provider.isLoading) {
|
||||||
context.read<IPTVProvider>().logout();
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: _isLargeScreen ? 32 : 24,
|
||||||
|
height: _isLargeScreen ? 32 : 24,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Colors.white70,
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
if (provider.totalChannels > 0)
|
||||||
|
Text(
|
||||||
|
'Cargando canales... ${provider.loadedChannels} de ${provider.totalChannels}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: _isLargeScreen ? 14 : 12,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Text(
|
||||||
|
'Cargando canales...',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: _isLargeScreen ? 14 : 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Focus(
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final hasFocus = Focus.of(context).hasFocus;
|
||||||
|
return Container(
|
||||||
|
decoration: hasFocus
|
||||||
|
? BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
child: IconButton(
|
||||||
|
icon: Icon(Icons.refresh, color: hasFocus ? Colors.white : Colors.white70, size: _isLargeScreen ? 32 : 24),
|
||||||
|
onPressed: _refreshChannels,
|
||||||
|
tooltip: 'Actualizar canales',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Focus(
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final hasFocus = Focus.of(context).hasFocus;
|
||||||
|
return Container(
|
||||||
|
decoration: hasFocus
|
||||||
|
? BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
child: IconButton(
|
||||||
|
icon: Icon(Icons.download, color: hasFocus ? Colors.white : Colors.white70, size: _isLargeScreen ? 32 : 24),
|
||||||
|
onPressed: () => _downloadPlaylistAsJson(),
|
||||||
|
tooltip: 'Descargar playlist como JSON',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Focus(
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final hasFocus = Focus.of(context).hasFocus;
|
||||||
|
return Container(
|
||||||
|
decoration: hasFocus
|
||||||
|
? BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
child: IconButton(
|
||||||
|
icon: Icon(Icons.settings, color: hasFocus ? Colors.white : Colors.white70, size: _isLargeScreen ? 32 : 24),
|
||||||
|
onPressed: () {
|
||||||
|
context.read<IPTVProvider>().logout();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -272,11 +404,13 @@ class _DashboardCard extends StatelessWidget {
|
|||||||
final titleSize = isLarge ? 32.0 : 24.0;
|
final titleSize = isLarge ? 32.0 : 24.0;
|
||||||
final bgIconSize = isLarge ? 200.0 : 150.0;
|
final bgIconSize = isLarge ? 200.0 : 150.0;
|
||||||
return Focus(
|
return Focus(
|
||||||
|
canRequestFocus: true,
|
||||||
child: Builder(
|
child: Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final hasFocus = Focus.of(context).hasFocus;
|
final hasFocus = Focus.of(context).hasFocus;
|
||||||
return GestureDetector(
|
return InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -284,14 +418,15 @@ class _DashboardCard extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: hasFocus ? 0.5 : 0.3),
|
color: hasFocus ? Colors.white.withValues(alpha: 0.4) : Colors.black.withValues(alpha: 0.3),
|
||||||
blurRadius: hasFocus ? 25 : 15,
|
blurRadius: hasFocus ? 30 : 15,
|
||||||
|
spreadRadius: hasFocus ? 4 : 0,
|
||||||
offset: const Offset(0, 8),
|
offset: const Offset(0, 8),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
border: hasFocus
|
border: hasFocus
|
||||||
? Border.all(color: Colors.white.withValues(alpha: 0.5), width: 3)
|
? Border.all(color: Colors.white, width: 4)
|
||||||
: null,
|
: Border.all(color: Colors.transparent, width: 4),
|
||||||
),
|
),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
@@ -352,6 +487,7 @@ class _ContentListScreenState extends State<ContentListScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
print('DEBUG: ContentListScreen.initState() - type: ${widget.type}');
|
||||||
_loadContent();
|
_loadContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,8 +506,10 @@ class _ContentListScreenState extends State<ContentListScreen> {
|
|||||||
double get _headerPadding => _isLargeScreen ? 32 : 16;
|
double get _headerPadding => _isLargeScreen ? 32 : 16;
|
||||||
|
|
||||||
void _loadContent() {
|
void _loadContent() {
|
||||||
|
print('DEBUG: _loadContent() called for type: ${widget.type}');
|
||||||
final provider = context.read<IPTVProvider>();
|
final provider = context.read<IPTVProvider>();
|
||||||
if (widget.type == ContentType.live) {
|
if (widget.type == ContentType.live) {
|
||||||
|
print('DEBUG: Loading live streams with country filter: "${_selectedCountry ?? ''}"');
|
||||||
provider.loadLiveStreams(_selectedCountry ?? '');
|
provider.loadLiveStreams(_selectedCountry ?? '');
|
||||||
} else if (widget.type == ContentType.movies) {
|
} else if (widget.type == ContentType.movies) {
|
||||||
provider.loadVodStreams();
|
provider.loadVodStreams();
|
||||||
@@ -380,6 +518,11 @@ class _ContentListScreenState extends State<ContentListScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onFootballSelected() {
|
||||||
|
final provider = context.read<IPTVProvider>();
|
||||||
|
provider.filterByCategory(SpecialCategories.argentineFootball);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
@@ -412,14 +555,24 @@ class _ContentListScreenState extends State<ContentListScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
print('DEBUG: ContentListScreen.build() - type: ${widget.type}, isLive: ${widget.type == ContentType.live}');
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFF0f0f1a),
|
backgroundColor: const Color(0xFF0f0f1a),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_buildHeader(),
|
_buildHeader(),
|
||||||
_buildCountryFilter(),
|
if (widget.type == ContentType.live)
|
||||||
Expanded(child: _buildContentList()),
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_buildCountrySidebar(),
|
||||||
|
Expanded(child: _buildContentList()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Expanded(child: _buildContentList()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -494,66 +647,20 @@ class _ContentListScreenState extends State<ContentListScreen> {
|
|||||||
return categoryName.trim();
|
return categoryName.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCountryFilter() {
|
Widget _buildCountrySidebar() {
|
||||||
if (widget.type != ContentType.live) {
|
return Consumer<IPTVProvider>(
|
||||||
return const SizedBox.shrink();
|
builder: (context, provider, _) {
|
||||||
}
|
print('🔥 BUILDING SIDEBAR - countries: ${provider.countries.length}, loading: ${provider.isLoading}, organizing: ${provider.isOrganizingCountries}');
|
||||||
|
return SimpleCountriesSidebar(
|
||||||
final categories = _categories;
|
countries: provider.countries,
|
||||||
if (categories.isEmpty) {
|
selectedCountry: provider.selectedCategory.isNotEmpty ? provider.selectedCategory : provider.selectedCountry,
|
||||||
return const SizedBox.shrink();
|
onCountrySelected: (country) => provider.filterByCountry(country),
|
||||||
}
|
isLoading: provider.isLoading,
|
||||||
|
isOrganizing: provider.isOrganizingCountries,
|
||||||
final countries = categories.map((c) => _getCountryName(c.name)).toList();
|
showFootballCategory: true,
|
||||||
final chipHeight = _isLargeScreen ? 56.0 : 50.0;
|
onFootballSelected: () => _onFootballSelected(),
|
||||||
final chipFontSize = _isLargeScreen ? 16.0 : 14.0;
|
);
|
||||||
|
},
|
||||||
return Container(
|
|
||||||
height: chipHeight,
|
|
||||||
margin: EdgeInsets.only(bottom: _isLargeScreen ? 16 : 8),
|
|
||||||
child: ListView.builder(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: _headerPadding),
|
|
||||||
itemCount: countries.length + 1,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
if (index == 0) {
|
|
||||||
return Padding(
|
|
||||||
padding: EdgeInsets.only(right: _isLargeScreen ? 12 : 8),
|
|
||||||
child: FilterChip(
|
|
||||||
label: Text('Todos', style: TextStyle(color: Colors.white, fontSize: chipFontSize)),
|
|
||||||
selected: _selectedCountry == null,
|
|
||||||
selectedColor: Colors.red,
|
|
||||||
backgroundColor: Colors.grey[800],
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: _isLargeScreen ? 12 : 8),
|
|
||||||
onSelected: (_) {
|
|
||||||
setState(() => _selectedCountry = null);
|
|
||||||
context.read<IPTVProvider>().loadLiveStreams('');
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final countryName = countries[index - 1];
|
|
||||||
final category = categories[index - 1];
|
|
||||||
return Padding(
|
|
||||||
padding: EdgeInsets.only(right: _isLargeScreen ? 12 : 8),
|
|
||||||
child: FilterChip(
|
|
||||||
label: Text(
|
|
||||||
countryName.length > 20 ? '${countryName.substring(0, 20)}...' : countryName,
|
|
||||||
style: TextStyle(color: Colors.white, fontSize: chipFontSize),
|
|
||||||
),
|
|
||||||
selected: _selectedCountry == category.id,
|
|
||||||
selectedColor: Colors.red,
|
|
||||||
backgroundColor: Colors.grey[800],
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: _isLargeScreen ? 12 : 8),
|
|
||||||
onSelected: (_) {
|
|
||||||
setState(() => _selectedCountry = category.id);
|
|
||||||
context.read<IPTVProvider>().loadLiveStreams(category.id);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,12 +670,46 @@ class _ContentListScreenState extends State<ContentListScreen> {
|
|||||||
return Consumer<IPTVProvider>(
|
return Consumer<IPTVProvider>(
|
||||||
builder: (context, provider, _) {
|
builder: (context, provider, _) {
|
||||||
if (provider.isLoading) {
|
if (provider.isLoading) {
|
||||||
return Center(child: CircularProgressIndicator(color: Colors.red, strokeWidth: _isLargeScreen ? 4 : 2));
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(color: Colors.red, strokeWidth: _isLargeScreen ? 4 : 2),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (provider.totalChannels > 0)
|
||||||
|
Text(
|
||||||
|
'Cargando canales... ${provider.loadedChannels} de ${provider.totalChannels}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: _isLargeScreen ? 18 : 14,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Text(
|
||||||
|
'Cargando canales...',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: _isLargeScreen ? 18 : 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (provider.totalChannels > 0)
|
||||||
|
SizedBox(
|
||||||
|
width: 200,
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: provider.loadingProgress,
|
||||||
|
backgroundColor: Colors.grey[800],
|
||||||
|
valueColor: const AlwaysStoppedAnimation<Color>(Colors.red),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<XtreamStream> streams = [];
|
List<XtreamStream> streams = [];
|
||||||
if (widget.type == ContentType.live) {
|
if (widget.type == ContentType.live) {
|
||||||
streams = provider.liveStreams;
|
streams = provider.filteredLiveStreams;
|
||||||
} else if (widget.type == ContentType.movies) {
|
} else if (widget.type == ContentType.movies) {
|
||||||
streams = provider.vodStreams;
|
streams = provider.vodStreams;
|
||||||
} else {
|
} else {
|
||||||
@@ -582,9 +723,17 @@ class _ContentListScreenState extends State<ContentListScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_searchQuery.isNotEmpty) {
|
if (_searchQuery.isNotEmpty) {
|
||||||
streams = streams
|
// Special case: "arg|" prefix search - exact pattern match for "arg|" in channel name
|
||||||
.where((s) => s.name.toLowerCase().contains(_searchQuery.toLowerCase()))
|
if (_searchQuery.toLowerCase() == 'arg|') {
|
||||||
.toList();
|
streams = streams
|
||||||
|
.where((s) => s.name.toLowerCase().contains('arg|'))
|
||||||
|
.toList();
|
||||||
|
} else {
|
||||||
|
// Normal search - contains query anywhere in name
|
||||||
|
streams = streams
|
||||||
|
.where((s) => s.name.toLowerCase().contains(_searchQuery.toLowerCase()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (streams.isEmpty) {
|
if (streams.isEmpty) {
|
||||||
|
|||||||
@@ -5,30 +5,46 @@ import '../models/xtream_models.dart';
|
|||||||
|
|
||||||
enum ContentType { live, movies, series }
|
enum ContentType { live, movies, series }
|
||||||
|
|
||||||
|
// Special category constants
|
||||||
|
class SpecialCategories {
|
||||||
|
static const String argentineFootball = 'Fútbol Argentino';
|
||||||
|
}
|
||||||
|
|
||||||
class IPTVProvider extends ChangeNotifier {
|
class IPTVProvider extends ChangeNotifier {
|
||||||
final XtreamApiService _api = XtreamApiService();
|
final XtreamApiService _api = XtreamApiService();
|
||||||
|
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
XtreamUserInfo? _userInfo;
|
XtreamUserInfo? _userInfo;
|
||||||
|
|
||||||
|
int _loadedChannels = 0;
|
||||||
|
int _totalChannels = 0;
|
||||||
|
|
||||||
List<XtreamCategory> _liveCategories = [];
|
List<XtreamCategory> _liveCategories = [];
|
||||||
List<XtreamCategory> _vodCategories = [];
|
List<XtreamCategory> _vodCategories = [];
|
||||||
List<XtreamCategory> _seriesCategories = [];
|
List<XtreamCategory> _seriesCategories = [];
|
||||||
|
|
||||||
List<XtreamStream> _liveStreams = [];
|
List<XtreamStream> _liveStreams = [];
|
||||||
List<XtreamStream> _vodStreams = [];
|
List<XtreamStream> _vodStreams = [];
|
||||||
List<XtreamSeries> _seriesList = [];
|
List<XtreamSeries> _seriesList = [];
|
||||||
List<XtreamEpisode> _seriesEpisodes = [];
|
List<XtreamEpisode> _seriesEpisodes = [];
|
||||||
|
|
||||||
String _selectedLiveCategory = '';
|
String _selectedLiveCategory = '';
|
||||||
String _selectedVodCategory = '';
|
String _selectedVodCategory = '';
|
||||||
|
String _selectedCountry = '';
|
||||||
|
String _selectedCategory = ''; // For special categories like "Fútbol Argentino"
|
||||||
|
List<String> _countries = [];
|
||||||
|
bool _isOrganizingCountries = false;
|
||||||
XtreamSeries? _selectedSeries;
|
XtreamSeries? _selectedSeries;
|
||||||
|
|
||||||
bool get isLoading => _isLoading;
|
bool get isLoading => _isLoading;
|
||||||
String? get error => _error;
|
String? get error => _error;
|
||||||
XtreamUserInfo? get userInfo => _userInfo;
|
XtreamUserInfo? get userInfo => _userInfo;
|
||||||
XtreamApiService get api => _api;
|
XtreamApiService get api => _api;
|
||||||
|
int get loadedChannels => _loadedChannels;
|
||||||
|
int get totalChannels => _totalChannels;
|
||||||
|
double get loadingProgress => _totalChannels > 0 ? _loadedChannels / _totalChannels : 0.0;
|
||||||
|
bool get isOrganizingCountries => _isOrganizingCountries;
|
||||||
|
|
||||||
List<XtreamCategory> get liveCategories => _liveCategories;
|
List<XtreamCategory> get liveCategories => _liveCategories;
|
||||||
List<XtreamCategory> get vodCategories => _vodCategories;
|
List<XtreamCategory> get vodCategories => _vodCategories;
|
||||||
@@ -40,7 +56,76 @@ class IPTVProvider extends ChangeNotifier {
|
|||||||
List<XtreamEpisode> get seriesEpisodes => _seriesEpisodes;
|
List<XtreamEpisode> get seriesEpisodes => _seriesEpisodes;
|
||||||
|
|
||||||
String get selectedLiveCategory => _selectedLiveCategory;
|
String get selectedLiveCategory => _selectedLiveCategory;
|
||||||
String get selectedVodCategory => _selectedVodCategory;
|
String get selectedCountry => _selectedCountry;
|
||||||
|
String get selectedCategory => _selectedCategory;
|
||||||
|
List<String> get countries {
|
||||||
|
print('DEBUG: =========================================');
|
||||||
|
print('DEBUG: countries getter called');
|
||||||
|
print('DEBUG: _countries list length: ${_countries.length}');
|
||||||
|
print('DEBUG: _countries list content: $_countries');
|
||||||
|
print('DEBUG: _countries is empty: ${_countries.isEmpty}');
|
||||||
|
print('DEBUG: =========================================');
|
||||||
|
return _countries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get display items for sidebar including special categories
|
||||||
|
/// Returns a list of maps with 'name', 'type', and 'priority' for proper ordering
|
||||||
|
List<Map<String, dynamic>> get sidebarItems {
|
||||||
|
final items = <Map<String, dynamic>>[];
|
||||||
|
|
||||||
|
// Add all countries with their priority
|
||||||
|
for (final country in _countries) {
|
||||||
|
int priority = _getCountryPriority(country);
|
||||||
|
items.add({
|
||||||
|
'name': country,
|
||||||
|
'type': 'country',
|
||||||
|
'priority': priority,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add special category: Fútbol Argentino (priority 2.5 - between Perú and other countries)
|
||||||
|
// Only add if there are any Argentine football channels
|
||||||
|
final hasArgentineFootball = _liveStreams.any((s) => _api.isArgentineFootballChannel(s.name));
|
||||||
|
if (hasArgentineFootball) {
|
||||||
|
items.add({
|
||||||
|
'name': SpecialCategories.argentineFootball,
|
||||||
|
'type': 'category',
|
||||||
|
'priority': 2.5, // Between Perú (2) and other South American countries (3)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by priority, then alphabetically
|
||||||
|
items.sort((a, b) {
|
||||||
|
final priorityCompare = (a['priority'] as double).compareTo(b['priority'] as double);
|
||||||
|
if (priorityCompare != 0) return priorityCompare;
|
||||||
|
return (a['name'] as String).compareTo(b['name'] as String);
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get priority for a country (lower number = higher priority)
|
||||||
|
int _getCountryPriority(String country) {
|
||||||
|
switch (country) {
|
||||||
|
case 'Argentina':
|
||||||
|
return 1;
|
||||||
|
case 'Perú':
|
||||||
|
case 'Peru':
|
||||||
|
return 2;
|
||||||
|
case 'Bolivia':
|
||||||
|
case 'Brasil':
|
||||||
|
case 'Brazil':
|
||||||
|
case 'Chile':
|
||||||
|
case 'Colombia':
|
||||||
|
case 'Ecuador':
|
||||||
|
case 'Paraguay':
|
||||||
|
case 'Uruguay':
|
||||||
|
case 'Venezuela':
|
||||||
|
return 3;
|
||||||
|
default:
|
||||||
|
return 100; // Low priority for other countries
|
||||||
|
}
|
||||||
|
}
|
||||||
XtreamSeries? get selectedSeries => _selectedSeries;
|
XtreamSeries? get selectedSeries => _selectedSeries;
|
||||||
|
|
||||||
Future<void> login(String server, String username, String password) async {
|
Future<void> login(String server, String username, String password) async {
|
||||||
@@ -52,7 +137,8 @@ class IPTVProvider extends ChangeNotifier {
|
|||||||
_api.setCredentials(server, username, password);
|
_api.setCredentials(server, username, password);
|
||||||
_userInfo = await _api.getUserInfo();
|
_userInfo = await _api.getUserInfo();
|
||||||
|
|
||||||
await _loadCategories();
|
// No automatic data loading on startup - data loads on demand only
|
||||||
|
|
||||||
await _saveCredentials(server, username, password);
|
await _saveCredentials(server, username, password);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_error = e.toString();
|
_error = e.toString();
|
||||||
@@ -73,19 +159,173 @@ class IPTVProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadLiveStreams([String categoryId = '']) async {
|
Future<void> loadLiveStreams([String categoryId = '']) async {
|
||||||
|
print('DEBUG: =========================================================');
|
||||||
|
print('DEBUG: loadLiveStreams() START - API First Strategy');
|
||||||
|
print('DEBUG: =========================================================');
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
|
_isOrganizingCountries = false;
|
||||||
|
_loadedChannels = 0;
|
||||||
|
_totalChannels = 0;
|
||||||
|
_countries = [];
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_liveStreams = await _api.getLiveStreams(categoryId);
|
// STEP 1: Load from API first (much faster than M3U)
|
||||||
_selectedLiveCategory = categoryId;
|
print('DEBUG: Attempting to load from API first...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
_liveStreams = await _api.getLiveStreams(categoryId);
|
||||||
|
_selectedLiveCategory = categoryId;
|
||||||
|
_totalChannels = _liveStreams.length;
|
||||||
|
_loadedChannels = _liveStreams.length;
|
||||||
|
print('DEBUG: API SUCCESS - Loaded ${_liveStreams.length} streams in < 5 seconds');
|
||||||
|
|
||||||
|
if (_liveStreams.isEmpty) {
|
||||||
|
throw Exception('API returned 0 streams');
|
||||||
|
}
|
||||||
|
} catch (apiError) {
|
||||||
|
print('DEBUG: API failed: $apiError');
|
||||||
|
print('DEBUG: Falling back to M3U...');
|
||||||
|
|
||||||
|
// Fallback to M3U only if API fails
|
||||||
|
_liveStreams = await _api.getM3UStreams(
|
||||||
|
onProgress: (loaded, total) {
|
||||||
|
_loadedChannels = loaded;
|
||||||
|
_totalChannels = total;
|
||||||
|
print('DEBUG: M3U progress: $loaded of $total');
|
||||||
|
notifyListeners();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
_selectedLiveCategory = categoryId;
|
||||||
|
print('DEBUG: M3U FALLBACK - Loaded ${_liveStreams.length} streams');
|
||||||
|
|
||||||
|
if (_liveStreams.isEmpty) {
|
||||||
|
throw Exception('No channels available from API or M3U');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// STEP 2: Mark loading complete - channels ready to display
|
||||||
|
print('DEBUG: === CHANNELS READY - Starting background country extraction ===');
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
// STEP 3: Extract countries in background (using optimized method)
|
||||||
|
_extractCountriesInBackground();
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_error = e.toString();
|
_error = e.toString();
|
||||||
|
print('DEBUG: ERROR loading streams: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
print('DEBUG: =========================================================');
|
||||||
|
print('DEBUG: loadLiveStreams() END - Loaded ${_liveStreams.length} channels');
|
||||||
|
print('DEBUG: =========================================================');
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract countries from streams in the background to avoid UI freezing
|
||||||
|
void _extractCountriesInBackground() {
|
||||||
|
if (_liveStreams.isEmpty) return;
|
||||||
|
|
||||||
|
_isOrganizingCountries = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
print('DEBUG: Starting background country extraction from ${_liveStreams.length} streams...');
|
||||||
|
|
||||||
|
// Use Future.microtask to schedule the extraction after the current frame
|
||||||
|
Future.microtask(() {
|
||||||
|
try {
|
||||||
|
// Use optimized extraction (only sample 2000 channels for speed)
|
||||||
|
_countries = _api.getCountriesOptimized(_liveStreams, maxChannelsToProcess: 2000);
|
||||||
|
print('DEBUG: Countries extraction complete. Found ${_countries.length} countries');
|
||||||
|
print('DEBUG: Countries list: $_countries');
|
||||||
|
} catch (e) {
|
||||||
|
print('DEBUG: Error extracting countries: $e');
|
||||||
|
_countries = [];
|
||||||
|
} finally {
|
||||||
|
_isOrganizingCountries = false;
|
||||||
|
print('DEBUG: === CHANNEL LOADING COMPLETE ===');
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract country names from live categories (format: "Country|XX")
|
||||||
|
List<String> _extractCountriesFromCategories() {
|
||||||
|
final countries = <String>{};
|
||||||
|
for (final category in _liveCategories) {
|
||||||
|
final countryName = category.name.split('|').first.trim();
|
||||||
|
// Only add if it's a valid country (not a group title)
|
||||||
|
if (countryName.isNotEmpty && !_isGroupTitle(countryName)) {
|
||||||
|
countries.add(countryName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return countries.toList()..sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a string is a group title (not a country)
|
||||||
|
bool _isGroupTitle(String name) {
|
||||||
|
final normalized = name.toLowerCase().trim();
|
||||||
|
final groupTitles = {
|
||||||
|
'24/7', '24/7 ar', '24/7 in', '24/7-es', '24/7-de', '24/7-gr',
|
||||||
|
'24/7-my', '24/7-pt', '24/7-ro', '24/7-tr', '24/7-latino',
|
||||||
|
'vip', 'vip - pk', 'ppv', 'movies', 'cine', 'cine sd',
|
||||||
|
'cine y serie', 'latino', 'general', 'music', 'religious',
|
||||||
|
'bein', 'mbc', 'tod', 'osn', 'myhd', 'dstv', 'art',
|
||||||
|
'icc-ca', 'icc-car', 'icc-dstv', 'icc-in', 'icc-nz',
|
||||||
|
'icc-pk', 'icc-uk', 'xmas', 'sin', 'ezd', 'exyu', 'rot',
|
||||||
|
'ar-kids', 'ar-sp', 'islam', 'bab', 'as', 'ei'
|
||||||
|
};
|
||||||
|
return groupTitles.contains(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a map from category ID to country name for API streams
|
||||||
|
Map<String, String> _buildCategoryToCountryMap() {
|
||||||
|
final map = <String, String>{};
|
||||||
|
for (final category in _liveCategories) {
|
||||||
|
final countryName = category.name.split('|').first.trim();
|
||||||
|
// Only add if it's a valid country (not a group title)
|
||||||
|
if (countryName.isNotEmpty && !_isGroupTitle(countryName)) {
|
||||||
|
map[category.id] = countryName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
print('DEBUG: Built category map with ${map.length} entries');
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
void filterByCountry(String country) {
|
||||||
|
_selectedCountry = country.trim();
|
||||||
|
_selectedCategory = ''; // Clear special category when country is selected
|
||||||
|
print('DEBUG: Filter by country: "$_selectedCountry"');
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void filterByCategory(String category) {
|
||||||
|
_selectedCategory = category.trim();
|
||||||
|
_selectedCountry = ''; // Clear country when special category is selected
|
||||||
|
print('DEBUG: Filter by category: "$_selectedCategory"');
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<XtreamStream> get filteredLiveStreams {
|
||||||
|
// If a special category is selected, filter by that
|
||||||
|
if (_selectedCategory.isNotEmpty) {
|
||||||
|
print('DEBUG: Filtering by special category: "$_selectedCategory"');
|
||||||
|
return _api.filterByCategory(_liveStreams, _selectedCategory);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show all if empty or "Todos"/"All" selected
|
||||||
|
final normalizedCountry = _selectedCountry.trim();
|
||||||
|
if (normalizedCountry.isEmpty ||
|
||||||
|
normalizedCountry.toLowerCase() == 'todos' ||
|
||||||
|
normalizedCountry.toLowerCase() == 'all') {
|
||||||
|
return _liveStreams;
|
||||||
|
}
|
||||||
|
// Build category map for API streams that don't have country in name
|
||||||
|
final categoryMap = _buildCategoryToCountryMap();
|
||||||
|
return _api.filterByCountry(_liveStreams, _selectedCountry, categoryToCountryMap: categoryMap);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> loadVodStreams([String categoryId = '']) async {
|
Future<void> loadVodStreams([String categoryId = '']) async {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
@@ -131,6 +371,195 @@ class IPTVProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> reloadM3UStreams() async {
|
||||||
|
_isLoading = true;
|
||||||
|
_isOrganizingCountries = false;
|
||||||
|
_error = null;
|
||||||
|
_loadedChannels = 0;
|
||||||
|
_totalChannels = 0;
|
||||||
|
_countries = [];
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try API first, then M3U fallback
|
||||||
|
try {
|
||||||
|
print('DEBUG: Attempting to reload from API...');
|
||||||
|
_liveStreams = await _api.getLiveStreams('');
|
||||||
|
_totalChannels = _liveStreams.length;
|
||||||
|
_loadedChannels = _liveStreams.length;
|
||||||
|
print('DEBUG: API reload - Loaded ${_liveStreams.length} streams');
|
||||||
|
} catch (apiError) {
|
||||||
|
print('DEBUG: API reload failed: $apiError');
|
||||||
|
print('DEBUG: Falling back to M3U...');
|
||||||
|
|
||||||
|
_liveStreams = await _api.getM3UStreams(
|
||||||
|
onProgress: (loaded, total) {
|
||||||
|
_loadedChannels = loaded;
|
||||||
|
_totalChannels = total;
|
||||||
|
print('DEBUG: M3U progress: $loaded of $total');
|
||||||
|
notifyListeners();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
print('DEBUG: M3U reload - Loaded ${_liveStreams.length} streams');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark loading as complete - channels are ready to display
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
// Extract countries in background (optimized)
|
||||||
|
_extractCountriesInBackground();
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
print('DEBUG: Error reloading channels: $e');
|
||||||
|
_error = 'Error al cargar canales: $e';
|
||||||
|
_isLoading = false;
|
||||||
|
_isOrganizingCountries = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads M3U playlist and saves it as JSON file
|
||||||
|
/// Returns the file path where the JSON was saved
|
||||||
|
Future<String> downloadAndSaveM3UAsJson() async {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
print('DEBUG: Starting M3U download and JSON conversion...');
|
||||||
|
|
||||||
|
// If we already have streams loaded, save those instead of downloading again
|
||||||
|
if (_liveStreams.isNotEmpty) {
|
||||||
|
print('DEBUG: Using already loaded ${_liveStreams.length} streams');
|
||||||
|
|
||||||
|
// Create M3U result from loaded streams
|
||||||
|
final channels = _liveStreams.map((stream) => M3UChannel(
|
||||||
|
name: stream.name,
|
||||||
|
url: stream.url ?? '',
|
||||||
|
groupTitle: stream.plot ?? 'Unknown',
|
||||||
|
tvgLogo: stream.streamIcon,
|
||||||
|
)).toList();
|
||||||
|
|
||||||
|
final result = M3UDownloadResult(
|
||||||
|
sourceUrl: '${_api.server}/get.php',
|
||||||
|
downloadTime: DateTime.now(),
|
||||||
|
totalChannels: channels.length,
|
||||||
|
groupsCount: _groupChannelsByCountry(channels),
|
||||||
|
channels: channels,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save as JSON file
|
||||||
|
final filePath = await _api.saveM3UAsJson(result);
|
||||||
|
print('DEBUG: Saved JSON to: $filePath');
|
||||||
|
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no streams loaded, try to download
|
||||||
|
print('DEBUG: No streams loaded, attempting download...');
|
||||||
|
final result = await _api.downloadM3UAsJson();
|
||||||
|
print('DEBUG: Downloaded ${result.totalChannels} channels from ${result.sourceUrl}');
|
||||||
|
print('DEBUG: Groups found: ${result.groupsCount}');
|
||||||
|
|
||||||
|
// Save as JSON file
|
||||||
|
final filePath = await _api.saveM3UAsJson(result);
|
||||||
|
print('DEBUG: Saved JSON to: $filePath');
|
||||||
|
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
return filePath;
|
||||||
|
} catch (e) {
|
||||||
|
print('DEBUG: Error downloading/saving M3U as JSON: $e');
|
||||||
|
_error = 'Error al descargar playlist: $e';
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
throw Exception(_error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, int> _groupChannelsByCountry(List<M3UChannel> channels) {
|
||||||
|
final groups = <String, int>{};
|
||||||
|
for (final channel in channels) {
|
||||||
|
final country = channel.groupTitle ?? 'Unknown';
|
||||||
|
groups[country] = (groups[country] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves all loaded live channels as a text file for analysis
|
||||||
|
Future<String> saveChannelsAsText() async {
|
||||||
|
try {
|
||||||
|
print('DEBUG: Saving ${_liveStreams.length} channels as text file');
|
||||||
|
|
||||||
|
// Build text content
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
buffer.writeln('=== XSTREAM TV - LISTA DE CANALES ===');
|
||||||
|
buffer.writeln('Fecha: ${DateTime.now()}');
|
||||||
|
buffer.writeln('Total de canales: ${_liveStreams.length}');
|
||||||
|
buffer.writeln('');
|
||||||
|
|
||||||
|
// Build category map for API streams
|
||||||
|
final categoryMap = _buildCategoryToCountryMap();
|
||||||
|
|
||||||
|
// Group by country
|
||||||
|
final groupedChannels = <String, List<XtreamStream>>{};
|
||||||
|
for (final stream in _liveStreams) {
|
||||||
|
String? country;
|
||||||
|
|
||||||
|
// First try to extract from name (M3U format)
|
||||||
|
final countryFromName = _api.extractCountryFromChannelName(stream.name);
|
||||||
|
if (countryFromName.isNotEmpty) {
|
||||||
|
country = countryFromName;
|
||||||
|
}
|
||||||
|
// If not found, try category mapping (API format)
|
||||||
|
else if (stream.categoryId != null && categoryMap.containsKey(stream.categoryId)) {
|
||||||
|
country = categoryMap[stream.categoryId];
|
||||||
|
}
|
||||||
|
|
||||||
|
final normalizedCountry = country != null && country.isNotEmpty
|
||||||
|
? _api.normalizeCountry(country)
|
||||||
|
: 'Sin País';
|
||||||
|
|
||||||
|
if (!groupedChannels.containsKey(normalizedCountry)) {
|
||||||
|
groupedChannels[normalizedCountry] = [];
|
||||||
|
}
|
||||||
|
groupedChannels[normalizedCountry]!.add(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write grouped channels
|
||||||
|
final sortedCountries = groupedChannels.keys.toList()..sort();
|
||||||
|
for (final country in sortedCountries) {
|
||||||
|
final channels = groupedChannels[country]!;
|
||||||
|
buffer.writeln('');
|
||||||
|
buffer.writeln('=== $country (${channels.length} canales) ===');
|
||||||
|
buffer.writeln('');
|
||||||
|
|
||||||
|
for (int i = 0; i < channels.length; i++) {
|
||||||
|
final stream = channels[i];
|
||||||
|
buffer.writeln('${i + 1}. ${stream.name}');
|
||||||
|
if (stream.url != null && stream.url!.isNotEmpty) {
|
||||||
|
buffer.writeln(' URL: ${stream.url}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to file
|
||||||
|
final fileName = 'xstream_canales_${DateTime.now().millisecondsSinceEpoch}.txt';
|
||||||
|
final filePath = await _api.saveTextFile(fileName, buffer.toString());
|
||||||
|
|
||||||
|
print('DEBUG: Saved channels list to: $filePath');
|
||||||
|
return filePath;
|
||||||
|
} catch (e) {
|
||||||
|
print('DEBUG: Error saving channels as text: $e');
|
||||||
|
throw Exception('Error al guardar lista: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void clearError() {
|
void clearError() {
|
||||||
_error = null;
|
_error = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -161,7 +590,7 @@ class IPTVProvider extends ChangeNotifier {
|
|||||||
await prefs.remove('server');
|
await prefs.remove('server');
|
||||||
await prefs.remove('username');
|
await prefs.remove('username');
|
||||||
await prefs.remove('password');
|
await prefs.remove('password');
|
||||||
|
|
||||||
_userInfo = null;
|
_userInfo = null;
|
||||||
_liveCategories = [];
|
_liveCategories = [];
|
||||||
_vodCategories = [];
|
_vodCategories = [];
|
||||||
@@ -169,6 +598,9 @@ class IPTVProvider extends ChangeNotifier {
|
|||||||
_liveStreams = [];
|
_liveStreams = [];
|
||||||
_vodStreams = [];
|
_vodStreams = [];
|
||||||
_seriesList = [];
|
_seriesList = [];
|
||||||
|
_countries = [];
|
||||||
|
_selectedCategory = '';
|
||||||
|
_isOrganizingCountries = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
1901
lib/services/xtream_api.dart.backup
Normal file
1901
lib/services/xtream_api.dart.backup
Normal file
File diff suppressed because it is too large
Load Diff
256
lib/widgets/countries_sidebar.dart
Normal file
256
lib/widgets/countries_sidebar.dart
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class CountriesSidebar extends StatelessWidget {
|
||||||
|
final List<String> countries;
|
||||||
|
final String selectedCountry;
|
||||||
|
final String title;
|
||||||
|
final ValueChanged<String> onCountrySelected;
|
||||||
|
final bool isLoading;
|
||||||
|
|
||||||
|
const CountriesSidebar({
|
||||||
|
super.key,
|
||||||
|
required this.countries,
|
||||||
|
required this.selectedCountry,
|
||||||
|
required this.onCountrySelected,
|
||||||
|
this.title = 'Países',
|
||||||
|
this.isLoading = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
print('DEBUG: CountriesSidebar.build() called');
|
||||||
|
print('DEBUG: CountriesSidebar received ${countries.length} countries: $countries');
|
||||||
|
print('DEBUG: CountriesSidebar selectedCountry: "$selectedCountry"');
|
||||||
|
|
||||||
|
final screenWidth = MediaQuery.of(context).size.width;
|
||||||
|
final isLargeScreen = screenWidth > 900;
|
||||||
|
final sidebarWidth = isLargeScreen ? 280.0 : 220.0;
|
||||||
|
final headerHeight = isLargeScreen ? 70.0 : 60.0;
|
||||||
|
final itemHeight = isLargeScreen ? 52.0 : 44.0;
|
||||||
|
final fontSize = isLargeScreen ? 16.0 : 14.0;
|
||||||
|
final headerFontSize = isLargeScreen ? 18.0 : 16.0;
|
||||||
|
final horizontalPadding = isLargeScreen ? 20.0 : 16.0;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: sidebarWidth,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF14141f),
|
||||||
|
border: Border(
|
||||||
|
right: BorderSide(
|
||||||
|
color: Colors.white.withValues(alpha: 0.08),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: headerHeight,
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF1a1a2e),
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: Colors.white.withValues(alpha: 0.08),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.flag,
|
||||||
|
color: Colors.red.shade400,
|
||||||
|
size: isLargeScreen ? 24 : 20,
|
||||||
|
),
|
||||||
|
SizedBox(width: isLargeScreen ? 12 : 10),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: headerFontSize,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: isLoading && countries.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: isLargeScreen ? 40 : 32,
|
||||||
|
height: isLargeScreen ? 40 : 32,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Colors.red.shade400,
|
||||||
|
strokeWidth: isLargeScreen ? 3 : 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: isLargeScreen ? 16 : 12),
|
||||||
|
Text(
|
||||||
|
'Cargando países...',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white.withValues(alpha: 0.6),
|
||||||
|
fontSize: fontSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: countries.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(horizontalPadding),
|
||||||
|
child: Text(
|
||||||
|
'No hay países disponibles',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white.withValues(alpha: 0.5),
|
||||||
|
fontSize: fontSize,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: isLargeScreen ? 12 : 8),
|
||||||
|
itemCount: countries.length + 1,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index == 0) {
|
||||||
|
return _CountryListItem(
|
||||||
|
name: 'Todos',
|
||||||
|
isSelected: selectedCountry.isEmpty,
|
||||||
|
onTap: () => onCountrySelected(''),
|
||||||
|
itemHeight: itemHeight,
|
||||||
|
fontSize: fontSize,
|
||||||
|
horizontalPadding: horizontalPadding,
|
||||||
|
icon: Icons.apps,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final country = countries[index - 1];
|
||||||
|
return _CountryListItem(
|
||||||
|
name: country,
|
||||||
|
isSelected: selectedCountry == country,
|
||||||
|
onTap: () => onCountrySelected(country),
|
||||||
|
itemHeight: itemHeight,
|
||||||
|
fontSize: fontSize,
|
||||||
|
horizontalPadding: horizontalPadding,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CountryListItem extends StatelessWidget {
|
||||||
|
final String name;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final double itemHeight;
|
||||||
|
final double fontSize;
|
||||||
|
final double horizontalPadding;
|
||||||
|
final IconData? icon;
|
||||||
|
|
||||||
|
const _CountryListItem({
|
||||||
|
required this.name,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onTap,
|
||||||
|
required this.itemHeight,
|
||||||
|
required this.fontSize,
|
||||||
|
required this.horizontalPadding,
|
||||||
|
this.icon,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: horizontalPadding,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Container(
|
||||||
|
height: itemHeight,
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: horizontalPadding * 0.8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected ? Colors.red.shade600 : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
boxShadow: isSelected
|
||||||
|
? [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.red.withValues(alpha: 0.25),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 3,
|
||||||
|
height: isSelected ? 20 : 0,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected ? Colors.white : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: isSelected ? 12 : 15),
|
||||||
|
if (icon != null) ...[
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
color: isSelected ? Colors.white : Colors.white.withValues(alpha: 0.6),
|
||||||
|
size: fontSize + 2,
|
||||||
|
),
|
||||||
|
SizedBox(width: 10),
|
||||||
|
] else ...[
|
||||||
|
Icon(
|
||||||
|
Icons.circle,
|
||||||
|
color: isSelected
|
||||||
|
? Colors.white
|
||||||
|
: Colors.white.withValues(alpha: 0.3),
|
||||||
|
size: 6,
|
||||||
|
),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
],
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isSelected
|
||||||
|
? Colors.white
|
||||||
|
: Colors.white.withValues(alpha: 0.7),
|
||||||
|
fontSize: fontSize,
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isSelected)
|
||||||
|
Icon(
|
||||||
|
Icons.check,
|
||||||
|
color: Colors.white,
|
||||||
|
size: fontSize + 2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
225
lib/widgets/simple_countries_sidebar.dart
Normal file
225
lib/widgets/simple_countries_sidebar.dart
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class SimpleCountriesSidebar extends StatelessWidget {
|
||||||
|
final List<String> countries;
|
||||||
|
final String selectedCountry;
|
||||||
|
final ValueChanged<String> onCountrySelected;
|
||||||
|
final bool isLoading;
|
||||||
|
final bool isOrganizing;
|
||||||
|
final bool showFootballCategory;
|
||||||
|
final VoidCallback? onFootballSelected;
|
||||||
|
|
||||||
|
const SimpleCountriesSidebar({
|
||||||
|
super.key,
|
||||||
|
required this.countries,
|
||||||
|
required this.selectedCountry,
|
||||||
|
required this.onCountrySelected,
|
||||||
|
this.isLoading = false,
|
||||||
|
this.isOrganizing = false,
|
||||||
|
this.showFootballCategory = false,
|
||||||
|
this.onFootballSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
static const String _footballCategoryName = 'Fútbol Argentino';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
print('🔥 SIDEBAR BUILD ============================================');
|
||||||
|
print('🔥 SIDEBAR BUILD - Countries count: ${countries.length}');
|
||||||
|
print('🔥 SIDEBAR BUILD - Is Loading: $isLoading');
|
||||||
|
print('🔥 SIDEBAR BUILD - Is Organizing: $isOrganizing');
|
||||||
|
print('🔥 SIDEBAR BUILD - Countries list: $countries');
|
||||||
|
print('🔥 SIDEBAR BUILD - Selected country: "$selectedCountry"');
|
||||||
|
|
||||||
|
if (countries.isNotEmpty) {
|
||||||
|
print('🔥 SIDEBAR BUILD - First 10 countries:');
|
||||||
|
for (int i = 0; i < countries.length && i < 10; i++) {
|
||||||
|
print('🔥 SIDEBAR BUILD [${i + 1}] "${countries[i]}"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
print('🔥 SIDEBAR BUILD ============================================');
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: 250,
|
||||||
|
color: Colors.grey[900],
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Header
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
color: Colors.red,
|
||||||
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.public, color: Colors.white),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'PAÍSES',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// List
|
||||||
|
Expanded(
|
||||||
|
child: isOrganizing || (isLoading && countries.isEmpty)
|
||||||
|
? const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(color: Colors.red),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Organizando países...',
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: countries.isEmpty
|
||||||
|
? const Center(
|
||||||
|
child: Text(
|
||||||
|
'No hay países',
|
||||||
|
style: TextStyle(color: Colors.white54),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
itemCount: _getItemCount(),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return _buildItemAtIndex(context, index);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCountryItem(String name, bool isSelected, VoidCallback onTap) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected ? Colors.red : Colors.transparent,
|
||||||
|
border: Border(
|
||||||
|
left: BorderSide(
|
||||||
|
color: isSelected ? Colors.white : Colors.transparent,
|
||||||
|
width: 4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _getItemCount() {
|
||||||
|
int count = countries.length + 1; // +1 for "Todos"
|
||||||
|
if (showFootballCategory) {
|
||||||
|
count += 1; // +1 for "Fútbol Argentino"
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildItemAtIndex(BuildContext context, int index) {
|
||||||
|
if (index == 0) {
|
||||||
|
return _buildCountryItem(
|
||||||
|
'Todos',
|
||||||
|
selectedCountry.isEmpty,
|
||||||
|
() => onCountrySelected(''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find insertion point for "Fútbol Argentino" (after Perú)
|
||||||
|
final peruIndex = countries.indexOf('Perú');
|
||||||
|
final footballInsertIndex = peruIndex >= 0 ? peruIndex + 1 : countries.length;
|
||||||
|
|
||||||
|
if (showFootballCategory) {
|
||||||
|
// Adjust for "Todos" at index 0 and "Fútbol Argentino" after Perú
|
||||||
|
if (index == footballInsertIndex + 1) {
|
||||||
|
return _buildFootballItem();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map the adjusted index to the actual country list
|
||||||
|
int countryIndex;
|
||||||
|
if (index <= footballInsertIndex) {
|
||||||
|
// Before football item
|
||||||
|
countryIndex = index - 1;
|
||||||
|
} else {
|
||||||
|
// After football item
|
||||||
|
countryIndex = index - 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (countryIndex >= 0 && countryIndex < countries.length) {
|
||||||
|
final country = countries[countryIndex];
|
||||||
|
return _buildCountryItem(
|
||||||
|
country,
|
||||||
|
selectedCountry == country,
|
||||||
|
() => onCountrySelected(country),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal behavior without football category
|
||||||
|
final country = countries[index - 1];
|
||||||
|
return _buildCountryItem(
|
||||||
|
country,
|
||||||
|
selectedCountry == country,
|
||||||
|
() => onCountrySelected(country),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFootballItem() {
|
||||||
|
final isSelected = selectedCountry == _footballCategoryName;
|
||||||
|
return InkWell(
|
||||||
|
onTap: onFootballSelected,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected ? Colors.green[700] : Colors.green[900]?.withOpacity(0.3),
|
||||||
|
border: Border(
|
||||||
|
left: BorderSide(
|
||||||
|
color: isSelected ? Colors.white : Colors.green[400]!,
|
||||||
|
width: 4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.sports_soccer,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_footballCategoryName,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,9 @@ dependencies:
|
|||||||
shared_preferences: ^2.3.5
|
shared_preferences: ^2.3.5
|
||||||
provider: ^6.1.2
|
provider: ^6.1.2
|
||||||
intl: ^0.19.0
|
intl: ^0.19.0
|
||||||
|
path_provider: ^2.1.5
|
||||||
|
permission_handler: ^11.4.0
|
||||||
|
path: ^1.9.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user