import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import '../data/models/maintenance.dart'; import '../data/models/session.dart'; import '../data/models/shot.dart'; import '../data/models/target_analysis.dart'; import '../data/models/weapon.dart'; import '../data/repositories/session_repository.dart'; import 'statistics_service.dart'; /// Résumé d'un fichier de sauvegarde, affiché avant de confirmer un import. class BackupPreview { final int sessionCount; final int targetCount; final int shotCount; final int weaponCount; final int maintenanceCount; final bool hasImages; final DateTime? exportedAt; final Map raw; const BackupPreview({ required this.sessionCount, required this.targetCount, required this.shotCount, required this.weaponCount, required this.maintenanceCount, required this.hasImages, required this.exportedAt, required this.raw, }); } /// Résultat d'un import : ce qui a réellement été écrit en base. class ImportResult { final int sessions; final int weapons; final int maintenance; final int images; final List errors; const ImportResult({ required this.sessions, required this.weapons, required this.maintenance, required this.images, required this.errors, }); } /// Erreur « propre » d'import : message directement affichable à l'utilisateur. class BackupFormatException implements Exception { final String message; BackupFormatException(this.message); @override String toString() => message; } /// Import / export de l'intégralité des données de l'app dans un fichier JSON : /// sessions (avec cibles, impacts et calibration), armurerie (armes + /// maintenance) et un instantané des statistiques calculées. /// /// Les statistiques ne sont pas réimportées : elles sont recalculées à partir /// des sessions. Elles figurent dans le fichier pour pouvoir être lues telles /// quelles (analyse externe, IA, tableur). class BackupService { static const String formatId = 'impact.backup'; static const int formatVersion = 1; final SessionRepository _repository; final StatisticsService _statisticsService; BackupService({ required SessionRepository repository, StatisticsService? statisticsService, }) : _repository = repository, _statisticsService = statisticsService ?? StatisticsService(); // ---------------------------------------------------------------- EXPORT /// Construit le fichier de sauvegarde et renvoie le fichier écrit dans le /// dossier temporaire, prêt à être passé à la feuille de partage du système. Future exportToFile({bool includeImages = false}) async { final json = await buildBackupJson(includeImages: includeImages); final tempDir = await getTemporaryDirectory(); final file = File(p.join(tempDir.path, _buildFileName())); await file.writeAsString( const JsonEncoder.withIndent(' ').convert(json), flush: true, ); return file; } String _buildFileName() { final now = DateTime.now(); String two(int v) => v.toString().padLeft(2, '0'); return 'impact_sauvegarde_${now.year}-${two(now.month)}-${two(now.day)}' '_${two(now.hour)}${two(now.minute)}.json'; } @visibleForTesting Future> buildBackupJson({ bool includeImages = false, }) async { final sessions = await _repository.getAllSessions(); final weapons = await _repository.getWeapons(); final maintenance = await _repository.getAllMaintenance(); // Maintenance regroupée par arme : une arme reste autonome dans le fichier. final maintenanceByWeapon = >{}; for (final entry in maintenance) { (maintenanceByWeapon[entry.weaponId] ??= []).add(entry); } var totalTargets = 0; var totalShots = 0; final sessionsJson = >[]; for (final session in sessions) { final analysesJson = >[]; for (final analysis in session.analyses) { totalTargets++; totalShots += analysis.shots.length; analysesJson.add(await _analysisToJson(analysis, includeImages)); } sessionsJson.add({ ...session.toMap(), 'analyses': analysesJson, }); } return { 'format': formatId, 'version': formatVersion, 'app': 'bully', 'exported_at': DateTime.now().toIso8601String(), 'includes_images': includeImages, 'counts': { 'sessions': sessions.length, 'targets': totalTargets, 'shots': totalShots, 'weapons': weapons.length, 'maintenance': maintenance.length, }, 'statistics': _statisticsToJson(sessions), 'weapons': weapons .map((w) => { ...w.toMap(), 'maintenance': (maintenanceByWeapon[w.id] ?? []) .map((e) => e.toMap()) .toList(), }) .toList(), 'sessions': sessionsJson, // Maintenance orpheline (arme supprimée) : conservée pour ne rien perdre. 'orphan_maintenance': maintenance .where((e) => !weapons.any((w) => w.id == e.weaponId)) .map((e) => e.toMap()) .toList(), }; } Future> _analysisToJson( TargetAnalysis analysis, bool includeImages, ) async { final json = { ...analysis.toMap(), 'shots': analysis.shots.map((s) => s.toMap()).toList(), }; if (includeImages) { try { final file = File(analysis.imagePath); if (await file.exists()) { json['image_extension'] = p.extension(analysis.imagePath); json['image_base64'] = base64Encode(await file.readAsBytes()); } } catch (e) { // Une photo illisible ne doit pas faire échouer toute la sauvegarde. debugPrint('Sauvegarde : image ignorée (${analysis.id}) : $e'); } } return json; } Map _statisticsToJson(List sessions) { final stats = _statisticsService.calculateStatistics( sessions, period: StatsPeriod.all, ); return { 'total_shots': stats.totalShots, 'total_score': stats.totalScore, 'average_score': stats.avgScore, 'max_score': stats.maxScore, 'min_score': stats.minScore, 'precision': { 'avg_distance_from_center': stats.precision.avgDistanceFromCenter, 'grouping_diameter': stats.precision.groupingDiameter, 'precision_score': stats.precision.precisionScore, 'consistency_score': stats.precision.consistencyScore, }, 'std_dev': { 'x': stats.stdDev.stdDevX, 'y': stats.stdDev.stdDevY, 'radial': stats.stdDev.stdDevRadial, 'score': stats.stdDev.stdDevScore, 'mean_x': stats.stdDev.meanX, 'mean_y': stats.stdDev.meanY, 'mean_score': stats.stdDev.meanScore, }, 'regional': { 'quadrants': stats.regional.quadrantDistribution, 'sectors': stats.regional.sectorDistribution, 'dominant_direction': stats.regional.dominantDirection, 'bias_x': stats.regional.biasX, 'bias_y': stats.regional.biasY, }, 'heat_map': { 'grid_size': stats.heatMap.gridSize, 'max_shots_in_zone': stats.heatMap.maxShotsInZone, 'zones': [ for (final row in stats.heatMap.zones) for (final zone in row) { 'row': zone.row, 'col': zone.col, 'shot_count': zone.shotCount, 'intensity': zone.intensity, 'avg_score': zone.avgScore, }, ], }, }; } // ---------------------------------------------------------------- IMPORT /// Lit et valide un fichier de sauvegarde sans rien écrire en base. Future readBackup(File file) async { late final dynamic decoded; try { decoded = jsonDecode(await file.readAsString()); } catch (e) { throw BackupFormatException( 'Fichier illisible : ce n\'est pas un JSON valide.', ); } if (decoded is! Map) { throw BackupFormatException('Fichier illisible : format inattendu.'); } if (decoded['format'] != formatId) { throw BackupFormatException( 'Ce fichier n\'est pas une sauvegarde IMPACT.', ); } final version = (decoded['version'] as num?)?.toInt() ?? 0; if (version > formatVersion) { throw BackupFormatException( 'Sauvegarde créée par une version plus récente de l\'application ' '(format $version). Mettez l\'app à jour pour l\'importer.', ); } final sessions = _asList(decoded['sessions']); final weapons = _asList(decoded['weapons']); var targets = 0; var shots = 0; var hasImages = false; for (final session in sessions) { for (final analysis in _asList(session['analyses'])) { targets++; shots += _asList(analysis['shots']).length; if (analysis['image_base64'] != null) hasImages = true; } } var maintenance = _asList(decoded['orphan_maintenance']).length; for (final weapon in weapons) { maintenance += _asList(weapon['maintenance']).length; } return BackupPreview( sessionCount: sessions.length, targetCount: targets, shotCount: shots, weaponCount: weapons.length, maintenanceCount: maintenance, hasImages: hasImages, exportedAt: DateTime.tryParse(decoded['exported_at'] as String? ?? ''), raw: decoded, ); } /// Écrit en base le contenu d'une sauvegarde déjà lue par [readBackup]. /// /// Fusion : les entrées existantes portant le même identifiant sont /// remplacées, les autres sont conservées. Réimporter deux fois la même /// sauvegarde ne crée donc pas de doublons. Future applyBackup(BackupPreview preview) async { final errors = []; var importedSessions = 0; var importedWeapons = 0; var importedMaintenance = 0; var importedImages = 0; // 1. Armurerie d'abord : les sessions y font référence par weapon_id. for (final weaponJson in _asList(preview.raw['weapons'])) { try { await _repository.saveWeapon(Weapon.fromMap(weaponJson)); importedWeapons++; } catch (e) { errors.add('Arme ignorée : $e'); continue; } for (final entryJson in _asList(weaponJson['maintenance'])) { try { await _repository.saveMaintenanceEntry( MaintenanceEntry.fromMap(entryJson), ); importedMaintenance++; } catch (e) { errors.add('Entretien ignoré : $e'); } } } // 2. Sessions, cibles et impacts. for (final sessionJson in _asList(preview.raw['sessions'])) { try { final analyses = []; for (final analysisJson in _asList(sessionJson['analyses'])) { final shots = _asList(analysisJson['shots']) .map((s) => Shot.fromMap(s)) .toList(); var map = _normalizeAnalysis(analysisJson); final imagePath = await _restoreImage(analysisJson); if (imagePath != null) { map = {...map, 'image_path': imagePath}; importedImages++; } analyses.add(TargetAnalysis.fromMap(map, shots)); } await _repository.saveSession(Session.fromMap(sessionJson, analyses)); importedSessions++; } catch (e) { errors.add('Session ignorée : $e'); } } // 3. Maintenance dont l'arme a été supprimée avant l'export. for (final entryJson in _asList(preview.raw['orphan_maintenance'])) { try { await _repository.saveMaintenanceEntry( MaintenanceEntry.fromMap(entryJson), ); importedMaintenance++; } catch (e) { errors.add('Entretien ignoré : $e'); } } return ImportResult( sessions: importedSessions, weapons: importedWeapons, maintenance: importedMaintenance, images: importedImages, errors: errors, ); } /// Recrée la photo de cible si la sauvegarde l'embarque, et renvoie son /// nouveau chemin local. `null` si la sauvegarde est sans photos : le chemin /// d'origine est alors conservé (l'app affiche un placeholder s'il est mort). Future _restoreImage(Map analysisJson) async { final encoded = analysisJson['image_base64'] as String?; if (encoded == null || encoded.isEmpty) return null; try { final bytes = base64Decode(encoded); final extension = analysisJson['image_extension'] as String? ?? '.jpg'; return await _repository.saveImageBytes(bytes, extension); } catch (e) { debugPrint('Import : image ignorée : $e'); return null; } } /// JSON ne distingue pas 1 de 1.0 : une valeur ronde relue devient un `int` /// et casse les `as double?` des modèles. On reforce donc les doubles. Map _normalizeAnalysis(Map json) { const doubleKeys = [ 'grouping_diameter', 'grouping_center_x', 'grouping_center_y', 'target_center_x', 'target_center_y', 'target_radius', ]; final map = Map.from(json); for (final key in doubleKeys) { map[key] = (map[key] as num?)?.toDouble(); } return map; } List> _asList(dynamic value) { if (value is! List) return const []; return value.whereType>().toList(); } }