Files
xtremflow/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart
T
MichaelandClaude Opus 5 8b82b101aa fix(ui): remplacer les couleurs Material codées en dur par les jetons Warm Cinema
41 couleurs Material brutes traînaient encore dans l'interface, hors palette :
rouge, vert, orange et surtout un violet omniprésent sur les Season Pass, qui
n'appartient à aucune famille du thème.

Correspondances retenues :
- Colors.red / redAccent -> AppColors.live (#E5484D) pour les badges LIVE et
  les indicateurs d'enregistrement, AppColors.error pour les messages d'erreur
- Colors.green / greenAccent -> AppColors.success
- Colors.orangeAccent -> AppColors.warning
- Colors.amber -> AppColors.ratingGold, pour aligner les étoiles de notation
  du mobile sur celles du bureau
- Colors.purpleAccent -> famille secondary (terre cuite)
- fonds de boutons passés sur les variantes *Container avec leur couleur de
  premier plan, le remplissage clair ne portant pas de texte lisible

Le titre du programme EPG dans la grille Live TV utilisait ratingGold, un jeton
réservé aux notes : il passe sur primary, l'accent ember du thème.

Les fonds de SnackBar vert/rouge deviennent surfaceContainerHigh, le succès et
l'échec restant portés par le pictogramme du message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 10:16:38 +02:00

415 lines
16 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../../features/iptv/providers/xtream_provider.dart';
import '../../../../features/iptv/providers/settings_provider.dart';
import '../../../../features/iptv/providers/favorites_provider.dart';
import '../screens/mobile_player_screen.dart';
import '../../../../core/models/iptv_models.dart';
import '../../../../core/models/playlist_config.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/widgets/glass_container.dart';
class MobileLiveTVTab extends ConsumerStatefulWidget {
final PlaylistConfig playlist;
const MobileLiveTVTab({super.key, required this.playlist});
@override
ConsumerState<MobileLiveTVTab> createState() => _MobileLiveTVTabState();
}
class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
String? _selectedCategory;
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
bool _showFavoritesOnly = false;
@override
void initState() {
super.initState();
_searchController.addListener(() {
setState(() => _searchQuery = _searchController.text.toLowerCase());
});
}
@override
Widget build(BuildContext context) {
final channelsAsync =
ref.watch(liveChannelsByPlaylistProvider(widget.playlist));
final favorites = ref.watch(favoritesProvider);
final settings = ref.watch(iptvSettingsProvider);
return Scaffold(
backgroundColor: Colors.transparent, // Handled by MobileScaffold
body: channelsAsync.when(
loading: () => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
const Text(
'Loading channels...',
style: TextStyle(color: AppColors.onSurfaceVariant),
),
const SizedBox(height: 24),
FutureBuilder(
future: Future.delayed(const Duration(seconds: 8)),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return TextButton(
onPressed: () => ref.refresh(
liveChannelsByPlaylistProvider(widget.playlist),
),
child: const Text('Taking too long? Tap to retry'),
);
}
return const SizedBox.shrink();
},
),
],
),
),
error: (e, s) => Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, color: AppColors.error, size: 48),
const SizedBox(height: 16),
Text(
'Failed to load channels',
style: GoogleFonts.karla(
color: AppColors.onSurface,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'$e',
style: const TextStyle(color: AppColors.onSurface54),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => ref
.refresh(liveChannelsByPlaylistProvider(widget.playlist)),
child: const Text('Retry'),
),
],
),
),
),
data: (groupedChannels) {
var categories = groupedChannels.keys.toList();
if (settings.liveTvKeywords.isNotEmpty) {
categories = categories
.where((cat) => settings.matchesLiveTvFilter(cat))
.toList();
}
// REMOVED alphabetic sort to respect server/settings order
List<Channel> displayedChannels = [];
if (_searchQuery.isNotEmpty) {
displayedChannels = groupedChannels.values
.expand((l) => l)
.where((c) => c.name.toLowerCase().contains(_searchQuery))
.toList();
} else if (_showFavoritesOnly) {
displayedChannels = groupedChannels.values
.expand((l) => l)
.where((c) => favorites.contains(c.streamId))
.toList();
} else {
if (_selectedCategory == null && categories.isNotEmpty) {
_selectedCategory = categories.first;
}
// If the selected category is no longer available (due to filters), reset it
if (_selectedCategory != null &&
!categories.contains(_selectedCategory)) {
_selectedCategory =
categories.isNotEmpty ? categories.first : null;
}
if (_selectedCategory != null) {
displayedChannels = groupedChannels[_selectedCategory] ?? [];
}
}
return SafeArea(
bottom: false,
child: Column(
children: [
// Header & Search
Padding(
padding: const EdgeInsets.all(16),
child: GlassContainer.glass(
borderRadius: 16,
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
style: GoogleFonts.karla(color: AppColors.onSurface),
decoration: InputDecoration(
hintText: 'Search channels...',
hintStyle:
GoogleFonts.karla(color: AppColors.onSurface54),
border: InputBorder.none,
prefixIcon: const Icon(
Icons.search,
color: AppColors.onSurface54,
),
isDense: true,
),
),
),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () => _searchController.clear(),
child:
const Icon(Icons.close, color: AppColors.onSurface54),
),
],
),
),
),
// Categories
if (_searchQuery.isEmpty && !_showFavoritesOnly)
SizedBox(
height: 40,
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16),
scrollDirection: Axis.horizontal,
itemCount: categories.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final category = categories[index];
final isSelected = category == _selectedCategory;
return GestureDetector(
onTap: () =>
setState(() => _selectedCategory = category),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
decoration: BoxDecoration(
color: isSelected
? AppColors.onSurface
: Colors.black.withOpacity(0.4),
borderRadius: BorderRadius.circular(20),
),
child: Text(
category,
style: GoogleFonts.karla(
color:
isSelected ? Colors.black : AppColors.onSurfaceVariant,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.w500,
fontSize: 13,
),
),
),
);
},
),
),
// Sub-header
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
Text(
_searchQuery.isNotEmpty
? 'Results'
: _showFavoritesOnly
? 'Favorites'
: _selectedCategory ?? 'Channels',
style: GoogleFonts.karla(
color: AppColors.onSurface,
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
const Spacer(),
GestureDetector(
onTap: () => setState(
() => _showFavoritesOnly = !_showFavoritesOnly,
),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _showFavoritesOnly
? AppColors.live.withOpacity(0.2)
: AppColors.onSurface.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: _showFavoritesOnly
? Border.all(color: AppColors.live)
: null,
),
child: Icon(
_showFavoritesOnly
? Icons.favorite
: Icons.favorite_border,
color: _showFavoritesOnly
? AppColors.live
: AppColors.onSurface,
size: 20,
),
),
),
],
),
),
// List
Expanded(
child: displayedChannels.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.tv_off,
color: AppColors.onSurface24,
size: 48,
),
const SizedBox(height: 16),
Text(
'No channels found',
style: GoogleFonts.karla(color: AppColors.onSurface38),
),
],
),
)
: ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 100),
itemCount: displayedChannels.length,
separatorBuilder: (_, __) =>
const SizedBox(height: 8),
itemBuilder: (context, index) {
final channel = displayedChannels[index];
return _MobileChannelTile(
channel: channel,
onTap: () => _playChannel(
context,
channel,
displayedChannels,
),
);
},
),
),
],
),
);
},
),
);
}
void _playChannel(
BuildContext context,
Channel channel,
List<Channel> channels,
) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => MobilePlayerScreen(
streamId: channel.streamId,
title: channel.name,
playlist: widget.playlist,
streamType: StreamType.live,
channels: channels,
),
),
);
}
}
class _MobileChannelTile extends StatelessWidget {
final Channel channel;
final VoidCallback onTap;
const _MobileChannelTile({
required this.channel,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final iconUrl =
channel.streamIcon.isNotEmpty && channel.streamIcon.startsWith('http')
? '/api/xtream/${channel.streamIcon}'
: null;
return GlassContainer.glass(
borderRadius: 12,
padding: EdgeInsets.zero,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: AppColors.onSurface.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.all(4),
child: iconUrl != null
? Image.network(
iconUrl,
fit: BoxFit.contain,
cacheWidth: 120,
filterQuality: FilterQuality.low,
errorBuilder: (_, __, ___) =>
const Icon(Icons.tv, color: AppColors.onSurface24),
)
: const Icon(Icons.tv, color: AppColors.onSurface24),
),
const SizedBox(width: 16),
Expanded(
child: Text(
channel.name,
style: GoogleFonts.karla(
color: AppColors.onSurface,
fontWeight: FontWeight.w500,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.onSurface.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(
Icons.play_arrow_rounded,
color: AppColors.onSurface,
size: 16,
),
),
],
),
),
),
);
}
}