Files
GameTime/lib/presentation/profile_screen.dart
Blomios 917777e18b chore(wip): consolidation intermédiaire multi-tickets (sprints Statistiques, UI, Bug resolution, Serveur-client)
Regroupe l'état de travail en cours réalisé dans un même worktree sur
plusieurs tickets/sprints (#85, #136, #145, #155-160, #162-164),
mélangeant des tickets QA et inProgress. Ne constitue pas une feature
terminée : commit de sauvegarde avant triage/split par ticket en
branches feature/* dédiées. Exclut les dossiers d'environnement de
build locaux et le heap dump parasite (.gitignore mis à jour).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 16:48:54 +02:00

1293 lines
40 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: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 'share_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<ProfileScreen> createState() => _ProfileScreenState();
}
final class _ProfileScreenState extends State<ProfileScreen> {
late Future<UserAccountSession?> _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<UserAccountSession?>(
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),
onSharePack: () => _openSharePack(context),
dataExportUseCase: widget.dataExportUseCase,
dataImportUseCase: widget.dataImportUseCase,
backupFileExporter: widget.backupFileExporter,
backupFilePicker: widget.backupFilePicker,
);
},
),
);
}
Future<void> _openLogin(BuildContext context) async {
final connected = await Navigator.of(context).push<bool>(
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<void> _openRegister(BuildContext context) async {
final connected = await Navigator.of(context).push<bool>(
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<void> _confirmLogout(BuildContext context) async {
final confirmed = await showDialog<bool>(
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 _openSharePack(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => SharePackSelectionScreen(
shareUseCases: widget.shareUseCases,
authUseCases: widget.authUseCases,
syncUseCases: widget.syncUseCases,
),
),
);
}
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.onSharePack,
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 VoidCallback onSharePack;
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: Column(
children: [
Material(
type: MaterialType.transparency,
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.inventory_2_outlined),
title: const Text('Partager un pack'),
subtitle: const Text('Envoyer plusieurs séances ensemble'),
trailing: const Icon(Icons.chevron_right),
onTap: onSharePack,
),
),
const Divider(height: 1),
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, séances et packs 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<SyncStatusPanel> createState() => _SyncStatusPanelState();
}
final class _SyncStatusPanelState extends State<SyncStatusPanel> {
late Future<SyncMetadataSnapshot> _status;
var _syncing = false;
@override
void initState() {
super.initState();
_status = widget.syncUseCases.currentStatus();
}
@override
Widget build(BuildContext context) {
return CourtBlazerAccentPanel(
child: FutureBuilder<SyncMetadataSnapshot>(
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<void> _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<ShareResultStatus> exportFile({
required String fileName,
required Uint8List bytes,
});
}
final class SharePlusLocalBackupFileExporter
implements LocalBackupFileExporter {
const SharePlusLocalBackupFileExporter();
@override
Future<ShareResultStatus> 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<LocalBackupPickedFile?> pickFile();
}
final class SystemLocalBackupFilePicker implements LocalBackupFilePicker {
const SystemLocalBackupFilePicker();
@override
Future<LocalBackupPickedFile?> 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<LocalBackupPanel> createState() => _LocalBackupPanelState();
}
final class _LocalBackupPanelState extends State<LocalBackupPanel> {
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 lexport...'
: '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<void> _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<void> _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<LocalBackupImportMode?> _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<LocalBackupImportMode?> _confirmSimpleImport(
LocalBackupPreview preview,
) {
return showDialog<LocalBackupImportMode>(
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<bool> _confirmReplaceAll() async {
final confirmed = await showDialog<bool>(
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<Widget> _previewSummaryLines(
LocalBackupPreview preview, {
required bool showChoiceHint,
}) {
final counts = preview.counts;
final lines = <Widget>[
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 quil '
'sagit du même élément.',
),
);
}
if (showChoiceHint) {
lines.add(const SizedBox(height: 12));
lines.add(const Text('Choisis comment lajouter à 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<void> _showBackupErrorDialog(LocalBackupValidationError error) {
final (title, message) = _backupErrorText(error);
return showDialog<void>(
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 nest 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 dune 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 dimporter des données.',
),
LocalBackupValidationError.importFailedNoMutation => (
'Import impossible',
'Aucune donnée na é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<LoginScreen> createState() => _LoginScreenState();
}
final class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
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<void> _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<RegisterScreen> createState() => _RegisterScreenState();
}
final class _RegisterScreenState extends State<RegisterScreen> {
final _formKey = GlobalKey<FormState>();
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<void> _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 dau moins 8 caractères.';
}
return null;
}
String? _newPasswordValidator(String? value) {
if ((value ?? '').length < 8) {
return 'Saisis un mot de passe dau 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 'à linstant';
}
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();
}