mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-14 10:47:31 +02:00
feat: Introduce core IPTV features, authentication screen, web player, and application theming.
This commit is contained in:
1 parent
c90ea530ea
commit
e699d3ec31
8 files changed
+931
-1379
No files matched your search
@@ -45,241 +45,210 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
final authState = ref.watch(authProvider);
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Color(0xFF0F1923), // Deep dark blue
|
||||
AppColors.background,
|
||||
Color(0xFF0A1628), // Navy accent
|
||||
],
|
||||
stops: [0.0, 0.5, 1.0],
|
||||
backgroundColor: AppColors.background,
|
||||
body: Stack(
|
||||
children: [
|
||||
// Ambient background glow (Subtle)
|
||||
Positioned(
|
||||
top: -100,
|
||||
left: -100,
|
||||
child: Container(
|
||||
width: 500,
|
||||
height: 500,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
AppColors.primary.withOpacity(0.15),
|
||||
Colors.transparent,
|
||||
],
|
||||
radius: 0.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Glow effect top
|
||||
Positioned(
|
||||
top: -100,
|
||||
left: -100,
|
||||
|
||||
// Main content
|
||||
Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Container(
|
||||
width: 300,
|
||||
height: 300,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
AppColors.primary.withOpacity(0.1),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Glow effect bottom
|
||||
Positioned(
|
||||
bottom: -150,
|
||||
right: -150,
|
||||
child: Container(
|
||||
width: 400,
|
||||
height: 400,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
AppColors.secondary.withOpacity(0.08),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Main content
|
||||
Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Logo and title
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primary.withOpacity(0.4),
|
||||
blurRadius: 30,
|
||||
offset: const Offset(0, 10),
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Logo and title
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primary.withOpacity(0.2),
|
||||
blurRadius: 30,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.live_tv,
|
||||
color: AppColors.primary,
|
||||
size: 40,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'XtremFlow',
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Cinematic Experience',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
letterSpacing: 2,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
// Login form
|
||||
GlassCard(
|
||||
padding: const EdgeInsets.all(32),
|
||||
borderRadius: 16,
|
||||
showBorder: true,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Welcome Back',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
TextFormField(
|
||||
controller: _usernameController,
|
||||
style: const TextStyle(color: AppColors.textPrimary),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Username',
|
||||
prefixIcon: const Icon(Icons.person_outline),
|
||||
fillColor: AppColors.surface,
|
||||
hoverColor: AppColors.surfaceLight,
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Username required';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
style: const TextStyle(color: AppColors.textPrimary),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Password',
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
fillColor: AppColors.surface,
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Password required';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => _handleLogin(),
|
||||
),
|
||||
if (authState.errorMessage != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: AppColors.error.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: AppColors.error,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
authState.errorMessage!,
|
||||
style: const TextStyle(
|
||||
color: AppColors.error,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 32),
|
||||
GradientButton(
|
||||
label: 'Sign In',
|
||||
icon: Icons.arrow_forward,
|
||||
isLoading: authState.isLoading,
|
||||
isExpanded: true,
|
||||
onPressed: _handleLogin,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.live_tv,
|
||||
color: Colors.black,
|
||||
size: 40,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ShaderMask(
|
||||
shaderCallback: (bounds) =>
|
||||
AppColors.primaryGradient.createShader(bounds),
|
||||
child: const Text(
|
||||
'XtremFlow',
|
||||
style: TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'Default: admin / admin',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textTertiary,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Streaming IPTV Premium',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
// Login form
|
||||
GlassCard(
|
||||
padding: const EdgeInsets.all(24),
|
||||
borderRadius: AppTheme.radiusLg,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Connexion',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
TextFormField(
|
||||
controller: _usernameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Nom d\'utilisateur',
|
||||
prefixIcon: Icon(
|
||||
Icons.person_outline,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Nom d\'utilisateur requis';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Mot de passe',
|
||||
prefixIcon: Icon(
|
||||
Icons.lock_outline,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Mot de passe requis';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => _handleLogin(),
|
||||
),
|
||||
if (authState.errorMessage != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: AppColors.error.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: AppColors.error,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
authState.errorMessage!,
|
||||
style: TextStyle(
|
||||
color: AppColors.error,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
GradientButton(
|
||||
label: 'Se connecter',
|
||||
icon: Icons.login,
|
||||
isLoading: authState.isLoading,
|
||||
isExpanded: true,
|
||||
onPressed: _handleLogin,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Identifiants par défaut: admin / admin',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textDisabled,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Manages the list of favorite channel IDs
|
||||
class FavoritesNotifier extends StateNotifier<List<String>> {
|
||||
FavoritesNotifier() : super([]) {
|
||||
_loadFavorites();
|
||||
}
|
||||
|
||||
static const String _key = 'xtremflow_favorites';
|
||||
|
||||
Future<void> _loadFavorites() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final favorites = prefs.getStringList(_key) ?? [];
|
||||
state = favorites;
|
||||
}
|
||||
|
||||
Future<void> toggleFavorite(String streamId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (state.contains(streamId)) {
|
||||
state = state.where((id) => id != streamId).toList();
|
||||
} else {
|
||||
state = [...state, streamId];
|
||||
}
|
||||
await prefs.setStringList(_key, state);
|
||||
}
|
||||
|
||||
bool isFavorite(String streamId) {
|
||||
return state.contains(streamId);
|
||||
}
|
||||
}
|
||||
|
||||
final favoritesProvider = StateNotifierProvider<FavoritesNotifier, List<String>>((ref) {
|
||||
return FavoritesNotifier();
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../providers/favorites_provider.dart';
|
||||
|
||||
/// Premium channel card with Netflix-style design
|
||||
///
|
||||
@@ -9,8 +11,10 @@ import '../../../core/theme/app_colors.dart';
|
||||
/// - Thumbnail/logo with fallback
|
||||
/// - Live badge with pulse animation
|
||||
/// - EPG overlay
|
||||
/// - Favorite toggle
|
||||
/// - Hover/tap effects
|
||||
class ChannelCard extends StatefulWidget {
|
||||
class ChannelCard extends ConsumerStatefulWidget {
|
||||
final String streamId;
|
||||
final String name;
|
||||
final String? iconUrl;
|
||||
final String? currentProgram;
|
||||
@@ -21,6 +25,7 @@ class ChannelCard extends StatefulWidget {
|
||||
|
||||
const ChannelCard({
|
||||
super.key,
|
||||
required this.streamId,
|
||||
required this.name,
|
||||
this.iconUrl,
|
||||
this.currentProgram,
|
||||
@@ -31,10 +36,10 @@ class ChannelCard extends StatefulWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChannelCard> createState() => _ChannelCardState();
|
||||
ConsumerState<ChannelCard> createState() => _ChannelCardState();
|
||||
}
|
||||
|
||||
class _ChannelCardState extends State<ChannelCard>
|
||||
class _ChannelCardState extends ConsumerState<ChannelCard>
|
||||
with SingleTickerProviderStateMixin {
|
||||
bool _isHovered = false;
|
||||
late AnimationController _pulseController;
|
||||
@@ -61,9 +66,9 @@ class _ChannelCardState extends State<ChannelCard>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
|
||||
final favorites = ref.watch(favoritesProvider);
|
||||
final isFavorite = favorites.contains(widget.streamId);
|
||||
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovered = true),
|
||||
onExit: (_) => setState(() => _isHovered = false),
|
||||
@@ -77,49 +82,31 @@ class _ChannelCardState extends State<ChannelCard>
|
||||
transform: Matrix4.identity()..scale(_isHovered ? 1.05 : 1.0),
|
||||
transformAlignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: isDark
|
||||
? [
|
||||
AppColors.surfaceDark.withOpacity(0.95),
|
||||
AppColors.surfaceVariantDark.withOpacity(0.9),
|
||||
]
|
||||
: [
|
||||
Colors.white.withOpacity(0.95),
|
||||
AppColors.surfaceVariantLight.withOpacity(0.9),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: AppColors.surface, // Clean flat dark surface
|
||||
border: Border.all(
|
||||
color: _isHovered
|
||||
? AppColors.primary.withOpacity(0.5)
|
||||
: (isDark ? AppColors.borderDark : AppColors.borderLight),
|
||||
width: _isHovered ? 1.5 : 1,
|
||||
? AppColors.primary
|
||||
: AppColors.border,
|
||||
width: _isHovered ? 2 : 1,
|
||||
),
|
||||
boxShadow: _isHovered
|
||||
? [
|
||||
BoxShadow(
|
||||
color: AppColors.primary.withOpacity(0.2),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
]
|
||||
: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(isDark ? 0.3 : 0.1),
|
||||
blurRadius: 8,
|
||||
color: AppColors.primary.withOpacity(0.3),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
]
|
||||
: [],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
borderRadius: BorderRadius.circular(7), // Inner radius
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Channel logo/thumbnail
|
||||
_buildChannelImage(isDark),
|
||||
_buildChannelImage(),
|
||||
|
||||
// Gradient overlay for text readability
|
||||
Positioned.fill(
|
||||
@@ -129,10 +116,10 @@ class _ChannelCardState extends State<ChannelCard>
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withOpacity(0.7),
|
||||
Colors.black.withOpacity(0.2),
|
||||
Colors.black.withOpacity(0.9),
|
||||
],
|
||||
stops: const [0.4, 1.0],
|
||||
stops: const [0.0, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -145,8 +132,24 @@ class _ChannelCardState extends State<ChannelCard>
|
||||
right: 8,
|
||||
child: _buildLiveBadge(),
|
||||
),
|
||||
|
||||
// Favorite Icon
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||
size: 20,
|
||||
color: isFavorite ? AppColors.primary : Colors.white70,
|
||||
),
|
||||
onPressed: () {
|
||||
ref.read(favoritesProvider.notifier).toggleFavorite(widget.streamId);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Channel info overlay
|
||||
// Channel info
|
||||
Positioned(
|
||||
left: 10,
|
||||
right: 10,
|
||||
@@ -159,26 +162,20 @@ class _ChannelCardState extends State<ChannelCard>
|
||||
widget.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black54,
|
||||
blurRadius: 4,
|
||||
),
|
||||
],
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (widget.currentProgram != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
if (widget.currentProgram != null && widget.currentProgram!.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.currentProgram!,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
color: Colors.white.withOpacity(0.7),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -195,26 +192,26 @@ class _ChannelCardState extends State<ChannelCard>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChannelImage(bool isDark) {
|
||||
Widget _buildChannelImage() {
|
||||
if (widget.iconUrl != null && widget.iconUrl!.isNotEmpty) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: widget.iconUrl!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => _buildPlaceholder(isDark),
|
||||
errorWidget: (context, url, error) => _buildPlaceholder(isDark),
|
||||
fit: BoxFit.contain, // Contain usually looks better for channel logos
|
||||
placeholder: (context, url) => _buildPlaceholder(),
|
||||
errorWidget: (context, url, error) => _buildPlaceholder(),
|
||||
);
|
||||
}
|
||||
return _buildPlaceholder(isDark);
|
||||
return _buildPlaceholder();
|
||||
}
|
||||
|
||||
Widget _buildPlaceholder(bool isDark) {
|
||||
Widget _buildPlaceholder() {
|
||||
return Container(
|
||||
color: isDark ? AppColors.surfaceVariantDark : AppColors.surfaceVariantLight,
|
||||
color: AppColors.surfaceVariant,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.live_tv_rounded,
|
||||
size: 36,
|
||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
|
||||
Icons.tv,
|
||||
size: 32,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -225,40 +222,21 @@ class _ChannelCardState extends State<ChannelCard>
|
||||
animation: _pulseAnimation,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.live.withOpacity(_pulseAnimation.value),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.live.withOpacity(0.4 * _pulseAnimation.value),
|
||||
blurRadius: 8,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.circle,
|
||||
size: 6,
|
||||
color: Colors.white,
|
||||
),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
'LIVE',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: const Text(
|
||||
'LIVE',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../providers/xtream_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../providers/favorites_provider.dart';
|
||||
import '../screens/player_screen.dart';
|
||||
import '../../../core/models/iptv_models.dart';
|
||||
import '../../../core/models/playlist_config.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/utils/responsive_layout.dart';
|
||||
import 'epg_widget.dart';
|
||||
import 'channel_card.dart';
|
||||
|
||||
/// Live TV tab with category box grid navigation
|
||||
/// Live TV tab - Enhanced with Search, Favorites and Grid/List View
|
||||
class LiveTVTab extends ConsumerStatefulWidget {
|
||||
final PlaylistConfig playlist;
|
||||
|
||||
@@ -23,9 +23,34 @@ class LiveTVTab extends ConsumerStatefulWidget {
|
||||
|
||||
class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
|
||||
// State
|
||||
String? _selectedCategory;
|
||||
int _currentPage = 0;
|
||||
static const int _itemsPerPage = 50;
|
||||
static const int _itemsPerPage = 100; // Increased for grid view
|
||||
|
||||
// UI State
|
||||
bool _isGridView = true;
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
bool _showFavoritesOnly = false;
|
||||
String _searchQuery = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchController.addListener(() {
|
||||
setState(() {
|
||||
_searchQuery = _searchController.text.toLowerCase();
|
||||
_currentPage = 0; // Reset page on search
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
@@ -35,391 +60,388 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
||||
super.build(context);
|
||||
|
||||
final channelsAsync = ref.watch(liveChannelsByPlaylistProvider(widget.playlist));
|
||||
final favorites = ref.watch(favoritesProvider);
|
||||
|
||||
return channelsAsync.when(
|
||||
data: (groupedChannels) {
|
||||
if (groupedChannels.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('No live channels available'),
|
||||
);
|
||||
}
|
||||
return Scaffold( // Use scaffold for toolbar body structure
|
||||
backgroundColor: Colors.transparent,
|
||||
body: Column(
|
||||
children: [
|
||||
// Toolbar
|
||||
_buildToolbar(),
|
||||
|
||||
// If a category is selected, show channels list
|
||||
if (_selectedCategory != null && groupedChannels.containsKey(_selectedCategory)) {
|
||||
return _buildChannelsList(groupedChannels[_selectedCategory]!);
|
||||
}
|
||||
// Content
|
||||
Expanded(
|
||||
child: channelsAsync.when(
|
||||
data: (groupedChannels) {
|
||||
// 1. Flatten for global search/filter if needed
|
||||
final allChannels = groupedChannels.values.expand((element) => element).toList();
|
||||
|
||||
List<Channel> displayedChannels = [];
|
||||
bool isGlobalMode = _searchQuery.isNotEmpty || _showFavoritesOnly;
|
||||
|
||||
// Otherwise show category grid
|
||||
return _buildCategoryGrid(groupedChannels);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stack) => Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
||||
const SizedBox(height: 16),
|
||||
Text('Error loading channels: $error'),
|
||||
],
|
||||
),
|
||||
if (isGlobalMode) {
|
||||
// Global Filter Mode
|
||||
displayedChannels = allChannels.where((channel) {
|
||||
final matchesSearch = _searchQuery.isEmpty ||
|
||||
channel.name.toLowerCase().contains(_searchQuery);
|
||||
final matchesFavorite = !_showFavoritesOnly ||
|
||||
favorites.contains(channel.streamId);
|
||||
return matchesSearch && matchesFavorite;
|
||||
}).toList();
|
||||
} else if (_selectedCategory != null) {
|
||||
// Category Mode
|
||||
displayedChannels = groupedChannels[_selectedCategory] ?? [];
|
||||
}
|
||||
|
||||
// If no global mode and no category selected, show Category Grid
|
||||
if (!isGlobalMode && _selectedCategory == null) {
|
||||
if (groupedChannels.isEmpty) {
|
||||
return const Center(child: Text('No channels found'));
|
||||
}
|
||||
return _buildCategoryGrid(groupedChannels);
|
||||
}
|
||||
|
||||
// Show Channels (Grid or List)
|
||||
if (displayedChannels.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.tv_off, size: 64, color: AppColors.textTertiary),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_showFavoritesOnly ? 'No favorites found' : 'No channels found',
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return _buildChannelsView(displayedChannels, isGlobalMode);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, s) => Center(child: Text('Error: $e')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildToolbar() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border(bottom: BorderSide(color: AppColors.border)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Search Bar
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search channels...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchQuery.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () => _searchController.clear(),
|
||||
)
|
||||
: null,
|
||||
isDense: true,
|
||||
filled: true,
|
||||
fillColor: AppColors.surfaceVariant,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Favorites Toggle
|
||||
IconButton(
|
||||
tooltip: 'Favorites Only',
|
||||
icon: Icon(
|
||||
_showFavoritesOnly ? Icons.favorite : Icons.favorite_border,
|
||||
color: _showFavoritesOnly ? AppColors.primary : AppColors.textSecondary,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showFavoritesOnly = !_showFavoritesOnly;
|
||||
_selectedCategory = null; // Reset category when toggling favorites
|
||||
_currentPage = 0;
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
// View Toggle
|
||||
IconButton(
|
||||
tooltip: _isGridView ? 'List View' : 'Grid View',
|
||||
icon: Icon(
|
||||
_isGridView ? Icons.view_list : Icons.grid_view,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
onPressed: () => setState(() => _isGridView = !_isGridView),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build the category grid view with box cards
|
||||
Widget _buildCategoryGrid(Map<String, List<Channel>> groupedChannels) {
|
||||
// Get filter settings
|
||||
final settings = ref.watch(iptvSettingsProvider);
|
||||
|
||||
// Filter categories based on keywords
|
||||
var categories = groupedChannels.keys.toList();
|
||||
|
||||
// Apply Settings Filter (Hidden categories)
|
||||
if (settings.liveTvKeywords.isNotEmpty) {
|
||||
categories = categories.where((cat) => settings.matchesLiveTvFilter(cat)).toList();
|
||||
}
|
||||
categories.sort();
|
||||
|
||||
if (categories.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.filter_list_off, size: 48, color: Colors.grey),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No categories match the filter',
|
||||
style: GoogleFonts.roboto(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Filter: ${settings.liveTvCategoryFilter}',
|
||||
style: GoogleFonts.roboto(fontSize: 12, color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final crossAxisCount = ResponsiveLayout.value(
|
||||
context,
|
||||
mobile: 2,
|
||||
tablet: 3,
|
||||
desktop: 5,
|
||||
);
|
||||
|
||||
// Responsive grid columns
|
||||
final gridColumns = ResponsiveLayout.value(
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: crossAxisCount,
|
||||
mainAxisSpacing: 16,
|
||||
crossAxisSpacing: 16,
|
||||
childAspectRatio: 1.5,
|
||||
),
|
||||
itemCount: categories.length,
|
||||
itemBuilder: (context, index) {
|
||||
final category = categories[index];
|
||||
final count = groupedChannels[category]!.length;
|
||||
|
||||
return InkWell(
|
||||
onTap: () => setState(() {
|
||||
_selectedCategory = category;
|
||||
_currentPage = 0;
|
||||
}),
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||
border: Border.all(color: AppColors.border),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
AppColors.surfaceLight,
|
||||
AppColors.surface,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
right: -10,
|
||||
bottom: -10,
|
||||
child: Icon(
|
||||
Icons.tv,
|
||||
size: 80,
|
||||
color: Colors.white.withOpacity(0.03),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
category,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'$count channels',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChannelsView(List<Channel> channels, bool isGlobalMode) {
|
||||
// Pagination
|
||||
final totalPages = (channels.length / _itemsPerPage).ceil();
|
||||
final startIndex = _currentPage * _itemsPerPage;
|
||||
final endIndex = (startIndex + _itemsPerPage > channels.length)
|
||||
? channels.length
|
||||
: startIndex + _itemsPerPage;
|
||||
final pageChannels = channels.sublist(startIndex, endIndex);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Breadcrumb / Back Navigation
|
||||
if (!isGlobalMode && _selectedCategory != null)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: AppColors.surfaceVariant.withOpacity(0.5),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios, size: 16),
|
||||
onPressed: () => setState(() => _selectedCategory = null),
|
||||
tooltip: 'Back to Categories',
|
||||
),
|
||||
Text(
|
||||
_selectedCategory!,
|
||||
style: GoogleFonts.inter(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${channels.length} items',
|
||||
style: GoogleFonts.inter(color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Grid/List Content
|
||||
Expanded(
|
||||
child: _isGridView
|
||||
? _buildGridView(pageChannels)
|
||||
: _buildListView(pageChannels),
|
||||
),
|
||||
|
||||
// Pagination Controls
|
||||
if (totalPages > 1)
|
||||
Container(
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border(top: BorderSide(color: AppColors.border)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
onPressed: _currentPage > 0 ? () => setState(() => _currentPage--) : null,
|
||||
),
|
||||
Text(
|
||||
'Page ${_currentPage + 1} of $totalPages',
|
||||
style: GoogleFonts.inter(fontWeight: FontWeight.w500),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
onPressed: _currentPage < totalPages - 1 ? () => setState(() => _currentPage++) : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGridView(List<Channel> channels) {
|
||||
final columns = ResponsiveLayout.value(
|
||||
context,
|
||||
mobile: 2,
|
||||
tablet: 4,
|
||||
desktop: 6,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: GridView.builder(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: gridColumns,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
childAspectRatio: 1.6,
|
||||
),
|
||||
itemCount: categories.length,
|
||||
itemBuilder: (context, index) {
|
||||
final category = categories[index];
|
||||
final channelCount = groupedChannels[category]!.length;
|
||||
|
||||
return _CategoryBox(
|
||||
categoryName: category,
|
||||
channelCount: channelCount,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedCategory = category;
|
||||
_currentPage = 0;
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: columns,
|
||||
mainAxisSpacing: 16,
|
||||
crossAxisSpacing: 16,
|
||||
childAspectRatio: 1.4, // Card ratio
|
||||
),
|
||||
itemCount: channels.length,
|
||||
itemBuilder: (context, index) {
|
||||
final channel = channels[index];
|
||||
return ChannelCard(
|
||||
streamId: channel.streamId, // Now required
|
||||
name: channel.name,
|
||||
iconUrl: _getProxiedIconUrl(channel.streamIcon),
|
||||
currentProgram: null, // ShortEPG requires async fetch
|
||||
onTap: () => _playChannel(channel),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Build channels list for selected category
|
||||
Widget _buildChannelsList(List<Channel> channels) {
|
||||
final totalPages = (channels.length / _itemsPerPage).ceil();
|
||||
final startIndex = _currentPage * _itemsPerPage;
|
||||
final endIndex = (startIndex + _itemsPerPage > channels.length)
|
||||
? channels.length
|
||||
: startIndex + _itemsPerPage;
|
||||
final paginatedChannels = channels.sublist(startIndex, endIndex);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Header with back button
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
),
|
||||
Widget _buildListView(List<Channel> channels) {
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: channels.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final channel = channels[index];
|
||||
return SizedBox(
|
||||
height: 80, // Fixed height for list item
|
||||
child: ChannelCard(
|
||||
streamId: channel.streamId,
|
||||
name: channel.name,
|
||||
iconUrl: _getProxiedIconUrl(channel.streamIcon),
|
||||
height: 80,
|
||||
width: double.infinity, // Full width
|
||||
onTap: () => _playChannel(channel),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_selectedCategory = null;
|
||||
_currentPage = 0;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_selectedCategory ?? '',
|
||||
style: GoogleFonts.roboto(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${channels.length} channels',
|
||||
style: GoogleFonts.roboto(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Pagination controls
|
||||
if (totalPages > 1)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
onPressed: _currentPage > 0
|
||||
? () => setState(() => _currentPage--)
|
||||
: null,
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'${_currentPage + 1} / $totalPages',
|
||||
style: GoogleFonts.roboto(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
onPressed: _currentPage < totalPages - 1
|
||||
? () => setState(() => _currentPage++)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Channels list
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: paginatedChannels.length,
|
||||
itemBuilder: (context, index) {
|
||||
final channel = paginatedChannels[index];
|
||||
return _buildChannelTile(context, channel);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChannelTile(BuildContext context, Channel channel) {
|
||||
// Accept both HTTP and HTTPS icons - use proxy for HTTP
|
||||
final bool hasIcon = channel.streamIcon.isNotEmpty;
|
||||
String iconUrl = channel.streamIcon;
|
||||
|
||||
// Proxy HTTP images through our server to avoid Mixed Content blocking
|
||||
if (hasIcon && channel.streamIcon.startsWith('http://')) {
|
||||
iconUrl = '/api/xtream/${channel.streamIcon}';
|
||||
String? _getProxiedIconUrl(String originalUrl) {
|
||||
if (originalUrl.isEmpty) return null;
|
||||
if (originalUrl.startsWith('http://')) {
|
||||
return '/api/xtream/$originalUrl';
|
||||
}
|
||||
|
||||
final Widget placeholder = Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade800,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(Icons.tv, color: Colors.white54),
|
||||
);
|
||||
return originalUrl;
|
||||
}
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PlayerScreen(
|
||||
streamId: channel.streamId,
|
||||
title: channel.name,
|
||||
playlist: widget.playlist,
|
||||
streamType: StreamType.live,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
children: [
|
||||
// Channel icon
|
||||
hasIcon
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: iconUrl,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => placeholder,
|
||||
errorWidget: (context, url, error) => placeholder,
|
||||
),
|
||||
)
|
||||
: placeholder,
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Channel info + EPG
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
channel.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: GoogleFonts.roboto(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// EPG info
|
||||
EPGWidget(
|
||||
channelId: channel.streamId,
|
||||
playlist: widget.playlist,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Play button
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.play_arrow,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Category box card widget
|
||||
class _CategoryBox extends StatelessWidget {
|
||||
final String categoryName;
|
||||
final int channelCount;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _CategoryBox({
|
||||
required this.categoryName,
|
||||
required this.channelCount,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Theme.of(context).colorScheme.primary.withOpacity(0.15),
|
||||
Theme.of(context).colorScheme.secondary.withOpacity(0.08),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.25),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.live_tv,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'$channelCount',
|
||||
style: GoogleFonts.roboto(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
categoryName,
|
||||
style: GoogleFonts.roboto(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
void _playChannel(Channel channel) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PlayerScreen(
|
||||
streamId: channel.streamId,
|
||||
title: channel.name,
|
||||
playlist: widget.playlist,
|
||||
streamType: StreamType.live,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -200,7 +200,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
||||
required bool isSelected,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
@@ -210,11 +211,11 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? AppColors.primary.withOpacity(0.1)
|
||||
: (isDark ? AppColors.surfaceVariantDark : AppColors.surfaceVariantLight),
|
||||
? colorScheme.primary.withOpacity(0.1)
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isSelected ? AppColors.primary : Colors.transparent,
|
||||
color: isSelected ? colorScheme.primary : Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
@@ -223,8 +224,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
||||
Icon(
|
||||
icon,
|
||||
color: isSelected
|
||||
? AppColors.primary
|
||||
: (isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight),
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
@@ -236,15 +237,15 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
||||
style: GoogleFonts.inter(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isSelected
|
||||
? AppColors.primary
|
||||
: (isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight),
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -253,7 +254,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
||||
if (isSelected)
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
color: AppColors.primary,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -261,6 +262,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Widget _buildFiltersTab(BuildContext context, dynamic currentUser, IptvSettings settings) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
|
||||
Reference in new issue
Block a user