import 'dart:async'; import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:share_plus/share_plus.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, required this.dataExportUseCase, required this.dataImportUseCase, this.backupFileExporter = const SharePlusLocalBackupFileExporter(), this.backupFilePicker = const SystemLocalBackupFilePicker(), super.key, }); final AuthUseCases authUseCases; final SyncUseCases syncUseCases; final ShareUseCases shareUseCases; final DataExportUseCase dataExportUseCase; final DataImportUseCase dataImportUseCase; final LocalBackupFileExporter backupFileExporter; final LocalBackupFilePicker backupFilePicker; @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), dataExportUseCase: widget.dataExportUseCase, dataImportUseCase: widget.dataImportUseCase, backupFileExporter: widget.backupFileExporter, backupFilePicker: widget.backupFilePicker, ); } return _SignedInProfile( session: session, syncUseCases: widget.syncUseCases, onLogout: () => _confirmLogout(context), onShares: () => _openReceivedShares(context), dataExportUseCase: widget.dataExportUseCase, dataImportUseCase: widget.dataImportUseCase, backupFileExporter: widget.backupFileExporter, backupFilePicker: widget.backupFilePicker, ); }, ), ); } 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 || !context.mounted) return; _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 || !context.mounted) return; _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, required this.dataExportUseCase, required this.dataImportUseCase, required this.backupFileExporter, required this.backupFilePicker, }); final VoidCallback onRegister; final VoidCallback onLogin; final DataExportUseCase dataExportUseCase; final DataImportUseCase dataImportUseCase; final LocalBackupFileExporter backupFileExporter; final LocalBackupFilePicker backupFilePicker; @override Widget build(BuildContext context) { return ListView( padding: const EdgeInsets.all(16), children: [ CourtBlazerAccentPanel( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( 'Compte GameTime', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), const Text( 'Connecte-toi pour sauvegarder tes données en ligne et ' '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), LocalBackupPanel( dataExportUseCase: dataExportUseCase, dataImportUseCase: dataImportUseCase, isAccountConnected: false, fileExporter: backupFileExporter, filePicker: backupFilePicker, ), 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, required this.dataExportUseCase, required this.dataImportUseCase, required this.backupFileExporter, required this.backupFilePicker, }); final UserAccountSession session; final SyncUseCases syncUseCases; final VoidCallback onLogout; final VoidCallback onShares; final DataExportUseCase dataExportUseCase; final DataImportUseCase dataImportUseCase; final LocalBackupFileExporter backupFileExporter; final LocalBackupFilePicker backupFilePicker; @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), LocalBackupPanel( dataExportUseCase: dataExportUseCase, dataImportUseCase: dataImportUseCase, isAccountConnected: true, fileExporter: backupFileExporter, filePicker: backupFilePicker, ), 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, ); } abstract interface class LocalBackupFileExporter { Future exportFile({ required String fileName, required Uint8List bytes, }); } final class SharePlusLocalBackupFileExporter implements LocalBackupFileExporter { const SharePlusLocalBackupFileExporter(); @override Future exportFile({ required String fileName, required Uint8List bytes, }) async { final result = await SharePlus.instance.share( ShareParams( files: [ XFile.fromData( bytes, name: fileName, mimeType: 'application/octet-stream', ), ], ), ); return result.status; } } final class LocalBackupPickedFile { const LocalBackupPickedFile({required this.fileName, required this.bytes}); final String fileName; final Uint8List bytes; } abstract interface class LocalBackupFilePicker { Future pickFile(); } final class SystemLocalBackupFilePicker implements LocalBackupFilePicker { const SystemLocalBackupFilePicker(); @override Future pickFile() async { final result = await FilePicker.pickFiles(withData: true); if (result == null || result.files.isEmpty) { return null; } final file = result.files.single; final bytes = file.bytes; if (bytes == null) { return null; } return LocalBackupPickedFile(fileName: file.name, bytes: bytes); } } enum _ImportChoice { cancel, merge, replaceAll } final class LocalBackupPanel extends StatefulWidget { const LocalBackupPanel({ required this.dataExportUseCase, required this.dataImportUseCase, required this.isAccountConnected, this.fileExporter = const SharePlusLocalBackupFileExporter(), this.filePicker = const SystemLocalBackupFilePicker(), super.key, }); final DataExportUseCase dataExportUseCase; final DataImportUseCase dataImportUseCase; final bool isAccountConnected; final LocalBackupFileExporter fileExporter; final LocalBackupFilePicker filePicker; @override State createState() => _LocalBackupPanelState(); } final class _LocalBackupPanelState extends State { var _exporting = false; var _importBusy = false; @override Widget build(BuildContext context) { return CourtBlazerAccentPanel( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Sauvegarde locale', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), const Text( 'Exporte un fichier de sauvegarde ou importe une sauvegarde ' 'GameTime depuis cet appareil.', ), const SizedBox(height: 16), FilledButton.icon( onPressed: _exporting ? null : _export, icon: const Icon(Icons.ios_share), label: Text( _exporting ? 'Préparation de l’export...' : 'Exporter mes données', ), ), const SizedBox(height: 8), OutlinedButton.icon( onPressed: _importBusy ? null : _startImport, icon: const Icon(Icons.file_download_outlined), label: Text( _importBusy ? 'Lecture de la sauvegarde...' : 'Importer des données', ), ), ], ), ); } Future _export() async { setState(() => _exporting = true); try { final document = await widget.dataExportUseCase.exportAll(); final status = await widget.fileExporter.exportFile( fileName: document.fileName, bytes: document.bytes, ); if (!mounted) return; final message = switch (status) { ShareResultStatus.success => 'Sauvegarde exportée.', ShareResultStatus.dismissed => null, ShareResultStatus.unavailable => 'Export prêt. Choisis où enregistrer le fichier.', }; if (message != null) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(message))); } } on Object { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Export impossible pour le moment.')), ); } finally { if (mounted) setState(() => _exporting = false); } } Future _startImport() async { final picked = await widget.filePicker.pickFile(); if (picked == null || !mounted) return; setState(() => _importBusy = true); final LocalBackupPreview preview; try { preview = await widget.dataImportUseCase.preview(picked.bytes); } on LocalBackupException catch (error) { if (mounted) setState(() => _importBusy = false); if (!mounted) return; await _showBackupErrorDialog(error.error); return; } final hasLocalData = await widget.dataImportUseCase.hasAnyLocalData(); if (mounted) setState(() => _importBusy = false); if (!mounted) return; final mode = hasLocalData ? await _resolveModeWithChoice(preview) : await _confirmSimpleImport(preview); if (mode == null || !mounted) return; setState(() => _importBusy = true); try { await widget.dataImportUseCase.importFrom(picked.bytes, mode: mode); if (!mounted) return; final message = mode == LocalBackupImportMode.replaceAll ? 'Sauvegarde restaurée.' : 'Données importées.'; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(message))); } on LocalBackupException catch (error) { if (!mounted) return; await _showBackupErrorDialog(error.error); } finally { if (mounted) setState(() => _importBusy = false); } } Future _resolveModeWithChoice( LocalBackupPreview preview, ) async { final choice = await _confirmFullImport(preview); if (choice == _ImportChoice.merge) { return LocalBackupImportMode.merge; } if (choice == _ImportChoice.replaceAll) { if (!mounted) return null; final confirmedReplace = await _confirmReplaceAll(); return confirmedReplace ? LocalBackupImportMode.replaceAll : null; } return null; } Future _confirmSimpleImport( LocalBackupPreview preview, ) { return showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Importer cette sauvegarde ?'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: _previewSummaryLines(preview, showChoiceHint: false), ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Annuler'), ), FilledButton( onPressed: () => Navigator.of(context).pop(LocalBackupImportMode.merge), child: const Text('Importer'), ), ], ), ); } Future<_ImportChoice?> _confirmFullImport(LocalBackupPreview preview) { return showDialog<_ImportChoice>( context: context, builder: (context) => AlertDialog( title: const Text('Importer cette sauvegarde ?'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ ..._previewSummaryLines(preview, showChoiceHint: true), const SizedBox(height: 12), const Text( 'Fusionner ajoute ce qui manque et conserve tes données ' 'actuelles.', ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(_ImportChoice.cancel), child: const Text('Annuler'), ), FilledButton( onPressed: () => Navigator.of(context).pop(_ImportChoice.merge), child: const Text('Fusionner'), ), TextButton( onPressed: () => Navigator.of(context).pop(_ImportChoice.replaceAll), child: const Text('Remplacer tout'), ), ], ), ); } Future _confirmReplaceAll() async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Remplacer toutes les données locales ?'), content: const Text( 'Tes exercices, programmes, séances et historiques actuels seront ' 'remplacés par ceux de cette sauvegarde. Cette action ne peut pas ' 'être annulée.', ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Annuler'), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), child: const Text('Remplacer tout'), ), ], ), ); return confirmed == true; } List _previewSummaryLines( LocalBackupPreview preview, { required bool showChoiceHint, }) { final counts = preview.counts; final lines = [ const Text('Cette sauvegarde contient :'), Text('${counts.exercises} exercices'), Text('${counts.programs} programmes'), Text('${counts.workoutTemplates} séances'), Text('${counts.workoutHistories} historiques'), ]; if (preview.hasEmbeddedMedia) { lines.add(const SizedBox(height: 8)); lines.add(const Text('Médias inclus')); } if (preview.missingMediaCount > 0) { lines.add(const SizedBox(height: 8)); lines.add(const Text('Les médias absents seront ignorés.')); } if (preview.hasNameDuplicates) { lines.add(const SizedBox(height: 8)); lines.add( const Text( 'Des éléments portent déjà le même nom. En fusion, ils seront ' 'conservés séparément si GameTime ne peut pas reconnaître qu’il ' 's’agit du même élément.', ), ); } if (showChoiceHint) { lines.add(const SizedBox(height: 12)); lines.add(const Text('Choisis comment l’ajouter à tes données locales.')); } if (widget.isAccountConnected) { lines.add(const SizedBox(height: 8)); lines.add( const Text( 'Les données importées resteront locales et seront ' 'synchronisées selon tes réglages habituels.', ), ); } return lines; } Future _showBackupErrorDialog(LocalBackupValidationError error) { final (title, message) = _backupErrorText(error); return showDialog( context: context, builder: (context) => AlertDialog( title: Text(title), content: Text(message), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('OK'), ), ], ), ); } } (String, String) _backupErrorText(LocalBackupValidationError error) { return switch (error) { LocalBackupValidationError.invalidFile => ( 'Fichier invalide', 'Ce fichier n’est pas une sauvegarde GameTime valide.', ), LocalBackupValidationError.incompatibleFormat => ( 'Format incompatible', 'Cette sauvegarde ne peut pas être importée par cette version de ' 'GameTime.', ), LocalBackupValidationError.newerVersion => ( 'Mets GameTime à jour', 'Cette sauvegarde vient d’une version plus récente de GameTime.', ), LocalBackupValidationError.corrupted => ( 'Sauvegarde illisible', 'Le fichier semble incomplet ou endommagé.', ), LocalBackupValidationError.activeWorkoutInProgress => ( 'Séance en cours', 'Termine ou sauvegarde ta séance avant d’importer des données.', ), LocalBackupValidationError.importFailedNoMutation => ( 'Import impossible', 'Aucune donnée n’a été modifiée.', ), }; } 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'), ), ], ), ), ), ); } 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.', 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.', RemoteAuthFailure.server => 'Serveur indisponible 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.', RemoteAuthFailure.server => 'Serveur indisponible 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(); }