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>
This commit is contained in:
2026-07-19 21:41:18 +02:00
parent b643988986
commit c0dbce275b
16 changed files with 3913 additions and 3 deletions

View File

@ -0,0 +1,631 @@
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();
}