Light theme default + yt-dlp search fallback
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user