- F1: "Sauvegarde locale" panel in Profil (both signed-out/signed-in states, positioned per UX cadrage), "Exporter mes données" calling DataExportUseCase.exportAll() then handing the file to the system share sheet, with the exact UX success/error snackbars. - F2: "Importer des données" picks a file, previews it, then confirms with the exact UX dialogs: simplified single-choice when there is no local data yet, Fusionner/Remplacer tout (with a destructive second confirmation) otherwise. All typed LocalBackupValidationError cases are mapped to their dedicated dialog copy, including the active workout session block.
1264 lines
39 KiB
Dart
1264 lines
39 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) {
|
||
_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) {
|
||
_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 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),
|
||
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'),
|
||
),
|
||
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,
|
||
);
|
||
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. 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,
|
||
);
|
||
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.',
|
||
_ => '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 'à 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();
|
||
}
|