import 'dart:async'; import 'package:flutter/material.dart'; import '../models/channel_rank_item.dart'; import '../services/channel_catalog_service.dart'; import '../services/hidden_channels_service.dart'; import '../services/library_service.dart'; import 'channel_videos_page.dart'; class ChannelsPage extends StatefulWidget { const ChannelsPage({super.key}); @override State createState() => _ChannelsPageState(); } class _ChannelsPageState extends State { List _channels = []; bool _loading = true; String? _error; String _filter = ''; Timer? _refreshDebounce; @override void initState() { super.initState(); LibraryService.instance.watchRevision.addListener(_onWatchChanged); HiddenChannelsService.instance.revision.addListener(_onHiddenChanged); _load(); } @override void dispose() { _refreshDebounce?.cancel(); LibraryService.instance.watchRevision.removeListener(_onWatchChanged); HiddenChannelsService.instance.revision.removeListener(_onHiddenChanged); super.dispose(); } void _onWatchChanged() { _scheduleRefresh(); } void _onHiddenChanged() { _scheduleRefresh(); } void _scheduleRefresh() { _refreshDebounce?.cancel(); _refreshDebounce = Timer( Duration(milliseconds: 300), () { if (mounted) { unawaited(_load(force: true, quiet: true)); } }, ); } Future _load({ bool force = false, bool quiet = false, }) async { if (!quiet) { setState(() { _loading = true; _error = null; }); } try { final channels = await ChannelCatalogService.instance.load(force: force); if (!mounted) { return; } setState(() { _channels = channels; _loading = false; _error = null; }); } catch (error) { if (!mounted) { return; } setState(() { _loading = false; _error = error.toString(); }); } } List get _filteredChannels { final query = _filter.trim().toLowerCase(); if (query.isEmpty) { return _channels; } return _channels .where( (item) => item.channel.title.toLowerCase().contains(query) || (item.topVideoTitle ?? '').toLowerCase().contains(query), ) .toList(growable: false); } Future _open(ChannelRankItem item) async { await Navigator.of(context).push( MaterialPageRoute( builder: (_) => ChannelVideosPage(channel: item.channel), ), ); if (mounted) { await _load(force: true, quiet: true); } } Future _hide(ChannelRankItem item) async { await HiddenChannelsService.instance.hide(item.channel); if (!mounted) { return; } setState(() { _channels = _channels .where( (candidate) => candidate.channel.channelId != item.channel.channelId, ) .toList(growable: false); }); final messenger = ScaffoldMessenger.of(context); messenger.hideCurrentSnackBar(); final controller = messenger.showSnackBar( SnackBar( duration: Duration(seconds: 3), content: Text( '${item.channel.title} ne sera plus proposée.', ), action: SnackBarAction( label: 'ANNULER', onPressed: () { unawaited(_restore(item)); }, ), ), ); // Sur certaines configurations desktop/accessibilité, un SnackBar avec // action peut rester visible au-delà de sa durée. On impose donc aussi // sa fermeture côté application. unawaited( Future.delayed(Duration(seconds: 3)).then((_) { if (!mounted) { return; } controller.close(); }), ); } Future _restore(ChannelRankItem item) async { await HiddenChannelsService.instance.restore(item.channel); if (mounted) { await _load(force: true, quiet: true); } } @override Widget build(BuildContext context) { final channels = _filteredChannels; final canGoBack = Navigator.of(context).canPop(); return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: SafeArea( child: RefreshIndicator( onRefresh: () => _load(force: true), child: CustomScrollView( physics: AlwaysScrollableScrollPhysics(), slivers: [ SliverToBoxAdapter( child: _ChannelsHeader( count: _channels.length, loading: _loading, canGoBack: canGoBack, onRefresh: () => _load(force: true), onFilterChanged: (value) { setState(() { _filter = value; }); }, ), ), if (_loading && _channels.isEmpty) SliverFillRemaining( child: Center(child: CircularProgressIndicator()), ) else if (_error != null && _channels.isEmpty) SliverFillRemaining( hasScrollBody: false, child: _ChannelsError( message: _error!, onRetry: _load, ), ) else if (channels.isEmpty) SliverFillRemaining( hasScrollBody: false, child: _EmptyChannels(), ) else SliverPadding( padding: EdgeInsets.fromLTRB(18, 4, 18, 28), sliver: SliverList.separated( itemCount: channels.length, separatorBuilder: (_, __) => SizedBox(height: 8), itemBuilder: (context, index) { final item = channels[index]; return _ChannelRankCard( rank: index + 1, item: item, onTap: () => _open(item), onHide: () => _hide(item), ); }, ), ), ], ), ), ), ); } } class _ChannelsHeader extends StatelessWidget { const _ChannelsHeader({ required this.count, required this.loading, required this.canGoBack, required this.onRefresh, required this.onFilterChanged, }); final int count; final bool loading; final bool canGoBack; final VoidCallback onRefresh; final ValueChanged onFilterChanged; @override Widget build(BuildContext context) { final wide = MediaQuery.sizeOf(context).width >= 800; return Padding( padding: EdgeInsets.fromLTRB( wide ? 30 : 18, 24, wide ? 30 : 18, 14, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ if (canGoBack) ...[ IconButton( tooltip: 'Retour', onPressed: () => Navigator.of(context).pop(), icon: Icon(Icons.arrow_back), ), SizedBox(width: 2), ], Icon( Icons.video_library_outlined, size: 31, color: Color(0xFFFF4E45), ), SizedBox(width: 10), Expanded( child: Text( 'Chaînes', style: TextStyle( fontSize: 25, fontWeight: FontWeight.w700, ), ), ), IconButton( tooltip: 'Actualiser', onPressed: loading ? null : onRefresh, icon: Icon(Icons.refresh), ), ], ), SizedBox(height: 7), Text( count == 0 ? 'Les chaînes apparaissent à partir de ce que tu regardes réellement.' : '$count chaîne${count > 1 ? 's' : ''} • triées selon le nombre de tes propres vues', style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), fontSize: 13, ), ), SizedBox(height: 16), TextField( onChanged: onFilterChanged, decoration: InputDecoration( hintText: 'Rechercher une chaîne', prefixIcon: Icon(Icons.search), filled: true, fillColor: Theme.of(context).colorScheme.surfaceContainerLow, border: OutlineInputBorder( borderRadius: BorderRadius.circular(14), borderSide: BorderSide.none, ), ), ), ], ), ); } } class _ChannelRankCard extends StatelessWidget { const _ChannelRankCard({ required this.rank, required this.item, required this.onTap, required this.onHide, }); final int rank; final ChannelRankItem item; final VoidCallback onTap; final VoidCallback onHide; @override Widget build(BuildContext context) { final channel = item.channel; return Material( color: Theme.of(context).colorScheme.surfaceContainerLow, borderRadius: BorderRadius.circular(15), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(15), child: Padding( padding: EdgeInsets.all(13), child: Row( children: [ SizedBox( width: 32, child: Text( '#$rank', style: TextStyle( color: Color(0xFFFF766F), fontSize: 14, fontWeight: FontWeight.w800, ), ), ), CircleAvatar( radius: 27, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, backgroundImage: channel.thumbnailUrl.isNotEmpty ? NetworkImage(channel.thumbnailUrl) : null, child: channel.thumbnailUrl.isEmpty ? Icon(Icons.account_circle, size: 31) : null, ), SizedBox(width: 13), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( channel.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 16, fontWeight: FontWeight.w700, ), ), SizedBox(height: 5), Text( '${item.personalViewCount} de mes vue${item.personalViewCount > 1 ? 's' : ''}' ' • ${item.distinctVideoCount} vidéo${item.distinctVideoCount > 1 ? 's' : ''} différente${item.distinctVideoCount > 1 ? 's' : ''}', style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86), fontSize: 12, ), ), if (item.topVideoTitle != null) ...[ SizedBox(height: 3), Text( 'La plus regardée par moi (${item.topVideoPersonalViews}×) : ${item.topVideoTitle}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), fontSize: 11, ), ), ], ], ), ), PopupMenuButton( tooltip: 'Options', color: Theme.of(context).colorScheme.surfaceContainerHighest, onSelected: (value) { if (value == 'hide') { onHide(); } }, itemBuilder: (_) => [ PopupMenuItem( value: 'hide', child: Row( children: [ Icon(Icons.visibility_off_outlined), SizedBox(width: 10), Text('Cette chaîne ne m’intéresse pas'), ], ), ), ], icon: Icon( Icons.more_vert, color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), ), ), ], ), ), ), ); } } class _ChannelsError extends StatelessWidget { const _ChannelsError({ required this.message, required this.onRetry, }); final String message; final VoidCallback onRetry; @override Widget build(BuildContext context) { return Center( child: Padding( padding: EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.error_outline, size: 52, color: Colors.redAccent, ), SizedBox(height: 12), Text( message, textAlign: TextAlign.center, style: TextStyle(color: Colors.redAccent), ), SizedBox(height: 16), FilledButton.icon( onPressed: onRetry, icon: Icon(Icons.refresh), label: Text('Réessayer'), ), ], ), ), ); } } class _EmptyChannels extends StatelessWidget { const _EmptyChannels(); @override Widget build(BuildContext context) { return Center( child: Padding( padding: EdgeInsets.all(28), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.video_library_outlined, size: 68, color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.44), ), SizedBox(height: 16), Text( 'Pas encore assez de chaînes', style: TextStyle( fontSize: 20, fontWeight: FontWeight.w600, ), ), SizedBox(height: 7), Text( 'Regarde quelques vidéos. Le classement sera construit à partir de tes propres vues.', textAlign: TextAlign.center, style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78)), ), ], ), ), ); } }