feat: Implement a web-based IPTV player with HLS/MPEG-TS support and Flutter communication.

This commit is contained in:
Michael committed 2025-12-17 23:45:56 +01:00
1 parent 2ca92d8425
commit 55acbbc135
28 files changed
+2075 -1448

No files matched your search

+269 -97
View File
@@ -87,9 +87,9 @@ class _AdminContentState extends ConsumerState<AdminContent>
Text(
'Administration',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
// Custom Tab Bar Indicator
@@ -111,12 +111,13 @@ class _AdminContentState extends ConsumerState<AdminContent>
],
),
),
// Content
Expanded(
child: TabBarView(
controller: _tabController,
physics: const NeverScrollableScrollPhysics(), // Custom tabs handle ref
physics:
const NeverScrollableScrollPhysics(), // Custom tabs handle ref
children: const [
_PlaylistsTab(),
_UsersTab(),
@@ -182,7 +183,11 @@ class _PlaylistsTab extends ConsumerWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('${playlistsAsync.asData?.value.length ?? 0} Playlists', style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.white70)),
Text('${playlistsAsync.asData?.value.length ?? 0} Playlists',
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(color: Colors.white70)),
GradientButton(
label: 'Add Playlist',
icon: Icons.add,
@@ -194,16 +199,21 @@ class _PlaylistsTab extends ConsumerWidget {
Expanded(
child: playlistsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('Error: $error', style: const TextStyle(color: AppColors.error))),
error: (error, stack) => Center(
child: Text('Error: $error',
style: const TextStyle(color: AppColors.error))),
data: (playlists) {
if (playlists.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.playlist_remove, size: 64, color: AppColors.textSecondary.withOpacity(0.5)),
Icon(Icons.playlist_remove,
size: 64,
color: AppColors.textSecondary.withOpacity(0.5)),
const SizedBox(height: 16),
Text('No playlists configured', style: Theme.of(context).textTheme.titleMedium),
Text('No playlists configured',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
TextButton(
onPressed: () => _showPlaylistDialog(context, ref),
@@ -215,7 +225,8 @@ class _PlaylistsTab extends ConsumerWidget {
}
return ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
padding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
itemCount: playlists.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
@@ -231,16 +242,25 @@ class _PlaylistsTab extends ConsumerWidget {
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.playlist_play, color: AppColors.textPrimary),
child: const Icon(Icons.playlist_play,
color: AppColors.textPrimary),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(playlist.name, style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Colors.white)),
Text(playlist.name,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(color: Colors.white)),
const SizedBox(height: 4),
Text(playlist.dns, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.white60)),
Text(playlist.dns,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: Colors.white60)),
],
),
),
@@ -249,13 +269,15 @@ class _PlaylistsTab extends ConsumerWidget {
IconButton(
icon: const Icon(Icons.edit_outlined, size: 20),
color: Colors.white70,
onPressed: () => _showPlaylistDialog(context, ref, playlist),
onPressed: () =>
_showPlaylistDialog(context, ref, playlist),
tooltip: 'Edit',
),
IconButton(
icon: const Icon(Icons.delete_outline, size: 20),
color: AppColors.error,
onPressed: () => _deletePlaylist(context, ref, playlist),
onPressed: () =>
_deletePlaylist(context, ref, playlist),
tooltip: 'Delete',
),
],
@@ -272,7 +294,8 @@ class _PlaylistsTab extends ConsumerWidget {
);
}
void _showPlaylistDialog(BuildContext context, WidgetRef ref, [PlaylistConfig? playlist]) {
void _showPlaylistDialog(BuildContext context, WidgetRef ref,
[PlaylistConfig? playlist]) {
final nameController = TextEditingController(text: playlist?.name);
final dnsController = TextEditingController(text: playlist?.dns);
final usernameController = TextEditingController(text: playlist?.username);
@@ -284,40 +307,79 @@ class _PlaylistsTab extends ConsumerWidget {
context: context,
builder: (dialogContext) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: Text(playlist == null ? 'Ajouter une playlist' : 'Modifier la playlist'),
title: Text(playlist == null
? 'Ajouter une playlist'
: 'Modifier la playlist'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: nameController, decoration: const InputDecoration(labelText: 'Nom')),
TextField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Nom')),
const SizedBox(height: 12),
TextField(controller: dnsController, decoration: const InputDecoration(labelText: 'URL du serveur', hintText: 'http://server.com:8080')),
TextField(
controller: dnsController,
decoration: const InputDecoration(
labelText: 'URL du serveur',
hintText: 'http://server.com:8080')),
const SizedBox(height: 12),
TextField(controller: usernameController, decoration: const InputDecoration(labelText: 'Utilisateur')),
TextField(
controller: usernameController,
decoration:
const InputDecoration(labelText: 'Utilisateur')),
const SizedBox(height: 12),
TextField(controller: passwordController, decoration: const InputDecoration(labelText: 'Mot de passe'), obscureText: true),
TextField(
controller: passwordController,
decoration:
const InputDecoration(labelText: 'Mot de passe'),
obscureText: true),
],
),
),
actions: [
TextButton(onPressed: isLoading ? null : () => Navigator.pop(dialogContext), child: const Text('Annuler')),
TextButton(
onPressed:
isLoading ? null : () => Navigator.pop(dialogContext),
child: const Text('Annuler')),
FilledButton(
onPressed: isLoading ? null : () async {
if (nameController.text.isEmpty || dnsController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Tous les champs sont requis')));
return;
}
setState(() => isLoading = true);
final dns = dnsController.text.trim().replaceAll(RegExp(r'/$'), '');
if (playlist == null) {
await service.createPlaylist(name: nameController.text.trim(), dns: dns, username: usernameController.text.trim(), password: passwordController.text);
} else {
await service.updatePlaylist(id: playlist.id, name: nameController.text.trim(), dns: dns, username: usernameController.text.trim(), password: passwordController.text);
}
if (dialogContext.mounted) Navigator.pop(dialogContext);
ref.invalidate(playlistsProvider);
},
child: isLoading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Sauvegarder'),
onPressed: isLoading
? null
: () async {
if (nameController.text.isEmpty ||
dnsController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Tous les champs sont requis')));
return;
}
setState(() => isLoading = true);
final dns = dnsController.text
.trim()
.replaceAll(RegExp(r'/$'), '');
if (playlist == null) {
await service.createPlaylist(
name: nameController.text.trim(),
dns: dns,
username: usernameController.text.trim(),
password: passwordController.text);
} else {
await service.updatePlaylist(
id: playlist.id,
name: nameController.text.trim(),
dns: dns,
username: usernameController.text.trim(),
password: passwordController.text);
}
if (dialogContext.mounted) Navigator.pop(dialogContext);
ref.invalidate(playlistsProvider);
},
child: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Sauvegarder'),
),
],
),
@@ -325,14 +387,17 @@ class _PlaylistsTab extends ConsumerWidget {
);
}
void _deletePlaylist(BuildContext context, WidgetRef ref, PlaylistConfig playlist) {
void _deletePlaylist(
BuildContext context, WidgetRef ref, PlaylistConfig playlist) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Supprimer la playlist'),
content: Text('Supprimer "${playlist.name}" ?'),
actions: [
TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Annuler')),
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Annuler')),
FilledButton(
onPressed: () async {
Navigator.pop(dialogContext);
@@ -363,7 +428,11 @@ class _UsersTab extends ConsumerWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('${usersAsync.asData?.value.length ?? 0} Users', style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.white70)),
Text('${usersAsync.asData?.value.length ?? 0} Users',
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(color: Colors.white70)),
GradientButton(
label: 'Create User',
icon: Icons.person_add,
@@ -375,23 +444,29 @@ class _UsersTab extends ConsumerWidget {
Expanded(
child: usersAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('Error: $error', style: const TextStyle(color: AppColors.error))),
error: (error, stack) => Center(
child: Text('Error: $error',
style: const TextStyle(color: AppColors.error))),
data: (users) {
if (users.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.people_outline, size: 64, color: AppColors.textSecondary.withOpacity(0.5)),
Icon(Icons.people_outline,
size: 64,
color: AppColors.textSecondary.withOpacity(0.5)),
const SizedBox(height: 16),
Text('No users found', style: Theme.of(context).textTheme.titleMedium),
Text('No users found',
style: Theme.of(context).textTheme.titleMedium),
],
),
);
}
return ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
padding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
itemCount: users.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
@@ -404,12 +479,18 @@ class _UsersTab extends ConsumerWidget {
width: 48,
height: 48,
decoration: BoxDecoration(
color: user.isAdmin ? AppColors.focusColor : AppColors.surface,
color: user.isAdmin
? AppColors.focusColor
: AppColors.surface,
shape: BoxShape.circle,
),
child: Icon(
user.isAdmin ? Icons.admin_panel_settings : Icons.person,
color: user.isAdmin ? Colors.black : AppColors.textPrimary,
user.isAdmin
? Icons.admin_panel_settings
: Icons.person,
color: user.isAdmin
? Colors.black
: AppColors.textPrimary,
size: 24,
),
),
@@ -420,38 +501,72 @@ class _UsersTab extends ConsumerWidget {
children: [
Row(
children: [
Text(user.username, style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Colors.white)),
Text(user.username,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(color: Colors.white)),
if (user.isAdmin) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.focusColor.withOpacity(0.2),
color: AppColors.focusColor
.withOpacity(0.2),
borderRadius: BorderRadius.circular(50),
),
child: const Text('ADMIN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: AppColors.focusColor)),
child: const Text('ADMIN',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: AppColors.focusColor)),
),
],
],
),
const SizedBox(height: 4),
Text('ID: ${user.id}', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.white60)),
Text('ID: ${user.id}',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: Colors.white60)),
],
),
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, color: Colors.white70),
icon: const Icon(Icons.more_vert,
color: Colors.white70),
color: AppColors.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
onSelected: (value) {
switch (value) {
case 'password': _showChangePasswordDialog(context, ref, user); break;
case 'delete': _deleteUser(context, ref, user); break;
case 'password':
_showChangePasswordDialog(context, ref, user);
break;
case 'delete':
_deleteUser(context, ref, user);
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem(value: 'password', child: Row(children: [Icon(Icons.lock_reset, size: 18), SizedBox(width: 8), Text('Change Password')])),
const PopupMenuItem(value: 'delete', child: Row(children: [Icon(Icons.delete, color: AppColors.error, size: 18), SizedBox(width: 8), Text('Delete', style: TextStyle(color: AppColors.error))])),
const PopupMenuItem(
value: 'password',
child: Row(children: [
Icon(Icons.lock_reset, size: 18),
SizedBox(width: 8),
Text('Change Password')
])),
const PopupMenuItem(
value: 'delete',
child: Row(children: [
Icon(Icons.delete,
color: AppColors.error, size: 18),
SizedBox(width: 8),
Text('Delete',
style: TextStyle(color: AppColors.error))
])),
],
),
],
@@ -480,9 +595,18 @@ class _UsersTab extends ConsumerWidget {
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: usernameController, decoration: const InputDecoration(labelText: 'Nom d\'utilisateur', prefixIcon: Icon(Icons.person_outline))),
TextField(
controller: usernameController,
decoration: const InputDecoration(
labelText: 'Nom d\'utilisateur',
prefixIcon: Icon(Icons.person_outline))),
const SizedBox(height: 12),
TextField(controller: passwordController, decoration: const InputDecoration(labelText: 'Mot de passe', prefixIcon: Icon(Icons.lock_outline)), obscureText: true),
TextField(
controller: passwordController,
decoration: const InputDecoration(
labelText: 'Mot de passe',
prefixIcon: Icon(Icons.lock_outline)),
obscureText: true),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('Administrateur'),
@@ -493,22 +617,40 @@ class _UsersTab extends ConsumerWidget {
],
),
actions: [
TextButton(onPressed: isLoading ? null : () => Navigator.pop(dialogContext), child: const Text('Annuler')),
TextButton(
onPressed:
isLoading ? null : () => Navigator.pop(dialogContext),
child: const Text('Annuler')),
FilledButton(
onPressed: isLoading ? null : () async {
if (usernameController.text.isEmpty || passwordController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Tous les champs sont requis')));
return;
}
setState(() => isLoading = true);
final result = await UserApiService().createUser(username: usernameController.text.trim(), password: passwordController.text, isAdmin: isAdmin);
if (dialogContext.mounted) Navigator.pop(dialogContext);
ref.invalidate(usersProvider);
if (!result.success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(result.error ?? 'Erreur'), backgroundColor: AppColors.error));
}
},
child: isLoading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Créer'),
onPressed: isLoading
? null
: () async {
if (usernameController.text.isEmpty ||
passwordController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Tous les champs sont requis')));
return;
}
setState(() => isLoading = true);
final result = await UserApiService().createUser(
username: usernameController.text.trim(),
password: passwordController.text,
isAdmin: isAdmin);
if (dialogContext.mounted) Navigator.pop(dialogContext);
ref.invalidate(usersProvider);
if (!result.success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(result.error ?? 'Erreur'),
backgroundColor: AppColors.error));
}
},
child: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Créer'),
),
],
),
@@ -516,7 +658,8 @@ class _UsersTab extends ConsumerWidget {
);
}
void _showChangePasswordDialog(BuildContext context, WidgetRef ref, AppUser user) {
void _showChangePasswordDialog(
BuildContext context, WidgetRef ref, AppUser user) {
final passwordController = TextEditingController();
bool isLoading = false;
@@ -525,25 +668,49 @@ class _UsersTab extends ConsumerWidget {
builder: (dialogContext) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: Text('Changer le mot de passe de ${user.username}'),
content: TextField(controller: passwordController, decoration: const InputDecoration(labelText: 'Nouveau mot de passe', prefixIcon: Icon(Icons.lock_outline)), obscureText: true),
content: TextField(
controller: passwordController,
decoration: const InputDecoration(
labelText: 'Nouveau mot de passe',
prefixIcon: Icon(Icons.lock_outline)),
obscureText: true),
actions: [
TextButton(onPressed: isLoading ? null : () => Navigator.pop(dialogContext), child: const Text('Annuler')),
TextButton(
onPressed:
isLoading ? null : () => Navigator.pop(dialogContext),
child: const Text('Annuler')),
FilledButton(
onPressed: isLoading ? null : () async {
if (passwordController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Mot de passe requis')));
return;
}
setState(() => isLoading = true);
final result = await UserApiService().updatePassword(userId: user.id, newPassword: passwordController.text);
if (dialogContext.mounted) Navigator.pop(dialogContext);
if (result.success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Mot de passe modifié'), backgroundColor: AppColors.success));
} else if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(result.error ?? 'Erreur'), backgroundColor: AppColors.error));
}
},
child: isLoading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Changer'),
onPressed: isLoading
? null
: () async {
if (passwordController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Mot de passe requis')));
return;
}
setState(() => isLoading = true);
final result = await UserApiService().updatePassword(
userId: user.id,
newPassword: passwordController.text);
if (dialogContext.mounted) Navigator.pop(dialogContext);
if (result.success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Mot de passe modifié'),
backgroundColor: AppColors.success));
} else if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(result.error ?? 'Erreur'),
backgroundColor: AppColors.error));
}
},
child: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Changer'),
),
],
),
@@ -556,16 +723,21 @@ class _UsersTab extends ConsumerWidget {
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Supprimer l\'utilisateur'),
content: Text('Supprimer "${user.username}" ? Cette action est irréversible.'),
content: Text(
'Supprimer "${user.username}" ? Cette action est irréversible.'),
actions: [
TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Annuler')),
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Annuler')),
FilledButton(
onPressed: () async {
Navigator.pop(dialogContext);
final result = await UserApiService().deleteUser(user.id);
ref.invalidate(usersProvider);
if (!result.success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(result.error ?? 'Erreur'), backgroundColor: AppColors.error));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(result.error ?? 'Erreur'),
backgroundColor: AppColors.error));
}
},
style: FilledButton.styleFrom(backgroundColor: AppColors.error),
+28 -16
View File
@@ -67,7 +67,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
),
),
),
// Main content
Center(
child: SingleChildScrollView(
@@ -132,9 +132,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
children: [
Text(
'Welcome Back',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
@@ -143,21 +146,26 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: 'Username',
labelStyle: const TextStyle(color: Colors.white70),
prefixIcon: const Icon(Icons.person_outline, color: Colors.white70),
labelStyle:
const TextStyle(color: Colors.white70),
prefixIcon: const Icon(Icons.person_outline,
color: Colors.white70),
filled: true,
fillColor: Colors.white.withOpacity(0.05),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.white.withOpacity(0.1)),
borderSide: BorderSide(
color: Colors.white.withOpacity(0.1)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.white.withOpacity(0.1)),
borderSide: BorderSide(
color: Colors.white.withOpacity(0.1)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.primary),
borderSide: const BorderSide(
color: AppColors.primary),
),
),
validator: (value) {
@@ -176,21 +184,26 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: 'Password',
labelStyle: const TextStyle(color: Colors.white70),
prefixIcon: const Icon(Icons.lock_outline, color: Colors.white70),
labelStyle:
const TextStyle(color: Colors.white70),
prefixIcon: const Icon(Icons.lock_outline,
color: Colors.white70),
filled: true,
fillColor: Colors.white.withOpacity(0.05),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.white.withOpacity(0.1)),
borderSide: BorderSide(
color: Colors.white.withOpacity(0.1)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.white.withOpacity(0.1)),
borderSide: BorderSide(
color: Colors.white.withOpacity(0.1)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.primary),
borderSide: const BorderSide(
color: AppColors.primary),
),
suffixIcon: IconButton(
icon: Icon(
@@ -228,7 +241,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
),
child: Row(
children: [
Icon(
const Icon(
Icons.error_outline,
color: AppColors.error,
size: 20,
@@ -277,6 +290,5 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
],
),
);
}
}
+45 -23
View File
@@ -2,8 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../core/models/playlist_config.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/digital_clock.dart';
import '../widgets/live_tv_tab.dart';
import '../widgets/movies_tab.dart';
@@ -11,7 +9,7 @@ import '../widgets/series_tab.dart';
import '../widgets/settings_tab.dart';
/// Apple TV Style Dashboard
///
///
/// Uses a floating Top Bar for navigation instead of a sidebar.
/// Content flows underneath the top bar.
class DashboardScreen extends ConsumerStatefulWidget {
@@ -26,11 +24,17 @@ class DashboardScreen extends ConsumerStatefulWidget {
ConsumerState<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends ConsumerState<DashboardScreen> with SingleTickerProviderStateMixin {
class _DashboardScreenState extends ConsumerState<DashboardScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final List<String> _tabs = ['Live TV', 'Films', 'Séries', 'Réglages'];
final List<IconData> _icons = [Icons.live_tv, Icons.movie, Icons.tv, Icons.settings];
final List<IconData> _icons = [
Icons.live_tv,
Icons.movie,
Icons.tv,
Icons.settings
];
@override
void initState() {
@@ -74,13 +78,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> with SingleTi
child: _buildLogo(),
),
),
// 2. Centered Navigation Tabs (Expanded to fill space, then Centered)
Expanded(
child: Center(
child: Container(
height: 50,
constraints: const BoxConstraints(maxWidth: 600), // Prevent taking too much space
constraints: const BoxConstraints(
maxWidth: 600), // Prevent taking too much space
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(30),
@@ -97,8 +102,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> with SingleTi
indicatorSize: TabBarIndicatorSize.tab,
indicatorPadding: const EdgeInsets.all(4),
labelColor: Colors.black,
unselectedLabelColor: Colors.white70, // Fixed: Improved readability
labelStyle: GoogleFonts.inter(fontWeight: FontWeight.w700, fontSize: 13),
unselectedLabelColor:
Colors.white70, // Fixed: Improved readability
labelStyle: GoogleFonts.inter(
fontWeight: FontWeight.w700, fontSize: 13),
overlayColor: WidgetStateProperty.all(Colors.transparent),
tabs: List.generate(_tabs.length, (index) {
return Tab(
@@ -118,7 +125,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> with SingleTi
),
),
),
// 3. Search / Profile (Fixed Width for centering balance)
SizedBox(
width: 200,
@@ -130,7 +137,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> with SingleTi
IconButton(
icon: const Icon(Icons.search, color: Colors.white),
onPressed: () {
// Global search trigger
// Global search trigger
},
),
const SizedBox(width: 16),
@@ -139,7 +146,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> with SingleTi
const CircleAvatar(
radius: 16,
backgroundColor: Colors.white24,
child: Icon(Icons.person, size: 18, color: Colors.white),
child:
Icon(Icons.person, size: 18, color: Colors.white),
),
],
),
@@ -165,17 +173,25 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> with SingleTi
),
),
),
// Content
TabBarView(
controller: _tabController,
physics: const NeverScrollableScrollPhysics(), // Disable swipe to avoid gesture conflicts
physics:
const NeverScrollableScrollPhysics(), // Disable swipe to avoid gesture conflicts
children: [
// Padding top to account for the transparent AppBar
Padding(padding: const EdgeInsets.only(top: 80), child: LiveTVTab(playlist: widget.playlist)),
Padding(padding: const EdgeInsets.only(top: 80), child: MoviesTab(playlist: widget.playlist)),
Padding(padding: const EdgeInsets.only(top: 80), child: SeriesTab(playlist: widget.playlist)),
const Padding(padding: EdgeInsets.only(top: 80), child: SettingsTab()),
Padding(
padding: const EdgeInsets.only(top: 80),
child: LiveTVTab(playlist: widget.playlist)),
Padding(
padding: const EdgeInsets.only(top: 80),
child: MoviesTab(playlist: widget.playlist)),
Padding(
padding: const EdgeInsets.only(top: 80),
child: SeriesTab(playlist: widget.playlist)),
const Padding(
padding: EdgeInsets.only(top: 80), child: SettingsTab()),
],
),
],
@@ -190,7 +206,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> with SingleTi
width: 36,
height: 36,
decoration: BoxDecoration(
gradient: const LinearGradient(colors: [Color(0xFF43cea2), Color(0xFF185a9d)]), // Green to Blue
gradient: const LinearGradient(colors: [
Color(0xFF43cea2),
Color(0xFF185a9d)
]), // Green to Blue
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.play_arrow_rounded, color: Colors.white),
@@ -202,13 +221,16 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> with SingleTi
children: [
Text(
'Xtrem',
style: GoogleFonts.inter(fontWeight: FontWeight.w800, fontSize: 16, color: Colors.white),
style: GoogleFonts.inter(
fontWeight: FontWeight.w800,
fontSize: 16,
color: Colors.white),
),
Text(
'Flow',
style: GoogleFonts.inter(
fontWeight: FontWeight.w300,
fontSize: 16,
fontWeight: FontWeight.w300,
fontSize: 16,
color: Colors.white.withOpacity(0.8),
height: 0.8,
),
File diff suppressed because it is too large. Load diff
@@ -26,14 +26,16 @@ class PlaylistSelectionScreen extends ConsumerWidget {
appBar: AppBar(
title: Text(
'Select Playlist',
style: GoogleFonts.roboto(fontWeight: FontWeight.w600, color: Colors.white),
style: GoogleFonts.roboto(
fontWeight: FontWeight.w600, color: Colors.white),
),
backgroundColor: Colors.transparent,
elevation: 0,
actions: [
if (currentUser?.isAdmin ?? false)
IconButton(
icon: const Icon(Icons.admin_panel_settings, color: Colors.white70),
icon:
const Icon(Icons.admin_panel_settings, color: Colors.white70),
onPressed: () => context.go('/admin'),
tooltip: 'Admin Panel',
),
@@ -69,7 +71,9 @@ class PlaylistSelectionScreen extends ConsumerWidget {
children: [
Icon(Icons.error_outline, size: 64, color: Colors.red.shade400),
const SizedBox(height: 16),
Text('Error loading playlists', style: GoogleFonts.roboto(fontSize: 18, color: Colors.white70)),
Text('Error loading playlists',
style: GoogleFonts.roboto(
fontSize: 18, color: Colors.white70)),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () => ref.refresh(playlistsProvider),
@@ -84,7 +88,7 @@ class PlaylistSelectionScreen extends ConsumerWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
const Icon(
Icons.playlist_remove,
size: 64,
color: Colors.white24,
@@ -113,7 +117,8 @@ class PlaylistSelectionScreen extends ConsumerWidget {
}
return GridView.builder(
padding: const EdgeInsets.fromLTRB(24, 100, 24, 24), // Top padding for transparent AppBar
padding: const EdgeInsets.fromLTRB(
24, 100, 24, 24), // Top padding for transparent AppBar
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 24,
@@ -165,23 +170,25 @@ class _PlaylistCardState extends State<_PlaylistCard> {
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color: _isHovered
? Colors.white.withOpacity(0.15)
color: _isHovered
? Colors.white.withOpacity(0.15)
: Colors.white.withOpacity(0.05), // Glass effect
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: _isHovered
? Colors.white.withOpacity(0.5)
color: _isHovered
? Colors.white.withOpacity(0.5)
: Colors.white.withOpacity(0.1),
width: 1.5,
),
boxShadow: _isHovered ? [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
] : [],
boxShadow: _isHovered
? [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
]
: [],
),
padding: const EdgeInsets.all(24),
child: Column(
@@ -204,7 +211,8 @@ class _PlaylistCardState extends State<_PlaylistCard> {
const SizedBox(height: 16),
Text(
widget.playlist.name,
style: GoogleFonts.outfit( // Using Outfit for modern look
style: GoogleFonts.outfit(
// Using Outfit for modern look
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
@@ -229,4 +237,3 @@ class _PlaylistCardState extends State<_PlaylistCard> {
);
}
}
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
/// Horizontal carousel for displaying channels by category
///
///
/// Netflix-style horizontal scrolling list with:
/// - Category title with count
/// - Smooth horizontal scrolling
@@ -36,7 +36,7 @@ class CategoryCarousel<T> extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
if (items.isEmpty) {
return const SizedBox.shrink();
}
@@ -110,15 +110,15 @@ class CategoryCarousel<T> extends StatelessWidget {
],
),
),
const SizedBox(height: 12),
// Horizontal scrolling list with fade edges
SizedBox(
height: itemHeight,
child: ShaderMask(
shaderCallback: (Rect bounds) {
return LinearGradient(
return const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
@@ -127,7 +127,7 @@ class CategoryCarousel<T> extends StatelessWidget {
Colors.white,
Colors.transparent,
],
stops: const [0.0, 0.02, 0.98, 1.0],
stops: [0.0, 0.02, 0.98, 1.0],
).createShader(bounds);
},
blendMode: BlendMode.dstIn,
@@ -173,7 +173,7 @@ class CategoryChipButton extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
@@ -181,14 +181,10 @@ class CategoryChipButton extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
gradient: isSelected ? AppColors.primaryGradient : null,
color: isSelected
? null
: colorScheme.surfaceContainerHighest,
color: isSelected ? null : colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isSelected
? Colors.transparent
: colorScheme.outlineVariant,
color: isSelected ? Colors.transparent : colorScheme.outlineVariant,
),
),
child: Row(
@@ -209,9 +205,8 @@ class CategoryChipButton extends StatelessWidget {
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isSelected
? colorScheme.onPrimary
: colorScheme.onSurface,
color:
isSelected ? colorScheme.onPrimary : colorScheme.onSurface,
),
),
],
+58 -56
View File
@@ -1,10 +1,8 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/playlist_config.dart';
import '../providers/xtream_provider.dart';
/// Minimal EPG overlay - shows program title and info
class EpgOverlay extends ConsumerWidget {
final String streamId;
@@ -18,9 +16,11 @@ class EpgOverlay extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final epgAsync = ref.watch(epgByPlaylistProvider(
EpgRequestKey(playlist: playlist, streamId: streamId),
),);
final epgAsync = ref.watch(
epgByPlaylistProvider(
EpgRequestKey(playlist: playlist, streamId: streamId),
),
);
return epgAsync.when(
data: (epgEntries) {
@@ -41,67 +41,69 @@ class EpgOverlay extends ConsumerWidget {
// Enhanced info display
return Align(
alignment: Alignment.bottomLeft, // Align to bottom left near controls
child: Container(
margin: const EdgeInsets.fromLTRB(24, 0, 24, 24),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5), // Semi-transparent backing for readability
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white.withOpacity(0.1)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'LIVE',
style: TextStyle(
color: Colors.white,
fontSize: 12, // Larger
fontWeight: FontWeight.bold,
),
alignment: Alignment.bottomLeft, // Align to bottom left near controls
child: Container(
margin: const EdgeInsets.fromLTRB(24, 0, 24, 24),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.black
.withOpacity(0.5), // Semi-transparent backing for readability
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white.withOpacity(0.1)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'LIVE',
style: TextStyle(
color: Colors.white,
fontSize: 12, // Larger
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 12),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 500), // Wider
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
),
const SizedBox(width: 12),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 500), // Wider
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
currentProgram.title,
style: const TextStyle(
color: Colors.white,
fontSize: 18, // Much Larger
fontWeight: FontWeight.bold,
shadows: [Shadow(color: Colors.black, blurRadius: 4)],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (currentProgram.description.isNotEmpty)
Text(
currentProgram.title,
currentProgram.description,
style: const TextStyle(
color: Colors.white,
fontSize: 18, // Much Larger
fontWeight: FontWeight.bold,
shadows: [Shadow(color: Colors.black, blurRadius: 4)],
color: Colors.white70,
fontSize: 13, // Larger desc
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (currentProgram.description.isNotEmpty)
Text(
currentProgram.description,
style: const TextStyle(
color: Colors.white70,
fontSize: 13, // Larger desc
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
],
),
],
),
),
],
),
),
);
},
loading: () => const SizedBox.shrink(),
+95 -68
View File
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../providers/xtream_provider.dart';
import '../providers/settings_provider.dart';
import '../providers/favorites_provider.dart';
@@ -10,7 +9,6 @@ import '../../../core/models/playlist_config.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/utils/responsive_layout.dart';
import '../../../core/widgets/components/ui_components.dart';
import '../../../core/widgets/themed_loading_screen.dart';
import 'channel_card.dart';
@@ -26,16 +24,15 @@ class LiveTVTab extends ConsumerStatefulWidget {
class _LiveTVTabState extends ConsumerState<LiveTVTab>
with AutomaticKeepAliveClientMixin {
// State
String? _selectedCategory;
// UI State
bool _isGridView = true;
final TextEditingController _searchController = TextEditingController();
bool _showFavoritesOnly = false;
String _searchQuery = '';
// Scroll Controllers
final ScrollController _mainScrollController = ScrollController();
@@ -63,7 +60,8 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
Widget build(BuildContext context) {
super.build(context);
final channelsAsync = ref.watch(liveChannelsByPlaylistProvider(widget.playlist));
final channelsAsync =
ref.watch(liveChannelsByPlaylistProvider(widget.playlist));
final favorites = ref.watch(favoritesProvider);
final settings = ref.watch(iptvSettingsProvider);
@@ -73,34 +71,38 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
loading: () => const ThemedLoading(),
error: (e, s) => Center(child: Text('Error: $e')),
data: (groupedChannels) {
// Groups
var categories = groupedChannels.keys.toList();
if (settings.liveTvKeywords.isNotEmpty) {
categories = categories.where((cat) => settings.matchesLiveTvFilter(cat)).toList();
}
categories.sort();
// Filter Logic
List<Channel> displayedChannels = [];
bool showingCategoryGrid = false;
if (_searchQuery.isNotEmpty) {
// Global Search
displayedChannels = groupedChannels.values.expand((l) => l)
.where((c) => c.name.toLowerCase().contains(_searchQuery))
.toList();
} else if (_showFavoritesOnly) {
// Favorites
displayedChannels = groupedChannels.values.expand((l) => l)
.where((c) => favorites.contains(c.streamId))
.toList();
} else if (_selectedCategory != null) {
// Selected Category View
displayedChannels = groupedChannels[_selectedCategory] ?? [];
} else {
// Category Grid View (Default)
showingCategoryGrid = true;
}
// Groups
var categories = groupedChannels.keys.toList();
if (settings.liveTvKeywords.isNotEmpty) {
categories = categories
.where((cat) => settings.matchesLiveTvFilter(cat))
.toList();
}
categories.sort();
// Filter Logic
List<Channel> displayedChannels = [];
bool showingCategoryGrid = false;
if (_searchQuery.isNotEmpty) {
// Global Search
displayedChannels = groupedChannels.values
.expand((l) => l)
.where((c) => c.name.toLowerCase().contains(_searchQuery))
.toList();
} else if (_showFavoritesOnly) {
// Favorites
displayedChannels = groupedChannels.values
.expand((l) => l)
.where((c) => favorites.contains(c.streamId))
.toList();
} else if (_selectedCategory != null) {
// Selected Category View
displayedChannels = groupedChannels[_selectedCategory] ?? [];
} else {
// Category Grid View (Default)
showingCategoryGrid = true;
}
return Column(
children: [
@@ -110,9 +112,12 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
child: Row(
children: [
// Back Button (if in category view and not searching)
if (_selectedCategory != null && _searchQuery.isEmpty && !_showFavoritesOnly) ...[
if (_selectedCategory != null &&
_searchQuery.isEmpty &&
!_showFavoritesOnly) ...[
IconButton(
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
icon: const Icon(Icons.arrow_back,
color: AppColors.textPrimary),
onPressed: () {
setState(() {
_selectedCategory = null;
@@ -125,34 +130,40 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
],
Text(
_searchQuery.isNotEmpty ? 'Search Results' :
_showFavoritesOnly ? 'Favorites' :
_selectedCategory ?? 'Live TV',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
_searchQuery.isNotEmpty
? 'Search Results'
: _showFavoritesOnly
? 'Favorites'
: _selectedCategory ?? 'Live TV',
style:
Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
// Search Pill
Container(
width: 300,
height: 40,
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
borderRadius:
BorderRadius.circular(AppTheme.radiusFull),
border: Border.all(color: AppColors.border),
),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
const Icon(Icons.search, size: 20, color: AppColors.textSecondary),
const Icon(Icons.search,
size: 20, color: AppColors.textSecondary),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _searchController,
style: const TextStyle(fontSize: 14, color: Colors.white),
style: const TextStyle(
fontSize: 14, color: Colors.white),
decoration: const InputDecoration(
hintText: 'Rechercher...',
hintStyle: TextStyle(color: Colors.white54),
@@ -163,31 +174,37 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
),
),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () => _searchController.clear(),
child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary),
),
GestureDetector(
onTap: () => _searchController.clear(),
child: const Icon(Icons.close,
size: 16, color: AppColors.textSecondary),
),
],
),
),
const SizedBox(width: 16),
// Favorites Toggle
IconButton(
icon: Icon(
_showFavoritesOnly ? Icons.favorite : Icons.favorite_border,
color: _showFavoritesOnly ? AppColors.error : AppColors.textSecondary,
_showFavoritesOnly
? Icons.favorite
: Icons.favorite_border,
color: _showFavoritesOnly
? AppColors.error
: AppColors.textSecondary,
),
onPressed: () {
setState(() {
_showFavoritesOnly = !_showFavoritesOnly;
if (_showFavoritesOnly) _searchController.clear();
_selectedCategory = null; // Reset selection when toggling favorites
});
setState(() {
_showFavoritesOnly = !_showFavoritesOnly;
if (_showFavoritesOnly) _searchController.clear();
_selectedCategory =
null; // Reset selection when toggling favorites
});
},
tooltip: 'Favorites Only',
),
// View Toggle (Only show when not in category grid)
if (!showingCategoryGrid)
IconButton(
@@ -195,7 +212,8 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
_isGridView ? Icons.view_list : Icons.grid_view,
color: AppColors.textSecondary,
),
onPressed: () => setState(() => _isGridView = !_isGridView),
onPressed: () =>
setState(() => _isGridView = !_isGridView),
tooltip: 'Toggle View',
),
],
@@ -209,9 +227,15 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
: displayedChannels.isEmpty
? Center(
child: Text(
_searchQuery.isNotEmpty ? 'No channels found' :
_showFavoritesOnly ? 'No favorites' : 'No channels in this group',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: AppColors.textSecondary),
_searchQuery.isNotEmpty
? 'No channels found'
: _showFavoritesOnly
? 'No favorites'
: 'No channels in this group',
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(color: AppColors.textSecondary),
),
)
: _isGridView
@@ -225,7 +249,8 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
);
}
Widget _buildCategoryGrid(List<String> categories, Map<String, List<Channel>> groupedChannels) {
Widget _buildCategoryGrid(
List<String> categories, Map<String, List<Channel>> groupedChannels) {
final columns = ResponsiveLayout.value(
context,
mobile: 2,
@@ -246,11 +271,12 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
itemBuilder: (context, index) {
final category = categories[index];
final count = groupedChannels[category]?.length ?? 0;
return Card(
clipBehavior: Clip.antiAlias,
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: InkWell(
onTap: () {
setState(() {
@@ -281,7 +307,8 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
color: AppColors.primary.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(Icons.folder_open, color: AppColors.primary, size: 32),
child: const Icon(Icons.folder_open,
color: AppColors.primary, size: 32),
),
const SizedBox(height: 12),
Text(
+52 -35
View File
@@ -61,7 +61,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
void _onSearchChanged(String query) {
_searchDebounce?.cancel();
setState(() {
_searchQuery = query;
if (query.isEmpty) {
@@ -69,7 +69,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
_isSearching = false;
}
});
if (query.length >= 2) {
_searchDebounce = Timer(const Duration(milliseconds: 500), () {
_performSearch(query);
@@ -79,13 +79,13 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
Future<void> _performSearch(String query) async {
if (query.isEmpty) return;
setState(() => _isSearching = true);
try {
final service = ref.read(xtreamServiceProvider(widget.playlist));
final results = await service.searchMovies(query);
if (mounted && _searchQuery == query) {
setState(() {
_searchResults = results;
@@ -151,7 +151,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
void _playMovie(Movie movie) {
ref.read(watchHistoryProvider.notifier).markMovieWatched(movie.streamId);
Navigator.push(
context,
MaterialPageRoute(
@@ -170,32 +170,39 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
Widget build(BuildContext context) {
final settings = ref.watch(iptvSettingsProvider);
final watchHistory = ref.watch(watchHistoryProvider);
List<Movie> displayMovies;
if (_searchQuery.isNotEmpty && _searchResults != null) {
displayMovies = _searchResults!;
} else {
displayMovies = settings.moviesKeywords.isEmpty
? _movies
: _movies.where((m) => settings.matchesMoviesFilter(m.categoryName)).toList();
: _movies
.where((m) => settings.matchesMoviesFilter(m.categoryName))
.toList();
}
// Hero Items (Take 5 random or first 5 from filtered list)
final heroItems = displayMovies.take(5).map((m) => HeroItem(
id: m.streamId,
title: m.name,
imageUrl: _getProxiedImageUrl(m.streamIcon),
subtitle: m.rating != null ? '${_formatRating(m.rating)} ★' : null,
onMoreInfo: () {
_playMovie(m);
},
)).toList();
final heroItems = displayMovies
.take(5)
.map(
(m) => HeroItem(
id: m.streamId,
title: m.name,
imageUrl: _getProxiedImageUrl(m.streamIcon),
subtitle: m.rating != null ? '${_formatRating(m.rating)} ★' : null,
onMoreInfo: () {
_playMovie(m);
},
),
)
.toList();
if (_movies.isEmpty && !_isLoading) {
return const Center(child: Text('No movies available'));
}
final double gridItemRatio = 0.65;
const double gridItemRatio = 0.65;
final int crossAxisCount = ResponsiveLayout.value(
context,
mobile: 3,
@@ -215,9 +222,9 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
Text(
'Movies',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
Container(
@@ -231,12 +238,14 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
const Icon(Icons.search, size: 20, color: AppColors.textSecondary),
const Icon(Icons.search,
size: 20, color: AppColors.textSecondary),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _searchController,
style: const TextStyle(fontSize: 14, color: Colors.white),
style: const TextStyle(
fontSize: 14, color: Colors.white),
decoration: const InputDecoration(
hintText: 'Rechercher...',
hintStyle: TextStyle(color: Colors.white54),
@@ -250,14 +259,18 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
),
),
if (_isSearching)
const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)),
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(strokeWidth: 2)),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () {
_searchController.clear();
_onSearchChanged('');
_searchController.clear();
_onSearchChanged('');
},
child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary),
child: const Icon(Icons.close,
size: 16, color: AppColors.textSecondary),
),
],
),
@@ -275,13 +288,14 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
child: HeroCarousel(
items: heroItems,
onTap: (item) {
final movie = _movies.firstWhere((element) => element.streamId == item.id);
_playMovie(movie);
final movie = _movies
.firstWhere((element) => element.streamId == item.id);
_playMovie(movie);
},
),
),
),
// Grid
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
@@ -297,17 +311,20 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
if (index >= displayMovies.length) return null;
final movie = displayMovies[index];
final isWatched = watchHistory.isMovieWatched(movie.streamId);
return MediaCard(
title: movie.name,
imageUrl: _getProxiedImageUrl(movie.streamIcon),
subtitle: movie.rating != null ? '${_formatRating(movie.rating)} ★' : null,
subtitle: movie.rating != null
? '${_formatRating(movie.rating)} ★'
: null,
rating: _formatRating(movie.rating),
isWatched: isWatched,
onTap: () => _playMovie(movie),
onLongPress: () {
ref.read(watchHistoryProvider.notifier).toggleMovieWatched(movie.streamId);
ref
.read(watchHistoryProvider.notifier)
.toggleMovieWatched(movie.streamId);
},
);
},
@@ -315,7 +332,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
),
),
),
// Loader
if (_isLoading)
const SliverToBoxAdapter(
+45 -30
View File
@@ -61,7 +61,7 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
void _onSearchChanged(String query) {
_searchDebounce?.cancel();
setState(() {
_searchQuery = query;
if (query.isEmpty) {
@@ -69,7 +69,7 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
_isSearching = false;
}
});
if (query.length >= 2) {
_searchDebounce = Timer(const Duration(milliseconds: 500), () {
_performSearch(query);
@@ -79,13 +79,13 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
Future<void> _performSearch(String query) async {
if (query.isEmpty) return;
setState(() => _isSearching = true);
try {
final service = ref.read(xtreamServiceProvider(widget.playlist));
final results = await service.searchSeries(query);
if (mounted && _searchQuery == query) {
setState(() {
_searchResults = results;
@@ -164,14 +164,16 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
@override
Widget build(BuildContext context) {
final settings = ref.watch(iptvSettingsProvider);
List<Series> displaySeries;
if (_searchQuery.isNotEmpty && _searchResults != null) {
displaySeries = _searchResults!;
} else {
displaySeries = settings.seriesKeywords.isEmpty
? _series
: _series.where((s) => settings.matchesSeriesFilter(s.categoryName)).toList();
: _series
.where((s) => settings.matchesSeriesFilter(s.categoryName))
.toList();
}
if (_series.isEmpty && !_isLoading) {
@@ -179,15 +181,20 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
}
// Hero Items
final heroItems = displaySeries.take(5).map((s) => HeroItem(
id: s.seriesId.toString(), // ID logic might differ for Series
title: s.name,
imageUrl: _getProxiedImageUrl(s.cover),
subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null,
onMoreInfo: () => _openSeries(s),
)).toList();
final heroItems = displaySeries
.take(5)
.map(
(s) => HeroItem(
id: s.seriesId.toString(), // ID logic might differ for Series
title: s.name,
imageUrl: _getProxiedImageUrl(s.cover),
subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null,
onMoreInfo: () => _openSeries(s),
),
)
.toList();
final double gridItemRatio = 0.65;
const double gridItemRatio = 0.65;
final int crossAxisCount = ResponsiveLayout.value(
context,
mobile: 3,
@@ -207,9 +214,9 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
Text(
'TV Series',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
Container(
@@ -223,12 +230,14 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
const Icon(Icons.search, size: 20, color: AppColors.textSecondary),
const Icon(Icons.search,
size: 20, color: AppColors.textSecondary),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _searchController,
style: const TextStyle(fontSize: 14, color: Colors.white),
style: const TextStyle(
fontSize: 14, color: Colors.white),
decoration: const InputDecoration(
hintText: 'Rechercher...',
hintStyle: TextStyle(color: Colors.white54),
@@ -242,14 +251,18 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
),
),
if (_isSearching)
const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)),
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(strokeWidth: 2)),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () {
_searchController.clear();
_onSearchChanged('');
_searchController.clear();
_onSearchChanged('');
},
child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary),
child: const Icon(Icons.close,
size: 16, color: AppColors.textSecondary),
),
],
),
@@ -267,13 +280,14 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
child: HeroCarousel(
items: heroItems,
onTap: (item) {
final series = _series.firstWhere((s) => s.name == item.title); // Fallback by title if ID mismatch
_openSeries(series);
final series = _series.firstWhere((s) =>
s.name == item.title); // Fallback by title if ID mismatch
_openSeries(series);
},
),
),
),
// Grid
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
@@ -291,8 +305,9 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
return MediaCard(
title: serie.name,
imageUrl: _getProxiedImageUrl(serie.cover),
subtitle: serie.rating != null ? '${_formatRating(serie.rating)} ★' : null,
subtitle: serie.rating != null
? '${_formatRating(serie.rating)} ★'
: null,
rating: _formatRating(serie.rating),
placeholderIcon: Icons.tv,
onTap: () => _openSeries(serie),
@@ -302,7 +317,7 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
),
),
),
// Loader
if (_isLoading)
const SliverToBoxAdapter(
+100 -83
View File
@@ -18,7 +18,8 @@ class SettingsTab extends ConsumerStatefulWidget {
ConsumerState<SettingsTab> createState() => _SettingsTabState();
}
class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProviderStateMixin {
class _SettingsTabState extends ConsumerState<SettingsTab>
with SingleTickerProviderStateMixin {
late TabController _tabController;
late TextEditingController _liveTvController;
late TextEditingController _moviesController;
@@ -45,13 +46,16 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
}
void _syncControllers(IptvSettings settings) {
if (!_initialized || _liveTvController.text != settings.liveTvCategoryFilter) {
if (!_initialized ||
_liveTvController.text != settings.liveTvCategoryFilter) {
_liveTvController.text = settings.liveTvCategoryFilter;
}
if (!_initialized || _moviesController.text != settings.moviesCategoryFilter) {
if (!_initialized ||
_moviesController.text != settings.moviesCategoryFilter) {
_moviesController.text = settings.moviesCategoryFilter;
}
if (!_initialized || _seriesController.text != settings.seriesCategoryFilter) {
if (!_initialized ||
_seriesController.text != settings.seriesCategoryFilter) {
_seriesController.text = settings.seriesCategoryFilter;
}
_initialized = true;
@@ -61,7 +65,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
Widget build(BuildContext context) {
final currentUser = ref.watch(authProvider).currentUser;
final settings = ref.watch(iptvSettingsProvider);
// Sync controllers with persisted state (only on first load)
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_initialized) _syncControllers(settings);
@@ -85,17 +89,19 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
labelColor: Colors.white,
unselectedLabelColor: Colors.white54,
dividerColor: Colors.transparent,
overlayColor: MaterialStateProperty.all(Colors.transparent),
overlayColor: WidgetStateProperty.all(Colors.transparent),
tabs: [
const Tab(icon: Icon(Icons.filter_list), text: 'Filtres'),
const Tab(icon: Icon(Icons.stream), text: 'Streaming'),
const Tab(icon: Icon(Icons.palette), text: 'Apparence'),
if (currentUser?.isAdmin ?? false)
const Tab(icon: Icon(Icons.admin_panel_settings), text: 'Administration'),
const Tab(
icon: Icon(Icons.admin_panel_settings),
text: 'Administration'),
],
),
),
// TabBarView
Expanded(
child: TabBarView(
@@ -103,16 +109,15 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
children: [
// Filters Tab
_buildFiltersTab(context, currentUser, settings),
// Streaming Tab
const StreamingSettingsTab(),
// Appearance Tab
_buildAppearanceTab(context),
// Admin Tab
if (currentUser?.isAdmin ?? false)
const AdminContent(),
if (currentUser?.isAdmin ?? false) const AdminContent(),
],
),
),
@@ -125,7 +130,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
final themeState = ref.watch(themeProvider);
final themeNotifier = ref.read(themeProvider.notifier);
final isDark = Theme.of(context).brightness == Brightness.dark;
return ListView(
padding: const EdgeInsets.all(16),
children: [
@@ -142,7 +147,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
children: [
Row(
children: [
Icon(
const Icon(
Icons.dark_mode,
color: Colors.white,
),
@@ -158,7 +163,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
],
),
const SizedBox(height: 16),
// Theme options
_buildThemeOption(
context: context,
@@ -189,9 +194,9 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
],
),
),
const SizedBox(height: 16),
// Quick toggle card
Container(
decoration: BoxDecoration(
@@ -206,20 +211,21 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
),
title: Text(
'Mode actuel',
style: GoogleFonts.inter(fontWeight: FontWeight.w500, color: Colors.white),
style: GoogleFonts.inter(
fontWeight: FontWeight.w500, color: Colors.white),
),
subtitle: Text(
isDark ? 'Thème sombre actif' : 'Thème clair actif',
style: TextStyle(color: Colors.white70),
style: const TextStyle(color: Colors.white70),
),
trailing: Switch(
value: isDark,
onChanged: (_) => themeNotifier.toggleTheme(),
activeColor: AppColors.primary,
trackColor: MaterialStateProperty.resolveWith(
(states) => states.contains(MaterialState.selected)
? AppColors.primary.withOpacity(0.5)
: Colors.grey.shade800,
activeThumbColor: AppColors.primary,
trackColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected)
? AppColors.primary.withOpacity(0.5)
: Colors.grey.shade800,
),
),
),
@@ -238,7 +244,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
}) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
@@ -298,8 +304,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
);
}
Widget _buildFiltersTab(BuildContext context, dynamic currentUser, IptvSettings settings) {
Widget _buildFiltersTab(
BuildContext context, dynamic currentUser, IptvSettings settings) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
@@ -319,18 +325,21 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
),
subtitle: Text(
currentUser?.username ?? 'Unknown',
style: GoogleFonts.roboto(fontWeight: FontWeight.w600, color: Colors.white),
style: GoogleFonts.roboto(
fontWeight: FontWeight.w600, color: Colors.white),
),
trailing: currentUser?.isAdmin ?? false
? Chip(
label: Text('Admin', style: GoogleFonts.roboto(fontSize: 11, color: Colors.black)),
label: Text('Admin',
style: GoogleFonts.roboto(
fontSize: 11, color: Colors.black)),
backgroundColor: Colors.white,
)
: null,
),
),
const SizedBox(height: 16),
// Category filters section
Text(
'Filtres de Catégories',
@@ -349,7 +358,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
),
),
const SizedBox(height: 12),
// Live TV Filter
_buildFilterCard(
title: 'Filtre TV Live',
@@ -365,7 +374,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
},
),
const SizedBox(height: 8),
// Movies Filter
_buildFilterCard(
title: 'Filtre Films',
@@ -381,7 +390,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
},
),
const SizedBox(height: 8),
// Series Filter
_buildFilterCard(
title: 'Filtre Séries',
@@ -397,9 +406,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
},
),
const SizedBox(height: 16),
// Change playlist
Container(
decoration: BoxDecoration(
@@ -409,13 +416,14 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
),
child: ListTile(
leading: const Icon(Icons.playlist_play, color: Colors.white),
title: Text('Changer de Playlist', style: GoogleFonts.roboto(color: Colors.white)),
title: Text('Changer de Playlist',
style: GoogleFonts.roboto(color: Colors.white)),
trailing: const Icon(Icons.chevron_right, color: Colors.white54),
onTap: () => context.go('/playlists'),
),
),
const SizedBox(height: 8),
// About
Container(
decoration: BoxDecoration(
@@ -425,7 +433,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
),
child: ListTile(
leading: const Icon(Icons.info_outline, color: Colors.white),
title: Text('À propos', style: GoogleFonts.roboto(color: Colors.white)),
title: Text('À propos',
style: GoogleFonts.roboto(color: Colors.white)),
subtitle: Text(
'XtremFlow IPTV v1.0.0',
style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54),
@@ -433,7 +442,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
),
),
const SizedBox(height: 16),
// Maintenance Section
Container(
decoration: BoxDecoration(
@@ -443,50 +452,53 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
),
child: ListTile(
leading: const Icon(Icons.cleaning_services, color: Colors.orange),
title: Text('Maintenance', style: GoogleFonts.roboto(color: Colors.white)),
title: Text('Maintenance',
style: GoogleFonts.roboto(color: Colors.white)),
subtitle: Text(
'En cas de problèmes d\'affichage ou de mise à jour',
style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54),
),
trailing: FilledButton.icon(
onPressed: () {
// Show confirmation dialog
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: const Color(0xFF1E1E1E),
title: const Text('Vider le cache ?', style: TextStyle(color: Colors.white)),
content: const Text(
'Cette action va recharger l\'application et effacer les données temporaires.\n'
'Vos réglages et favoris sont sauvegardés sur le serveur.',
style: TextStyle(color: Colors.white70),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.orange),
onPressed: () {
Navigator.pop(context);
// Clear Flutter Image Cache
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
// Clear Browser Local Storage (SharedPreferences backend for Web)
html.window.localStorage.clear();
html.window.sessionStorage.clear();
// Reload application
html.window.location.reload();
},
child: const Text('Vider et Recharger'),
),
],
)
);
// Show confirmation dialog
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: const Color(0xFF1E1E1E),
title: const Text('Vider le cache ?',
style: TextStyle(color: Colors.white)),
content: const Text(
'Cette action va recharger l\'application et effacer les données temporaires.\n'
'Vos réglages et favoris sont sauvegardés sur le serveur.',
style: TextStyle(color: Colors.white70),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Colors.orange),
onPressed: () {
Navigator.pop(context);
// Clear Flutter Image Cache
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
// Clear Browser Local Storage (SharedPreferences backend for Web)
html.window.localStorage.clear();
html.window.sessionStorage.clear();
// Reload application
html.window.location.reload();
},
child: const Text('Vider et Recharger'),
),
],
),
);
},
icon: const Icon(Icons.refresh, size: 16),
label: const Text('Vider le Cache'),
@@ -498,7 +510,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
),
),
const SizedBox(height: 24),
// Logout button
FilledButton.icon(
onPressed: () {
@@ -553,7 +565,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
controller: controller,
decoration: InputDecoration(
hintText: 'Ex: FR,FRANCE,HD,SPORT',
hintStyle: GoogleFonts.roboto(fontSize: 12, color: Colors.white30),
hintStyle:
GoogleFonts.roboto(fontSize: 12, color: Colors.white30),
filled: true,
fillColor: Colors.black26,
border: OutlineInputBorder(
@@ -567,7 +580,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
),
suffixIcon: controller.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 16, color: Colors.white54),
icon: const Icon(Icons.clear,
size: 16, color: Colors.white54),
onPressed: onClear,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
@@ -584,15 +598,18 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
runSpacing: 6,
children: keywords.map((keyword) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.blueAccent.withOpacity(0.2),
borderRadius: BorderRadius.circular(50),
border: Border.all(color: Colors.blueAccent.withOpacity(0.5)),
border:
Border.all(color: Colors.blueAccent.withOpacity(0.5)),
),
child: Text(
keyword,
style: GoogleFonts.roboto(fontSize: 11, color: Colors.white),
style:
GoogleFonts.roboto(fontSize: 11, color: Colors.white),
),
);
}).toList(),
@@ -107,7 +107,7 @@ class StreamingSettingsTab extends ConsumerWidget {
style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54),
),
value: settings.autoReconnect,
activeColor: Colors.blueAccent,
activeThumbColor: Colors.blueAccent,
onChanged: (v) => notifier.setAutoReconnect(v),
),
],
@@ -143,7 +143,7 @@ class StreamingSettingsTab extends ConsumerWidget {
style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54),
),
value: settings.preferDirectPlay,
activeColor: Colors.blueAccent,
activeThumbColor: Colors.blueAccent,
onChanged: (v) => notifier.setPreferDirectPlay(v),
),
],
@@ -248,8 +248,10 @@ class StreamingSettingsTab extends ConsumerWidget {
required String Function(T) labelBuilder,
}) {
return ListTile(
title: Text(title, style: GoogleFonts.roboto(fontSize: 14, color: Colors.white)),
subtitle: Text(subtitle, style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54)),
title: Text(title,
style: GoogleFonts.roboto(fontSize: 14, color: Colors.white)),
subtitle: Text(subtitle,
style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54)),
trailing: DropdownButton<T>(
value: value,
underline: const SizedBox(),