537 lines
15 KiB
Dart
537 lines
15 KiB
Dart
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<ChannelsPage> createState() => _ChannelsPageState();
|
||
}
|
||
|
||
class _ChannelsPageState extends State<ChannelsPage> {
|
||
List<ChannelRankItem> _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<void> _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<ChannelRankItem> 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<void> _open(ChannelRankItem item) async {
|
||
await Navigator.of(context).push(
|
||
MaterialPageRoute<void>(
|
||
builder: (_) => ChannelVideosPage(channel: item.channel),
|
||
),
|
||
);
|
||
|
||
if (mounted) {
|
||
await _load(force: true, quiet: true);
|
||
}
|
||
}
|
||
|
||
Future<void> _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<void>.delayed(Duration(seconds: 3)).then((_) {
|
||
if (!mounted) {
|
||
return;
|
||
}
|
||
controller.close();
|
||
}),
|
||
);
|
||
}
|
||
|
||
Future<void> _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<String> 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<String>(
|
||
tooltip: 'Options',
|
||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||
onSelected: (value) {
|
||
if (value == 'hide') {
|
||
onHide();
|
||
}
|
||
},
|
||
itemBuilder: (_) => [
|
||
PopupMenuItem<String>(
|
||
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)),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|