Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5351513619 | |||
| 8c7bbc5f2d | |||
| 5d38b89a53 | |||
| 19b45152f8 |
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">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<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
|
||||
android:label="XStream TV"
|
||||
|
||||
@@ -26,6 +26,7 @@ class XtreamStream {
|
||||
final String? plot;
|
||||
final String? rating;
|
||||
final String? containerExtension;
|
||||
final String? categoryId;
|
||||
String? url;
|
||||
|
||||
XtreamStream({
|
||||
@@ -35,6 +36,7 @@ class XtreamStream {
|
||||
this.plot,
|
||||
this.rating,
|
||||
this.containerExtension,
|
||||
this.categoryId,
|
||||
this.url,
|
||||
});
|
||||
|
||||
@@ -46,6 +48,7 @@ class XtreamStream {
|
||||
plot: json['plot']?.toString(),
|
||||
rating: json['rating']?.toString(),
|
||||
containerExtension: json['container_extension']?.toString(),
|
||||
categoryId: json['category_id']?.toString(),
|
||||
url: null,
|
||||
);
|
||||
}
|
||||
@@ -58,6 +61,7 @@ class XtreamStream {
|
||||
'plot': plot,
|
||||
'rating': rating,
|
||||
'container_extension': containerExtension,
|
||||
'category_id': categoryId,
|
||||
'url': url,
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
import 'package:chewie/chewie.dart';
|
||||
import '../models/xtream_models.dart';
|
||||
@@ -8,18 +7,14 @@ class PlayerScreen extends StatefulWidget {
|
||||
final XtreamStream stream;
|
||||
final bool isLive;
|
||||
|
||||
const PlayerScreen({
|
||||
super.key,
|
||||
required this.stream,
|
||||
this.isLive = true,
|
||||
});
|
||||
const PlayerScreen({super.key, required this.stream, this.isLive = true});
|
||||
|
||||
@override
|
||||
State<PlayerScreen> createState() => _PlayerScreenState();
|
||||
}
|
||||
|
||||
class _PlayerScreenState extends State<PlayerScreen> {
|
||||
late VideoPlayerController _videoController;
|
||||
VideoPlayerController? _videoController;
|
||||
ChewieController? _chewieController;
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
@@ -32,20 +27,32 @@ class _PlayerScreenState extends State<PlayerScreen> {
|
||||
|
||||
Future<void> _initPlayer() async {
|
||||
try {
|
||||
_chewieController?.dispose();
|
||||
_chewieController = null;
|
||||
await _videoController?.dispose();
|
||||
_videoController = null;
|
||||
|
||||
final url = widget.stream.url;
|
||||
if (url == null || url.isEmpty) {
|
||||
throw Exception('No stream URL available');
|
||||
}
|
||||
|
||||
_videoController = VideoPlayerController.networkUrl(Uri.parse(url));
|
||||
|
||||
await _videoController.initialize();
|
||||
final videoController = VideoPlayerController.networkUrl(
|
||||
Uri.parse(url),
|
||||
videoPlayerOptions: VideoPlayerOptions(
|
||||
allowBackgroundPlayback: false,
|
||||
mixWithOthers: false,
|
||||
),
|
||||
);
|
||||
|
||||
await videoController.initialize();
|
||||
_videoController = videoController;
|
||||
|
||||
_chewieController = ChewieController(
|
||||
videoPlayerController: _videoController,
|
||||
videoPlayerController: videoController,
|
||||
autoPlay: true,
|
||||
looping: widget.isLive,
|
||||
aspectRatio: _videoController.value.aspectRatio,
|
||||
aspectRatio: videoController.value.aspectRatio,
|
||||
allowFullScreen: true,
|
||||
allowMuting: true,
|
||||
showControls: true,
|
||||
@@ -76,10 +83,6 @@ class _PlayerScreenState extends State<PlayerScreen> {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
_videoController.addListener(() {
|
||||
setState(() {});
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
@@ -90,7 +93,7 @@ class _PlayerScreenState extends State<PlayerScreen> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_videoController.dispose();
|
||||
_videoController?.dispose();
|
||||
_chewieController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -99,26 +102,17 @@ class _PlayerScreenState extends State<PlayerScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
title: Text(
|
||||
widget.stream.name,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
iconTheme: const IconThemeData(color: Colors.white),
|
||||
),
|
||||
body: Center(
|
||||
child: _isLoading
|
||||
? const CircularProgressIndicator(color: Colors.red)
|
||||
: _error != null
|
||||
? _buildError()
|
||||
: _chewieController != null
|
||||
? Chewie(controller: _chewieController!)
|
||||
: const Text(
|
||||
'No video available',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
? _buildError()
|
||||
: _chewieController != null
|
||||
? Chewie(controller: _chewieController!)
|
||||
: const Text(
|
||||
'No video available',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,44 +5,156 @@ import '../models/xtream_models.dart';
|
||||
|
||||
enum ContentType { live, movies, series }
|
||||
|
||||
// Special category constants
|
||||
class SpecialCategories {
|
||||
static const String argentineFootball = 'Fútbol Argentino';
|
||||
}
|
||||
|
||||
class IPTVProvider extends ChangeNotifier {
|
||||
final XtreamApiService _api = XtreamApiService();
|
||||
|
||||
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
XtreamUserInfo? _userInfo;
|
||||
|
||||
|
||||
int _loadedChannels = 0;
|
||||
int _totalChannels = 0;
|
||||
|
||||
List<XtreamCategory> _liveCategories = [];
|
||||
List<XtreamCategory> _vodCategories = [];
|
||||
List<XtreamCategory> _seriesCategories = [];
|
||||
|
||||
|
||||
List<XtreamStream> _liveStreams = [];
|
||||
List<XtreamStream> _vodStreams = [];
|
||||
List<XtreamSeries> _seriesList = [];
|
||||
List<XtreamEpisode> _seriesEpisodes = [];
|
||||
|
||||
|
||||
String _selectedLiveCategory = '';
|
||||
String _selectedVodCategory = '';
|
||||
String _selectedCountry = '';
|
||||
String _selectedCategory =
|
||||
''; // For special categories like "Fútbol Argentino"
|
||||
List<String> _countries = [];
|
||||
bool _isOrganizingCountries = false;
|
||||
XtreamSeries? _selectedSeries;
|
||||
|
||||
Map<String, String>? _categoryToCountryMapCache;
|
||||
List<XtreamStream>? _filteredLiveStreamsCache;
|
||||
String _filteredCountryCacheKey = '';
|
||||
String _filteredCategoryCacheKey = '';
|
||||
int _liveStreamsVersion = 0;
|
||||
int _filteredCacheVersion = -1;
|
||||
int _lastProgressUiUpdateMs = 0;
|
||||
|
||||
bool get isLoading => _isLoading;
|
||||
String? get error => _error;
|
||||
XtreamUserInfo? get userInfo => _userInfo;
|
||||
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 vodCategories => _vodCategories;
|
||||
List<XtreamCategory> get seriesCategories => _seriesCategories;
|
||||
|
||||
|
||||
List<XtreamStream> get liveStreams => _liveStreams;
|
||||
List<XtreamStream> get vodStreams => _vodStreams;
|
||||
List<XtreamSeries> get seriesList => _seriesList;
|
||||
List<XtreamEpisode> get seriesEpisodes => _seriesEpisodes;
|
||||
|
||||
|
||||
String get selectedLiveCategory => _selectedLiveCategory;
|
||||
String get selectedVodCategory => _selectedVodCategory;
|
||||
String get selectedCountry => _selectedCountry;
|
||||
String get selectedCategory => _selectedCategory;
|
||||
List<String> get countries => _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;
|
||||
|
||||
void _invalidateLiveDerivedCaches() {
|
||||
_categoryToCountryMapCache = null;
|
||||
_filteredLiveStreamsCache = null;
|
||||
_filteredCacheVersion = -1;
|
||||
}
|
||||
|
||||
void _setLiveStreams(List<XtreamStream> streams, String categoryId) {
|
||||
_liveStreams = streams;
|
||||
_selectedLiveCategory = categoryId;
|
||||
_liveStreamsVersion++;
|
||||
_invalidateLiveDerivedCaches();
|
||||
}
|
||||
|
||||
void _notifyProgressUpdate() {
|
||||
final nowMs = DateTime.now().millisecondsSinceEpoch;
|
||||
final shouldUpdate =
|
||||
(nowMs - _lastProgressUiUpdateMs) >= 120 ||
|
||||
(_totalChannels > 0 && _loadedChannels >= _totalChannels);
|
||||
if (shouldUpdate) {
|
||||
_lastProgressUiUpdateMs = nowMs;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> login(String server, String username, String password) async {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
@@ -51,8 +163,9 @@ class IPTVProvider extends ChangeNotifier {
|
||||
try {
|
||||
_api.setCredentials(server, username, password);
|
||||
_userInfo = await _api.getUserInfo();
|
||||
|
||||
await _loadCategories();
|
||||
|
||||
// No automatic data loading on startup - data loads on demand only
|
||||
|
||||
await _saveCredentials(server, username, password);
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
@@ -62,23 +175,51 @@ class IPTVProvider extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _loadCategories() async {
|
||||
try {
|
||||
_liveCategories = await _api.getLiveCategories();
|
||||
_vodCategories = await _api.getVodCategories();
|
||||
_seriesCategories = await _api.getSeriesCategories();
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadLiveStreams([String categoryId = '']) async {
|
||||
_isLoading = true;
|
||||
_isOrganizingCountries = false;
|
||||
_loadedChannels = 0;
|
||||
_totalChannels = 0;
|
||||
_countries = [];
|
||||
_lastProgressUiUpdateMs = 0;
|
||||
_invalidateLiveDerivedCaches();
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
_liveStreams = await _api.getLiveStreams(categoryId);
|
||||
_selectedLiveCategory = categoryId;
|
||||
// STEP 1: Load from API first (much faster than M3U)
|
||||
|
||||
try {
|
||||
_setLiveStreams(await _api.getLiveStreams(categoryId), categoryId);
|
||||
_totalChannels = _liveStreams.length;
|
||||
_loadedChannels = _liveStreams.length;
|
||||
|
||||
if (_liveStreams.isEmpty) {
|
||||
throw Exception('API returned 0 streams');
|
||||
}
|
||||
} catch (apiError) {
|
||||
// Fallback to M3U only if API fails
|
||||
_setLiveStreams(
|
||||
await _api.getM3UStreams(
|
||||
onProgress: (loaded, total) {
|
||||
_loadedChannels = loaded;
|
||||
_totalChannels = total;
|
||||
_notifyProgressUpdate();
|
||||
},
|
||||
),
|
||||
categoryId,
|
||||
);
|
||||
|
||||
if (_liveStreams.isEmpty) {
|
||||
throw Exception('No channels available from API or M3U');
|
||||
}
|
||||
}
|
||||
|
||||
// STEP 2: Mark loading complete - channels ready to display
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
// STEP 3: Extract countries in background (using optimized method)
|
||||
_extractCountriesInBackground();
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
}
|
||||
@@ -87,13 +228,169 @@ class IPTVProvider extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Extract countries from streams in the background to avoid UI freezing
|
||||
void _extractCountriesInBackground() {
|
||||
if (_liveStreams.isEmpty) return;
|
||||
|
||||
_isOrganizingCountries = true;
|
||||
notifyListeners();
|
||||
|
||||
// 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,
|
||||
);
|
||||
} catch (e) {
|
||||
_countries = [];
|
||||
} finally {
|
||||
_isOrganizingCountries = false;
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
if (_categoryToCountryMapCache != null) {
|
||||
return _categoryToCountryMapCache!;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
_categoryToCountryMapCache = map;
|
||||
return map;
|
||||
}
|
||||
|
||||
void filterByCountry(String country) {
|
||||
final normalizedCountry = country.trim();
|
||||
if (_selectedCountry == normalizedCountry && _selectedCategory.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_selectedCountry = normalizedCountry;
|
||||
_selectedCategory = ''; // Clear special category when country is selected
|
||||
_filteredLiveStreamsCache = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void filterByCategory(String category) {
|
||||
final normalizedCategory = category.trim();
|
||||
if (_selectedCategory == normalizedCategory && _selectedCountry.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_selectedCategory = normalizedCategory;
|
||||
_selectedCountry = ''; // Clear country when special category is selected
|
||||
_filteredLiveStreamsCache = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
List<XtreamStream> get filteredLiveStreams {
|
||||
final selectedCountry = _selectedCountry.trim();
|
||||
final selectedCategory = _selectedCategory.trim();
|
||||
if (_filteredLiveStreamsCache != null &&
|
||||
_filteredCacheVersion == _liveStreamsVersion &&
|
||||
_filteredCountryCacheKey == selectedCountry &&
|
||||
_filteredCategoryCacheKey == selectedCategory) {
|
||||
return _filteredLiveStreamsCache!;
|
||||
}
|
||||
|
||||
late final List<XtreamStream> result;
|
||||
|
||||
// If a special category is selected, filter by that
|
||||
if (selectedCategory.isNotEmpty) {
|
||||
result = _api.filterByCategory(_liveStreams, selectedCategory);
|
||||
} else if (selectedCountry.isEmpty ||
|
||||
selectedCountry.toLowerCase() == 'todos' ||
|
||||
selectedCountry.toLowerCase() == 'all') {
|
||||
result = _liveStreams;
|
||||
} else {
|
||||
// Build category map for API streams that don't have country in name
|
||||
final categoryMap = _buildCategoryToCountryMap();
|
||||
result = _api.filterByCountry(
|
||||
_liveStreams,
|
||||
selectedCountry,
|
||||
categoryToCountryMap: categoryMap,
|
||||
);
|
||||
}
|
||||
|
||||
_filteredCountryCacheKey = selectedCountry;
|
||||
_filteredCategoryCacheKey = selectedCategory;
|
||||
_filteredCacheVersion = _liveStreamsVersion;
|
||||
_filteredLiveStreamsCache = identical(result, _liveStreams)
|
||||
? _liveStreams
|
||||
: List.unmodifiable(result);
|
||||
return _filteredLiveStreamsCache!;
|
||||
}
|
||||
|
||||
Future<void> loadVodStreams([String categoryId = '']) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
_vodStreams = await _api.getVodStreams(categoryId);
|
||||
_selectedVodCategory = categoryId;
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
}
|
||||
@@ -131,12 +428,193 @@ class IPTVProvider extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> reloadM3UStreams() async {
|
||||
_isLoading = true;
|
||||
_isOrganizingCountries = false;
|
||||
_error = null;
|
||||
_loadedChannels = 0;
|
||||
_totalChannels = 0;
|
||||
_countries = [];
|
||||
_lastProgressUiUpdateMs = 0;
|
||||
_invalidateLiveDerivedCaches();
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
// Try API first, then M3U fallback
|
||||
try {
|
||||
_setLiveStreams(await _api.getLiveStreams(''), '');
|
||||
_totalChannels = _liveStreams.length;
|
||||
_loadedChannels = _liveStreams.length;
|
||||
} catch (apiError) {
|
||||
_setLiveStreams(
|
||||
await _api.getM3UStreams(
|
||||
onProgress: (loaded, total) {
|
||||
_loadedChannels = loaded;
|
||||
_totalChannels = total;
|
||||
_notifyProgressUpdate();
|
||||
},
|
||||
),
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
// Mark loading as complete - channels are ready to display
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
// Extract countries in background (optimized)
|
||||
_extractCountriesInBackground();
|
||||
} catch (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 {
|
||||
// If we already have streams loaded, save those instead of downloading again
|
||||
if (_liveStreams.isNotEmpty) {
|
||||
// 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);
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
// If no streams loaded, try to download
|
||||
final result = await _api.downloadM3UAsJson();
|
||||
|
||||
// Save as JSON file
|
||||
final filePath = await _api.saveM3UAsJson(result);
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
return filePath;
|
||||
} catch (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 {
|
||||
// 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());
|
||||
|
||||
return filePath;
|
||||
} catch (e) {
|
||||
throw Exception('Error al guardar lista: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void clearError() {
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _saveCredentials(String server, String username, String password) async {
|
||||
Future<void> _saveCredentials(
|
||||
String server,
|
||||
String username,
|
||||
String password,
|
||||
) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('server', server);
|
||||
await prefs.setString('username', username);
|
||||
@@ -161,7 +639,7 @@ class IPTVProvider extends ChangeNotifier {
|
||||
await prefs.remove('server');
|
||||
await prefs.remove('username');
|
||||
await prefs.remove('password');
|
||||
|
||||
|
||||
_userInfo = null;
|
||||
_liveCategories = [];
|
||||
_vodCategories = [];
|
||||
@@ -169,6 +647,13 @@ class IPTVProvider extends ChangeNotifier {
|
||||
_liveStreams = [];
|
||||
_vodStreams = [];
|
||||
_seriesList = [];
|
||||
_countries = [];
|
||||
_selectedLiveCategory = '';
|
||||
_selectedCountry = '';
|
||||
_selectedCategory = '';
|
||||
_isOrganizingCountries = false;
|
||||
_liveStreamsVersion = 0;
|
||||
_invalidateLiveDerivedCaches();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1918
lib/services/xtream_api.dart.backup
Normal file
1918
lib/services/xtream_api.dart.backup
Normal file
File diff suppressed because it is too large
Load Diff
55
lib/utils/channel_name_formatter.dart
Normal file
55
lib/utils/channel_name_formatter.dart
Normal file
@@ -0,0 +1,55 @@
|
||||
class ChannelNameFormatter {
|
||||
static final Map<String, String> _cache = <String, String>{};
|
||||
static const Set<String> _qualityTokens = <String>{
|
||||
'SD',
|
||||
'HD',
|
||||
'FHD',
|
||||
'UHD',
|
||||
'4K',
|
||||
'8K',
|
||||
'HDR',
|
||||
'HEVC',
|
||||
'H264',
|
||||
'H265',
|
||||
'FULLHD',
|
||||
};
|
||||
|
||||
static String forDisplay(String rawName) {
|
||||
if (rawName.isEmpty) return rawName;
|
||||
|
||||
final cached = _cache[rawName];
|
||||
if (cached != null) return cached;
|
||||
|
||||
var display = rawName.trim();
|
||||
|
||||
final pipeIndex = display.indexOf('|');
|
||||
if (pipeIndex >= 0 && pipeIndex < display.length - 1) {
|
||||
display = display.substring(pipeIndex + 1).trim();
|
||||
}
|
||||
|
||||
display = display.replaceFirst(RegExp(r'^[\-\–\—:]+'), '').trim();
|
||||
|
||||
if (display.isNotEmpty) {
|
||||
final parts = display.split(RegExp(r'\s+')).toList(growable: true);
|
||||
while (parts.isNotEmpty && _isQualityToken(parts.last)) {
|
||||
parts.removeLast();
|
||||
}
|
||||
display = parts.join(' ').trim();
|
||||
}
|
||||
|
||||
if (display.isEmpty) {
|
||||
display = rawName.trim();
|
||||
}
|
||||
|
||||
if (_cache.length > 50000) {
|
||||
_cache.clear();
|
||||
}
|
||||
_cache[rawName] = display;
|
||||
return display;
|
||||
}
|
||||
|
||||
static bool _isQualityToken(String token) {
|
||||
final normalized = token.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
||||
return _qualityTokens.contains(normalized);
|
||||
}
|
||||
}
|
||||
251
lib/widgets/countries_sidebar.dart
Normal file
251
lib/widgets/countries_sidebar.dart
Normal file
@@ -0,0 +1,251 @@
|
||||
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) {
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
268
lib/widgets/simple_countries_sidebar.dart
Normal file
268
lib/widgets/simple_countries_sidebar.dart
Normal file
@@ -0,0 +1,268 @@
|
||||
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) {
|
||||
if (countries.isNotEmpty) {
|
||||
for (int i = 0; i < countries.length && i < 10; i++) {}
|
||||
}
|
||||
|
||||
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 FocusableActionDetector(
|
||||
actions: <Type, Action<Intent>>{
|
||||
ActivateIntent: CallbackAction<ActivateIntent>(
|
||||
onInvoke: (intent) {
|
||||
onTap();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
ButtonActivateIntent: CallbackAction<ButtonActivateIntent>(
|
||||
onInvoke: (intent) {
|
||||
onTap();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final hasFocus = Focus.of(context).hasFocus;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Colors.red
|
||||
: (hasFocus
|
||||
? Colors.red.withValues(alpha: 0.5)
|
||||
: Colors.transparent),
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: (hasFocus ? 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 FocusableActionDetector(
|
||||
actions: <Type, Action<Intent>>{
|
||||
ActivateIntent: CallbackAction<ActivateIntent>(
|
||||
onInvoke: (intent) {
|
||||
onFootballSelected?.call();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
ButtonActivateIntent: CallbackAction<ButtonActivateIntent>(
|
||||
onInvoke: (intent) {
|
||||
onFootballSelected?.call();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final hasFocus = Focus.of(context).hasFocus;
|
||||
return GestureDetector(
|
||||
onTap: onFootballSelected,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Colors.green[700]
|
||||
: (hasFocus
|
||||
? Colors.green[700]?.withValues(alpha: 0.8)
|
||||
: Colors.green[900]?.withValues(alpha: 0.3)),
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: (hasFocus ? 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
|
||||
provider: ^6.1.2
|
||||
intl: ^0.19.0
|
||||
path_provider: ^2.1.5
|
||||
permission_handler: ^11.4.0
|
||||
path: ^1.9.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -1,30 +1,21 @@
|
||||
// This is a basic Flutter widget test.
|
||||
//
|
||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||
// tree, read text, and verify that the values of widget properties are correct.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:xstream_tv/main.dart';
|
||||
import 'package:xstream_tv/screens/login_screen.dart';
|
||||
import 'package:xstream_tv/services/iptv_provider.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
await tester.pumpWidget(const MyApp());
|
||||
testWidgets('renders login screen', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => IPTVProvider(),
|
||||
child: const MaterialApp(home: LoginScreen()),
|
||||
),
|
||||
);
|
||||
|
||||
// Verify that our counter starts at 0.
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
expect(find.text('1'), findsNothing);
|
||||
|
||||
// Tap the '+' icon and trigger a frame.
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
await tester.pump();
|
||||
|
||||
// Verify that our counter has incremented.
|
||||
expect(find.text('0'), findsNothing);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
expect(find.text('XStream TV'), findsOneWidget);
|
||||
expect(find.text('IPTV Player for Android TV'), findsOneWidget);
|
||||
expect(find.text('Login'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user