import 'dart:async'; import 'package:flutter/material.dart'; import '../application/application.dart'; import '../domain/domain.dart'; import 'share_inbox_screen.dart'; import 'theme.dart'; final class ProfileScreen extends StatefulWidget { const ProfileScreen({ required this.authUseCases, required this.syncUseCases, required this.shareUseCases, super.key, }); final AuthUseCases authUseCases; final SyncUseCases syncUseCases; final ShareUseCases shareUseCases; @override State createState() => _ProfileScreenState(); } final class _ProfileScreenState extends State { late Future _session; @override void initState() { super.initState(); _session = widget.authUseCases.currentSession(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Profil')), body: FutureBuilder( future: _session, builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); } final session = snapshot.data; if (session == null || !session.isLoggedIn) { return _SignedOutProfile( onRegister: () => _openRegister(context), onLogin: () => _openLogin(context), ); } return _SignedInProfile( session: session, syncUseCases: widget.syncUseCases, onLogout: () => _confirmLogout(context), onShares: () => _openReceivedShares(context), ); }, ), ); } Future _openLogin(BuildContext context) async { final connected = await Navigator.of(context).push( MaterialPageRoute( builder: (context) => LoginScreen( authUseCases: widget.authUseCases, syncUseCases: widget.syncUseCases, shareUseCases: widget.shareUseCases, ), ), ); if (connected == true && mounted) { _reloadSession(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Compte connecté. Synchronisation en arrière-plan.'), ), ); } } Future _openRegister(BuildContext context) async { final connected = await Navigator.of(context).push( MaterialPageRoute( builder: (context) => RegisterScreen( authUseCases: widget.authUseCases, syncUseCases: widget.syncUseCases, shareUseCases: widget.shareUseCases, ), ), ); if (connected == true && mounted) { _reloadSession(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Compte connecté. Synchronisation en arrière-plan.'), ), ); } } Future _confirmLogout(BuildContext context) async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Se déconnecter ?'), content: const Text( 'Les données restent sur cet appareil. La synchronisation et les ' 'partages seront suspendus jusqu’à une prochaine connexion.', ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Annuler'), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), child: const Text('Se déconnecter'), ), ], ), ); if (confirmed != true) return; await widget.authUseCases.logout(); if (!mounted) return; _reloadSession(); } void _openReceivedShares(BuildContext context) { Navigator.of(context).push( MaterialPageRoute( builder: (context) => ShareInboxScreen(shareUseCases: widget.shareUseCases), ), ); } void _reloadSession() { setState(() { _session = widget.authUseCases.currentSession(); }); } } final class _SignedOutProfile extends StatelessWidget { const _SignedOutProfile({required this.onRegister, required this.onLogin}); final VoidCallback onRegister; final VoidCallback onLogin; @override Widget build(BuildContext context) { return ListView( padding: const EdgeInsets.all(16), children: [ CourtBlazerAccentPanel( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( 'Compte optionnel', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), const Text( 'GameTime fonctionne entièrement sans compte. Connecte-toi ' 'seulement si tu veux sauvegarder tes données en ligne ou ' 'partager des programmes et séances.', ), const SizedBox(height: 16), FilledButton( onPressed: onRegister, child: const Text('Créer un compte'), ), const SizedBox(height: 8), OutlinedButton( onPressed: onLogin, child: const Text('Se connecter'), ), ], ), ), const SizedBox(height: 16), CourtBlazerAccentPanel( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Données locales', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), const Text( 'Tes exercices, programmes, séances et historiques sont ' 'enregistrés sur cet appareil.', ), ], ), ), const SizedBox(height: 16), CourtBlazerAccentPanel( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Partages', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 8), const Text( 'Connecte-toi pour envoyer et recevoir des programmes ou des ' 'séances.', ), ], ), ), ], ); } } final class _SignedInProfile extends StatelessWidget { const _SignedInProfile({ required this.session, required this.syncUseCases, required this.onLogout, required this.onShares, }); final UserAccountSession session; final SyncUseCases syncUseCases; final VoidCallback onLogout; final VoidCallback onShares; @override Widget build(BuildContext context) { final displayName = session.displayName?.trim(); final title = displayName == null || displayName.isEmpty ? session.email : displayName; return ListView( padding: const EdgeInsets.all(16), children: [ CourtBlazerAccentPanel( child: Row( children: [ CircleAvatar(radius: 28, child: Text(_profileInitials(title))), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: Theme.of(context).textTheme.titleLarge), if (title != session.email) ...[ const SizedBox(height: 4), Text(session.email), ], ], ), ), ], ), ), const SizedBox(height: 16), OutlinedButton.icon( onPressed: onLogout, icon: const Icon(Icons.logout), label: const Text('Se déconnecter'), ), const SizedBox(height: 16), SyncStatusPanel(syncUseCases: syncUseCases), const SizedBox(height: 12), CourtBlazerAccentPanel( child: Material( type: MaterialType.transparency, child: ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.inbox_outlined), title: const Text('Partages reçus'), subtitle: const Text( "Programmes et séances reçus d'autres comptes", ), trailing: const Icon(Icons.chevron_right), onTap: onShares, ), ), ), ], ); } } final class SyncStatusPanel extends StatefulWidget { const SyncStatusPanel({required this.syncUseCases, super.key}); final SyncUseCases syncUseCases; @override State createState() => _SyncStatusPanelState(); } final class _SyncStatusPanelState extends State { late Future _status; var _syncing = false; @override void initState() { super.initState(); _status = widget.syncUseCases.currentStatus(); } @override Widget build(BuildContext context) { return CourtBlazerAccentPanel( child: FutureBuilder( future: _status, builder: (context, snapshot) { final status = snapshot.data; final view = _syncStatusView(status, _syncing); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(top: 2), child: _syncing ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ) : Icon(view.icon), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Synchronisation', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 4), Text(view.label), ], ), ), ], ), const SizedBox(height: 16), OutlinedButton.icon( onPressed: _syncing ? null : _syncNow, icon: const Icon(Icons.sync), label: const Text('Synchroniser maintenant'), ), ], ); }, ), ); } Future _syncNow() async { setState(() { _syncing = true; }); await widget.syncUseCases.synchronize(manual: true); if (!mounted) return; setState(() { _syncing = false; _status = widget.syncUseCases.currentStatus(); }); } } final class _SyncStatusView { const _SyncStatusView({required this.label, required this.icon}); final String label; final IconData icon; } _SyncStatusView _syncStatusView(SyncMetadataSnapshot? metadata, bool syncing) { if (syncing || metadata?.status == OnlineSyncStatus.syncing) { return const _SyncStatusView( label: 'Synchronisation en cours...', icon: Icons.sync, ); } if (metadata == null) { return const _SyncStatusView( label: 'Synchronisation en attente', icon: Icons.cloud_outlined, ); } final lastSuccess = metadata.lastSuccessfulSyncAt; if (metadata.status == OnlineSyncStatus.success && lastSuccess != null) { return _SyncStatusView( label: 'Dernière synchro : ${_relativeSyncTime(lastSuccess)}', icon: Icons.cloud_done_outlined, ); } if (metadata.status == OnlineSyncStatus.failure) { if (lastSuccess != null) { return _SyncStatusView( label: 'Dernière synchronisation : ${_relativeSyncTime(lastSuccess)}. ' 'Nouvelle tentative automatique.', icon: Icons.schedule, ); } return const _SyncStatusView( label: 'Synchronisation en attente', icon: Icons.schedule, ); } return const _SyncStatusView( label: 'Synchronisation en attente', icon: Icons.cloud_outlined, ); } final class LoginScreen extends StatefulWidget { const LoginScreen({ required this.authUseCases, required this.syncUseCases, this.shareUseCases, super.key, }); final AuthUseCases authUseCases; final SyncUseCases syncUseCases; final ShareUseCases? shareUseCases; @override State createState() => _LoginScreenState(); } final class _LoginScreenState extends State { final _formKey = GlobalKey(); final _emailController = TextEditingController(); final _passwordController = TextEditingController(); String? _error; var _submitting = false; @override void dispose() { _emailController.dispose(); _passwordController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Se connecter')), body: _AuthFormScaffold( child: Form( key: _formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextFormField( controller: _emailController, decoration: const InputDecoration(labelText: 'Email'), keyboardType: TextInputType.emailAddress, validator: _emailValidator, ), const SizedBox(height: 12), TextFormField( controller: _passwordController, decoration: const InputDecoration(labelText: 'Mot de passe'), obscureText: true, validator: _passwordValidator, ), if (_error != null) ...[ const SizedBox(height: 12), _InlineAuthError(message: _error!), ], const SizedBox(height: 16), FilledButton( onPressed: _submitting ? null : _submit, child: Text(_submitting ? 'Connexion...' : 'Se connecter'), ), const SizedBox(height: 8), OutlinedButton( onPressed: _submitting ? null : () { Navigator.of(context).pushReplacement( MaterialPageRoute( builder: (context) => RegisterScreen( authUseCases: widget.authUseCases, syncUseCases: widget.syncUseCases, shareUseCases: widget.shareUseCases, ), ), ); }, child: const Text('Créer un compte'), ), const SizedBox(height: 16), Text( 'Tu peux continuer à utiliser GameTime sans compte.', style: Theme.of(context).textTheme.bodySmall, ), ], ), ), ), ); } Future _submit() async { if (!_formKey.currentState!.validate()) return; setState(() { _error = null; _submitting = true; }); try { await widget.authUseCases.login( email: _emailController.text, password: _passwordController.text, ); unawaited(widget.syncUseCases.synchronize(manual: false)); final shareUseCases = widget.shareUseCases; if (shareUseCases != null) { unawaited(shareUseCases.processPendingShareActions()); } if (!mounted) return; Navigator.of(context).pop(true); } on RemoteAuthException catch (error) { if (!mounted) return; setState(() { _error = _loginErrorMessage(error.failure); _submitting = false; }); } on DomainException { if (!mounted) return; setState(() { _error = 'Vérifie les informations saisies.'; _submitting = false; }); } } } final class RegisterScreen extends StatefulWidget { const RegisterScreen({ required this.authUseCases, required this.syncUseCases, this.shareUseCases, super.key, }); final AuthUseCases authUseCases; final SyncUseCases syncUseCases; final ShareUseCases? shareUseCases; @override State createState() => _RegisterScreenState(); } final class _RegisterScreenState extends State { final _formKey = GlobalKey(); final _emailController = TextEditingController(); final _passwordController = TextEditingController(); final _confirmPasswordController = TextEditingController(); final _displayNameController = TextEditingController(); String? _error; var _submitting = false; @override void dispose() { _emailController.dispose(); _passwordController.dispose(); _confirmPasswordController.dispose(); _displayNameController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Créer un compte')), body: _AuthFormScaffold( child: Form( key: _formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextFormField( controller: _emailController, decoration: const InputDecoration(labelText: 'Email'), keyboardType: TextInputType.emailAddress, validator: _emailValidator, ), const SizedBox(height: 12), TextFormField( controller: _passwordController, decoration: const InputDecoration(labelText: 'Mot de passe'), obscureText: true, validator: _newPasswordValidator, ), const SizedBox(height: 12), TextFormField( controller: _confirmPasswordController, decoration: const InputDecoration( labelText: 'Confirmer le mot de passe', ), obscureText: true, validator: (value) { if (value != _passwordController.text) { return 'Les mots de passe ne correspondent pas.'; } return null; }, ), const SizedBox(height: 12), TextFormField( controller: _displayNameController, decoration: const InputDecoration( labelText: 'Pseudo (optionnel)', ), ), if (_error != null) ...[ const SizedBox(height: 12), _InlineAuthError(message: _error!), ], const SizedBox(height: 16), FilledButton( onPressed: _submitting ? null : _submit, child: Text(_submitting ? 'Création...' : 'Créer le compte'), ), const SizedBox(height: 8), OutlinedButton( onPressed: _submitting ? null : () { Navigator.of(context).pushReplacement( MaterialPageRoute( builder: (context) => LoginScreen( authUseCases: widget.authUseCases, syncUseCases: widget.syncUseCases, shareUseCases: widget.shareUseCases, ), ), ); }, child: const Text('Déjà un compte ? Se connecter'), ), const SizedBox(height: 16), Text( 'Le compte sert à synchroniser tes données et partager tes ' "contenus. L'app reste utilisable sans compte.", style: Theme.of(context).textTheme.bodySmall, ), ], ), ), ), ); } Future _submit() async { if (!_formKey.currentState!.validate()) return; setState(() { _error = null; _submitting = true; }); final displayName = _displayNameController.text.trim(); try { await widget.authUseCases.register( email: _emailController.text, password: _passwordController.text, displayName: displayName.isEmpty ? null : displayName, ); unawaited(widget.syncUseCases.synchronize(manual: false)); final shareUseCases = widget.shareUseCases; if (shareUseCases != null) { unawaited(shareUseCases.processPendingShareActions()); } if (!mounted) return; Navigator.of(context).pop(true); } on RemoteAuthException catch (error) { if (!mounted) return; setState(() { _error = _registerErrorMessage(error.failure); _submitting = false; }); } on DomainException { if (!mounted) return; setState(() { _error = 'Vérifie les informations saisies.'; _submitting = false; }); } } } final class _AuthFormScaffold extends StatelessWidget { const _AuthFormScaffold({required this.child}); final Widget child; @override Widget build(BuildContext context) { return ListView( padding: const EdgeInsets.all(16), children: [CourtBlazerAccentPanel(child: child)], ); } } final class _InlineAuthError extends StatelessWidget { const _InlineAuthError({required this.message}); final String message; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Text( message, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.error, ), ); } } String? _emailValidator(String? value) { final email = value?.trim() ?? ''; if (!RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(email)) { return 'Saisis une adresse email valide.'; } return null; } String? _passwordValidator(String? value) { final password = value ?? ''; if (password.isEmpty) { return 'Saisis ton mot de passe.'; } if (password.length < 8) { return 'Saisis un mot de passe d’au moins 8 caractères.'; } return null; } String? _newPasswordValidator(String? value) { if ((value ?? '').length < 8) { return 'Saisis un mot de passe d’au moins 8 caractères.'; } return null; } String _loginErrorMessage(RemoteAuthFailure failure) { return switch (failure) { RemoteAuthFailure.invalidCredentials => 'Email ou mot de passe incorrect.', RemoteAuthFailure.network => 'Connexion impossible pour le moment. Réessaie plus tard.', _ => 'Connexion impossible pour le moment. Réessaie plus tard.', }; } String _registerErrorMessage(RemoteAuthFailure failure) { return switch (failure) { RemoteAuthFailure.emailAlreadyUsed => 'Un compte existe déjà avec cet email.', RemoteAuthFailure.network => 'Création impossible pour le moment. Réessaie plus tard.', _ => 'Création impossible pour le moment. Réessaie plus tard.', }; } String _relativeSyncTime(DateTime value) { final localValue = value.toLocal(); final difference = DateTime.now().difference(localValue); if (difference.inMinutes < 1) { return 'à l’instant'; } if (difference.inHours < 1) { return 'il y a ${difference.inMinutes} min'; } if (difference.inDays < 1) { return 'il y a ${difference.inHours} h'; } if (difference.inDays == 1) { return 'hier à ${_formatHourMinute(localValue)}'; } return 'le ${localValue.day.toString().padLeft(2, '0')}/' '${localValue.month.toString().padLeft(2, '0')} ' 'à ${_formatHourMinute(localValue)}'; } String _formatHourMinute(DateTime value) { final hour = value.hour.toString().padLeft(2, '0'); final minute = value.minute.toString().padLeft(2, '0'); return '$hour:$minute'; } String _profileInitials(String value) { final trimmed = value.trim(); if (trimmed.isEmpty) return '?'; final words = trimmed.split(RegExp(r'\s+')); if (words.length == 1) { return words.first.substring(0, 1).toUpperCase(); } return '${words.first.substring(0, 1)}${words.last.substring(0, 1)}' .toUpperCase(); }