Light theme default + yt-dlp search fallback

This commit is contained in:
iliasmouhcine
2026-08-22 13:02:32 +01:00
commit 7704cae4a0
282 changed files with 52324 additions and 0 deletions
+354
View File
@@ -0,0 +1,354 @@
import 'package:flutter/material.dart';
import '../services/memory_backup_service.dart';
class MemoryTransferSettings extends StatefulWidget {
const MemoryTransferSettings({super.key});
@override
State<MemoryTransferSettings> createState() => _MemoryTransferSettingsState();
}
class _MemoryTransferSettingsState extends State<MemoryTransferSettings> {
final MemoryBackupService _memory = MemoryBackupService.instance;
bool _busy = false;
int? _entryCount;
@override
void initState() {
super.initState();
_refreshCount();
}
Future<void> _refreshCount() async {
try {
final count = await _memory.memoryEntryCount();
if (!mounted) return;
setState(() => _entryCount = count);
} catch (_) {
// Le compteur est informatif. Une erreur ici ne bloque pas les actions.
}
}
Future<void> _export() async {
if (_busy) return;
setState(() => _busy = true);
try {
final result = await _memory.exportMemory();
if (!mounted || result == null) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Mémoire exportée : ${result.entryCount} bloc(s).'),
),
);
} catch (error) {
if (!mounted) return;
_showError('Export impossible', error);
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _import() async {
if (_busy) return;
setState(() => _busy = true);
try {
final candidate = await _memory.pickMemoryBackup();
if (!mounted || candidate == null) return;
final mode = await _chooseImportMode(candidate);
if (!mounted || mode == null) return;
if (mode == MemoryImportMode.replace) {
final confirmed = await _confirmReplace();
if (!mounted || !confirmed) return;
}
final result = await _memory.importMemory(candidate, mode: mode);
if (!mounted) return;
await _refreshCount();
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
icon: const Icon(Icons.check_circle_outline),
title: const Text('Mémoire importée'),
content: Text(
'${result.importedCount} bloc(s) de mémoire ont été importés.\n\n'
'Ferme complètement puis rouvre YouTube Personal pour que '
'l’historique, les favoris et le Radar rechargent la nouvelle mémoire.',
),
actions: [
FilledButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
} catch (error) {
if (!mounted) return;
_showError('Import impossible', error);
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<MemoryImportMode?> _chooseImportMode(MemoryImportCandidate candidate) {
final exported = candidate.exportedAtUtc == null
? 'Date inconnue'
: _formatDate(candidate.exportedAtUtc!.toLocal());
final source = _platformLabel(candidate.sourcePlatform);
return showDialog<MemoryImportMode>(
context: context,
builder: (context) => AlertDialog(
icon: const Icon(Icons.memory_rounded),
title: const Text('Importer cette mémoire ?'),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Source : $source'),
const SizedBox(height: 4),
Text('Export : $exported'),
const SizedBox(height: 4),
Text('${candidate.entryCount} bloc(s) de mémoire'),
const SizedBox(height: 18),
const Text(
'Fusionner garde les blocs locaux absents de la sauvegarde. '
'Pour les blocs présents des deux côtés, la sauvegarde importée '
'devient la référence.',
),
const SizedBox(height: 10),
const Text(
'Remplacer efface d’abord la mémoire comportementale locale, '
'puis restaure exactement cette sauvegarde.',
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
OutlinedButton.icon(
onPressed: () => Navigator.of(context).pop(MemoryImportMode.merge),
icon: const Icon(Icons.merge_type_rounded),
label: const Text('Fusionner'),
),
FilledButton.icon(
onPressed: () =>
Navigator.of(context).pop(MemoryImportMode.replace),
icon: const Icon(Icons.restore_rounded),
label: const Text('Remplacer'),
),
],
),
);
}
Future<bool> _confirmReplace() async {
return await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
icon: const Icon(Icons.warning_amber_rounded),
title: const Text('Remplacer la mémoire locale ?'),
content: const Text(
'L’historique, les favoris, les reprises, les compteurs, les '
'chaînes masquées et l’apprentissage du Radar présents sur cet '
'appareil seront remplacés par la sauvegarde sélectionnée.\n\n'
'La connexion Google et les données OAuth ne sont pas touchées.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Oui, remplacer'),
),
],
),
) ??
false;
}
void _showError(String title, Object error) {
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
icon: const Icon(Icons.error_outline),
title: Text(title),
content: Text('$error'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
],
),
);
}
String _formatDate(DateTime value) {
String two(int number) => number.toString().padLeft(2, '0');
return '${two(value.day)}/${two(value.month)}/${value.year} '
'${two(value.hour)}:${two(value.minute)}';
}
String _platformLabel(String platform) {
switch (platform.toLowerCase()) {
case 'windows':
return 'Windows';
case 'android':
return 'Android';
case 'linux':
return 'Linux';
case 'macos':
return 'macOS';
default:
return platform;
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: colors.surfaceContainerLow,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: colors.outlineVariant.withValues(alpha: 0.55),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: colors.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.sync_alt_rounded,
color: colors.onPrimaryContainer,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Mémoire & transfert',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 3),
Text(
_entryCount == null
? 'Mémoire locale de ton profil utilisateur'
: '$_entryCount bloc(s) de mémoire transférable(s)',
style: TextStyle(
color: colors.onSurfaceVariant,
fontSize: 12.5,
),
),
],
),
),
if (_busy)
const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2.4),
),
],
),
const SizedBox(height: 14),
Text(
'Transfère l’apprentissage de YouTube Personal entre Windows, '
'Android et plus tard Linux : historique, favoris, reprises, '
'habitudes du Radar, catégories et chaînes masquées.',
style: TextStyle(
color: colors.onSurfaceVariant,
height: 1.45,
fontSize: 12.5,
),
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.lock_outline_rounded,
size: 17,
color: colors.onSurfaceVariant,
),
const SizedBox(width: 7),
Expanded(
child: Text(
'La connexion Google, les tokens OAuth, les secrets et les '
'chemins de cache ne sont jamais inclus.',
style: TextStyle(
color: colors.onSurfaceVariant,
height: 1.4,
fontSize: 12,
),
),
),
],
),
const SizedBox(height: 16),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
FilledButton.icon(
onPressed: _busy ? null : _export,
icon: const Icon(Icons.upload_file_rounded),
label: const Text('Exporter la mémoire'),
),
OutlinedButton.icon(
onPressed: _busy ? null : _import,
icon: const Icon(Icons.download_rounded),
label: const Text('Importer une mémoire'),
),
],
),
const SizedBox(height: 12),
Text(
'Format versionné V1, prévu pour servir de base à une future '
'synchronisation automatique multi-appareils.',
style: TextStyle(
color: colors.onSurfaceVariant.withValues(alpha: 0.76),
fontSize: 11.5,
fontStyle: FontStyle.italic,
),
),
],
),
);
}
}
+236
View File
@@ -0,0 +1,236 @@
import 'package:flutter/material.dart';
import '../models/video_item.dart';
class VideoResultCard extends StatelessWidget {
const VideoResultCard({
super.key,
required this.video,
required this.onTap,
this.trailing,
});
final VideoItem video;
final VoidCallback onTap;
final Widget? trailing;
@override
Widget build(BuildContext context) {
final isWide = MediaQuery.sizeOf(context).width >= 700;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: EdgeInsets.all(8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: isWide ? _buildWide(context) : _buildCompact(context)),
if (trailing != null) trailing!,
],
),
),
),
);
}
Widget _buildWide(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 300,
child: _Thumbnail(video: video),
),
SizedBox(width: 18),
Expanded(
child: Padding(
padding: EdgeInsets.only(top: 4, right: 12),
child: _VideoText(video: video),
),
),
],
);
}
Widget _buildCompact(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Thumbnail(video: video),
SizedBox(height: 10),
Padding(
padding: EdgeInsets.symmetric(horizontal: 2),
child: _VideoText(video: video),
),
],
);
}
}
class _Thumbnail extends StatelessWidget {
const _Thumbnail({required this.video});
final VideoItem video;
@override
Widget build(BuildContext context) {
return AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Stack(
fit: StackFit.expand,
children: [
ColoredBox(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Image.network(
video.thumbnailUrl,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Center(
child: Icon(
Icons.broken_image_outlined,
size: 42,
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62),
),
);
},
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) {
return child;
}
return Center(
child: CircularProgressIndicator(strokeWidth: 2),
);
},
),
),
if (video.durationLabel.isNotEmpty)
Positioned(
right: 7,
bottom: 7,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(5),
),
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 6,
vertical: 3,
),
child: Text(
video.durationLabel,
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
),
),
],
),
),
);
}
}
class _VideoText extends StatelessWidget {
const _VideoText({required this.video});
final VideoItem video;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
video.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 16,
height: 1.25,
fontWeight: FontWeight.w600,
),
),
SizedBox(height: 7),
Row(
children: [
Icon(
Icons.account_circle_outlined,
size: 18,
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86),
),
SizedBox(width: 6),
Expanded(
child: Text(
video.author,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86),
fontSize: 14,
),
),
),
],
),
if (video.viewCount != null ||
video.likeCount != null) ...[
SizedBox(height: 6),
Text(
[
if (video.viewCount != null)
'${_compact(video.viewCount!)} vues',
if (video.likeCount != null)
'${_compact(video.likeCount!)} likes',
].join(' • '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Color(0x73FFFFFF),
fontSize: 12,
),
),
],
if (video.description.trim().isNotEmpty) ...[
SizedBox(height: 6),
Text(
video.description
.replaceAll(RegExp(r'\s+'), ' ')
.trim(),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62),
fontSize: 12,
height: 1.3,
),
),
],
],
);
}
String _compact(int value) {
if (value >= 1000000000) {
return '${(value / 1000000000).toStringAsFixed(value >= 10000000000 ? 0 : 1)} Md';
}
if (value >= 1000000) {
return '${(value / 1000000).toStringAsFixed(value >= 10000000 ? 0 : 1)} M';
}
if (value >= 1000) {
return '${(value / 1000).toStringAsFixed(value >= 10000 ? 0 : 1)} k';
}
return '$value';
}
}