Files
GameTime/lib/presentation/profile_screen.dart
Blomios d16a017743 feat(online): partage sortant et boîte de réception (ticket #69)
Ajoute l'écran de partage sortant (presentation/share_screen.dart) et
la boîte de réception des partages (presentation/share_inbox_screen.dart),
avec les points d'entrée sur profile_screen.dart, home_screen.dart,
program_screen.dart et workout_template_screen.dart. flutter analyze
propre (mêmes infos préexistantes), 132/132 tests verts, build APK
debug validé. Dernier ticket de développement du chantier "Ajouter
les features serveur au client" (#63) — il ne reste que la QA finale
(#70).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 07:23:14 +02:00

793 lines
24 KiB
Dart
Raw 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 '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<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),
);
}
return _SignedInProfile(
session: session,
syncUseCases: widget.syncUseCases,
onLogout: () => _confirmLogout(context),
onShares: () => _openReceivedShares(context),
);
},
),
);
}
Future<void> _openLogin(BuildContext context) async {
final connected = await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (context) => LoginScreen(
authUseCases: widget.authUseCases,
syncUseCases: widget.syncUseCases,
),
),
);
if (connected == true && mounted) {
_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,
),
),
);
if (connected == true && mounted) {
_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 _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<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,
);
}
final class LoginScreen extends StatefulWidget {
const LoginScreen({
required this.authUseCases,
required this.syncUseCases,
super.key,
});
final AuthUseCases authUseCases;
final SyncUseCases syncUseCases;
@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,
),
),
);
},
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<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_error = null;
_submitting = true;
});
try {
await widget.authUseCases.login(
email: _emailController.text,
password: _passwordController.text,
);
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,
super.key,
});
final AuthUseCases authUseCases;
final SyncUseCases syncUseCases;
@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,
),
),
);
},
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<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,
);
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.',
_ => '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 'à 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();
}