From 0374a7611e663d9318aea637c1a866bb66ffd952 Mon Sep 17 00:00:00 2001 From: qlionbleusam Date: Mon, 24 Aug 2026 11:30:07 +0200 Subject: [PATCH] fix: ajoute backup_service oublie lors du commit precedent Le commit 543f54dc livrait l'ecran Statistiques qui importe services/backup_service.dart, mais le service et son test n'avaient jamais ete ajoutes a l'index : la branche poussee ne compilait pas. Co-Authored-By: Claude Opus 5 --- lib/services/backup_service.dart | 426 +++++++++++++++++++++++++ test/services/backup_service_test.dart | 372 +++++++++++++++++++++ 2 files changed, 798 insertions(+) create mode 100644 lib/services/backup_service.dart create mode 100644 test/services/backup_service_test.dart diff --git a/lib/services/backup_service.dart b/lib/services/backup_service.dart new file mode 100644 index 00000000..6e0a807d --- /dev/null +++ b/lib/services/backup_service.dart @@ -0,0 +1,426 @@ +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(); + } +} diff --git a/test/services/backup_service_test.dart b/test/services/backup_service_test.dart new file mode 100644 index 00000000..4339cda6 --- /dev/null +++ b/test/services/backup_service_test.dart @@ -0,0 +1,372 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:bully/data/models/maintenance.dart'; +import 'package:bully/data/models/session.dart'; +import 'package:bully/data/models/shot.dart'; +import 'package:bully/data/models/target_analysis.dart'; +import 'package:bully/data/models/target_type.dart'; +import 'package:bully/data/models/weapon.dart'; +import 'package:bully/data/repositories/session_repository.dart'; +import 'package:bully/services/backup_service.dart'; + +/// Dépôt en mémoire : évite d'ouvrir une vraie base SQLite dans les tests. +class _FakeRepository extends SessionRepository { + final List sessions; + final List weapons; + final List maintenance; + final List> savedImages = []; + + _FakeRepository({ + List? sessions, + List? weapons, + List? maintenance, + }) : sessions = sessions ?? [], + weapons = weapons ?? [], + maintenance = maintenance ?? []; + + @override + Future> getAllSessions({int? limit, int? offset}) async => + sessions; + + @override + Future> getWeapons() async => weapons; + + @override + Future> getAllMaintenance() async => maintenance; + + @override + Future saveSession(Session session) async { + sessions.removeWhere((s) => s.id == session.id); + sessions.add(session); + } + + @override + Future saveWeapon(Weapon weapon) async { + weapons.removeWhere((w) => w.id == weapon.id); + weapons.add(weapon); + } + + @override + Future saveMaintenanceEntry(MaintenanceEntry entry) async { + maintenance.removeWhere((e) => e.id == entry.id); + maintenance.add(entry); + } + + @override + Future saveImageBytes(List bytes, String extension) async { + savedImages.add(bytes); + return '/imported/image_${savedImages.length}$extension'; + } +} + +Session _session({String id = 's1', String weapon = 'Glock 17'}) { + return Session( + id: id, + weapon: weapon, + weaponId: 'w1', + maxShotsPerTarget: 5, + createdAt: DateTime(2026, 3, 14, 10, 30), + notes: 'Entraînement', + distance: 25, + analyses: [ + TargetAnalysis( + id: '$id-a1', + sessionId: id, + targetType: TargetType.concentric, + imagePath: '/photos/$id.jpg', + totalScore: 18, + groupingDiameter: 0.12, + groupingCenterX: 0.5, + groupingCenterY: 0.48, + createdAt: DateTime(2026, 3, 14, 10, 35), + targetCenterX: 0.5, + targetCenterY: 0.5, + targetRadius: 0.4, + shots: [ + Shot(id: '$id-t1', x: 0.5, y: 0.5, score: 10, analysisId: '$id-a1'), + Shot(id: '$id-t2', x: 0.55, y: 0.52, score: 8, analysisId: '$id-a1'), + ], + ), + ], + ); +} + +Weapon _weapon() => Weapon( + id: 'w1', + name: 'Glock 17', + type: WeaponType.handgun, + caliber: '9mm', + magazineCount: 3, + magazineCapacity: 17, + createdAt: DateTime(2025, 1, 5), + optic: 'Point rouge', + customName: 'La bleue', + ); + +MaintenanceEntry _maintenance() => MaintenanceEntry( + id: 'm1', + weaponId: 'w1', + type: MaintenanceType.cleaning, + description: 'Nettoyage complet', + date: DateTime(2026, 2, 1), + roundsSinceLastMaintenance: 500, + ); + +/// Sérialise puis relit la sauvegarde comme le ferait un vrai fichier partagé. +Future _roundTrip( + Map json, + BackupService service, +) async { + final dir = await Directory.systemTemp.createTemp('impact_backup_test'); + addTearDown(() => dir.delete(recursive: true)); + final file = File('${dir.path}/backup.json'); + await file.writeAsString(jsonEncode(json)); + return service.readBackup(file); +} + +void main() { + group('export', () { + test('la sauvegarde contient sessions, armurerie et stats', () async { + final source = _FakeRepository( + sessions: [_session()], + weapons: [_weapon()], + maintenance: [_maintenance()], + ); + final json = await BackupService(repository: source).buildBackupJson(); + + expect(json['format'], BackupService.formatId); + expect(json['counts'], { + 'sessions': 1, + 'targets': 1, + 'shots': 2, + 'weapons': 1, + 'maintenance': 1, + }); + + // Les stats sont recalculées et écrites telles quelles dans le fichier. + final stats = json['statistics'] as Map; + expect(stats['total_shots'], 2); + expect(stats['total_score'], 18); + + // L'entretien voyage avec son arme. + final weapon = (json['weapons'] as List).single as Map; + expect((weapon['maintenance'] as List), hasLength(1)); + expect(json['orphan_maintenance'], isEmpty); + }); + + test('sans photos, aucune image n\'est encodée', () async { + final source = _FakeRepository(sessions: [_session()]); + final json = await BackupService(repository: source).buildBackupJson(); + + final session = (json['sessions'] as List).single as Map; + final analysis = (session['analyses'] as List).single as Map; + expect(analysis.containsKey('image_base64'), isFalse); + }); + + test('l\'entretien d\'une arme supprimée n\'est pas perdu', () async { + final source = _FakeRepository(maintenance: [_maintenance()]); + final json = await BackupService(repository: source).buildBackupJson(); + + expect(json['orphan_maintenance'], hasLength(1)); + }); + }); + + group('import', () { + test('aller-retour complet : tout est restauré à l\'identique', () async { + final source = _FakeRepository( + sessions: [_session()], + weapons: [_weapon()], + maintenance: [_maintenance()], + ); + final json = await BackupService(repository: source).buildBackupJson(); + + final target = _FakeRepository(); + final service = BackupService(repository: target); + final preview = await _roundTrip(json, service); + + expect(preview.sessionCount, 1); + expect(preview.shotCount, 2); + expect(preview.weaponCount, 1); + expect(preview.maintenanceCount, 1); + expect(preview.hasImages, isFalse); + + final result = await service.applyBackup(preview); + expect(result.errors, isEmpty); + expect(result.sessions, 1); + expect(result.weapons, 1); + expect(result.maintenance, 1); + + final session = target.sessions.single; + expect(session.id, 's1'); + expect(session.weapon, 'Glock 17'); + expect(session.distance, 25); + expect(session.createdAt, DateTime(2026, 3, 14, 10, 30)); + expect(session.totalShots, 2); + expect(session.totalScore, 18); + expect(session.analyses.single.targetRadius, 0.4); + expect(session.analyses.single.shots.first.score, 10); + + expect(target.weapons.single.customName, 'La bleue'); + expect(target.weapons.single.magazineCapacity, 17); + expect(target.maintenance.single.roundsSinceLastMaintenance, 500); + }); + + test('réimporter deux fois ne crée pas de doublon', () async { + final source = _FakeRepository( + sessions: [_session()], + weapons: [_weapon()], + ); + final json = await BackupService(repository: source).buildBackupJson(); + + final target = _FakeRepository(); + final service = BackupService(repository: target); + await service.applyBackup(await _roundTrip(json, service)); + await service.applyBackup(await _roundTrip(json, service)); + + expect(target.sessions, hasLength(1)); + expect(target.weapons, hasLength(1)); + }); + + test('les photos embarquées sont réécrites sur le disque', () async { + final bytes = utf8.encode('fausse-image'); + final json = { + 'format': BackupService.formatId, + 'version': 1, + 'sessions': [ + { + 'id': 's1', + 'weapon': 'Glock 17', + 'max_shots_per_target': 5, + 'created_at': '2026-03-14T10:30:00.000', + 'distance': 25, + 'analyses': [ + { + 'id': 'a1', + 'session_id': 's1', + 'target_type': 'concentric', + 'image_path': '/ancien/chemin.jpg', + 'total_score': 10, + 'created_at': '2026-03-14T10:35:00.000', + 'image_extension': '.jpg', + 'image_base64': base64Encode(bytes), + 'shots': [ + {'id': 't1', 'x': 0.5, 'y': 0.5, 'score': 10, 'analysis_id': 'a1'}, + ], + }, + ], + }, + ], + }; + + final target = _FakeRepository(); + final service = BackupService(repository: target); + final preview = await _roundTrip(json, service); + expect(preview.hasImages, isTrue); + + final result = await service.applyBackup(preview); + expect(result.images, 1); + expect(target.savedImages.single, bytes); + expect(target.sessions.single.analyses.single.imagePath, + '/imported/image_1.jpg'); + }); + + test('un nombre entier là où un décimal est attendu ne casse rien', () async { + // JSON ne distingue pas 1 de 1.0 : un fichier édité à la main peut + // livrer des entiers là où les modèles attendent des doubles. + final json = { + 'format': BackupService.formatId, + 'version': 1, + 'sessions': [ + { + 'id': 's1', + 'weapon': 'Glock 17', + 'max_shots_per_target': 5, + 'created_at': '2026-03-14T10:30:00.000', + 'analyses': [ + { + 'id': 'a1', + 'session_id': 's1', + 'target_type': 'concentric', + 'image_path': '/photo.jpg', + 'total_score': 10, + 'created_at': '2026-03-14T10:35:00.000', + 'target_radius': 1, + 'grouping_diameter': 0, + 'shots': const [], + }, + ], + }, + ], + }; + + final target = _FakeRepository(); + final service = BackupService(repository: target); + final result = await service.applyBackup(await _roundTrip(json, service)); + + expect(result.errors, isEmpty); + expect(target.sessions.single.analyses.single.targetRadius, 1.0); + expect(target.sessions.single.analyses.single.groupingDiameter, 0.0); + }); + + test('un fichier étranger est refusé avec un message clair', () async { + final service = BackupService(repository: _FakeRepository()); + final dir = await Directory.systemTemp.createTemp('impact_backup_test'); + addTearDown(() => dir.delete(recursive: true)); + + final notJson = File('${dir.path}/photo.jpg'); + await notJson.writeAsString('pas du json du tout'); + expect( + () => service.readBackup(notJson), + throwsA(isA()), + ); + + final otherJson = File('${dir.path}/autre.json'); + await otherJson.writeAsString('{"hello": "world"}'); + expect( + () => service.readBackup(otherJson), + throwsA(isA()), + ); + }); + + test('une sauvegarde plus récente que l\'app est refusée', () async { + final service = BackupService(repository: _FakeRepository()); + final dir = await Directory.systemTemp.createTemp('impact_backup_test'); + addTearDown(() => dir.delete(recursive: true)); + + final file = File('${dir.path}/futur.json'); + await file.writeAsString(jsonEncode({ + 'format': BackupService.formatId, + 'version': BackupService.formatVersion + 1, + })); + + expect( + () => service.readBackup(file), + throwsA(isA()), + ); + }); + + test('une session corrompue est ignorée sans bloquer les autres', () async { + final valid = await BackupService( + repository: _FakeRepository(sessions: [_session()]), + ).buildBackupJson(); + // On injecte une session sans date : elle doit être la seule écartée. + (valid['sessions'] as List).add({ + 'id': 'corrompue', + 'weapon': 'X', + 'max_shots_per_target': 5, + 'analyses': const [], + }); + + final target = _FakeRepository(); + final service = BackupService(repository: target); + final result = await service.applyBackup(await _roundTrip(valid, service)); + + expect(result.sessions, 1); + expect(result.errors, hasLength(1)); + expect(target.sessions.single.id, 's1'); + }); + }); +}