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>
632 lines
19 KiB
Dart
632 lines
19 KiB
Dart
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 d’au moins 8 caractères.';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
String? _newPasswordValidator(String? value) {
|
||
if ((value ?? '').length < 8) {
|
||
return 'Saisis un mot de passe d’au moins 8 caractères.';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
String _loginErrorMessage(RemoteAuthFailure failure) {
|
||
return switch (failure) {
|
||
RemoteAuthFailure.invalidCredentials => 'Email ou mot de passe incorrect.',
|
||
RemoteAuthFailure.network =>
|
||
'Connexion impossible pour le moment. Réessaie plus tard.',
|
||
_ => 'Connexion impossible pour le moment. Réessaie plus tard.',
|
||
};
|
||
}
|
||
|
||
String _registerErrorMessage(RemoteAuthFailure failure) {
|
||
return switch (failure) {
|
||
RemoteAuthFailure.emailAlreadyUsed =>
|
||
'Un compte existe déjà avec cet email.',
|
||
RemoteAuthFailure.network =>
|
||
'Création impossible pour le moment. Réessaie plus tard.',
|
||
_ => 'Création impossible pour le moment. Réessaie plus tard.',
|
||
};
|
||
}
|
||
|
||
String _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();
|
||
}
|