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
File diff suppressed because it is too large Load Diff
+536
View File
@@ -0,0 +1,536 @@
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)),
),
],
),
),
);
}
}
File diff suppressed because it is too large Load Diff
+490
View File
@@ -0,0 +1,490 @@
import 'package:flutter/material.dart';
import '../models/video_item.dart';
import '../services/library_service.dart';
import '../widgets/video_result_card.dart';
import 'channels_page.dart';
import 'video_player_page.dart';
class LibraryHubPage extends StatelessWidget {
const LibraryHubPage({super.key});
@override
Widget build(BuildContext context) {
return SafeArea(
child: ValueListenableBuilder<List<VideoItem>>(
valueListenable: LibraryService.instance.history,
builder: (context, history, _) {
return ValueListenableBuilder<List<VideoItem>>(
valueListenable: LibraryService.instance.favorites,
builder: (context, favorites, __) {
final recentHistory = history.take(3).toList(growable: false);
final recentFavorites =
favorites.take(3).toList(growable: false);
return ListView(
padding: EdgeInsets.fromLTRB(18, 20, 18, 28),
children: [
Text(
'Bibliothèque',
style:
Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
SizedBox(height: 6),
Text(
'Retrouve rapidement ton historique, tes favoris et tes chaînes.',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78),
fontSize: 13,
),
),
SizedBox(height: 18),
LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final crossAxisCount = width >= 1100
? 3
: width >= 720
? 2
: 1;
final mainAxisExtent = width >= 1100
? 220.0
: width >= 720
? 210.0
: 196.0;
return GridView.count(
crossAxisCount: crossAxisCount,
shrinkWrap: true,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
mainAxisExtent: mainAxisExtent,
physics: NeverScrollableScrollPhysics(),
children: [
_LibraryHubCard(
icon: Icons.history,
title: 'Historique',
value: '${history.length}',
subtitle: history.isEmpty
? 'Aucune vidéo lue pour le moment.'
: 'Tes vidéos récemment ouvertes.',
onTap: () => _openPage(
context,
HistoryPage(),
),
),
_LibraryHubCard(
icon: Icons.favorite,
title: 'Favoris',
value: '${favorites.length}',
subtitle: favorites.isEmpty
? 'Aucune vidéo en favori.'
: 'Tes vidéos enregistrées pour plus tard.',
onTap: () => _openPage(
context,
FavoritesPage(),
),
),
_LibraryHubCard(
icon: Icons.video_library,
title: 'Chaînes',
value: 'Explorer',
subtitle:
'Accède à tes chaînes personnelles et au catalogue.',
onTap: () => _openPage(
context,
ChannelsPage(),
),
),
],
);
},
),
SizedBox(height: 24),
if (recentHistory.isNotEmpty) ...[
_SectionHeader(
title: 'Reprendre vite',
actionLabel: 'Voir tout',
onTap: () => _openPage(context, HistoryPage()),
),
SizedBox(height: 8),
...recentHistory.map(
(video) => Padding(
padding: EdgeInsets.only(bottom: 6),
child: VideoResultCard(
video: video,
onTap: () => _openVideo(context, video),
),
),
),
SizedBox(height: 16),
],
if (recentFavorites.isNotEmpty) ...[
_SectionHeader(
title: 'Favoris récents',
actionLabel: 'Voir tout',
onTap: () => _openPage(context, FavoritesPage()),
),
SizedBox(height: 8),
...recentFavorites.map(
(video) => Padding(
padding: EdgeInsets.only(bottom: 6),
child: VideoResultCard(
video: video,
onTap: () => _openVideo(context, video),
),
),
),
],
],
);
},
);
},
),
);
}
void _openPage(BuildContext context, Widget page) {
Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => page),
);
}
void _openVideo(BuildContext context, VideoItem video) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => VideoPlayerPage(video: video),
),
);
}
}
class HistoryPage extends StatelessWidget {
const HistoryPage({super.key});
@override
Widget build(BuildContext context) {
return _LibraryPage(
title: 'Historique',
emptyIcon: Icons.history,
emptyTitle: 'Aucun historique',
emptySubtitle:
'Les vidéos que tu regardes apparaîtront ici automatiquement.',
listenable: LibraryService.instance.history,
onClear: LibraryService.instance.clearHistory,
);
}
}
class FavoritesPage extends StatelessWidget {
const FavoritesPage({super.key});
@override
Widget build(BuildContext context) {
return _LibraryPage(
title: 'Favoris',
emptyIcon: Icons.favorite_border,
emptyTitle: 'Aucun favori',
emptySubtitle: 'Ajoute des vidéos aux favoris depuis le lecteur.',
listenable: LibraryService.instance.favorites,
onClear: LibraryService.instance.clearFavorites,
);
}
}
class _LibraryHubCard extends StatelessWidget {
const _LibraryHubCard({
required this.icon,
required this.title,
required this.value,
required this.subtitle,
required this.onTap,
});
final IconData icon;
final String title;
final String value;
final String subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(22),
child: InkWell(
borderRadius: BorderRadius.circular(22),
onTap: onTap,
child: Ink(
padding: EdgeInsets.all(18),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(22),
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: Color(0x22FF6B63),
borderRadius: BorderRadius.circular(14),
),
child: Icon(icon, color: Color(0xFFFF7A73)),
),
SizedBox(height: 16),
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 4),
Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w800,
),
),
SizedBox(height: 10),
Expanded(
child: Align(
alignment: Alignment.topLeft,
child: Text(
subtitle,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78),
fontSize: 12.5,
height: 1.35,
),
),
),
),
],
),
),
),
);
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader({
required this.title,
required this.actionLabel,
required this.onTap,
});
final String title;
final String actionLabel;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Text(
title,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
),
TextButton(onPressed: onTap, child: Text(actionLabel)),
],
);
}
}
class _LibraryPage extends StatelessWidget {
const _LibraryPage({
required this.title,
required this.emptyIcon,
required this.emptyTitle,
required this.emptySubtitle,
required this.listenable,
required this.onClear,
});
final String title;
final IconData emptyIcon;
final String emptyTitle;
final String emptySubtitle;
final ValueNotifier<List<VideoItem>> listenable;
final Future<void> Function() onClear;
@override
Widget build(BuildContext context) {
return SafeArea(
child: ValueListenableBuilder<List<VideoItem>>(
valueListenable: listenable,
builder: (context, videos, child) {
final canGoBack = Navigator.of(context).canPop();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.fromLTRB(20, 18, 14, 10),
child: Row(
children: [
if (canGoBack) ...[
IconButton(
tooltip: 'Retour',
onPressed: () => Navigator.of(context).pop(),
icon: Icon(Icons.arrow_back),
),
SizedBox(width: 4),
],
Expanded(
child: Text(
title,
style: Theme.of(context).textTheme.headlineMedium,
),
),
if (videos.isNotEmpty)
IconButton(
tooltip: 'Tout effacer',
onPressed: () => _confirmClear(context),
icon: Icon(Icons.delete_outline),
),
],
),
),
Expanded(
child: videos.isEmpty
? _EmptyLibrary(
icon: emptyIcon,
title: emptyTitle,
subtitle: emptySubtitle,
)
: ListView.separated(
padding: EdgeInsets.fromLTRB(12, 4, 12, 24),
itemCount: videos.length,
separatorBuilder: (context, index) =>
SizedBox(height: 4),
itemBuilder: (context, index) {
final video = videos[index];
return VideoResultCard(
video: video,
onTap: () => _openVideo(context, video),
trailing: listenable == LibraryService.instance.history
? IconButton(
tooltip: "Supprimer de l'historique",
icon: Icon(Icons.remove_circle_outline),
onPressed: () => LibraryService.instance.removeFromHistory(video.id),
)
: null,
);
},
),
),
],
);
},
),
);
}
void _openVideo(
BuildContext context,
VideoItem video,
) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => VideoPlayerPage(video: video),
),
);
}
Future<void> _confirmClear(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text('Effacer $title ?'),
content: Text(
'Cette action supprimera cette liste uniquement sur cet appareil.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text('Effacer'),
),
],
);
},
);
if (confirmed == true) {
await onClear();
}
}
}
class _EmptyLibrary extends StatelessWidget {
const _EmptyLibrary({
required this.icon,
required this.title,
required this.subtitle,
});
final IconData icon;
final String title;
final String subtitle;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: EdgeInsets.all(28),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.44),
),
SizedBox(height: 16),
Text(
title,
style: TextStyle(
fontSize: 21,
fontWeight: FontWeight.w600,
),
),
SizedBox(height: 8),
Text(
subtitle,
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86),
fontSize: 15,
),
),
],
),
),
);
}
}
+370
View File
@@ -0,0 +1,370 @@
import 'package:flutter/material.dart';
import '../models/video_item.dart';
import '../services/app_settings_service.dart';
import '../services/preferred_language_service.dart';
import '../services/youtube_service.dart';
import '../widgets/video_result_card.dart';
import 'video_player_page.dart';
class SearchPage extends StatefulWidget {
const SearchPage({super.key});
@override
State<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
final TextEditingController _controller = TextEditingController();
final YoutubeService _youtubeService = YoutubeService();
List<VideoItem> _results = [];
bool _loading = false;
String? _error;
String _lastQuery = '';
Future<void> _search() async {
final query = _controller.text.trim();
if (query.isEmpty || _loading) {
return;
}
FocusScope.of(context).unfocus();
setState(() {
_loading = true;
_error = null;
_lastQuery = query;
});
try {
final results = await _youtubeService.searchVideos(query);
final preferredLanguage =
AppSettingsService.instance.preferredLanguage.value;
final rankedResults = PreferredLanguageService.rankVideos(
results,
preferredLanguage,
);
if (!mounted) {
return;
}
setState(() {
_results = rankedResults;
});
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_results = [];
_error =
'Impossible de récupérer les résultats YouTube.\n'
'Vérifie ta connexion puis réessaie.';
});
} finally {
if (mounted) {
setState(() {
_loading = false;
});
}
}
}
void _clear() {
_controller.clear();
setState(() {
_results = [];
_error = null;
_lastQuery = '';
});
}
void _openVideo(VideoItem video) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => VideoPlayerPage(video: video),
),
);
}
@override
void dispose() {
_controller.dispose();
_youtubeService.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: Column(
children: [
_SearchHeader(
controller: _controller,
loading: _loading,
onSearch: _search,
onClear: _clear,
),
Expanded(
child: _buildBody(),
),
],
),
);
}
Widget _buildBody() {
if (_loading && _results.isEmpty) {
return Center(
child: CircularProgressIndicator(),
);
}
if (_error != null) {
return _ErrorState(
message: _error!,
onRetry: _search,
);
}
if (_results.isEmpty) {
if (_lastQuery.isNotEmpty) {
return Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'Aucune vidéo trouvée pour « $_lastQuery ».',
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86),
fontSize: 16,
),
),
),
);
}
return _EmptySearchState();
}
return Column(
children: [
Padding(
padding: EdgeInsets.fromLTRB(20, 4, 20, 6),
child: Row(
children: [
Expanded(
child: Text(
'Résultats pour « $_lastQuery »',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
),
if (_loading)
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
),
),
Expanded(
child: ListView.separated(
padding: EdgeInsets.fromLTRB(12, 4, 12, 24),
itemCount: _results.length,
separatorBuilder: (context, index) => SizedBox(height: 4),
itemBuilder: (context, index) {
final video = _results[index];
return VideoResultCard(
video: video,
onTap: () => _openVideo(video),
);
},
),
),
],
);
}
}
class _SearchHeader extends StatelessWidget {
const _SearchHeader({
required this.controller,
required this.loading,
required this.onSearch,
required this.onClear,
});
final TextEditingController controller;
final bool loading;
final VoidCallback onSearch;
final VoidCallback onClear;
@override
Widget build(BuildContext context) {
final wide = MediaQuery.sizeOf(context).width >= 700;
return Padding(
padding: EdgeInsets.fromLTRB(
wide ? 28 : 16,
18,
wide ? 28 : 16,
14,
),
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 900),
child: Row(
children: [
Expanded(
child: TextField(
controller: controller,
textInputAction: TextInputAction.search,
onSubmitted: (_) => onSearch(),
decoration: InputDecoration(
hintText: 'Rechercher sur YouTube...',
prefixIcon: Icon(Icons.search),
suffixIcon: ValueListenableBuilder<TextEditingValue>(
valueListenable: controller,
builder: (context, value, child) {
if (value.text.isEmpty) {
return SizedBox.shrink();
}
return IconButton(
tooltip: 'Effacer',
onPressed: onClear,
icon: Icon(Icons.close),
);
},
),
filled: true,
fillColor: Theme.of(context).colorScheme.surfaceContainerHigh,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(30),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(30),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(30),
borderSide: BorderSide(
color: Color(0xFFFF4E45),
width: 1.5,
),
),
),
),
),
SizedBox(width: 10),
IconButton.filled(
tooltip: 'Rechercher',
onPressed: loading ? null : onSearch,
icon: Icon(Icons.search),
),
],
),
),
),
);
}
}
class _EmptySearchState extends StatelessWidget {
const _EmptySearchState();
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: EdgeInsets.all(28),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.youtube_searched_for,
size: 70,
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.44),
),
SizedBox(height: 18),
Text(
'Recherche YouTube',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w600,
),
),
SizedBox(height: 8),
Text(
'Saisis un titre, une chaîne ou un sujet.',
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86),
fontSize: 15,
),
),
],
),
),
);
}
}
class _ErrorState extends StatelessWidget {
const _ErrorState({
required this.message,
required this.onRetry,
});
final String message;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: EdgeInsets.all(28),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.cloud_off_outlined,
size: 62,
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62),
),
SizedBox(height: 16),
Text(
message,
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 15,
),
),
SizedBox(height: 18),
FilledButton.icon(
onPressed: onRetry,
icon: Icon(Icons.refresh),
label: Text('Réessayer'),
),
],
),
),
);
}
}
+899
View File
@@ -0,0 +1,899 @@
import 'dart:io';
import 'package:flutter/material.dart';
import '../services/app_settings_service.dart';
import '../services/media_cache_settings_service.dart';
import '../services/preferred_language_service.dart';
import '../widgets/memory_transfer_settings.dart';
class SettingsPage extends StatelessWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context) {
return SafeArea(
child: ListView(
padding: EdgeInsets.fromLTRB(20, 22, 20, 32),
children: [
Text(
'Paramètres',
style: TextStyle(fontSize: 28, fontWeight: FontWeight.w800),
),
SizedBox(height: 6),
Text(
'Personnalise le comportement de YouTube Personal.',
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.78),
fontSize: 13,
),
),
SizedBox(height: 24),
_SettingsSection(
title: 'Apparence',
icon: Icons.brightness_6_outlined,
child: ValueListenableBuilder<String>(
valueListenable: AppSettingsService.instance.appearanceMode,
builder: (context, appearanceMode, child) {
return _SettingsRow(
title: 'Thème de l’application',
subtitle:
'Système suit automatiquement le thème clair ou sombre configuré sur Windows ou Android.',
trailing: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: appearanceMode,
dropdownColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
items: AppSettingsService.supportedAppearanceModes
.map(
(mode) => DropdownMenuItem<String>(
value: mode,
child: Text(
AppSettingsService.appearanceModeLabel(mode),
),
),
)
.toList(growable: false),
onChanged: (value) {
if (value == null) {
return;
}
AppSettingsService.instance.setAppearanceMode(value);
},
),
),
);
},
),
),
SizedBox(height: 14),
_SettingsSection(
title: 'Lecture',
icon: Icons.play_circle_outline,
child: ValueListenableBuilder<int>(
valueListenable: AppSettingsService.instance.defaultResolution,
builder: (context, resolution, child) {
return _SettingsRow(
title: 'Résolution par défaut',
subtitle:
'Utilisée à l’ouverture d’une nouvelle vidéo. La qualité reste modifiable dans le lecteur.',
trailing: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: resolution,
dropdownColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
items: AppSettingsService.supportedResolutions
.map(
(value) => DropdownMenuItem<int>(
value: value,
child: Text(
AppSettingsService.resolutionLabel(value),
),
),
)
.toList(growable: false),
onChanged: (value) {
if (value == null) {
return;
}
AppSettingsService.instance.setDefaultResolution(value);
},
),
),
);
},
),
),
SizedBox(height: 14),
_SettingsSection(
title: 'Recommandations',
icon: Icons.language,
child: ValueListenableBuilder<String>(
valueListenable: AppSettingsService.instance.preferredLanguage,
builder: (context, language, child) {
return _SettingsRow(
title: 'Langue préférée des vidéos',
subtitle:
'Favorise cette langue dans le Radar et dans l’ordre des résultats de recherche, sans masquer les autres langues.',
trailing: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: language,
dropdownColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
items: PreferredLanguageService.options
.map(
(option) => DropdownMenuItem<String>(
value: option.code,
child: Text(option.label),
),
)
.toList(growable: false),
onChanged: (value) {
if (value == null) {
return;
}
AppSettingsService.instance.setPreferredLanguage(value);
},
),
),
);
},
),
),
SizedBox(height: 14),
_SettingsSection(
title: 'Filtre contenu très jeune',
icon: Icons.child_care,
child: ValueListenableBuilder<int>(
valueListenable:
AppSettingsService.instance.youngAudienceFilterLevel,
builder: (context, level, child) {
return _SettingsRow(
title: 'Chaînes / vidéos orientées très jeune',
subtitle:
'Modéré masque surtout les contenus explicitement enfants, bébés, jouets ou comptines. Strict élargit le filtrage aux chaînes très fortement orientées jeune public. Le filtre s’applique aussi à Continuer à regarder, Continuer ce sujet et aux autres recommandations automatiques. Il se base sur les métadonnées textuelles, pas sur l’apparence des personnes.',
trailing: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: level,
dropdownColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
items: AppSettingsService
.supportedYoungAudienceFilterLevels
.map(
(value) => DropdownMenuItem<int>(
value: value,
child: Text(
AppSettingsService.youngAudienceFilterLabel(
value,
),
),
),
)
.toList(growable: false),
onChanged: (value) {
if (value == null) {
return;
}
AppSettingsService.instance.setYoungAudienceFilterLevel(
value,
);
},
),
),
);
},
),
),
if (Platform.isWindows) ...[
SizedBox(height: 14),
_WindowsMediaCacheSettings(),
],
if (Platform.isAndroid) ...[
SizedBox(height: 14),
_AndroidMediaCacheSettings(),
SizedBox(height: 14),
_SettingsSection(
title: 'Écran Android',
icon: Icons.bedtime_outlined,
child: ValueListenableBuilder<int>(
valueListenable:
AppSettingsService.instance.pauseScreenSleepMinutes,
builder: (context, minutes, child) {
return _SettingsRow(
title: 'Mise en veille après pause',
subtitle:
'Pendant la lecture, l’écran reste allumé. À la fin de la vidéo, la mise en veille système redevient immédiatement possible. En pause, le maintien de l’écran est libéré après ce délai.',
trailing: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: minutes,
dropdownColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
items: AppSettingsService
.supportedPauseScreenSleepMinutes
.map(
(value) => DropdownMenuItem<int>(
value: value,
child: Text(
AppSettingsService.pauseScreenSleepLabel(
value,
),
),
),
)
.toList(growable: false),
onChanged: (value) {
if (value == null) {
return;
}
AppSettingsService.instance
.setPauseScreenSleepMinutes(value);
},
),
),
);
},
),
),
],
SizedBox(height: 14),
const MemoryTransferSettings(),
SizedBox(height: 14),
_SettingsSection(
title: 'Navigation',
icon: Icons.dashboard_outlined,
child: Text(
'La barre du bas mobile a été simplifiée à 5 onglets : Accueil, Recherche, Abos, Bibliothèque et Réglages. Historique, Favoris et Chaînes sont maintenant regroupés dans Bibliothèque pour un rendu plus lisible.',
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.78),
height: 1.45,
fontSize: 12.5,
),
),
),
],
),
);
}
}
class _WindowsMediaCacheSettings extends StatefulWidget {
const _WindowsMediaCacheSettings();
@override
State<_WindowsMediaCacheSettings> createState() =>
_WindowsMediaCacheSettingsState();
}
class _WindowsMediaCacheSettingsState
extends State<_WindowsMediaCacheSettings> {
final MediaCacheSettingsService _cache = MediaCacheSettingsService.instance;
bool _loading = true;
String _effectivePath = '';
int _currentBytes = 0;
int _defaultBytes = 0;
int _maxGb = MediaCacheSettingsService.defaultMaxCacheGb;
@override
void initState() {
super.initState();
_reload();
}
Future<void> _reload() async {
setState(() => _loading = true);
await _cache.ensureInitialized();
final effectivePath = await _cache.effectiveCachePath();
final currentBytes = await _cache.currentCacheSizeBytes();
final defaultBytes = await _cache.defaultCacheSizeBytes();
if (!mounted) {
return;
}
setState(() {
_effectivePath = effectivePath;
_currentBytes = currentBytes;
_defaultBytes = defaultBytes;
_maxGb = _cache.maxCacheGb.value;
_loading = false;
});
}
@override
Widget build(BuildContext context) {
final usingCustomPath = _cache.customCachePath.value.trim().isNotEmpty;
final defaultPath = _cache.defaultCachePath;
return _SettingsSection(
title: 'Cache média Windows',
icon: Icons.storage_outlined,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Le cache accélère les relectures et les bascules HD. Il est maintenant limité automatiquement pour éviter de remplir le disque système.',
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.78),
fontSize: 12.5,
height: 1.4,
),
),
SizedBox(height: 16),
Text('Emplacement', style: TextStyle(fontWeight: FontWeight.w700)),
SizedBox(height: 6),
Container(
width: double.infinity,
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
child: SelectableText(
_loading ? 'Calcul...' : _effectivePath,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 12.5,
),
),
),
SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
onPressed: _loading ? null : _changePath,
icon: Icon(Icons.folder_outlined),
label: Text('Modifier le chemin'),
),
if (usingCustomPath)
TextButton.icon(
onPressed: _loading ? null : _resetPath,
icon: Icon(Icons.restart_alt),
label: Text('Revenir au chemin par défaut'),
),
],
),
SizedBox(height: 16),
_SettingsRow(
title: 'Taille maximale',
subtitle:
'Quand la limite est dépassée, les vidéos les moins récemment utilisées sont supprimées. La vidéo active est protégée.',
trailing: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: _maxGb,
dropdownColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
items: MediaCacheSettingsService.supportedMaxCacheGb
.map(
(value) => DropdownMenuItem<int>(
value: value,
child: Text(
MediaCacheSettingsService.maxCacheLabel(value),
),
),
)
.toList(growable: false),
onChanged: _loading
? null
: (value) async {
if (value == null) {
return;
}
await _cache.setMaxCacheGb(value);
await _reload();
},
),
),
),
SizedBox(height: 16),
Row(
children: [
Expanded(
child: Text(
_loading
? 'Taille actuelle : calcul...'
: 'Taille actuelle : ${MediaCacheSettingsService.formatBytesForUi(_currentBytes)}',
style: TextStyle(fontWeight: FontWeight.w700),
),
),
OutlinedButton.icon(
onPressed: _loading ? null : _clearCurrentCache,
icon: Icon(Icons.delete_sweep_outlined),
label: Text('Vider le cache'),
),
],
),
if (usingCustomPath &&
_defaultBytes > 0 &&
_effectivePath.toLowerCase() != defaultPath.toLowerCase()) ...[
SizedBox(height: 14),
Container(
width: double.infinity,
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: Color(0x1AFF9800),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Color(0x44FF9800)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ancien cache sur C: ${MediaCacheSettingsService.formatBytesForUi(_defaultBytes)}',
style: TextStyle(fontWeight: FontWeight.w700),
),
SizedBox(height: 4),
SelectableText(
defaultPath,
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.78),
fontSize: 12,
),
),
SizedBox(height: 8),
TextButton.icon(
onPressed: _clearDefaultCache,
icon: Icon(Icons.delete_outline),
label: Text('Supprimer cet ancien cache'),
),
],
),
),
],
],
),
);
}
Future<void> _changePath() async {
final initialValue = _cache.customCachePath.value.trim().isNotEmpty
? _cache.customCachePath.value
: _effectivePath;
final controller = TextEditingController(text: initialValue);
final value = await showDialog<String>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text('Emplacement du cache'),
content: SizedBox(
width: 560,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Entre un dossier disposant de suffisamment d’espace. Exemple : D:\\YouTubePersonalCache',
),
SizedBox(height: 14),
TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(
labelText: 'Chemin du cache',
border: OutlineInputBorder(),
),
),
SizedBox(height: 8),
Text(
'Le changement ne copie pas les anciens fichiers. Ils pourront être supprimés séparément.',
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.78),
fontSize: 12,
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(controller.text),
child: Text('Utiliser ce dossier'),
),
],
);
},
);
controller.dispose();
if (value == null) {
return;
}
try {
await _cache.setCustomCachePath(value);
await _reload();
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Impossible d’utiliser ce dossier : $error')),
);
}
}
Future<void> _resetPath() async {
await _cache.resetCustomCachePath();
await _reload();
}
Future<void> _clearCurrentCache() async {
final confirmed = await _confirmDelete(
'Vider le cache média ?',
'Ferme d’abord toute vidéo en cours. Les vidéos mises en cache devront être retéléchargées lors de la prochaine lecture.',
);
if (!confirmed) {
return;
}
await _cache.clearCurrentCache();
await _reload();
}
Future<void> _clearDefaultCache() async {
final confirmed = await _confirmDelete(
'Supprimer l’ancien cache sur C: ?',
'Cette opération libérera l’espace utilisé par l’ancien cache média. Le nouveau cache restera intact.',
);
if (!confirmed) {
return;
}
await _cache.clearDefaultCache();
await _reload();
}
Future<bool> _confirmDelete(String title, String message) async {
final result = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text('Supprimer'),
),
],
);
},
);
return result == true;
}
}
class _AndroidMediaCacheSettings extends StatefulWidget {
const _AndroidMediaCacheSettings();
@override
State<_AndroidMediaCacheSettings> createState() =>
_AndroidMediaCacheSettingsState();
}
class _AndroidMediaCacheSettingsState
extends State<_AndroidMediaCacheSettings> {
final MediaCacheSettingsService _cache = MediaCacheSettingsService.instance;
bool _loading = true;
int _bufferMb = MediaCacheSettingsService.defaultAndroidBufferMb;
int _diskMode = MediaCacheSettingsService.androidDiskCacheDisabled;
String _path = '';
int _sizeBytes = 0;
@override
void initState() {
super.initState();
_reload();
}
Future<void> _reload() async {
if (mounted) {
setState(() => _loading = true);
}
await _cache.ensureInitialized();
final mode = _cache.androidDiskCacheMode.value;
final path = await _cache.androidCachePathForMode(mode);
final bytes = await _cache.androidCacheSizeBytes(mode);
if (!mounted) {
return;
}
setState(() {
_bufferMb = _cache.androidBufferMb.value;
_diskMode = mode;
_path = path;
_sizeBytes = bytes;
_loading = false;
});
}
@override
Widget build(BuildContext context) {
return _SettingsSection(
title: 'Cache média Android',
icon: Icons.memory_outlined,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Par défaut, Android garde le cache vidéo en RAM pour éviter de remplir le stockage. Le cache disque temporaire est optionnel et reste dans l’espace privé de l’application.',
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.78),
fontSize: 12.5,
height: 1.4,
),
),
SizedBox(height: 16),
_SettingsRow(
title: 'Tampon mémoire vidéo',
subtitle:
'Limite le cache démuxeur de media_kit. Le changement s’applique aux nouvelles pages vidéo.',
trailing: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: _bufferMb,
dropdownColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
items: MediaCacheSettingsService.supportedAndroidBufferMb
.map(
(value) => DropdownMenuItem<int>(
value: value,
child: Text(
MediaCacheSettingsService.androidBufferLabel(value),
),
),
)
.toList(growable: false),
onChanged: _loading
? null
: (value) async {
if (value == null) {
return;
}
await _cache.setAndroidBufferMb(value);
await _reload();
},
),
),
),
SizedBox(height: 14),
_SettingsRow(
title: 'Cache disque temporaire',
subtitle:
'RAM uniquement est recommandé. Interne/Externe utilise le cache temporaire mpv et le supprime à la fermeture normale de la vidéo.',
trailing: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: _diskMode,
dropdownColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
items: MediaCacheSettingsService.supportedAndroidDiskCacheModes
.map(
(value) => DropdownMenuItem<int>(
value: value,
child: Text(
MediaCacheSettingsService.androidDiskCacheModeLabel(
value,
),
),
),
)
.toList(growable: false),
onChanged: _loading
? null
: (value) async {
if (value == null) {
return;
}
await _cache.setAndroidDiskCacheMode(value);
await _reload();
},
),
),
),
if (_diskMode !=
MediaCacheSettingsService.androidDiskCacheDisabled) ...[
SizedBox(height: 14),
Text('Emplacement', style: TextStyle(fontWeight: FontWeight.w700)),
SizedBox(height: 6),
Container(
width: double.infinity,
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
child: SelectableText(
_loading
? 'Calcul...'
: (_path.isEmpty ? 'Indisponible' : _path),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 12.5,
),
),
),
SizedBox(height: 10),
Row(
children: [
Expanded(
child: Text(
'Taille temporaire : ${MediaCacheSettingsService.formatBytesForUi(_sizeBytes)}',
style: TextStyle(fontWeight: FontWeight.w700),
),
),
OutlinedButton.icon(
onPressed: _loading ? null : _clear,
icon: Icon(Icons.delete_sweep_outlined),
label: Text('Vider'),
),
],
),
],
SizedBox(height: 10),
Text(
'Android ne permet pas à l’application d’écrire librement dans n’importe quel dossier sans passer par le sélecteur de stockage. Le choix Externe utilise donc le dossier externe privé de l’application, sans permission supplémentaire.',
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.62),
fontSize: 11.5,
height: 1.35,
),
),
],
),
);
}
Future<void> _clear() async {
await _cache.clearAndroidMediaCache(_diskMode);
await _reload();
}
}
class _SettingsSection extends StatelessWidget {
const _SettingsSection({
required this.title,
required this.icon,
required this.child,
});
final String title;
final IconData icon;
final Widget child;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 19, color: Color(0xFFFF6B63)),
SizedBox(width: 8),
Text(
title,
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
),
],
),
SizedBox(height: 14),
child,
],
),
);
}
}
class _SettingsRow extends StatelessWidget {
const _SettingsRow({
required this.title,
required this.subtitle,
required this.trailing,
});
final String title;
final String subtitle;
final Widget trailing;
@override
Widget build(BuildContext context) {
final narrow = MediaQuery.sizeOf(context).width < 560;
final text = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: TextStyle(fontWeight: FontWeight.w700)),
SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.78),
fontSize: 12,
height: 1.35,
),
),
],
);
if (narrow) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [text, SizedBox(height: 12), trailing],
);
}
return Row(
children: [
Expanded(child: text),
SizedBox(width: 18),
trailing,
],
);
}
}
@@ -0,0 +1,888 @@
import 'dart:io';
import 'package:flutter/material.dart';
import '../services/app_settings_service.dart';
import '../services/media_cache_settings_service.dart';
import '../services/preferred_language_service.dart';
class SettingsPage extends StatelessWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context) {
return SafeArea(
child: ListView(
padding: EdgeInsets.fromLTRB(20, 22, 20, 32),
children: [
Text(
'Paramètres',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w800,
),
),
SizedBox(height: 6),
Text(
'Personnalise le comportement de YouTube Personal.',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78),
fontSize: 13,
),
),
SizedBox(height: 24),
_SettingsSection(
title: 'Apparence',
icon: Icons.brightness_6_outlined,
child: ValueListenableBuilder<String>(
valueListenable: AppSettingsService.instance.appearanceMode,
builder: (context, appearanceMode, child) {
return _SettingsRow(
title: 'Thème de l’application',
subtitle:
'Système suit automatiquement le thème clair ou sombre configuré sur Windows ou Android.',
trailing: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: appearanceMode,
dropdownColor:
Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
items: AppSettingsService.supportedAppearanceModes
.map(
(mode) => DropdownMenuItem<String>(
value: mode,
child: Text(
AppSettingsService.appearanceModeLabel(mode),
),
),
)
.toList(growable: false),
onChanged: (value) {
if (value == null) {
return;
}
AppSettingsService.instance.setAppearanceMode(value);
},
),
),
);
},
),
),
SizedBox(height: 14),
_SettingsSection(
title: 'Lecture',
icon: Icons.play_circle_outline,
child: ValueListenableBuilder<int>(
valueListenable: AppSettingsService.instance.defaultResolution,
builder: (context, resolution, child) {
return _SettingsRow(
title: 'Résolution par défaut',
subtitle:
'Utilisée à l’ouverture d’une nouvelle vidéo. La qualité reste modifiable dans le lecteur.',
trailing: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: resolution,
dropdownColor: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
items: AppSettingsService.supportedResolutions
.map(
(value) => DropdownMenuItem<int>(
value: value,
child: Text(
AppSettingsService.resolutionLabel(value),
),
),
)
.toList(growable: false),
onChanged: (value) {
if (value == null) {
return;
}
AppSettingsService.instance
.setDefaultResolution(value);
},
),
),
);
},
),
),
SizedBox(height: 14),
_SettingsSection(
title: 'Recommandations',
icon: Icons.language,
child: ValueListenableBuilder<String>(
valueListenable: AppSettingsService.instance.preferredLanguage,
builder: (context, language, child) {
return _SettingsRow(
title: 'Langue préférée des vidéos',
subtitle:
'Favorise cette langue dans le Radar et dans l’ordre des résultats de recherche, sans masquer les autres langues.',
trailing: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: language,
dropdownColor: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
items: PreferredLanguageService.options
.map(
(option) => DropdownMenuItem<String>(
value: option.code,
child: Text(option.label),
),
)
.toList(growable: false),
onChanged: (value) {
if (value == null) {
return;
}
AppSettingsService.instance
.setPreferredLanguage(value);
},
),
),
);
},
),
),
SizedBox(height: 14),
_SettingsSection(
title: 'Filtre contenu très jeune',
icon: Icons.child_care,
child: ValueListenableBuilder<int>(
valueListenable:
AppSettingsService.instance.youngAudienceFilterLevel,
builder: (context, level, child) {
return _SettingsRow(
title: 'Chaînes / vidéos orientées très jeune',
subtitle:
'Modéré masque surtout les contenus explicitement enfants, bébés, jouets ou comptines. Strict élargit le filtrage aux chaînes très fortement orientées jeune public. Le filtre s’applique aussi à Continuer à regarder, Continuer ce sujet et aux autres recommandations automatiques. Il se base sur les métadonnées textuelles, pas sur l’apparence des personnes.',
trailing: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: level,
dropdownColor: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
items: AppSettingsService
.supportedYoungAudienceFilterLevels
.map(
(value) => DropdownMenuItem<int>(
value: value,
child: Text(
AppSettingsService
.youngAudienceFilterLabel(value),
),
),
)
.toList(growable: false),
onChanged: (value) {
if (value == null) {
return;
}
AppSettingsService.instance
.setYoungAudienceFilterLevel(value);
},
),
),
);
},
),
),
if (Platform.isWindows) ...[
SizedBox(height: 14),
_WindowsMediaCacheSettings(),
],
if (Platform.isAndroid) ...[
SizedBox(height: 14),
_AndroidMediaCacheSettings(),
SizedBox(height: 14),
_SettingsSection(
title: 'Écran Android',
icon: Icons.bedtime_outlined,
child: ValueListenableBuilder<int>(
valueListenable:
AppSettingsService.instance.pauseScreenSleepMinutes,
builder: (context, minutes, child) {
return _SettingsRow(
title: 'Mise en veille après pause',
subtitle:
'Pendant la lecture, l’écran reste allumé. À la fin de la vidéo, la mise en veille système redevient immédiatement possible. En pause, le maintien de l’écran est libéré après ce délai.',
trailing: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: minutes,
dropdownColor: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
items: AppSettingsService
.supportedPauseScreenSleepMinutes
.map(
(value) => DropdownMenuItem<int>(
value: value,
child: Text(
AppSettingsService
.pauseScreenSleepLabel(value),
),
),
)
.toList(growable: false),
onChanged: (value) {
if (value == null) {
return;
}
AppSettingsService.instance
.setPauseScreenSleepMinutes(value);
},
),
),
);
},
),
),
],
SizedBox(height: 14),
_SettingsSection(
title: 'Navigation',
icon: Icons.dashboard_outlined,
child: Text(
'La barre du bas mobile a été simplifiée à 5 onglets : Accueil, Recherche, Abos, Bibliothèque et Réglages. Historique, Favoris et Chaînes sont maintenant regroupés dans Bibliothèque pour un rendu plus lisible.',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78),
height: 1.45,
fontSize: 12.5,
),
),
),
],
),
);
}
}
class _WindowsMediaCacheSettings extends StatefulWidget {
const _WindowsMediaCacheSettings();
@override
State<_WindowsMediaCacheSettings> createState() =>
_WindowsMediaCacheSettingsState();
}
class _WindowsMediaCacheSettingsState
extends State<_WindowsMediaCacheSettings> {
final MediaCacheSettingsService _cache =
MediaCacheSettingsService.instance;
bool _loading = true;
String _effectivePath = '';
int _currentBytes = 0;
int _defaultBytes = 0;
int _maxGb = MediaCacheSettingsService.defaultMaxCacheGb;
@override
void initState() {
super.initState();
_reload();
}
Future<void> _reload() async {
setState(() => _loading = true);
await _cache.ensureInitialized();
final effectivePath = await _cache.effectiveCachePath();
final currentBytes = await _cache.currentCacheSizeBytes();
final defaultBytes = await _cache.defaultCacheSizeBytes();
if (!mounted) {
return;
}
setState(() {
_effectivePath = effectivePath;
_currentBytes = currentBytes;
_defaultBytes = defaultBytes;
_maxGb = _cache.maxCacheGb.value;
_loading = false;
});
}
@override
Widget build(BuildContext context) {
final usingCustomPath = _cache.customCachePath.value.trim().isNotEmpty;
final defaultPath = _cache.defaultCachePath;
return _SettingsSection(
title: 'Cache média Windows',
icon: Icons.storage_outlined,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Le cache accélère les relectures et les bascules HD. Il est maintenant limité automatiquement pour éviter de remplir le disque système.',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78),
fontSize: 12.5,
height: 1.4,
),
),
SizedBox(height: 16),
Text(
'Emplacement',
style: TextStyle(fontWeight: FontWeight.w700),
),
SizedBox(height: 6),
Container(
width: double.infinity,
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
),
child: SelectableText(
_loading ? 'Calcul...' : _effectivePath,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 12.5,
),
),
),
SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
onPressed: _loading ? null : _changePath,
icon: Icon(Icons.folder_outlined),
label: Text('Modifier le chemin'),
),
if (usingCustomPath)
TextButton.icon(
onPressed: _loading ? null : _resetPath,
icon: Icon(Icons.restart_alt),
label: Text('Revenir au chemin par défaut'),
),
],
),
SizedBox(height: 16),
_SettingsRow(
title: 'Taille maximale',
subtitle:
'Quand la limite est dépassée, les vidéos les moins récemment utilisées sont supprimées. La vidéo active est protégée.',
trailing: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: _maxGb,
dropdownColor: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
items: MediaCacheSettingsService.supportedMaxCacheGb
.map(
(value) => DropdownMenuItem<int>(
value: value,
child: Text(
MediaCacheSettingsService.maxCacheLabel(value),
),
),
)
.toList(growable: false),
onChanged: _loading
? null
: (value) async {
if (value == null) {
return;
}
await _cache.setMaxCacheGb(value);
await _reload();
},
),
),
),
SizedBox(height: 16),
Row(
children: [
Expanded(
child: Text(
_loading
? 'Taille actuelle : calcul...'
: 'Taille actuelle : ${MediaCacheSettingsService.formatBytesForUi(_currentBytes)}',
style: TextStyle(
fontWeight: FontWeight.w700,
),
),
),
OutlinedButton.icon(
onPressed: _loading ? null : _clearCurrentCache,
icon: Icon(Icons.delete_sweep_outlined),
label: Text('Vider le cache'),
),
],
),
if (usingCustomPath &&
_defaultBytes > 0 &&
_effectivePath.toLowerCase() != defaultPath.toLowerCase()) ...[
SizedBox(height: 14),
Container(
width: double.infinity,
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: Color(0x1AFF9800),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Color(0x44FF9800)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ancien cache sur C: ${MediaCacheSettingsService.formatBytesForUi(_defaultBytes)}',
style: TextStyle(fontWeight: FontWeight.w700),
),
SizedBox(height: 4),
SelectableText(
defaultPath,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78),
fontSize: 12,
),
),
SizedBox(height: 8),
TextButton.icon(
onPressed: _clearDefaultCache,
icon: Icon(Icons.delete_outline),
label: Text('Supprimer cet ancien cache'),
),
],
),
),
],
],
),
);
}
Future<void> _changePath() async {
final initialValue = _cache.customCachePath.value.trim().isNotEmpty
? _cache.customCachePath.value
: _effectivePath;
final controller = TextEditingController(text: initialValue);
final value = await showDialog<String>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text('Emplacement du cache'),
content: SizedBox(
width: 560,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Entre un dossier disposant de suffisamment d’espace. Exemple : D:\\YouTubePersonalCache',
),
SizedBox(height: 14),
TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(
labelText: 'Chemin du cache',
border: OutlineInputBorder(),
),
),
SizedBox(height: 8),
Text(
'Le changement ne copie pas les anciens fichiers. Ils pourront être supprimés séparément.',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78),
fontSize: 12,
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(
controller.text,
),
child: Text('Utiliser ce dossier'),
),
],
);
},
);
controller.dispose();
if (value == null) {
return;
}
try {
await _cache.setCustomCachePath(value);
await _reload();
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Impossible d’utiliser ce dossier : $error'),
),
);
}
}
Future<void> _resetPath() async {
await _cache.resetCustomCachePath();
await _reload();
}
Future<void> _clearCurrentCache() async {
final confirmed = await _confirmDelete(
'Vider le cache média ?',
'Ferme d’abord toute vidéo en cours. Les vidéos mises en cache devront être retéléchargées lors de la prochaine lecture.',
);
if (!confirmed) {
return;
}
await _cache.clearCurrentCache();
await _reload();
}
Future<void> _clearDefaultCache() async {
final confirmed = await _confirmDelete(
'Supprimer l’ancien cache sur C: ?',
'Cette opération libérera l’espace utilisé par l’ancien cache média. Le nouveau cache restera intact.',
);
if (!confirmed) {
return;
}
await _cache.clearDefaultCache();
await _reload();
}
Future<bool> _confirmDelete(String title, String message) async {
final result = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text('Supprimer'),
),
],
);
},
);
return result == true;
}
}
class _AndroidMediaCacheSettings extends StatefulWidget {
const _AndroidMediaCacheSettings();
@override
State<_AndroidMediaCacheSettings> createState() =>
_AndroidMediaCacheSettingsState();
}
class _AndroidMediaCacheSettingsState
extends State<_AndroidMediaCacheSettings> {
final MediaCacheSettingsService _cache =
MediaCacheSettingsService.instance;
bool _loading = true;
int _bufferMb = MediaCacheSettingsService.defaultAndroidBufferMb;
int _diskMode = MediaCacheSettingsService.androidDiskCacheDisabled;
String _path = '';
int _sizeBytes = 0;
@override
void initState() {
super.initState();
_reload();
}
Future<void> _reload() async {
if (mounted) {
setState(() => _loading = true);
}
await _cache.ensureInitialized();
final mode = _cache.androidDiskCacheMode.value;
final path = await _cache.androidCachePathForMode(mode);
final bytes = await _cache.androidCacheSizeBytes(mode);
if (!mounted) {
return;
}
setState(() {
_bufferMb = _cache.androidBufferMb.value;
_diskMode = mode;
_path = path;
_sizeBytes = bytes;
_loading = false;
});
}
@override
Widget build(BuildContext context) {
return _SettingsSection(
title: 'Cache média Android',
icon: Icons.memory_outlined,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Par défaut, Android garde le cache vidéo en RAM pour éviter de remplir le stockage. Le cache disque temporaire est optionnel et reste dans l’espace privé de l’application.',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78),
fontSize: 12.5,
height: 1.4,
),
),
SizedBox(height: 16),
_SettingsRow(
title: 'Tampon mémoire vidéo',
subtitle:
'Limite le cache démuxeur de media_kit. Le changement s’applique aux nouvelles pages vidéo.',
trailing: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: _bufferMb,
dropdownColor: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
items: MediaCacheSettingsService.supportedAndroidBufferMb
.map(
(value) => DropdownMenuItem<int>(
value: value,
child: Text(
MediaCacheSettingsService.androidBufferLabel(value),
),
),
)
.toList(growable: false),
onChanged: _loading
? null
: (value) async {
if (value == null) {
return;
}
await _cache.setAndroidBufferMb(value);
await _reload();
},
),
),
),
SizedBox(height: 14),
_SettingsRow(
title: 'Cache disque temporaire',
subtitle:
'RAM uniquement est recommandé. Interne/Externe utilise le cache temporaire mpv et le supprime à la fermeture normale de la vidéo.',
trailing: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: _diskMode,
dropdownColor: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
items: MediaCacheSettingsService.supportedAndroidDiskCacheModes
.map(
(value) => DropdownMenuItem<int>(
value: value,
child: Text(
MediaCacheSettingsService
.androidDiskCacheModeLabel(value),
),
),
)
.toList(growable: false),
onChanged: _loading
? null
: (value) async {
if (value == null) {
return;
}
await _cache.setAndroidDiskCacheMode(value);
await _reload();
},
),
),
),
if (_diskMode !=
MediaCacheSettingsService.androidDiskCacheDisabled) ...[
SizedBox(height: 14),
Text(
'Emplacement',
style: TextStyle(fontWeight: FontWeight.w700),
),
SizedBox(height: 6),
Container(
width: double.infinity,
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
),
child: SelectableText(
_loading
? 'Calcul...'
: (_path.isEmpty ? 'Indisponible' : _path),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 12.5,
),
),
),
SizedBox(height: 10),
Row(
children: [
Expanded(
child: Text(
'Taille temporaire : ${MediaCacheSettingsService.formatBytesForUi(_sizeBytes)}',
style: TextStyle(fontWeight: FontWeight.w700),
),
),
OutlinedButton.icon(
onPressed: _loading ? null : _clear,
icon: Icon(Icons.delete_sweep_outlined),
label: Text('Vider'),
),
],
),
],
SizedBox(height: 10),
Text(
'Android ne permet pas à l’application d’écrire librement dans n’importe quel dossier sans passer par le sélecteur de stockage. Le choix Externe utilise donc le dossier externe privé de l’application, sans permission supplémentaire.',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62),
fontSize: 11.5,
height: 1.35,
),
),
],
),
);
}
Future<void> _clear() async {
await _cache.clearAndroidMediaCache(_diskMode);
await _reload();
}
}
class _SettingsSection extends StatelessWidget {
const _SettingsSection({
required this.title,
required this.icon,
required this.child,
});
final String title;
final IconData icon;
final Widget child;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 19, color: Color(0xFFFF6B63)),
SizedBox(width: 8),
Text(
title,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
],
),
SizedBox(height: 14),
child,
],
),
);
}
}
class _SettingsRow extends StatelessWidget {
const _SettingsRow({
required this.title,
required this.subtitle,
required this.trailing,
});
final String title;
final String subtitle;
final Widget trailing;
@override
Widget build(BuildContext context) {
final narrow = MediaQuery.sizeOf(context).width < 560;
final text = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78),
fontSize: 12,
height: 1.35,
),
),
],
);
if (narrow) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
text,
SizedBox(height: 12),
trailing,
],
);
}
return Row(
children: [
Expanded(child: text),
SizedBox(width: 18),
trailing,
],
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff