v2: TV optimized UI, country filter fix, landscape mode

This commit is contained in:
2026-02-25 15:43:24 -03:00
parent 120dd746d7
commit e281ba3922
2 changed files with 321 additions and 192 deletions

View File

@@ -1,10 +1,17 @@
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 'services/iptv_provider.dart'; import 'services/iptv_provider.dart';
import 'screens/login_screen.dart'; import 'screens/login_screen.dart';
import 'screens/home_screen.dart'; import 'screens/home_screen.dart';
void main() { void main() {
WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
runApp(const XStreamTVApp()); runApp(const XStreamTVApp());
} }

View File

@@ -13,6 +13,8 @@ class HomeScreen extends StatefulWidget {
} }
class _HomeScreenState extends State<HomeScreen> { class _HomeScreenState extends State<HomeScreen> {
int _focusedIndex = 0;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -21,6 +23,20 @@ class _HomeScreenState extends State<HomeScreen> {
}); });
} }
double get _screenWidth => MediaQuery.of(context).size.width;
bool get _isLargeScreen => _screenWidth > 900;
bool get _isMediumScreen => _screenWidth > 600 && _screenWidth <= 900;
int get _gridCrossAxisCount {
if (_screenWidth > 900) return 6;
if (_screenWidth > 600) return 4;
return 3;
}
double get _titleFontSize => _isLargeScreen ? 32 : (_isMediumScreen ? 28 : 24);
double get _iconSize => _isLargeScreen ? 80 : 60;
double get _headerPadding => _isLargeScreen ? 32 : 24;
Future<void> _loadInitialData() async { Future<void> _loadInitialData() async {
final provider = context.read<IPTVProvider>(); final provider = context.read<IPTVProvider>();
await provider.loadLiveStreams(); await provider.loadLiveStreams();
@@ -91,20 +107,22 @@ class _HomeScreenState extends State<HomeScreen> {
} }
Widget _buildHeader(String timeStr, String dateStr) { Widget _buildHeader(String timeStr, String dateStr) {
final double titleSize = _isLargeScreen ? 28.0 : 24.0;
final double iconSize = _isLargeScreen ? 40.0 : 32.0;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), padding: EdgeInsets.symmetric(horizontal: _headerPadding, vertical: _isLargeScreen ? 24 : 16),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Row( Row(
children: [ children: [
Icon(Icons.live_tv, color: Colors.red, size: 32), Icon(Icons.live_tv, color: Colors.red, size: iconSize),
SizedBox(width: 12), const SizedBox(width: 12),
Text( Text(
'XSTREAM TV', 'XSTREAM TV',
style: TextStyle( style: TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 24, fontSize: titleSize,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
letterSpacing: 2, letterSpacing: 2,
), ),
@@ -113,14 +131,14 @@ class _HomeScreenState extends State<HomeScreen> {
), ),
Row( Row(
children: [ children: [
Text(timeStr, style: const TextStyle(color: Colors.white70, fontSize: 16)), Text(timeStr, style: TextStyle(color: Colors.white70, fontSize: _isLargeScreen ? 20 : 16)),
const SizedBox(width: 16), const SizedBox(width: 16),
Text(dateStr, style: const TextStyle(color: Colors.white54, fontSize: 14)), Text(dateStr, style: TextStyle(color: Colors.white54, fontSize: _isLargeScreen ? 16 : 14)),
const SizedBox(width: 24), const SizedBox(width: 24),
const Icon(Icons.person, color: Colors.white70, size: 24), Icon(Icons.person, color: Colors.white70, size: _isLargeScreen ? 32 : 24),
const SizedBox(width: 16), const SizedBox(width: 16),
IconButton( IconButton(
icon: const Icon(Icons.settings, color: Colors.white70), icon: Icon(Icons.settings, color: Colors.white70, size: _isLargeScreen ? 32 : 24),
onPressed: () { onPressed: () {
context.read<IPTVProvider>().logout(); context.read<IPTVProvider>().logout();
}, },
@@ -133,50 +151,63 @@ class _HomeScreenState extends State<HomeScreen> {
} }
Widget _buildDashboard() { Widget _buildDashboard() {
final double cardSpacing = _isLargeScreen ? 24.0 : 16.0;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24), padding: EdgeInsets.symmetric(horizontal: _headerPadding),
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
flex: 2, flex: 2,
child: _DashboardCard( child: Focus(
title: 'LIVE TV', autofocus: _focusedIndex == 0,
icon: Icons.tv, child: _DashboardCard(
gradient: const LinearGradient( title: 'LIVE TV',
colors: [Color(0xFF00c853), Color(0xFF2979ff)], icon: Icons.tv,
begin: Alignment.topLeft, isLarge: _isLargeScreen,
end: Alignment.bottomRight, gradient: const LinearGradient(
colors: [Color(0xFF00c853), Color(0xFF2979ff)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
onTap: _showLiveCategories,
), ),
onTap: _showLiveCategories,
), ),
), ),
const SizedBox(width: 16), SizedBox(width: cardSpacing),
Expanded( Expanded(
child: Column( child: Column(
children: [ children: [
Expanded( Expanded(
child: _DashboardCard( child: Focus(
title: 'MOVIES', autofocus: _focusedIndex == 1,
icon: Icons.play_circle_fill, child: _DashboardCard(
gradient: const LinearGradient( title: 'MOVIES',
colors: [Color(0xFFff5252), Color(0xFFff9800)], icon: Icons.play_circle_fill,
begin: Alignment.topLeft, isLarge: _isLargeScreen,
end: Alignment.bottomRight, gradient: const LinearGradient(
colors: [Color(0xFFff5252), Color(0xFFff9800)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
onTap: _showMovies,
), ),
onTap: _showMovies,
), ),
), ),
const SizedBox(height: 16), SizedBox(height: cardSpacing),
Expanded( Expanded(
child: _DashboardCard( child: Focus(
title: 'SERIES', autofocus: _focusedIndex == 2,
icon: Icons.movie, child: _DashboardCard(
gradient: const LinearGradient( title: 'SERIES',
colors: [Color(0xFF9c27b0), Color(0xFF03a9f4)], icon: Icons.movie,
begin: Alignment.topLeft, isLarge: _isLargeScreen,
end: Alignment.bottomRight, gradient: const LinearGradient(
colors: [Color(0xFF9c27b0), Color(0xFF03a9f4)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
onTap: _showSeries,
), ),
onTap: _showSeries,
), ),
), ),
], ],
@@ -188,27 +219,29 @@ class _HomeScreenState extends State<HomeScreen> {
} }
Widget _buildFooter() { Widget _buildFooter() {
final double footerPadding = _isLargeScreen ? 32.0 : 24.0;
final double fontSize = _isLargeScreen ? 16.0 : 12.0;
return Consumer<IPTVProvider>( return Consumer<IPTVProvider>(
builder: (context, provider, _) { builder: (context, provider, _) {
final expDate = provider.userInfo?.expDate; final expDate = provider.userInfo?.expDate;
final username = provider.userInfo?.username ?? 'Usuario'; final username = provider.userInfo?.username ?? 'Usuario';
return Padding( return Padding(
padding: const EdgeInsets.all(24), padding: EdgeInsets.all(footerPadding),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Text(
'Expiración: ${_formatExpiry(expDate)}', 'Expiración: ${_formatExpiry(expDate)}',
style: const TextStyle(color: Colors.white38, fontSize: 12), style: TextStyle(color: Colors.white38, fontSize: fontSize),
), ),
const Text( Text(
'Términos de Servicio', 'Términos de Servicio',
style: TextStyle(color: Colors.white38, fontSize: 12), style: TextStyle(color: Colors.white38, fontSize: fontSize),
), ),
Text( Text(
'Usuario: $username', 'Usuario: $username',
style: const TextStyle(color: Colors.white38, fontSize: 12), style: TextStyle(color: Colors.white38, fontSize: fontSize),
), ),
], ],
), ),
@@ -223,61 +256,77 @@ class _DashboardCard extends StatelessWidget {
final IconData icon; final IconData icon;
final Gradient gradient; final Gradient gradient;
final VoidCallback onTap; final VoidCallback onTap;
final bool isLarge;
const _DashboardCard({ const _DashboardCard({
required this.title, required this.title,
required this.icon, required this.icon,
required this.gradient, required this.gradient,
required this.onTap, required this.onTap,
this.isLarge = false,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( final iconSize = isLarge ? 80.0 : 60.0;
onTap: onTap, final titleSize = isLarge ? 32.0 : 24.0;
child: Container( final bgIconSize = isLarge ? 200.0 : 150.0;
decoration: BoxDecoration( return Focus(
gradient: gradient, child: Builder(
borderRadius: BorderRadius.circular(20), builder: (context) {
boxShadow: [ final hasFocus = Focus.of(context).hasFocus;
BoxShadow( return GestureDetector(
color: Colors.black.withValues(alpha: 0.3), onTap: onTap,
blurRadius: 15, child: AnimatedContainer(
offset: const Offset(0, 8), duration: const Duration(milliseconds: 200),
), decoration: BoxDecoration(
], gradient: gradient,
), borderRadius: BorderRadius.circular(20),
child: Stack( boxShadow: [
children: [ BoxShadow(
Positioned( color: Colors.black.withValues(alpha: hasFocus ? 0.5 : 0.3),
right: -20, blurRadius: hasFocus ? 25 : 15,
bottom: -20, offset: const Offset(0, 8),
child: Icon( ),
icon, ],
size: 150, border: hasFocus
color: Colors.white.withValues(alpha: 0.1), ? Border.all(color: Colors.white.withValues(alpha: 0.5), width: 3)
: null,
), ),
), child: Stack(
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(icon, size: 60, color: Colors.white), Positioned(
const SizedBox(height: 12), right: -40,
Text( bottom: -40,
title, child: Icon(
style: const TextStyle( icon,
color: Colors.white, size: bgIconSize,
fontSize: 24, color: Colors.white.withValues(alpha: 0.1),
fontWeight: FontWeight.bold, ),
letterSpacing: 2, ),
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: iconSize, color: Colors.white),
const SizedBox(height: 16),
Text(
title,
style: TextStyle(
color: Colors.white,
fontSize: titleSize,
fontWeight: FontWeight.bold,
letterSpacing: 2,
),
),
],
), ),
), ),
], ],
), ),
), ),
], );
), },
), ),
); );
} }
@@ -298,6 +347,7 @@ class _ContentListScreenState extends State<ContentListScreen> {
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
String _searchQuery = ''; String _searchQuery = '';
String? _selectedCountry; String? _selectedCountry;
final FocusNode _gridFocusNode = FocusNode();
@override @override
void initState() { void initState() {
@@ -305,6 +355,20 @@ class _ContentListScreenState extends State<ContentListScreen> {
_loadContent(); _loadContent();
} }
double get _screenWidth => MediaQuery.of(context).size.width;
bool get _isLargeScreen => _screenWidth > 900;
bool get _isMediumScreen => _screenWidth > 600 && _screenWidth <= 900;
int get _gridCrossAxisCount {
if (_screenWidth > 900) return 6;
if (_screenWidth > 600) return 4;
return 3;
}
double get _titleFontSize => _isLargeScreen ? 32 : (_isMediumScreen ? 28 : 24);
double get _cardTextSize => _isLargeScreen ? 16 : 12;
double get _headerPadding => _isLargeScreen ? 32 : 16;
void _loadContent() { void _loadContent() {
final provider = context.read<IPTVProvider>(); final provider = context.read<IPTVProvider>();
if (widget.type == ContentType.live) { if (widget.type == ContentType.live) {
@@ -319,6 +383,7 @@ class _ContentListScreenState extends State<ContentListScreen> {
@override @override
void dispose() { void dispose() {
_searchController.dispose(); _searchController.dispose();
_gridFocusNode.dispose();
super.dispose(); super.dispose();
} }
@@ -362,37 +427,42 @@ class _ContentListScreenState extends State<ContentListScreen> {
} }
Widget _buildHeader() { Widget _buildHeader() {
final searchWidth = _isLargeScreen ? 350.0 : (_isMediumScreen ? 300.0 : 250.0);
final searchHeight = _isLargeScreen ? 56.0 : 44.0;
final iconSize = _isLargeScreen ? 32.0 : 24.0;
return Container( return Container(
padding: const EdgeInsets.all(16), padding: EdgeInsets.all(_headerPadding),
child: Row( child: Row(
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white), icon: Icon(Icons.arrow_back, color: Colors.white, size: iconSize),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
iconSize: 48,
padding: EdgeInsets.all(_isLargeScreen ? 12 : 8),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
_title, _title,
style: const TextStyle( style: TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 24, fontSize: _titleFontSize,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
const Spacer(), const Spacer(),
SizedBox( SizedBox(
width: 250, width: searchWidth,
height: 44, height: searchHeight,
child: TextField( child: TextField(
controller: _searchController, controller: _searchController,
style: const TextStyle(color: Colors.white), style: TextStyle(color: Colors.white, fontSize: _isLargeScreen ? 18 : 14),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Buscar...', hintText: 'Buscar...',
hintStyle: const TextStyle(color: Colors.grey), hintStyle: TextStyle(color: Colors.grey, fontSize: _isLargeScreen ? 18 : 14),
prefixIcon: const Icon(Icons.search, color: Colors.grey), prefixIcon: Icon(Icons.search, color: Colors.grey, size: _isLargeScreen ? 28 : 20),
suffixIcon: _searchQuery.isNotEmpty suffixIcon: _searchQuery.isNotEmpty
? IconButton( ? IconButton(
icon: const Icon(Icons.clear, color: Colors.grey), icon: Icon(Icons.clear, color: Colors.grey, size: _isLargeScreen ? 28 : 20),
onPressed: () { onPressed: () {
_searchController.clear(); _searchController.clear();
setState(() => _searchQuery = ''); setState(() => _searchQuery = '');
@@ -405,7 +475,7 @@ class _ContentListScreenState extends State<ContentListScreen> {
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none, borderSide: BorderSide.none,
), ),
contentPadding: const EdgeInsets.symmetric(horizontal: 16), contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: _isLargeScreen ? 16 : 12),
), ),
onChanged: (value) { onChanged: (value) {
setState(() => _searchQuery = value); setState(() => _searchQuery = value);
@@ -417,6 +487,13 @@ class _ContentListScreenState extends State<ContentListScreen> {
); );
} }
String _getCountryName(String categoryName) {
if (categoryName.contains('|')) {
return categoryName.split('|').first.trim();
}
return categoryName.trim();
}
Widget _buildCountryFilter() { Widget _buildCountryFilter() {
if (widget.type != ContentType.live) { if (widget.type != ContentType.live) {
return const SizedBox.shrink(); return const SizedBox.shrink();
@@ -427,24 +504,27 @@ class _ContentListScreenState extends State<ContentListScreen> {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final countries = categories.map((c) => c.name).toList(); final countries = categories.map((c) => _getCountryName(c.name)).toList();
final chipHeight = _isLargeScreen ? 56.0 : 50.0;
final chipFontSize = _isLargeScreen ? 16.0 : 14.0;
return Container( return Container(
height: 50, height: chipHeight,
margin: const EdgeInsets.only(bottom: 8), margin: EdgeInsets.only(bottom: _isLargeScreen ? 16 : 8),
child: ListView.builder( child: ListView.builder(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16), padding: EdgeInsets.symmetric(horizontal: _headerPadding),
itemCount: countries.length + 1, itemCount: countries.length + 1,
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index == 0) { if (index == 0) {
return Padding( return Padding(
padding: const EdgeInsets.only(right: 8), padding: EdgeInsets.only(right: _isLargeScreen ? 12 : 8),
child: FilterChip( child: FilterChip(
label: const Text('Todos', style: TextStyle(color: Colors.white)), label: Text('Todos', style: TextStyle(color: Colors.white, fontSize: chipFontSize)),
selected: _selectedCountry == null, selected: _selectedCountry == null,
selectedColor: Colors.red, selectedColor: Colors.red,
backgroundColor: Colors.grey[800], backgroundColor: Colors.grey[800],
padding: EdgeInsets.symmetric(horizontal: _isLargeScreen ? 12 : 8),
onSelected: (_) { onSelected: (_) {
setState(() => _selectedCountry = null); setState(() => _selectedCountry = null);
context.read<IPTVProvider>().loadLiveStreams(''); context.read<IPTVProvider>().loadLiveStreams('');
@@ -453,20 +533,22 @@ class _ContentListScreenState extends State<ContentListScreen> {
); );
} }
final country = countries[index - 1]; final countryName = countries[index - 1];
final category = categories[index - 1];
return Padding( return Padding(
padding: const EdgeInsets.only(right: 8), padding: EdgeInsets.only(right: _isLargeScreen ? 12 : 8),
child: FilterChip( child: FilterChip(
label: Text( label: Text(
country.length > 20 ? '${country.substring(0, 20)}...' : country, countryName.length > 20 ? '${countryName.substring(0, 20)}...' : countryName,
style: const TextStyle(color: Colors.white), style: TextStyle(color: Colors.white, fontSize: chipFontSize),
), ),
selected: _selectedCountry == categories[index - 1].id, selected: _selectedCountry == category.id,
selectedColor: Colors.red, selectedColor: Colors.red,
backgroundColor: Colors.grey[800], backgroundColor: Colors.grey[800],
padding: EdgeInsets.symmetric(horizontal: _isLargeScreen ? 12 : 8),
onSelected: (_) { onSelected: (_) {
setState(() => _selectedCountry = categories[index - 1].id); setState(() => _selectedCountry = category.id);
context.read<IPTVProvider>().loadLiveStreams(categories[index - 1].id); context.read<IPTVProvider>().loadLiveStreams(category.id);
}, },
), ),
); );
@@ -476,10 +558,12 @@ class _ContentListScreenState extends State<ContentListScreen> {
} }
Widget _buildContentList() { Widget _buildContentList() {
final padding = _isLargeScreen ? 24.0 : 16.0;
final spacing = _isLargeScreen ? 16.0 : 12.0;
return Consumer<IPTVProvider>( return Consumer<IPTVProvider>(
builder: (context, provider, _) { builder: (context, provider, _) {
if (provider.isLoading) { if (provider.isLoading) {
return const Center(child: CircularProgressIndicator(color: Colors.red)); return Center(child: CircularProgressIndicator(color: Colors.red, strokeWidth: _isLargeScreen ? 4 : 2));
} }
List<XtreamStream> streams = []; List<XtreamStream> streams = [];
@@ -507,18 +591,18 @@ class _ContentListScreenState extends State<ContentListScreen> {
return Center( return Center(
child: Text( child: Text(
_searchQuery.isNotEmpty ? 'No se encontraron resultados' : 'Sin contenido', _searchQuery.isNotEmpty ? 'No se encontraron resultados' : 'Sin contenido',
style: const TextStyle(color: Colors.grey, fontSize: 16), style: TextStyle(color: Colors.grey, fontSize: _isLargeScreen ? 20 : 16),
), ),
); );
} }
return GridView.builder( return GridView.builder(
padding: const EdgeInsets.all(16), padding: EdgeInsets.all(padding),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4, crossAxisCount: _gridCrossAxisCount,
childAspectRatio: 16 / 9, childAspectRatio: 16 / 9,
crossAxisSpacing: 12, crossAxisSpacing: spacing,
mainAxisSpacing: 12, mainAxisSpacing: spacing,
), ),
itemCount: streams.length, itemCount: streams.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
@@ -526,6 +610,7 @@ class _ContentListScreenState extends State<ContentListScreen> {
return _ChannelCard( return _ChannelCard(
stream: stream, stream: stream,
isSeries: widget.type == ContentType.series, isSeries: widget.type == ContentType.series,
isLarge: _isLargeScreen,
onTap: () { onTap: () {
if (widget.type == ContentType.series) { if (widget.type == ContentType.series) {
final series = provider.seriesList.firstWhere( final series = provider.seriesList.firstWhere(
@@ -561,100 +646,128 @@ class _ChannelCard extends StatelessWidget {
final XtreamStream stream; final XtreamStream stream;
final bool isSeries; final bool isSeries;
final VoidCallback onTap; final VoidCallback onTap;
final bool isLarge;
const _ChannelCard({ const _ChannelCard({
required this.stream, required this.stream,
required this.isSeries, required this.isSeries,
required this.onTap, required this.onTap,
this.isLarge = false,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( final textSize = isLarge ? 16.0 : 12.0;
onTap: onTap, final ratingFontSize = isLarge ? 14.0 : 10.0;
child: Container( final placeholderIconSize = isLarge ? 56.0 : 40.0;
decoration: BoxDecoration( final padding = isLarge ? 12.0 : 8.0;
color: Colors.grey[900], final ratingPaddingH = isLarge ? 10.0 : 6.0;
borderRadius: BorderRadius.circular(12), final ratingPaddingV = isLarge ? 4.0 : 2.0;
border: Border.all(color: Colors.red.withValues(alpha: 0.3)), return Focus(
), child: Builder(
child: Stack( builder: (context) {
children: [ final hasFocus = Focus.of(context).hasFocus;
if (stream.streamIcon != null && stream.streamIcon!.isNotEmpty) return GestureDetector(
ClipRRect( onTap: onTap,
borderRadius: BorderRadius.circular(12), child: AnimatedContainer(
child: Image.network( duration: const Duration(milliseconds: 200),
stream.streamIcon!,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _buildPlaceholder(),
),
)
else
_buildPlaceholder(),
Container(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12), color: Colors.grey[900],
gradient: LinearGradient( borderRadius: BorderRadius.circular(16),
begin: Alignment.topCenter, border: Border.all(
end: Alignment.bottomCenter, color: hasFocus ? Colors.red : Colors.red.withValues(alpha: 0.3),
colors: [ width: hasFocus ? 3 : 1,
Colors.transparent,
Colors.black.withValues(alpha: 0.8),
],
), ),
boxShadow: hasFocus
? [
BoxShadow(
color: Colors.red.withValues(alpha: 0.3),
blurRadius: 15,
spreadRadius: 2,
),
]
: null,
), ),
), child: Stack(
Positioned( children: [
bottom: 8, if (stream.streamIcon != null && stream.streamIcon!.isNotEmpty)
left: 8, ClipRRect(
right: 8, borderRadius: BorderRadius.circular(16),
child: Text( child: Image.network(
stream.name, stream.streamIcon!,
style: const TextStyle( width: double.infinity,
color: Colors.white, height: double.infinity,
fontSize: 12, fit: BoxFit.cover,
fontWeight: FontWeight.w500, errorBuilder: (_, __, ___) => _buildPlaceholder(placeholderIconSize),
), ),
maxLines: 2, )
overflow: TextOverflow.ellipsis, else
), _buildPlaceholder(placeholderIconSize),
), Container(
if (stream.rating != null) decoration: BoxDecoration(
Positioned( borderRadius: BorderRadius.circular(16),
top: 8, gradient: LinearGradient(
right: 8, begin: Alignment.topCenter,
child: Container( end: Alignment.bottomCenter,
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), colors: [
decoration: BoxDecoration( Colors.transparent,
color: Colors.amber, Colors.black.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(4), ],
), ),
child: Text(
stream.rating!,
style: const TextStyle(
color: Colors.black,
fontSize: 10,
fontWeight: FontWeight.bold,
), ),
), ),
), Positioned(
bottom: padding,
left: padding,
right: padding,
child: Text(
stream.name,
style: TextStyle(
color: Colors.white,
fontSize: textSize,
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
if (stream.rating != null)
Positioned(
top: padding,
right: padding,
child: Container(
padding: EdgeInsets.symmetric(horizontal: ratingPaddingH, vertical: ratingPaddingV),
decoration: BoxDecoration(
color: Colors.amber,
borderRadius: BorderRadius.circular(6),
),
child: Text(
stream.rating!,
style: TextStyle(
color: Colors.black,
fontSize: ratingFontSize,
fontWeight: FontWeight.bold,
),
),
),
),
],
), ),
], ),
), );
},
), ),
); );
} }
Widget _buildPlaceholder() { Widget _buildPlaceholder(double iconSize) {
return Container( return Container(
color: Colors.grey[800], color: Colors.grey[800],
child: Center( child: Center(
child: Icon( child: Icon(
isSeries ? Icons.tv : Icons.play_circle_outline, isSeries ? Icons.tv : Icons.play_circle_outline,
color: Colors.red, color: Colors.red,
size: 40, size: iconSize,
), ),
), ),
); );
@@ -679,28 +792,36 @@ class _SeriesEpisodesScreenState extends State<SeriesEpisodesScreen> {
}); });
} }
double get _screenWidth => MediaQuery.of(context).size.width;
bool get _isLargeScreen => _screenWidth > 900;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final double fontSize = _isLargeScreen ? 24.0 : 20.0;
final double iconSize = _isLargeScreen ? 56.0 : 40.0;
final double padding = _isLargeScreen ? 24.0 : 16.0;
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFF0f0f1a), backgroundColor: const Color(0xFF0f0f1a),
body: SafeArea( body: SafeArea(
child: Column( child: Column(
children: [ children: [
Container( Container(
padding: const EdgeInsets.all(16), padding: EdgeInsets.all(padding),
child: Row( child: Row(
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white), icon: Icon(Icons.arrow_back, color: Colors.white, size: iconSize),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
iconSize: 48,
padding: EdgeInsets.all(_isLargeScreen ? 12 : 8),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
widget.series.name, widget.series.name,
style: const TextStyle( style: TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 20, fontSize: fontSize,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
maxLines: 1, maxLines: 1,
@@ -714,38 +835,39 @@ class _SeriesEpisodesScreenState extends State<SeriesEpisodesScreen> {
child: Consumer<IPTVProvider>( child: Consumer<IPTVProvider>(
builder: (context, provider, _) { builder: (context, provider, _) {
if (provider.isLoading) { if (provider.isLoading) {
return const Center( return Center(
child: CircularProgressIndicator(color: Colors.red), child: CircularProgressIndicator(color: Colors.red, strokeWidth: _isLargeScreen ? 4 : 2),
); );
} }
final episodes = provider.seriesEpisodes; final episodes = provider.seriesEpisodes;
if (episodes.isEmpty) { if (episodes.isEmpty) {
return const Center( return Center(
child: Text( child: Text(
'No hay episodios', 'No hay episodios',
style: TextStyle(color: Colors.grey), style: TextStyle(color: Colors.grey, fontSize: _isLargeScreen ? 20 : 16),
), ),
); );
} }
return ListView.builder( return ListView.builder(
padding: const EdgeInsets.all(16), padding: EdgeInsets.all(padding),
itemCount: episodes.length, itemCount: episodes.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final episode = episodes[index]; final episode = episodes[index];
return Card( return Card(
color: Colors.grey[900], color: Colors.grey[900],
margin: const EdgeInsets.only(bottom: 8), margin: EdgeInsets.only(bottom: _isLargeScreen ? 16 : 8),
child: ListTile( child: ListTile(
leading: const Icon( contentPadding: EdgeInsets.all(_isLargeScreen ? 16 : 12),
leading: Icon(
Icons.play_circle_fill, Icons.play_circle_fill,
color: Colors.red, color: Colors.red,
size: 40, size: iconSize,
), ),
title: Text( title: Text(
'S${episode.seasonNumber}E${episode.episodeNumber} - ${episode.title}', 'S${episode.seasonNumber}E${episode.episodeNumber} - ${episode.title}',
style: const TextStyle(color: Colors.white), style: TextStyle(color: Colors.white, fontSize: _isLargeScreen ? 20 : 16),
), ),
onTap: () { onTap: () {
Navigator.push( Navigator.push(