import 'dart:io'; import 'package:flutter/material.dart'; import '../models/subscription_channel.dart'; import '../services/google_auth_service.dart'; import '../services/google_oauth_config_service.dart'; import '../services/youtube_account_service.dart'; import 'channel_videos_page.dart'; class SubscriptionsPage extends StatefulWidget { const SubscriptionsPage({super.key}); @override State createState() => _SubscriptionsPageState(); } class _SubscriptionsPageState extends State { final YoutubeAccountService _youtubeAccountService = YoutubeAccountService(); List _subscriptions = []; bool _loading = false; bool _subscriptionsRequestInFlight = false; String? _error; String _filterQuery = ''; @override void initState() { super.initState(); GoogleAuthService.instance.session.addListener( _onSessionChanged, ); if (GoogleAuthService.instance.session.value != null) { _loadSubscriptions(); } } @override void dispose() { GoogleAuthService.instance.session.removeListener( _onSessionChanged, ); super.dispose(); } void _onSessionChanged() { if (!mounted) { return; } final session = GoogleAuthService.instance.session.value; if (session == null) { setState(() { _subscriptions = []; _error = null; _loading = false; }); return; } // Un rafraîchissement du jeton OAuth ne doit jamais déclencher // une nouvelle requête subscriptions.list pendant qu'une requête // est déjà en cours. C'était la cause de la boucle qui épuisait // le quota YouTube Data API. if (_subscriptionsRequestInFlight || _loading) { return; } // On charge automatiquement uniquement quand la session vient // d'apparaître et que la liste n'a encore rien. if (_subscriptions.isEmpty) { _loadSubscriptions(); } } Future _connect() async { setState(() { _loading = true; _error = null; }); try { await GoogleAuthService.instance.signIn(); if (mounted) { await _loadSubscriptions(); } } catch (error) { if (!mounted) { return; } setState(() { _loading = false; _error = error.toString(); }); } } Future _loadSubscriptions() async { final session = GoogleAuthService.instance.session.value; if (session == null || _subscriptionsRequestInFlight) { return; } _subscriptionsRequestInFlight = true; if (mounted) { setState(() { _loading = true; _error = null; }); } try { final accessToken = await GoogleAuthService.instance .getAccessToken(); final subscriptions = await _youtubeAccountService .fetchSubscriptions( accessToken, ); if (!mounted) { return; } setState(() { _subscriptions = subscriptions; _loading = false; }); } catch (error) { if (!mounted) { return; } setState(() { _loading = false; _error = error.toString(); }); } finally { _subscriptionsRequestInFlight = false; } } Future _openChannel( SubscriptionChannel channel, ) async { await Navigator.of(context).push( MaterialPageRoute( builder: (_) => ChannelVideosPage( channel: channel, ), ), ); } Future _disconnect() async { await GoogleAuthService.instance.signOut(); } Future _showConfigurationDialog() async { final initial = GoogleOauthConfigService.instance.config.value; final result = await showDialog<_OauthDialogResult>( context: context, builder: (dialogContext) { return _GoogleOauthConfigDialog( initial: initial, ); }, ); if (result == null || !mounted) { return; } setState(() { _loading = true; _error = null; }); try { if (Platform.isAndroid) { final canUseWithoutRestart = await GoogleAuthService.instance .saveAndroidConfiguration( result.webClientId, ); if (!mounted) { return; } setState(() { _loading = false; }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( canUseWithoutRestart ? 'Configuration OAuth enregistrée.' : 'Configuration OAuth enregistrée. ' 'Redémarre l’application avant ' 'la connexion Google.', ), ), ); return; } if (Platform.isWindows) { await GoogleAuthService.instance .saveWindowsConfiguration( desktopClientId: result.desktopClientId, desktopClientSecret: result.desktopClientSecret, ); } if (!mounted) { return; } setState(() { _loading = false; }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( 'Configuration OAuth enregistrée.', ), ), ); } catch (error) { if (!mounted) { return; } setState(() { _loading = false; _error = error.toString(); }); } } @override Widget build(BuildContext context) { return SafeArea( child: ValueListenableBuilder( valueListenable: GoogleAuthService.instance.session, builder: ( context, session, child, ) { if (session == null) { return _buildSignedOut(); } return _buildSignedIn(session); }, ), ); } Widget _buildSignedOut() { final configured = GoogleAuthService.instance.isConfigured; return Center( child: ConstrainedBox( constraints: BoxConstraints(maxWidth: 620), child: Padding( padding: EdgeInsets.all(28), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.subscriptions_outlined, size: 72, color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.52), ), SizedBox(height: 18), Text( 'Tes abonnements YouTube', textAlign: TextAlign.center, style: TextStyle( fontSize: 24, fontWeight: FontWeight.w700, ), ), SizedBox(height: 10), Text( 'Connecte ton compte Google pour ' 'charger tes chaînes abonnées via ' 'YouTube Data API.', textAlign: TextAlign.center, style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86), height: 1.4, ), ), SizedBox(height: 20), Container( width: double.infinity, padding: EdgeInsets.all(14), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerLow, borderRadius: BorderRadius.circular(12), border: Border.all( color: configured ? Colors.green.withValues( alpha: 0.35, ) : Colors.orange.withValues( alpha: 0.35, ), ), ), child: Row( children: [ Icon( configured ? Icons.check_circle_outline : Icons .settings_outlined, color: configured ? Colors.greenAccent : Colors.orangeAccent, ), SizedBox(width: 12), Expanded( child: Text( configured ? 'OAuth Google configuré ' 'pour cette plateforme.' : GoogleAuthService .instance .configurationHint, style: TextStyle( color: configured ? Colors.greenAccent : Colors.orangeAccent, ), ), ), TextButton( onPressed: _showConfigurationDialog, child: Text( configured ? 'Modifier' : 'Configurer', ), ), ], ), ), if (_error != null) ...[ SizedBox(height: 14), Text( _error!, textAlign: TextAlign.center, style: TextStyle( color: Colors.redAccent, ), ), ], SizedBox(height: 20), FilledButton.icon( onPressed: configured && !_loading ? _connect : null, icon: _loading ? SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, ), ) : Icon(Icons.login), label: Text( 'Se connecter avec Google', ), ), SizedBox(height: 10), Text( 'Permission demandée : lecture ' 'seule des données YouTube.', style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), fontSize: 12, ), ), ], ), ), ), ); } Widget _buildSignedIn( GoogleSession session, ) { final mobile = MediaQuery.sizeOf(context).width < 600; final normalizedQuery = _filterQuery.trim().toLowerCase(); final visibleSubscriptions = normalizedQuery.isEmpty ? _subscriptions : _subscriptions .where( (channel) => channel.title .toLowerCase() .contains( normalizedQuery, ) || channel.description .toLowerCase() .contains( normalizedQuery, ), ) .toList(growable: false); return Column( children: [ Padding( padding: EdgeInsets.fromLTRB( mobile ? 14 : 20, 18, mobile ? 6 : 12, 10, ), child: Row( children: [ CircleAvatar( radius: mobile ? 18 : 20, backgroundImage: session.photoUrl != null ? NetworkImage( session.photoUrl!, ) : null, child: session.photoUrl == null ? Icon(Icons.person) : null, ), SizedBox(width: mobile ? 10 : 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( _subscriptions.isEmpty ? 'Abonnements' : 'Abonnements ' '(${_subscriptions.length})', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: mobile ? 20 : 24, fontWeight: FontWeight.w700, ), ), Text( session.email ?? session.displayName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), ), ), ], ), ), IconButton( tooltip: 'Actualiser', onPressed: _loading ? null : _loadSubscriptions, icon: Icon(Icons.refresh), ), if (mobile) PopupMenuButton( tooltip: 'Plus', icon: Icon(Icons.more_vert), onSelected: (value) { if (value == 'oauth') { _showConfigurationDialog(); } else if (value == 'logout') { _disconnect(); } }, itemBuilder: (context) => [ PopupMenuItem( value: 'oauth', child: Row( children: [ Icon(Icons.settings), SizedBox(width: 10), Text('Configuration OAuth'), ], ), ), PopupMenuItem( value: 'logout', child: Row( children: [ Icon(Icons.logout), SizedBox(width: 10), Text('Déconnexion'), ], ), ), ], ) else ...[ IconButton( tooltip: 'Configuration OAuth', onPressed: _showConfigurationDialog, icon: Icon(Icons.settings), ), IconButton( tooltip: 'Déconnexion', onPressed: _disconnect, icon: Icon(Icons.logout), ), ], ], ), ), if (_subscriptions.isNotEmpty) Padding( padding: EdgeInsets.fromLTRB( 18, 4, 18, 10, ), child: TextField( onChanged: (value) { setState(() { _filterQuery = value; }); }, decoration: InputDecoration( prefixIcon: Icon(Icons.search), hintText: 'Rechercher parmi ' '${_subscriptions.length} chaînes', filled: true, fillColor: Theme.of(context).colorScheme.surfaceContainerLow, border: OutlineInputBorder( borderRadius: BorderRadius.circular(14), borderSide: BorderSide.none, ), ), ), ), if (_loading) LinearProgressIndicator( minHeight: 2, ), if (_error != null) Padding( padding: EdgeInsets.all(18), child: Text( _error!, textAlign: TextAlign.center, style: TextStyle( color: Colors.redAccent, ), ), ), Expanded( child: !_loading && visibleSubscriptions.isEmpty ? Center( child: Text( _subscriptions.isEmpty ? 'Aucun abonnement trouvé.' : 'Aucune chaîne ne correspond ' 'à "$_filterQuery".', textAlign: TextAlign.center, style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86), ), ), ) : LayoutBuilder( builder: ( context, constraints, ) { final mobile = constraints.maxWidth < 600; if (mobile) { return ListView.separated( padding: const EdgeInsets .fromLTRB( 12, 8, 12, 24, ), itemCount: visibleSubscriptions .length, separatorBuilder: (_, __) => SizedBox( height: 8, ), itemBuilder: ( context, index, ) { final channel = visibleSubscriptions[ index]; return _SubscriptionListTile( channel: channel, onTap: () => _openChannel( channel, ), ); }, ); } final columns = constraints.maxWidth >= 1100 ? 5 : constraints.maxWidth >= 800 ? 4 : 3; return GridView.builder( padding: const EdgeInsets .all(18), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: columns, crossAxisSpacing: 12, mainAxisSpacing: 12, childAspectRatio: 1.35, ), itemCount: visibleSubscriptions .length, itemBuilder: ( context, index, ) { final channel = visibleSubscriptions[ index]; return _SubscriptionCard( channel: channel, compact: false, onTap: () => _openChannel( channel, ), ); }, ); }, ), ), ], ); } } class _OauthDialogResult { const _OauthDialogResult({ this.webClientId = '', this.desktopClientId = '', this.desktopClientSecret = '', }); final String webClientId; final String desktopClientId; final String desktopClientSecret; } class _GoogleOauthConfigDialog extends StatefulWidget { const _GoogleOauthConfigDialog({ required this.initial, }); final GoogleOauthConfig initial; @override State<_GoogleOauthConfigDialog> createState() => _GoogleOauthConfigDialogState(); } class _GoogleOauthConfigDialogState extends State<_GoogleOauthConfigDialog> { late final TextEditingController _webController; late final TextEditingController _desktopIdController; late final TextEditingController _desktopSecretController; bool _obscureSecret = true; @override void initState() { super.initState(); _webController = TextEditingController( text: widget.initial.webClientId, ); _desktopIdController = TextEditingController( text: widget.initial.desktopClientId, ); _desktopSecretController = TextEditingController( text: widget.initial.desktopClientSecret, ); } @override void dispose() { _webController.dispose(); _desktopIdController.dispose(); _desktopSecretController.dispose(); super.dispose(); } void _submit() { Navigator.of(context).pop( _OauthDialogResult( webClientId: _webController.text.trim(), desktopClientId: _desktopIdController.text.trim(), desktopClientSecret: _desktopSecretController.text.trim(), ), ); } @override Widget build(BuildContext context) { return AlertDialog( title: Text( 'Configuration Google OAuth', ), content: SizedBox( width: 560, child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (Platform.isAndroid) ...[ Text( 'Android', style: TextStyle( fontSize: 18, fontWeight: FontWeight.w700, ), ), SizedBox(height: 8), Text( 'Dans Google Cloud, il faut ' 'un client OAuth Android ' 'pour le package ' 'com.studioweb.youtube_personal ' 'avec le SHA-1 de l’application. ' 'Colle ici le Client ID du client ' 'OAuth de type "Web application".', style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86), height: 1.4, ), ), SizedBox(height: 14), TextField( controller: _webController, decoration: InputDecoration( border: OutlineInputBorder(), labelText: 'Client ID Web', hintText: '...apps.googleusercontent.com', ), ), ] else if (Platform.isWindows) ...[ Text( 'Windows', style: TextStyle( fontSize: 18, fontWeight: FontWeight.w700, ), ), SizedBox(height: 8), Text( 'Crée un client OAuth ' '"Desktop app" dans Google Cloud ' 'et colle ses identifiants ici.', style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86), height: 1.4, ), ), SizedBox(height: 14), TextField( controller: _desktopIdController, decoration: InputDecoration( border: OutlineInputBorder(), labelText: 'Client ID Desktop', hintText: '...apps.googleusercontent.com', ), ), SizedBox(height: 12), TextField( controller: _desktopSecretController, obscureText: _obscureSecret, decoration: InputDecoration( border: OutlineInputBorder(), labelText: 'Client Secret (si fourni)', suffixIcon: IconButton( onPressed: () { setState(() { _obscureSecret = !_obscureSecret; }); }, icon: Icon( _obscureSecret ? Icons.visibility_outlined : Icons .visibility_off_outlined, ), ), ), ), ], SizedBox(height: 16), Text( 'YouTube Data API v3 doit être ' 'activée dans le même projet ' 'Google Cloud.', style: TextStyle( color: Colors.orangeAccent, ), ), ], ), ), ), actions: [ TextButton( onPressed: () { Navigator.of(context).pop(); }, child: Text('Annuler'), ), FilledButton( onPressed: _submit, child: Text('Enregistrer'), ), ], ); } } class _SubscriptionListTile extends StatelessWidget { const _SubscriptionListTile({ required this.channel, required this.onTap, }); final SubscriptionChannel channel; final VoidCallback onTap; @override Widget build(BuildContext context) { return Material( color: Theme.of(context).colorScheme.surfaceContainerLow, borderRadius: BorderRadius.circular(14), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, child: Padding( padding: EdgeInsets.symmetric( horizontal: 14, vertical: 12, ), child: Row( children: [ CircleAvatar( radius: 28, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, backgroundImage: channel.thumbnailUrl.isNotEmpty ? NetworkImage( channel.thumbnailUrl, ) : null, child: channel.thumbnailUrl.isEmpty ? Icon( Icons.account_circle, size: 38, ) : null, ), SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( channel.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontWeight: FontWeight.w600, fontSize: 15, ), ), SizedBox(height: 5), Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.play_circle_outline, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), ), SizedBox(width: 5), Text( 'Voir les vidéos', style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), fontSize: 12, ), ), ], ), ], ), ), SizedBox(width: 8), Icon( Icons.chevron_right, color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), ), ], ), ), ), ); } } class _SubscriptionCard extends StatelessWidget { const _SubscriptionCard({ required this.channel, required this.onTap, required this.compact, }); final SubscriptionChannel channel; final VoidCallback onTap; final bool compact; @override Widget build(BuildContext context) { final avatarRadius = compact ? 29.0 : 34.0; final cardPadding = compact ? 10.0 : 14.0; return Card( clipBehavior: Clip.antiAlias, color: Theme.of(context).colorScheme.surfaceContainerLow, child: InkWell( onTap: onTap, child: Padding( padding: EdgeInsets.all(cardPadding), child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ CircleAvatar( radius: avatarRadius, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, backgroundImage: channel.thumbnailUrl.isNotEmpty ? NetworkImage( channel.thumbnailUrl, ) : null, child: channel.thumbnailUrl.isEmpty ? Icon( Icons.account_circle, size: compact ? 40 : 46, ) : null, ), SizedBox( height: compact ? 9 : 12, ), Flexible( child: Text( channel.title, maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: TextStyle( fontWeight: FontWeight.w600, fontSize: compact ? 13 : null, height: 1.15, ), ), ), SizedBox( height: compact ? 5 : 7, ), Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.play_circle_outline, size: compact ? 14 : 16, color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), ), SizedBox(width: 5), Flexible( child: Text( 'Voir les vidéos', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), fontSize: compact ? 11 : 12, ), ), ), ], ), ], ), ), ), ); } }