Files

1129 lines
33 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart: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<SubscriptionsPage> createState() =>
_SubscriptionsPageState();
}
class _SubscriptionsPageState
extends State<SubscriptionsPage> {
final YoutubeAccountService _youtubeAccountService =
YoutubeAccountService();
List<SubscriptionChannel> _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<void> _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<void> _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<void> _openChannel(
SubscriptionChannel channel,
) async {
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ChannelVideosPage(
channel: channel,
),
),
);
}
Future<void> _disconnect() async {
await GoogleAuthService.instance.signOut();
}
Future<void> _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<GoogleSession?>(
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<String>(
tooltip: 'Plus',
icon: Icon(Icons.more_vert),
onSelected: (value) {
if (value == 'oauth') {
_showConfigurationDialog();
} else if (value == 'logout') {
_disconnect();
}
},
itemBuilder: (context) => [
PopupMenuItem<String>(
value: 'oauth',
child: Row(
children: [
Icon(Icons.settings),
SizedBox(width: 10),
Text('Configuration OAuth'),
],
),
),
PopupMenuItem<String>(
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,
),
),
),
],
),
],
),
),
),
);
}
}