Consolide le lot applicatif watch companion validé : - télémétrie fréquence cardiaque live remontée montre -> téléphone (collecteur watch, adapter Wear Data Layer, persistance Drift, propagation aux écrans historique/programme/profil/exécution) - notifications de séance en arrière-plan côté téléphone (service foreground de statut + passerelle applicative) - finitions montre : chrono d'étape, score d'étape, retrait du bouton "lancer une séance", thème, icônes et polices watch_app Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1261 lines
38 KiB
Dart
1261 lines
38 KiB
Dart
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 '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),
|
||
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 _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.dataExportUseCase,
|
||
required this.dataImportUseCase,
|
||
required this.backupFileExporter,
|
||
required this.backupFilePicker,
|
||
});
|
||
|
||
final UserAccountSession session;
|
||
final SyncUseCases syncUseCases;
|
||
final VoidCallback onLogout;
|
||
final VoidCallback onShares;
|
||
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: 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,
|
||
);
|
||
}
|
||
|
||
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 l’export...'
|
||
: '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 qu’il '
|
||
's’agit du même élément.',
|
||
),
|
||
);
|
||
}
|
||
if (showChoiceHint) {
|
||
lines.add(const SizedBox(height: 12));
|
||
lines.add(const Text('Choisis comment l’ajouter à 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 n’est 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 d’une 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 d’importer des données.',
|
||
),
|
||
LocalBackupValidationError.importFailedNoMutation => (
|
||
'Import impossible',
|
||
'Aucune donnée n’a é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 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.',
|
||
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 'à l’instant';
|
||
}
|
||
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();
|
||
}
|