1129 lines
33 KiB
Dart
1129 lines
33 KiB
Dart
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,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|