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 <noreply@anthropic.com>
427 lines
14 KiB
Dart
427 lines
14 KiB
Dart
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<String, dynamic> 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<String> 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<File> 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<Map<String, dynamic>> 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 = <String, List<MaintenanceEntry>>{};
|
|
for (final entry in maintenance) {
|
|
(maintenanceByWeapon[entry.weaponId] ??= []).add(entry);
|
|
}
|
|
|
|
var totalTargets = 0;
|
|
var totalShots = 0;
|
|
final sessionsJson = <Map<String, dynamic>>[];
|
|
for (final session in sessions) {
|
|
final analysesJson = <Map<String, dynamic>>[];
|
|
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<Map<String, dynamic>> _analysisToJson(
|
|
TargetAnalysis analysis,
|
|
bool includeImages,
|
|
) async {
|
|
final json = <String, dynamic>{
|
|
...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<String, dynamic> _statisticsToJson(List<Session> 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<BackupPreview> 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<String, dynamic>) {
|
|
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<ImportResult> applyBackup(BackupPreview preview) async {
|
|
final errors = <String>[];
|
|
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 = <TargetAnalysis>[];
|
|
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<String?> _restoreImage(Map<String, dynamic> 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<String, dynamic> _normalizeAnalysis(Map<String, dynamic> json) {
|
|
const doubleKeys = [
|
|
'grouping_diameter',
|
|
'grouping_center_x',
|
|
'grouping_center_y',
|
|
'target_center_x',
|
|
'target_center_y',
|
|
'target_radius',
|
|
];
|
|
|
|
final map = Map<String, dynamic>.from(json);
|
|
for (final key in doubleKeys) {
|
|
map[key] = (map[key] as num?)?.toDouble();
|
|
}
|
|
return map;
|
|
}
|
|
|
|
List<Map<String, dynamic>> _asList(dynamic value) {
|
|
if (value is! List) return const [];
|
|
return value.whereType<Map<String, dynamic>>().toList();
|
|
}
|
|
}
|