Files

537 lines
15 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)),
),
],
),
),
);
}
}