Files
GameTime/lib/presentation/profile_screen.dart
Blomios c0dbce275b feat(online): sync client incrémentale LWW et profil/écrans auth (tickets #65, #67)
Ajoute la synchronisation client incrémentale LWW vers l'API serveur
(application/use_cases.dart: SyncUseCases, ports.dart, migration
Drift schemaVersion 10→11 pour les métadonnées de sync et mappings de
ressources, infrastructure/remote/sync_api.dart) et l'écran de profil
avec entrée sur l'accueil (presentation/profile_screen.dart,
home_screen.dart, presentation.dart). Développés dans le même
worktree partagé par DevBackend et DevFrontend ; commit combiné car
test/presentation/home_screen_test.dart mélange authentiquement les
deux tickets (le test de l'entrée Profil et la mise à jour du fake de
bootstrap requise par le nouveau getter syncUseCases sur
AppDependencies). Corrige au passage une couleur `crimson` inexistante
dans le thème (remplacée par colorScheme.error) et une signature de
paramètres positionnels/nommés incohérente sur un fake de test.
flutter pub get OK, build_runner OK, dart format appliqué, analyze
propre (mêmes infos préexistantes), 119/119 tests verts, build APK
debug validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:41:18 +02:00

632 lines
19 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 'theme.dart';
final class ProfileScreen extends StatefulWidget {
const ProfileScreen({required this.authUseCases, super.key});
final AuthUseCases authUseCases;
@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,
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),
),
);
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),
),
);
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) => const ReceivedSharesScreen()),
);
}
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.onLogout,
required this.onShares,
});
final UserAccountSession session;
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),
CourtBlazerAccentPanel(
child: Material(
type: MaterialType.transparency,
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.cloud_outlined),
title: const Text('Synchronisation'),
subtitle: const Text('Synchronisation : à venir'),
),
),
),
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 LoginScreen extends StatefulWidget {
const LoginScreen({required this.authUseCases, super.key});
final AuthUseCases authUseCases;
@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,
),
),
);
},
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, super.key});
final AuthUseCases authUseCases;
@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),
),
);
},
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 ReceivedSharesScreen extends StatelessWidget {
const ReceivedSharesScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Partages reçus')),
body: const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
"Les programmes et séances qu'on t'envoie apparaîtront ici.",
textAlign: TextAlign.center,
),
),
),
);
}
}
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 _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();
}